feat: Add track favorites feature with star button

- Add user_favorites and listening_history database tables
- Add migration 005-user-favorites-history.sql
- Create user-profile.lisp with favorites/history API endpoints
- Add star button (☆/★) to Now Playing on main page
- Add star button to frame player bar
- Add Favorites section to profile page
- Show login prompt when unauthenticated user clicks star
- Use gold color (#ffcc00) for favorited state (space theme)
- Fix require-authentication to properly detect API routes
- Support title-based favorites (no track DB required)
This commit is contained in:
glenneth 2025-12-21 08:15:52 +03:00
parent 349fa31d8f
commit 5225a07b8b
15 changed files with 873 additions and 164 deletions

View File

@ -63,6 +63,7 @@
(:file "stream-control") (:file "stream-control")
(:file "playlist-scheduler") (:file "playlist-scheduler")
(:file "listener-stats") (:file "listener-stats")
(:file "user-profile")
(:file "auth-routes") (:file "auth-routes")
(:file "frontend-partials") (:file "frontend-partials")
(:file "asteroid"))) (:file "asteroid")))

View File

@ -51,6 +51,19 @@
(position :integer) (position :integer)
(added_date :integer)))) (added_date :integer))))
(unless (db:collection-exists-p "user_favorites")
(db:create "user_favorites" '((user-id :integer)
(track-id :integer)
(rating :integer)
(created-date :integer))))
(unless (db:collection-exists-p "listening_history")
(db:create "listening_history" '((user-id :integer)
(track-id :integer)
(listened-at :integer)
(listen-duration :integer)
(completed :integer))))
;; TODO: the radiance db interface is too basic to contain anything ;; TODO: the radiance db interface is too basic to contain anything
;; but strings, integers, booleans, and maybe timestamps... we will ;; but strings, integers, booleans, and maybe timestamps... we will
;; need to rethink this. currently track/playlist relationships are ;; need to rethink this. currently track/playlist relationships are

View File

@ -1,5 +1,23 @@
(in-package :asteroid) (in-package :asteroid)
(defun find-track-by-title (title)
"Find a track in the database by its title. Returns track ID or nil."
(when (and title (not (string= title "Unknown")))
(handler-case
(with-db
(let* ((search-pattern (format nil "%~a%" title))
(result (postmodern:query
(:limit
(:select '_id
:from 'tracks
:where (:ilike 'title search-pattern))
1)
:single)))
result))
(error (e)
(declare (ignore e))
nil))))
(defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3")) (defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3"))
"Fetch now-playing information from Icecast server. "Fetch now-playing information from Icecast server.
@ -54,7 +72,8 @@
`((:listenurl . ,(format nil "~a/~a" *stream-base-url* mount)) `((:listenurl . ,(format nil "~a/~a" *stream-base-url* mount))
(:title . ,title) (:title . ,title)
(:listeners . ,total-listeners))))))) (:listeners . ,total-listeners)
(:track-id . ,(find-track-by-title title))))))))
(define-api asteroid/partial/now-playing (&optional mount) () (define-api asteroid/partial/now-playing (&optional mount) ()
"Get Partial HTML with live status from Icecast server. "Get Partial HTML with live status from Icecast server.
@ -75,7 +94,8 @@
(setf (header "Content-Type") "text/html") (setf (header "Content-Type") "text/html")
(clip:process-to-string (clip:process-to-string
(load-template "partial/now-playing") (load-template "partial/now-playing")
:stats now-playing-stats)) :stats now-playing-stats
:track-id (cdr (assoc :track-id now-playing-stats))))
(progn (progn
(setf (header "Content-Type") "text/html") (setf (header "Content-Type") "text/html")
(clip:process-to-string (clip:process-to-string
@ -97,6 +117,21 @@
(setf (header "Content-Type") "text/plain") (setf (header "Content-Type") "text/plain")
"Stream Offline"))))) "Stream Offline")))))
(define-api asteroid/partial/now-playing-json (&optional mount) ()
"Get JSON with now playing info including track ID for favorites.
Optional MOUNT parameter specifies which stream to get metadata from."
(with-error-handling
(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)))))
(api-output `(("status" . "offline")
("title" . "Stream Offline")
("track_id" . nil)))))))
(define-api asteroid/channel-name () () (define-api asteroid/channel-name () ()
"Get the current curated channel name for live updates. "Get the current curated channel name for live updates.
Returns JSON with the channel name from the current playlist's PHASE header." Returns JSON with the channel name from the current playlist's PHASE header."

View File

@ -0,0 +1,44 @@
-- Migration 005: User Favorites and Listening History
-- Adds tables for track favorites/ratings and per-user listening history
-- User favorites table - tracks that users have liked/rated
CREATE TABLE IF NOT EXISTS user_favorites (
_id SERIAL PRIMARY KEY,
"user-id" INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE,
"track-id" INTEGER NOT NULL REFERENCES tracks(_id) ON DELETE CASCADE,
rating INTEGER DEFAULT 1 CHECK (rating >= 1 AND rating <= 5),
"created-date" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE("user-id", "track-id")
);
-- Create indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites("user-id");
CREATE INDEX IF NOT EXISTS idx_user_favorites_track_id ON user_favorites("track-id");
CREATE INDEX IF NOT EXISTS idx_user_favorites_rating ON user_favorites(rating);
-- User listening history - per-user track play history
CREATE TABLE IF NOT EXISTS listening_history (
_id SERIAL PRIMARY KEY,
"user-id" INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE,
"track-id" INTEGER NOT NULL REFERENCES tracks(_id) ON DELETE CASCADE,
"listened-at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"listen-duration" INTEGER DEFAULT 0, -- seconds listened
"completed" BOOLEAN DEFAULT false -- did they listen to the whole track?
);
-- Create indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_listening_history_user_id ON listening_history("user-id");
CREATE INDEX IF NOT EXISTS idx_listening_history_track_id ON listening_history("track-id");
CREATE INDEX IF NOT EXISTS idx_listening_history_listened_at ON listening_history("listened-at");
-- Grant permissions
GRANT ALL PRIVILEGES ON user_favorites TO asteroid;
GRANT ALL PRIVILEGES ON listening_history TO asteroid;
GRANT ALL PRIVILEGES ON SEQUENCE user_favorites__id_seq TO asteroid;
GRANT ALL PRIVILEGES ON SEQUENCE listening_history__id_seq TO asteroid;
-- Verification
DO $$
BEGIN
RAISE NOTICE 'Migration 005: User favorites and listening history tables created successfully!';
END $$;

View File

@ -592,6 +592,56 @@
(redirect-when-frame))))) (redirect-when-frame)))))
;; Toggle favorite for current track
(defun toggle-favorite ()
(let ((track-id-el (ps:chain document (get-element-by-id "current-track-id")))
(title-el (ps:chain document (get-element-by-id "current-track-title")))
(btn (ps:chain document (get-element-by-id "favorite-btn"))))
(let ((track-id (when track-id-el (ps:@ track-id-el value)))
(title (when title-el (ps:@ title-el text-content)))
(is-favorited (ps:chain btn class-list (contains "favorited"))))
;; Need either track-id or title
(when (or (and track-id (not (= track-id ""))) title)
(let ((params (+ "title=" (encode-u-r-i-component (or title ""))
(if (and track-id (not (= track-id "")))
(+ "&track-id=" track-id)
""))))
(if is-favorited
;; Remove favorite
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/remove?" params)
(ps:create :method "POST"))
(then (lambda (response)
(cond
((not (ps:@ response ok))
(alert "Please log in to manage favorites")
nil)
(t (ps:chain response (json))))))
(then (lambda (data)
(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) "☆"))))
(catch (lambda (error)
(ps:chain console (error "Error removing favorite:" error)))))
;; Add favorite
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/add?" params)
(ps:create :method "POST"))
(then (lambda (response)
(cond
((not (ps:@ response ok))
(alert "Please log in to save favorites")
nil)
(t (ps:chain response (json))))))
(then (lambda (data)
(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) "★"))))
(catch (lambda (error)
(ps:chain console (error "Error adding favorite:" error)))))))))))
;; Update now playing every 5 seconds ;; Update now playing every 5 seconds
(set-interval update-now-playing 5000) (set-interval update-now-playing 5000)

View File

@ -167,6 +167,66 @@
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error loading top artists:" error)))))) (ps:chain console (error "Error loading top artists:" error))))))
(defvar *favorites-offset* 0)
(defun load-favorites ()
(ps:chain
(fetch "/api/asteroid/user/favorites")
(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 "favorites-list"))))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data favorites)
(> (ps:@ data favorites length) 0))
(progn
(setf (ps:@ container inner-h-t-m-l) "")
(ps:chain data favorites
(for-each (lambda (fav)
(let ((item (ps:chain document (create-element "div"))))
(setf (ps:@ item class-name) "track-item favorite-item")
(setf (ps:@ item inner-h-t-m-l)
(+ "<div class=\"track-info\">"
"<span class=\"track-title\">" (or (ps:@ fav title) "Unknown") "</span>"
"<span class=\"track-artist\">" (or (ps:@ fav artist) "") "</span>"
"</div>"
"<div class=\"track-meta\">"
"<span class=\"rating\">" (render-stars (or (ps:@ fav rating) 1)) "</span>"
"<button class=\"btn btn-small btn-danger\" onclick=\"removeFavorite(" (ps:@ fav track_id) ")\">Remove</button>"
"</div>"))
(ps:chain container (append-child item)))))))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No favorites yet. Like tracks while listening!</p>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading favorites:" error))
(let ((container (ps:chain document (get-element-by-id "favorites-list"))))
(when container
(setf (ps:@ container inner-h-t-m-l) "<p class=\"error\">Failed to load favorites</p>")))))))
(defun render-stars (rating)
(let ((stars ""))
(dotimes (i 5)
(setf stars (+ stars (if (< i rating) "★" "☆"))))
stars))
(defun remove-favorite (track-id)
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/remove?track-id=" track-id)
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (data)
(if (= (ps:@ data status) "success")
(progn
(show-message "Removed from favorites" "success")
(load-favorites))
(show-message "Failed to remove favorite" "error"))))
(catch (lambda (error)
(ps:chain console (error "Error removing favorite:" error))
(show-message "Error removing favorite" "error")))))
(defun load-more-favorites ()
(show-message "Loading more favorites..." "info"))
(defun load-profile-data () (defun load-profile-data ()
(ps:chain console (log "Loading profile data...")) (ps:chain console (log "Loading profile data..."))
@ -188,6 +248,7 @@
(load-listening-stats) (load-listening-stats)
(load-recent-tracks) (load-recent-tracks)
(load-favorites)
(load-top-artists)) (load-top-artists))
;; Action functions ;; Action functions

View File

@ -202,18 +202,74 @@
(defun update-mini-now-playing () (defun update-mini-now-playing ()
(let ((mount (get-current-mount))) (let ((mount (get-current-mount)))
(ps:chain (ps:chain
(fetch (+ "/api/asteroid/partial/now-playing-inline?mount=" mount)) (fetch (+ "/api/asteroid/partial/now-playing-json?mount=" mount))
(then (lambda (response) (then (lambda (response)
(if (ps:@ response ok) (if (ps:@ response ok)
(ps:chain response (text)) (ps:chain response (json))
""))) nil)))
(then (lambda (text) (then (lambda (data)
(let ((el (ps:chain document (get-element-by-id "mini-now-playing")))) (when data
(when el (let ((el (ps:chain document (get-element-by-id "mini-now-playing")))
(setf (ps:@ el text-content) text))))) (track-id-el (ps:chain document (get-element-by-id "current-track-id-mini")))
(title (or (ps:@ data data title) (ps:@ data title) "Loading...")))
(when el
(setf (ps:@ el text-content) title))
(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 ""))))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (log "Could not fetch now playing:" error))))))) (ps:chain console (log "Could not fetch now playing:" error)))))))
;; Toggle favorite for mini player
(defun toggle-favorite-mini ()
(let ((track-id-el (ps:chain document (get-element-by-id "current-track-id-mini")))
(title-el (ps:chain document (get-element-by-id "mini-now-playing")))
(btn (ps:chain document (get-element-by-id "favorite-btn-mini"))))
(let ((track-id (when track-id-el (ps:@ track-id-el value)))
(title (when title-el (ps:@ title-el text-content)))
(is-favorited (ps:chain btn class-list (contains "favorited"))))
;; Need either track-id or title
(when (or (and track-id (not (= track-id ""))) title)
(let ((params (+ "title=" (encode-u-r-i-component (or title ""))
(if (and track-id (not (= track-id "")))
(+ "&track-id=" track-id)
""))))
(if is-favorited
;; Remove favorite
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/remove?" params)
(ps:create :method "POST"))
(then (lambda (response)
(cond
((not (ps:@ response ok))
(alert "Please log in to manage favorites")
nil)
(t (ps:chain response (json))))))
(then (lambda (data)
(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) "☆"))))
(catch (lambda (error)
(ps:chain console (error "Error removing favorite:" error)))))
;; Add favorite
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/add?" params)
(ps:create :method "POST"))
(then (lambda (response)
(cond
((not (ps:@ response ok))
(alert "Please log in to save favorites")
nil)
(t (ps:chain response (json))))))
(then (lambda (data)
(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) "★"))))
(catch (lambda (error)
(ps:chain console (error "Error adding favorite:" error)))))))))))
;; Update popout now playing display (parses artist - title) ;; Update popout now playing display (parses artist - title)
(defun update-popout-now-playing () (defun update-popout-now-playing ()
(let ((mount (get-current-mount))) (let ((mount (get-current-mount)))
@ -641,6 +697,7 @@
(setf (ps:@ window init-popout-player) init-popout-player) (setf (ps:@ window init-popout-player) init-popout-player)
(setf (ps:@ window update-mini-now-playing) update-mini-now-playing) (setf (ps:@ window update-mini-now-playing) update-mini-now-playing)
(setf (ps:@ window update-popout-now-playing) update-popout-now-playing) (setf (ps:@ window update-popout-now-playing) update-popout-now-playing)
(setf (ps:@ window toggle-favorite-mini) toggle-favorite-mini)
;; Auto-initialize on DOMContentLoaded based on which elements exist ;; Auto-initialize on DOMContentLoaded based on which elements exist
(ps:chain document (ps:chain document

View File

@ -1,144 +1,83 @@
#EXTM3U #EXTM3U
#PLAYLIST:Escape Velocity - A Christmas Journey Through Space #PLAYLIST:Midnight Ambient
#PHASE:Escape Velocity #PHASE:Midnight Ambient
#DURATION:12 hours (approx) #DURATION:6 hours (approx)
#CURATOR:Asteroid Radio #CURATOR:Asteroid Radio
#DESCRIPTION:A festive 12-hour voyage blending Christmas classics with ambient, IDM, and space music for the holiday season #DESCRIPTION:Deep, dark ambient for the late night hours (00:00-06:00)
# === PHASE 1: WINTER AWAKENING (Ambient Beginnings) === #EXTINF:-1,Biosphere - The Petrified Forest
#EXTINF:-1,Brian Eno - Snow /app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/04. Biosphere - The Petrified Forest.flac
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac #EXTINF:-1,Labradford - S
#EXTINF:-1,Brian Eno - Wintergreen /app/music/Labradford/1997 - Mi Media Naranja/1 S.flac
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/04 Wintergreen.flac #EXTINF:-1,Tim Hecker - Seasick
#EXTINF:-1,Proem - Winter Wolves /app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 01 Seasick.flac
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac #EXTINF:-1,Pye Corner Audio - Hollow Earth
#EXTINF:-1,Tim Hecker - Winter's Coming /app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/01 - Hollow Earth.mp3
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 10 Winter's Coming.flac
#EXTINF:-1,Biosphere - Drifter
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac
#EXTINF:-1,Dead Voices On Air - On Winters Gibbet
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/02 - On Winters Gibbet.flac
#EXTINF:-1,Color Therapy - Wintering
/app/music/Color Therapy - Mr. Wolf Is Dead (2015) WEB FLAC/12 - Wintering.flac
# === PHASE 2: CHRISTMAS ARRIVAL (TSO Introduction) ===
#EXTINF:-1,Trans-Siberian Orchestra - The Ghosts Of Christmas Eve
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/01 The Ghosts Of Christmas Eve.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas In The Air
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/14 Christmas In The Air.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Canon
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/08 Christmas Canon.flac
#EXTINF:-1,Trans-Siberian Orchestra - Appalachian Snowfall
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/11 Appalachian Snowfall.flac
#EXTINF:-1,Trans-Siberian Orchestra - The Snow Came Down
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/13 The Snow Came Down.flac
# === PHASE 3: AMBIENT INTERLUDE (Space & Atmosphere) ===
#EXTINF:-1,Biosphere - 10 Snurp 1937
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 10 Snurp 1937.flac
#EXTINF:-1,Biosphere - 05 Fluvialmorphologie
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 05 Fluvialmorphologie.flac
#EXTINF:-1,God is an Astronaut - Winter Dusk-Awakening
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/03. Winter Dusk-Awakening.flac
#EXTINF:-1,Proem - Snow Drifts
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/11 - Snow Drifts.flac
#EXTINF:-1,Proem - Stick to Music Snowflake
/app/music/Proem - Until Here for Years (n5md, 2019) flac/04 - Stick to Music Snowflake.flac
#EXTINF:-1,Four Tet - 04 Tremper
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/04 Tremper.flac
# === PHASE 4: CHRISTMAS EVE STORIES ===
#EXTINF:-1,Trans-Siberian Orchestra - First Snow (Instrumental)
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/04 First Snow (Instrumental).flac
#EXTINF:-1,Trans-Siberian Orchestra - The Silent Nutcracker (Instrumental)
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/05 The Silent Nutcracker (Instrumental).flac
#EXTINF:-1,Trans-Siberian Orchestra - A Mad Russian's Christmas (Instrumental)
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/06 A Mad Russian's Christmas (Instrumental).flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Eve,Sarajevo 12,24 (Instrumental)
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/08 Christmas Eve,Sarajevo 12,24 (Instrumental).flac
#EXTINF:-1,Trans-Siberian Orchestra - This Christmas Day
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/14 This Christmas Day.flac
# === PHASE 5: ELECTRONIC DREAMS (IDM & Ambient) ===
#EXTINF:-1,Autechre - NTS Session 1-005-Autechre-carefree counter dronal
/app/music/Autechre - 2018 - NTS Session 1/NTS Session 1-005-Autechre-carefree counter dronal.flac
#EXTINF:-1,Clark - Living Fantasy
/app/music/Clark - Death Peak (2017) [FLAC]/08 - Living Fantasy.flac
#EXTINF:-1,Clark - My Machines (Clark Remix)
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.17. Battles - My Machines (Clark Remix).flac
#EXTINF:-1,Plaid - Dancers
/app/music/Plaid - Polymer (2019) [WEB FLAC]/07 - Dancers.flac
#EXTINF:-1,Faux Tales - Avalon
/app/music/Faux Tales - 2015 - Kairos [FLAC] {Kensai Records KNS006 WEB}/3 - Avalon.flac
#EXTINF:-1,Color Therapy - Expect Delays (feat. Ulrich Schnauss)
/app/music/Color Therapy - Mr. Wolf Is Dead (2015) WEB FLAC/11 - Expect Delays (feat. Ulrich Schnauss).flac
# === PHASE 6: THE LOST CHRISTMAS EVE ===
#EXTINF:-1,Trans-Siberian Orchestra - The Lost Christmas Eve
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/02 The Lost Christmas Eve.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Dreams
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/03 Christmas Dreams.flac
#EXTINF:-1,Trans-Siberian Orchestra - Wizards in Winter
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/04 Wizards in Winter.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Concerto
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/07 Christmas Concerto.flac
#EXTINF:-1,Trans-Siberian Orchestra - Queen Of The Winter Night
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/08 Queen Of The Winter Night.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Nights In Blue
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/09 Christmas Nights In Blue.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Jazz
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/10 Christmas Jazz.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Jam
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/11 Christmas Jam.flac
# === PHASE 7: CLASSICAL WINTER (Nutcracker & More) ===
#EXTINF:-1,Various Artists - Dance of the Sugar-Plum Fairy
/app/music/Various Artists - The 50 Darkest Pieces of Classical Music (2011) - FLAC/CD 1/02 - Tchaikovsky - The Nutcracker - Dance of the Sugar-Plum Fairy.flac
#EXTINF:-1,Quaeschning and Ulrich Schnauss - Thirst
/app/music/Quaeschning and Ulrich Schnauss - Synthwaves (2017) {vista003, GER, CD} [FLAC]/06 - Thirst.flac
#EXTINF:-1,Proem - 04. Drawing Room Anguish
/app/music/Proem - 2018 Modern Rope (WEB)/04. Drawing Room Anguish.flac
#EXTINF:-1,Dead Voices On Air - 07. Dogger Doorlopende Split
/app/music/Dead Voices On Air - Frankie Pett En De Onderzeer Boten (2017) web/07. Dogger Doorlopende Split.flac
# === PHASE 8: WISDOM & REFLECTION ===
#EXTINF:-1,Trans-Siberian Orchestra - What Is Christmas
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/13 What Is Christmas.flac
#EXTINF:-1,Trans-Siberian Orchestra - The Wisdom Of Snow
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/15 The Wisdom Of Snow.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Bells, Carousels & Time
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/18 Christmas Bells, Carousels & Time.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Canon Rock
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/21 Christmas Canon Rock.flac
#EXTINF:-1,Trans-Siberian Orchestra - Midnight Christmas Eve
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/05 Midnight Christmas Eve.flac
#EXTINF:-1,Trans-Siberian Orchestra - Dream Child (A Christmas Dream)
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/15 Dream Child (A Christmas Dream).flac
# === PHASE 9: DEEP SPACE JOURNEY (Extended Ambient) ===
#EXTINF:-1,Dead Voices On Air - Red Howls #EXTINF:-1,Dead Voices On Air - Red Howls
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/01 - Red Howls.flac /app/music/Dead Voices On Air - Ghohst Stories (FLAC)/01 - Red Howls.flac
#EXTINF:-1,Cut Copy - Airborne #EXTINF:-1,Tangerine Dream - The Seventh Propellor of Silence
/app/music/Cut Copy - Haiku From Zero (2017) [FLAC] {2557864014}/05 - Airborne.flac /app/music/Tangerine Dream - Ambient Monkeys (flac)/03 Tangerine Dream - The Seventh Propellor of Silence.flac
#EXTINF:-1,Owl City - 01 Hot Air Balloon #EXTINF:-1,Biosphere - Fall Asleep For Me
/app/music/Owl City - Ocean Eyes (Deluxe Edition) [Flac,Cue,Logs]/Disc 2/01 Hot Air Balloon.flac /app/music/Biosphere - Departed Glories (2016) - FLAC WEB/17 - Fall Asleep For Me.flac
#EXTINF:-1,VA - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit] #EXTINF:-1,Locrian - Arc of Extinction
/app/music/VA - Melodic Vocal Trance 2017/24. Airborn, Bogdan Vix & KeyPlayer - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit].flac /app/music/Locrian - Infinite Dissolution (2015) - WEB FLAC/01 - Arc of Extinction.flac
#EXTINF:-1,VA - Winter Took Over (Radio Edit) #EXTINF:-1,FSOL - Polarize
/app/music/VA - Melodic Vocal Trance 2017/22. Bluskay, KeyPlayer & Esmee Bor Stotijn - Winter Took Over (Radio Edit).flac /app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/01 - Polarize.flac
#EXTINF:-1,Alison Krauss and Union Station - My Opening Farewell #EXTINF:-1,Marconi Union - Sleeper
/app/music/Alison Krauss and Union Station - Paper Airplane (flac)/11 - Alison Krauss & Union Station - My Opening Farewell.flac /app/music/Marconi Union - Ghost Stations (2016 - WEB - FLAC)/01. Marconi Union - Sleeper.flac
#EXTINF:-1,Bedouin Soundclash - Money Worries (E-Clair Refix) #EXTINF:-1,Labradford - G
/app/music/Bedouin Soundclash - Sounding a Mosaic (2004) [FLAC] {SD1267}/14 - Money Worries (E-Clair Refix).flac /app/music/Labradford/1997 - Mi Media Naranja/2 G.flac
#EXTINF:-1,Biosphere - This Is The End
# === PHASE 10: RETURN TO WINTER (Closing Circle) === /app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/06. Biosphere - This Is The End.flac
#EXTINF:-1,Brian Eno - Snow #EXTINF:-1,Pye Corner Audio - Descent
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac /app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/02 - Descent.mp3
#EXTINF:-1,Proem - Winter Wolves #EXTINF:-1,Brian Eno - Reflection
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac /app/music/Brian Eno/2017 - Reflection/01. Reflection.mp3
#EXTINF:-1,God is an Astronaut - Winter Dusk-Awakening #EXTINF:-1,Dead Voices On Air - On Winters Gibbet
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/03. Winter Dusk-Awakening.flac /app/music/Dead Voices On Air - Ghohst Stories (FLAC)/02 - On Winters Gibbet.flac
#EXTINF:-1,Trans-Siberian Orchestra - The Snow Came Down #EXTINF:-1,Tim Hecker - Left On The Ice
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/13 The Snow Came Down.flac /app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 05 Left On The Ice.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas In The Air #EXTINF:-1,Autechre - Further
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/14 Christmas In The Air.flac /app/music/Autechre/1994 - Amber/08 Further.flac
#EXTINF:-1,Tangerine Dream - Moon Marble
/app/music/Tangerine Dream - Ambient Monkeys (flac)/06 Tangerine Dream - Moon Marble.flac
#EXTINF:-1,Biosphere - Sweet Dreams Form A Shade
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/07 - Sweet Dreams Form A Shade.flac
#EXTINF:-1,Locrian - Dark Shales
/app/music/Locrian - Infinite Dissolution (2015) - WEB FLAC/02 - Dark Shales.flac
#EXTINF:-1,FSOL - Forest Soundbed
/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/06 - Forest Soundbed.flac
#EXTINF:-1,Pye Corner Audio - Subterranean Lakes
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/09 - Subterranean Lakes.mp3
#EXTINF:-1,Labradford - WR
/app/music/Labradford/1997 - Mi Media Naranja/3 WR.flac
#EXTINF:-1,Bark Psychosis - Hex
/app/music/Bark Psychosis/1994 - Hex/01 The Loom.flac
#EXTINF:-1,Biosphere - Turned To Stone
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/03. Biosphere - Turned To Stone.flac
#EXTINF:-1,Tim Hecker - Winter's Coming
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 10 Winter's Coming.flac
#EXTINF:-1,Dead Voices On Air - On Wicca Way
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/03 - On Wicca Way.flac
#EXTINF:-1,Marconi Union - Abandoned - In Silence
/app/music/Marconi Union - Ghost Stations (2016 - WEB - FLAC)/03. Marconi Union - Abandoned - In Silence.flac
#EXTINF:-1,Pye Corner Audio - Deeper Dreaming
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/13 - Deeper Dreaming.mp3
#EXTINF:-1,Tangerine Dream - Myopia World
/app/music/Tangerine Dream - Ambient Monkeys (flac)/12 Tangerine Dream - Myopia World.flac
#EXTINF:-1,Biosphere - Departed Glories
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/09 - Departed Glories.flac
#EXTINF:-1,Locrian - The Future of Death
/app/music/Locrian - Infinite Dissolution (2015) - WEB FLAC/04 - The Future of Death.flac
#EXTINF:-1,Labradford - V
/app/music/Labradford/1997 - Mi Media Naranja/6 V.flac
#EXTINF:-1,FSOL - Solace
/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/23 - Solace.flac
#EXTINF:-1,Pye Corner Audio - Buried Memories
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/12 - Buried Memories.mp3
#EXTINF:-1,Tim Hecker - Twinkle In The Wasteland
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 14 Twinkle In The Wasteland.flac
#EXTINF:-1,Locrian - Heavy Water
/app/music/Locrian - Infinite Dissolution (2015) - WEB FLAC/08 - Heavy Water.flac
#EXTINF:-1,Tape Loop Orchestra - 1953 Culture Festival
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/01 - 1953 Culture Festival.flac

View File

@ -1545,4 +1545,160 @@ body.popout-body .status-mini{
100%{ 100%{
opacity: 1; opacity: 1;
} }
}
.site-footer{
text-align: center;
padding: 20px 0;
margin-top: 30px;
border-top: 1px solid #333;
font-size: 0.85em;
color: #666;
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.site-footer a{
color: #888;
text-decoration: none;
-moz-transition: color 0.2s ease;
-o-transition: color 0.2s ease;
-webkit-transition: color 0.2s ease;
-ms-transition: color 0.2s ease;
transition: color 0.2s ease;
}
.site-footer a:hover{
color: #00ff00;
}
.site-footer .craftering a{
margin: 0 5px;
}
.now-playing-track{
display: flex;
align-items: center;
gap: 10px;
}
.now-playing-track p{
margin: 0;
}
.btn-favorite{
background: transparent;
border: none;
cursor: pointer;
padding: 5px 10px;
font-size: 1.4em;
-moz-transition: transform 0.2s ease, color 0.2s ease;
-o-transition: transform 0.2s ease, color 0.2s ease;
-webkit-transition: transform 0.2s ease, color 0.2s ease;
-ms-transition: transform 0.2s ease, color 0.2s ease;
transition: transform 0.2s ease, color 0.2s ease;
}
.btn-favorite .star-icon{
color: #888;
}
.btn-favorite:hover{
-moz-transform: scale(1.2);
-o-transform: scale(1.2);
-webkit-transform: scale(1.2);
-ms-transform: scale(1.2);
transform: scale(1.2);
}
.btn-favorite:hover .star-icon{
color: #ffcc00;
}
.btn-favorite.favorited .star-icon{
color: #ffcc00;
}
.btn-favorite-mini{
background: transparent;
border: none;
cursor: pointer;
padding: 2px 8px;
font-size: 1.3em;
-moz-transition: transform 0.2s ease, color 0.2s ease;
-o-transition: transform 0.2s ease, color 0.2s ease;
-webkit-transition: transform 0.2s ease, color 0.2s ease;
-ms-transition: transform 0.2s ease, color 0.2s ease;
transition: transform 0.2s ease, color 0.2s ease;
margin-left: 8px;
}
.btn-favorite-mini .star-icon{
color: #00cc00;
}
.btn-favorite-mini:hover{
-moz-transform: scale(1.3);
-o-transform: scale(1.3);
-webkit-transform: scale(1.3);
-ms-transform: scale(1.3);
transform: scale(1.3);
}
.btn-favorite-mini:hover .star-icon{
color: #ffcc00;
}
.btn-favorite-mini.favorited .star-icon{
color: #ffcc00;
}
.favorites-list{
margin: 10px 0;
}
.favorites-list .favorite-item{
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
margin: 5px 0;
background: rgba(0, 255, 0, 0.05);
border: 1px solid #333;
border-radius: 4px;
-moz-transition: background 0.2s ease;
-o-transition: background 0.2s ease;
-webkit-transition: background 0.2s ease;
-ms-transition: background 0.2s ease;
transition: background 0.2s ease;
}
.favorites-list .favorite-item:hover{
background: rgba(0, 255, 0, 0.1);
}
.favorites-list .rating{
color: #ffcc00;
font-size: 1.1em;
margin-right: 10px;
}
.favorites-list .no-data{
color: #666;
font-style: italic;
text-align: center;
padding: 20px;
}
.favorites-list .btn-small{
padding: 4px 8px;
font-size: 0.8em;
} }

View File

@ -1274,4 +1274,89 @@
(.craftering (.craftering
(a :margin "0 5px"))) (a :margin "0 5px")))
;; Now playing favorite button
(.now-playing-track
:display "flex"
:align-items "center"
:gap "10px"
(p :margin 0))
(.btn-favorite
:background "transparent"
:border "none"
:cursor "pointer"
:padding "5px 10px"
:font-size "1.4em"
:transition "transform 0.2s ease, color 0.2s ease"
(.star-icon
:color "#888"))
((:and .btn-favorite :hover)
:transform "scale(1.2)"
(.star-icon
:color "#ffcc00"))
((:and .btn-favorite .favorited)
(.star-icon
:color "#ffcc00"))
;; Mini favorite button for frame player
(.btn-favorite-mini
:background "transparent"
:border "none"
:cursor "pointer"
:padding "2px 8px"
:font-size "1.3em"
:transition "transform 0.2s ease, color 0.2s ease"
:margin-left "8px"
(.star-icon
:color "#00cc00"))
((:and .btn-favorite-mini :hover)
:transform "scale(1.3)"
(.star-icon
:color "#ffcc00"))
((:and .btn-favorite-mini .favorited)
(.star-icon
:color "#ffcc00"))
;; Favorites list styling
(.favorites-list
:margin "10px 0"
(.favorite-item
:display "flex"
:justify-content "space-between"
:align-items "center"
:padding "10px 15px"
:margin "5px 0"
:background "rgba(0, 255, 0, 0.05)"
:border "1px solid #333"
:border-radius "4px"
:transition "background 0.2s ease")
((:and .favorite-item :hover)
:background "rgba(0, 255, 0, 0.1)")
(.rating
:color "#ffcc00"
:font-size "1.1em"
:margin-right "10px")
(.no-data
:color "#666"
:font-style "italic"
:text-align "center"
:padding "20px")
(.btn-small
:padding "4px 8px"
:font-size "0.8em"))
) ;; End of let block ) ;; End of let block

View File

@ -37,6 +37,11 @@
<span class="now-playing-mini" id="mini-now-playing">Loading...</span> <span class="now-playing-mini" id="mini-now-playing">Loading...</span>
<button class="btn-favorite-mini" id="favorite-btn-mini" onclick="toggleFavoriteMini()" title="Add to favorites">
<span class="star-icon">☆</span>
</button>
<input type="hidden" id="current-track-id-mini" value="">
<button id="reconnect-btn" onclick="reconnectStream()" class="persistent-reconnect-btn" title="Reconnect if audio stops working"> <button id="reconnect-btn" onclick="reconnectStream()" class="persistent-reconnect-btn" title="Reconnect if audio stops working">
<img src="/asteroid/static/icons/sync.png" alt="Reconnect" style="width: 18px; height: 18px; vertical-align: middle; filter: invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%);"> <img src="/asteroid/static/icons/sync.png" alt="Reconnect" style="width: 18px; height: 18px; vertical-align: middle; filter: invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%);">
</button> </button>

View File

@ -2,10 +2,15 @@
<c:if test="stats"> <c:if test="stats">
<c:then> <c:then>
<c:using value="stats"> <c:using value="stats">
<!--<p>Artist: <span>The Void</span></p>--> <div class="now-playing-track">
<p>Track: <span lquery="(text title)">The Void - Silence</span></p> <p>Track: <span lquery="(text title)" id="current-track-title">The Void - Silence</span></p>
<button class="btn-favorite" id="favorite-btn" onclick="toggleFavorite()" title="Add to favorites">
<span class="star-icon">☆</span>
</button>
</div>
<p>Listeners: <span lquery="(text listeners)">1</span></p> <p>Listeners: <span lquery="(text listeners)">1</span></p>
</c:using> </c:using>
<input type="hidden" id="current-track-id" lquery="(val track-id)" value="">
</c:then> </c:then>
<c:else> <c:else>
<c:if test="connection-error"> <c:if test="connection-error">

View File

@ -104,6 +104,17 @@
</div> </div>
</div> </div>
<!-- Favorite Tracks -->
<div class="admin-section">
<h2>❤️ Favorite Tracks</h2>
<div class="favorites-list" id="favorites-list">
<p class="loading-message">Loading favorites...</p>
</div>
<div class="profile-actions">
<button class="btn btn-secondary" onclick="loadMoreFavorites()">Load More</button>
</div>
</div>
<!-- Top Artists --> <!-- Top Artists -->
<div class="admin-section"> <div class="admin-section">
<h2>🎤 Top Artists</h2> <h2>🎤 Top Artists</h2>

View File

@ -164,24 +164,26 @@
(let* ((user-id (session:field "user-id")) (let* ((user-id (session:field "user-id"))
(uri (radiance:path (radiance:uri *request*))) (uri (radiance:path (radiance:uri *request*)))
;; Use explicit flag if provided, otherwise auto-detect from URI ;; Use explicit flag if provided, otherwise auto-detect from URI
(is-api-request (if api t (search "/api/" uri)))) ;; Check for "api/" anywhere in the path
(is-api-request (if api t (or (search "/api/" uri)
(search "api/" uri)))))
(format t "Authentication check - User ID: ~a, URI: ~a, Is API: ~a~%" (format t "Authentication check - User ID: ~a, URI: ~a, Is API: ~a~%"
user-id uri (if is-api-request "YES" "NO")) user-id uri (if is-api-request "YES" "NO"))
(if user-id (if user-id
t ; Authenticated - return T to continue t ; Authenticated - return T to continue
;; Not authenticated - emit error ;; Not authenticated - emit error and signal to stop processing
(if is-api-request (progn
;; API request - emit JSON error and return the value from api-output (if is-api-request
(progn ;; API request - emit JSON error with 401 status
(format t "Authentication failed - returning JSON 401~%") (progn
(radiance:api-output (format t "Authentication failed - returning JSON 401~%")
'(("error" . "Authentication required")) (setf (radiance:return-code *response*) 401)
:status 401 (setf (radiance:content-type *response*) "application/json")
:message "You must be logged in to access this resource")) (error 'radiance:request-denied :message "Authentication required"))
;; Page request - redirect to login (redirect doesn't return) ;; Page request - redirect to login
(progn (progn
(format t "Authentication failed - redirecting to login~%") (format t "Authentication failed - redirecting to login~%")
(radiance:redirect "/login")))))) (radiance:redirect "/login")))))))
(defun require-role (role &key (api nil)) (defun require-role (role &key (api nil))
"Require user to have a specific role. "Require user to have a specific role.

245
user-profile.lisp Normal file
View File

@ -0,0 +1,245 @@
;;;; user-profile.lisp - User profile features: favorites, listening history
;;;; Part of Asteroid Radio
(in-package #:asteroid)
;;; ==========================================================================
;;; User Favorites - Track likes/ratings
;;; ==========================================================================
(defun add-favorite (user-id track-id &optional (rating 1) track-title)
"Add a track to user's favorites with optional rating (1-5).
If track-id is nil but track-title is provided, stores by title."
(let ((rating-val (max 1 (min 5 (or rating 1)))))
(with-db
(if track-id
(postmodern:query
(:raw (format nil "INSERT INTO user_favorites (\"user-id\", \"track-id\", track_title, rating) VALUES (~a, ~a, ~a, ~a)"
user-id track-id
(if track-title (format nil "$$~a$$" track-title) "NULL")
rating-val)))
;; No track-id, store by title only
(when track-title
(postmodern:query
(:raw (format nil "INSERT INTO user_favorites (\"user-id\", track_title, rating) VALUES (~a, $$~a$$, ~a)"
user-id track-title rating-val))))))))
(defun remove-favorite (user-id track-id &optional track-title)
"Remove a track from user's favorites by track-id or title"
(with-db
(if track-id
(postmodern:query
(:raw (format nil "DELETE FROM user_favorites WHERE \"user-id\" = ~a AND \"track-id\" = ~a"
user-id track-id)))
(when track-title
(postmodern:query
(:raw (format nil "DELETE FROM user_favorites WHERE \"user-id\" = ~a AND track_title = $$~a$$"
user-id track-title)))))))
(defun update-favorite-rating (user-id track-id rating)
"Update the rating for a favorited track"
(let ((rating-val (max 1 (min 5 rating))))
(with-db
(postmodern:query
(:update 'user_favorites
:set 'rating rating-val
:where (:and (:= '"user-id" user-id)
(:= '"track-id" track-id)))))))
(defun get-user-favorites (user-id &key (limit 50) (offset 0))
"Get user's favorite tracks - works with both track-id and title-based favorites"
(with-db
(postmodern:query
(:raw (format nil "SELECT _id, rating, \"created-date\", track_title, \"track-id\" FROM user_favorites WHERE \"user-id\" = ~a ORDER BY \"created-date\" DESC LIMIT ~a OFFSET ~a"
user-id limit offset))
:alists)))
(defun is-track-favorited (user-id track-id)
"Check if a track is in user's favorites, returns rating or nil"
(with-db
(postmodern:query
(:raw (format nil "SELECT rating FROM user_favorites WHERE \"user-id\" = ~a AND \"track-id\" = ~a"
user-id track-id))
:single)))
(defun get-favorites-count (user-id)
"Get total count of user's favorites"
(with-db
(postmodern:query
(:raw (format nil "SELECT COUNT(*) FROM user_favorites WHERE \"user-id\" = ~a" user-id))
:single)))
;;; ==========================================================================
;;; Listening History - Per-user track play history
;;; ==========================================================================
(defun record-listen (user-id track-id &key (duration 0) (completed nil))
"Record a track listen in user's history"
(with-db
(postmodern:query
(:insert-into 'listening_history
:set '"user-id" user-id
'"track-id" track-id
'"listened-at" (:current_timestamp)
'"listen-duration" duration
'completed (if completed 1 0)))))
(defun get-listening-history (user-id &key (limit 20) (offset 0))
"Get user's listening history with track details"
(with-db
(postmodern:query
(:limit
(:order-by
(:select 'lh._id 'lh.listened-at 'lh.listen-duration 'lh.completed
't.title 't.artist 't.album 't.duration 't._id
:from (:as 'listening_history 'lh)
:inner-join (:as 'tracks 't) :on (:= 'lh.track-id 't._id)
:where (:= 'lh.user-id user-id))
(:desc 'lh.listened-at))
limit offset)
:alists)))
(defun get-listening-stats (user-id)
"Get aggregate listening statistics for a user"
(with-db
(let ((stats (postmodern:query
(:select (:count '*) (:sum 'listen-duration)
:from 'listening_history
:where (:= '"user-id" user-id))
:row)))
(list :tracks-played (or (first stats) 0)
:total-listen-time (or (second stats) 0)))))
(defun get-top-artists (user-id &key (limit 5))
"Get user's most listened artists"
(with-db
(postmodern:query
(:limit
(:order-by
(:select 't.artist (:as (:count '*) 'play_count)
:from (:as 'listening_history 'lh)
:inner-join (:as 'tracks 't) :on (:= 'lh.track-id 't._id)
:where (:= 'lh.user-id user-id)
:group-by 't.artist)
(:desc 'play_count))
limit)
:alists)))
(defun clear-listening-history (user-id)
"Clear all listening history for a user"
(with-db
(postmodern:query
(:delete-from 'listening_history
:where (:= '"user-id" user-id)))))
;;; ==========================================================================
;;; API Endpoints for User Favorites
;;; ==========================================================================
(define-api asteroid/user/favorites () ()
"Get current user's favorite tracks"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(favorites (get-user-favorites user-id)))
(api-output `(("status" . "success")
("favorites" . ,(mapcar (lambda (fav)
`(("id" . ,(cdr (assoc :_id fav)))
("track_id" . ,(cdr (assoc :track-id fav)))
("title" . ,(or (cdr (assoc :track-title fav))
(cdr (assoc :track_title fav))))
("rating" . ,(cdr (assoc :rating fav)))))
favorites))
("count" . ,(get-favorites-count user-id)))))))
(define-api asteroid/user/favorites/add (&optional track-id rating title) ()
"Add a track to user's favorites. Can use track-id or title."
(require-authentication)
(with-error-handling
(let* ((user-id-raw (session:field "user-id"))
(user-id (if (stringp user-id-raw)
(parse-integer user-id-raw :junk-allowed t)
user-id-raw))
(track-id-int (when (and track-id (not (string= track-id "")))
(parse-integer track-id :junk-allowed t)))
(rating-int (if rating (parse-integer rating :junk-allowed t) 1)))
(format t "Adding favorite: user-id=~a track-id=~a title=~a~%" user-id track-id-int title)
(add-favorite user-id track-id-int (or rating-int 1) title)
(api-output `(("status" . "success")
("message" . "Track added to favorites"))))))
(define-api asteroid/user/favorites/remove (&optional track-id title) ()
"Remove a track from user's favorites by track-id or title"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(track-id-int (when (and track-id (not (string= track-id "")))
(parse-integer track-id :junk-allowed t))))
(remove-favorite user-id track-id-int title)
(api-output `(("status" . "success")
("message" . "Track removed from favorites"))))))
(define-api asteroid/user/favorites/rate (track-id rating) ()
"Update rating for a favorited track"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(track-id-int (parse-integer track-id))
(rating-int (parse-integer rating)))
(update-favorite-rating user-id track-id-int rating-int)
(api-output `(("status" . "success")
("message" . "Rating updated"))))))
(define-api asteroid/user/favorites/check (track-id) ()
"Check if a track is in user's favorites"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(track-id-int (parse-integer track-id))
(rating (is-track-favorited user-id track-id-int)))
(api-output `(("status" . "success")
("favorited" . ,(if rating t nil))
("rating" . ,rating))))))
;;; ==========================================================================
;;; API Endpoints for Listening History
;;; ==========================================================================
(define-api asteroid/user/history () ()
"Get current user's listening history"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(history (get-listening-history user-id)))
(api-output `(("status" . "success")
("history" . ,(mapcar (lambda (h)
`(("id" . ,(cdr (assoc :_id h)))
("track_id" . ,(cdr (assoc :_id h)))
("title" . ,(cdr (assoc :title h)))
("artist" . ,(cdr (assoc :artist h)))
("album" . ,(cdr (assoc :album h)))
("duration" . ,(cdr (assoc :duration h)))
("listened_at" . ,(cdr (assoc :listened-at h)))
("completed" . ,(= 1 (cdr (assoc :completed h))))))
history)))))))
(define-api asteroid/user/history/record (track-id &optional duration completed) ()
"Record a track listen (called by player)"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(track-id-int (parse-integer track-id))
(duration-int (if duration (parse-integer duration) 0))
(completed-bool (and completed (string-equal completed "true"))))
(record-listen user-id track-id-int :duration duration-int :completed completed-bool)
(api-output `(("status" . "success")
("message" . "Listen recorded"))))))
(define-api asteroid/user/history/clear () ()
"Clear user's listening history"
(require-authentication)
(with-error-handling
(let ((user-id (session:field "user-id")))
(clear-listening-history user-id)
(api-output `(("status" . "success")
("message" . "Listening history cleared"))))))