diff --git a/asteroid.lisp b/asteroid.lisp
index 7e23387..e98969e 100644
--- a/asteroid.lisp
+++ b/asteroid.lisp
@@ -133,17 +133,10 @@
(playlists (get-user-playlists user-id)))
(api-output `(("status" . "success")
("playlists" . ,(mapcar (lambda (playlist)
- (let* ((track-ids (dm:field playlist "track-ids"))
- ;; Calculate track count from comma-separated string
- ;; Handle nil, empty string, or list containing empty string
- (track-count (if (and track-ids
- (stringp track-ids)
- (not (string= track-ids "")))
- (length (cl-ppcre:split "," track-ids))
- 0)))
+ (let ((track-count (get-playlist-track-count (dm:id playlist))))
`(("id" . ,(dm:id playlist))
("name" . ,(dm:field playlist "name"))
- ("description" . ,(dm:field playlist "description"))
+ ("description" . ,(or (dm:field playlist "description") ""))
("track-count" . ,track-count)
("created-date" . ,(dm:field playlist "created-date")))))
playlists)))))))
@@ -177,7 +170,7 @@
(let* ((id (parse-integer playlist-id :junk-allowed t))
(playlist (get-playlist-by-id id)))
(if playlist
- (let* ((track-ids (dm:field playlist "tracks"))
+ (let* ((track-ids (get-playlist-tracks id))
(tracks (mapcar (lambda (track-id)
(dm:get-one "tracks" (db:query (:= '_id track-id))))
track-ids))
@@ -185,6 +178,8 @@
(api-output `(("status" . "success")
("playlist" . (("id" . ,id)
("name" . ,(dm:field playlist "name"))
+ ("description" . ,(or (dm:field playlist "description") ""))
+ ("track-count" . ,(length valid-tracks))
("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(dm:id track))
("title" . ,(dm:field track "title"))
@@ -195,6 +190,31 @@
("message" . "Playlist not found"))
:status 404)))))
+(define-api asteroid/playlists/delete (playlist-id) ()
+ "Delete a playlist"
+ (require-authentication)
+ (with-error-handling
+ (let* ((id (parse-integer playlist-id :junk-allowed t))
+ (user-id (get-current-user-id)))
+ (if (delete-playlist id user-id)
+ (api-output `(("status" . "success")
+ ("message" . "Playlist deleted")))
+ (api-output `(("status" . "error")
+ ("message" . "Could not delete playlist (not found or not owned by you)"))
+ :status 403)))))
+
+(define-api asteroid/playlists/remove-track (playlist-id track-id) ()
+ "Remove a track from a playlist"
+ (require-authentication)
+ (with-error-handling
+ (let ((pl-id (parse-integer playlist-id :junk-allowed t))
+ (tr-id (parse-integer track-id :junk-allowed t)))
+ (if (remove-track-from-playlist pl-id tr-id)
+ (api-output `(("status" . "success")
+ ("message" . "Track removed from playlist")))
+ (api-output `(("status" . "error")
+ ("message" . "Could not remove track")))))))
+
;; Recently played tracks API endpoint
(define-api asteroid/recently-played () ()
"Get the last 3 played tracks with AllMusic links"
@@ -558,28 +578,26 @@
(define-page stream-track #@"/tracks/(.*)/stream" (:uri-groups (track-id))
"Stream audio file by track ID"
- (with-error-handling
- (let* ((id (parse-integer track-id))
- (track (get-track-by-id id)))
- (unless track
- (signal-not-found "track" id))
- (let* ((file-path (dm:field track "file-path"))
- (format (dm:field track "format"))
- (file (probe-file file-path)))
- (unless file
- (error 'not-found-error
- :message "Audio file not found on disk"
- :resource-type "file"
- :resource-id file-path))
- ;; Set appropriate headers for audio streaming
- (setf (radiance:header "Content-Type") (get-mime-type-for-format format))
- (setf (radiance:header "Accept-Ranges") "bytes")
- (setf (radiance:header "Cache-Control") "public, max-age=3600")
- ;; Increment play count
- (setf (dm:field track "play-count") (1+ (dm:field track "play-count")))
- (data-model-save track)
- ;; Return file contents
- (alexandria:read-file-into-byte-vector file)))))
+ (let* ((id (parse-integer track-id :junk-allowed t))
+ (track (when id (get-track-by-id id))))
+ (if (not track)
+ (progn
+ (setf (radiance:header "Content-Type") "text/plain")
+ "Track not found")
+ (let* ((file-path (dm:field track "file-path"))
+ (format (dm:field track "format"))
+ (file (probe-file file-path)))
+ (if (not file)
+ (progn
+ (setf (radiance:header "Content-Type") "text/plain")
+ "Audio file not found")
+ (progn
+ ;; Set appropriate headers for audio streaming
+ (setf (radiance:header "Content-Type") (get-mime-type-for-format format))
+ (setf (radiance:header "Accept-Ranges") "bytes")
+ (setf (radiance:header "Cache-Control") "public, max-age=3600")
+ ;; Return file contents
+ (alexandria:read-file-into-byte-vector file)))))))
;; Player state management
(defvar *current-track* nil "Currently playing track")
diff --git a/database.lisp b/database.lisp
index bbeb568..b234188 100644
--- a/database.lisp
+++ b/database.lisp
@@ -1,5 +1,19 @@
(in-package :asteroid)
+;; Database connection parameters for direct postmodern queries
+(defun get-db-connection-params ()
+ "Get database connection parameters for postmodern"
+ (list (or (uiop:getenv "ASTEROID_DB_NAME") "asteroid")
+ (or (uiop:getenv "ASTEROID_DB_USER") "asteroid")
+ (or (uiop:getenv "ASTEROID_DB_PASSWORD") "asteroid_db_2025")
+ (or (uiop:getenv "ASTEROID_DB_HOST") "localhost")
+ :port (parse-integer (or (uiop:getenv "ASTEROID_DB_PORT") "5432"))))
+
+(defmacro with-db (&body body)
+ "Execute body with database connection"
+ `(postmodern:with-connection (get-db-connection-params)
+ ,@body))
+
;; Database initialization - must be in db:connected trigger because
;; the system could load before the database is ready.
diff --git a/listener-stats.lisp b/listener-stats.lisp
index e86039c..aab1a5f 100644
--- a/listener-stats.lisp
+++ b/listener-stats.lisp
@@ -3,20 +3,7 @@
(in-package #:asteroid)
-;;; Use postmodern for direct SQL queries
-;;; Connection params from environment or defaults matching config/radiance-postgres.lisp
-(defun get-db-connection-params ()
- "Get database connection parameters"
- (list (or (uiop:getenv "ASTEROID_DB_NAME") "asteroid")
- (or (uiop:getenv "ASTEROID_DB_USER") "asteroid")
- (or (uiop:getenv "ASTEROID_DB_PASSWORD") "asteroid_db_2025")
- "localhost"
- :port 5432))
-
-(defmacro with-db (&body body)
- "Execute body with database connection"
- `(postmodern:with-connection (get-db-connection-params)
- ,@body))
+;;; Note: get-db-connection-params and with-db are defined in database.lisp
;;; Configuration
(defvar *stats-polling-interval* 60
diff --git a/parenscript/player.lisp b/parenscript/player.lisp
index a72bd1e..f7eb25f 100644
--- a/parenscript/player.lisp
+++ b/parenscript/player.lisp
@@ -209,8 +209,9 @@
"
" (or (ps:@ track artist) "Unknown Artist") " • " (or (ps:@ track album) "Unknown Album") "
"
""
""
- ""
- ""
+ ""
+ ""
+ ""
"
"
""))))
(join ""))))
@@ -409,6 +410,91 @@
(setf *play-queue* (array))
(update-queue-display))
+ ;; Store playlists for the add-to-playlist menu
+ (defvar *user-playlists* (array))
+
+ ;; Show add to playlist dropdown menu
+ (defun show-add-to-playlist-menu (track-id event)
+ (ps:chain event (stop-propagation))
+ ;; Remove any existing menu
+ (let ((existing-menu (ps:chain document (get-element-by-id "playlist-dropdown-menu"))))
+ (when existing-menu
+ (ps:chain existing-menu (remove))))
+
+ ;; Fetch playlists and show menu
+ (ps:chain (fetch "/api/asteroid/playlists")
+ (then (lambda (response) (ps:chain response (json))))
+ (then (lambda (result)
+ (let* ((data (or (ps:@ result data) result))
+ (playlists (or (ps:@ data playlists) (array)))
+ (menu (ps:chain document (create-element "div"))))
+ (setf *user-playlists* playlists)
+ (setf (ps:@ menu id) "playlist-dropdown-menu")
+ (setf (ps:@ menu class-name) "playlist-dropdown-menu")
+ (setf (ps:@ menu style position) "fixed")
+ (setf (ps:@ menu style left) (+ (ps:@ event client-x) "px"))
+ (setf (ps:@ menu style top) (+ (ps:@ event client-y) "px"))
+ (setf (ps:@ menu style z-index) "1000")
+ (setf (ps:@ menu style background) "#1a1a2e")
+ (setf (ps:@ menu style border) "1px solid #00ff00")
+ (setf (ps:@ menu style border-radius) "4px")
+ (setf (ps:@ menu style padding) "5px 0")
+ (setf (ps:@ menu style min-width) "150px")
+
+ (if (= (ps:@ playlists length) 0)
+ (setf (ps:@ menu inner-h-t-m-l)
+ "No playlists yet
")
+ (setf (ps:@ menu inner-h-t-m-l)
+ (ps:chain playlists
+ (map (lambda (playlist)
+ (+ "")))
+ (join ""))))
+
+ (ps:chain document body (append-child menu))
+
+ ;; Close menu when clicking elsewhere
+ (let ((close-handler (lambda (e)
+ (when (not (ps:chain menu (contains (ps:@ e target))))
+ (ps:chain menu (remove))
+ (ps:chain document (remove-event-listener "click" close-handler))))))
+ (set-timeout (lambda ()
+ (ps:chain document (add-event-listener "click" close-handler)))
+ 100)))))
+ (catch (lambda (error)
+ (ps:chain console (error "Error loading playlists for menu:" error))))))
+
+ ;; Add track to a specific playlist
+ (defun add-track-to-playlist (playlist-id track-id)
+ ;; Close the menu
+ (let ((menu (ps:chain document (get-element-by-id "playlist-dropdown-menu"))))
+ (when menu (ps:chain menu (remove))))
+
+ (let ((form-data (new -Form-data)))
+ (ps:chain form-data (append "playlist-id" playlist-id))
+ (ps:chain form-data (append "track-id" track-id))
+ (ps:chain (fetch "/api/asteroid/playlists/add-track"
+ (ps:create :method "POST" :body form-data))
+ (then (lambda (response) (ps:chain response (json))))
+ (then (lambda (result)
+ (let ((data (or (ps:@ result data) result)))
+ (if (= (ps:@ data status) "success")
+ (progn
+ ;; Find playlist name for feedback
+ (let ((playlist (ps:chain *user-playlists*
+ (find (lambda (p) (= (ps:@ p id) playlist-id))))))
+ (alert (+ "Track added to \"" (if playlist (ps:@ playlist name) "playlist") "\"")))
+ (load-playlists))
+ (alert (+ "Error: " (ps:@ data message)))))))
+ (catch (lambda (error)
+ (ps:chain console (error "Error adding track to playlist:" error))
+ (alert "Error adding track to playlist"))))))
+
;; Create playlist
(defun create-playlist ()
(let ((name (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value (trim))))
@@ -510,16 +596,21 @@
;; Load playlists from API
(defun load-playlists ()
(ps:chain
- (ps:chain (fetch "/api/asteroid/playlists"))
+ (fetch "/api/asteroid/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
+ (ps:chain console (log "Playlists API result:" result))
(let ((playlists (cond
((and (ps:@ result data) (= (ps:@ result data status) "success"))
+ (ps:chain console (log "Found playlists in result.data.playlists"))
(or (ps:@ result data playlists) (array)))
((= (ps:@ result status) "success")
+ (ps:chain console (log "Found playlists in result.playlists"))
(or (ps:@ result playlists) (array)))
(t
+ (ps:chain console (log "No playlists found in response"))
(array)))))
+ (ps:chain console (log "Playlists to display:" playlists))
(display-playlists playlists))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlists:" error))
@@ -533,19 +624,66 @@
(setf (ps:@ container inner-h-t-m-l) "No playlists created yet.
")
(let ((playlists-html (ps:chain playlists
(map (lambda (playlist)
- (+ ""
+ (+ "
"
"
"
"
" (ps:@ playlist name) "
"
"
" (ps:@ playlist "track-count") " tracks
"
"
"
"
"
- ""
+ ""
+ ""
+ ""
"
"
"
")))
(join ""))))
(setf (ps:@ container inner-h-t-m-l) playlists-html)))))
+ ;; Delete playlist
+ (defun delete-playlist (playlist-id playlist-name)
+ (when (confirm (+ "Are you sure you want to delete playlist \"" playlist-name "\"?"))
+ (let ((form-data (new -Form-data)))
+ (ps:chain form-data (append "playlist-id" playlist-id))
+ (ps:chain (fetch "/api/asteroid/playlists/delete"
+ (ps:create :method "POST" :body form-data))
+ (then (lambda (response) (ps:chain response (json))))
+ (then (lambda (result)
+ (let ((data (or (ps:@ result data) result)))
+ (if (= (ps:@ data status) "success")
+ (progn
+ (alert (+ "Playlist \"" playlist-name "\" deleted"))
+ (load-playlists))
+ (alert (+ "Error deleting playlist: " (ps:@ data message)))))))
+ (catch (lambda (error)
+ (ps:chain console (error "Error deleting playlist:" error))
+ (alert "Error deleting playlist")))))))
+
+ ;; View playlist contents
+ (defun view-playlist (playlist-id)
+ (ps:chain
+ (fetch (+ "/api/asteroid/playlists/get?playlist-id=" playlist-id))
+ (then (lambda (response) (ps:chain response (json))))
+ (then (lambda (result)
+ (let ((data (or (ps:@ result data) result)))
+ (if (and (= (ps:@ data status) "success") (ps:@ data playlist))
+ (let* ((playlist (ps:@ data playlist))
+ (tracks (or (ps:@ playlist tracks) (array)))
+ (track-list (if (> (ps:@ tracks length) 0)
+ (ps:chain tracks
+ (map (lambda (track index)
+ (+ (+ index 1) ". "
+ (or (ps:@ track artist) "Unknown") " - "
+ (or (ps:@ track title) "Unknown"))))
+ (join "\\n"))
+ "No tracks in playlist")))
+ (alert (+ "Playlist: " (ps:@ playlist name) "\\n"
+ "Tracks: " (ps:@ playlist "track-count") "\\n\\n"
+ track-list)))
+ (alert "Could not load playlist")))))
+ (catch (lambda (error)
+ (ps:chain console (error "Error viewing playlist:" error))
+ (alert "Error viewing playlist")))))
+
;; Load playlist into queue
(defun load-playlist (playlist-id)
(ps:chain
@@ -561,28 +699,31 @@
(setf *play-queue* (array))
;; Add all playlist tracks to queue
- (when (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
- (ps:chain (ps:@ playlist tracks)
- (for-each (lambda (track)
- ;; Find the full track object from our tracks array
- (let ((full-track (ps:chain *tracks*
- (find (lambda (trk) (= (ps:@ trk id) (ps:@ track id)))))))
- (when full-track
- (setf (aref *play-queue* (ps:@ *play-queue* length)) full-track)))))
-
- (update-queue-display)
- (alert (+ "Loaded " (ps:@ *play-queue* length) " tracks from \"" (ps:@ playlist name) "\" into queue!"))
-
- ;; Optionally start playing the first track
- (when (> (ps:@ *play-queue* length) 0)
- (let ((first-track (ps:chain *play-queue* (shift)))
- (track-index (ps:chain *tracks*
- (find-index (lambda (trk) (= (ps:@ trk id) (ps:@ first-track id))))))
- )
- (when (>= track-index 0)
- (play-track track-index))))))
- (when (or (not (ps:@ playlist tracks)) (= (ps:@ playlist tracks length) 0))
- (alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
+ (if (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
+ (progn
+ (ps:chain (ps:@ playlist tracks)
+ (for-each (lambda (track)
+ ;; Find the full track object from our tracks array
+ (let ((full-track (ps:chain *tracks*
+ (find (lambda (trk) (= (ps:@ trk id) (ps:@ track id)))))))
+ (when full-track
+ (setf (aref *play-queue* (ps:@ *play-queue* length)) full-track))))))
+
+ (update-queue-display)
+ (let ((loaded-count (ps:@ *play-queue* length)))
+ (alert (+ "Loaded " loaded-count " tracks from \"" (ps:@ playlist name) "\" into queue!"))
+
+ ;; Optionally start playing the first track
+ (when (> loaded-count 0)
+ (let* ((first-track (aref *play-queue* 0))
+ (track-index (ps:chain *tracks*
+ (find-index (lambda (trk) (= (ps:@ trk id) (ps:@ first-track id)))))))
+ ;; Remove first track from queue since we're playing it
+ (ps:chain *play-queue* (shift))
+ (update-queue-display)
+ (when (>= track-index 0)
+ (play-track track-index))))))
+ (alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
(alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlist:" error))
@@ -660,7 +801,11 @@
(setf (ps:@ window library-next-page) library-next-page)
(setf (ps:@ window library-go-to-last-page) library-go-to-last-page)
(setf (ps:@ window change-library-tracks-per-page) change-library-tracks-per-page)
- (setf (ps:@ window load-playlist) load-playlist)))
+ (setf (ps:@ window load-playlist) load-playlist)
+ (setf (ps:@ window delete-playlist) delete-playlist)
+ (setf (ps:@ window view-playlist) view-playlist)
+ (setf (ps:@ window show-add-to-playlist-menu) show-add-to-playlist-menu)
+ (setf (ps:@ window add-track-to-playlist) add-track-to-playlist)))
"Compiled JavaScript for web player - generated at load time")
(defun generate-player-js ()
diff --git a/playlist-management.lisp b/playlist-management.lisp
index b236157..9a7d82d 100644
--- a/playlist-management.lisp
+++ b/playlist-management.lisp
@@ -43,53 +43,72 @@
(dm:get-one "playlists" (db:query (:= '_id playlist-id))))
(defun add-track-to-playlist (playlist-id track-id)
- "Add a track to a playlist"
- (db:with-transaction ()
- (let ((playlist (get-playlist-by-id playlist-id)))
- (when playlist
- (let* ((current-track-ids (dm:field playlist "track-ids"))
- ;; Parse comma-separated string into list
- (tracks-list (if (and current-track-ids
- (stringp current-track-ids)
- (not (string= current-track-ids "")))
- (mapcar #'parse-integer
- (cl-ppcre:split "," current-track-ids))
- nil))
- (new-tracks (append tracks-list (list track-id)))
- ;; Convert back to comma-separated string
- (track-ids-str (format nil "~{~a~^,~}" new-tracks)))
- (format t "Adding track ~a to playlist ~a~%" track-id playlist-id)
- (format t "Current track-ids raw: ~a (type: ~a)~%" current-track-ids-raw (type-of current-track-ids-raw))
- (format t "Current track-ids: ~a~%" current-track-ids)
- (format t "Tracks list: ~a~%" tracks-list)
- (format t "New tracks: ~a~%" new-tracks)
- (format t "Track IDs string: ~a~%" track-ids-str)
- ;; Update using track-ids field (defined in schema)
- (setf (dm:field playlist "track-ids") track-ids-str)
- (data-model-save playlist)
- (format t "Update complete~%")
- t)))))
+ "Add a track to a playlist using the playlist_tracks junction table"
+ (format t "Adding track ~a to playlist ~a~%" track-id playlist-id)
+ (handler-case
+ (postmodern:with-connection (get-db-connection-params)
+ ;; Get the next position for this playlist
+ (let* ((max-pos-result (postmodern:query
+ (format nil "SELECT COALESCE(MAX(position), 0) FROM playlist_tracks WHERE playlist_id = ~a"
+ playlist-id)
+ :single))
+ (next-position (1+ (or max-pos-result 0))))
+ (postmodern:execute
+ (format nil "INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (~a, ~a, ~a)"
+ playlist-id track-id next-position))
+ (format t "Added track at position ~a~%" next-position)
+ t))
+ (error (e)
+ (format t "Error adding track to playlist: ~a~%" e)
+ nil)))
(defun remove-track-from-playlist (playlist-id track-id)
- "Remove a track from a playlist"
- (let ((playlist (get-playlist-by-id playlist-id)))
- (when playlist
- (let* ((current-track-ids (dm:field playlist "track-ids"))
- ;; Parse comma-separated string into list
- (tracks-list (if (and current-track-ids
- (stringp current-track-ids)
- (not (string= current-track-ids "")))
- (mapcar #'parse-integer
- (cl-ppcre:split "," current-track-ids))
- nil))
- (new-tracks (remove track-id tracks-list :test #'equal))
- ;; Convert back to comma-separated string
- (track-ids-str (format nil "~{~a~^,~}" new-tracks)))
- (setf (dm:field playlist "track-ids") track-ids-str)
- (data-model-save playlist)
- t))))
+ "Remove a track from a playlist using the playlist_tracks junction table"
+ (format t "Removing track ~a from playlist ~a~%" track-id playlist-id)
+ (handler-case
+ (postmodern:with-connection (get-db-connection-params)
+ (postmodern:execute
+ (format nil "DELETE FROM playlist_tracks WHERE playlist_id = ~a AND track_id = ~a"
+ playlist-id track-id))
+ (format t "Track removed~%")
+ t)
+ (error (e)
+ (format t "Error removing track from playlist: ~a~%" e)
+ nil)))
-(defun delete-playlist (playlist-id)
- "Delete a playlist"
- (dm:delete "playlists" (db:query (:= '_id playlist-id)))
- t)
+(defun get-playlist-tracks (playlist-id)
+ "Get all track IDs for a playlist from the junction table, ordered by position"
+ (handler-case
+ (postmodern:with-connection (get-db-connection-params)
+ (let ((results (postmodern:query
+ (format nil "SELECT track_id FROM playlist_tracks WHERE playlist_id = ~a ORDER BY position"
+ playlist-id))))
+ (mapcar #'first results)))
+ (error (e)
+ (format t "Error getting playlist tracks: ~a~%" e)
+ nil)))
+
+(defun get-playlist-track-count (playlist-id)
+ "Get the number of tracks in a playlist"
+ (handler-case
+ (postmodern:with-connection (get-db-connection-params)
+ (let ((result (postmodern:query
+ (format nil "SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = ~a"
+ playlist-id)
+ :single)))
+ (format t "Track count for playlist ~a: ~a (type: ~a)~%" playlist-id result (type-of result))
+ ;; Ensure we return an integer
+ (if (integerp result)
+ result
+ (if result (parse-integer (format nil "~a" result) :junk-allowed t) 0))))
+ (error (e)
+ (format t "Error getting playlist track count: ~a~%" e)
+ 0)))
+
+(defun delete-playlist (playlist-id user-id)
+ "Delete a playlist (only if owned by user)"
+ (let ((playlist (get-playlist-by-id playlist-id)))
+ (when (and playlist (equal (dm:field playlist "user-id") user-id))
+ ;; Junction table entries will be deleted by CASCADE
+ (dm:delete "playlists" (db:query (:= '_id playlist-id)))
+ t)))