feat: Custom user playlists with submission and admin review

- Add user playlist creation, editing, and track management
- Add library browser for adding tracks to playlists
- Add playlist submission workflow for station airing
- Add admin review interface with preview, approve, reject
- Generate M3U files on approval in playlists/user-submissions/
- Include user-submissions in playlist scheduler dropdown
- Use playlist description as PHASE tag in M3U
- Add database migration for user_playlists table
- Update TODO-next-features.org to mark feature complete
This commit is contained in:
glenneth 2025-12-21 18:45:35 +03:00 committed by Brian O'Reilly
parent 7351d7f800
commit 868b13af3d
23 changed files with 2260 additions and 35 deletions

View File

@ -33,15 +33,15 @@
2) [ ] Make calendar editable, reschedule, ammend &c
3) [ ] Add bumpers to landing page for scheduled programs
4) [0/8] User Profile pages
1) [ ] avatars
4) [5/8] User Profile pages
1) [X] avatars
2) [ ] default playlist
3) [ ] tarted up 'now playing' with highlights of previously upvoted tracks
3) [X] tarted up 'now playing' with highlights of previously upvoted tracks
4) [ ] polls
5) [ ] Listener requests interface
5) [X] Listener requests interface
6) [ ] Calendar of upcoming scheduled 'shows'
7) [ ] requests
8) [ ] Custom user playlists, with submission for station airing
7) [X] requests
8) [X] Custom user playlists, with submission for station airing
5) [0/2] Shuffle/Random queue
1) [ ] randomly run the whole library

View File

@ -64,6 +64,7 @@
(:file "playlist-scheduler")
(:file "listener-stats")
(:file "user-profile")
(:file "user-playlists")
(:file "track-requests")
(:file "auth-routes")
(:file "frontend-partials")

View File

@ -64,6 +64,18 @@
(listen-duration :integer)
(completed :integer))))
(unless (db:collection-exists-p "user_playlists")
(db:create "user_playlists" '((user-id :integer)
(name :text)
(description :text)
(track-ids :text)
(status :text)
(created-date :integer)
(submitted-date :integer)
(reviewed-date :integer)
(reviewed-by :integer)
(review-notes :text))))
;; TODO: the radiance db interface is too basic to contain anything
;; but strings, integers, booleans, and maybe timestamps... we will
;; need to rethink this. currently track/playlist relationships are

View File

@ -88,14 +88,16 @@
(icecast-now-playing *stream-base-url* "asteroid-shuffle.mp3")))
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
(if now-playing-stats
(progn
(let* ((title (cdr (assoc :title now-playing-stats)))
(favorite-count (or (get-track-favorite-count title) 0)))
;; TODO: it should be able to define a custom api-output for this
;; (api-output <clip-parser> :format "html"))
(setf (header "Content-Type") "text/html")
(clip:process-to-string
(load-template "partial/now-playing")
:stats now-playing-stats
:track-id (cdr (assoc :track-id now-playing-stats))))
:track-id (cdr (assoc :track-id now-playing-stats))
:favorite-count favorite-count))
(progn
(setf (header "Content-Type") "text/html")
(clip:process-to-string
@ -124,10 +126,13 @@
(let* ((mount-name (or mount "asteroid.mp3"))
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
(if now-playing-stats
(api-output `(("status" . "success")
("title" . ,(cdr (assoc :title now-playing-stats)))
("listeners" . ,(cdr (assoc :listeners now-playing-stats)))
("track_id" . ,(cdr (assoc :track-id now-playing-stats)))))
(let* ((title (cdr (assoc :title now-playing-stats)))
(favorite-count (or (get-track-favorite-count title) 0)))
(api-output `(("status" . "success")
("title" . ,title)
("listeners" . ,(cdr (assoc :listeners now-playing-stats)))
("track_id" . ,(cdr (assoc :track-id now-playing-stats)))
("favorite_count" . ,favorite-count))))
(api-output `(("status" . "offline")
("title" . "Stream Offline")
("track_id" . nil)))))))

View File

@ -0,0 +1,30 @@
-- Migration 008: User Playlists
-- Adds table for user-created playlists with submission/review workflow
CREATE TABLE IF NOT EXISTS user_playlists (
_id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
track_ids TEXT DEFAULT '[]', -- JSON array of track IDs
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'scheduled')),
created_date INTEGER DEFAULT EXTRACT(EPOCH FROM NOW())::INTEGER,
submitted_date INTEGER,
reviewed_date INTEGER,
reviewed_by INTEGER REFERENCES "USERS"(_id),
review_notes TEXT
);
-- Create indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_user_playlists_user_id ON user_playlists(user_id);
CREATE INDEX IF NOT EXISTS idx_user_playlists_status ON user_playlists(status);
-- Grant permissions
GRANT ALL PRIVILEGES ON user_playlists TO asteroid;
GRANT ALL PRIVILEGES ON SEQUENCE user_playlists__id_seq TO asteroid;
-- Verification
DO $$
BEGIN
RAISE NOTICE 'Migration 008: User playlists table created successfully!';
END $$;

View File

@ -29,6 +29,7 @@
(refresh-liquidsoap-status)
(setup-stats-refresh)
(refresh-scheduler-status)
(refresh-track-requests)
;; Update Liquidsoap status every 10 seconds
(set-interval refresh-liquidsoap-status 10000)
;; Update scheduler status every 30 seconds
@ -1286,6 +1287,216 @@
(ps:chain console (error "Error loading scheduled playlist:" error))
(alert "Error loading scheduled playlist")))))
;; ========================================
;; Track Requests Management
;; ========================================
(defvar *current-request-tab* "pending")
(defun format-request-time (timestamp)
"Format a timestamp for display"
(if (not timestamp)
""
(let* ((ts-str (+ "" timestamp))
(iso-str (if (ps:chain ts-str (includes " "))
(+ (ps:chain ts-str (replace " " "T")) "Z")
ts-str))
(date (ps:new (-date iso-str))))
(if (ps:chain -number (is-na-n (ps:chain date (get-time))))
"Recently"
(ps:chain date (to-locale-string))))))
(defun show-request-tab (tab)
(setf *current-request-tab* tab)
;; Update tab button styles
(let ((tabs (ps:chain document (query-selector-all ".btn-tab"))))
(ps:chain tabs (for-each (lambda (btn)
(ps:chain btn class-list (remove "active"))))))
(let ((active-tab (ps:chain document (get-element-by-id (+ "tab-" tab)))))
(when active-tab
(ps:chain active-tab class-list (add "active"))))
;; Load the appropriate requests
(refresh-track-requests))
(defun refresh-track-requests ()
(let ((container (ps:chain document (get-element-by-id "pending-requests-container")))
(status-el (ps:chain document (get-element-by-id "requests-status")))
(url (+ "/api/asteroid/admin/requests/list?status=" *current-request-tab*)))
(when status-el
(setf (ps:@ status-el text-content) "Loading..."))
(ps:chain
(fetch url)
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(when status-el
(setf (ps:@ status-el text-content) ""))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data requests)
(> (ps:@ data requests length) 0))
(let ((html ""))
(ps:chain (ps:@ data requests) (for-each (lambda (req)
(let ((actions-html
(cond
((= *current-request-tab* "pending")
(+ "<button class=\"btn btn-success btn-sm\" onclick=\"approveRequest(" (ps:@ req id) ")\">✓ Approve</button>"
"<button class=\"btn btn-danger btn-sm\" onclick=\"rejectRequest(" (ps:@ req id) ")\">✗ Reject</button>"))
((= *current-request-tab* "approved")
"<span class=\"status-badge status-approved\">✓ Approved</span>")
((= *current-request-tab* "rejected")
"<span class=\"status-badge status-rejected\">✗ Rejected</span>")
((= *current-request-tab* "played")
"<span class=\"status-badge status-played\">🎵 Played</span>")
(t ""))))
(setf html (+ html
"<div class=\"request-item-admin\" data-request-id=\"" (ps:@ req id) "\">"
"<div class=\"request-info\">"
"<strong>" (ps:@ req title) "</strong>"
"<span class=\"request-user\">Requested by @" (ps:@ req username) "</span>"
(if (ps:@ req message)
(+ "<p class=\"request-message\">\"" (ps:@ req message) "\"</p>")
"")
"<span class=\"request-time\">" (format-request-time (ps:@ req created_at)) "</span>"
"</div>"
"<div class=\"request-actions\">"
actions-html
"</div>"
"</div>"))))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) (+ "<p style=\"color: #888;\">No " *current-request-tab* " requests</p>")))))))
(catch (lambda (error)
(ps:chain console (error "Error loading requests:" error))
(when status-el
(setf (ps:@ status-el text-content) "Error loading requests")))))))
(defun approve-request (request-id)
(ps:chain
(fetch (+ "/api/asteroid/requests/approve?id=" request-id)
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-toast "✓ Request approved")
(refresh-track-requests))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error approving request:" error))
(alert "Error approving request")))))
(defun reject-request (request-id)
(when (confirm "Are you sure you want to reject this request?")
(ps:chain
(fetch (+ "/api/asteroid/requests/reject?id=" request-id)
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-toast "Request rejected")
(refresh-track-requests))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error rejecting request:" error))
(alert "Error rejecting request"))))))
;; ========================================
;; User Playlist Review Functions
;; ========================================
(defun load-user-playlist-submissions ()
(ps:chain
(fetch "/api/asteroid/admin/user-playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((container (ps:chain document (get-element-by-id "user-playlists-container")))
(data (or (ps:@ result data) result)))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data playlists)
(> (ps:@ data playlists length) 0))
(let ((html "<table class='admin-table'><thead><tr><th>Playlist</th><th>User</th><th>Tracks</th><th>Submitted</th><th>Actions</th></tr></thead><tbody>"))
(ps:chain (ps:@ data playlists) (for-each (lambda (pl)
(let* ((ts (aref pl "submittedDate"))
(submitted-date (if ts
(ps:chain (ps:new (*Date (* ts 1000))) (to-locale-string))
"N/A")))
(setf html (+ html
"<tr>"
"<td><strong>" (aref pl "name") "</strong>"
(if (aref pl "description") (+ "<br><small>" (aref pl "description") "</small>") "")
"</td>"
"<td>" (or (aref pl "username") "Unknown") "</td>"
"<td>" (or (aref pl "trackCount") 0) " tracks</td>"
"<td>" submitted-date "</td>"
"<td>"
"<button class='btn btn-info btn-sm' onclick='previewPlaylist(" (aref pl "id") ")'>👁 Preview</button> "
"<button class='btn btn-success btn-sm' onclick='approvePlaylist(" (aref pl "id") ")'>✓ Approve</button> "
"<button class='btn btn-danger btn-sm' onclick='rejectPlaylist(" (aref pl "id") ")'>✗ Reject</button>"
"</td>"
"</tr>"))))))
(setf html (+ html "</tbody></table>"))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class='no-data'>No playlists awaiting review</p>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading user playlists:" error))
(let ((container (ps:chain document (get-element-by-id "user-playlists-container"))))
(when container
(setf (ps:@ container inner-h-t-m-l) "<p class='error'>Error loading submissions</p>")))))))
(defun approve-playlist (playlist-id)
(when (confirm "Approve this playlist? It will be available for scheduling.")
(ps:chain
(fetch (+ "/api/asteroid/admin/user-playlists/review?id=" playlist-id "&action=approve")
(ps:create :method "POST"))
(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 approved!")
(load-user-playlist-submissions))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error approving playlist:" error))
(alert "Error approving playlist"))))))
(defun reject-playlist (playlist-id)
(let ((notes (prompt "Reason for rejection (optional):")))
(ps:chain
(fetch (+ "/api/asteroid/admin/user-playlists/review?id=" playlist-id
"&action=reject&notes=" (encode-u-r-i-component (or notes "")))
(ps:create :method "POST"))
(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 rejected.")
(load-user-playlist-submissions))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error rejecting playlist:" error))
(alert "Error rejecting playlist"))))))
(defun preview-playlist (playlist-id)
(ps:chain
(fetch (+ "/api/asteroid/admin/user-playlists/preview?id=" playlist-id))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(let ((m3u (aref data "m3u")))
;; Show in a modal or alert
(alert (+ "Playlist M3U Preview:\n\n" m3u)))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error previewing playlist:" error))
(alert "Error previewing playlist")))))
;; Make functions globally accessible for onclick handlers
(setf (ps:@ window go-to-page) go-to-page)
(setf (ps:@ window previous-page) previous-page)
@ -1309,6 +1520,17 @@
(setf (ps:@ window load-current-scheduled-playlist) load-current-scheduled-playlist)
(setf (ps:@ window add-schedule-entry) add-schedule-entry)
(setf (ps:@ window remove-schedule-entry) remove-schedule-entry)
(setf (ps:@ window refresh-track-requests) refresh-track-requests)
(setf (ps:@ window approve-request) approve-request)
(setf (ps:@ window reject-request) reject-request)
(setf (ps:@ window show-request-tab) show-request-tab)
(setf (ps:@ window load-user-playlist-submissions) load-user-playlist-submissions)
(setf (ps:@ window approve-playlist) approve-playlist)
(setf (ps:@ window reject-playlist) reject-playlist)
(setf (ps:@ window preview-playlist) preview-playlist)
;; Load user playlist submissions on page load
(load-user-playlist-submissions)
))
"Compiled JavaScript for admin dashboard - generated at load time")

View File

@ -168,6 +168,39 @@
;; Track last recorded title to avoid duplicate history entries
(defvar *last-recorded-title-main* nil)
;; Cache of user's favorite track titles for quick lookup
(defvar *user-favorites-cache* (array))
;; Load user's favorites into cache
(defun load-favorites-cache ()
(ps:chain
(fetch "/api/asteroid/user/favorites")
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (json))
nil)))
(then (lambda (data)
(when (and data (ps:@ data data) (ps:@ data data favorites))
(setf *user-favorites-cache*
(ps:chain (ps:@ data data favorites)
(map (lambda (f) (ps:@ f title))))))))
(catch (lambda (error) nil))))
;; Check if current track is in favorites and update UI
(defun check-favorite-status ()
(let ((title-el (ps:chain document (get-element-by-id "current-track-title")))
(btn (ps:chain document (get-element-by-id "favorite-btn"))))
(when (and title-el btn)
(let ((title (ps:@ title-el text-content))
(star-icon (ps:chain btn (query-selector ".star-icon"))))
(if (ps:chain *user-favorites-cache* (includes title))
(progn
(ps:chain btn class-list (add "favorited"))
(when star-icon (setf (ps:@ star-icon text-content) "★")))
(progn
(ps:chain btn class-list (remove "favorited"))
(when star-icon (setf (ps:@ star-icon text-content) "☆"))))))))
;; Record track to listening history (only if logged in)
(defun record-track-listen-main (title)
(when (and title (not (= title "")) (not (= title "Loading..."))
@ -177,8 +210,7 @@
(fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title))
(ps:create :method "POST"))
(then (lambda (response)
(when (ps:@ response ok)
(ps:chain console (log "Recorded listen:" title)))))
(ps:@ response ok)))
(catch (lambda (error)
;; Silently fail - user might not be logged in
nil)))))
@ -206,7 +238,20 @@
;; Record if title changed
(when (or (not old-title-el)
(not (= (ps:@ old-title-el text-content) new-title)))
(record-track-listen-main new-title))))))))))
(record-track-listen-main new-title))
;; Check if this track is in user's favorites
(check-favorite-status)
;; Update favorite count display
(let ((count-el (ps:chain document (get-element-by-id "favorite-count-display")))
(count-val-el (ps:chain document (get-element-by-id "favorite-count-value"))))
(when (and count-el count-val-el)
(let ((fav-count (parse-int (or (ps:@ count-val-el value) "0") 10)))
(if (> fav-count 0)
(setf (ps:@ count-el text-content)
(if (= fav-count 1)
"1 person loves this track ❤️"
(+ fav-count " people love this track ❤️")))
(setf (ps:@ count-el text-content) "")))))))))))))
(catch (lambda (error)
(ps:chain console (log "Could not fetch stream status:" error)))))))
@ -582,6 +627,9 @@
(when is-frameset-page
(set-interval update-stream-information 10000)))
;; Load user's favorites for highlight feature
(load-favorites-cache)
;; Update now playing
(update-now-playing)
@ -650,7 +698,9 @@
(when (and data (or (= (ps:@ data status) "success")
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (remove "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆"))))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
;; Refresh now playing to update favorite count
(update-now-playing))))
(catch (lambda (error)
(ps:chain console (error "Error removing favorite:" error)))))
;; Add favorite
@ -667,7 +717,8 @@
(when (and data (or (= (ps:@ data status) "success")
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (add "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★"))))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")
(update-now-playing))))
(catch (lambda (error)
(ps:chain console (error "Error adding favorite:" error)))))))))))

View File

@ -327,7 +327,46 @@
(load-favorites)
(load-top-artists)
(load-activity-chart)
(load-avatar))
(load-avatar)
(load-my-requests))
;; Load user's track requests
(defun load-my-requests ()
(ps:chain
(fetch "/api/asteroid/requests/my")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(container (ps:chain document (get-element-by-id "my-requests-list"))))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data requests)
(> (ps:@ data requests length) 0))
(let ((html ""))
(ps:chain (ps:@ data requests) (for-each (lambda (req)
(let ((status-class (cond
((= (ps:@ req status) "pending") "status-pending")
((= (ps:@ req status) "approved") "status-approved")
((= (ps:@ req status) "rejected") "status-rejected")
((= (ps:@ req status) "played") "status-played")
(t "")))
(status-icon (cond
((= (ps:@ req status) "pending") "⏳")
((= (ps:@ req status) "approved") "✓")
((= (ps:@ req status) "rejected") "✗")
((= (ps:@ req status) "played") "🎵")
(t "?"))))
(setf html (+ html
"<div class=\"my-request-item " status-class "\">"
"<div class=\"request-title\">" (ps:@ req title) "</div>"
"<div class=\"request-status\">"
"<span class=\"status-badge " status-class "\">" status-icon " " (ps:@ req status) "</span>"
"</div>"
"</div>"))))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-requests\">You haven't made any requests yet.</p>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading requests:" error))))))
;; Action functions
(defun edit-profile ()
@ -426,11 +465,402 @@
false))
;; ========================================
;; User Playlists functionality
;; ========================================
(defvar *library-page* 1)
(defvar *library-search* "")
(defvar *library-artist* "")
(defvar *library-total* 0)
(defvar *current-playlist-tracks* (array))
(defvar *user-playlists* (array))
;; Load user's playlists
(defun load-my-playlists ()
(ps:chain
(fetch "/api/asteroid/user/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(container (ps:chain document (get-element-by-id "my-playlists-list"))))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data playlists)
(> (ps:@ data playlists length) 0))
(progn
(setf *user-playlists* (ps:@ data playlists))
(let ((html ""))
(ps:chain (ps:@ data playlists) (for-each (lambda (pl)
(let ((playlist-id (or (ps:@ pl id) (aref pl "id")))
(status-class (cond
((= (ps:@ pl status) "draft") "status-draft")
((= (ps:@ pl status) "submitted") "status-pending")
((= (ps:@ pl status) "approved") "status-approved")
((= (ps:@ pl status) "rejected") "status-rejected")
(t "")))
(status-icon (cond
((= (ps:@ pl status) "draft") "📝")
((= (ps:@ pl status) "submitted") "⏳")
((= (ps:@ pl status) "approved") "✓")
((= (ps:@ pl status) "rejected") "✗")
(t "?"))))
(ps:chain console (log "Playlist:" pl "ID:" playlist-id))
(setf html (+ html
"<div class=\"playlist-item " status-class "\">"
"<div class=\"playlist-info\">"
"<span class=\"playlist-name\">" (or (ps:@ pl name) (aref pl "name")) "</span>"
"<span class=\"playlist-meta\">" (or (ps:@ pl track-count) (aref pl "track-count") 0) " tracks</span>"
"</div>"
"<div class=\"playlist-actions\">"
"<span class=\"status-badge " status-class "\">" status-icon " " (or (ps:@ pl status) (aref pl "status")) "</span>"
(if (= (or (ps:@ pl status) (aref pl "status")) "draft")
(+ "<button class=\"btn btn-small\" onclick=\"editPlaylist(" playlist-id ")\">Edit</button>")
"")
"</div>"
"</div>"))))))
(setf (ps:@ container inner-h-t-m-l) html)))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No playlists yet. Create one to get started!</p>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlists:" error))))))
;; Modal functions
(defun show-create-playlist-modal ()
(let ((modal (ps:chain document (get-element-by-id "create-playlist-modal"))))
(when modal
(setf (ps:@ modal style display) "flex"))))
(defun hide-create-playlist-modal ()
(let ((modal (ps:chain document (get-element-by-id "create-playlist-modal"))))
(when modal
(setf (ps:@ modal style display) "none")
(ps:chain (ps:chain document (get-element-by-id "create-playlist-form")) (reset)))))
(defun show-library-browser ()
(let ((modal (ps:chain document (get-element-by-id "library-browser-modal"))))
(when modal
(setf (ps:@ modal style display) "flex")
(load-library-tracks)
(update-playlist-select))))
(defun hide-library-browser ()
(let ((modal (ps:chain document (get-element-by-id "library-browser-modal"))))
(when modal
(setf (ps:@ modal style display) "none"))))
(defun show-library-browser-for-playlist ()
(show-library-browser))
(defun show-edit-playlist-modal ()
(let ((modal (ps:chain document (get-element-by-id "edit-playlist-modal"))))
(when modal
(setf (ps:@ modal style display) "flex"))))
(defun hide-edit-playlist-modal ()
(let ((modal (ps:chain document (get-element-by-id "edit-playlist-modal"))))
(when modal
(setf (ps:@ modal style display) "none"))))
;; Create playlist
(defun create-playlist (event)
(ps:chain event (prevent-default))
(let ((name (ps:@ (ps:chain document (get-element-by-id "playlist-name")) value))
(description (ps:@ (ps:chain document (get-element-by-id "playlist-description")) value))
(message-div (ps:chain document (get-element-by-id "create-playlist-message"))))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/create?name=" (encode-u-r-i-component name)
"&description=" (encode-u-r-i-component description))
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-message "Playlist created!" "success")
(hide-create-playlist-modal)
(load-my-playlists)
;; Open the new playlist for editing
(when (ps:@ data playlist id)
(edit-playlist (ps:@ data playlist id))))
(progn
(setf (ps:@ message-div text-content) (or (ps:@ data message) "Failed to create playlist"))
(setf (ps:@ message-div class-name) "message error"))))))
(catch (lambda (error)
(ps:chain console (error "Error creating playlist:" error))
(setf (ps:@ message-div text-content) "Error creating playlist")
(setf (ps:@ message-div class-name) "message error")))))
false)
;; Edit playlist
(defun edit-playlist (playlist-id)
(ps:chain console (log "edit-playlist called with id:" playlist-id))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/get?id=" playlist-id))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(ps:chain console (log "edit-playlist response:" result))
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(let* ((pl (ps:@ data playlist))
(pl-id (or (ps:@ pl id) (aref pl "id")))
(pl-name (or (ps:@ pl name) (aref pl "name")))
(pl-desc (or (ps:@ pl description) (aref pl "description") ""))
(pl-tracks (or (ps:@ pl tracks) (aref pl "tracks") (array))))
(ps:chain console (log "Playlist id:" pl-id "name:" pl-name))
(setf (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value) pl-id)
(setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-name")) value) pl-name)
(setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-description")) value) pl-desc)
(setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-title")) text-content) (+ "Edit: " pl-name))
(setf *current-playlist-tracks* pl-tracks)
(render-playlist-tracks)
(show-edit-playlist-modal))
(show-message "Failed to load playlist" "error")))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlist:" error))
(show-message "Error loading playlist" "error")))))
(defun render-playlist-tracks ()
(let ((container (ps:chain document (get-element-by-id "playlist-tracks-list"))))
(when container
(if (> (ps:@ *current-playlist-tracks* length) 0)
(let ((html ""))
(ps:chain *current-playlist-tracks* (for-each (lambda (track index)
(setf html (+ html
"<div class=\"playlist-track-item\" data-index=\"" index "\">"
"<span class=\"track-number\">" (+ index 1) ".</span>"
"<span class=\"track-title\">" (ps:@ track title) "</span>"
"<span class=\"track-artist\">" (ps:@ track artist) "</span>"
"<div class=\"track-controls\">"
"<button class=\"btn btn-tiny\" onclick=\"moveTrackInPlaylist(" index ", -1)\" " (if (= index 0) "disabled" "") ">↑</button>"
"<button class=\"btn btn-tiny\" onclick=\"moveTrackInPlaylist(" index ", 1)\" " (if (= index (- (ps:@ *current-playlist-tracks* length) 1)) "disabled" "") ">↓</button>"
"<button class=\"btn btn-tiny btn-danger\" onclick=\"removeTrackFromPlaylist(" index ")\">✕</button>"
"</div>"
"</div>")))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"empty-message\">No tracks yet. Browse the library to add tracks!</p>")))))
(defun move-track-in-playlist (index direction)
(let ((new-index (+ index direction)))
(when (and (>= new-index 0) (< new-index (ps:@ *current-playlist-tracks* length)))
(let ((track (ps:chain *current-playlist-tracks* (splice index 1))))
(ps:chain *current-playlist-tracks* (splice new-index 0 (ps:getprop track 0)))
(render-playlist-tracks)
(save-playlist-tracks)))))
(defun remove-track-from-playlist (index)
(ps:chain *current-playlist-tracks* (splice index 1))
(render-playlist-tracks)
(save-playlist-tracks))
(defun add-track-to-playlist (track-id title artist album)
(ps:chain console (log "addTrackToPlaylist called with track-id:" track-id "title:" title))
(let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value)))
(when (not playlist-id)
;; No playlist open, use the select dropdown
(let ((select (ps:chain document (get-element-by-id "add-to-playlist-select"))))
(when select
(setf playlist-id (ps:@ select value)))))
(when (not playlist-id)
(show-message "Please select a playlist first" "warning")
(return))
;; Add to current tracks array
(ps:chain console (log "Adding track with id:" track-id "to playlist:" playlist-id))
;; Create object and set id property explicitly
(let ((track-obj (ps:create)))
(setf (ps:@ track-obj id) track-id)
(setf (ps:@ track-obj title) title)
(setf (ps:@ track-obj artist) artist)
(setf (ps:@ track-obj album) album)
(ps:chain *current-playlist-tracks* (push track-obj)))
(ps:chain console (log "Current tracks:" *current-playlist-tracks*))
(render-playlist-tracks)
(save-playlist-tracks)
(show-message (+ "Added: " title) "success")))
(defun save-playlist-tracks ()
(let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value)))
(when playlist-id
;; Access id property directly - use 'trk' not 't' (t is boolean true in Lisp/ParenScript)
(let ((track-ids (ps:chain *current-playlist-tracks* (map (lambda (trk) (ps:@ trk id))))))
(ps:chain console (log "Saving track-ids:" track-ids))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/update?id=" playlist-id
"&tracks=" (encode-u-r-i-component (ps:chain -j-s-o-n (stringify track-ids))))
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(catch (lambda (error)
(ps:chain console (error "Error saving playlist:" error)))))))))
(defun save-playlist-metadata ()
(let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))
(name (ps:@ (ps:chain document (get-element-by-id "edit-playlist-name")) value))
(description (ps:@ (ps:chain document (get-element-by-id "edit-playlist-description")) value)))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/update?id=" playlist-id
"&name=" (encode-u-r-i-component name)
"&description=" (encode-u-r-i-component description))
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-message "Playlist saved!" "success")
(setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-title")) text-content) (+ "Edit: " name))
(load-my-playlists))
(show-message "Failed to save playlist" "error")))))
(catch (lambda (error)
(ps:chain console (error "Error saving playlist:" error))
(show-message "Error saving playlist" "error"))))))
(defun submit-playlist-for-review ()
(let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value)))
(when (not (confirm "Submit this playlist for admin review? You won't be able to edit it after submission."))
(return))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/submit?id=" playlist-id)
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-message "Playlist submitted for review!" "success")
(hide-edit-playlist-modal)
(load-my-playlists))
(show-message (or (ps:@ data message) "Failed to submit playlist") "error")))))
(catch (lambda (error)
(ps:chain console (error "Error submitting playlist:" error))
(show-message "Error submitting playlist" "error"))))))
(defun delete-current-playlist ()
(let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value)))
(when (not (confirm "Delete this playlist? This cannot be undone."))
(return))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/delete?id=" playlist-id)
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-message "Playlist deleted" "success")
(hide-edit-playlist-modal)
(load-my-playlists))
(show-message "Failed to delete playlist" "error")))))
(catch (lambda (error)
(ps:chain console (error "Error deleting playlist:" error))
(show-message "Error deleting playlist" "error"))))))
;; Library browsing
(defun load-library-tracks ()
(let ((url (+ "/api/asteroid/library/browse?page=" *library-page*)))
(when (and *library-search* (> (ps:@ *library-search* length) 0))
(setf url (+ url "&search=" (encode-u-r-i-component *library-search*))))
(when (and *library-artist* (> (ps:@ *library-artist* length) 0))
(setf url (+ url "&artist=" (encode-u-r-i-component *library-artist*))))
(ps:chain
(fetch url)
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(container (ps:chain document (get-element-by-id "library-tracks")))
(artist-select (ps:chain document (get-element-by-id "library-artist-filter"))))
(when container
(setf *library-total* (or (ps:@ data total) 0))
(if (and (= (ps:@ data status) "success")
(ps:@ data tracks)
(> (ps:@ data tracks length) 0))
(let ((html ""))
(ps:chain (ps:@ data tracks) (for-each (lambda (track)
(setf html (+ html
"<div class=\"library-track-item\">"
"<div class=\"track-info\">"
"<span class=\"track-title\">" (ps:@ track title) "</span>"
"<span class=\"track-artist\">" (ps:@ track artist) "</span>"
"<span class=\"track-album\">" (ps:@ track album) "</span>"
"</div>"
"<button class=\"btn btn-small btn-primary\" onclick=\"addTrackToPlaylist("
(ps:@ track id) ", '"
(ps:chain (ps:@ track title) (replace (ps:regex "/'/g") "\\'")) "', '"
(ps:chain (ps:@ track artist) (replace (ps:regex "/'/g") "\\'")) "', '"
(ps:chain (ps:@ track album) (replace (ps:regex "/'/g") "\\'")) "')\">+ Add</button>"
"</div>")))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No tracks found</p>")))
;; Update artist filter
(when (and artist-select (ps:@ data artists))
(let ((current-val (ps:@ artist-select value)))
(setf (ps:@ artist-select inner-h-t-m-l) "<option value=\"\">All Artists</option>")
(ps:chain (ps:@ data artists) (for-each (lambda (artist)
(let ((opt (ps:chain document (create-element "option"))))
(setf (ps:@ opt value) artist)
(setf (ps:@ opt text-content) artist)
(ps:chain artist-select (append-child opt))))))
(setf (ps:@ artist-select value) current-val)))
;; Update pagination
(update-library-pagination))))
(catch (lambda (error)
(ps:chain console (error "Error loading library:" error)))))))
(defun update-library-pagination ()
(let ((page-info (ps:chain document (get-element-by-id "library-page-info")))
(prev-btn (ps:chain document (get-element-by-id "lib-prev-btn")))
(next-btn (ps:chain document (get-element-by-id "lib-next-btn")))
(total-pages (ps:chain -math (ceil (/ *library-total* 50)))))
(when page-info
(setf (ps:@ page-info text-content) (+ "Page " *library-page* " of " total-pages)))
(when prev-btn
(setf (ps:@ prev-btn disabled) (<= *library-page* 1)))
(when next-btn
(setf (ps:@ next-btn disabled) (>= *library-page* total-pages)))))
(defun prev-library-page ()
(when (> *library-page* 1)
(setf *library-page* (- *library-page* 1))
(load-library-tracks)))
(defun next-library-page ()
(setf *library-page* (+ *library-page* 1))
(load-library-tracks))
(defvar *search-timeout* nil)
(defun search-library ()
(when *search-timeout*
(clear-timeout *search-timeout*))
(setf *search-timeout*
(set-timeout
(lambda ()
(setf *library-search* (ps:@ (ps:chain document (get-element-by-id "library-search")) value))
(setf *library-page* 1)
(load-library-tracks))
300)))
(defun filter-by-artist ()
(setf *library-artist* (ps:@ (ps:chain document (get-element-by-id "library-artist-filter")) value))
(setf *library-page* 1)
(load-library-tracks))
(defun update-playlist-select ()
(let ((select (ps:chain document (get-element-by-id "add-to-playlist-select"))))
(when select
(setf (ps:@ select inner-h-t-m-l) "<option value=\"\">Select playlist to add to...</option>")
(ps:chain *user-playlists* (for-each (lambda (pl)
(when (= (ps:@ pl status) "draft")
(let ((opt (ps:chain document (create-element "option"))))
(setf (ps:@ opt value) (ps:@ pl id))
(setf (ps:@ opt text-content) (ps:@ pl name))
(ps:chain select (append-child opt))))))))))
;; Initialize on page load
(ps:chain window
(add-event-listener
"DOMContentLoaded"
load-profile-data))))
(lambda ()
(load-profile-data)
(load-my-playlists))))))
"Compiled JavaScript for profile page - generated at load time")
(defun generate-profile-js ()

View File

@ -201,6 +201,39 @@
;; Track the last recorded title to avoid duplicate history entries
(defvar *last-recorded-title* nil)
;; Cache of user's favorite track titles for quick lookup (mini player)
(defvar *user-favorites-cache-mini* (array))
;; Load user's favorites into cache (mini player)
(defun load-favorites-cache-mini ()
(ps:chain
(fetch "/api/asteroid/user/favorites")
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (json))
nil)))
(then (lambda (data)
(when (and data (ps:@ data data) (ps:@ data data favorites))
(setf *user-favorites-cache-mini*
(ps:chain (ps:@ data data favorites)
(map (lambda (f) (ps:@ f title))))))))
(catch (lambda (error) nil))))
;; Check if current track is in favorites and update mini player UI
(defun check-favorite-status-mini ()
(let ((title-el (ps:chain document (get-element-by-id "mini-now-playing")))
(btn (ps:chain document (get-element-by-id "favorite-btn-mini"))))
(when (and title-el btn)
(let ((title (ps:@ title-el text-content))
(star-icon (ps:chain btn (query-selector ".star-icon"))))
(if (ps:chain *user-favorites-cache-mini* (includes title))
(progn
(ps:chain btn class-list (add "favorited"))
(when star-icon (setf (ps:@ star-icon text-content) "★")))
(progn
(ps:chain btn class-list (remove "favorited"))
(when star-icon (setf (ps:@ star-icon text-content) "☆"))))))))
;; Record track to listening history (only if logged in)
(defun record-track-listen (title)
(when (and title (not (= title "")) (not (= title "Loading...")) (not (= title *last-recorded-title*)))
@ -209,11 +242,8 @@
(fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title))
(ps:create :method "POST"))
(then (lambda (response)
(when (ps:@ response ok)
(ps:chain console (log "Recorded listen:" title)))))
(catch (lambda (error)
;; Silently fail - user might not be logged in
nil)))))
(ps:@ response ok)))
(catch (lambda (error) nil)))))
;; Update mini now playing display (for persistent player frame)
(defun update-mini-now-playing ()
@ -233,10 +263,20 @@
;; Check if track changed and record to history
(when (not (= (ps:@ el text-content) title))
(record-track-listen title))
(setf (ps:@ el text-content) title))
(setf (ps:@ el text-content) title)
;; Check if this track is in user's favorites
(check-favorite-status-mini))
(when track-id-el
(let ((track-id (or (ps:@ data data track_id) (ps:@ data track_id))))
(setf (ps:@ track-id-el value) (or track-id ""))))))))
(setf (ps:@ track-id-el value) (or track-id ""))))
;; Update favorite count display
(let ((count-el (ps:chain document (get-element-by-id "favorite-count-mini")))
(fav-count (or (ps:@ data data favorite_count) (ps:@ data favorite_count) 0)))
(when count-el
(cond
((= fav-count 0) (setf (ps:@ count-el text-content) ""))
((= fav-count 1) (setf (ps:@ count-el text-content) "1 ❤️"))
(t (setf (ps:@ count-el text-content) (+ fav-count " ❤️"))))))))))
(catch (lambda (error)
(ps:chain console (log "Could not fetch now playing:" error)))))))
@ -269,7 +309,10 @@
(when (and data (or (= (ps:@ data status) "success")
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (remove "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆"))))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
;; Reload cache and refresh display to update favorite count
(load-favorites-cache-mini)
(update-mini-now-playing))))
(catch (lambda (error)
(ps:chain console (error "Error removing favorite:" error)))))
;; Add favorite
@ -286,7 +329,10 @@
(when (and data (or (= (ps:@ data status) "success")
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (add "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★"))))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")
;; Reload cache and refresh display to update favorite count
(load-favorites-cache-mini)
(update-mini-now-playing))))
(catch (lambda (error)
(ps:chain console (error "Error adding favorite:" error)))))))))))
@ -604,6 +650,9 @@
(defun init-persistent-player ()
(let ((audio-element (ps:chain document (get-element-by-id "persistent-audio"))))
(when audio-element
;; Load user's favorites for highlight feature
(load-favorites-cache-mini)
;; Try to enable low-latency mode if supported
(when (ps:@ navigator media-session)
(setf (ps:@ navigator media-session metadata)

View File

@ -163,11 +163,19 @@
(sort (copy-list *playlist-schedule*) #'< :key #'car))
(defun get-available-playlists ()
"Get list of available playlist files from the playlists directory."
(let ((playlists-dir (get-playlists-directory)))
(when (probe-file playlists-dir)
(mapcar #'file-namestring
(directory (merge-pathnames "*.m3u" playlists-dir))))))
"Get list of available playlist files from the playlists directory and user-submissions."
(let ((playlists-dir (get-playlists-directory))
(submissions-dir (merge-pathnames "user-submissions/" (get-playlists-directory))))
(append
;; Main playlists directory
(when (probe-file playlists-dir)
(mapcar #'file-namestring
(directory (merge-pathnames "*.m3u" playlists-dir))))
;; User submissions directory (prefixed with user-submissions/)
(when (probe-file submissions-dir)
(mapcar (lambda (path)
(format nil "user-submissions/~a" (file-namestring path)))
(directory (merge-pathnames "*.m3u" submissions-dir)))))))
(defun get-server-time-info ()
"Get current server time information in both UTC and local timezone."

View File

@ -0,0 +1,25 @@
#EXTM3U
#PLAYLIST:glenneth
#PHASE:Zero Gravity
#CURATOR:admin
#EXTINF:-1,Kiasmos - 65
/home/fade/Media/Music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/01. Kiasmos - 65.flac
#EXTINF:-1,Kiasmos - Walled
/home/fade/Media/Music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/02. Kiasmos - Walled.flac
#EXTINF:-1,Kiasmos - Bound
/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 05 Bound.flac
#EXTINF:-1,Kiasmos - Burst
/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 02 Burst.flac
#EXTINF:-1,Kiasmos - Dazed
/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 10 Dazed.flac
#EXTINF:-1,Kiasmos - Held
/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/02 - Held.flac
#EXTINF:-1,Kiasmos - Dragged
/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/06 - Dragged.flac
#EXTINF:-1,Kiasmos - Lit
/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/01 - Lit.flac
#EXTINF:-1,Kiasmos - Looped
/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/03 - Looped.flac
#EXTINF:-1,Kiasmos - Burnt [Lubomyr Melnyl Rework]
/home/fade/Media/Music/Kiasmos/2015 - Looped/03 Burnt (Lubomyr Melnyl Rework).flac

View File

@ -1842,6 +1842,136 @@ body.popout-body .status-mini{
font-style: italic;
}
.request-item-admin{
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 15px;
margin-bottom: 10px;
background: rgba(0, 255, 0, 0.05);
border: 1px solid #333;
border-radius: 8px;
}
.request-item-admin .request-info{
flex: 1;
}
.request-item-admin .request-info strong{
color: #00cc00;
font-size: 1.1em;
display: block;
margin-bottom: 5px;
}
.request-item-admin .request-info .request-user{
color: #888;
font-size: 0.9em;
display: block;
margin-bottom: 5px;
}
.request-item-admin .request-info .request-message{
color: #aaa;
font-style: italic;
margin: 8px 0;
}
.request-item-admin .request-info .request-time{
color: #666;
font-size: 0.8em;
}
.request-item-admin .request-actions{
display: flex;
gap: 8px;
margin-left: 15px;
}
.my-request-item{
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid #333;
border-radius: 6px;
}
.my-request-item .request-title{
color: #ddd;
flex: 1;
}
.my-request-item .request-status{
margin-left: 15px;
}
.status-badge{
padding: 4px 10px;
border-radius: 12px;
font-size: 0.85em;
text-transform: capitalize;
}
.favorite-count{
color: #ff6699;
font-size: 0.9em;
margin: 5px 0;
}
.favorite-count-mini{
color: #ff6699;
font-size: 0.85em;
margin-left: 8px;
}
.status-pending{
background: rgba(255, 200, 0, 0.2);
color: #ffcc00;
}
.status-approved{
background: rgba(0, 255, 0, 0.2);
color: #00ff00;
}
.status-rejected{
background: rgba(255, 0, 0, 0.2);
color: #ff6666;
}
.status-played{
background: rgba(0, 200, 255, 0.2);
color: #00ccff;
}
.btn-tab{
background: transparent;
border: 1px solid #444;
color: #888;
padding: 8px 16px;
margin-right: 5px;
cursor: pointer;
-moz-transition: all 0.2s;
-o-transition: all 0.2s;
-webkit-transition: all 0.2s;
-ms-transition: all 0.2s;
transition: all 0.2s;
}
.btn-tab:hover{
border-color: #00cc00;
color: #00cc00;
}
.btn-tab.active{
background: rgba(0, 255, 0, 0.1);
border-color: #00cc00;
color: #00cc00;
}
.activity-chart{
padding: 15px;
}
@ -1911,4 +2041,300 @@ body.popout-body .status-mini{
font-style: italic;
text-align: center;
padding: 20px;
}
.section-description{
color: #888;
margin-bottom: 15px;
font-size: 0.9em;
}
.playlist-actions{
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.playlists-list{
display: flex;
flex-direction: column;
gap: 10px;
}
.playlist-item{
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid #333;
border-radius: 4px;
}
.playlist-info{
display: flex;
flex-direction: column;
gap: 4px;
}
.playlist-name{
font-weight: bold;
color: #00cc00;
}
.playlist-meta{
font-size: 0.85em;
color: #888;
}
.playlist-actions{
display: flex;
align-items: center;
gap: 10px;
}
.modal{
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content{
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
padding: 25px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
}
.modal-large{
max-width: 800px;
}
.modal-close{
position: absolute;
top: 10px;
right: 15px;
font-size: 24px;
color: #888;
cursor: pointer;
-moz-transition: color 0.2s;
-o-transition: color 0.2s;
-webkit-transition: color 0.2s;
-ms-transition: color 0.2s;
transition: color 0.2s;
}
.modal-close:hover{
color: #00cc00;
}
.library-controls{
display: flex;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.library-controls input{
flex: 1;
min-width: 200px;
padding: 8px 12px;
background: #222;
border: 1px solid #444;
color: #fff;
border-radius: 4px;
}
.library-controls select{
padding: 8px 12px;
background: #222;
border: 1px solid #444;
color: #fff;
border-radius: 4px;
}
.library-tracks-list{
max-height: 400px;
overflow-y: auto;
margin-bottom: 15px;
}
.library-track-item{
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #333;
}
.library-track-item:hover{
background: rgba(0, 255, 0, 0.05);
}
.library-track-item .track-info{
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.library-track-item .track-title{
color: #fff;
font-weight: bold;
}
.library-track-item .track-artist{
color: #00cc00;
font-size: 0.9em;
}
.library-track-item .track-album{
color: #666;
font-size: 0.85em;
}
.library-pagination{
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
}
.playlist-edit-header{
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.playlist-edit-header input{
padding: 10px;
background: #222;
border: 1px solid #444;
color: #fff;
border-radius: 4px;
font-size: 1.1em;
}
.playlist-edit-header textarea{
padding: 10px;
background: #222;
border: 1px solid #444;
color: #fff;
border-radius: 4px;
resize: vertical;
}
.playlist-tracks-container{
margin-bottom: 20px;
}
.playlist-tracks-sortable{
max-height: 300px;
overflow-y: auto;
border: 1px solid #333;
border-radius: 4px;
padding: 10px;
}
.playlist-track-item{
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
margin-bottom: 5px;
}
.playlist-track-item .track-number{
color: #666;
min-width: 25px;
}
.playlist-track-item .track-title{
flex: 1;
color: #fff;
}
.playlist-track-item .track-artist{
color: #00cc00;
font-size: 0.9em;
}
.playlist-track-item .track-controls{
display: flex;
gap: 5px;
}
.btn-tiny{
padding: 2px 6px;
font-size: 0.8em;
background: transparent;
border: 1px solid #444;
color: #888;
cursor: pointer;
border-radius: 3px;
}
.btn-tiny:hover{
border-color: #00cc00;
color: #00cc00;
}
.btn-tiny:disabled{
opacity: 0.3;
cursor: not-allowed;
}
.btn-danger{
border-color: #cc0000;
color: #cc0000;
}
.btn-danger:hover{
border-color: #ff0000;
color: #ff0000;
background: rgba(255, 0, 0, 0.1);
}
.playlist-edit-actions{
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.empty-message{
color: #666;
font-style: italic;
text-align: center;
padding: 20px;
}
.status-draft{
border-left: 3px solid #888;
}
.status-pending{
border-left: 3px solid #ffcc00;
}
.status-approved{
border-left: 3px solid #00cc00;
}
.status-rejected{
border-left: 3px solid #cc0000;
}

View File

@ -1475,6 +1475,116 @@
:color "#666"
:font-style "italic")
;; Admin request items
(.request-item-admin
:display "flex"
:justify-content "space-between"
:align-items "flex-start"
:padding "15px"
:margin-bottom "10px"
:background "rgba(0, 255, 0, 0.05)"
:border "1px solid #333"
:border-radius "8px"
(.request-info
:flex "1"
(strong
:color "#00cc00"
:font-size "1.1em"
:display "block"
:margin-bottom "5px")
(.request-user
:color "#888"
:font-size "0.9em"
:display "block"
:margin-bottom "5px")
(.request-message
:color "#aaa"
:font-style "italic"
:margin "8px 0")
(.request-time
:color "#666"
:font-size "0.8em"))
(.request-actions
:display "flex"
:gap "8px"
:margin-left "15px"))
;; User's request items on profile
(.my-request-item
:display "flex"
:justify-content "space-between"
:align-items "center"
:padding "12px 15px"
:margin-bottom "8px"
:background "rgba(255, 255, 255, 0.05)"
:border "1px solid #333"
:border-radius "6px"
(.request-title
:color "#ddd"
:flex "1")
(.request-status
:margin-left "15px"))
(.status-badge
:padding "4px 10px"
:border-radius "12px"
:font-size "0.85em"
:text-transform "capitalize")
;; Favorite count display
(.favorite-count
:color "#ff6699"
:font-size "0.9em"
:margin "5px 0")
(.favorite-count-mini
:color "#ff6699"
:font-size "0.85em"
:margin-left "8px")
(.status-pending
:background "rgba(255, 200, 0, 0.2)"
:color "#ffcc00")
(.status-approved
:background "rgba(0, 255, 0, 0.2)"
:color "#00ff00")
(.status-rejected
:background "rgba(255, 0, 0, 0.2)"
:color "#ff6666")
(.status-played
:background "rgba(0, 200, 255, 0.2)"
:color "#00ccff")
;; Tab buttons
(".btn-tab"
:background "transparent"
:border "1px solid #444"
:color "#888"
:padding "8px 16px"
:margin-right "5px"
:cursor "pointer"
:transition "all 0.2s")
(".btn-tab:hover"
:border-color "#00cc00"
:color "#00cc00")
(".btn-tab.active"
:background "rgba(0, 255, 0, 0.1)"
:border-color "#00cc00"
:color "#00cc00")
;; Activity chart styling
(.activity-chart
:padding "15px"
@ -1532,4 +1642,256 @@
:font-style "italic"
:text-align "center"
:padding "20px"))
;; User Playlists styling
(.section-description
:color "#888"
:margin-bottom "15px"
:font-size "0.9em")
(.playlist-actions
:display "flex"
:gap "10px"
:margin-bottom "15px")
(.playlists-list
:display "flex"
:flex-direction "column"
:gap "10px")
(.playlist-item
:display "flex"
:justify-content "space-between"
:align-items "center"
:padding "12px 15px"
:background "rgba(0, 0, 0, 0.3)"
:border "1px solid #333"
:border-radius "4px")
(.playlist-info
:display "flex"
:flex-direction "column"
:gap "4px")
(.playlist-name
:font-weight "bold"
:color "#00cc00")
(.playlist-meta
:font-size "0.85em"
:color "#888")
(".playlist-actions"
:display "flex"
:align-items "center"
:gap "10px")
;; Modal styling
(.modal
:position "fixed"
:top "0"
:left "0"
:width "100%"
:height "100%"
:background "rgba(0, 0, 0, 0.8)"
:display "flex"
:justify-content "center"
:align-items "center"
:z-index "1000")
(.modal-content
:background "#1a1a1a"
:border "1px solid #333"
:border-radius "8px"
:padding "25px"
:max-width "500px"
:width "90%"
:max-height "80vh"
:overflow-y "auto"
:position "relative")
(.modal-large
:max-width "800px")
(.modal-close
:position "absolute"
:top "10px"
:right "15px"
:font-size "24px"
:color "#888"
:cursor "pointer"
:transition "color 0.2s")
((:and .modal-close :hover)
:color "#00cc00")
;; Library browser styling
(.library-controls
:display "flex"
:gap "10px"
:margin-bottom "15px"
:flex-wrap "wrap")
(".library-controls input"
:flex "1"
:min-width "200px"
:padding "8px 12px"
:background "#222"
:border "1px solid #444"
:color "#fff"
:border-radius "4px")
(".library-controls select"
:padding "8px 12px"
:background "#222"
:border "1px solid #444"
:color "#fff"
:border-radius "4px")
(.library-tracks-list
:max-height "400px"
:overflow-y "auto"
:margin-bottom "15px")
(.library-track-item
:display "flex"
:justify-content "space-between"
:align-items "center"
:padding "10px"
:border-bottom "1px solid #333")
((:and .library-track-item :hover)
:background "rgba(0, 255, 0, 0.05)")
(".library-track-item .track-info"
:display "flex"
:flex-direction "column"
:gap "2px"
:flex "1")
(".library-track-item .track-title"
:color "#fff"
:font-weight "bold")
(".library-track-item .track-artist"
:color "#00cc00"
:font-size "0.9em")
(".library-track-item .track-album"
:color "#666"
:font-size "0.85em")
(.library-pagination
:display "flex"
:justify-content "center"
:align-items "center"
:gap "15px")
;; Playlist edit modal styling
(.playlist-edit-header
:display "flex"
:flex-direction "column"
:gap "10px"
:margin-bottom "20px")
(".playlist-edit-header input"
:padding "10px"
:background "#222"
:border "1px solid #444"
:color "#fff"
:border-radius "4px"
:font-size "1.1em")
(".playlist-edit-header textarea"
:padding "10px"
:background "#222"
:border "1px solid #444"
:color "#fff"
:border-radius "4px"
:resize "vertical")
(.playlist-tracks-container
:margin-bottom "20px")
(.playlist-tracks-sortable
:max-height "300px"
:overflow-y "auto"
:border "1px solid #333"
:border-radius "4px"
:padding "10px")
(.playlist-track-item
:display "flex"
:align-items "center"
:gap "10px"
:padding "8px"
:background "rgba(0, 0, 0, 0.2)"
:border-radius "4px"
:margin-bottom "5px")
(".playlist-track-item .track-number"
:color "#666"
:min-width "25px")
(".playlist-track-item .track-title"
:flex "1"
:color "#fff")
(".playlist-track-item .track-artist"
:color "#00cc00"
:font-size "0.9em")
(".playlist-track-item .track-controls"
:display "flex"
:gap "5px")
(.btn-tiny
:padding "2px 6px"
:font-size "0.8em"
:background "transparent"
:border "1px solid #444"
:color "#888"
:cursor "pointer"
:border-radius "3px")
((:and .btn-tiny :hover)
:border-color "#00cc00"
:color "#00cc00")
((:and .btn-tiny :disabled)
:opacity "0.3"
:cursor "not-allowed")
(.btn-danger
:border-color "#cc0000"
:color "#cc0000")
((:and .btn-danger :hover)
:border-color "#ff0000"
:color "#ff0000"
:background "rgba(255, 0, 0, 0.1)")
(.playlist-edit-actions
:display "flex"
:gap "10px"
:flex-wrap "wrap")
(.empty-message
:color "#666"
:font-style "italic"
:text-align "center"
:padding "20px")
;; Status badges for playlists
(.status-draft
:border-left "3px solid #888")
(.status-pending
:border-left "3px solid #ffcc00")
(.status-approved
:border-left "3px solid #00cc00")
(.status-rejected
:border-left "3px solid #cc0000")
) ;; End of let block

View File

@ -102,6 +102,22 @@
</div>
</div>
<!-- Track Requests -->
<div class="admin-section">
<h2>🎵 Track Requests</h2>
<div class="request-tabs" style="margin-bottom: 15px;">
<button class="btn btn-tab active" id="tab-pending" onclick="showRequestTab('pending')">⏳ Pending</button>
<button class="btn btn-tab" id="tab-approved" onclick="showRequestTab('approved')">✓ Approved</button>
<button class="btn btn-tab" id="tab-rejected" onclick="showRequestTab('rejected')">✗ Rejected</button>
<button class="btn btn-tab" id="tab-played" onclick="showRequestTab('played')">🎵 Played</button>
<button class="btn btn-secondary" onclick="refreshTrackRequests()" style="margin-left: 15px;">🔄 Refresh</button>
<span id="requests-status" style="margin-left: 15px;"></span>
</div>
<div id="pending-requests-container">
<p class="loading">Loading requests...</p>
</div>
</div>
<!-- Music Library Management -->
<div class="admin-section">
<h2>Music Library Management</h2>
@ -346,6 +362,15 @@
</p>
</div>
<!-- User Playlist Review -->
<div class="admin-section">
<h2>📋 User Playlist Submissions</h2>
<p>Review and approve user-submitted playlists. Approved playlists will be available for scheduling.</p>
<div id="user-playlists-container">
<p class="loading-message">Loading submissions...</p>
</div>
</div>
<!-- User Management -->
<div class="admin-section">
<div class="card">

View File

@ -36,6 +36,7 @@
</audio>
<span class="now-playing-mini" id="mini-now-playing">Loading...</span>
<span class="favorite-count-mini" id="favorite-count-mini"></span>
<button class="btn-favorite-mini" id="favorite-btn-mini" onclick="toggleFavoriteMini()" title="Add to favorites">
<span class="star-icon">☆</span>

View File

@ -92,6 +92,24 @@
<p class="loading">Loading...</p>
</div>
</div>
<!-- Track Request Section -->
<div class="request-panel">
<h3>🎵 Request a Track</h3>
<p class="request-description">Want to hear something specific? Submit a request and an admin will review it.</p>
<div class="request-form">
<input type="text" id="request-title" class="request-input" placeholder="Suggest a track, artist, or album...">
<input type="text" id="request-message" class="request-input" placeholder="Why do you want to hear this? (optional)">
<button class="btn btn-primary" onclick="submitTrackRequest()">Submit Request</button>
</div>
<div id="request-status" class="request-status" style="display: none;"></div>
<div class="recent-requests">
<h4>Recently Played Requests</h4>
<div id="recent-requests-list">
<p class="no-requests">Loading...</p>
</div>
</div>
</div>
</main>
<footer class="site-footer">

View File

@ -131,8 +131,8 @@
<h3>🎵 Request a Track</h3>
<p class="request-description">Want to hear something specific? Submit a request!</p>
<div class="request-form">
<input type="text" id="request-title" placeholder="Artist - Track Title" class="request-input">
<input type="text" id="request-message" placeholder="Optional message (e.g., 'for my late night coding session')" class="request-input">
<input type="text" id="request-title" placeholder="Suggest a track, artist, or album..." class="request-input">
<input type="text" id="request-message" placeholder="Why do you want to hear this? (optional)" class="request-input">
<button onclick="submitTrackRequest()" class="btn btn-primary">Submit Request</button>
</div>
<div id="request-status" class="request-status" style="display: none;"></div>

View File

@ -11,6 +11,8 @@
<p>Listeners: <span lquery="(text listeners)">1</span></p>
</c:using>
<input type="hidden" id="current-track-id" lquery="(val track-id)" value="">
<input type="hidden" id="favorite-count-value" lquery="(val favorite-count)" value="0">
<p class="favorite-count" id="favorite-count-display"></p>
</c:then>
<c:else>
<c:if test="connection-error">

View File

@ -75,6 +75,27 @@
</div>
</div>
<!-- My Track Requests -->
<div class="admin-section">
<h2>🎵 My Track Requests</h2>
<div id="my-requests-list" class="requests-list">
<p class="loading-message">Loading your requests...</p>
</div>
</div>
<!-- My Playlists -->
<div class="admin-section">
<h2>📝 My Playlists</h2>
<p class="section-description">Create custom playlists from the music library and submit them for station airplay!</p>
<div class="playlist-actions">
<button class="btn btn-primary" onclick="showCreatePlaylistModal()"> Create New Playlist</button>
<button class="btn btn-secondary" onclick="showLibraryBrowser()">🎵 Browse Library</button>
</div>
<div id="my-playlists-list" class="playlists-list">
<p class="loading-message">Loading your playlists...</p>
</div>
</div>
<!-- Favorite Tracks -->
<div class="admin-section">
<h2>❤️ Favorite Tracks</h2>
@ -158,6 +179,76 @@
</div>
</div>
<!-- Create Playlist Modal -->
<div id="create-playlist-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="modal-close" onclick="hideCreatePlaylistModal()">&times;</span>
<h2>Create New Playlist</h2>
<form id="create-playlist-form" onsubmit="return createPlaylist(event)">
<div class="form-group">
<label for="playlist-name">Playlist Name:</label>
<input type="text" id="playlist-name" name="name" required maxlength="100" placeholder="My Awesome Playlist">
</div>
<div class="form-group">
<label for="playlist-description">Description (optional):</label>
<textarea id="playlist-description" name="description" maxlength="500" rows="3" placeholder="Describe your playlist..."></textarea>
</div>
<div id="create-playlist-message" class="message"></div>
<button type="submit" class="btn btn-primary">Create Playlist</button>
</form>
</div>
</div>
<!-- Library Browser Modal -->
<div id="library-browser-modal" class="modal" style="display: none; z-index: 1100;">
<div class="modal-content modal-large">
<span class="modal-close" onclick="hideLibraryBrowser()">&times;</span>
<h2>🎵 Music Library</h2>
<div class="library-controls">
<input type="text" id="library-search" placeholder="Search tracks..." onkeyup="searchLibrary()">
<select id="library-artist-filter" onchange="filterByArtist()">
<option value="">All Artists</option>
</select>
<select id="add-to-playlist-select">
<option value="">Select playlist to add to...</option>
</select>
</div>
<div id="library-tracks" class="library-tracks-list">
<p class="loading-message">Loading library...</p>
</div>
<div class="library-pagination">
<button class="btn btn-secondary" onclick="prevLibraryPage()" id="lib-prev-btn" disabled>← Previous</button>
<span id="library-page-info">Page 1</span>
<button class="btn btn-secondary" onclick="nextLibraryPage()" id="lib-next-btn">Next →</button>
</div>
</div>
</div>
<!-- Edit Playlist Modal -->
<div id="edit-playlist-modal" class="modal" style="display: none;">
<div class="modal-content modal-large">
<span class="modal-close" onclick="hideEditPlaylistModal()">&times;</span>
<h2 id="edit-playlist-title">Edit Playlist</h2>
<div class="playlist-edit-header">
<input type="text" id="edit-playlist-name" placeholder="Playlist name">
<textarea id="edit-playlist-description" placeholder="Description..." rows="2"></textarea>
<button class="btn btn-secondary" onclick="savePlaylistMetadata()">Save Details</button>
</div>
<div class="playlist-tracks-container">
<h3>Tracks in Playlist</h3>
<div id="playlist-tracks-list" class="playlist-tracks-sortable">
<p class="empty-message">No tracks yet. Browse the library to add tracks!</p>
</div>
</div>
<div class="playlist-edit-actions">
<button class="btn btn-secondary" onclick="showLibraryBrowserForPlaylist()"> Add Tracks</button>
<button class="btn btn-primary" onclick="submitPlaylistForReview()" id="submit-playlist-btn">📤 Submit for Review</button>
<button class="btn btn-danger" onclick="deleteCurrentPlaylist()">🗑️ Delete Playlist</button>
</div>
<input type="hidden" id="current-edit-playlist-id" value="">
</div>
</div>
<!-- Initialization handled by profile.js -->
</body>
</html>

View File

@ -49,6 +49,18 @@
LIMIT ~a" user-id limit))
:alists)))
(defun get-requests-by-status (status &key (limit 50))
"Get requests by status with user info"
(with-db
(postmodern:query
(:raw (format nil "SELECT r._id, r.track_title, r.track_path, r.message, r.status, r.\"created-at\", u.username
FROM track_requests r
JOIN \"USERS\" u ON r.\"user-id\" = u._id
WHERE r.status = '~a'
ORDER BY r.\"created-at\" DESC
LIMIT ~a" status limit))
:alists)))
(defun get-recent-played-requests (&key (limit 10))
"Get recently played requests with user attribution"
(with-db
@ -168,6 +180,21 @@
("created_at" . ,(cdr (assoc :created-at r)))))
requests)))))))
(define-api asteroid/admin/requests/list (&optional (status "pending")) ()
"Get requests by status (pending, approved, rejected, played)"
(require-role :admin)
(with-error-handling
(let ((requests (get-requests-by-status status)))
(api-output `(("status" . "success")
("requests" . ,(mapcar (lambda (r)
`(("id" . ,(cdr (assoc :_id r)))
("title" . ,(cdr (assoc :track-title r)))
("path" . ,(cdr (assoc :track-path r)))
("message" . ,(cdr (assoc :message r)))
("username" . ,(cdr (assoc :username r)))
("created_at" . ,(cdr (assoc :created-at r)))))
requests)))))))
(define-api asteroid/admin/requests/approved () ()
"Get approved requests ready to queue"
(require-role :admin)

View File

@ -156,6 +156,10 @@
(format t "Error getting current user: ~a~%" e)
nil)))
(defun get-current-user-id ()
"Get the currently authenticated user's ID from session"
(session:field "user-id"))
(defun require-authentication (&key (api nil))
"Require user to be authenticated.
Returns T if authenticated, NIL if not (after emitting error response).

421
user-playlists.lisp Normal file
View File

@ -0,0 +1,421 @@
(in-package :asteroid)
;;; ==========================================================================
;;; User Playlists - Custom playlist creation and submission
;;; ==========================================================================
;;; Status values: "draft", "submitted", "approved", "rejected", "scheduled"
;; Helper to get value from Postmodern alist (keys are uppercase symbols)
(defun aget (key alist)
"Get value from alist using string-equal comparison for key"
(cdr (assoc key alist :test (lambda (a b) (string-equal (string a) (string b))))))
(defun get-user-playlists (user-id &optional status)
"Get all playlists for a user, optionally filtered by status"
(with-db
(if status
(postmodern:query
(:order-by
(:select '* :from 'user_playlists
:where (:and (:= 'user-id user-id)
(:= 'status status)))
(:desc 'created-date))
:alists)
(postmodern:query
(:order-by
(:select '* :from 'user_playlists
:where (:= 'user-id user-id))
(:desc 'created-date))
:alists))))
(defun get-user-playlist-by-id (playlist-id)
"Get a single playlist by ID"
(with-db
(first (postmodern:query
(:select '* :from 'user_playlists
:where (:= '_id playlist-id))
:alists))))
(defun create-user-playlist (user-id name description)
"Create a new user playlist"
(with-db
(postmodern:query
(:insert-into 'user_playlists
:set 'user-id user-id
'name name
'description (or description "")
'track-ids "[]"
'status "draft"
'created-date (:raw "EXTRACT(EPOCH FROM NOW())::INTEGER"))
:none)
;; Return the created playlist
(first (postmodern:query
(:order-by
(:select '* :from 'user_playlists
:where (:= 'user-id user-id))
(:desc '_id))
:alists))))
(defun update-user-playlist-tracks (playlist-id track-ids-json)
"Update the track list for a playlist"
(with-db
(postmodern:query
(:update 'user_playlists
:set 'track-ids track-ids-json
:where (:= '_id playlist-id))
:none)))
(defun update-user-playlist-metadata (playlist-id name description)
"Update playlist name and description"
(with-db
(postmodern:query
(:update 'user_playlists
:set 'name name
'description description
:where (:= '_id playlist-id))
:none)))
(defun submit-user-playlist (playlist-id)
"Submit a playlist for admin review"
(with-db
(postmodern:query
(:update 'user_playlists
:set 'status "submitted"
'submitted-date (:raw "EXTRACT(EPOCH FROM NOW())::INTEGER")
:where (:= '_id playlist-id))
:none)))
(defun get-submitted-playlists ()
"Get all submitted playlists awaiting review (admin)"
(with-db
(postmodern:query
(:order-by
(:select 'p.* 'u.username
:from (:as 'user_playlists 'p)
:left-join (:as (:raw "\"USERS\"") 'u) :on (:= 'p.user-id 'u._id)
:where (:= 'p.status "submitted"))
(:asc 'p.submitted-date))
:alists)))
(defun review-user-playlist (playlist-id admin-id status notes)
"Approve or reject a submitted playlist"
(with-db
(postmodern:query
(:update 'user_playlists
:set 'status status
'reviewed-date (:raw "EXTRACT(EPOCH FROM NOW())::INTEGER")
'reviewed-by admin-id
'review-notes (or notes "")
:where (:= '_id playlist-id))
:none)))
(defun generate-user-playlist-m3u (playlist-id)
"Generate M3U file content for a user playlist"
(let* ((playlist (get-user-playlist-by-id playlist-id))
(track-ids-json (aget "TRACK-IDS" playlist))
(track-ids (when (and track-ids-json (not (string= track-ids-json "[]")))
(cl-json:decode-json-from-string track-ids-json)))
(name (aget "NAME" playlist))
(description (aget "DESCRIPTION" playlist))
(user-id (aget "USER-ID" playlist))
(username (get-username-by-id user-id)))
(with-output-to-string (out)
(format out "#EXTM3U~%")
(format out "#PLAYLIST:~a~%" name)
;; Use description as the phase name if provided, otherwise use playlist name
(format out "#PHASE:~a~%" (if (and description (not (string= description "")))
description
name))
(format out "#CURATOR:~a~%" (or username "Anonymous"))
(format out "~%")
;; Add tracks
(dolist (track-id track-ids)
(let ((track (get-track-by-id track-id)))
(when track
(let* ((title (dm:field track "title"))
(artist (dm:field track "artist"))
(file-path (dm:field track "file-path"))
(docker-path (convert-to-docker-path file-path)))
(format out "#EXTINF:-1,~a - ~a~%" artist title)
(format out "~a~%" docker-path))))))))
(defun save-user-playlist-m3u (playlist-id)
"Save user playlist as M3U file in playlists/user-submissions/"
(let* ((playlist (get-user-playlist-by-id playlist-id))
(name (aget "NAME" playlist))
(user-id (aget "USER-ID" playlist))
(username (get-username-by-id user-id))
(safe-name (cl-ppcre:regex-replace-all "[^a-zA-Z0-9-_]" name "-"))
(filename (format nil "~a-~a-~a.m3u" username safe-name playlist-id))
(submissions-dir (merge-pathnames "playlists/user-submissions/"
(asdf:system-source-directory :asteroid)))
(filepath (merge-pathnames filename submissions-dir)))
;; Ensure directory exists
(ensure-directories-exist submissions-dir)
;; Write M3U file
(with-open-file (out filepath :direction :output
:if-exists :supersede
:if-does-not-exist :create)
(write-string (generate-user-playlist-m3u playlist-id) out))
filename))
(defun get-username-by-id (user-id)
"Get username for a user ID"
(with-db
(postmodern:query
(:select 'username :from (:raw "\"USERS\"") :where (:= '_id user-id))
:single)))
(defun delete-user-playlist (playlist-id user-id)
"Delete a user playlist (only if owned by user and in draft status)"
(with-db
(postmodern:query
(:delete-from 'user_playlists
:where (:and (:= '_id playlist-id)
(:= 'user-id user-id)
(:= 'status "draft")))
:none)))
;;; ==========================================================================
;;; API Endpoints
;;; ==========================================================================
(define-api asteroid/library/browse (&optional search artist album page) ()
"Browse the music library - available to all authenticated users"
(require-authentication)
(with-error-handling
(let* ((page-num (or (and page (parse-integer page :junk-allowed t)) 1))
(per-page 50)
(offset (* (1- page-num) per-page))
(tracks (with-db
(cond
;; Search by text
(search
(let ((search-pattern (format nil "%~a%" search)))
(postmodern:query
(:raw (format nil "SELECT * FROM tracks WHERE title ILIKE $1 OR artist ILIKE $1 OR album ILIKE $1 ORDER BY artist, album, title LIMIT ~a OFFSET ~a"
per-page offset))
search-pattern
:alists)))
;; Filter by artist
(artist
(postmodern:query
(:raw (format nil "SELECT * FROM tracks WHERE artist = $1 ORDER BY album, title LIMIT ~a OFFSET ~a"
per-page offset))
artist
:alists))
;; Filter by album
(album
(postmodern:query
(:raw (format nil "SELECT * FROM tracks WHERE album = $1 ORDER BY title LIMIT ~a OFFSET ~a"
per-page offset))
album
:alists))
;; All tracks
(t
(postmodern:query
(:raw (format nil "SELECT * FROM tracks ORDER BY artist, album, title LIMIT ~a OFFSET ~a"
per-page offset))
:alists)))))
;; Get unique artists for filtering
(artists (with-db
(postmodern:query
(:order-by
(:select (:distinct 'artist) :from 'tracks)
'artist)
:column)))
;; Get total count
(total-count (with-db
(postmodern:query
(:select (:count '*) :from 'tracks)
:single))))
(api-output `(("status" . "success")
("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(aget "-ID" track))
("title" . ,(aget "TITLE" track))
("artist" . ,(aget "ARTIST" track))
("album" . ,(aget "ALBUM" track))
("duration" . ,(aget "DURATION" track))
("format" . ,(aget "FORMAT" track))))
tracks))
("artists" . ,artists)
("page" . ,page-num)
("per-page" . ,per-page)
("total" . ,total-count))))))
(define-api asteroid/user/playlists () ()
"Get current user's playlists"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlists (get-user-playlists user-id)))
(api-output `(("status" . "success")
("playlists" . ,(mapcar (lambda (pl)
`(("id" . ,(aget "-ID" pl))
("name" . ,(aget "NAME" pl))
("description" . ,(aget "DESCRIPTION" pl))
("track-count" . ,(let ((ids (aget "TRACK-IDS" pl)))
(if (and ids (not (string= ids "[]")))
(length (cl-json:decode-json-from-string ids))
0)))
("status" . ,(aget "STATUS" pl))
("created-date" . ,(aget "CREATED-DATE" pl))))
playlists)))))))
(define-api asteroid/user/playlists/get (id) ()
"Get a specific playlist with full track details"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlist-id (when (and id (not (string= id "null"))) (parse-integer id :junk-allowed t)))
(playlist (when playlist-id (get-user-playlist-by-id playlist-id))))
(if (and playlist (= (aget "USER-ID" playlist) user-id))
(let* ((track-ids-json (aget "TRACK-IDS" playlist))
(track-ids (when (and track-ids-json (not (string= track-ids-json "[]")))
(cl-json:decode-json-from-string track-ids-json)))
;; Filter out null values from track-ids
(valid-track-ids (remove-if #'null track-ids))
(tracks (mapcar (lambda (tid)
(when (and tid (integerp tid))
(let ((track (get-track-by-id tid)))
(when track
`(("id" . ,tid)
("title" . ,(dm:field track "title"))
("artist" . ,(dm:field track "artist"))
("album" . ,(dm:field track "album")))))))
valid-track-ids)))
(api-output `(("status" . "success")
("playlist" . (("id" . ,(aget "-ID" playlist))
("name" . ,(aget "NAME" playlist))
("description" . ,(aget "DESCRIPTION" playlist))
("status" . ,(aget "STATUS" playlist))
("tracks" . ,(remove nil tracks)))))))
(api-output `(("status" . "error")
("message" . "Playlist not found"))
:status 404)))))
(define-api asteroid/user/playlists/create (name &optional description) ()
"Create a new playlist"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlist (create-user-playlist user-id name description)))
(api-output `(("status" . "success")
("message" . "Playlist created")
("playlist" . (("id" . ,(aget "-ID" playlist))
("name" . ,(aget "NAME" playlist)))))))))
(define-api asteroid/user/playlists/update (id &optional name description tracks) ()
"Update a playlist (name, description, or tracks)"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlist-id (parse-integer id :junk-allowed t))
(playlist (get-user-playlist-by-id playlist-id)))
(if (and playlist
(= (aget "USER-ID" playlist) user-id)
(string= (aget "STATUS" playlist) "draft"))
(progn
(when (or name description)
(update-user-playlist-metadata playlist-id
(or name (aget "NAME" playlist))
(or description (aget "DESCRIPTION" playlist))))
(when tracks
(update-user-playlist-tracks playlist-id tracks))
(api-output `(("status" . "success")
("message" . "Playlist updated"))))
(api-output `(("status" . "error")
("message" . "Cannot update playlist (not found, not owned, or already submitted)"))
:status 400)))))
(define-api asteroid/user/playlists/submit (id) ()
"Submit a playlist for admin review"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlist-id (parse-integer id :junk-allowed t))
(playlist (get-user-playlist-by-id playlist-id)))
(if (and playlist
(= (aget "USER-ID" playlist) user-id)
(string= (aget "STATUS" playlist) "draft"))
(let ((track-ids-json (aget "TRACK-IDS" playlist)))
(if (and track-ids-json
(not (string= track-ids-json "[]"))
(> (length (cl-json:decode-json-from-string track-ids-json)) 0))
(progn
(submit-user-playlist playlist-id)
;; Generate M3U file
(let ((filename (save-user-playlist-m3u playlist-id)))
(api-output `(("status" . "success")
("message" . "Playlist submitted for review")
("filename" . ,filename)))))
(api-output `(("status" . "error")
("message" . "Cannot submit empty playlist"))
:status 400)))
(api-output `(("status" . "error")
("message" . "Cannot submit playlist"))
:status 400)))))
(define-api asteroid/user/playlists/delete (id) ()
"Delete a draft playlist"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlist-id (parse-integer id :junk-allowed t)))
(delete-user-playlist playlist-id user-id)
(api-output `(("status" . "success")
("message" . "Playlist deleted"))))))
;;; Admin endpoints for reviewing user playlists
(define-api asteroid/admin/user-playlists () ()
"Get all submitted playlists awaiting review"
(require-role :admin)
(with-error-handling
(let ((playlists (get-submitted-playlists)))
(api-output `(("status" . "success")
("playlists" . ,(mapcar (lambda (pl)
(let* ((track-ids-json (aget "TRACK-IDS" pl))
(track-count (if (and track-ids-json
(stringp track-ids-json)
(not (string= track-ids-json "[]")))
(length (cl-json:decode-json-from-string track-ids-json))
0)))
`(("id" . ,(aget "-ID" pl))
("name" . ,(aget "NAME" pl))
("description" . ,(aget "DESCRIPTION" pl))
("username" . ,(aget "USERNAME" pl))
("trackCount" . ,track-count)
("submittedDate" . ,(aget "SUBMITTED-DATE" pl)))))
playlists)))))))
(define-api asteroid/admin/user-playlists/review (id action &optional notes) ()
"Approve or reject a submitted playlist"
(require-role :admin)
(with-error-handling
(let* ((admin-id (get-current-user-id))
(playlist-id (parse-integer id :junk-allowed t))
(new-status (cond ((string= action "approve") "approved")
((string= action "reject") "rejected")
(t nil))))
(if new-status
(progn
(review-user-playlist playlist-id admin-id new-status notes)
;; Generate/regenerate M3U file when approving
(when (string= action "approve")
(save-user-playlist-m3u playlist-id))
(api-output `(("status" . "success")
("message" . ,(format nil "Playlist ~a" new-status)))))
(api-output `(("status" . "error")
("message" . "Invalid action (use 'approve' or 'reject')"))
:status 400)))))
(define-api asteroid/admin/user-playlists/preview (id) ()
"Preview M3U content for a submitted playlist"
(require-role :admin)
(with-error-handling
(let* ((playlist-id (parse-integer id :junk-allowed t))
(m3u-content (generate-user-playlist-m3u playlist-id)))
(api-output `(("status" . "success")
("m3u" . ,m3u-content))))))

View File

@ -69,6 +69,21 @@
(:raw (format nil "SELECT COUNT(*) FROM user_favorites WHERE \"user-id\" = ~a" user-id))
:single)))
(defun get-track-favorite-count (track-title)
"Get count of how many users have favorited a track by title"
(if (and track-title (not (string= track-title "")))
(handler-case
(with-db
(let* ((escaped-title (sql-escape-string track-title))
(result (postmodern:query
(:raw (format nil "SELECT COUNT(*) FROM user_favorites WHERE track_title = '~a'" escaped-title))
:single)))
(or result 0)))
(error (e)
(declare (ignore e))
0))
0))
;;; ==========================================================================
;;; Listening History - Per-user track play history
;;; ==========================================================================