From bfc33c8d4e8ffb96519ae582fd24c01bfb39f4cf Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 21 Dec 2025 08:15:52 +0300 Subject: [PATCH] feat: Add track favorites feature with star button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- asteroid.asd | 1 + database.lisp | 13 ++ frontend-partials.lisp | 39 +++- migrations/005-user-favorites-history.sql | 44 ++++ parenscript/front-page.lisp | 50 +++++ parenscript/profile.lisp | 61 ++++++ parenscript/stream-player.lisp | 71 ++++++- playlists/stream-queue.m3u | 217 +++++++------------ static/asteroid.css | 156 ++++++++++++++ static/asteroid.lass | 85 ++++++++ template/audio-player-frame.ctml | 5 + template/partial/now-playing.ctml | 9 +- template/profile.ctml | 11 + user-management.lisp | 30 +-- user-profile.lisp | 245 ++++++++++++++++++++++ 15 files changed, 873 insertions(+), 164 deletions(-) create mode 100644 migrations/005-user-favorites-history.sql create mode 100644 user-profile.lisp diff --git a/asteroid.asd b/asteroid.asd index 30d0713..305afd1 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -63,6 +63,7 @@ (:file "stream-control") (:file "playlist-scheduler") (:file "listener-stats") + (:file "user-profile") (:file "auth-routes") (:file "frontend-partials") (:file "asteroid"))) diff --git a/database.lisp b/database.lisp index b234188..5d1b924 100644 --- a/database.lisp +++ b/database.lisp @@ -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 diff --git a/frontend-partials.lisp b/frontend-partials.lisp index a300e4c..5797731 100644 --- a/frontend-partials.lisp +++ b/frontend-partials.lisp @@ -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." diff --git a/migrations/005-user-favorites-history.sql b/migrations/005-user-favorites-history.sql new file mode 100644 index 0000000..cacb4b9 --- /dev/null +++ b/migrations/005-user-favorites-history.sql @@ -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 $$; diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index de76396..a8a1551 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -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) diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index c5c3499..f8ab9d3 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -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) + (+ "
" + "" (or (ps:@ fav title) "Unknown") "" + "" (or (ps:@ fav artist) "") "" + "
" + "
" + "" (render-stars (or (ps:@ fav rating) 1)) "" + "" + "
")) + (ps:chain container (append-child item))))))) + (setf (ps:@ container inner-h-t-m-l) "

No favorites yet. Like tracks while listening!

")))))) + (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) "

Failed to load favorites

"))))))) + + (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 diff --git a/parenscript/stream-player.lisp b/parenscript/stream-player.lisp index a69c7ab..ce9e6b9 100644 --- a/parenscript/stream-player.lisp +++ b/parenscript/stream-player.lisp @@ -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 diff --git a/playlists/stream-queue.m3u b/playlists/stream-queue.m3u index a126ce4..b1180e1 100644 --- a/playlists/stream-queue.m3u +++ b/playlists/stream-queue.m3u @@ -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 diff --git a/static/asteroid.css b/static/asteroid.css index 93c3445..cef3ae1 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1545,4 +1545,160 @@ body.popout-body .status-mini{ 100%{ 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; } \ No newline at end of file diff --git a/static/asteroid.lass b/static/asteroid.lass index 9265903..c811de7 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -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 diff --git a/template/audio-player-frame.ctml b/template/audio-player-frame.ctml index 494a4d6..7c9772d 100644 --- a/template/audio-player-frame.ctml +++ b/template/audio-player-frame.ctml @@ -37,6 +37,11 @@ Loading... + + + diff --git a/template/partial/now-playing.ctml b/template/partial/now-playing.ctml index 4ad60de..7cb87f6 100644 --- a/template/partial/now-playing.ctml +++ b/template/partial/now-playing.ctml @@ -2,10 +2,15 @@ - -

Track: The Void - Silence

+
+

Track: The Void - Silence

+ +

Listeners: 1

+
diff --git a/template/profile.ctml b/template/profile.ctml index 44e1de1..3a6635e 100644 --- a/template/profile.ctml +++ b/template/profile.ctml @@ -104,6 +104,17 @@ + +
+

❤️ Favorite Tracks

+
+

Loading favorites...

+
+
+ +
+
+

🎤 Top Artists

diff --git a/user-management.lisp b/user-management.lisp index d579a0a..25f3566 100644 --- a/user-management.lisp +++ b/user-management.lisp @@ -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. diff --git a/user-profile.lisp b/user-profile.lisp new file mode 100644 index 0000000..6c3aff6 --- /dev/null +++ b/user-profile.lisp @@ -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"))))))