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 committed by Brian O'Reilly
parent 349fa31d8f
commit bfc33c8d4e
15 changed files with 873 additions and 164 deletions

View File

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

View File

@ -51,6 +51,19 @@
(position :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
;; but strings, integers, booleans, and maybe timestamps... we will
;; need to rethink this. currently track/playlist relationships are

View File

@ -1,5 +1,23 @@
(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"))
"Fetch now-playing information from Icecast server.
@ -54,7 +72,8 @@
`((:listenurl . ,(format nil "~a/~a" *stream-base-url* mount))
(:title . ,title)
(:listeners . ,total-listeners)))))))
(:listeners . ,total-listeners)
(:track-id . ,(find-track-by-title title))))))))
(define-api asteroid/partial/now-playing (&optional mount) ()
"Get Partial HTML with live status from Icecast server.
@ -75,7 +94,8 @@
(setf (header "Content-Type") "text/html")
(clip:process-to-string
(load-template "partial/now-playing")
:stats now-playing-stats))
:stats now-playing-stats
:track-id (cdr (assoc :track-id now-playing-stats))))
(progn
(setf (header "Content-Type") "text/html")
(clip:process-to-string
@ -97,6 +117,21 @@
(setf (header "Content-Type") "text/plain")
"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 () ()
"Get the current curated channel name for live updates.
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)))))
;; 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
(set-interval update-now-playing 5000)

View File

@ -167,6 +167,66 @@
(catch (lambda (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 ()
(ps:chain console (log "Loading profile data..."))
@ -188,6 +248,7 @@
(load-listening-stats)
(load-recent-tracks)
(load-favorites)
(load-top-artists))
;; Action functions

View File

@ -202,18 +202,74 @@
(defun update-mini-now-playing ()
(let ((mount (get-current-mount)))
(ps:chain
(fetch (+ "/api/asteroid/partial/now-playing-inline?mount=" mount))
(fetch (+ "/api/asteroid/partial/now-playing-json?mount=" mount))
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (text))
"")))
(then (lambda (text)
(let ((el (ps:chain document (get-element-by-id "mini-now-playing"))))
(when el
(setf (ps:@ el text-content) text)))))
(ps:chain response (json))
nil)))
(then (lambda (data)
(when data
(let ((el (ps:chain document (get-element-by-id "mini-now-playing")))
(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)
(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)
(defun update-popout-now-playing ()
(let ((mount (get-current-mount)))
@ -641,6 +697,7 @@
(setf (ps:@ window init-popout-player) init-popout-player)
(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 toggle-favorite-mini) toggle-favorite-mini)
;; Auto-initialize on DOMContentLoaded based on which elements exist
(ps:chain document

View File

@ -1,144 +1,83 @@
#EXTM3U
#PLAYLIST:Escape Velocity - A Christmas Journey Through Space
#PHASE:Escape Velocity
#DURATION:12 hours (approx)
#PLAYLIST:Midnight Ambient
#PHASE:Midnight Ambient
#DURATION:6 hours (approx)
#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,Brian Eno - Snow
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac
#EXTINF:-1,Brian Eno - Wintergreen
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/04 Wintergreen.flac
#EXTINF:-1,Proem - Winter Wolves
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.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,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,Biosphere - The Petrified Forest
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/04. Biosphere - The Petrified Forest.flac
#EXTINF:-1,Labradford - S
/app/music/Labradford/1997 - Mi Media Naranja/1 S.flac
#EXTINF:-1,Tim Hecker - Seasick
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 01 Seasick.flac
#EXTINF:-1,Pye Corner Audio - Hollow Earth
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/01 - Hollow Earth.mp3
#EXTINF:-1,Dead Voices On Air - Red Howls
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/01 - Red Howls.flac
#EXTINF:-1,Cut Copy - Airborne
/app/music/Cut Copy - Haiku From Zero (2017) [FLAC] {2557864014}/05 - Airborne.flac
#EXTINF:-1,Owl City - 01 Hot Air Balloon
/app/music/Owl City - Ocean Eyes (Deluxe Edition) [Flac,Cue,Logs]/Disc 2/01 Hot Air Balloon.flac
#EXTINF:-1,VA - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit]
/app/music/VA - Melodic Vocal Trance 2017/24. Airborn, Bogdan Vix & KeyPlayer - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit].flac
#EXTINF:-1,VA - Winter Took Over (Radio Edit)
/app/music/VA - Melodic Vocal Trance 2017/22. Bluskay, KeyPlayer & Esmee Bor Stotijn - Winter Took Over (Radio Edit).flac
#EXTINF:-1,Alison Krauss and Union Station - My Opening Farewell
/app/music/Alison Krauss and Union Station - Paper Airplane (flac)/11 - Alison Krauss & Union Station - My Opening Farewell.flac
#EXTINF:-1,Bedouin Soundclash - Money Worries (E-Clair Refix)
/app/music/Bedouin Soundclash - Sounding a Mosaic (2004) [FLAC] {SD1267}/14 - Money Worries (E-Clair Refix).flac
# === PHASE 10: RETURN TO WINTER (Closing Circle) ===
#EXTINF:-1,Brian Eno - Snow
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac
#EXTINF:-1,Proem - Winter Wolves
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.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,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
#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,Tangerine Dream - The Seventh Propellor of Silence
/app/music/Tangerine Dream - Ambient Monkeys (flac)/03 Tangerine Dream - The Seventh Propellor of Silence.flac
#EXTINF:-1,Biosphere - Fall Asleep For Me
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/17 - Fall Asleep For Me.flac
#EXTINF:-1,Locrian - Arc of Extinction
/app/music/Locrian - Infinite Dissolution (2015) - WEB FLAC/01 - Arc of Extinction.flac
#EXTINF:-1,FSOL - Polarize
/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/01 - Polarize.flac
#EXTINF:-1,Marconi Union - Sleeper
/app/music/Marconi Union - Ghost Stations (2016 - WEB - FLAC)/01. Marconi Union - Sleeper.flac
#EXTINF:-1,Labradford - G
/app/music/Labradford/1997 - Mi Media Naranja/2 G.flac
#EXTINF:-1,Biosphere - This Is The End
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/06. Biosphere - This Is The End.flac
#EXTINF:-1,Pye Corner Audio - Descent
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/02 - Descent.mp3
#EXTINF:-1,Brian Eno - Reflection
/app/music/Brian Eno/2017 - Reflection/01. Reflection.mp3
#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,Tim Hecker - Left On The Ice
/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,Autechre - Further
/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

@ -1546,3 +1546,159 @@ body.popout-body .status-mini{
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
(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

View File

@ -37,6 +37,11 @@
<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">
<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>

View File

@ -2,10 +2,15 @@
<c:if test="stats">
<c:then>
<c:using value="stats">
<!--<p>Artist: <span>The Void</span></p>-->
<p>Track: <span lquery="(text title)">The Void - Silence</span></p>
<div class="now-playing-track">
<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>
</c:using>
<input type="hidden" id="current-track-id" lquery="(val track-id)" value="">
</c:then>
<c:else>
<c:if test="connection-error">

View File

@ -104,6 +104,17 @@
</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 -->
<div class="admin-section">
<h2>🎤 Top Artists</h2>

View File

@ -164,24 +164,26 @@
(let* ((user-id (session:field "user-id"))
(uri (radiance:path (radiance:uri *request*)))
;; 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~%"
user-id uri (if is-api-request "YES" "NO"))
(if user-id
t ; Authenticated - return T to continue
;; Not authenticated - emit error
(if is-api-request
;; API request - emit JSON error and return the value from api-output
(progn
(format t "Authentication failed - returning JSON 401~%")
(radiance:api-output
'(("error" . "Authentication required"))
:status 401
:message "You must be logged in to access this resource"))
;; Page request - redirect to login (redirect doesn't return)
(progn
(format t "Authentication failed - redirecting to login~%")
(radiance:redirect "/login"))))))
;; Not authenticated - emit error and signal to stop processing
(progn
(if is-api-request
;; API request - emit JSON error with 401 status
(progn
(format t "Authentication failed - returning JSON 401~%")
(setf (radiance:return-code *response*) 401)
(setf (radiance:content-type *response*) "application/json")
(error 'radiance:request-denied :message "Authentication required"))
;; Page request - redirect to login
(progn
(format t "Authentication failed - redirecting to login~%")
(radiance:redirect "/login")))))))
(defun require-role (role &key (api nil))
"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"))))))