From 1186214770e9cfdfb94a057aef730d609fcbcf9e Mon Sep 17 00:00:00 2001 From: Glenn Thompson Date: Tue, 9 Dec 2025 17:50:21 +0300 Subject: [PATCH] Implement playlist management MVP for player page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Create, view, load, and delete playlists - Add tracks to playlists via dropdown menu (📋 button) - View playlist contents (👁️ button) - Load playlist to queue with auto-play (📂 button) - Delete playlist with confirmation (🗑️ button) Backend changes: - Move get-db-connection-params to database.lisp for proper load order - Update playlist functions to use playlist_tracks junction table - Add get-playlist-tracks and get-playlist-track-count functions - Add delete and remove-track API endpoints - Fix stream-track endpoint to not wrap errors in JSON Frontend changes (ParenScript): - Add playlist display with action buttons - Add showAddToPlaylistMenu dropdown - Add deletePlaylist and viewPlaylist functions - Fix forEach chaining with progn - Fix let* scoping for sequential bindings - Fix ps:regex syntax for string escaping - Add console logging for playlist debugging --- asteroid.lisp | 82 +++++++++------- database.lisp | 14 +++ listener-stats.lisp | 15 +-- parenscript/player.lisp | 201 +++++++++++++++++++++++++++++++++------ playlist-management.lisp | 113 +++++++++++++--------- 5 files changed, 304 insertions(+), 121 deletions(-) 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) + (+ "
" + (ps:@ playlist name) " (" (ps:@ playlist "track-count") ")" + "
"))) + (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)))