From 5225a07b8b27a821f4dc1a7846ed9f4ba2de183b Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 21 Dec 2025 08:15:52 +0300 Subject: [PATCH 01/12] 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")))))) From 7600ea6bed4f07ecadcd36d286b312219fbbe949 Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 21 Dec 2025 08:35:35 +0300 Subject: [PATCH 02/12] feat: Add listening history tracking and fix favorites Listening History: - Auto-record tracks when they change (logged-in users only) - Track stored by title (no tracks table dependency) - Profile page shows real recent tracks, top artists, listening stats - APIs: /api/asteroid/user/history, /user/listening-stats, /user/recent-tracks, /user/top-artists Favorites Fixes: - Remove favorite now uses title instead of track-id - Fixed response parsing to show green success message - Profile page remove button works correctly Migration Script Updated: - track_title column added to both tables - track-id now optional (nullable) - Unique index on (user-id, track_title) - No foreign key to tracks table (title-based storage) For production: run migrations/005-user-favorites-history.sql --- asteroid.lisp | 44 ++++++++---- migrations/005-user-favorites-history.sql | 15 ++-- parenscript/front-page.lisp | 33 ++++++++- parenscript/profile.lisp | 23 ++++--- parenscript/stream-player.lisp | 20 ++++++ user-profile.lisp | 84 ++++++++++------------- 6 files changed, 143 insertions(+), 76 deletions(-) diff --git a/asteroid.lisp b/asteroid.lisp index 4ae53e2..8940159 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -1158,28 +1158,44 @@ (define-api asteroid/user/listening-stats () () "Get user listening statistics" (require-authentication) - (let* ((current-user (get-current-user)) - (user-id (when current-user (dm:id current-user))) - (stats (if user-id - (get-user-listening-stats user-id) - (list :total-listen-time 0 :session-count 0 :tracks-played 0)))) - (api-output `(("status" . "success") - ("stats" . (("total_listen_time" . ,(getf stats :total-listen-time 0)) - ("tracks_played" . ,(getf stats :tracks-played 0)) - ("session_count" . ,(getf stats :session-count 0)) - ("favorite_genre" . "Unknown"))))))) + (with-error-handling + (let* ((user-id (session:field "user-id")) + (stats (get-listening-stats user-id))) + (api-output `(("status" . "success") + ("stats" . (("total_listen_time" . ,(getf stats :total-listen-time 0)) + ("tracks_played" . ,(getf stats :tracks-played 0)) + ("session_count" . 0) + ("favorite_genre" . "Ambient")))))))) (define-api asteroid/user/recent-tracks (&optional (limit "3")) () "Get recently played tracks for user" (require-authentication) - (api-output `(("status" . "success") - ("tracks" . ())))) + (with-error-handling + (let* ((user-id (session:field "user-id")) + (limit-int (parse-integer limit :junk-allowed t)) + (history (get-listening-history user-id :limit (or limit-int 3)))) + (api-output `(("status" . "success") + ("tracks" . ,(mapcar (lambda (h) + `(("title" . ,(or (cdr (assoc :track-title h)) + (cdr (assoc :track_title h)))) + ("artist" . "") + ("played_at" . ,(cdr (assoc :listened-at h))) + ("duration" . ,(or (cdr (assoc :listen-duration h)) 0)))) + history))))))) (define-api asteroid/user/top-artists (&optional (limit "5")) () "Get top artists for user" (require-authentication) - (api-output `(("status" . "success") - ("artists" . ())))) + (with-error-handling + (let* ((user-id (session:field "user-id")) + (limit-int (parse-integer limit :junk-allowed t)) + (artists (get-top-artists user-id :limit (or limit-int 5)))) + (api-output `(("status" . "success") + ("artists" . ,(mapcar (lambda (a) + `(("name" . ,(or (cdr (assoc :artist a)) "Unknown")) + ("play_count" . ,(or (cdr (assoc :play-count a)) + (cdr (assoc :play_count a)) 0)))) + artists))))))) ;; Register page (GET) (define-page register #@"/register" () diff --git a/migrations/005-user-favorites-history.sql b/migrations/005-user-favorites-history.sql index cacb4b9..199053c 100644 --- a/migrations/005-user-favorites-history.sql +++ b/migrations/005-user-favorites-history.sql @@ -1,29 +1,34 @@ -- Migration 005: User Favorites and Listening History -- Adds tables for track favorites/ratings and per-user listening history +-- Updated to support title-based storage (no tracks table dependency) -- User favorites table - tracks that users have liked/rated +-- Supports both track-id (when tracks table is populated) and track_title (for now) 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, + "track-id" INTEGER, -- Optional: references tracks(_id) when available + track_title TEXT, -- Store title directly for title-based favorites rating INTEGER DEFAULT 1 CHECK (rating >= 1 AND rating <= 5), - "created-date" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE("user-id", "track-id") + "created-date" TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 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); +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_favorites_unique ON user_favorites("user-id", COALESCE(track_title, '')); -- User listening history - per-user track play history +-- Supports both track-id and track_title 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, + "track-id" INTEGER, -- Optional: references tracks(_id) when available + track_title TEXT, -- Store title directly for title-based history "listened-at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "listen-duration" INTEGER DEFAULT 0, -- seconds listened - "completed" BOOLEAN DEFAULT false -- did they listen to the whole track? + completed INTEGER DEFAULT 0 -- 1 if they listened to the whole track ); -- Create indexes for efficient queries diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index a8a1551..e62da0a 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -165,6 +165,24 @@ (get-stream-config (ps:@ stream-base-url value) channel quality)))) (if config (ps:@ config mount) "asteroid.mp3"))) + ;; Track last recorded title to avoid duplicate history entries + (defvar *last-recorded-title-main* nil) + + ;; Record track to listening history (only if logged in) + (defun record-track-listen-main (title) + (when (and title (not (= title "")) (not (= title "Loading...")) + (not (= title "NA")) (not (= title *last-recorded-title-main*))) + (setf *last-recorded-title-main* title) + (ps:chain + (fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title)) + (ps:create :method "POST")) + (then (lambda (response) + (when (ps:@ response ok) + (ps:chain console (log "Recorded listen:" title))))) + (catch (lambda (error) + ;; Silently fail - user might not be logged in + nil))))) + ;; Update now playing info from API (defun update-now-playing () (let ((mount (get-current-mount))) @@ -176,8 +194,19 @@ (ps:chain response (text)) (throw (ps:new (-error "Error connecting to stream"))))))) (then (lambda (data) - (setf (ps:@ (ps:chain document (get-element-by-id "now-playing")) inner-h-t-m-l) - data))) + (let ((now-playing-el (ps:chain document (get-element-by-id "now-playing")))) + (when now-playing-el + ;; Get current title before updating + (let ((old-title-el (ps:chain now-playing-el (query-selector "#current-track-title")))) + (setf (ps:@ now-playing-el inner-h-t-m-l) data) + ;; Get new title after updating + (let ((new-title-el (ps:chain now-playing-el (query-selector "#current-track-title")))) + (when new-title-el + (let ((new-title (ps:@ new-title-el text-content))) + ;; Record if title changed + (when (or (not old-title-el) + (not (= (ps:@ old-title-el text-content) new-title))) + (record-track-listen-main new-title)))))))))) (catch (lambda (error) (ps:chain console (log "Could not fetch stream status:" error))))))) diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index f8ab9d3..eb12b92 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -193,7 +193,7 @@ "
" "
" "" (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!

")))))) @@ -209,17 +209,22 @@ (setf stars (+ stars (if (< i rating) "★" "☆")))) stars)) - (defun remove-favorite (track-id) + (defun remove-favorite (title) (ps:chain - (fetch (+ "/api/asteroid/user/favorites/remove?track-id=" track-id) + (fetch (+ "/api/asteroid/user/favorites/remove?title=" (encode-u-r-i-component title)) (ps:create :method "POST")) - (then (lambda (response) (ps:chain response (json)))) + (then (lambda (response) + (if (ps:@ response ok) + (ps:chain response (json)) + (throw (ps:new (-error "Request failed")))))) (then (lambda (data) - (if (= (ps:@ data status) "success") - (progn - (show-message "Removed from favorites" "success") - (load-favorites)) - (show-message "Failed to remove favorite" "error")))) + ;; API returns {"status": 200, "data": {"status": "success"}} + (let ((inner-status (or (ps:@ data data status) (ps:@ data status)))) + (if (or (= inner-status "success") (= (ps:@ data status) 200)) + (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"))))) diff --git a/parenscript/stream-player.lisp b/parenscript/stream-player.lisp index ce9e6b9..7d83537 100644 --- a/parenscript/stream-player.lisp +++ b/parenscript/stream-player.lisp @@ -198,6 +198,23 @@ (config (get-stream-config stream-base-url channel quality))) (if config (ps:@ config mount) "asteroid.mp3"))) + ;; Track the last recorded title to avoid duplicate history entries + (defvar *last-recorded-title* nil) + + ;; Record track to listening history (only if logged in) + (defun record-track-listen (title) + (when (and title (not (= title "")) (not (= title "Loading...")) (not (= title *last-recorded-title*))) + (setf *last-recorded-title* title) + (ps:chain + (fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title)) + (ps:create :method "POST")) + (then (lambda (response) + (when (ps:@ response ok) + (ps:chain console (log "Recorded listen:" title))))) + (catch (lambda (error) + ;; Silently fail - user might not be logged in + nil))))) + ;; Update mini now playing display (for persistent player frame) (defun update-mini-now-playing () (let ((mount (get-current-mount))) @@ -213,6 +230,9 @@ (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 + ;; Check if track changed and record to history + (when (not (= (ps:@ el text-content) title)) + (record-track-listen title)) (setf (ps:@ el text-content) title)) (when track-id-el (let ((track-id (or (ps:@ data data track_id) (ps:@ data track_id)))) diff --git a/user-profile.lisp b/user-profile.lisp index 6c3aff6..5e4515a 100644 --- a/user-profile.lisp +++ b/user-profile.lisp @@ -73,64 +73,51 @@ ;;; 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" +(defun record-listen (user-id &key track-id track-title (duration 0) (completed nil)) + "Record a track listen in user's history. Can use track-id or track-title." (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))))) + (if track-id + (postmodern:query + (:raw (format nil "INSERT INTO listening_history (\"user-id\", \"track-id\", track_title, \"listen-duration\", completed) VALUES (~a, ~a, ~a, ~a, ~a)" + user-id track-id + (if track-title (format nil "$$~a$$" track-title) "NULL") + duration (if completed 1 0)))) + (when track-title + (postmodern:query + (:raw (format nil "INSERT INTO listening_history (\"user-id\", track_title, \"listen-duration\", completed) VALUES (~a, $$~a$$, ~a, ~a)" + user-id track-title duration (if completed 1 0)))))))) (defun get-listening-history (user-id &key (limit 20) (offset 0)) - "Get user's listening history with track details" + "Get user's listening history - works with title-based history" (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) + (:raw (format nil "SELECT _id, \"listened-at\", \"listen-duration\", completed, track_title, \"track-id\" FROM listening_history WHERE \"user-id\" = ~a ORDER BY \"listened-at\" DESC LIMIT ~a OFFSET ~a" + user-id 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)) + (:raw (format nil "SELECT COUNT(*), COALESCE(SUM(\"listen-duration\"), 0) FROM listening_history WHERE \"user-id\" = ~a" 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" + "Get user's most listened artists - extracts artist from track_title" (with-db + ;; Extract artist from 'Artist - Title' format in track_title (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) + (:raw (format nil "SELECT SPLIT_PART(track_title, ' - ', 1) as artist, COUNT(*) as play_count FROM listening_history WHERE \"user-id\" = ~a AND track_title IS NOT NULL GROUP BY SPLIT_PART(track_title, ' - ', 1) ORDER BY play_count DESC LIMIT ~a" + user-id 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))))) + (:raw (format nil "DELETE FROM listening_history WHERE \"user-id\" = ~a" user-id))))) ;;; ========================================================================== ;;; API Endpoints for User Favorites @@ -214,24 +201,29 @@ (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))) + ("track_id" . ,(cdr (assoc :track-id h))) + ("title" . ,(or (cdr (assoc :track-title h)) + (cdr (assoc :track_title h)))) ("listened_at" . ,(cdr (assoc :listened-at h))) - ("completed" . ,(= 1 (cdr (assoc :completed h)))))) + ("listen_duration" . ,(cdr (assoc :listen-duration h))) + ("completed" . ,(let ((c (cdr (assoc :completed h)))) + (and c (= 1 c)))))) history))))))) -(define-api asteroid/user/history/record (track-id &optional duration completed) () - "Record a track listen (called by player)" +(define-api asteroid/user/history/record (&optional track-id title duration completed) () + "Record a track listen (called by player). Can use track-id or title." (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)) + (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))) + (duration-int (if duration (parse-integer duration :junk-allowed t) 0)) (completed-bool (and completed (string-equal completed "true")))) - (record-listen user-id track-id-int :duration duration-int :completed completed-bool) + (record-listen user-id :track-id track-id-int :track-title title + :duration (or duration-int 0) :completed completed-bool) (api-output `(("status" . "success") ("message" . "Listen recorded")))))) From a2ebc415f26b3560f2d128932e74c791bb617be6 Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 21 Dec 2025 08:43:36 +0300 Subject: [PATCH 03/12] feat: Add listening activity chart to profile page - New API endpoint /api/asteroid/user/activity for daily aggregation - Bar chart showing tracks played per day (last 30 days) - Hover tooltips show exact date and count - Total tracks summary below chart - Green gradient bars matching site theme --- parenscript/profile.lisp | 51 ++++++++++++++++++++++++++++- static/asteroid.css | 71 ++++++++++++++++++++++++++++++++++++++++ static/asteroid.lass | 58 ++++++++++++++++++++++++++++++++ template/profile.ctml | 15 +++------ user-profile.lisp | 21 ++++++++++++ 5 files changed, 204 insertions(+), 12 deletions(-) diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index eb12b92..cbcc146 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -232,6 +232,54 @@ (defun load-more-favorites () (show-message "Loading more favorites..." "info")) + (defun load-activity-chart () + (ps:chain + (fetch "/api/asteroid/user/activity?days=30") + (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 "activity-chart"))) + (total-el (ps:chain document (get-element-by-id "activity-total")))) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data activity) + (> (ps:@ data activity length) 0)) + (let ((activity (ps:@ data activity)) + (max-count 1) + (total 0)) + ;; Find max for scaling + (ps:chain activity (for-each (lambda (day) + (let ((count (or (ps:@ day track_count) 0))) + (setf total (+ total count)) + (when (> count max-count) + (setf max-count count)))))) + ;; Build chart HTML + (let ((html "
")) + (ps:chain activity (for-each (lambda (day) + (let* ((count (or (ps:@ day track_count) 0)) + (height (ps:chain -math (round (* (/ count max-count) 100)))) + (date-str (ps:@ day day)) + (date-parts (ps:chain date-str (split "-"))) + (day-label (if (> (ps:@ date-parts length) 2) + (ps:getprop date-parts 2) + ""))) + (setf html (+ html "
" + "
" + "" day-label "" + "
")))))) + (setf html (+ html "
")) + (setf (ps:@ container inner-h-t-m-l) html)) + ;; Update total + (when total-el + (setf (ps:@ total-el text-content) (+ "Total: " total " tracks in the last 30 days")))) + ;; No data + (setf (ps:@ container inner-h-t-m-l) "

No listening activity yet. Start listening to build your history!

")))))) + (catch (lambda (error) + (ps:chain console (error "Error loading activity:" error)) + (let ((container (ps:chain document (get-element-by-id "activity-chart")))) + (when container + (setf (ps:@ container inner-h-t-m-l) "

Failed to load activity data

"))))))) + (defun load-profile-data () (ps:chain console (log "Loading profile data...")) @@ -254,7 +302,8 @@ (load-listening-stats) (load-recent-tracks) (load-favorites) - (load-top-artists)) + (load-top-artists) + (load-activity-chart)) ;; Action functions (defun load-more-recent-tracks () diff --git a/static/asteroid.css b/static/asteroid.css index cef3ae1..fcda789 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1701,4 +1701,75 @@ body.popout-body .status-mini{ .favorites-list .btn-small{ padding: 4px 8px; font-size: 0.8em; +} + +.activity-chart{ + padding: 15px; +} + +.activity-chart .chart-container{ + min-height: 120px; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.activity-chart .chart-bars{ + display: flex; + align-items: flex-end; + justify-content: center; + gap: 4px; + height: 100px; + width: 100%; + max-width: 600px; +} + +.activity-chart .chart-bar-wrapper{ + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + max-width: 20px; +} + +.activity-chart .chart-bar{ + width: 100%; + min-height: 2px; + background: linear-gradient(to top, #006600, #00cc00); + border-radius: 2px 2px 0 0; + -moz-transition: height 0.3s ease; + -o-transition: height 0.3s ease; + -webkit-transition: height 0.3s ease; + -ms-transition: height 0.3s ease; + transition: height 0.3s ease; +} + +.activity-chart .chart-bar:hover{ + background: linear-gradient(to top, #009900, #00ff00); +} + +.activity-chart .chart-day{ + font-size: 0.6em; + color: #666; + margin-top: 4px; +} + +.activity-chart .chart-note{ + text-align: center; + color: #888; + font-size: 0.9em; + margin-top: 10px; +} + +.activity-chart .loading-message{ + color: #666; + font-style: italic; + text-align: center; +} + +.activity-chart .no-data{ + color: #666; + font-style: italic; + text-align: center; + padding: 20px; } \ No newline at end of file diff --git a/static/asteroid.lass b/static/asteroid.lass index c811de7..6460b89 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -1359,4 +1359,62 @@ (.btn-small :padding "4px 8px" :font-size "0.8em")) + + ;; Activity chart styling + (.activity-chart + :padding "15px" + + (.chart-container + :min-height "120px" + :display "flex" + :align-items "flex-end" + :justify-content "center") + + (.chart-bars + :display "flex" + :align-items "flex-end" + :justify-content "center" + :gap "4px" + :height "100px" + :width "100%" + :max-width "600px") + + (.chart-bar-wrapper + :display "flex" + :flex-direction "column" + :align-items "center" + :flex "1" + :max-width "20px") + + (.chart-bar + :width "100%" + :min-height "2px" + :background "linear-gradient(to top, #006600, #00cc00)" + :border-radius "2px 2px 0 0" + :transition "height 0.3s ease") + + ((:and .chart-bar :hover) + :background "linear-gradient(to top, #009900, #00ff00)") + + (.chart-day + :font-size "0.6em" + :color "#666" + :margin-top "4px") + + (.chart-note + :text-align "center" + :color "#888" + :font-size "0.9em" + :margin-top "10px") + + (.loading-message + :color "#666" + :font-style "italic" + :text-align "center") + + (.no-data + :color "#666" + :font-style "italic" + :text-align "center" + :padding "20px")) ) ;; End of let block diff --git a/template/profile.ctml b/template/profile.ctml index 3a6635e..f829f43 100644 --- a/template/profile.ctml +++ b/template/profile.ctml @@ -146,18 +146,11 @@

📈 Listening Activity

-

Activity over the last 30 days

-
-
-
-
-
-
-
-
- +

Tracks played over the last 30 days

+
+

Loading activity data...

-

Listening hours per day

+

Total: 0 tracks

diff --git a/user-profile.lisp b/user-profile.lisp index 5e4515a..1ddca88 100644 --- a/user-profile.lisp +++ b/user-profile.lisp @@ -119,6 +119,14 @@ (postmodern:query (:raw (format nil "DELETE FROM listening_history WHERE \"user-id\" = ~a" user-id))))) +(defun get-listening-activity (user-id &key (days 30)) + "Get listening activity aggregated by day for the last N days" + (with-db + (postmodern:query + (:raw (format nil "SELECT DATE(\"listened-at\") as day, COUNT(*) as track_count FROM listening_history WHERE \"user-id\" = ~a AND \"listened-at\" >= NOW() - INTERVAL '~a days' GROUP BY DATE(\"listened-at\") ORDER BY day ASC" + user-id days)) + :alists))) + ;;; ========================================================================== ;;; API Endpoints for User Favorites ;;; ========================================================================== @@ -235,3 +243,16 @@ (clear-listening-history user-id) (api-output `(("status" . "success") ("message" . "Listening history cleared")))))) + +(define-api asteroid/user/activity (&optional (days "30")) () + "Get listening activity by day for the last N days" + (require-authentication) + (with-error-handling + (let* ((user-id (session:field "user-id")) + (days-int (or (parse-integer days :junk-allowed t) 30)) + (activity (get-listening-activity user-id :days days-int))) + (api-output `(("status" . "success") + ("activity" . ,(mapcar (lambda (a) + `(("day" . ,(cdr (assoc :day a))) + ("track_count" . ,(cdr (assoc :track-count a))))) + activity))))))) From 8f5fe7534dd943f517cf4010111bbc21773ce35b Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 21 Dec 2025 09:07:56 +0300 Subject: [PATCH 04/12] feat: Add avatar upload and fix authentication errors Avatars: - Add avatar_path column to USERS table (migration 006) - Upload API endpoint /api/asteroid/user/avatar/upload - Profile page shows avatar with hover-to-change overlay - Default SVG avatar for users without uploaded image - Avatars stored in static/avatars/ directory Fixes: - 401 errors now return proper JSON instead of 500 - SQL escaping for history recording (single quotes) - Added debug logging for history/record API - Avatar container has background color for visibility For production: run migrations/006-user-avatars.sql --- migrations/006-user-avatars.sql | 14 ++ parenscript/profile.lisp | 42 ++++- playlists/stream-queue.m3u | 282 +++++++++++++++++++++++--------- static/asteroid.css | 51 ++++++ static/asteroid.lass | 41 +++++ static/avatars/.gitkeep | 0 static/avatars/1.png | Bin 0 -> 14757 bytes static/icons/default-avatar.svg | 5 + template/profile.ctml | 41 +++-- user-management.lisp | 8 +- user-profile.lisp | 79 ++++++++- 11 files changed, 459 insertions(+), 104 deletions(-) create mode 100644 migrations/006-user-avatars.sql create mode 100644 static/avatars/.gitkeep create mode 100644 static/avatars/1.png create mode 100644 static/icons/default-avatar.svg diff --git a/migrations/006-user-avatars.sql b/migrations/006-user-avatars.sql new file mode 100644 index 0000000..82aeea0 --- /dev/null +++ b/migrations/006-user-avatars.sql @@ -0,0 +1,14 @@ +-- Migration 006: User Avatars +-- Adds avatar support to user profiles + +-- Add avatar_path column to USERS table +ALTER TABLE "USERS" ADD COLUMN IF NOT EXISTS avatar_path TEXT; + +-- Grant permissions +GRANT ALL PRIVILEGES ON "USERS" TO asteroid; + +-- Verification +DO $$ +BEGIN + RAISE NOTICE 'Migration 006: User avatars column added successfully!'; +END $$; diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index cbcc146..a6da081 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -232,6 +232,45 @@ (defun load-more-favorites () (show-message "Loading more favorites..." "info")) + (defun load-avatar () + (ps:chain + (fetch "/api/asteroid/user/avatar") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (when (and (= (ps:@ data status) "success") + (ps:@ data avatar_path)) + (let ((img (ps:chain document (get-element-by-id "user-avatar")))) + (when img + (setf (ps:@ img src) (ps:@ data avatar_path)))))))) + (catch (lambda (error) + (ps:chain console (log "No avatar set or error loading:" error)))))) + + (defun upload-avatar (input) + (let ((file (ps:getprop (ps:@ input files) 0))) + (when file + (let ((form-data (ps:new (-form-data)))) + (ps:chain form-data (append "avatar" file)) + (ps:chain form-data (append "filename" (ps:@ file name))) + (show-message "Uploading avatar..." "info") + (ps:chain + (fetch "/api/asteroid/user/avatar/upload" + (ps:create :method "POST" + :body form-data)) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (let ((img (ps:chain document (get-element-by-id "user-avatar")))) + (when img + (setf (ps:@ img src) (+ (ps:@ data avatar_path) "?" (ps:chain -date (now)))))) + (show-message "Avatar updated!" "success")) + (show-message "Failed to upload avatar" "error"))))) + (catch (lambda (error) + (ps:chain console (error "Error uploading avatar:" error)) + (show-message "Error uploading avatar" "error")))))))) + (defun load-activity-chart () (ps:chain (fetch "/api/asteroid/user/activity?days=30") @@ -303,7 +342,8 @@ (load-recent-tracks) (load-favorites) (load-top-artists) - (load-activity-chart)) + (load-activity-chart) + (load-avatar)) ;; Action functions (defun load-more-recent-tracks () diff --git a/playlists/stream-queue.m3u b/playlists/stream-queue.m3u index b1180e1..0f84bed 100644 --- a/playlists/stream-queue.m3u +++ b/playlists/stream-queue.m3u @@ -1,83 +1,207 @@ #EXTM3U -#PLAYLIST:Midnight Ambient -#PHASE:Midnight Ambient +#PLAYLIST:Morning Drift +#PHASE:Morning Drift #DURATION:6 hours (approx) #CURATOR:Asteroid Radio -#DESCRIPTION:Deep, dark ambient for the late night hours (00:00-06:00) +#DESCRIPTION:Lighter, awakening ambient for the morning hours (06:00-12:00) -#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,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 +#EXTINF:-1,Brian Eno - Emerald And Lime +/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A1 Emerald And Lime.flac +#EXTINF:-1,Tycho - Glider +/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/01 - Glider.flac +#EXTINF:-1,Biosphere - Drifter +/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac +#EXTINF:-1,Four Tet - Alap +/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/01 Alap.flac +#EXTINF:-1,Johann Johannsson - Cambridge, 1963 +/app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/01 - Cambridge, 1963.flac +#EXTINF:-1,Ulrich Schnauss - Negative Sunrise (2019 Version) +/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/09. Negative Sunrise (2019 Version).flac +#EXTINF:-1,Kiasmos - Lit +/app/music/Kiasmos/2014 - Kiasmos/01 - Lit.flac +#EXTINF:-1,FSOL - Mountain Path +/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/02 - Mountain Path.flac +#EXTINF:-1,Brian Eno - Garden of Stars +/app/music/Brian Eno/2022 - ForeverAndEverNoMore/04 Garden of Stars.flac +#EXTINF:-1,Clark - Kiri's Glee +/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/04 - Kiri's Glee.flac +#EXTINF:-1,Tycho - Source +/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/07 - Source.flac +#EXTINF:-1,Biosphere - Out Of The Cradle +/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/01 - Out Of The Cradle.flac +#EXTINF:-1,Tangerine Dream - Token from Birdland +/app/music/Tangerine Dream - Ambient Monkeys (flac)/01 Tangerine Dream - Token from Birdland.flac +#EXTINF:-1,Four Tet - Scientists +/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/06 Scientists.flac +#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Solitary Falling +/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/03. Solitary Falling.flac +#EXTINF:-1,Proem - Modern Rope +/app/music/Proem - 2018 Modern Rope (WEB)/05. Modern Rope.flac +#EXTINF:-1,Johann Johannsson - Rowing +/app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/02 - Rowing.flac +#EXTINF:-1,Kiasmos - Held +/app/music/Kiasmos/2014 - Kiasmos/02 - Held.flac +#EXTINF:-1,Brian Eno - Complex Heaven +/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A2 Complex Heaven.flac +#EXTINF:-1,Biosphere - Skålbrekka +/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/01 - Skålbrekka.flac +#EXTINF:-1,FSOL - Thought Pattern +/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/03 - Thought Pattern.flac +#EXTINF:-1,Tycho - Into The Woods +/app/music/Tycho - Simulcast (2020) [WEB FLAC]/04 - Into The Woods.flac +#EXTINF:-1,arovane - hymn +/app/music/arovane - Wirkung (2020) [WEB FLAC16]/12. arovane - hymn.flac +#EXTINF:-1,Four Tet - Green +/app/music/Four Tet - Sixteen Oceans (2020) {Text Records - TEXT051} [CD FLAC]/12 - Four Tet - Green.flac +#EXTINF:-1,Clark - Primary Pluck +/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/08 - Primary Pluck.flac +#EXTINF:-1,Biosphere - Strandby +/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/02 - Strandby.flac +#EXTINF:-1,Johann Johannsson - The Dreams That Stuff Is Made Of +/app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/11 - The Dreams That Stuff Is Made Of.flac +#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Polychrome +/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/08. Polychrome.flac +#EXTINF:-1,Kiasmos - Swayed +/app/music/Kiasmos/2014 - Kiasmos/04 - Swayed.flac +#EXTINF:-1,Brian Eno - These Small Noises +/app/music/Brian Eno/2022 - ForeverAndEverNoMore/09 These Small Noises.flac +#EXTINF:-1,Tycho - Ascension +/app/music/Thievery Corporation and Tycho - Fragments Ascension EP (flac)/3. Tycho - Ascension.flac +#EXTINF:-1,FSOL - Imagined Friends +/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/15 - Imagined Friends.flac +#EXTINF:-1,Biosphere - Lysbotn +/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/11 - Lysbotn.flac +#EXTINF:-1,Boards of Canada - In a Beautiful Place Out in the Country +/app/music/Boards of Canada/In a Beautiful Place Out in the Country/01 Kid for Today.flac +#EXTINF:-1,Brian Eno - Making Gardens Out of Silence +/app/music/Brian Eno/2022 - ForeverAndEverNoMore/10 Making Gardens Out of Silence.flac +#EXTINF:-1,Tangerine Dream - Virtue Is Its Own Reward +/app/music/Tangerine Dream - Ambient Monkeys (flac)/10 Tangerine Dream - Virtue Is Its Own Reward.flac +#EXTINF:-1,Brian Eno - Small Craft On A Milk Sea +/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A3 Small Craft On A Milk Sea.flac +#EXTINF:-1,Tycho - Horizon +/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/02 - Horizon.flac +#EXTINF:-1,Four Tet - Two Thousand And Seventeen +/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/02 Two Thousand And Seventeen.flac +#EXTINF:-1,Boards of Canada - Dayvan Cowboy +/app/music/Boards of Canada/Trans Canada Highway/01 - Dayvan Cowboy.mp3 +#EXTINF:-1,Ulrich Schnauss - Melts into Air (2019 Version) +/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/01. Melts into Air (2019 Version).flac +#EXTINF:-1,arovane - olopp_eleen +/app/music/arovane - Wirkung (2020) [WEB FLAC16]/01. arovane - olopp_eleen.flac +#EXTINF:-1,Biosphere - Bergsbotn +/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/03 - Bergsbotn.flac +#EXTINF:-1,F.S.Blumm & Nils Frahm - Perff +/app/music/F.S Blumm and Nils Frahm - Music For Wobbling Music Versus Gravity (2013 - WEB - FLAC)/02. F.S.Blumm & Nils Frahm - Perff.flac +#EXTINF:-1,Clark - Simple Homecoming Loop +/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/02 - Simple Homecoming Loop.flac +#EXTINF:-1,Tycho - Slack +/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/03 - Slack.flac +#EXTINF:-1,Johann Johannsson - A Game Of Croquet +/app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/07 - A Game Of Croquet.flac +#EXTINF:-1,FSOL - Motioned +/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/04 - Motioned.flac +#EXTINF:-1,Brian Eno - Flint March +/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A4 Flint March.flac +#EXTINF:-1,Four Tet - Lush +/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/05 Lush.flac +#EXTINF:-1,Boards of Canada - Turquoise Hexagon Sun +/app/music/Boards of Canada/Hi Scores/02 - Turquoise Hexagon Sun.mp3 +#EXTINF:-1,Ulrich Schnauss - Love Grows Out of Thin Air (2019 Version) +/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/02. Love Grows Out of Thin Air (2019 Version).flac +#EXTINF:-1,arovane - wirkung +/app/music/arovane - Wirkung (2020) [WEB FLAC16]/02. arovane - wirkung.flac +#EXTINF:-1,Biosphere - Berg +/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/05 - Berg.flac +#EXTINF:-1,God is an Astronaut - Komorebi +/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/05. Komorebi.flac +#EXTINF:-1,Tycho - Weather +/app/music/Tycho - Simulcast (2020) [WEB FLAC]/01 - Weather.flac +#EXTINF:-1,Port Blue - Sunset Cruiser +/app/music/Port Blue - The Airship (2007)/04. Sunset Cruiser.flac +#EXTINF:-1,F.S.Blumm & Nils Frahm - Exercising Levitation +/app/music/F.S Blumm and Nils Frahm - Music For Wobbling Music Versus Gravity (2013 - WEB - FLAC)/07. F.S.Blumm & Nils Frahm - Exercising Levitation.flac +#EXTINF:-1,Four Tet - You Are Loved +/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/08 You Are Loved.flac +#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Asteroid 2467 +/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/01. Asteroid 2467.flac +#EXTINF:-1,Boards of Canada - Left Side Drive +/app/music/Boards of Canada/Trans Canada Highway/02 - Left Side Drive.mp3 +#EXTINF:-1,Brian Eno - Who Gives a Thought +/app/music/Brian Eno/2022 - ForeverAndEverNoMore/01 Who Gives a Thought.flac +#EXTINF:-1,arovane - find +/app/music/arovane - Wirkung (2020) [WEB FLAC16]/03. arovane - find.flac +#EXTINF:-1,Tycho - Receiver +/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/04 - Receiver.flac +#EXTINF:-1,Johann Johannsson - The Origins Of Time +/app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/08 - The Origins Of Time.flac +#EXTINF:-1,FSOL - Lichaen +/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/05 - Lichaen.flac +#EXTINF:-1,Biosphere - Kyle +/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/06 - Kyle.flac +#EXTINF:-1,Port Blue - The Grand Staircase +/app/music/Port Blue - The Airship (2007)/03. The Grand Staircase.flac +#EXTINF:-1,Vector Lovers - City Lights From A Train +/app/music/Vector Lovers/2005 - Capsule For One/01 - City Lights From A Train.mp3 +#EXTINF:-1,Four Tet - Teenage Birdsong +/app/music/Four Tet - Sixteen Oceans (2020) {Text Records - TEXT051} [CD FLAC]/04 - Four Tet - Teenage Birdsong.flac +#EXTINF:-1,Ulrich Schnauss - The Magic in You (2019 Version) +/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/03. The Magic in You (2019 Version).flac +#EXTINF:-1,Boards of Canada - Oirectine +/app/music/Boards of Canada/Twoism/02 - Oirectine.mp3 +#EXTINF:-1,Clark - Bench +/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/03 - Bench.flac +#EXTINF:-1,Tycho - Alright +/app/music/Tycho - Simulcast (2020) [WEB FLAC]/02 - Alright.flac +#EXTINF:-1,Brian Eno - Lesser Heaven +/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/C3 Lesser Heaven.flac +#EXTINF:-1,arovane - sloon +/app/music/arovane - Wirkung (2020) [WEB FLAC16]/05. arovane - sloon.flac +#EXTINF:-1,God is an Astronaut - Epitaph +/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/01. Epitaph.flac +#EXTINF:-1,F.S.Blumm & Nils Frahm - Silently Sharing +/app/music/F.S Blumm and Nils Frahm - Music For Wobbling Music Versus Gravity (2013 - WEB - FLAC)/10. F.S.Blumm & Nils Frahm - Silently Sharing.flac +#EXTINF:-1,Biosphere - Fjølhøgget +/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/07 - Fjølhøgget.flac +#EXTINF:-1,Port Blue - Over Atlantic City +/app/music/Port Blue - The Airship (2007)/02. Over Atlantic City.flac +#EXTINF:-1,Johann Johannsson - Viva Voce +/app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/09 - Viva Voce.flac +#EXTINF:-1,Tangerine Dream - Symphony in A-minor +/app/music/Tangerine Dream - Ambient Monkeys (flac)/02 Tangerine Dream - Symphony in A-minor.flac +#EXTINF:-1,Tycho - Epoch +/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/05 - Epoch.flac +#EXTINF:-1,Four Tet - Memories +/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/11 Memories.flac +#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Return To Burlington +/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/02. Return To Burlington.flac +#EXTINF:-1,Boards of Canada - Skyliner +/app/music/Boards of Canada/Trans Canada Highway/04 - Skyliner.mp3 +#EXTINF:-1,Vector Lovers - Melodies And Memory +/app/music/Vector Lovers/2005 - Capsule For One/07 - Melodies And Memory.mp3 +#EXTINF:-1,Brian Eno - Icarus or Blériot +/app/music/Brian Eno/2022 - ForeverAndEverNoMore/03 Icarus or Blériot.flac +#EXTINF:-1,arovane - noondt +/app/music/arovane - Wirkung (2020) [WEB FLAC16]/07. arovane - noondt.flac +#EXTINF:-1,FSOL - Symphony for Halia +/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/14 - Symphony for Halia.flac +#EXTINF:-1,Biosphere - Straumen +/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/12 - Straumen.flac +#EXTINF:-1,Port Blue - The Gentle Descent +/app/music/Port Blue - The Airship (2007)/12. The Gentle Descent.flac +#EXTINF:-1,Proem - As They Go +/app/music/Proem/2019 - As They Go/Proem - As They Go - 01 As They Go.flac +#EXTINF:-1,Tycho - Outer Sunset +/app/music/Tycho - Simulcast (2020) [WEB FLAC]/03 - Outer Sunset.flac +#EXTINF:-1,Four Tet - Planet +/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/14 Planet.flac +#EXTINF:-1,Ulrich Schnauss - New Day Starts at Dawn (2019 Version) +/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/07. New Day Starts at Dawn (2019 Version).flac +#EXTINF:-1,Clark - Goodnight Kiri +/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/14 - Goodnight Kiri.flac +#EXTINF:-1,Biosphere - Steinfjord +/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/13 - Steinfjord.flac +#EXTINF:-1,Tangerine Dream - Calyx Calamander +/app/music/Tangerine Dream - Ambient Monkeys (flac)/04 Tangerine Dream - Calyx Calamander.flac +#EXTINF:-1,Vector Lovers - To The Stars +/app/music/Vector Lovers/2005 - Capsule For One/12 - To The Stars.mp3 diff --git a/static/asteroid.css b/static/asteroid.css index fcda789..cf58547 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1703,6 +1703,57 @@ body.popout-body .status-mini{ font-size: 0.8em; } +.profile-header{ + display: flex; + gap: 30px; + align-items: flex-start; +} + +.avatar-section{ + flex-shrink: 0; +} + +.avatar-container{ + position: relative; + width: 120px; + height: 120px; + border-radius: 50%; + overflow: hidden; + border: 3px solid #00cc00; + cursor: pointer; + background: #1a1a1a; +} + +.avatar-image{ + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatar-overlay{ + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.7); + color: #00cc00; + text-align: center; + padding: 8px; + font-size: 0.8em; + opacity: 0; + -moz-transition: opacity 0.2s ease; + -o-transition: opacity 0.2s ease; + -webkit-transition: opacity 0.2s ease; + -ms-transition: opacity 0.2s ease; + transition: opacity 0.2s ease; +} + + + +.avatar-container:hover .avatar-overlay{ + opacity: 1; +} + .activity-chart{ padding: 15px; } diff --git a/static/asteroid.lass b/static/asteroid.lass index 6460b89..5bd0449 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -1360,6 +1360,47 @@ :padding "4px 8px" :font-size "0.8em")) + ;; Avatar styling + (.profile-header + :display "flex" + :gap "30px" + :align-items "flex-start") + + (.avatar-section + :flex-shrink "0") + + (.avatar-container + :position "relative" + :width "120px" + :height "120px" + :border-radius "50%" + :overflow "hidden" + :border "3px solid #00cc00" + :cursor "pointer" + :background "#1a1a1a") + + (.avatar-image + :width "100%" + :height "100%" + :object-fit "cover") + + (.avatar-overlay + :position "absolute" + :bottom "0" + :left "0" + :right "0" + :background "rgba(0, 0, 0, 0.7)" + :color "#00cc00" + :text-align "center" + :padding "8px" + :font-size "0.8em" + :opacity "0" + :transition "opacity 0.2s ease") + + ((:and .avatar-container :hover) + (.avatar-overlay + :opacity "1")) + ;; Activity chart styling (.activity-chart :padding "15px" diff --git a/static/avatars/.gitkeep b/static/avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/static/avatars/1.png b/static/avatars/1.png new file mode 100644 index 0000000000000000000000000000000000000000..95e48be0d3a9690b779dde0ac69c973826007818 GIT binary patch literal 14757 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 literal 0 HcmV?d00001 diff --git a/static/icons/default-avatar.svg b/static/icons/default-avatar.svg new file mode 100644 index 0000000..c26c3c5 --- /dev/null +++ b/static/icons/default-avatar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/template/profile.ctml b/template/profile.ctml index f829f43..dc149c3 100644 --- a/template/profile.ctml +++ b/template/profile.ctml @@ -21,22 +21,33 @@

🎧 User Profile

-
-
- Username: - user +
+
+
+ User Avatar +
+ Change +
+
+
-
- Role: - listener -
-
- Member Since: - 2024-01-01 -
-
- Last Active: - Today +
+
+ Username: + user +
+
+ Role: + listener +
+
+ Member Since: + 2024-01-01 +
+
+ Last Active: + Today +
diff --git a/user-management.lisp b/user-management.lisp index 25f3566..c4fb60e 100644 --- a/user-management.lisp +++ b/user-management.lisp @@ -174,12 +174,12 @@ ;; Not authenticated - emit error and signal to stop processing (progn (if is-api-request - ;; API request - emit JSON error with 401 status + ;; API request - return JSON error with 401 status using api-output (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")) + (api-output `(("status" . "error") + ("message" . "Authentication required")) + :status 401)) ;; Page request - redirect to login (progn (format t "Authentication failed - redirecting to login~%") diff --git a/user-profile.lisp b/user-profile.lisp index 1ddca88..edcc58c 100644 --- a/user-profile.lisp +++ b/user-profile.lisp @@ -73,6 +73,12 @@ ;;; Listening History - Per-user track play history ;;; ========================================================================== +(defun sql-escape-string (str) + "Escape a string for SQL by doubling single quotes" + (if str + (cl-ppcre:regex-replace-all "'" str "''") + "")) + (defun record-listen (user-id &key track-id track-title (duration 0) (completed nil)) "Record a track listen in user's history. Can use track-id or track-title." (with-db @@ -80,12 +86,12 @@ (postmodern:query (:raw (format nil "INSERT INTO listening_history (\"user-id\", \"track-id\", track_title, \"listen-duration\", completed) VALUES (~a, ~a, ~a, ~a, ~a)" user-id track-id - (if track-title (format nil "$$~a$$" track-title) "NULL") + (if track-title (format nil "'~a'" (sql-escape-string track-title)) "NULL") duration (if completed 1 0)))) (when track-title (postmodern:query - (:raw (format nil "INSERT INTO listening_history (\"user-id\", track_title, \"listen-duration\", completed) VALUES (~a, $$~a$$, ~a, ~a)" - user-id track-title duration (if completed 1 0)))))))) + (:raw (format nil "INSERT INTO listening_history (\"user-id\", track_title, \"listen-duration\", completed) VALUES (~a, '~a', ~a, ~a)" + user-id (sql-escape-string track-title) duration (if completed 1 0)))))))) (defun get-listening-history (user-id &key (limit 20) (offset 0)) "Get user's listening history - works with title-based history" @@ -230,8 +236,10 @@ (parse-integer track-id :junk-allowed t))) (duration-int (if duration (parse-integer duration :junk-allowed t) 0)) (completed-bool (and completed (string-equal completed "true")))) - (record-listen user-id :track-id track-id-int :track-title title - :duration (or duration-int 0) :completed completed-bool) + (format t "Recording listen: user-id=~a title=~a~%" user-id title) + (when (and user-id title) + (record-listen user-id :track-id track-id-int :track-title title + :duration (or duration-int 0) :completed completed-bool)) (api-output `(("status" . "success") ("message" . "Listen recorded")))))) @@ -256,3 +264,64 @@ `(("day" . ,(cdr (assoc :day a))) ("track_count" . ,(cdr (assoc :track-count a))))) activity))))))) + +;;; ========================================================================== +;;; Avatar Management +;;; ========================================================================== + +(defun get-avatars-directory () + "Get the path to the avatars directory" + (merge-pathnames "static/avatars/" (asdf:system-source-directory :asteroid))) + +(defun save-avatar (user-id temp-file-path original-filename) + "Save an avatar file from temp path and return the relative path" + (let* ((extension (pathname-type original-filename)) + (safe-ext (if (member extension '("png" "jpg" "jpeg" "gif" "webp") :test #'string-equal) + extension + "png")) + (new-filename (format nil "~a.~a" user-id safe-ext)) + (full-path (merge-pathnames new-filename (get-avatars-directory))) + (relative-path (format nil "/asteroid/static/avatars/~a" new-filename))) + ;; Copy from temp file to avatars directory + (uiop:copy-file temp-file-path full-path) + ;; Update database + (with-db + (postmodern:query + (:raw (format nil "UPDATE \"USERS\" SET avatar_path = '~a' WHERE _id = ~a" + relative-path user-id)))) + relative-path)) + +(defun get-user-avatar (user-id) + "Get the avatar path for a user" + (with-db + (postmodern:query + (:raw (format nil "SELECT avatar_path FROM \"USERS\" WHERE _id = ~a" user-id)) + :single))) + +(define-api asteroid/user/avatar/upload () () + "Upload a new avatar image" + (require-authentication) + (with-error-handling + (let* ((user-id (session:field "user-id")) + ;; Radiance wraps hunchentoot - post-var returns (path filename content-type) for files + (file-info (radiance:post-var "avatar")) + (temp-path (when (listp file-info) (first file-info))) + (original-name (when (listp file-info) (second file-info)))) + (format t "Avatar upload: file-info=~a temp-path=~a original-name=~a~%" file-info temp-path original-name) + (if (and temp-path (probe-file temp-path)) + (let ((avatar-path (save-avatar user-id temp-path (or original-name "avatar.png")))) + (api-output `(("status" . "success") + ("message" . "Avatar uploaded successfully") + ("avatar_path" . ,avatar-path)))) + (api-output `(("status" . "error") + ("message" . "No file provided")) + :status 400))))) + +(define-api asteroid/user/avatar () () + "Get current user's avatar path" + (require-authentication) + (with-error-handling + (let* ((user-id (session:field "user-id")) + (avatar-path (get-user-avatar user-id))) + (api-output `(("status" . "success") + ("avatar_path" . ,avatar-path)))))) From 0359e5909a8cd7148d3835a8853a3707e9a09400 Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 21 Dec 2025 12:45:49 +0300 Subject: [PATCH 05/12] feat: Track requests, listening history, and profile enhancements Track Requests: - Database table for user track requests (migration 007) - API endpoints for submit, approve, reject, play - Front page UI for submitting requests - Shows recently played requests section Listening History: - Auto-records tracks when playing (with 60s deduplication) - Recently Played section on profile (has date formatting issues) - Activity chart showing listening patterns by day - Load More Tracks pagination Profile Improvements: - Fixed 401 errors returning proper JSON - Fixed PostgreSQL boolean type for completed column - Added offset parameter to recent-tracks API Note: Recently Played section has date formatting issues showing '20397 days ago' - may be removed in future commit if not needed. The listening history backend works correctly. For production: run migrations/007-track-requests.sql --- asteroid.asd | 1 + asteroid.lisp | 7 +- migrations/007-track-requests.sql | 31 +++++ parenscript/front-page.lisp | 72 +++++++++- parenscript/profile.lisp | 76 ++++++++--- static/asteroid.css | 88 ++++++++++++ static/asteroid.lass | 74 ++++++++++ static/avatars/5.png | Bin 0 -> 14757 bytes template/front-page.ctml | 20 +++ track-requests.lisp | 219 ++++++++++++++++++++++++++++++ user-profile.lisp | 73 ++++++---- 11 files changed, 613 insertions(+), 48 deletions(-) create mode 100644 migrations/007-track-requests.sql create mode 100644 static/avatars/5.png create mode 100644 track-requests.lisp diff --git a/asteroid.asd b/asteroid.asd index 305afd1..f09308b 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -64,6 +64,7 @@ (:file "playlist-scheduler") (:file "listener-stats") (:file "user-profile") + (:file "track-requests") (:file "auth-routes") (:file "frontend-partials") (:file "asteroid"))) diff --git a/asteroid.lisp b/asteroid.lisp index 8940159..a88e22c 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -1167,13 +1167,14 @@ ("session_count" . 0) ("favorite_genre" . "Ambient")))))))) -(define-api asteroid/user/recent-tracks (&optional (limit "3")) () +(define-api asteroid/user/recent-tracks (&optional (limit "3") (offset "0")) () "Get recently played tracks for user" (require-authentication) (with-error-handling (let* ((user-id (session:field "user-id")) - (limit-int (parse-integer limit :junk-allowed t)) - (history (get-listening-history user-id :limit (or limit-int 3)))) + (limit-int (or (parse-integer limit :junk-allowed t) 3)) + (offset-int (or (parse-integer offset :junk-allowed t) 0)) + (history (get-listening-history user-id :limit limit-int :offset offset-int))) (api-output `(("status" . "success") ("tracks" . ,(mapcar (lambda (h) `(("title" . ,(or (cdr (assoc :track-title h)) diff --git a/migrations/007-track-requests.sql b/migrations/007-track-requests.sql new file mode 100644 index 0000000..50a984c --- /dev/null +++ b/migrations/007-track-requests.sql @@ -0,0 +1,31 @@ +-- Migration 007: Track Request System +-- Allows users to request tracks for the stream with social attribution + +-- Track requests table +CREATE TABLE IF NOT EXISTS track_requests ( + _id SERIAL PRIMARY KEY, + "user-id" INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE, + track_title TEXT NOT NULL, -- Track title (Artist - Title format) + track_path TEXT, -- Optional: path to file if known + message TEXT, -- Optional message from requester + status TEXT DEFAULT 'pending', -- pending, approved, rejected, played + "created-at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "reviewed-at" TIMESTAMP, -- When admin reviewed + "reviewed-by" INTEGER REFERENCES "USERS"(_id), + "played-at" TIMESTAMP -- When it was actually played +); + +-- Create indexes for efficient queries +CREATE INDEX IF NOT EXISTS idx_track_requests_user_id ON track_requests("user-id"); +CREATE INDEX IF NOT EXISTS idx_track_requests_status ON track_requests(status); +CREATE INDEX IF NOT EXISTS idx_track_requests_created ON track_requests("created-at"); + +-- Grant permissions +GRANT ALL PRIVILEGES ON track_requests TO asteroid; +GRANT ALL PRIVILEGES ON SEQUENCE track_requests__id_seq TO asteroid; + +-- Verification +DO $$ +BEGIN + RAISE NOTICE 'Migration 007: Track requests table created successfully!'; +END $$; diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index e62da0a..cadbff4 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -721,7 +721,77 @@ (when (and *popout-window* (ps:@ *popout-window* closed)) (update-popout-button nil) (setf *popout-window* nil))) - 1000))) + 1000) + + ;; Track Request Functions + (defun submit-track-request () + (let ((title-input (ps:chain document (get-element-by-id "request-title"))) + (message-input (ps:chain document (get-element-by-id "request-message"))) + (status-div (ps:chain document (get-element-by-id "request-status")))) + (when (and title-input message-input status-div) + (let ((title (ps:@ title-input value)) + (message (ps:@ message-input value))) + (if (or (not title) (= title "")) + (progn + (setf (ps:@ status-div style display) "block") + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Please enter a track title")) + (progn + (setf (ps:@ status-div style display) "block") + (setf (ps:@ status-div class-name) "request-status info") + (setf (ps:@ status-div text-content) "Submitting request...") + (ps:chain + (fetch (+ "/api/asteroid/requests/submit?title=" (encode-u-r-i-component title) + (if message (+ "&message=" (encode-u-r-i-component message)) "")) + (ps:create :method "POST")) + (then (lambda (response) + (if (ps:@ response ok) + (ps:chain response (json)) + (progn + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Please log in to submit requests") + nil)))) + (then (lambda (data) + (when data + (let ((status (or (ps:@ data data status) (ps:@ data status)))) + (if (= status "success") + (progn + (setf (ps:@ status-div class-name) "request-status success") + (setf (ps:@ status-div text-content) "Request submitted! An admin will review it soon.") + (setf (ps:@ title-input value) "") + (setf (ps:@ message-input value) "")) + (progn + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Failed to submit request"))))))) + (catch (lambda (error) + (ps:chain console (error "Error submitting request:" error)) + (setf (ps:@ status-div class-name) "request-status error") + (setf (ps:@ status-div text-content) "Error submitting request")))))))))) + + (defun load-recent-requests () + (let ((container (ps:chain document (get-element-by-id "recent-requests-list")))) + (when container + (ps:chain + (fetch "/api/asteroid/requests/recent") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (and (= (ps:@ data status) "success") + (ps:@ data requests) + (> (ps:@ data requests length) 0)) + (let ((html "")) + (ps:chain (ps:@ data requests) (for-each (lambda (req) + (setf html (+ html "
" + "" (ps:@ req title) "" + "Requested by @" (ps:@ req username) "" + "
"))))) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) "

No recent requests yet. Be the first!

"))))) + (catch (lambda (error) + (ps:chain console (log "Could not load recent requests:" error)))))))) + + ;; Load recent requests on page load + (load-recent-requests))) "Compiled JavaScript for front-page - generated at load time") (defun generate-front-page-js () diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index a6da081..dd7c954 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -32,20 +32,31 @@ :day "numeric"))))) (defun format-relative-time (date-string) - (let* ((date (ps:new (-date date-string))) - (now (ps:new (-date))) - (diff-ms (- now date)) - (diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24))))) - (diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60))))) - (diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60)))))) - (cond - ((> diff-days 0) - (+ diff-days " day" (if (> diff-days 1) "s" "") " ago")) - ((> diff-hours 0) - (+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago")) - ((> diff-minutes 0) - (+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago")) - (t "Just now")))) + (when (not date-string) + (return-from format-relative-time "Unknown")) + ;; Convert PostgreSQL timestamp format to ISO format + ;; "2025-12-21 09:22:58.215986" -> "2025-12-21T09:22:58.215986Z" + (let* ((iso-string (if (and (ps:@ date-string replace) + (ps:chain date-string (includes " "))) + (+ (ps:chain date-string (replace " " "T")) "Z") + date-string)) + (date (ps:new (-date iso-string))) + (now (ps:new (-date)))) + ;; Check if date is valid + (when (ps:chain -number (is-na-n (ps:chain date (get-time)))) + (return-from format-relative-time "Recently")) + (let* ((diff-ms (- now date)) + (diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24))))) + (diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60))))) + (diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60)))))) + (cond + ((> diff-days 0) + (+ diff-days " day" (if (> diff-days 1) "s" "") " ago")) + ((> diff-hours 0) + (+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago")) + ((> diff-minutes 0) + (+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago")) + (t "Just now"))))) (defun format-duration (seconds) (let ((hours (ps:chain -math (floor (/ seconds 3600)))) @@ -297,8 +308,13 @@ (ps:chain activity (for-each (lambda (day) (let* ((count (or (ps:@ day track_count) 0)) (height (ps:chain -math (round (* (/ count max-count) 100)))) - (date-str (ps:@ day day)) - (date-parts (ps:chain date-str (split "-"))) + (date-raw (ps:@ day day)) + (date-str (if (and date-raw (ps:@ date-raw to-string)) + (ps:chain date-raw (to-string)) + (+ "" date-raw))) + (date-parts (if (and date-str (ps:@ date-str split)) + (ps:chain date-str (split "-")) + (array))) (day-label (if (> (ps:@ date-parts length) 2) (ps:getprop date-parts 2) ""))) @@ -345,10 +361,36 @@ (load-activity-chart) (load-avatar)) + ;; Track offset for pagination + (defvar *recent-tracks-offset* 3) + ;; Action functions (defun load-more-recent-tracks () (ps:chain console (log "Loading more recent tracks...")) - (show-message "Loading more tracks..." "info")) + (ps:chain + (fetch (+ "/api/asteroid/user/recent-tracks?limit=10&offset=" *recent-tracks-offset*)) + (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 "recent-tracks-list")))) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data tracks) + (> (ps:@ data tracks length) 0)) + (progn + (ps:chain (ps:@ data tracks) (for-each (lambda (track) + (let ((item (ps:chain document (create-element "div")))) + (setf (ps:@ item class-name) "track-item") + (setf (ps:@ item inner-h-t-m-l) + (+ "" (or (ps:@ track title) "Unknown") "" + "" (or (ps:@ track played_at) "") "")) + (ps:chain container (append-child item)))))) + (setf *recent-tracks-offset* (+ *recent-tracks-offset* (ps:@ data tracks length))) + (show-message (+ "Loaded " (ps:@ data tracks length) " more tracks") "success")) + (show-message "No more tracks to load" "info")))))) + (catch (lambda (error) + (ps:chain console (error "Error loading more tracks:" error)) + (show-message "Error loading tracks" "error"))))) (defun edit-profile () (ps:chain console (log "Edit profile clicked")) diff --git a/static/asteroid.css b/static/asteroid.css index cf58547..77b3c5a 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1754,6 +1754,94 @@ body.popout-body .status-mini{ opacity: 1; } +.request-panel{ + background: rgba(0, 255, 0, 0.05); + border: 1px solid #333; + border-radius: 8px; + padding: 20px; + margin-top: 20px; +} + +.request-description{ + color: #888; + margin-bottom: 15px; +} + +.request-form{ + display: flex; + flex-direction: column; + gap: 10px; +} + +.request-input{ + background: #1a1a1a; + border: 1px solid #333; + border-radius: 4px; + padding: 10px; + color: #00cc00; + font-size: 1em; +} + +.request-input:focus{ + border-color: #00cc00; + outline: none; +} + +.request-status{ + padding: 10px; + border-radius: 4px; + margin-top: 10px; + text-align: center; +} + +.request-status.success{ + background: rgba(0, 255, 0, 0.2); + color: #00ff00; +} + +.request-status.error{ + background: rgba(255, 0, 0, 0.2); + color: #ff6b6b; +} + +.request-status.info{ + background: rgba(0, 150, 255, 0.2); + color: #66b3ff; +} + +.recent-requests{ + margin-top: 20px; + border-top: 1px solid #333; + padding-top: 15px; +} + +.recent-requests h4{ + color: #888; + margin-bottom: 10px; +} + +.request-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #222; +} + +.request-title{ + color: #00cc00; +} + +.request-by{ + color: #666; + font-size: 0.9em; +} + +.no-requests{ + color: #666; + font-style: italic; +} + .activity-chart{ padding: 15px; } diff --git a/static/asteroid.lass b/static/asteroid.lass index 5bd0449..1c5b0ac 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -1401,6 +1401,80 @@ (.avatar-overlay :opacity "1")) + ;; Track Request styling + (.request-panel + :background "rgba(0, 255, 0, 0.05)" + :border "1px solid #333" + :border-radius "8px" + :padding "20px" + :margin-top "20px") + + (.request-description + :color "#888" + :margin-bottom "15px") + + (.request-form + :display "flex" + :flex-direction "column" + :gap "10px") + + (.request-input + :background "#1a1a1a" + :border "1px solid #333" + :border-radius "4px" + :padding "10px" + :color "#00cc00" + :font-size "1em") + + ((:and .request-input :focus) + :border-color "#00cc00" + :outline "none") + + (.request-status + :padding "10px" + :border-radius "4px" + :margin-top "10px" + :text-align "center") + + ((:and .request-status .success) + :background "rgba(0, 255, 0, 0.2)" + :color "#00ff00") + + ((:and .request-status .error) + :background "rgba(255, 0, 0, 0.2)" + :color "#ff6b6b") + + ((:and .request-status .info) + :background "rgba(0, 150, 255, 0.2)" + :color "#66b3ff") + + (.recent-requests + :margin-top "20px" + :border-top "1px solid #333" + :padding-top "15px" + + (h4 + :color "#888" + :margin-bottom "10px")) + + (.request-item + :display "flex" + :justify-content "space-between" + :align-items "center" + :padding "8px 0" + :border-bottom "1px solid #222") + + (.request-title + :color "#00cc00") + + (.request-by + :color "#666" + :font-size "0.9em") + + (.no-requests + :color "#666" + :font-style "italic") + ;; Activity chart styling (.activity-chart :padding "15px" diff --git a/static/avatars/5.png b/static/avatars/5.png new file mode 100644 index 0000000000000000000000000000000000000000..95e48be0d3a9690b779dde0ac69c973826007818 GIT binary patch literal 14757 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 literal 0 HcmV?d00001 diff --git a/template/front-page.ctml b/template/front-page.ctml index 77994cd..a34913c 100644 --- a/template/front-page.ctml +++ b/template/front-page.ctml @@ -125,6 +125,26 @@

Loading...

+ + +
+

🎵 Request a Track

+

Want to hear something specific? Submit a request!

+
+ + + +
+ + + +
+

Recently Played Requests

+
+

No recent requests

+
+
+
diff --git a/track-requests.lisp b/track-requests.lisp new file mode 100644 index 0000000..bc603d4 --- /dev/null +++ b/track-requests.lisp @@ -0,0 +1,219 @@ +(in-package #:asteroid) + +;;; ========================================================================== +;;; Track Request System +;;; Allows users to request tracks with social attribution +;;; ========================================================================== + +(defun sql-escape (str) + "Escape a string for SQL by doubling single quotes" + (if str + (cl-ppcre:regex-replace-all "'" str "''") + "")) + +;;; ========================================================================== +;;; Database Functions +;;; ========================================================================== + +(defun create-track-request (user-id track-title &key track-path message) + "Create a new track request" + (with-db + (postmodern:query + (:raw (format nil "INSERT INTO track_requests (\"user-id\", track_title, track_path, message, status) VALUES (~a, '~a', ~a, ~a, 'pending') RETURNING _id" + user-id + (sql-escape track-title) + (if track-path (format nil "'~a'" (sql-escape track-path)) "NULL") + (if message (format nil "'~a'" (sql-escape message)) "NULL"))) + :single))) + +(defun get-pending-requests (&key (limit 50)) + "Get all pending track requests for admin review" + (with-db + (postmodern:query + (:raw (format nil "SELECT r._id, r.track_title, r.track_path, r.message, r.status, r.\"created-at\", u.username + FROM track_requests r + JOIN \"USERS\" u ON r.\"user-id\" = u._id + WHERE r.status = 'pending' + ORDER BY r.\"created-at\" ASC + LIMIT ~a" limit)) + :alists))) + +(defun get-user-requests (user-id &key (limit 20)) + "Get a user's track requests" + (with-db + (postmodern:query + (:raw (format nil "SELECT _id, track_title, message, status, \"created-at\", \"played-at\" + FROM track_requests + WHERE \"user-id\" = ~a + ORDER BY \"created-at\" DESC + LIMIT ~a" user-id limit)) + :alists))) + +(defun get-recent-played-requests (&key (limit 10)) + "Get recently played requests with user attribution" + (with-db + (postmodern:query + (:raw (format nil "SELECT r._id, r.track_title, r.\"played-at\", u.username, u.avatar_path + FROM track_requests r + JOIN \"USERS\" u ON r.\"user-id\" = u._id + WHERE r.status = 'played' + ORDER BY r.\"played-at\" DESC + LIMIT ~a" limit)) + :alists))) + +(defun approve-request (request-id admin-id) + "Approve a track request" + (with-db + (postmodern:query + (:raw (format nil "UPDATE track_requests SET status = 'approved', \"reviewed-at\" = NOW(), \"reviewed-by\" = ~a WHERE _id = ~a" + admin-id request-id))))) + +(defun reject-request (request-id admin-id) + "Reject a track request" + (with-db + (postmodern:query + (:raw (format nil "UPDATE track_requests SET status = 'rejected', \"reviewed-at\" = NOW(), \"reviewed-by\" = ~a WHERE _id = ~a" + admin-id request-id))))) + +(defun mark-request-played (request-id) + "Mark a request as played" + (with-db + (postmodern:query + (:raw (format nil "UPDATE track_requests SET status = 'played', \"played-at\" = NOW() WHERE _id = ~a" + request-id))))) + +(defun get-request-by-id (request-id) + "Get a single request by ID" + (with-db + (postmodern:query + (:raw (format nil "SELECT r.*, u.username FROM track_requests r JOIN \"USERS\" u ON r.\"user-id\" = u._id WHERE r._id = ~a" + request-id)) + :alist))) + +(defun get-approved-requests (&key (limit 20)) + "Get approved requests ready to be queued" + (with-db + (postmodern:query + (:raw (format nil "SELECT r._id, r.track_title, r.track_path, u.username + FROM track_requests r + JOIN \"USERS\" u ON r.\"user-id\" = u._id + WHERE r.status = 'approved' + ORDER BY r.\"reviewed-at\" ASC + LIMIT ~a" limit)) + :alists))) + +;;; ========================================================================== +;;; API Endpoints - User +;;; ========================================================================== + +(define-api asteroid/requests/submit (title &optional message) () + "Submit a track request" + (require-authentication) + (with-error-handling + (let* ((user-id (session:field "user-id")) + (request-id (create-track-request user-id title :message message))) + (if request-id + (api-output `(("status" . "success") + ("message" . "Request submitted!") + ("request_id" . ,request-id))) + (api-output `(("status" . "error") + ("message" . "Failed to submit request")) + :status 500))))) + +(define-api asteroid/requests/my () () + "Get current user's requests" + (require-authentication) + (with-error-handling + (let* ((user-id (session:field "user-id")) + (requests (get-user-requests user-id))) + (api-output `(("status" . "success") + ("requests" . ,(mapcar (lambda (r) + `(("id" . ,(cdr (assoc :_id r))) + ("title" . ,(cdr (assoc :track-title r))) + ("message" . ,(cdr (assoc :message r))) + ("status" . ,(cdr (assoc :status r))) + ("created_at" . ,(cdr (assoc :created-at r))) + ("played_at" . ,(cdr (assoc :played-at r))))) + requests))))))) + +(define-api asteroid/requests/recent () () + "Get recently played requests (public)" + (with-error-handling + (let ((requests (get-recent-played-requests))) + (api-output `(("status" . "success") + ("requests" . ,(mapcar (lambda (r) + `(("id" . ,(cdr (assoc :_id r))) + ("title" . ,(cdr (assoc :track-title r))) + ("username" . ,(cdr (assoc :username r))) + ("avatar" . ,(cdr (assoc :avatar-path r))) + ("played_at" . ,(cdr (assoc :played-at r))))) + requests))))))) + +;;; ========================================================================== +;;; API Endpoints - Admin +;;; ========================================================================== + +(define-api asteroid/admin/requests/pending () () + "Get pending requests for admin review" + (require-role :admin) + (with-error-handling + (let ((requests (get-pending-requests))) + (api-output `(("status" . "success") + ("requests" . ,(mapcar (lambda (r) + `(("id" . ,(cdr (assoc :_id r))) + ("title" . ,(cdr (assoc :track-title r))) + ("path" . ,(cdr (assoc :track-path r))) + ("message" . ,(cdr (assoc :message r))) + ("username" . ,(cdr (assoc :username r))) + ("created_at" . ,(cdr (assoc :created-at r))))) + requests))))))) + +(define-api asteroid/admin/requests/approved () () + "Get approved requests ready to queue" + (require-role :admin) + (with-error-handling + (let ((requests (get-approved-requests))) + (api-output `(("status" . "success") + ("requests" . ,(mapcar (lambda (r) + `(("id" . ,(cdr (assoc :_id r))) + ("title" . ,(cdr (assoc :track-title r))) + ("path" . ,(cdr (assoc :track-path r))) + ("username" . ,(cdr (assoc :username r))))) + requests))))))) + +(define-api asteroid/admin/requests/approve (id) () + "Approve a track request" + (require-role :admin) + (with-error-handling + (let ((admin-id (session:field "user-id")) + (request-id (parse-integer id :junk-allowed t))) + (approve-request request-id admin-id) + (api-output `(("status" . "success") + ("message" . "Request approved")))))) + +(define-api asteroid/admin/requests/reject (id) () + "Reject a track request" + (require-role :admin) + (with-error-handling + (let ((admin-id (session:field "user-id")) + (request-id (parse-integer id :junk-allowed t))) + (reject-request request-id admin-id) + (api-output `(("status" . "success") + ("message" . "Request rejected")))))) + +(define-api asteroid/admin/requests/play (id) () + "Mark a request as played and add to queue" + (require-role :admin) + (with-error-handling + (let* ((request-id (parse-integer id :junk-allowed t)) + (request (get-request-by-id request-id))) + (if request + (progn + (mark-request-played request-id) + (api-output `(("status" . "success") + ("message" . "Request marked as played") + ("title" . ,(cdr (assoc :track-title request))) + ("username" . ,(cdr (assoc :username request)))))) + (api-output `(("status" . "error") + ("message" . "Request not found")) + :status 404))))) diff --git a/user-profile.lisp b/user-profile.lisp index edcc58c..09ef5d6 100644 --- a/user-profile.lisp +++ b/user-profile.lisp @@ -80,18 +80,27 @@ "")) (defun record-listen (user-id &key track-id track-title (duration 0) (completed nil)) - "Record a track listen in user's history. Can use track-id or track-title." + "Record a track listen in user's history. Can use track-id or track-title. + Prevents duplicate entries for the same track within 60 seconds." (with-db - (if track-id - (postmodern:query - (:raw (format nil "INSERT INTO listening_history (\"user-id\", \"track-id\", track_title, \"listen-duration\", completed) VALUES (~a, ~a, ~a, ~a, ~a)" - user-id track-id - (if track-title (format nil "'~a'" (sql-escape-string track-title)) "NULL") - duration (if completed 1 0)))) - (when track-title - (postmodern:query - (:raw (format nil "INSERT INTO listening_history (\"user-id\", track_title, \"listen-duration\", completed) VALUES (~a, '~a', ~a, ~a)" - user-id (sql-escape-string track-title) duration (if completed 1 0)))))))) + ;; Check for recent duplicate (same user + same title within 60 seconds) + (let ((recent-exists + (when track-title + (postmodern:query + (:raw (format nil "SELECT 1 FROM listening_history WHERE \"user-id\" = ~a AND track_title = '~a' AND \"listened-at\" > NOW() - INTERVAL '60 seconds' LIMIT 1" + user-id (sql-escape-string track-title))) + :single)))) + (unless recent-exists + (if track-id + (postmodern:query + (:raw (format nil "INSERT INTO listening_history (\"user-id\", \"track-id\", track_title, \"listen-duration\", completed) VALUES (~a, ~a, ~a, ~a, ~a)" + user-id track-id + (if track-title (format nil "'~a'" (sql-escape-string track-title)) "NULL") + duration (if completed "TRUE" "FALSE")))) + (when track-title + (postmodern:query + (:raw (format nil "INSERT INTO listening_history (\"user-id\", track_title, \"listen-duration\", completed) VALUES (~a, '~a', ~a, ~a)" + user-id (sql-escape-string track-title) duration (if completed "TRUE" "FALSE")))))))))) (defun get-listening-history (user-id &key (limit 20) (offset 0)) "Get user's listening history - works with title-based history" @@ -224,24 +233,34 @@ (and c (= 1 c)))))) history))))))) +(defun get-session-user-id () + "Get user-id from session, handling BIT type from PostgreSQL" + (let ((user-id-raw (session:field "user-id"))) + (cond + ((null user-id-raw) nil) + ((integerp user-id-raw) user-id-raw) + ((stringp user-id-raw) (parse-integer user-id-raw :junk-allowed t)) + ((bit-vector-p user-id-raw) (parse-integer (format nil "~a" user-id-raw) :junk-allowed t)) + (t (handler-case (parse-integer (format nil "~a" user-id-raw) :junk-allowed t) + (error () nil)))))) + (define-api asteroid/user/history/record (&optional track-id title duration completed) () "Record a track listen (called by player). 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))) - (duration-int (if duration (parse-integer duration :junk-allowed t) 0)) - (completed-bool (and completed (string-equal completed "true")))) - (format t "Recording listen: user-id=~a title=~a~%" user-id title) - (when (and user-id title) - (record-listen user-id :track-id track-id-int :track-title title - :duration (or duration-int 0) :completed completed-bool)) - (api-output `(("status" . "success") - ("message" . "Listen recorded")))))) + (let ((user-id (get-session-user-id))) + (if (null user-id) + (api-output `(("status" . "error") + ("message" . "Not authenticated")) + :status 401) + (with-error-handling + (let* ((track-id-int (when (and track-id (not (string= track-id ""))) + (parse-integer track-id :junk-allowed t))) + (duration-int (if duration (parse-integer duration :junk-allowed t) 0)) + (completed-bool (and completed (string-equal completed "true")))) + (when title + (record-listen user-id :track-id track-id-int :track-title title + :duration (or duration-int 0) :completed completed-bool)) + (api-output `(("status" . "success") + ("message" . "Listen recorded")))))))) (define-api asteroid/user/history/clear () () "Clear user's listening history" From 2c49092c018c484c2f8309058125fa90730a1da6 Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 21 Dec 2025 12:53:10 +0300 Subject: [PATCH 06/12] refactor: Remove Recently Played section from profile page Removed the Recently Played UI section from profile as redundant. The listening history backend and APIs remain intact for future use. Previous commit (0359e59) preserves the full implementation. --- parenscript/profile.lisp | 62 ---------------------------------------- template/profile.ctml | 40 -------------------------- 2 files changed, 102 deletions(-) diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index dd7c954..70d69dd 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -120,37 +120,6 @@ (update-element "session-count" "0") (update-element "favorite-genre" "Unknown"))))) - (defun load-recent-tracks () - (ps:chain - (fetch "/api/asteroid/user/recent-tracks?limit=3") - (then (lambda (response) (ps:chain response (json)))) - (then (lambda (result) - (let ((data (or (ps:@ result data) result))) - (if (and (= (ps:@ data status) "success") - (ps:@ data tracks) - (> (ps:@ data tracks length) 0)) - (ps:chain data tracks - (for-each (lambda (track index) - (let ((track-num (+ index 1))) - (update-element (+ "recent-track-" track-num "-title") - (or (ps:@ track title) "Unknown Track")) - (update-element (+ "recent-track-" track-num "-artist") - (or (ps:@ track artist) "Unknown Artist")) - (update-element (+ "recent-track-" track-num "-duration") - (format-duration (or (ps:@ track duration) 0))) - (update-element (+ "recent-track-" track-num "-played-at") - (format-relative-time (ps:@ track played_at))))))) - (loop for i from 1 to 3 - do (let* ((track-item-selector (+ "[data-text=\"recent-track-" i "-title\"]")) - (track-item-el (ps:chain document (query-selector track-item-selector))) - (track-item (when track-item-el (ps:chain track-item-el (closest ".track-item"))))) - (when (and track-item - (or (not (ps:@ data tracks)) - (not (ps:getprop (ps:@ data tracks) (- i 1))))) - (setf (ps:@ track-item style display) "none")))))))) - (catch (lambda (error) - (ps:chain console (error "Error loading recent tracks:" error)))))) - (defun load-top-artists () (ps:chain (fetch "/api/asteroid/user/top-artists?limit=5") @@ -355,43 +324,12 @@ (show-error "Error loading profile data")))) (load-listening-stats) - (load-recent-tracks) (load-favorites) (load-top-artists) (load-activity-chart) (load-avatar)) - ;; Track offset for pagination - (defvar *recent-tracks-offset* 3) - ;; Action functions - (defun load-more-recent-tracks () - (ps:chain console (log "Loading more recent tracks...")) - (ps:chain - (fetch (+ "/api/asteroid/user/recent-tracks?limit=10&offset=" *recent-tracks-offset*)) - (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 "recent-tracks-list")))) - (when container - (if (and (= (ps:@ data status) "success") - (ps:@ data tracks) - (> (ps:@ data tracks length) 0)) - (progn - (ps:chain (ps:@ data tracks) (for-each (lambda (track) - (let ((item (ps:chain document (create-element "div")))) - (setf (ps:@ item class-name) "track-item") - (setf (ps:@ item inner-h-t-m-l) - (+ "" (or (ps:@ track title) "Unknown") "" - "" (or (ps:@ track played_at) "") "")) - (ps:chain container (append-child item)))))) - (setf *recent-tracks-offset* (+ *recent-tracks-offset* (ps:@ data tracks length))) - (show-message (+ "Loaded " (ps:@ data tracks length) " more tracks") "success")) - (show-message "No more tracks to load" "info")))))) - (catch (lambda (error) - (ps:chain console (error "Error loading more tracks:" error)) - (show-message "Error loading tracks" "error"))))) - (defun edit-profile () (ps:chain console (log "Edit profile clicked")) (show-message "Profile editing coming soon!" "info")) diff --git a/template/profile.ctml b/template/profile.ctml index dc149c3..b83ab7d 100644 --- a/template/profile.ctml +++ b/template/profile.ctml @@ -75,46 +75,6 @@
- -
-

🎵 Recently Played

-
-
-
- No recent tracks - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- -
-
-

❤️ Favorite Tracks

From 02e585bd5b23e391c3e88eb23f8132bb374b1c00 Mon Sep 17 00:00:00 2001 From: glenneth Date: Sun, 21 Dec 2025 18:45:35 +0300 Subject: [PATCH 07/12] feat: Custom user playlists with submission and admin review - Add user playlist creation, editing, and track management - Add library browser for adding tracks to playlists - Add playlist submission workflow for station airing - Add admin review interface with preview, approve, reject - Generate M3U files on approval in playlists/user-submissions/ - Include user-submissions in playlist scheduler dropdown - Use playlist description as PHASE tag in M3U - Add database migration for user_playlists table - Update TODO-next-features.org to mark feature complete --- TODO-next-features.org | 12 +- asteroid.asd | 1 + database.lisp | 12 + frontend-partials.lisp | 17 +- migrations/008-user-playlists.sql | 30 ++ parenscript/admin.lisp | 222 +++++++++ parenscript/front-page.lisp | 61 ++- parenscript/profile.lisp | 434 +++++++++++++++++- parenscript/stream-player.lisp | 67 ++- playlist-scheduler.lisp | 18 +- .../user-submissions/admin-glenneth-1.m3u | 25 + static/asteroid.css | 426 +++++++++++++++++ static/asteroid.lass | 362 +++++++++++++++ template/admin.ctml | 25 + template/audio-player-frame.ctml | 1 + template/front-page-content.ctml | 18 + template/front-page.ctml | 4 +- template/partial/now-playing.ctml | 2 + template/profile.ctml | 91 ++++ track-requests.lisp | 27 ++ user-management.lisp | 4 + user-playlists.lisp | 421 +++++++++++++++++ user-profile.lisp | 15 + 23 files changed, 2260 insertions(+), 35 deletions(-) create mode 100644 migrations/008-user-playlists.sql create mode 100644 playlists/user-submissions/admin-glenneth-1.m3u create mode 100644 user-playlists.lisp diff --git a/TODO-next-features.org b/TODO-next-features.org index e3d59f5..8eeb9bb 100644 --- a/TODO-next-features.org +++ b/TODO-next-features.org @@ -33,15 +33,15 @@ 2) [ ] Make calendar editable, reschedule, ammend &c 3) [ ] Add bumpers to landing page for scheduled programs -4) [0/8] User Profile pages - 1) [ ] avatars +4) [5/8] User Profile pages + 1) [X] avatars 2) [ ] default playlist - 3) [ ] tarted up 'now playing' with highlights of previously upvoted tracks + 3) [X] tarted up 'now playing' with highlights of previously upvoted tracks 4) [ ] polls - 5) [ ] Listener requests interface + 5) [X] Listener requests interface 6) [ ] Calendar of upcoming scheduled 'shows' - 7) [ ] requests - 8) [ ] Custom user playlists, with submission for station airing + 7) [X] requests + 8) [X] Custom user playlists, with submission for station airing 5) [0/2] Shuffle/Random queue 1) [ ] randomly run the whole library diff --git a/asteroid.asd b/asteroid.asd index f09308b..7cae886 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -64,6 +64,7 @@ (:file "playlist-scheduler") (:file "listener-stats") (:file "user-profile") + (:file "user-playlists") (:file "track-requests") (:file "auth-routes") (:file "frontend-partials") diff --git a/database.lisp b/database.lisp index 5d1b924..7add19b 100644 --- a/database.lisp +++ b/database.lisp @@ -64,6 +64,18 @@ (listen-duration :integer) (completed :integer)))) + (unless (db:collection-exists-p "user_playlists") + (db:create "user_playlists" '((user-id :integer) + (name :text) + (description :text) + (track-ids :text) + (status :text) + (created-date :integer) + (submitted-date :integer) + (reviewed-date :integer) + (reviewed-by :integer) + (review-notes :text)))) + ;; TODO: the radiance db interface is too basic to contain anything ;; but strings, integers, booleans, and maybe timestamps... we will ;; need to rethink this. currently track/playlist relationships are diff --git a/frontend-partials.lisp b/frontend-partials.lisp index 5797731..d7ebdb1 100644 --- a/frontend-partials.lisp +++ b/frontend-partials.lisp @@ -88,14 +88,16 @@ (icecast-now-playing *stream-base-url* "asteroid-shuffle.mp3"))) (now-playing-stats (icecast-now-playing *stream-base-url* mount-name))) (if now-playing-stats - (progn + (let* ((title (cdr (assoc :title now-playing-stats))) + (favorite-count (or (get-track-favorite-count title) 0))) ;; TODO: it should be able to define a custom api-output for this ;; (api-output :format "html")) (setf (header "Content-Type") "text/html") (clip:process-to-string (load-template "partial/now-playing") :stats now-playing-stats - :track-id (cdr (assoc :track-id now-playing-stats)))) + :track-id (cdr (assoc :track-id now-playing-stats)) + :favorite-count favorite-count)) (progn (setf (header "Content-Type") "text/html") (clip:process-to-string @@ -124,10 +126,13 @@ (let* ((mount-name (or mount "asteroid.mp3")) (now-playing-stats (icecast-now-playing *stream-base-url* mount-name))) (if now-playing-stats - (api-output `(("status" . "success") - ("title" . ,(cdr (assoc :title now-playing-stats))) - ("listeners" . ,(cdr (assoc :listeners now-playing-stats))) - ("track_id" . ,(cdr (assoc :track-id now-playing-stats))))) + (let* ((title (cdr (assoc :title now-playing-stats))) + (favorite-count (or (get-track-favorite-count title) 0))) + (api-output `(("status" . "success") + ("title" . ,title) + ("listeners" . ,(cdr (assoc :listeners now-playing-stats))) + ("track_id" . ,(cdr (assoc :track-id now-playing-stats))) + ("favorite_count" . ,favorite-count)))) (api-output `(("status" . "offline") ("title" . "Stream Offline") ("track_id" . nil))))))) diff --git a/migrations/008-user-playlists.sql b/migrations/008-user-playlists.sql new file mode 100644 index 0000000..76c2647 --- /dev/null +++ b/migrations/008-user-playlists.sql @@ -0,0 +1,30 @@ +-- Migration 008: User Playlists +-- Adds table for user-created playlists with submission/review workflow + +CREATE TABLE IF NOT EXISTS user_playlists ( + _id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + track_ids TEXT DEFAULT '[]', -- JSON array of track IDs + status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'scheduled')), + created_date INTEGER DEFAULT EXTRACT(EPOCH FROM NOW())::INTEGER, + submitted_date INTEGER, + reviewed_date INTEGER, + reviewed_by INTEGER REFERENCES "USERS"(_id), + review_notes TEXT +); + +-- Create indexes for efficient queries +CREATE INDEX IF NOT EXISTS idx_user_playlists_user_id ON user_playlists(user_id); +CREATE INDEX IF NOT EXISTS idx_user_playlists_status ON user_playlists(status); + +-- Grant permissions +GRANT ALL PRIVILEGES ON user_playlists TO asteroid; +GRANT ALL PRIVILEGES ON SEQUENCE user_playlists__id_seq TO asteroid; + +-- Verification +DO $$ +BEGIN + RAISE NOTICE 'Migration 008: User playlists table created successfully!'; +END $$; diff --git a/parenscript/admin.lisp b/parenscript/admin.lisp index 0c30f89..bbf5ab5 100644 --- a/parenscript/admin.lisp +++ b/parenscript/admin.lisp @@ -29,6 +29,7 @@ (refresh-liquidsoap-status) (setup-stats-refresh) (refresh-scheduler-status) + (refresh-track-requests) ;; Update Liquidsoap status every 10 seconds (set-interval refresh-liquidsoap-status 10000) ;; Update scheduler status every 30 seconds @@ -1286,6 +1287,216 @@ (ps:chain console (error "Error loading scheduled playlist:" error)) (alert "Error loading scheduled playlist"))))) + ;; ======================================== + ;; Track Requests Management + ;; ======================================== + + (defvar *current-request-tab* "pending") + + (defun format-request-time (timestamp) + "Format a timestamp for display" + (if (not timestamp) + "" + (let* ((ts-str (+ "" timestamp)) + (iso-str (if (ps:chain ts-str (includes " ")) + (+ (ps:chain ts-str (replace " " "T")) "Z") + ts-str)) + (date (ps:new (-date iso-str)))) + (if (ps:chain -number (is-na-n (ps:chain date (get-time)))) + "Recently" + (ps:chain date (to-locale-string)))))) + + (defun show-request-tab (tab) + (setf *current-request-tab* tab) + ;; Update tab button styles + (let ((tabs (ps:chain document (query-selector-all ".btn-tab")))) + (ps:chain tabs (for-each (lambda (btn) + (ps:chain btn class-list (remove "active")))))) + (let ((active-tab (ps:chain document (get-element-by-id (+ "tab-" tab))))) + (when active-tab + (ps:chain active-tab class-list (add "active")))) + ;; Load the appropriate requests + (refresh-track-requests)) + + (defun refresh-track-requests () + (let ((container (ps:chain document (get-element-by-id "pending-requests-container"))) + (status-el (ps:chain document (get-element-by-id "requests-status"))) + (url (+ "/api/asteroid/admin/requests/list?status=" *current-request-tab*))) + (when status-el + (setf (ps:@ status-el text-content) "Loading...")) + (ps:chain + (fetch url) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (when status-el + (setf (ps:@ status-el text-content) "")) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data requests) + (> (ps:@ data requests length) 0)) + (let ((html "")) + (ps:chain (ps:@ data requests) (for-each (lambda (req) + (let ((actions-html + (cond + ((= *current-request-tab* "pending") + (+ "" + "")) + ((= *current-request-tab* "approved") + "✓ Approved") + ((= *current-request-tab* "rejected") + "✗ Rejected") + ((= *current-request-tab* "played") + "🎵 Played") + (t "")))) + (setf html (+ html + "
" + "
" + "" (ps:@ req title) "" + "Requested by @" (ps:@ req username) "" + (if (ps:@ req message) + (+ "

\"" (ps:@ req message) "\"

") + "") + "" (format-request-time (ps:@ req created_at)) "" + "
" + "
" + actions-html + "
" + "
")))))) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) (+ "

No " *current-request-tab* " requests

"))))))) + (catch (lambda (error) + (ps:chain console (error "Error loading requests:" error)) + (when status-el + (setf (ps:@ status-el text-content) "Error loading requests"))))))) + + (defun approve-request (request-id) + (ps:chain + (fetch (+ "/api/asteroid/requests/approve?id=" request-id) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast "✓ Request approved") + (refresh-track-requests)) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error approving request:" error)) + (alert "Error approving request"))))) + + (defun reject-request (request-id) + (when (confirm "Are you sure you want to reject this request?") + (ps:chain + (fetch (+ "/api/asteroid/requests/reject?id=" request-id) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-toast "Request rejected") + (refresh-track-requests)) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error rejecting request:" error)) + (alert "Error rejecting request")))))) + + ;; ======================================== + ;; User Playlist Review Functions + ;; ======================================== + + (defun load-user-playlist-submissions () + (ps:chain + (fetch "/api/asteroid/admin/user-playlists") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((container (ps:chain document (get-element-by-id "user-playlists-container"))) + (data (or (ps:@ result data) result))) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data playlists) + (> (ps:@ data playlists length) 0)) + (let ((html "")) + (ps:chain (ps:@ data playlists) (for-each (lambda (pl) + (let* ((ts (aref pl "submittedDate")) + (submitted-date (if ts + (ps:chain (ps:new (*Date (* ts 1000))) (to-locale-string)) + "N/A"))) + (setf html (+ html + "" + "" + "" + "" + "" + "" + "")))))) + (setf html (+ html "
PlaylistUserTracksSubmittedActions
" (aref pl "name") "" + (if (aref pl "description") (+ "
" (aref pl "description") "") "") + "
" (or (aref pl "username") "Unknown") "" (or (aref pl "trackCount") 0) " tracks" submitted-date "" + " " + " " + "" + "
")) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) "

No playlists awaiting review

")))))) + (catch (lambda (error) + (ps:chain console (error "Error loading user playlists:" error)) + (let ((container (ps:chain document (get-element-by-id "user-playlists-container")))) + (when container + (setf (ps:@ container inner-h-t-m-l) "

Error loading submissions

"))))))) + + (defun approve-playlist (playlist-id) + (when (confirm "Approve this playlist? It will be available for scheduling.") + (ps:chain + (fetch (+ "/api/asteroid/admin/user-playlists/review?id=" playlist-id "&action=approve") + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (alert "Playlist approved!") + (load-user-playlist-submissions)) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error approving playlist:" error)) + (alert "Error approving playlist")))))) + + (defun reject-playlist (playlist-id) + (let ((notes (prompt "Reason for rejection (optional):"))) + (ps:chain + (fetch (+ "/api/asteroid/admin/user-playlists/review?id=" playlist-id + "&action=reject¬es=" (encode-u-r-i-component (or notes ""))) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (alert "Playlist rejected.") + (load-user-playlist-submissions)) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error rejecting playlist:" error)) + (alert "Error rejecting playlist")))))) + + (defun preview-playlist (playlist-id) + (ps:chain + (fetch (+ "/api/asteroid/admin/user-playlists/preview?id=" playlist-id)) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (let ((m3u (aref data "m3u"))) + ;; Show in a modal or alert + (alert (+ "Playlist M3U Preview:\n\n" m3u))) + (alert (+ "Error: " (or (ps:@ data message) "Unknown error"))))))) + (catch (lambda (error) + (ps:chain console (error "Error previewing playlist:" error)) + (alert "Error previewing playlist"))))) + ;; Make functions globally accessible for onclick handlers (setf (ps:@ window go-to-page) go-to-page) (setf (ps:@ window previous-page) previous-page) @@ -1309,6 +1520,17 @@ (setf (ps:@ window load-current-scheduled-playlist) load-current-scheduled-playlist) (setf (ps:@ window add-schedule-entry) add-schedule-entry) (setf (ps:@ window remove-schedule-entry) remove-schedule-entry) + (setf (ps:@ window refresh-track-requests) refresh-track-requests) + (setf (ps:@ window approve-request) approve-request) + (setf (ps:@ window reject-request) reject-request) + (setf (ps:@ window show-request-tab) show-request-tab) + (setf (ps:@ window load-user-playlist-submissions) load-user-playlist-submissions) + (setf (ps:@ window approve-playlist) approve-playlist) + (setf (ps:@ window reject-playlist) reject-playlist) + (setf (ps:@ window preview-playlist) preview-playlist) + + ;; Load user playlist submissions on page load + (load-user-playlist-submissions) )) "Compiled JavaScript for admin dashboard - generated at load time") diff --git a/parenscript/front-page.lisp b/parenscript/front-page.lisp index cadbff4..3477a7e 100644 --- a/parenscript/front-page.lisp +++ b/parenscript/front-page.lisp @@ -168,6 +168,39 @@ ;; Track last recorded title to avoid duplicate history entries (defvar *last-recorded-title-main* nil) + ;; Cache of user's favorite track titles for quick lookup + (defvar *user-favorites-cache* (array)) + + ;; Load user's favorites into cache + (defun load-favorites-cache () + (ps:chain + (fetch "/api/asteroid/user/favorites") + (then (lambda (response) + (if (ps:@ response ok) + (ps:chain response (json)) + nil))) + (then (lambda (data) + (when (and data (ps:@ data data) (ps:@ data data favorites)) + (setf *user-favorites-cache* + (ps:chain (ps:@ data data favorites) + (map (lambda (f) (ps:@ f title)))))))) + (catch (lambda (error) nil)))) + + ;; Check if current track is in favorites and update UI + (defun check-favorite-status () + (let ((title-el (ps:chain document (get-element-by-id "current-track-title"))) + (btn (ps:chain document (get-element-by-id "favorite-btn")))) + (when (and title-el btn) + (let ((title (ps:@ title-el text-content)) + (star-icon (ps:chain btn (query-selector ".star-icon")))) + (if (ps:chain *user-favorites-cache* (includes title)) + (progn + (ps:chain btn class-list (add "favorited")) + (when star-icon (setf (ps:@ star-icon text-content) "★"))) + (progn + (ps:chain btn class-list (remove "favorited")) + (when star-icon (setf (ps:@ star-icon text-content) "☆")))))))) + ;; Record track to listening history (only if logged in) (defun record-track-listen-main (title) (when (and title (not (= title "")) (not (= title "Loading...")) @@ -177,8 +210,7 @@ (fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title)) (ps:create :method "POST")) (then (lambda (response) - (when (ps:@ response ok) - (ps:chain console (log "Recorded listen:" title))))) + (ps:@ response ok))) (catch (lambda (error) ;; Silently fail - user might not be logged in nil))))) @@ -206,7 +238,20 @@ ;; Record if title changed (when (or (not old-title-el) (not (= (ps:@ old-title-el text-content) new-title))) - (record-track-listen-main new-title)))))))))) + (record-track-listen-main new-title)) + ;; Check if this track is in user's favorites + (check-favorite-status) + ;; Update favorite count display + (let ((count-el (ps:chain document (get-element-by-id "favorite-count-display"))) + (count-val-el (ps:chain document (get-element-by-id "favorite-count-value")))) + (when (and count-el count-val-el) + (let ((fav-count (parse-int (or (ps:@ count-val-el value) "0") 10))) + (if (> fav-count 0) + (setf (ps:@ count-el text-content) + (if (= fav-count 1) + "1 person loves this track ❤️" + (+ fav-count " people love this track ❤️"))) + (setf (ps:@ count-el text-content) ""))))))))))))) (catch (lambda (error) (ps:chain console (log "Could not fetch stream status:" error))))))) @@ -582,6 +627,9 @@ (when is-frameset-page (set-interval update-stream-information 10000))) + ;; Load user's favorites for highlight feature + (load-favorites-cache) + ;; Update now playing (update-now-playing) @@ -650,7 +698,9 @@ (when (and data (or (= (ps:@ data status) "success") (= (ps:@ data data status) "success"))) (ps:chain btn class-list (remove "favorited")) - (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")))) + (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆") + ;; Refresh now playing to update favorite count + (update-now-playing)))) (catch (lambda (error) (ps:chain console (error "Error removing favorite:" error))))) ;; Add favorite @@ -667,7 +717,8 @@ (when (and data (or (= (ps:@ data status) "success") (= (ps:@ data data status) "success"))) (ps:chain btn class-list (add "favorited")) - (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")))) + (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★") + (update-now-playing)))) (catch (lambda (error) (ps:chain console (error "Error adding favorite:" error))))))))))) diff --git a/parenscript/profile.lisp b/parenscript/profile.lisp index 70d69dd..4c6af2e 100644 --- a/parenscript/profile.lisp +++ b/parenscript/profile.lisp @@ -327,7 +327,46 @@ (load-favorites) (load-top-artists) (load-activity-chart) - (load-avatar)) + (load-avatar) + (load-my-requests)) + + ;; Load user's track requests + (defun load-my-requests () + (ps:chain + (fetch "/api/asteroid/requests/my") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result)) + (container (ps:chain document (get-element-by-id "my-requests-list")))) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data requests) + (> (ps:@ data requests length) 0)) + (let ((html "")) + (ps:chain (ps:@ data requests) (for-each (lambda (req) + (let ((status-class (cond + ((= (ps:@ req status) "pending") "status-pending") + ((= (ps:@ req status) "approved") "status-approved") + ((= (ps:@ req status) "rejected") "status-rejected") + ((= (ps:@ req status) "played") "status-played") + (t ""))) + (status-icon (cond + ((= (ps:@ req status) "pending") "⏳") + ((= (ps:@ req status) "approved") "✓") + ((= (ps:@ req status) "rejected") "✗") + ((= (ps:@ req status) "played") "🎵") + (t "?")))) + (setf html (+ html + "
" + "
" (ps:@ req title) "
" + "
" + "" status-icon " " (ps:@ req status) "" + "
" + "
")))))) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) "

You haven't made any requests yet.

")))))) + (catch (lambda (error) + (ps:chain console (error "Error loading requests:" error)))))) ;; Action functions (defun edit-profile () @@ -426,11 +465,402 @@ false)) + ;; ======================================== + ;; User Playlists functionality + ;; ======================================== + + (defvar *library-page* 1) + (defvar *library-search* "") + (defvar *library-artist* "") + (defvar *library-total* 0) + (defvar *current-playlist-tracks* (array)) + (defvar *user-playlists* (array)) + + ;; Load user's playlists + (defun load-my-playlists () + (ps:chain + (fetch "/api/asteroid/user/playlists") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result)) + (container (ps:chain document (get-element-by-id "my-playlists-list")))) + (when container + (if (and (= (ps:@ data status) "success") + (ps:@ data playlists) + (> (ps:@ data playlists length) 0)) + (progn + (setf *user-playlists* (ps:@ data playlists)) + (let ((html "")) + (ps:chain (ps:@ data playlists) (for-each (lambda (pl) + (let ((playlist-id (or (ps:@ pl id) (aref pl "id"))) + (status-class (cond + ((= (ps:@ pl status) "draft") "status-draft") + ((= (ps:@ pl status) "submitted") "status-pending") + ((= (ps:@ pl status) "approved") "status-approved") + ((= (ps:@ pl status) "rejected") "status-rejected") + (t ""))) + (status-icon (cond + ((= (ps:@ pl status) "draft") "📝") + ((= (ps:@ pl status) "submitted") "⏳") + ((= (ps:@ pl status) "approved") "✓") + ((= (ps:@ pl status) "rejected") "✗") + (t "?")))) + (ps:chain console (log "Playlist:" pl "ID:" playlist-id)) + (setf html (+ html + "
" + "
" + "" (or (ps:@ pl name) (aref pl "name")) "" + "" (or (ps:@ pl track-count) (aref pl "track-count") 0) " tracks" + "
" + "
" + "" status-icon " " (or (ps:@ pl status) (aref pl "status")) "" + (if (= (or (ps:@ pl status) (aref pl "status")) "draft") + (+ "") + "") + "
" + "
")))))) + (setf (ps:@ container inner-h-t-m-l) html))) + (setf (ps:@ container inner-h-t-m-l) "

No playlists yet. Create one to get started!

")))))) + (catch (lambda (error) + (ps:chain console (error "Error loading playlists:" error)))))) + + ;; Modal functions + (defun show-create-playlist-modal () + (let ((modal (ps:chain document (get-element-by-id "create-playlist-modal")))) + (when modal + (setf (ps:@ modal style display) "flex")))) + + (defun hide-create-playlist-modal () + (let ((modal (ps:chain document (get-element-by-id "create-playlist-modal")))) + (when modal + (setf (ps:@ modal style display) "none") + (ps:chain (ps:chain document (get-element-by-id "create-playlist-form")) (reset))))) + + (defun show-library-browser () + (let ((modal (ps:chain document (get-element-by-id "library-browser-modal")))) + (when modal + (setf (ps:@ modal style display) "flex") + (load-library-tracks) + (update-playlist-select)))) + + (defun hide-library-browser () + (let ((modal (ps:chain document (get-element-by-id "library-browser-modal")))) + (when modal + (setf (ps:@ modal style display) "none")))) + + (defun show-library-browser-for-playlist () + (show-library-browser)) + + (defun show-edit-playlist-modal () + (let ((modal (ps:chain document (get-element-by-id "edit-playlist-modal")))) + (when modal + (setf (ps:@ modal style display) "flex")))) + + (defun hide-edit-playlist-modal () + (let ((modal (ps:chain document (get-element-by-id "edit-playlist-modal")))) + (when modal + (setf (ps:@ modal style display) "none")))) + + ;; Create playlist + (defun create-playlist (event) + (ps:chain event (prevent-default)) + (let ((name (ps:@ (ps:chain document (get-element-by-id "playlist-name")) value)) + (description (ps:@ (ps:chain document (get-element-by-id "playlist-description")) value)) + (message-div (ps:chain document (get-element-by-id "create-playlist-message")))) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/create?name=" (encode-u-r-i-component name) + "&description=" (encode-u-r-i-component description)) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-message "Playlist created!" "success") + (hide-create-playlist-modal) + (load-my-playlists) + ;; Open the new playlist for editing + (when (ps:@ data playlist id) + (edit-playlist (ps:@ data playlist id)))) + (progn + (setf (ps:@ message-div text-content) (or (ps:@ data message) "Failed to create playlist")) + (setf (ps:@ message-div class-name) "message error")))))) + (catch (lambda (error) + (ps:chain console (error "Error creating playlist:" error)) + (setf (ps:@ message-div text-content) "Error creating playlist") + (setf (ps:@ message-div class-name) "message error"))))) + false) + + ;; Edit playlist + (defun edit-playlist (playlist-id) + (ps:chain console (log "edit-playlist called with id:" playlist-id)) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/get?id=" playlist-id)) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (ps:chain console (log "edit-playlist response:" result)) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (let* ((pl (ps:@ data playlist)) + (pl-id (or (ps:@ pl id) (aref pl "id"))) + (pl-name (or (ps:@ pl name) (aref pl "name"))) + (pl-desc (or (ps:@ pl description) (aref pl "description") "")) + (pl-tracks (or (ps:@ pl tracks) (aref pl "tracks") (array)))) + (ps:chain console (log "Playlist id:" pl-id "name:" pl-name)) + (setf (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value) pl-id) + (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-name")) value) pl-name) + (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-description")) value) pl-desc) + (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-title")) text-content) (+ "Edit: " pl-name)) + (setf *current-playlist-tracks* pl-tracks) + (render-playlist-tracks) + (show-edit-playlist-modal)) + (show-message "Failed to load playlist" "error"))))) + (catch (lambda (error) + (ps:chain console (error "Error loading playlist:" error)) + (show-message "Error loading playlist" "error"))))) + + (defun render-playlist-tracks () + (let ((container (ps:chain document (get-element-by-id "playlist-tracks-list")))) + (when container + (if (> (ps:@ *current-playlist-tracks* length) 0) + (let ((html "")) + (ps:chain *current-playlist-tracks* (for-each (lambda (track index) + (setf html (+ html + "
" + "" (+ index 1) "." + "" (ps:@ track title) "" + "" (ps:@ track artist) "" + "
" + "" + "" + "" + "
" + "
"))))) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) "

No tracks yet. Browse the library to add tracks!

"))))) + + (defun move-track-in-playlist (index direction) + (let ((new-index (+ index direction))) + (when (and (>= new-index 0) (< new-index (ps:@ *current-playlist-tracks* length))) + (let ((track (ps:chain *current-playlist-tracks* (splice index 1)))) + (ps:chain *current-playlist-tracks* (splice new-index 0 (ps:getprop track 0))) + (render-playlist-tracks) + (save-playlist-tracks))))) + + (defun remove-track-from-playlist (index) + (ps:chain *current-playlist-tracks* (splice index 1)) + (render-playlist-tracks) + (save-playlist-tracks)) + + (defun add-track-to-playlist (track-id title artist album) + (ps:chain console (log "addTrackToPlaylist called with track-id:" track-id "title:" title)) + (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) + (when (not playlist-id) + ;; No playlist open, use the select dropdown + (let ((select (ps:chain document (get-element-by-id "add-to-playlist-select")))) + (when select + (setf playlist-id (ps:@ select value))))) + (when (not playlist-id) + (show-message "Please select a playlist first" "warning") + (return)) + ;; Add to current tracks array + (ps:chain console (log "Adding track with id:" track-id "to playlist:" playlist-id)) + ;; Create object and set id property explicitly + (let ((track-obj (ps:create))) + (setf (ps:@ track-obj id) track-id) + (setf (ps:@ track-obj title) title) + (setf (ps:@ track-obj artist) artist) + (setf (ps:@ track-obj album) album) + (ps:chain *current-playlist-tracks* (push track-obj))) + (ps:chain console (log "Current tracks:" *current-playlist-tracks*)) + (render-playlist-tracks) + (save-playlist-tracks) + (show-message (+ "Added: " title) "success"))) + + (defun save-playlist-tracks () + (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) + (when playlist-id + ;; Access id property directly - use 'trk' not 't' (t is boolean true in Lisp/ParenScript) + (let ((track-ids (ps:chain *current-playlist-tracks* (map (lambda (trk) (ps:@ trk id)))))) + (ps:chain console (log "Saving track-ids:" track-ids)) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/update?id=" playlist-id + "&tracks=" (encode-u-r-i-component (ps:chain -j-s-o-n (stringify track-ids)))) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (catch (lambda (error) + (ps:chain console (error "Error saving playlist:" error))))))))) + + (defun save-playlist-metadata () + (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value)) + (name (ps:@ (ps:chain document (get-element-by-id "edit-playlist-name")) value)) + (description (ps:@ (ps:chain document (get-element-by-id "edit-playlist-description")) value))) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/update?id=" playlist-id + "&name=" (encode-u-r-i-component name) + "&description=" (encode-u-r-i-component description)) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-message "Playlist saved!" "success") + (setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-title")) text-content) (+ "Edit: " name)) + (load-my-playlists)) + (show-message "Failed to save playlist" "error"))))) + (catch (lambda (error) + (ps:chain console (error "Error saving playlist:" error)) + (show-message "Error saving playlist" "error")))))) + + (defun submit-playlist-for-review () + (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) + (when (not (confirm "Submit this playlist for admin review? You won't be able to edit it after submission.")) + (return)) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/submit?id=" playlist-id) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-message "Playlist submitted for review!" "success") + (hide-edit-playlist-modal) + (load-my-playlists)) + (show-message (or (ps:@ data message) "Failed to submit playlist") "error"))))) + (catch (lambda (error) + (ps:chain console (error "Error submitting playlist:" error)) + (show-message "Error submitting playlist" "error")))))) + + (defun delete-current-playlist () + (let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))) + (when (not (confirm "Delete this playlist? This cannot be undone.")) + (return)) + (ps:chain + (fetch (+ "/api/asteroid/user/playlists/delete?id=" playlist-id) + (ps:create :method "POST")) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result))) + (if (= (ps:@ data status) "success") + (progn + (show-message "Playlist deleted" "success") + (hide-edit-playlist-modal) + (load-my-playlists)) + (show-message "Failed to delete playlist" "error"))))) + (catch (lambda (error) + (ps:chain console (error "Error deleting playlist:" error)) + (show-message "Error deleting playlist" "error")))))) + + ;; Library browsing + (defun load-library-tracks () + (let ((url (+ "/api/asteroid/library/browse?page=" *library-page*))) + (when (and *library-search* (> (ps:@ *library-search* length) 0)) + (setf url (+ url "&search=" (encode-u-r-i-component *library-search*)))) + (when (and *library-artist* (> (ps:@ *library-artist* length) 0)) + (setf url (+ url "&artist=" (encode-u-r-i-component *library-artist*)))) + (ps:chain + (fetch url) + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + (let ((data (or (ps:@ result data) result)) + (container (ps:chain document (get-element-by-id "library-tracks"))) + (artist-select (ps:chain document (get-element-by-id "library-artist-filter")))) + (when container + (setf *library-total* (or (ps:@ data total) 0)) + (if (and (= (ps:@ data status) "success") + (ps:@ data tracks) + (> (ps:@ data tracks length) 0)) + (let ((html "")) + (ps:chain (ps:@ data tracks) (for-each (lambda (track) + (setf html (+ html + "
" + "
" + "" (ps:@ track title) "" + "" (ps:@ track artist) "" + "" (ps:@ track album) "" + "
" + "" + "
"))))) + (setf (ps:@ container inner-h-t-m-l) html)) + (setf (ps:@ container inner-h-t-m-l) "

No tracks found

"))) + ;; Update artist filter + (when (and artist-select (ps:@ data artists)) + (let ((current-val (ps:@ artist-select value))) + (setf (ps:@ artist-select inner-h-t-m-l) "") + (ps:chain (ps:@ data artists) (for-each (lambda (artist) + (let ((opt (ps:chain document (create-element "option")))) + (setf (ps:@ opt value) artist) + (setf (ps:@ opt text-content) artist) + (ps:chain artist-select (append-child opt)))))) + (setf (ps:@ artist-select value) current-val))) + ;; Update pagination + (update-library-pagination)))) + (catch (lambda (error) + (ps:chain console (error "Error loading library:" error))))))) + + (defun update-library-pagination () + (let ((page-info (ps:chain document (get-element-by-id "library-page-info"))) + (prev-btn (ps:chain document (get-element-by-id "lib-prev-btn"))) + (next-btn (ps:chain document (get-element-by-id "lib-next-btn"))) + (total-pages (ps:chain -math (ceil (/ *library-total* 50))))) + (when page-info + (setf (ps:@ page-info text-content) (+ "Page " *library-page* " of " total-pages))) + (when prev-btn + (setf (ps:@ prev-btn disabled) (<= *library-page* 1))) + (when next-btn + (setf (ps:@ next-btn disabled) (>= *library-page* total-pages))))) + + (defun prev-library-page () + (when (> *library-page* 1) + (setf *library-page* (- *library-page* 1)) + (load-library-tracks))) + + (defun next-library-page () + (setf *library-page* (+ *library-page* 1)) + (load-library-tracks)) + + (defvar *search-timeout* nil) + + (defun search-library () + (when *search-timeout* + (clear-timeout *search-timeout*)) + (setf *search-timeout* + (set-timeout + (lambda () + (setf *library-search* (ps:@ (ps:chain document (get-element-by-id "library-search")) value)) + (setf *library-page* 1) + (load-library-tracks)) + 300))) + + (defun filter-by-artist () + (setf *library-artist* (ps:@ (ps:chain document (get-element-by-id "library-artist-filter")) value)) + (setf *library-page* 1) + (load-library-tracks)) + + (defun update-playlist-select () + (let ((select (ps:chain document (get-element-by-id "add-to-playlist-select")))) + (when select + (setf (ps:@ select inner-h-t-m-l) "") + (ps:chain *user-playlists* (for-each (lambda (pl) + (when (= (ps:@ pl status) "draft") + (let ((opt (ps:chain document (create-element "option")))) + (setf (ps:@ opt value) (ps:@ pl id)) + (setf (ps:@ opt text-content) (ps:@ pl name)) + (ps:chain select (append-child opt)))))))))) + ;; Initialize on page load (ps:chain window (add-event-listener "DOMContentLoaded" - load-profile-data)))) + (lambda () + (load-profile-data) + (load-my-playlists)))))) "Compiled JavaScript for profile page - generated at load time") (defun generate-profile-js () diff --git a/parenscript/stream-player.lisp b/parenscript/stream-player.lisp index 7d83537..cad3fb2 100644 --- a/parenscript/stream-player.lisp +++ b/parenscript/stream-player.lisp @@ -201,6 +201,39 @@ ;; Track the last recorded title to avoid duplicate history entries (defvar *last-recorded-title* nil) + ;; Cache of user's favorite track titles for quick lookup (mini player) + (defvar *user-favorites-cache-mini* (array)) + + ;; Load user's favorites into cache (mini player) + (defun load-favorites-cache-mini () + (ps:chain + (fetch "/api/asteroid/user/favorites") + (then (lambda (response) + (if (ps:@ response ok) + (ps:chain response (json)) + nil))) + (then (lambda (data) + (when (and data (ps:@ data data) (ps:@ data data favorites)) + (setf *user-favorites-cache-mini* + (ps:chain (ps:@ data data favorites) + (map (lambda (f) (ps:@ f title)))))))) + (catch (lambda (error) nil)))) + + ;; Check if current track is in favorites and update mini player UI + (defun check-favorite-status-mini () + (let ((title-el (ps:chain document (get-element-by-id "mini-now-playing"))) + (btn (ps:chain document (get-element-by-id "favorite-btn-mini")))) + (when (and title-el btn) + (let ((title (ps:@ title-el text-content)) + (star-icon (ps:chain btn (query-selector ".star-icon")))) + (if (ps:chain *user-favorites-cache-mini* (includes title)) + (progn + (ps:chain btn class-list (add "favorited")) + (when star-icon (setf (ps:@ star-icon text-content) "★"))) + (progn + (ps:chain btn class-list (remove "favorited")) + (when star-icon (setf (ps:@ star-icon text-content) "☆")))))))) + ;; Record track to listening history (only if logged in) (defun record-track-listen (title) (when (and title (not (= title "")) (not (= title "Loading...")) (not (= title *last-recorded-title*))) @@ -209,11 +242,8 @@ (fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title)) (ps:create :method "POST")) (then (lambda (response) - (when (ps:@ response ok) - (ps:chain console (log "Recorded listen:" title))))) - (catch (lambda (error) - ;; Silently fail - user might not be logged in - nil))))) + (ps:@ response ok))) + (catch (lambda (error) nil))))) ;; Update mini now playing display (for persistent player frame) (defun update-mini-now-playing () @@ -233,10 +263,20 @@ ;; Check if track changed and record to history (when (not (= (ps:@ el text-content) title)) (record-track-listen title)) - (setf (ps:@ el text-content) title)) + (setf (ps:@ el text-content) title) + ;; Check if this track is in user's favorites + (check-favorite-status-mini)) (when track-id-el (let ((track-id (or (ps:@ data data track_id) (ps:@ data track_id)))) - (setf (ps:@ track-id-el value) (or track-id "")))))))) + (setf (ps:@ track-id-el value) (or track-id "")))) + ;; Update favorite count display + (let ((count-el (ps:chain document (get-element-by-id "favorite-count-mini"))) + (fav-count (or (ps:@ data data favorite_count) (ps:@ data favorite_count) 0))) + (when count-el + (cond + ((= fav-count 0) (setf (ps:@ count-el text-content) "")) + ((= fav-count 1) (setf (ps:@ count-el text-content) "1 ❤️")) + (t (setf (ps:@ count-el text-content) (+ fav-count " ❤️")))))))))) (catch (lambda (error) (ps:chain console (log "Could not fetch now playing:" error))))))) @@ -269,7 +309,10 @@ (when (and data (or (= (ps:@ data status) "success") (= (ps:@ data data status) "success"))) (ps:chain btn class-list (remove "favorited")) - (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")))) + (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆") + ;; Reload cache and refresh display to update favorite count + (load-favorites-cache-mini) + (update-mini-now-playing)))) (catch (lambda (error) (ps:chain console (error "Error removing favorite:" error))))) ;; Add favorite @@ -286,7 +329,10 @@ (when (and data (or (= (ps:@ data status) "success") (= (ps:@ data data status) "success"))) (ps:chain btn class-list (add "favorited")) - (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")))) + (setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★") + ;; Reload cache and refresh display to update favorite count + (load-favorites-cache-mini) + (update-mini-now-playing)))) (catch (lambda (error) (ps:chain console (error "Error adding favorite:" error))))))))))) @@ -604,6 +650,9 @@ (defun init-persistent-player () (let ((audio-element (ps:chain document (get-element-by-id "persistent-audio")))) (when audio-element + ;; Load user's favorites for highlight feature + (load-favorites-cache-mini) + ;; Try to enable low-latency mode if supported (when (ps:@ navigator media-session) (setf (ps:@ navigator media-session metadata) diff --git a/playlist-scheduler.lisp b/playlist-scheduler.lisp index 392d2cf..0abb893 100644 --- a/playlist-scheduler.lisp +++ b/playlist-scheduler.lisp @@ -163,11 +163,19 @@ (sort (copy-list *playlist-schedule*) #'< :key #'car)) (defun get-available-playlists () - "Get list of available playlist files from the playlists directory." - (let ((playlists-dir (get-playlists-directory))) - (when (probe-file playlists-dir) - (mapcar #'file-namestring - (directory (merge-pathnames "*.m3u" playlists-dir)))))) + "Get list of available playlist files from the playlists directory and user-submissions." + (let ((playlists-dir (get-playlists-directory)) + (submissions-dir (merge-pathnames "user-submissions/" (get-playlists-directory)))) + (append + ;; Main playlists directory + (when (probe-file playlists-dir) + (mapcar #'file-namestring + (directory (merge-pathnames "*.m3u" playlists-dir)))) + ;; User submissions directory (prefixed with user-submissions/) + (when (probe-file submissions-dir) + (mapcar (lambda (path) + (format nil "user-submissions/~a" (file-namestring path))) + (directory (merge-pathnames "*.m3u" submissions-dir))))))) (defun get-server-time-info () "Get current server time information in both UTC and local timezone." diff --git a/playlists/user-submissions/admin-glenneth-1.m3u b/playlists/user-submissions/admin-glenneth-1.m3u new file mode 100644 index 0000000..09861fc --- /dev/null +++ b/playlists/user-submissions/admin-glenneth-1.m3u @@ -0,0 +1,25 @@ +#EXTM3U +#PLAYLIST:glenneth +#PHASE:Zero Gravity +#CURATOR:admin + +#EXTINF:-1,Kiasmos - 65 +/home/fade/Media/Music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/01. Kiasmos - 65.flac +#EXTINF:-1,Kiasmos - Walled +/home/fade/Media/Music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/02. Kiasmos - Walled.flac +#EXTINF:-1,Kiasmos - Bound +/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 05 Bound.flac +#EXTINF:-1,Kiasmos - Burst +/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 02 Burst.flac +#EXTINF:-1,Kiasmos - Dazed +/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 10 Dazed.flac +#EXTINF:-1,Kiasmos - Held +/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/02 - Held.flac +#EXTINF:-1,Kiasmos - Dragged +/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/06 - Dragged.flac +#EXTINF:-1,Kiasmos - Lit +/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/01 - Lit.flac +#EXTINF:-1,Kiasmos - Looped +/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/03 - Looped.flac +#EXTINF:-1,Kiasmos - Burnt [Lubomyr Melnyl Rework] +/home/fade/Media/Music/Kiasmos/2015 - Looped/03 Burnt (Lubomyr Melnyl Rework).flac diff --git a/static/asteroid.css b/static/asteroid.css index 77b3c5a..a77f721 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -1842,6 +1842,136 @@ body.popout-body .status-mini{ font-style: italic; } +.request-item-admin{ + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 15px; + margin-bottom: 10px; + background: rgba(0, 255, 0, 0.05); + border: 1px solid #333; + border-radius: 8px; +} + +.request-item-admin .request-info{ + flex: 1; +} + +.request-item-admin .request-info strong{ + color: #00cc00; + font-size: 1.1em; + display: block; + margin-bottom: 5px; +} + +.request-item-admin .request-info .request-user{ + color: #888; + font-size: 0.9em; + display: block; + margin-bottom: 5px; +} + +.request-item-admin .request-info .request-message{ + color: #aaa; + font-style: italic; + margin: 8px 0; +} + +.request-item-admin .request-info .request-time{ + color: #666; + font-size: 0.8em; +} + +.request-item-admin .request-actions{ + display: flex; + gap: 8px; + margin-left: 15px; +} + +.my-request-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + margin-bottom: 8px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid #333; + border-radius: 6px; +} + +.my-request-item .request-title{ + color: #ddd; + flex: 1; +} + +.my-request-item .request-status{ + margin-left: 15px; +} + +.status-badge{ + padding: 4px 10px; + border-radius: 12px; + font-size: 0.85em; + text-transform: capitalize; +} + +.favorite-count{ + color: #ff6699; + font-size: 0.9em; + margin: 5px 0; +} + +.favorite-count-mini{ + color: #ff6699; + font-size: 0.85em; + margin-left: 8px; +} + +.status-pending{ + background: rgba(255, 200, 0, 0.2); + color: #ffcc00; +} + +.status-approved{ + background: rgba(0, 255, 0, 0.2); + color: #00ff00; +} + +.status-rejected{ + background: rgba(255, 0, 0, 0.2); + color: #ff6666; +} + +.status-played{ + background: rgba(0, 200, 255, 0.2); + color: #00ccff; +} + +.btn-tab{ + background: transparent; + border: 1px solid #444; + color: #888; + padding: 8px 16px; + margin-right: 5px; + cursor: pointer; + -moz-transition: all 0.2s; + -o-transition: all 0.2s; + -webkit-transition: all 0.2s; + -ms-transition: all 0.2s; + transition: all 0.2s; +} + +.btn-tab:hover{ + border-color: #00cc00; + color: #00cc00; +} + +.btn-tab.active{ + background: rgba(0, 255, 0, 0.1); + border-color: #00cc00; + color: #00cc00; +} + .activity-chart{ padding: 15px; } @@ -1911,4 +2041,300 @@ body.popout-body .status-mini{ font-style: italic; text-align: center; padding: 20px; +} + +.section-description{ + color: #888; + margin-bottom: 15px; + font-size: 0.9em; +} + +.playlist-actions{ + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.playlists-list{ + display: flex; + flex-direction: column; + gap: 10px; +} + +.playlist-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid #333; + border-radius: 4px; +} + +.playlist-info{ + display: flex; + flex-direction: column; + gap: 4px; +} + +.playlist-name{ + font-weight: bold; + color: #00cc00; +} + +.playlist-meta{ + font-size: 0.85em; + color: #888; +} + +.playlist-actions{ + display: flex; + align-items: center; + gap: 10px; +} + +.modal{ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content{ + background: #1a1a1a; + border: 1px solid #333; + border-radius: 8px; + padding: 25px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + position: relative; +} + +.modal-large{ + max-width: 800px; +} + +.modal-close{ + position: absolute; + top: 10px; + right: 15px; + font-size: 24px; + color: #888; + cursor: pointer; + -moz-transition: color 0.2s; + -o-transition: color 0.2s; + -webkit-transition: color 0.2s; + -ms-transition: color 0.2s; + transition: color 0.2s; +} + +.modal-close:hover{ + color: #00cc00; +} + +.library-controls{ + display: flex; + gap: 10px; + margin-bottom: 15px; + flex-wrap: wrap; +} + +.library-controls input{ + flex: 1; + min-width: 200px; + padding: 8px 12px; + background: #222; + border: 1px solid #444; + color: #fff; + border-radius: 4px; +} + +.library-controls select{ + padding: 8px 12px; + background: #222; + border: 1px solid #444; + color: #fff; + border-radius: 4px; +} + +.library-tracks-list{ + max-height: 400px; + overflow-y: auto; + margin-bottom: 15px; +} + +.library-track-item{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + border-bottom: 1px solid #333; +} + +.library-track-item:hover{ + background: rgba(0, 255, 0, 0.05); +} + +.library-track-item .track-info{ + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.library-track-item .track-title{ + color: #fff; + font-weight: bold; +} + +.library-track-item .track-artist{ + color: #00cc00; + font-size: 0.9em; +} + +.library-track-item .track-album{ + color: #666; + font-size: 0.85em; +} + +.library-pagination{ + display: flex; + justify-content: center; + align-items: center; + gap: 15px; +} + +.playlist-edit-header{ + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; +} + +.playlist-edit-header input{ + padding: 10px; + background: #222; + border: 1px solid #444; + color: #fff; + border-radius: 4px; + font-size: 1.1em; +} + +.playlist-edit-header textarea{ + padding: 10px; + background: #222; + border: 1px solid #444; + color: #fff; + border-radius: 4px; + resize: vertical; +} + +.playlist-tracks-container{ + margin-bottom: 20px; +} + +.playlist-tracks-sortable{ + max-height: 300px; + overflow-y: auto; + border: 1px solid #333; + border-radius: 4px; + padding: 10px; +} + +.playlist-track-item{ + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + margin-bottom: 5px; +} + +.playlist-track-item .track-number{ + color: #666; + min-width: 25px; +} + +.playlist-track-item .track-title{ + flex: 1; + color: #fff; +} + +.playlist-track-item .track-artist{ + color: #00cc00; + font-size: 0.9em; +} + +.playlist-track-item .track-controls{ + display: flex; + gap: 5px; +} + +.btn-tiny{ + padding: 2px 6px; + font-size: 0.8em; + background: transparent; + border: 1px solid #444; + color: #888; + cursor: pointer; + border-radius: 3px; +} + +.btn-tiny:hover{ + border-color: #00cc00; + color: #00cc00; +} + +.btn-tiny:disabled{ + opacity: 0.3; + cursor: not-allowed; +} + +.btn-danger{ + border-color: #cc0000; + color: #cc0000; +} + +.btn-danger:hover{ + border-color: #ff0000; + color: #ff0000; + background: rgba(255, 0, 0, 0.1); +} + +.playlist-edit-actions{ + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.empty-message{ + color: #666; + font-style: italic; + text-align: center; + padding: 20px; +} + +.status-draft{ + border-left: 3px solid #888; +} + +.status-pending{ + border-left: 3px solid #ffcc00; +} + +.status-approved{ + border-left: 3px solid #00cc00; +} + +.status-rejected{ + border-left: 3px solid #cc0000; } \ No newline at end of file diff --git a/static/asteroid.lass b/static/asteroid.lass index 1c5b0ac..98d662a 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -1475,6 +1475,116 @@ :color "#666" :font-style "italic") + ;; Admin request items + (.request-item-admin + :display "flex" + :justify-content "space-between" + :align-items "flex-start" + :padding "15px" + :margin-bottom "10px" + :background "rgba(0, 255, 0, 0.05)" + :border "1px solid #333" + :border-radius "8px" + + (.request-info + :flex "1" + + (strong + :color "#00cc00" + :font-size "1.1em" + :display "block" + :margin-bottom "5px") + + (.request-user + :color "#888" + :font-size "0.9em" + :display "block" + :margin-bottom "5px") + + (.request-message + :color "#aaa" + :font-style "italic" + :margin "8px 0") + + (.request-time + :color "#666" + :font-size "0.8em")) + + (.request-actions + :display "flex" + :gap "8px" + :margin-left "15px")) + + ;; User's request items on profile + (.my-request-item + :display "flex" + :justify-content "space-between" + :align-items "center" + :padding "12px 15px" + :margin-bottom "8px" + :background "rgba(255, 255, 255, 0.05)" + :border "1px solid #333" + :border-radius "6px" + + (.request-title + :color "#ddd" + :flex "1") + + (.request-status + :margin-left "15px")) + + (.status-badge + :padding "4px 10px" + :border-radius "12px" + :font-size "0.85em" + :text-transform "capitalize") + + ;; Favorite count display + (.favorite-count + :color "#ff6699" + :font-size "0.9em" + :margin "5px 0") + + (.favorite-count-mini + :color "#ff6699" + :font-size "0.85em" + :margin-left "8px") + + (.status-pending + :background "rgba(255, 200, 0, 0.2)" + :color "#ffcc00") + + (.status-approved + :background "rgba(0, 255, 0, 0.2)" + :color "#00ff00") + + (.status-rejected + :background "rgba(255, 0, 0, 0.2)" + :color "#ff6666") + + (.status-played + :background "rgba(0, 200, 255, 0.2)" + :color "#00ccff") + + ;; Tab buttons + (".btn-tab" + :background "transparent" + :border "1px solid #444" + :color "#888" + :padding "8px 16px" + :margin-right "5px" + :cursor "pointer" + :transition "all 0.2s") + + (".btn-tab:hover" + :border-color "#00cc00" + :color "#00cc00") + + (".btn-tab.active" + :background "rgba(0, 255, 0, 0.1)" + :border-color "#00cc00" + :color "#00cc00") + ;; Activity chart styling (.activity-chart :padding "15px" @@ -1532,4 +1642,256 @@ :font-style "italic" :text-align "center" :padding "20px")) + + ;; User Playlists styling + (.section-description + :color "#888" + :margin-bottom "15px" + :font-size "0.9em") + + (.playlist-actions + :display "flex" + :gap "10px" + :margin-bottom "15px") + + (.playlists-list + :display "flex" + :flex-direction "column" + :gap "10px") + + (.playlist-item + :display "flex" + :justify-content "space-between" + :align-items "center" + :padding "12px 15px" + :background "rgba(0, 0, 0, 0.3)" + :border "1px solid #333" + :border-radius "4px") + + (.playlist-info + :display "flex" + :flex-direction "column" + :gap "4px") + + (.playlist-name + :font-weight "bold" + :color "#00cc00") + + (.playlist-meta + :font-size "0.85em" + :color "#888") + + (".playlist-actions" + :display "flex" + :align-items "center" + :gap "10px") + + ;; Modal styling + (.modal + :position "fixed" + :top "0" + :left "0" + :width "100%" + :height "100%" + :background "rgba(0, 0, 0, 0.8)" + :display "flex" + :justify-content "center" + :align-items "center" + :z-index "1000") + + (.modal-content + :background "#1a1a1a" + :border "1px solid #333" + :border-radius "8px" + :padding "25px" + :max-width "500px" + :width "90%" + :max-height "80vh" + :overflow-y "auto" + :position "relative") + + (.modal-large + :max-width "800px") + + (.modal-close + :position "absolute" + :top "10px" + :right "15px" + :font-size "24px" + :color "#888" + :cursor "pointer" + :transition "color 0.2s") + + ((:and .modal-close :hover) + :color "#00cc00") + + ;; Library browser styling + (.library-controls + :display "flex" + :gap "10px" + :margin-bottom "15px" + :flex-wrap "wrap") + + (".library-controls input" + :flex "1" + :min-width "200px" + :padding "8px 12px" + :background "#222" + :border "1px solid #444" + :color "#fff" + :border-radius "4px") + + (".library-controls select" + :padding "8px 12px" + :background "#222" + :border "1px solid #444" + :color "#fff" + :border-radius "4px") + + (.library-tracks-list + :max-height "400px" + :overflow-y "auto" + :margin-bottom "15px") + + (.library-track-item + :display "flex" + :justify-content "space-between" + :align-items "center" + :padding "10px" + :border-bottom "1px solid #333") + + ((:and .library-track-item :hover) + :background "rgba(0, 255, 0, 0.05)") + + (".library-track-item .track-info" + :display "flex" + :flex-direction "column" + :gap "2px" + :flex "1") + + (".library-track-item .track-title" + :color "#fff" + :font-weight "bold") + + (".library-track-item .track-artist" + :color "#00cc00" + :font-size "0.9em") + + (".library-track-item .track-album" + :color "#666" + :font-size "0.85em") + + (.library-pagination + :display "flex" + :justify-content "center" + :align-items "center" + :gap "15px") + + ;; Playlist edit modal styling + (.playlist-edit-header + :display "flex" + :flex-direction "column" + :gap "10px" + :margin-bottom "20px") + + (".playlist-edit-header input" + :padding "10px" + :background "#222" + :border "1px solid #444" + :color "#fff" + :border-radius "4px" + :font-size "1.1em") + + (".playlist-edit-header textarea" + :padding "10px" + :background "#222" + :border "1px solid #444" + :color "#fff" + :border-radius "4px" + :resize "vertical") + + (.playlist-tracks-container + :margin-bottom "20px") + + (.playlist-tracks-sortable + :max-height "300px" + :overflow-y "auto" + :border "1px solid #333" + :border-radius "4px" + :padding "10px") + + (.playlist-track-item + :display "flex" + :align-items "center" + :gap "10px" + :padding "8px" + :background "rgba(0, 0, 0, 0.2)" + :border-radius "4px" + :margin-bottom "5px") + + (".playlist-track-item .track-number" + :color "#666" + :min-width "25px") + + (".playlist-track-item .track-title" + :flex "1" + :color "#fff") + + (".playlist-track-item .track-artist" + :color "#00cc00" + :font-size "0.9em") + + (".playlist-track-item .track-controls" + :display "flex" + :gap "5px") + + (.btn-tiny + :padding "2px 6px" + :font-size "0.8em" + :background "transparent" + :border "1px solid #444" + :color "#888" + :cursor "pointer" + :border-radius "3px") + + ((:and .btn-tiny :hover) + :border-color "#00cc00" + :color "#00cc00") + + ((:and .btn-tiny :disabled) + :opacity "0.3" + :cursor "not-allowed") + + (.btn-danger + :border-color "#cc0000" + :color "#cc0000") + + ((:and .btn-danger :hover) + :border-color "#ff0000" + :color "#ff0000" + :background "rgba(255, 0, 0, 0.1)") + + (.playlist-edit-actions + :display "flex" + :gap "10px" + :flex-wrap "wrap") + + (.empty-message + :color "#666" + :font-style "italic" + :text-align "center" + :padding "20px") + + ;; Status badges for playlists + (.status-draft + :border-left "3px solid #888") + + (.status-pending + :border-left "3px solid #ffcc00") + + (.status-approved + :border-left "3px solid #00cc00") + + (.status-rejected + :border-left "3px solid #cc0000") ) ;; End of let block diff --git a/template/admin.ctml b/template/admin.ctml index 54232a8..43c3bbd 100644 --- a/template/admin.ctml +++ b/template/admin.ctml @@ -102,6 +102,22 @@
+ +
+

🎵 Track Requests

+
+ + + + + + +
+
+

Loading requests...

+
+
+

Music Library Management

@@ -346,6 +362,15 @@

+ +
+

📋 User Playlist Submissions

+

Review and approve user-submitted playlists. Approved playlists will be available for scheduling.

+
+

Loading submissions...

+
+
+
diff --git a/template/audio-player-frame.ctml b/template/audio-player-frame.ctml index 7c9772d..43ac13d 100644 --- a/template/audio-player-frame.ctml +++ b/template/audio-player-frame.ctml @@ -36,6 +36,7 @@ Loading... +
+ + +
+

🎵 Request a Track

+

Want to hear something specific? Submit a request and an admin will review it.

+
+ + + +
+ +
+

Recently Played Requests

+
+

Loading...

+
+
+