Add stream queue control system
- New stream-control.lisp for queue management backend - M3U playlist generation with Docker path mapping - API endpoints for add/remove/clear/reorder queue - Fix library scan deduplication - Add stream control documentation
This commit is contained in:
parent
70263fbfbc
commit
b64d101f8a
|
|
@ -38,5 +38,6 @@
|
|||
(:file "stream-media")
|
||||
(:file "user-management")
|
||||
(:file "playlist-management")
|
||||
(:file "stream-control")
|
||||
(:file "auth-routes")
|
||||
(:file "asteroid")))
|
||||
|
|
|
|||
110
asteroid.lisp
110
asteroid.lisp
|
|
@ -76,8 +76,6 @@
|
|||
(user-id-raw (gethash "_id" user))
|
||||
(user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw))
|
||||
(playlists (get-user-playlists user-id)))
|
||||
(format t "Fetching playlists for user-id: ~a~%" user-id)
|
||||
(format t "Found ~a playlists~%" (length playlists))
|
||||
(api-output `(("status" . "success")
|
||||
("playlists" . ,(mapcar (lambda (playlist)
|
||||
(let ((name-val (gethash "name" playlist))
|
||||
|
|
@ -85,7 +83,6 @@
|
|||
(track-ids-val (gethash "track-ids" playlist))
|
||||
(created-val (gethash "created-date" playlist))
|
||||
(id-val (gethash "_id" playlist)))
|
||||
(format t "Playlist ID: ~a (type: ~a)~%" id-val (type-of id-val))
|
||||
;; Calculate track count from comma-separated string
|
||||
;; Handle nil, empty string, or list containing empty string
|
||||
(let* ((track-ids-str (if (listp track-ids-val)
|
||||
|
|
@ -114,9 +111,7 @@
|
|||
(let* ((user (get-current-user))
|
||||
(user-id-raw (gethash "_id" user))
|
||||
(user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw)))
|
||||
(format t "Creating playlist for user-id: ~a, name: ~a~%" user-id name)
|
||||
(create-playlist user-id name description)
|
||||
(format t "Playlist created successfully~%")
|
||||
(if (string= "true" (post/get "browser"))
|
||||
(redirect "/asteroid/")
|
||||
(api-output `(("status" . "success")
|
||||
|
|
@ -148,8 +143,6 @@
|
|||
(handler-case
|
||||
(let* ((id (parse-integer playlist-id :junk-allowed t))
|
||||
(playlist (get-playlist-by-id id)))
|
||||
(format t "Looking for playlist ID: ~a~%" id)
|
||||
(format t "Found playlist: ~a~%" (if playlist "YES" "NO"))
|
||||
(if playlist
|
||||
(let* ((track-ids-raw (gethash "tracks" playlist))
|
||||
(track-ids (if (listp track-ids-raw) track-ids-raw (list track-ids-raw)))
|
||||
|
|
@ -197,24 +190,101 @@
|
|||
("message" . ,(format nil "Error retrieving tracks: ~a" e)))
|
||||
:status 500))))
|
||||
|
||||
;; API endpoint to get track by ID (for streaming)
|
||||
(define-page api-get-track-by-id #@"/api/tracks/(.*)" (:uri-groups (track-id))
|
||||
"Retrieve track from database by ID"
|
||||
(let* ((id (if (stringp track-id) (parse-integer track-id) track-id))
|
||||
(tracks (db:select "tracks" (db:query (:= '_id id)))))
|
||||
(when tracks (first tracks))))
|
||||
;; Stream Control API Endpoints
|
||||
(define-api asteroid/stream/queue () ()
|
||||
"Get the current stream queue"
|
||||
(require-role :admin)
|
||||
(handler-case
|
||||
(let ((queue (get-stream-queue)))
|
||||
(api-output `(("status" . "success")
|
||||
("queue" . ,(mapcar (lambda (track-id)
|
||||
(let ((track (get-track-by-id track-id)))
|
||||
(when track
|
||||
`(("id" . ,track-id)
|
||||
("title" . ,(gethash "title" track))
|
||||
("artist" . ,(gethash "artist" track))
|
||||
("album" . ,(gethash "album" track))))))
|
||||
queue)))))
|
||||
(error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(format nil "Error getting queue: ~a" e)))
|
||||
:status 500))))
|
||||
|
||||
(define-api asteroid/stream/queue/add (track-id &optional (position "end")) ()
|
||||
"Add a track to the stream queue"
|
||||
(require-role :admin)
|
||||
(handler-case
|
||||
(let ((tr-id (parse-integer track-id :junk-allowed t))
|
||||
(pos (if (string= position "next") :next :end)))
|
||||
(add-to-stream-queue tr-id pos)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Track added to stream queue"))))
|
||||
(error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(format nil "Error adding to queue: ~a" e)))
|
||||
:status 500))))
|
||||
|
||||
(define-api asteroid/stream/queue/remove (track-id) ()
|
||||
"Remove a track from the stream queue"
|
||||
(require-role :admin)
|
||||
(handler-case
|
||||
(let ((tr-id (parse-integer track-id :junk-allowed t)))
|
||||
(remove-from-stream-queue tr-id)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Track removed from stream queue"))))
|
||||
(error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(format nil "Error removing from queue: ~a" e)))
|
||||
:status 500))))
|
||||
|
||||
(define-api asteroid/stream/queue/clear () ()
|
||||
"Clear the entire stream queue"
|
||||
(require-role :admin)
|
||||
(handler-case
|
||||
(progn
|
||||
(clear-stream-queue)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Stream queue cleared"))))
|
||||
(error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(format nil "Error clearing queue: ~a" e)))
|
||||
:status 500))))
|
||||
|
||||
(define-api asteroid/stream/queue/add-playlist (playlist-id) ()
|
||||
"Add all tracks from a playlist to the stream queue"
|
||||
(require-role :admin)
|
||||
(handler-case
|
||||
(let ((pl-id (parse-integer playlist-id :junk-allowed t)))
|
||||
(add-playlist-to-stream-queue pl-id)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Playlist added to stream queue"))))
|
||||
(error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(format nil "Error adding playlist to queue: ~a" e)))
|
||||
:status 500))))
|
||||
|
||||
(define-api asteroid/stream/queue/reorder (track-ids) ()
|
||||
"Reorder the stream queue (expects comma-separated track IDs)"
|
||||
(require-role :admin)
|
||||
(handler-case
|
||||
(let ((ids (mapcar (lambda (id-str) (parse-integer id-str :junk-allowed t))
|
||||
(cl-ppcre:split "," track-ids))))
|
||||
(reorder-stream-queue ids)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Stream queue reordered"))))
|
||||
(error (e)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . ,(format nil "Error reordering queue: ~a" e)))
|
||||
:status 500))))
|
||||
|
||||
(defun get-track-by-id (track-id)
|
||||
"Get a track by its ID - handles type mismatches"
|
||||
(format t "get-track-by-id called with: ~a (type: ~a)~%" track-id (type-of track-id))
|
||||
;; Try direct query first
|
||||
(let ((tracks (db:select "tracks" (db:query (:= "_id" track-id)))))
|
||||
(if (> (length tracks) 0)
|
||||
(progn
|
||||
(format t "Found via direct query~%")
|
||||
(first tracks))
|
||||
(first tracks)
|
||||
;; If not found, search manually (ID might be stored as list)
|
||||
(let ((all-tracks (db:select "tracks" (db:query :all))))
|
||||
(format t "Searching through ~a tracks manually~%" (length all-tracks))
|
||||
(find-if (lambda (track)
|
||||
(let ((stored-id (gethash "_id" track)))
|
||||
(or (equal stored-id track-id)
|
||||
|
|
@ -500,7 +570,9 @@
|
|||
:liquidsoap-status (check-liquidsoap-status)
|
||||
:icecast-status (check-icecast-status)
|
||||
:track-count (format nil "~d" track-count)
|
||||
:library-path "/home/glenn/Projects/Code/asteroid/music/library/")))
|
||||
:library-path "/home/glenn/Projects/Code/asteroid/music/library/"
|
||||
:stream-base-url *stream-base-url*
|
||||
:default-stream-url (concatenate 'string *stream-base-url* "/asteroid.aac"))))
|
||||
|
||||
;; User Management page (requires authentication)
|
||||
(define-page users-management #@"/admin/user" ()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,209 @@
|
|||
#+TITLE: Stream Queue Control System
|
||||
#+AUTHOR: Asteroid Radio Team
|
||||
#+DATE: 2025-10-14
|
||||
|
||||
* Overview
|
||||
|
||||
The stream queue control system allows administrators to manage what plays on the main Asteroid Radio broadcast stream. Instead of random playback from the music library, you can now curate the exact order of tracks.
|
||||
|
||||
* How It Works
|
||||
|
||||
1. *Stream Queue* - An ordered list of track IDs maintained in memory
|
||||
2. *M3U Generation* - The queue is converted to a =stream-queue.m3u= file
|
||||
3. *Liquidsoap Integration* - Liquidsoap reads the M3U file and reloads it every 60 seconds
|
||||
4. *Fallback* - If the queue is empty, Liquidsoap falls back to random directory playback
|
||||
|
||||
* API Endpoints (Admin Only)
|
||||
|
||||
All endpoints require admin authentication.
|
||||
|
||||
** Get Current Queue
|
||||
#+BEGIN_SRC
|
||||
GET /api/asteroid/stream/queue
|
||||
#+END_SRC
|
||||
|
||||
Returns the current stream queue with track details.
|
||||
|
||||
** Add Track to Queue
|
||||
#+BEGIN_SRC
|
||||
POST /api/asteroid/stream/queue/add
|
||||
Parameters:
|
||||
- track_id: ID of track to add
|
||||
- position: "end" (default) or "next"
|
||||
#+END_SRC
|
||||
|
||||
Adds a track to the end of the queue or as the next track to play.
|
||||
|
||||
** Remove Track from Queue
|
||||
#+BEGIN_SRC
|
||||
POST /api/asteroid/stream/queue/remove
|
||||
Parameters:
|
||||
- track_id: ID of track to remove
|
||||
#+END_SRC
|
||||
|
||||
Removes a specific track from the queue.
|
||||
|
||||
** Clear Queue
|
||||
#+BEGIN_SRC
|
||||
POST /api/asteroid/stream/queue/clear
|
||||
#+END_SRC
|
||||
|
||||
Clears the entire queue (will fall back to random playback).
|
||||
|
||||
** Add Playlist to Queue
|
||||
#+BEGIN_SRC
|
||||
POST /api/asteroid/stream/queue/add-playlist
|
||||
Parameters:
|
||||
- playlist_id: ID of playlist to add
|
||||
#+END_SRC
|
||||
|
||||
Adds all tracks from a user playlist to the stream queue.
|
||||
|
||||
** Reorder Queue
|
||||
#+BEGIN_SRC
|
||||
POST /api/asteroid/stream/queue/reorder
|
||||
Parameters:
|
||||
- track_ids: Comma-separated list of track IDs in desired order
|
||||
#+END_SRC
|
||||
|
||||
Completely reorders the queue with a new track order.
|
||||
|
||||
* Usage Examples
|
||||
|
||||
** Building a Stream Queue
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
# Add a specific track to the end
|
||||
curl -X POST http://localhost:8080/api/asteroid/stream/queue/add \
|
||||
-d "track-id=42" \
|
||||
-H "Cookie: radiance-session=..."
|
||||
|
||||
# Add a track to play next
|
||||
curl -X POST http://localhost:8080/api/asteroid/stream/queue/add \
|
||||
-d "track-id=43&position=next" \
|
||||
-H "Cookie: radiance-session=..."
|
||||
|
||||
# Add an entire playlist
|
||||
curl -X POST http://localhost:8080/api/asteroid/stream/queue/add-playlist \
|
||||
-d "playlist-id=5" \
|
||||
-H "Cookie: radiance-session=..."
|
||||
#+END_SRC
|
||||
|
||||
** Managing the Queue
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
# View current queue
|
||||
curl http://localhost:8080/api/asteroid/stream/queue \
|
||||
-H "Cookie: radiance-session=..."
|
||||
|
||||
# Remove a track
|
||||
curl -X POST http://localhost:8080/api/asteroid/stream/queue/remove \
|
||||
-d "track-id=42" \
|
||||
-H "Cookie: radiance-session=..."
|
||||
|
||||
# Clear everything
|
||||
curl -X POST http://localhost:8080/api/asteroid/stream/queue/clear \
|
||||
-H "Cookie: radiance-session=..."
|
||||
#+END_SRC
|
||||
|
||||
* Lisp Functions
|
||||
|
||||
If you're working directly in the Lisp REPL:
|
||||
|
||||
#+BEGIN_SRC lisp
|
||||
;; Add tracks to queue
|
||||
(add-to-stream-queue 42 :end)
|
||||
(add-to-stream-queue 43 :next)
|
||||
|
||||
;; View queue
|
||||
(get-stream-queue)
|
||||
|
||||
;; Add a playlist
|
||||
(add-playlist-to-stream-queue 5)
|
||||
|
||||
;; Remove a track
|
||||
(remove-from-stream-queue 42)
|
||||
|
||||
;; Clear queue
|
||||
(clear-stream-queue)
|
||||
|
||||
;; Reorder queue
|
||||
(reorder-stream-queue '(43 44 45 46))
|
||||
|
||||
;; Build smart queues
|
||||
(build-smart-queue "electronic" 20)
|
||||
(build-queue-from-artist "Nine Inch Nails" 15)
|
||||
|
||||
;; Manually regenerate playlist file
|
||||
(regenerate-stream-playlist)
|
||||
#+END_SRC
|
||||
|
||||
* File Locations
|
||||
|
||||
- *Stream Queue File*: =/home/glenn/Projects/Code/asteroid/stream-queue.m3u=
|
||||
- *Docker Mount*: =/app/stream-queue.m3u= (inside Liquidsoap container)
|
||||
- *Liquidsoap Config*: =docker/asteroid-radio-docker.liq=
|
||||
|
||||
* How Liquidsoap Reads Updates
|
||||
|
||||
The Liquidsoap configuration reloads the playlist file every 60 seconds:
|
||||
|
||||
#+BEGIN_SRC liquidsoap
|
||||
radio = playlist.safe(
|
||||
mode="normal",
|
||||
reload=60,
|
||||
"/app/stream-queue.m3u"
|
||||
)
|
||||
#+END_SRC
|
||||
|
||||
This means changes to the queue will take effect within 1 minute.
|
||||
|
||||
* Stream History
|
||||
|
||||
The system also tracks recently played tracks:
|
||||
|
||||
#+BEGIN_SRC lisp
|
||||
;; Get last 10 played tracks
|
||||
(get-stream-history 10)
|
||||
|
||||
;; Add to history (usually automatic)
|
||||
(add-to-stream-history 42)
|
||||
#+END_SRC
|
||||
|
||||
* Future Enhancements
|
||||
|
||||
- [ ] Web UI for queue management (drag-and-drop reordering)
|
||||
- [ ] Telnet integration for real-time skip/next commands
|
||||
- [ ] Scheduled programming (time-based queue switching)
|
||||
- [ ] Auto-queue filling (automatically add tracks when queue runs low)
|
||||
- [ ] Genre-based smart queues
|
||||
- [ ] Listener request system
|
||||
|
||||
* Troubleshooting
|
||||
|
||||
** Queue changes not taking effect
|
||||
|
||||
- Wait up to 60 seconds for Liquidsoap to reload
|
||||
- Check that =stream-queue.m3u= was generated correctly
|
||||
- Verify Docker volume mount is working: =docker exec asteroid-liquidsoap ls -la /app/stream-queue.m3u=
|
||||
- Check Liquidsoap logs: =docker logs asteroid-liquidsoap=
|
||||
|
||||
** Empty queue falls back to random
|
||||
|
||||
This is expected behavior. The system will play random tracks from the music library when the queue is empty to ensure continuous streaming.
|
||||
|
||||
** Playlist file not updating
|
||||
|
||||
- Ensure Asteroid server has write permissions to the project directory
|
||||
- Check that =regenerate-stream-playlist= is being called after queue modifications
|
||||
- Verify the file exists: =ls -la stream-queue.m3u=
|
||||
|
||||
* Integration with Admin Interface
|
||||
|
||||
The stream control system is designed to be integrated into the admin web interface. Future work will add:
|
||||
|
||||
- Visual queue editor with drag-and-drop
|
||||
- "Add to Stream Queue" buttons on track listings
|
||||
- "Queue Playlist" buttons on playlist pages
|
||||
- Real-time queue display showing what's currently playing
|
||||
- Skip/Next controls for immediate playback changes (via Telnet)
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
;;;; stream-control.lisp - Stream Queue and Playlist Control for Asteroid Radio
|
||||
;;;; Manages the main broadcast stream queue and generates M3U playlists for Liquidsoap
|
||||
|
||||
(in-package :asteroid)
|
||||
|
||||
;;; Stream Queue Management
|
||||
;;; The stream queue represents what will play on the main broadcast
|
||||
|
||||
(defvar *stream-queue* '() "List of track IDs queued for streaming")
|
||||
(defvar *stream-history* '() "List of recently played track IDs")
|
||||
(defvar *max-history-size* 50 "Maximum number of tracks to keep in history")
|
||||
|
||||
(defun get-stream-queue ()
|
||||
"Get the current stream queue"
|
||||
*stream-queue*)
|
||||
|
||||
(defun add-to-stream-queue (track-id &optional (position :end))
|
||||
"Add a track to the stream queue at specified position (:end or :next)"
|
||||
(case position
|
||||
(:next (push track-id *stream-queue*))
|
||||
(:end (setf *stream-queue* (append *stream-queue* (list track-id))))
|
||||
(t (error "Position must be :next or :end")))
|
||||
(regenerate-stream-playlist)
|
||||
t)
|
||||
|
||||
(defun remove-from-stream-queue (track-id)
|
||||
"Remove a track from the stream queue"
|
||||
(setf *stream-queue* (remove track-id *stream-queue* :test #'equal))
|
||||
(regenerate-stream-playlist)
|
||||
t)
|
||||
|
||||
(defun clear-stream-queue ()
|
||||
"Clear the entire stream queue"
|
||||
(setf *stream-queue* '())
|
||||
(regenerate-stream-playlist)
|
||||
t)
|
||||
|
||||
(defun reorder-stream-queue (track-ids)
|
||||
"Reorder the stream queue with a new list of track IDs"
|
||||
(setf *stream-queue* track-ids)
|
||||
(regenerate-stream-playlist)
|
||||
t)
|
||||
|
||||
(defun add-playlist-to-stream-queue (playlist-id)
|
||||
"Add all tracks from a playlist to the stream queue"
|
||||
(let ((playlist (get-playlist-by-id playlist-id)))
|
||||
(when playlist
|
||||
(let* ((track-ids-raw (gethash "track-ids" playlist))
|
||||
(track-ids-str (if (listp track-ids-raw)
|
||||
(first track-ids-raw)
|
||||
track-ids-raw))
|
||||
(track-ids (if (and track-ids-str
|
||||
(stringp track-ids-str)
|
||||
(not (string= track-ids-str "")))
|
||||
(mapcar #'parse-integer
|
||||
(cl-ppcre:split "," track-ids-str))
|
||||
nil)))
|
||||
(dolist (track-id track-ids)
|
||||
(add-to-stream-queue track-id :end))
|
||||
t))))
|
||||
|
||||
;;; M3U Playlist Generation
|
||||
|
||||
(defun get-track-file-path (track-id)
|
||||
"Get the file path for a track by ID"
|
||||
(let ((track (get-track-by-id track-id)))
|
||||
(when track
|
||||
(let ((file-path (gethash "file-path" track)))
|
||||
(if (listp file-path)
|
||||
(first file-path)
|
||||
file-path)))))
|
||||
|
||||
(defun convert-to-docker-path (host-path)
|
||||
"Convert host file path to Docker container path"
|
||||
;; Replace /home/glenn/Projects/Code/asteroid/music/library/ with /app/music/
|
||||
(let ((library-prefix "/home/glenn/Projects/Code/asteroid/music/library/"))
|
||||
(if (and (stringp host-path)
|
||||
(>= (length host-path) (length library-prefix))
|
||||
(string= host-path library-prefix :end1 (length library-prefix)))
|
||||
(concatenate 'string "/app/music/"
|
||||
(subseq host-path (length library-prefix)))
|
||||
host-path)))
|
||||
|
||||
(defun generate-m3u-playlist (track-ids output-path)
|
||||
"Generate an M3U playlist file from a list of track IDs"
|
||||
(with-open-file (stream output-path
|
||||
:direction :output
|
||||
:if-exists :supersede
|
||||
:if-does-not-exist :create)
|
||||
(format stream "#EXTM3U~%")
|
||||
(dolist (track-id track-ids)
|
||||
(let ((file-path (get-track-file-path track-id)))
|
||||
(when file-path
|
||||
(let ((docker-path (convert-to-docker-path file-path)))
|
||||
(format stream "#EXTINF:0,~%")
|
||||
(format stream "~a~%" docker-path))))))
|
||||
t)
|
||||
|
||||
(defun regenerate-stream-playlist ()
|
||||
"Regenerate the main stream playlist from the current queue"
|
||||
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
(if (null *stream-queue*)
|
||||
;; If queue is empty, generate from all tracks (fallback)
|
||||
(let ((all-tracks (db:select "tracks" (db:query :all))))
|
||||
(generate-m3u-playlist
|
||||
(mapcar (lambda (track)
|
||||
(let ((id (gethash "_id" track)))
|
||||
(if (listp id) (first id) id)))
|
||||
all-tracks)
|
||||
playlist-path))
|
||||
;; Generate from queue
|
||||
(generate-m3u-playlist *stream-queue* playlist-path))))
|
||||
|
||||
(defun export-playlist-to-m3u (playlist-id output-path)
|
||||
"Export a user playlist to an M3U file"
|
||||
(let ((playlist (get-playlist-by-id playlist-id)))
|
||||
(when playlist
|
||||
(let* ((track-ids-raw (gethash "track-ids" playlist))
|
||||
(track-ids-str (if (listp track-ids-raw)
|
||||
(first track-ids-raw)
|
||||
track-ids-raw))
|
||||
(track-ids (if (and track-ids-str
|
||||
(stringp track-ids-str)
|
||||
(not (string= track-ids-str "")))
|
||||
(mapcar #'parse-integer
|
||||
(cl-ppcre:split "," track-ids-str))
|
||||
nil)))
|
||||
(generate-m3u-playlist track-ids output-path)))))
|
||||
|
||||
;;; Stream History Management
|
||||
|
||||
(defun add-to-stream-history (track-id)
|
||||
"Add a track to the stream history"
|
||||
(push track-id *stream-history*)
|
||||
;; Keep history size limited
|
||||
(when (> (length *stream-history*) *max-history-size*)
|
||||
(setf *stream-history* (subseq *stream-history* 0 *max-history-size*)))
|
||||
t)
|
||||
|
||||
(defun get-stream-history (&optional (count 10))
|
||||
"Get recent stream history (default 10 tracks)"
|
||||
(subseq *stream-history* 0 (min count (length *stream-history*))))
|
||||
|
||||
;;; Smart Queue Building
|
||||
|
||||
(defun build-smart-queue (genre &optional (count 20))
|
||||
"Build a smart queue based on genre"
|
||||
(let ((tracks (db:select "tracks" (db:query :all))))
|
||||
;; For now, just add random tracks
|
||||
;; TODO: Implement genre filtering when we have genre metadata
|
||||
(let ((track-ids (mapcar (lambda (track)
|
||||
(let ((id (gethash "_id" track)))
|
||||
(if (listp id) (first id) id)))
|
||||
tracks)))
|
||||
(setf *stream-queue* (subseq (alexandria:shuffle track-ids)
|
||||
0
|
||||
(min count (length track-ids))))
|
||||
(regenerate-stream-playlist)
|
||||
*stream-queue*)))
|
||||
|
||||
(defun build-queue-from-artist (artist-name &optional (count 20))
|
||||
"Build a queue from tracks by a specific artist"
|
||||
(let ((tracks (db:select "tracks" (db:query :all))))
|
||||
(let ((matching-tracks
|
||||
(remove-if-not
|
||||
(lambda (track)
|
||||
(let ((artist (gethash "artist" track)))
|
||||
(when artist
|
||||
(let ((artist-str (if (listp artist) (first artist) artist)))
|
||||
(search artist-name artist-str :test #'char-equal)))))
|
||||
tracks)))
|
||||
(let ((track-ids (mapcar (lambda (track)
|
||||
(let ((id (gethash "_id" track)))
|
||||
(if (listp id) (first id) id)))
|
||||
matching-tracks)))
|
||||
(setf *stream-queue* (subseq track-ids 0 (min count (length track-ids))))
|
||||
(regenerate-stream-playlist)
|
||||
*stream-queue*))))
|
||||
|
|
@ -61,8 +61,17 @@
|
|||
|
||||
(defun track-exists-p (file-path)
|
||||
"Check if a track with the given file path already exists in the database"
|
||||
;; Try direct query first
|
||||
(let ((existing (db:select "tracks" (db:query (:= "file-path" file-path)))))
|
||||
(> (length existing) 0)))
|
||||
(if (> (length existing) 0)
|
||||
t
|
||||
;; If not found, search manually (file-path might be stored as list)
|
||||
(let ((all-tracks (db:select "tracks" (db:query :all))))
|
||||
(some (lambda (track)
|
||||
(let ((stored-path (gethash "file-path" track)))
|
||||
(or (equal stored-path file-path)
|
||||
(and (listp stored-path) (equal (first stored-path) file-path)))))
|
||||
all-tracks)))))
|
||||
|
||||
(defun insert-track-to-database (metadata)
|
||||
"Insert track metadata into database if it doesn't already exist"
|
||||
|
|
@ -73,9 +82,7 @@
|
|||
;; Check if track already exists
|
||||
(let ((file-path (getf metadata :file-path)))
|
||||
(if (track-exists-p file-path)
|
||||
(progn
|
||||
(format t "Track already exists, skipping: ~a~%" file-path)
|
||||
nil)
|
||||
nil
|
||||
(progn
|
||||
(db:insert "tracks"
|
||||
(list (list "title" (getf metadata :title))
|
||||
|
|
@ -91,34 +98,27 @@
|
|||
|
||||
(defun scan-music-library (&optional (directory *music-library-path*))
|
||||
"Scan music library directory and add tracks to database"
|
||||
(format t "Scanning music library: ~a~%" directory)
|
||||
(let ((audio-files (scan-directory-for-music-recursively directory))
|
||||
(added-count 0)
|
||||
(skipped-count 0))
|
||||
(format t "Found ~a audio files to process~%" (length audio-files))
|
||||
(dolist (file audio-files)
|
||||
(let ((metadata (extract-metadata-with-taglib file)))
|
||||
(when metadata
|
||||
(handler-case
|
||||
(if (insert-track-to-database metadata)
|
||||
(progn
|
||||
(incf added-count)
|
||||
(format t "Added: ~a~%" (getf metadata :file-path)))
|
||||
(incf added-count)
|
||||
(incf skipped-count))
|
||||
(error (e)
|
||||
(format t "Error adding ~a: ~a~%" file e))))))
|
||||
(format t "Library scan complete. Added ~a new tracks, skipped ~a existing tracks.~%"
|
||||
added-count skipped-count)
|
||||
added-count))
|
||||
|
||||
;; Initialize music directory structure
|
||||
(defun ensure-music-directories ()
|
||||
"Create music directory structure if it doesn't exist"
|
||||
(let ((base-dir (merge-pathnames "music/" (asdf:system-source-directory :asteroid))))
|
||||
(defun initialize-music-directories (&optional (base-dir *music-library-path*))
|
||||
"Create necessary music directories if they don't exist"
|
||||
(progn
|
||||
(ensure-directories-exist (merge-pathnames "library/" base-dir))
|
||||
(ensure-directories-exist (merge-pathnames "incoming/" base-dir))
|
||||
(ensure-directories-exist (merge-pathnames "temp/" base-dir))
|
||||
(format t "Music directories initialized at ~a~%" base-dir)))
|
||||
(ensure-directories-exist (merge-pathnames "temp/" base-dir))))
|
||||
|
||||
;; Simple file copy endpoint for manual uploads
|
||||
(define-page copy-files #@"/admin/copy-files" ()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
#EXTM3U
|
||||
#EXTINF:0,
|
||||
/app/music/Model500/2015 - Digital Solutions/02-model_500-electric_night.flac
|
||||
#EXTINF:0,
|
||||
/app/music/Kraftwerk/1978 - The Man-Machine \[2009 Digital Remaster]/02 - Spacelab.flac
|
||||
#EXTINF:0,
|
||||
/app/music/Kraftwerk/1981 - Computer World \[2009 Digital Remaster]/03 - Numbers.flac
|
||||
#EXTINF:0,
|
||||
/app/music/Model500/2015 - Digital Solutions/08-model_500-digital_solutions.flac
|
||||
#EXTINF:0,
|
||||
/app/music/Model500/2015 - Digital Solutions/02-model_500-electric_night.flac
|
||||
#EXTINF:0,
|
||||
/app/music/This Mortal Coil/1984 - It'll End In Tears/09 - Barramundi.flac
|
||||
#EXTINF:0,
|
||||
/app/music/Underworld/1996 - Second Toughest In The Infants/04. Underworld - Rowla.flac
|
||||
#EXTINF:0,
|
||||
/app/music/This Mortal Coil/1984 - It'll End In Tears/10 - Dreams Made Flesh.flac
|
||||
#EXTINF:0,
|
||||
/app/music/Underworld/1996 - Second Toughest In The Infants/07. Underworld - Blueski.flac
|
||||
Loading…
Reference in New Issue