feat: Add track favorites feature with star button
- Add user_favorites and listening_history database tables - Add migration 005-user-favorites-history.sql - Create user-profile.lisp with favorites/history API endpoints - Add star button (☆/★) to Now Playing on main page - Add star button to frame player bar - Add Favorites section to profile page - Show login prompt when unauthenticated user clicks star - Use gold color (#ffcc00) for favorited state (space theme) - Fix require-authentication to properly detect API routes - Support title-based favorites (no track DB required)
This commit is contained in:
parent
349fa31d8f
commit
bfc33c8d4e
|
|
@ -63,6 +63,7 @@
|
||||||
(:file "stream-control")
|
(:file "stream-control")
|
||||||
(:file "playlist-scheduler")
|
(:file "playlist-scheduler")
|
||||||
(:file "listener-stats")
|
(:file "listener-stats")
|
||||||
|
(:file "user-profile")
|
||||||
(:file "auth-routes")
|
(:file "auth-routes")
|
||||||
(:file "frontend-partials")
|
(:file "frontend-partials")
|
||||||
(:file "asteroid")))
|
(:file "asteroid")))
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,19 @@
|
||||||
(position :integer)
|
(position :integer)
|
||||||
(added_date :integer))))
|
(added_date :integer))))
|
||||||
|
|
||||||
|
(unless (db:collection-exists-p "user_favorites")
|
||||||
|
(db:create "user_favorites" '((user-id :integer)
|
||||||
|
(track-id :integer)
|
||||||
|
(rating :integer)
|
||||||
|
(created-date :integer))))
|
||||||
|
|
||||||
|
(unless (db:collection-exists-p "listening_history")
|
||||||
|
(db:create "listening_history" '((user-id :integer)
|
||||||
|
(track-id :integer)
|
||||||
|
(listened-at :integer)
|
||||||
|
(listen-duration :integer)
|
||||||
|
(completed :integer))))
|
||||||
|
|
||||||
;; TODO: the radiance db interface is too basic to contain anything
|
;; TODO: the radiance db interface is too basic to contain anything
|
||||||
;; but strings, integers, booleans, and maybe timestamps... we will
|
;; but strings, integers, booleans, and maybe timestamps... we will
|
||||||
;; need to rethink this. currently track/playlist relationships are
|
;; need to rethink this. currently track/playlist relationships are
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,23 @@
|
||||||
(in-package :asteroid)
|
(in-package :asteroid)
|
||||||
|
|
||||||
|
(defun find-track-by-title (title)
|
||||||
|
"Find a track in the database by its title. Returns track ID or nil."
|
||||||
|
(when (and title (not (string= title "Unknown")))
|
||||||
|
(handler-case
|
||||||
|
(with-db
|
||||||
|
(let* ((search-pattern (format nil "%~a%" title))
|
||||||
|
(result (postmodern:query
|
||||||
|
(:limit
|
||||||
|
(:select '_id
|
||||||
|
:from 'tracks
|
||||||
|
:where (:ilike 'title search-pattern))
|
||||||
|
1)
|
||||||
|
:single)))
|
||||||
|
result))
|
||||||
|
(error (e)
|
||||||
|
(declare (ignore e))
|
||||||
|
nil))))
|
||||||
|
|
||||||
(defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3"))
|
(defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3"))
|
||||||
"Fetch now-playing information from Icecast server.
|
"Fetch now-playing information from Icecast server.
|
||||||
|
|
||||||
|
|
@ -54,7 +72,8 @@
|
||||||
|
|
||||||
`((:listenurl . ,(format nil "~a/~a" *stream-base-url* mount))
|
`((:listenurl . ,(format nil "~a/~a" *stream-base-url* mount))
|
||||||
(:title . ,title)
|
(:title . ,title)
|
||||||
(:listeners . ,total-listeners)))))))
|
(:listeners . ,total-listeners)
|
||||||
|
(:track-id . ,(find-track-by-title title))))))))
|
||||||
|
|
||||||
(define-api asteroid/partial/now-playing (&optional mount) ()
|
(define-api asteroid/partial/now-playing (&optional mount) ()
|
||||||
"Get Partial HTML with live status from Icecast server.
|
"Get Partial HTML with live status from Icecast server.
|
||||||
|
|
@ -75,7 +94,8 @@
|
||||||
(setf (header "Content-Type") "text/html")
|
(setf (header "Content-Type") "text/html")
|
||||||
(clip:process-to-string
|
(clip:process-to-string
|
||||||
(load-template "partial/now-playing")
|
(load-template "partial/now-playing")
|
||||||
:stats now-playing-stats))
|
:stats now-playing-stats
|
||||||
|
:track-id (cdr (assoc :track-id now-playing-stats))))
|
||||||
(progn
|
(progn
|
||||||
(setf (header "Content-Type") "text/html")
|
(setf (header "Content-Type") "text/html")
|
||||||
(clip:process-to-string
|
(clip:process-to-string
|
||||||
|
|
@ -97,6 +117,21 @@
|
||||||
(setf (header "Content-Type") "text/plain")
|
(setf (header "Content-Type") "text/plain")
|
||||||
"Stream Offline")))))
|
"Stream Offline")))))
|
||||||
|
|
||||||
|
(define-api asteroid/partial/now-playing-json (&optional mount) ()
|
||||||
|
"Get JSON with now playing info including track ID for favorites.
|
||||||
|
Optional MOUNT parameter specifies which stream to get metadata from."
|
||||||
|
(with-error-handling
|
||||||
|
(let* ((mount-name (or mount "asteroid.mp3"))
|
||||||
|
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
|
||||||
|
(if now-playing-stats
|
||||||
|
(api-output `(("status" . "success")
|
||||||
|
("title" . ,(cdr (assoc :title now-playing-stats)))
|
||||||
|
("listeners" . ,(cdr (assoc :listeners now-playing-stats)))
|
||||||
|
("track_id" . ,(cdr (assoc :track-id now-playing-stats)))))
|
||||||
|
(api-output `(("status" . "offline")
|
||||||
|
("title" . "Stream Offline")
|
||||||
|
("track_id" . nil)))))))
|
||||||
|
|
||||||
(define-api asteroid/channel-name () ()
|
(define-api asteroid/channel-name () ()
|
||||||
"Get the current curated channel name for live updates.
|
"Get the current curated channel name for live updates.
|
||||||
Returns JSON with the channel name from the current playlist's PHASE header."
|
Returns JSON with the channel name from the current playlist's PHASE header."
|
||||||
|
|
|
||||||
|
|
@ -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 $$;
|
||||||
|
|
@ -592,6 +592,56 @@
|
||||||
|
|
||||||
(redirect-when-frame)))))
|
(redirect-when-frame)))))
|
||||||
|
|
||||||
|
;; Toggle favorite for current track
|
||||||
|
(defun toggle-favorite ()
|
||||||
|
(let ((track-id-el (ps:chain document (get-element-by-id "current-track-id")))
|
||||||
|
(title-el (ps:chain document (get-element-by-id "current-track-title")))
|
||||||
|
(btn (ps:chain document (get-element-by-id "favorite-btn"))))
|
||||||
|
(let ((track-id (when track-id-el (ps:@ track-id-el value)))
|
||||||
|
(title (when title-el (ps:@ title-el text-content)))
|
||||||
|
(is-favorited (ps:chain btn class-list (contains "favorited"))))
|
||||||
|
;; Need either track-id or title
|
||||||
|
(when (or (and track-id (not (= track-id ""))) title)
|
||||||
|
(let ((params (+ "title=" (encode-u-r-i-component (or title ""))
|
||||||
|
(if (and track-id (not (= track-id "")))
|
||||||
|
(+ "&track-id=" track-id)
|
||||||
|
""))))
|
||||||
|
(if is-favorited
|
||||||
|
;; Remove favorite
|
||||||
|
(ps:chain
|
||||||
|
(fetch (+ "/api/asteroid/user/favorites/remove?" params)
|
||||||
|
(ps:create :method "POST"))
|
||||||
|
(then (lambda (response)
|
||||||
|
(cond
|
||||||
|
((not (ps:@ response ok))
|
||||||
|
(alert "Please log in to manage favorites")
|
||||||
|
nil)
|
||||||
|
(t (ps:chain response (json))))))
|
||||||
|
(then (lambda (data)
|
||||||
|
(when (and data (or (= (ps:@ data status) "success")
|
||||||
|
(= (ps:@ data data status) "success")))
|
||||||
|
(ps:chain btn class-list (remove "favorited"))
|
||||||
|
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆"))))
|
||||||
|
(catch (lambda (error)
|
||||||
|
(ps:chain console (error "Error removing favorite:" error)))))
|
||||||
|
;; Add favorite
|
||||||
|
(ps:chain
|
||||||
|
(fetch (+ "/api/asteroid/user/favorites/add?" params)
|
||||||
|
(ps:create :method "POST"))
|
||||||
|
(then (lambda (response)
|
||||||
|
(cond
|
||||||
|
((not (ps:@ response ok))
|
||||||
|
(alert "Please log in to save favorites")
|
||||||
|
nil)
|
||||||
|
(t (ps:chain response (json))))))
|
||||||
|
(then (lambda (data)
|
||||||
|
(when (and data (or (= (ps:@ data status) "success")
|
||||||
|
(= (ps:@ data data status) "success")))
|
||||||
|
(ps:chain btn class-list (add "favorited"))
|
||||||
|
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★"))))
|
||||||
|
(catch (lambda (error)
|
||||||
|
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
||||||
|
|
||||||
;; Update now playing every 5 seconds
|
;; Update now playing every 5 seconds
|
||||||
(set-interval update-now-playing 5000)
|
(set-interval update-now-playing 5000)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,66 @@
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error loading top artists:" error))))))
|
(ps:chain console (error "Error loading top artists:" error))))))
|
||||||
|
|
||||||
|
(defvar *favorites-offset* 0)
|
||||||
|
|
||||||
|
(defun load-favorites ()
|
||||||
|
(ps:chain
|
||||||
|
(fetch "/api/asteroid/user/favorites")
|
||||||
|
(then (lambda (response) (ps:chain response (json))))
|
||||||
|
(then (lambda (result)
|
||||||
|
(let ((data (or (ps:@ result data) result))
|
||||||
|
(container (ps:chain document (get-element-by-id "favorites-list"))))
|
||||||
|
(when container
|
||||||
|
(if (and (= (ps:@ data status) "success")
|
||||||
|
(ps:@ data favorites)
|
||||||
|
(> (ps:@ data favorites length) 0))
|
||||||
|
(progn
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) "")
|
||||||
|
(ps:chain data favorites
|
||||||
|
(for-each (lambda (fav)
|
||||||
|
(let ((item (ps:chain document (create-element "div"))))
|
||||||
|
(setf (ps:@ item class-name) "track-item favorite-item")
|
||||||
|
(setf (ps:@ item inner-h-t-m-l)
|
||||||
|
(+ "<div class=\"track-info\">"
|
||||||
|
"<span class=\"track-title\">" (or (ps:@ fav title) "Unknown") "</span>"
|
||||||
|
"<span class=\"track-artist\">" (or (ps:@ fav artist) "") "</span>"
|
||||||
|
"</div>"
|
||||||
|
"<div class=\"track-meta\">"
|
||||||
|
"<span class=\"rating\">" (render-stars (or (ps:@ fav rating) 1)) "</span>"
|
||||||
|
"<button class=\"btn btn-small btn-danger\" onclick=\"removeFavorite(" (ps:@ fav track_id) ")\">Remove</button>"
|
||||||
|
"</div>"))
|
||||||
|
(ps:chain container (append-child item)))))))
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No favorites yet. Like tracks while listening!</p>"))))))
|
||||||
|
(catch (lambda (error)
|
||||||
|
(ps:chain console (error "Error loading favorites:" error))
|
||||||
|
(let ((container (ps:chain document (get-element-by-id "favorites-list"))))
|
||||||
|
(when container
|
||||||
|
(setf (ps:@ container inner-h-t-m-l) "<p class=\"error\">Failed to load favorites</p>")))))))
|
||||||
|
|
||||||
|
(defun render-stars (rating)
|
||||||
|
(let ((stars ""))
|
||||||
|
(dotimes (i 5)
|
||||||
|
(setf stars (+ stars (if (< i rating) "★" "☆"))))
|
||||||
|
stars))
|
||||||
|
|
||||||
|
(defun remove-favorite (track-id)
|
||||||
|
(ps:chain
|
||||||
|
(fetch (+ "/api/asteroid/user/favorites/remove?track-id=" track-id)
|
||||||
|
(ps:create :method "POST"))
|
||||||
|
(then (lambda (response) (ps:chain response (json))))
|
||||||
|
(then (lambda (data)
|
||||||
|
(if (= (ps:@ data status) "success")
|
||||||
|
(progn
|
||||||
|
(show-message "Removed from favorites" "success")
|
||||||
|
(load-favorites))
|
||||||
|
(show-message "Failed to remove favorite" "error"))))
|
||||||
|
(catch (lambda (error)
|
||||||
|
(ps:chain console (error "Error removing favorite:" error))
|
||||||
|
(show-message "Error removing favorite" "error")))))
|
||||||
|
|
||||||
|
(defun load-more-favorites ()
|
||||||
|
(show-message "Loading more favorites..." "info"))
|
||||||
|
|
||||||
(defun load-profile-data ()
|
(defun load-profile-data ()
|
||||||
(ps:chain console (log "Loading profile data..."))
|
(ps:chain console (log "Loading profile data..."))
|
||||||
|
|
||||||
|
|
@ -188,6 +248,7 @@
|
||||||
|
|
||||||
(load-listening-stats)
|
(load-listening-stats)
|
||||||
(load-recent-tracks)
|
(load-recent-tracks)
|
||||||
|
(load-favorites)
|
||||||
(load-top-artists))
|
(load-top-artists))
|
||||||
|
|
||||||
;; Action functions
|
;; Action functions
|
||||||
|
|
|
||||||
|
|
@ -202,18 +202,74 @@
|
||||||
(defun update-mini-now-playing ()
|
(defun update-mini-now-playing ()
|
||||||
(let ((mount (get-current-mount)))
|
(let ((mount (get-current-mount)))
|
||||||
(ps:chain
|
(ps:chain
|
||||||
(fetch (+ "/api/asteroid/partial/now-playing-inline?mount=" mount))
|
(fetch (+ "/api/asteroid/partial/now-playing-json?mount=" mount))
|
||||||
(then (lambda (response)
|
(then (lambda (response)
|
||||||
(if (ps:@ response ok)
|
(if (ps:@ response ok)
|
||||||
(ps:chain response (text))
|
(ps:chain response (json))
|
||||||
"")))
|
nil)))
|
||||||
(then (lambda (text)
|
(then (lambda (data)
|
||||||
(let ((el (ps:chain document (get-element-by-id "mini-now-playing"))))
|
(when data
|
||||||
|
(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
|
(when el
|
||||||
(setf (ps:@ el text-content) text)))))
|
(setf (ps:@ el text-content) title))
|
||||||
|
(when track-id-el
|
||||||
|
(let ((track-id (or (ps:@ data data track_id) (ps:@ data track_id))))
|
||||||
|
(setf (ps:@ track-id-el value) (or track-id ""))))))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (log "Could not fetch now playing:" error)))))))
|
(ps:chain console (log "Could not fetch now playing:" error)))))))
|
||||||
|
|
||||||
|
;; Toggle favorite for mini player
|
||||||
|
(defun toggle-favorite-mini ()
|
||||||
|
(let ((track-id-el (ps:chain document (get-element-by-id "current-track-id-mini")))
|
||||||
|
(title-el (ps:chain document (get-element-by-id "mini-now-playing")))
|
||||||
|
(btn (ps:chain document (get-element-by-id "favorite-btn-mini"))))
|
||||||
|
(let ((track-id (when track-id-el (ps:@ track-id-el value)))
|
||||||
|
(title (when title-el (ps:@ title-el text-content)))
|
||||||
|
(is-favorited (ps:chain btn class-list (contains "favorited"))))
|
||||||
|
;; Need either track-id or title
|
||||||
|
(when (or (and track-id (not (= track-id ""))) title)
|
||||||
|
(let ((params (+ "title=" (encode-u-r-i-component (or title ""))
|
||||||
|
(if (and track-id (not (= track-id "")))
|
||||||
|
(+ "&track-id=" track-id)
|
||||||
|
""))))
|
||||||
|
(if is-favorited
|
||||||
|
;; Remove favorite
|
||||||
|
(ps:chain
|
||||||
|
(fetch (+ "/api/asteroid/user/favorites/remove?" params)
|
||||||
|
(ps:create :method "POST"))
|
||||||
|
(then (lambda (response)
|
||||||
|
(cond
|
||||||
|
((not (ps:@ response ok))
|
||||||
|
(alert "Please log in to manage favorites")
|
||||||
|
nil)
|
||||||
|
(t (ps:chain response (json))))))
|
||||||
|
(then (lambda (data)
|
||||||
|
(when (and data (or (= (ps:@ data status) "success")
|
||||||
|
(= (ps:@ data data status) "success")))
|
||||||
|
(ps:chain btn class-list (remove "favorited"))
|
||||||
|
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆"))))
|
||||||
|
(catch (lambda (error)
|
||||||
|
(ps:chain console (error "Error removing favorite:" error)))))
|
||||||
|
;; Add favorite
|
||||||
|
(ps:chain
|
||||||
|
(fetch (+ "/api/asteroid/user/favorites/add?" params)
|
||||||
|
(ps:create :method "POST"))
|
||||||
|
(then (lambda (response)
|
||||||
|
(cond
|
||||||
|
((not (ps:@ response ok))
|
||||||
|
(alert "Please log in to save favorites")
|
||||||
|
nil)
|
||||||
|
(t (ps:chain response (json))))))
|
||||||
|
(then (lambda (data)
|
||||||
|
(when (and data (or (= (ps:@ data status) "success")
|
||||||
|
(= (ps:@ data data status) "success")))
|
||||||
|
(ps:chain btn class-list (add "favorited"))
|
||||||
|
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★"))))
|
||||||
|
(catch (lambda (error)
|
||||||
|
(ps:chain console (error "Error adding favorite:" error)))))))))))
|
||||||
|
|
||||||
;; Update popout now playing display (parses artist - title)
|
;; Update popout now playing display (parses artist - title)
|
||||||
(defun update-popout-now-playing ()
|
(defun update-popout-now-playing ()
|
||||||
(let ((mount (get-current-mount)))
|
(let ((mount (get-current-mount)))
|
||||||
|
|
@ -641,6 +697,7 @@
|
||||||
(setf (ps:@ window init-popout-player) init-popout-player)
|
(setf (ps:@ window init-popout-player) init-popout-player)
|
||||||
(setf (ps:@ window update-mini-now-playing) update-mini-now-playing)
|
(setf (ps:@ window update-mini-now-playing) update-mini-now-playing)
|
||||||
(setf (ps:@ window update-popout-now-playing) update-popout-now-playing)
|
(setf (ps:@ window update-popout-now-playing) update-popout-now-playing)
|
||||||
|
(setf (ps:@ window toggle-favorite-mini) toggle-favorite-mini)
|
||||||
|
|
||||||
;; Auto-initialize on DOMContentLoaded based on which elements exist
|
;; Auto-initialize on DOMContentLoaded based on which elements exist
|
||||||
(ps:chain document
|
(ps:chain document
|
||||||
|
|
|
||||||
|
|
@ -1,144 +1,83 @@
|
||||||
#EXTM3U
|
#EXTM3U
|
||||||
#PLAYLIST:Escape Velocity - A Christmas Journey Through Space
|
#PLAYLIST:Midnight Ambient
|
||||||
#PHASE:Escape Velocity
|
#PHASE:Midnight Ambient
|
||||||
#DURATION:12 hours (approx)
|
#DURATION:6 hours (approx)
|
||||||
#CURATOR:Asteroid Radio
|
#CURATOR:Asteroid Radio
|
||||||
#DESCRIPTION:A festive 12-hour voyage blending Christmas classics with ambient, IDM, and space music for the holiday season
|
#DESCRIPTION:Deep, dark ambient for the late night hours (00:00-06:00)
|
||||||
|
|
||||||
# === PHASE 1: WINTER AWAKENING (Ambient Beginnings) ===
|
#EXTINF:-1,Biosphere - The Petrified Forest
|
||||||
#EXTINF:-1,Brian Eno - Snow
|
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/04. Biosphere - The Petrified Forest.flac
|
||||||
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac
|
#EXTINF:-1,Labradford - S
|
||||||
#EXTINF:-1,Brian Eno - Wintergreen
|
/app/music/Labradford/1997 - Mi Media Naranja/1 S.flac
|
||||||
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/04 Wintergreen.flac
|
#EXTINF:-1,Tim Hecker - Seasick
|
||||||
#EXTINF:-1,Proem - Winter Wolves
|
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 01 Seasick.flac
|
||||||
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
|
#EXTINF:-1,Pye Corner Audio - Hollow Earth
|
||||||
#EXTINF:-1,Tim Hecker - Winter's Coming
|
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/01 - Hollow Earth.mp3
|
||||||
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 10 Winter's Coming.flac
|
|
||||||
#EXTINF:-1,Biosphere - Drifter
|
|
||||||
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac
|
|
||||||
#EXTINF:-1,Dead Voices On Air - On Winters Gibbet
|
|
||||||
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/02 - On Winters Gibbet.flac
|
|
||||||
#EXTINF:-1,Color Therapy - Wintering
|
|
||||||
/app/music/Color Therapy - Mr. Wolf Is Dead (2015) WEB FLAC/12 - Wintering.flac
|
|
||||||
|
|
||||||
# === PHASE 2: CHRISTMAS ARRIVAL (TSO Introduction) ===
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - The Ghosts Of Christmas Eve
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/01 The Ghosts Of Christmas Eve.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas In The Air
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/14 Christmas In The Air.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Canon
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/08 Christmas Canon.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Appalachian Snowfall
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/11 Appalachian Snowfall.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - The Snow Came Down
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/13 The Snow Came Down.flac
|
|
||||||
|
|
||||||
# === PHASE 3: AMBIENT INTERLUDE (Space & Atmosphere) ===
|
|
||||||
#EXTINF:-1,Biosphere - 10 Snurp 1937
|
|
||||||
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 10 Snurp 1937.flac
|
|
||||||
#EXTINF:-1,Biosphere - 05 Fluvialmorphologie
|
|
||||||
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 05 Fluvialmorphologie.flac
|
|
||||||
#EXTINF:-1,God is an Astronaut - Winter Dusk-Awakening
|
|
||||||
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/03. Winter Dusk-Awakening.flac
|
|
||||||
#EXTINF:-1,Proem - Snow Drifts
|
|
||||||
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/11 - Snow Drifts.flac
|
|
||||||
#EXTINF:-1,Proem - Stick to Music Snowflake
|
|
||||||
/app/music/Proem - Until Here for Years (n5md, 2019) flac/04 - Stick to Music Snowflake.flac
|
|
||||||
#EXTINF:-1,Four Tet - 04 Tremper
|
|
||||||
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/04 Tremper.flac
|
|
||||||
|
|
||||||
# === PHASE 4: CHRISTMAS EVE STORIES ===
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - First Snow (Instrumental)
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/04 First Snow (Instrumental).flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - The Silent Nutcracker (Instrumental)
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/05 The Silent Nutcracker (Instrumental).flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - A Mad Russian's Christmas (Instrumental)
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/06 A Mad Russian's Christmas (Instrumental).flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Eve,Sarajevo 12,24 (Instrumental)
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/08 Christmas Eve,Sarajevo 12,24 (Instrumental).flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - This Christmas Day
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/14 This Christmas Day.flac
|
|
||||||
|
|
||||||
# === PHASE 5: ELECTRONIC DREAMS (IDM & Ambient) ===
|
|
||||||
#EXTINF:-1,Autechre - NTS Session 1-005-Autechre-carefree counter dronal
|
|
||||||
/app/music/Autechre - 2018 - NTS Session 1/NTS Session 1-005-Autechre-carefree counter dronal.flac
|
|
||||||
#EXTINF:-1,Clark - Living Fantasy
|
|
||||||
/app/music/Clark - Death Peak (2017) [FLAC]/08 - Living Fantasy.flac
|
|
||||||
#EXTINF:-1,Clark - My Machines (Clark Remix)
|
|
||||||
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.17. Battles - My Machines (Clark Remix).flac
|
|
||||||
#EXTINF:-1,Plaid - Dancers
|
|
||||||
/app/music/Plaid - Polymer (2019) [WEB FLAC]/07 - Dancers.flac
|
|
||||||
#EXTINF:-1,Faux Tales - Avalon
|
|
||||||
/app/music/Faux Tales - 2015 - Kairos [FLAC] {Kensai Records KNS006 WEB}/3 - Avalon.flac
|
|
||||||
#EXTINF:-1,Color Therapy - Expect Delays (feat. Ulrich Schnauss)
|
|
||||||
/app/music/Color Therapy - Mr. Wolf Is Dead (2015) WEB FLAC/11 - Expect Delays (feat. Ulrich Schnauss).flac
|
|
||||||
|
|
||||||
# === PHASE 6: THE LOST CHRISTMAS EVE ===
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - The Lost Christmas Eve
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/02 The Lost Christmas Eve.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Dreams
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/03 Christmas Dreams.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Wizards in Winter
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/04 Wizards in Winter.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Concerto
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/07 Christmas Concerto.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Queen Of The Winter Night
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/08 Queen Of The Winter Night.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Nights In Blue
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/09 Christmas Nights In Blue.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Jazz
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/10 Christmas Jazz.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Jam
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/11 Christmas Jam.flac
|
|
||||||
|
|
||||||
# === PHASE 7: CLASSICAL WINTER (Nutcracker & More) ===
|
|
||||||
#EXTINF:-1,Various Artists - Dance of the Sugar-Plum Fairy
|
|
||||||
/app/music/Various Artists - The 50 Darkest Pieces of Classical Music (2011) - FLAC/CD 1/02 - Tchaikovsky - The Nutcracker - Dance of the Sugar-Plum Fairy.flac
|
|
||||||
#EXTINF:-1,Quaeschning and Ulrich Schnauss - Thirst
|
|
||||||
/app/music/Quaeschning and Ulrich Schnauss - Synthwaves (2017) {vista003, GER, CD} [FLAC]/06 - Thirst.flac
|
|
||||||
#EXTINF:-1,Proem - 04. Drawing Room Anguish
|
|
||||||
/app/music/Proem - 2018 Modern Rope (WEB)/04. Drawing Room Anguish.flac
|
|
||||||
#EXTINF:-1,Dead Voices On Air - 07. Dogger Doorlopende Split
|
|
||||||
/app/music/Dead Voices On Air - Frankie Pett En De Onderzeer Boten (2017) web/07. Dogger Doorlopende Split.flac
|
|
||||||
|
|
||||||
# === PHASE 8: WISDOM & REFLECTION ===
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - What Is Christmas
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/13 What Is Christmas.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - The Wisdom Of Snow
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/15 The Wisdom Of Snow.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Bells, Carousels & Time
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/18 Christmas Bells, Carousels & Time.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Canon Rock
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/21 Christmas Canon Rock.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Midnight Christmas Eve
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/05 Midnight Christmas Eve.flac
|
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Dream Child (A Christmas Dream)
|
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/15 Dream Child (A Christmas Dream).flac
|
|
||||||
|
|
||||||
# === PHASE 9: DEEP SPACE JOURNEY (Extended Ambient) ===
|
|
||||||
#EXTINF:-1,Dead Voices On Air - Red Howls
|
#EXTINF:-1,Dead Voices On Air - Red Howls
|
||||||
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/01 - Red Howls.flac
|
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/01 - Red Howls.flac
|
||||||
#EXTINF:-1,Cut Copy - Airborne
|
#EXTINF:-1,Tangerine Dream - The Seventh Propellor of Silence
|
||||||
/app/music/Cut Copy - Haiku From Zero (2017) [FLAC] {2557864014}/05 - Airborne.flac
|
/app/music/Tangerine Dream - Ambient Monkeys (flac)/03 Tangerine Dream - The Seventh Propellor of Silence.flac
|
||||||
#EXTINF:-1,Owl City - 01 Hot Air Balloon
|
#EXTINF:-1,Biosphere - Fall Asleep For Me
|
||||||
/app/music/Owl City - Ocean Eyes (Deluxe Edition) [Flac,Cue,Logs]/Disc 2/01 Hot Air Balloon.flac
|
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/17 - Fall Asleep For Me.flac
|
||||||
#EXTINF:-1,VA - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit]
|
#EXTINF:-1,Locrian - Arc of Extinction
|
||||||
/app/music/VA - Melodic Vocal Trance 2017/24. Airborn, Bogdan Vix & KeyPlayer - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit].flac
|
/app/music/Locrian - Infinite Dissolution (2015) - WEB FLAC/01 - Arc of Extinction.flac
|
||||||
#EXTINF:-1,VA - Winter Took Over (Radio Edit)
|
#EXTINF:-1,FSOL - Polarize
|
||||||
/app/music/VA - Melodic Vocal Trance 2017/22. Bluskay, KeyPlayer & Esmee Bor Stotijn - Winter Took Over (Radio Edit).flac
|
/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/01 - Polarize.flac
|
||||||
#EXTINF:-1,Alison Krauss and Union Station - My Opening Farewell
|
#EXTINF:-1,Marconi Union - Sleeper
|
||||||
/app/music/Alison Krauss and Union Station - Paper Airplane (flac)/11 - Alison Krauss & Union Station - My Opening Farewell.flac
|
/app/music/Marconi Union - Ghost Stations (2016 - WEB - FLAC)/01. Marconi Union - Sleeper.flac
|
||||||
#EXTINF:-1,Bedouin Soundclash - Money Worries (E-Clair Refix)
|
#EXTINF:-1,Labradford - G
|
||||||
/app/music/Bedouin Soundclash - Sounding a Mosaic (2004) [FLAC] {SD1267}/14 - Money Worries (E-Clair Refix).flac
|
/app/music/Labradford/1997 - Mi Media Naranja/2 G.flac
|
||||||
|
#EXTINF:-1,Biosphere - This Is The End
|
||||||
# === PHASE 10: RETURN TO WINTER (Closing Circle) ===
|
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/06. Biosphere - This Is The End.flac
|
||||||
#EXTINF:-1,Brian Eno - Snow
|
#EXTINF:-1,Pye Corner Audio - Descent
|
||||||
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac
|
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/02 - Descent.mp3
|
||||||
#EXTINF:-1,Proem - Winter Wolves
|
#EXTINF:-1,Brian Eno - Reflection
|
||||||
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
|
/app/music/Brian Eno/2017 - Reflection/01. Reflection.mp3
|
||||||
#EXTINF:-1,God is an Astronaut - Winter Dusk-Awakening
|
#EXTINF:-1,Dead Voices On Air - On Winters Gibbet
|
||||||
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/03. Winter Dusk-Awakening.flac
|
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/02 - On Winters Gibbet.flac
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - The Snow Came Down
|
#EXTINF:-1,Tim Hecker - Left On The Ice
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/13 The Snow Came Down.flac
|
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 05 Left On The Ice.flac
|
||||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas In The Air
|
#EXTINF:-1,Autechre - Further
|
||||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/14 Christmas In The Air.flac
|
/app/music/Autechre/1994 - Amber/08 Further.flac
|
||||||
|
#EXTINF:-1,Tangerine Dream - Moon Marble
|
||||||
|
/app/music/Tangerine Dream - Ambient Monkeys (flac)/06 Tangerine Dream - Moon Marble.flac
|
||||||
|
#EXTINF:-1,Biosphere - Sweet Dreams Form A Shade
|
||||||
|
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/07 - Sweet Dreams Form A Shade.flac
|
||||||
|
#EXTINF:-1,Locrian - Dark Shales
|
||||||
|
/app/music/Locrian - Infinite Dissolution (2015) - WEB FLAC/02 - Dark Shales.flac
|
||||||
|
#EXTINF:-1,FSOL - Forest Soundbed
|
||||||
|
/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/06 - Forest Soundbed.flac
|
||||||
|
#EXTINF:-1,Pye Corner Audio - Subterranean Lakes
|
||||||
|
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/09 - Subterranean Lakes.mp3
|
||||||
|
#EXTINF:-1,Labradford - WR
|
||||||
|
/app/music/Labradford/1997 - Mi Media Naranja/3 WR.flac
|
||||||
|
#EXTINF:-1,Bark Psychosis - Hex
|
||||||
|
/app/music/Bark Psychosis/1994 - Hex/01 The Loom.flac
|
||||||
|
#EXTINF:-1,Biosphere - Turned To Stone
|
||||||
|
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/03. Biosphere - Turned To Stone.flac
|
||||||
|
#EXTINF:-1,Tim Hecker - Winter's Coming
|
||||||
|
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 10 Winter's Coming.flac
|
||||||
|
#EXTINF:-1,Dead Voices On Air - On Wicca Way
|
||||||
|
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/03 - On Wicca Way.flac
|
||||||
|
#EXTINF:-1,Marconi Union - Abandoned - In Silence
|
||||||
|
/app/music/Marconi Union - Ghost Stations (2016 - WEB - FLAC)/03. Marconi Union - Abandoned - In Silence.flac
|
||||||
|
#EXTINF:-1,Pye Corner Audio - Deeper Dreaming
|
||||||
|
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/13 - Deeper Dreaming.mp3
|
||||||
|
#EXTINF:-1,Tangerine Dream - Myopia World
|
||||||
|
/app/music/Tangerine Dream - Ambient Monkeys (flac)/12 Tangerine Dream - Myopia World.flac
|
||||||
|
#EXTINF:-1,Biosphere - Departed Glories
|
||||||
|
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/09 - Departed Glories.flac
|
||||||
|
#EXTINF:-1,Locrian - The Future of Death
|
||||||
|
/app/music/Locrian - Infinite Dissolution (2015) - WEB FLAC/04 - The Future of Death.flac
|
||||||
|
#EXTINF:-1,Labradford - V
|
||||||
|
/app/music/Labradford/1997 - Mi Media Naranja/6 V.flac
|
||||||
|
#EXTINF:-1,FSOL - Solace
|
||||||
|
/app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/23 - Solace.flac
|
||||||
|
#EXTINF:-1,Pye Corner Audio - Buried Memories
|
||||||
|
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/12 - Buried Memories.mp3
|
||||||
|
#EXTINF:-1,Tim Hecker - Twinkle In The Wasteland
|
||||||
|
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 14 Twinkle In The Wasteland.flac
|
||||||
|
#EXTINF:-1,Locrian - Heavy Water
|
||||||
|
/app/music/Locrian - Infinite Dissolution (2015) - WEB FLAC/08 - Heavy Water.flac
|
||||||
|
#EXTINF:-1,Tape Loop Orchestra - 1953 Culture Festival
|
||||||
|
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/01 - 1953 Culture Festival.flac
|
||||||
|
|
|
||||||
|
|
@ -1546,3 +1546,159 @@ body.popout-body .status-mini{
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-footer{
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
margin-top: 30px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer a{
|
||||||
|
color: #888;
|
||||||
|
text-decoration: none;
|
||||||
|
-moz-transition: color 0.2s ease;
|
||||||
|
-o-transition: color 0.2s ease;
|
||||||
|
-webkit-transition: color 0.2s ease;
|
||||||
|
-ms-transition: color 0.2s ease;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer a:hover{
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.site-footer .craftering a{
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-track{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-playing-track p{
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-favorite{
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 1.4em;
|
||||||
|
-moz-transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
-o-transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
-webkit-transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
-ms-transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-favorite .star-icon{
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-favorite:hover{
|
||||||
|
-moz-transform: scale(1.2);
|
||||||
|
-o-transform: scale(1.2);
|
||||||
|
-webkit-transform: scale(1.2);
|
||||||
|
-ms-transform: scale(1.2);
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-favorite:hover .star-icon{
|
||||||
|
color: #ffcc00;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.btn-favorite.favorited .star-icon{
|
||||||
|
color: #ffcc00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-favorite-mini{
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 1.3em;
|
||||||
|
-moz-transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
-o-transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
-webkit-transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
-ms-transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-favorite-mini .star-icon{
|
||||||
|
color: #00cc00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-favorite-mini:hover{
|
||||||
|
-moz-transform: scale(1.3);
|
||||||
|
-o-transform: scale(1.3);
|
||||||
|
-webkit-transform: scale(1.3);
|
||||||
|
-ms-transform: scale(1.3);
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-favorite-mini:hover .star-icon{
|
||||||
|
color: #ffcc00;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.btn-favorite-mini.favorited .star-icon{
|
||||||
|
color: #ffcc00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-list{
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-list .favorite-item{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 15px;
|
||||||
|
margin: 5px 0;
|
||||||
|
background: rgba(0, 255, 0, 0.05);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
-moz-transition: background 0.2s ease;
|
||||||
|
-o-transition: background 0.2s ease;
|
||||||
|
-webkit-transition: background 0.2s ease;
|
||||||
|
-ms-transition: background 0.2s ease;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-list .favorite-item:hover{
|
||||||
|
background: rgba(0, 255, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-list .rating{
|
||||||
|
color: #ffcc00;
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-list .no-data{
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-list .btn-small{
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
@ -1274,4 +1274,89 @@
|
||||||
|
|
||||||
(.craftering
|
(.craftering
|
||||||
(a :margin "0 5px")))
|
(a :margin "0 5px")))
|
||||||
|
|
||||||
|
;; Now playing favorite button
|
||||||
|
(.now-playing-track
|
||||||
|
:display "flex"
|
||||||
|
:align-items "center"
|
||||||
|
:gap "10px"
|
||||||
|
|
||||||
|
(p :margin 0))
|
||||||
|
|
||||||
|
(.btn-favorite
|
||||||
|
:background "transparent"
|
||||||
|
:border "none"
|
||||||
|
:cursor "pointer"
|
||||||
|
:padding "5px 10px"
|
||||||
|
:font-size "1.4em"
|
||||||
|
:transition "transform 0.2s ease, color 0.2s ease"
|
||||||
|
|
||||||
|
(.star-icon
|
||||||
|
:color "#888"))
|
||||||
|
|
||||||
|
((:and .btn-favorite :hover)
|
||||||
|
:transform "scale(1.2)"
|
||||||
|
|
||||||
|
(.star-icon
|
||||||
|
:color "#ffcc00"))
|
||||||
|
|
||||||
|
((:and .btn-favorite .favorited)
|
||||||
|
(.star-icon
|
||||||
|
:color "#ffcc00"))
|
||||||
|
|
||||||
|
;; Mini favorite button for frame player
|
||||||
|
(.btn-favorite-mini
|
||||||
|
:background "transparent"
|
||||||
|
:border "none"
|
||||||
|
:cursor "pointer"
|
||||||
|
:padding "2px 8px"
|
||||||
|
:font-size "1.3em"
|
||||||
|
:transition "transform 0.2s ease, color 0.2s ease"
|
||||||
|
:margin-left "8px"
|
||||||
|
|
||||||
|
(.star-icon
|
||||||
|
:color "#00cc00"))
|
||||||
|
|
||||||
|
((:and .btn-favorite-mini :hover)
|
||||||
|
:transform "scale(1.3)"
|
||||||
|
|
||||||
|
(.star-icon
|
||||||
|
:color "#ffcc00"))
|
||||||
|
|
||||||
|
((:and .btn-favorite-mini .favorited)
|
||||||
|
(.star-icon
|
||||||
|
:color "#ffcc00"))
|
||||||
|
|
||||||
|
;; Favorites list styling
|
||||||
|
(.favorites-list
|
||||||
|
:margin "10px 0"
|
||||||
|
|
||||||
|
(.favorite-item
|
||||||
|
:display "flex"
|
||||||
|
:justify-content "space-between"
|
||||||
|
:align-items "center"
|
||||||
|
:padding "10px 15px"
|
||||||
|
:margin "5px 0"
|
||||||
|
:background "rgba(0, 255, 0, 0.05)"
|
||||||
|
:border "1px solid #333"
|
||||||
|
:border-radius "4px"
|
||||||
|
:transition "background 0.2s ease")
|
||||||
|
|
||||||
|
((:and .favorite-item :hover)
|
||||||
|
:background "rgba(0, 255, 0, 0.1)")
|
||||||
|
|
||||||
|
(.rating
|
||||||
|
:color "#ffcc00"
|
||||||
|
:font-size "1.1em"
|
||||||
|
:margin-right "10px")
|
||||||
|
|
||||||
|
(.no-data
|
||||||
|
:color "#666"
|
||||||
|
:font-style "italic"
|
||||||
|
:text-align "center"
|
||||||
|
:padding "20px")
|
||||||
|
|
||||||
|
(.btn-small
|
||||||
|
:padding "4px 8px"
|
||||||
|
:font-size "0.8em"))
|
||||||
) ;; End of let block
|
) ;; End of let block
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@
|
||||||
|
|
||||||
<span class="now-playing-mini" id="mini-now-playing">Loading...</span>
|
<span class="now-playing-mini" id="mini-now-playing">Loading...</span>
|
||||||
|
|
||||||
|
<button class="btn-favorite-mini" id="favorite-btn-mini" onclick="toggleFavoriteMini()" title="Add to favorites">
|
||||||
|
<span class="star-icon">☆</span>
|
||||||
|
</button>
|
||||||
|
<input type="hidden" id="current-track-id-mini" value="">
|
||||||
|
|
||||||
<button id="reconnect-btn" onclick="reconnectStream()" class="persistent-reconnect-btn" title="Reconnect if audio stops working">
|
<button id="reconnect-btn" onclick="reconnectStream()" class="persistent-reconnect-btn" title="Reconnect if audio stops working">
|
||||||
<img src="/asteroid/static/icons/sync.png" alt="Reconnect" style="width: 18px; height: 18px; vertical-align: middle; filter: invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%);">
|
<img src="/asteroid/static/icons/sync.png" alt="Reconnect" style="width: 18px; height: 18px; vertical-align: middle; filter: invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%);">
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,15 @@
|
||||||
<c:if test="stats">
|
<c:if test="stats">
|
||||||
<c:then>
|
<c:then>
|
||||||
<c:using value="stats">
|
<c:using value="stats">
|
||||||
<!--<p>Artist: <span>The Void</span></p>-->
|
<div class="now-playing-track">
|
||||||
<p>Track: <span lquery="(text title)">The Void - Silence</span></p>
|
<p>Track: <span lquery="(text title)" id="current-track-title">The Void - Silence</span></p>
|
||||||
|
<button class="btn-favorite" id="favorite-btn" onclick="toggleFavorite()" title="Add to favorites">
|
||||||
|
<span class="star-icon">☆</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p>Listeners: <span lquery="(text listeners)">1</span></p>
|
<p>Listeners: <span lquery="(text listeners)">1</span></p>
|
||||||
</c:using>
|
</c:using>
|
||||||
|
<input type="hidden" id="current-track-id" lquery="(val track-id)" value="">
|
||||||
</c:then>
|
</c:then>
|
||||||
<c:else>
|
<c:else>
|
||||||
<c:if test="connection-error">
|
<c:if test="connection-error">
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Favorite Tracks -->
|
||||||
|
<div class="admin-section">
|
||||||
|
<h2>❤️ Favorite Tracks</h2>
|
||||||
|
<div class="favorites-list" id="favorites-list">
|
||||||
|
<p class="loading-message">Loading favorites...</p>
|
||||||
|
</div>
|
||||||
|
<div class="profile-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="loadMoreFavorites()">Load More</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Top Artists -->
|
<!-- Top Artists -->
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h2>🎤 Top Artists</h2>
|
<h2>🎤 Top Artists</h2>
|
||||||
|
|
|
||||||
|
|
@ -164,24 +164,26 @@
|
||||||
(let* ((user-id (session:field "user-id"))
|
(let* ((user-id (session:field "user-id"))
|
||||||
(uri (radiance:path (radiance:uri *request*)))
|
(uri (radiance:path (radiance:uri *request*)))
|
||||||
;; Use explicit flag if provided, otherwise auto-detect from URI
|
;; Use explicit flag if provided, otherwise auto-detect from URI
|
||||||
(is-api-request (if api t (search "/api/" uri))))
|
;; Check for "api/" anywhere in the path
|
||||||
|
(is-api-request (if api t (or (search "/api/" uri)
|
||||||
|
(search "api/" uri)))))
|
||||||
(format t "Authentication check - User ID: ~a, URI: ~a, Is API: ~a~%"
|
(format t "Authentication check - User ID: ~a, URI: ~a, Is API: ~a~%"
|
||||||
user-id uri (if is-api-request "YES" "NO"))
|
user-id uri (if is-api-request "YES" "NO"))
|
||||||
(if user-id
|
(if user-id
|
||||||
t ; Authenticated - return T to continue
|
t ; Authenticated - return T to continue
|
||||||
;; Not authenticated - emit error
|
;; Not authenticated - emit error and signal to stop processing
|
||||||
|
(progn
|
||||||
(if is-api-request
|
(if is-api-request
|
||||||
;; API request - emit JSON error and return the value from api-output
|
;; API request - emit JSON error with 401 status
|
||||||
(progn
|
(progn
|
||||||
(format t "Authentication failed - returning JSON 401~%")
|
(format t "Authentication failed - returning JSON 401~%")
|
||||||
(radiance:api-output
|
(setf (radiance:return-code *response*) 401)
|
||||||
'(("error" . "Authentication required"))
|
(setf (radiance:content-type *response*) "application/json")
|
||||||
:status 401
|
(error 'radiance:request-denied :message "Authentication required"))
|
||||||
:message "You must be logged in to access this resource"))
|
;; Page request - redirect to login
|
||||||
;; Page request - redirect to login (redirect doesn't return)
|
|
||||||
(progn
|
(progn
|
||||||
(format t "Authentication failed - redirecting to login~%")
|
(format t "Authentication failed - redirecting to login~%")
|
||||||
(radiance:redirect "/login"))))))
|
(radiance:redirect "/login")))))))
|
||||||
|
|
||||||
(defun require-role (role &key (api nil))
|
(defun require-role (role &key (api nil))
|
||||||
"Require user to have a specific role.
|
"Require user to have a specific role.
|
||||||
|
|
|
||||||
|
|
@ -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"))))))
|
||||||
Loading…
Reference in New Issue