Compare commits

..

No commits in common. "c01d99da853b5e542fb052c39b0a4de1aa3360c5" and "349fa31d8f2c26abef8f358d92cd2ad9407d93a9" have entirely different histories.

34 changed files with 315 additions and 4337 deletions

1
.gitignore vendored
View File

@ -58,4 +58,3 @@ performance-logs/
# Temporary files
/static/asteroid.css
stream-queue.m3u
.jj/

View File

@ -5,7 +5,7 @@
1) [ ] [[https://www.radio.net/][Radio.net]]
2) [ ] [[https://tunein.com/][TuneIn]] (requires application)
3) [ ] [[https://streema.com/][Streema]]
4) [X] [[https://www.internet-radio.com/][Internet-Radio.com]]
4) [ ] [[https://www.internet-radio.com/][Internet-Radio.com]]
2) [0/5] Integrate with various social platforms
1) [ ] Mastodon
@ -24,35 +24,35 @@
2) [0/3] Listener Requests
This obviously ties into User profiles, but should also be available to anonymous users.
1) [X] Request library tracks
2) [X] Request tracks to add to library
3) [ ] Tie into user playlists - KIND OF COMPLETE!!??
1) [ ] Request library tracks
2) [ ] Request tracks to add to library
3) [ ] Tie into user playlists
3) [0/3] Calendar for Schedule/Programming
1) [ ] Define Scheduled Program
2) [ ] Make calendar editable, reschedule, ammend &c
3) [ ] Add bumpers to landing page for scheduled programs
4) [5/8] User Profile pages
1) [X] avatars
4) [0/8] User Profile pages
1) [ ] avatars
2) [ ] default playlist
3) [X] tarted up 'now playing' with highlights of previously upvoted tracks
3) [ ] tarted up 'now playing' with highlights of previously upvoted tracks
4) [ ] polls
5) [X] Listener requests interface
5) [ ] Listener requests interface
6) [ ] Calendar of upcoming scheduled 'shows'
7) [X] requests
8) [X] Custom user playlists, with submission for station airing
7) [ ] requests
8) [ ] Custom user playlists, with submission for station airing
5) [0/2] Shuffle/Random queue
1) [ ] randomly run the whole library
2) [ ] potentially weight 'random' by user prefs/voting records
6) [0/5] Themed streams
1) [X] Main curated amgbient 'low orbit'
2) [X] Random from full library 'deep space'
3) [X] Darker ambient
4) [X] Underworld and friends
5) [X] &c
1) [ ] Main curated amgbient 'low orbit'
2) [ ] Random from full library 'deep space'
3) [ ] Darker ambient
4) [ ] Underworld and friends
5) [ ] &c
7) [0/5] Integrate with various social platforms
1) [ ] Mastodon

View File

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

View File

@ -1158,45 +1158,28 @@
(define-api asteroid/user/listening-stats () ()
"Get user listening statistics"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(stats (get-listening-stats user-id)))
(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" . 0)
("favorite_genre" . "Ambient"))))))))
("session_count" . ,(getf stats :session-count 0))
("favorite_genre" . "Unknown")))))))
(define-api asteroid/user/recent-tracks (&optional (limit "3") (offset "0")) ()
(define-api asteroid/user/recent-tracks (&optional (limit "3")) ()
"Get recently played tracks for user"
(require-authentication)
(with-error-handling
(let* ((user-id (session:field "user-id"))
(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))
(cdr (assoc :track_title h))))
("artist" . "")
("played_at" . ,(cdr (assoc :listened-at h)))
("duration" . ,(or (cdr (assoc :listen-duration h)) 0))))
history)))))))
("tracks" . ()))))
(define-api asteroid/user/top-artists (&optional (limit "5")) ()
"Get top artists for user"
(require-authentication)
(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)))))))
("artists" . ()))))
;; Register page (GET)
(define-page register #@"/register" ()

View File

@ -51,31 +51,6 @@
(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))))
(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

View File

@ -22,16 +22,6 @@
<hostname>localhost</hostname>
<!-- YP Directory listings -->
<directory>
<yp-url-timeout>15</yp-url-timeout>
<yp-url>http://icecast-yp.internet-radio.com</yp-url>
</directory>
<directory>
<yp-url-timeout>15</yp-url-timeout>
<yp-url>http://dir.xiph.org/cgi-bin/yp-cgi</yp-url>
</directory>
<listen-socket>
<port>8000</port>
</listen-socket>

View File

@ -1,23 +1,5 @@
(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.
@ -72,8 +54,7 @@
`((:listenurl . ,(format nil "~a/~a" *stream-base-url* mount))
(:title . ,title)
(:listeners . ,total-listeners)
(:track-id . ,(find-track-by-title title))))))))
(:listeners . ,total-listeners)))))))
(define-api asteroid/partial/now-playing (&optional mount) ()
"Get Partial HTML with live status from Icecast server.
@ -88,16 +69,13 @@
(icecast-now-playing *stream-base-url* "asteroid-shuffle.mp3")))
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
(if now-playing-stats
(let* ((title (cdr (assoc :title now-playing-stats)))
(favorite-count (or (get-track-favorite-count title) 0)))
(progn
;; TODO: it should be able to define a custom api-output for this
;; (api-output <clip-parser> :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))
:favorite-count favorite-count))
:stats now-playing-stats))
(progn
(setf (header "Content-Type") "text/html")
(clip:process-to-string
@ -119,24 +97,6 @@
(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
(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)))))))
(define-api asteroid/channel-name () ()
"Get the current curated channel name for live updates.
Returns JSON with the channel name from the current playlist's PHASE header."

View File

@ -1,49 +0,0 @@
-- 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, -- 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
);
-- 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, -- 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 INTEGER DEFAULT 0 -- 1 if they listened to the whole track
);
-- Create indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_listening_history_user_id ON listening_history("user-id");
CREATE INDEX IF NOT EXISTS idx_listening_history_track_id ON listening_history("track-id");
CREATE INDEX IF NOT EXISTS idx_listening_history_listened_at ON listening_history("listened-at");
-- Grant permissions
GRANT ALL PRIVILEGES ON user_favorites TO asteroid;
GRANT ALL PRIVILEGES ON listening_history TO asteroid;
GRANT ALL PRIVILEGES ON SEQUENCE user_favorites__id_seq TO asteroid;
GRANT ALL PRIVILEGES ON SEQUENCE listening_history__id_seq TO asteroid;
-- Verification
DO $$
BEGIN
RAISE NOTICE 'Migration 005: User favorites and listening history tables created successfully!';
END $$;

View File

@ -1,14 +0,0 @@
-- 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 $$;

View File

@ -1,31 +0,0 @@
-- 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 $$;

View File

@ -1,30 +0,0 @@
-- 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 $$;

View File

@ -29,7 +29,6 @@
(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
@ -1287,216 +1286,6 @@
(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")
(+ "<button class=\"btn btn-success btn-sm\" onclick=\"approveRequest(" (ps:@ req id) ")\">✓ Approve</button>"
"<button class=\"btn btn-danger btn-sm\" onclick=\"rejectRequest(" (ps:@ req id) ")\">✗ Reject</button>"))
((= *current-request-tab* "approved")
"<span class=\"status-badge status-approved\">✓ Approved</span>")
((= *current-request-tab* "rejected")
"<span class=\"status-badge status-rejected\">✗ Rejected</span>")
((= *current-request-tab* "played")
"<span class=\"status-badge status-played\">🎵 Played</span>")
(t ""))))
(setf html (+ html
"<div class=\"request-item-admin\" data-request-id=\"" (ps:@ req id) "\">"
"<div class=\"request-info\">"
"<strong>" (ps:@ req title) "</strong>"
"<span class=\"request-user\">Requested by @" (ps:@ req username) "</span>"
(if (ps:@ req message)
(+ "<p class=\"request-message\">\"" (ps:@ req message) "\"</p>")
"")
"<span class=\"request-time\">" (format-request-time (ps:@ req created_at)) "</span>"
"</div>"
"<div class=\"request-actions\">"
actions-html
"</div>"
"</div>"))))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) (+ "<p style=\"color: #888;\">No " *current-request-tab* " requests</p>")))))))
(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 "<table class='admin-table'><thead><tr><th>Playlist</th><th>User</th><th>Tracks</th><th>Submitted</th><th>Actions</th></tr></thead><tbody>"))
(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
"<tr>"
"<td><strong>" (aref pl "name") "</strong>"
(if (aref pl "description") (+ "<br><small>" (aref pl "description") "</small>") "")
"</td>"
"<td>" (or (aref pl "username") "Unknown") "</td>"
"<td>" (or (aref pl "trackCount") 0) " tracks</td>"
"<td>" submitted-date "</td>"
"<td>"
"<button class='btn btn-info btn-sm' onclick='previewPlaylist(" (aref pl "id") ")'>👁 Preview</button> "
"<button class='btn btn-success btn-sm' onclick='approvePlaylist(" (aref pl "id") ")'>✓ Approve</button> "
"<button class='btn btn-danger btn-sm' onclick='rejectPlaylist(" (aref pl "id") ")'>✗ Reject</button>"
"</td>"
"</tr>"))))))
(setf html (+ html "</tbody></table>"))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class='no-data'>No playlists awaiting review</p>"))))))
(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) "<p class='error'>Error loading submissions</p>")))))))
(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&notes=" (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)
@ -1520,17 +1309,6 @@
(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")

View File

@ -165,56 +165,6 @@
(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)
;; 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..."))
(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)
(ps:@ response ok)))
(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)))
@ -226,32 +176,8 @@
(ps:chain response (text))
(throw (ps:new (-error "Error connecting to stream")))))))
(then (lambda (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))
;; 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) "")))))))))))))
(setf (ps:@ (ps:chain document (get-element-by-id "now-playing")) inner-h-t-m-l)
data)))
(catch (lambda (error)
(ps:chain console (log "Could not fetch stream status:" error)))))))
@ -627,9 +553,6 @@
(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)
@ -669,59 +592,6 @@
(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) "☆")
;; Refresh now playing to update favorite count
(update-now-playing))))
(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) "★")
(update-now-playing))))
(catch (lambda (error)
(ps:chain console (error "Error adding favorite:" error)))))))))))
;; Update now playing every 5 seconds
(set-interval update-now-playing 5000)
@ -772,77 +642,7 @@
(when (and *popout-window* (ps:@ *popout-window* closed))
(update-popout-button nil)
(setf *popout-window* nil)))
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 "<div class=\"request-item\">"
"<span class=\"request-title\">" (ps:@ req title) "</span>"
"<span class=\"request-by\">Requested by @" (ps:@ req username) "</span>"
"</div>")))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-requests\">No recent requests yet. Be the first!</p>")))))
(catch (lambda (error)
(ps:chain console (log "Could not load recent requests:" error))))))))
;; Load recent requests on page load
(load-recent-requests)))
1000)))
"Compiled JavaScript for front-page - generated at load time")
(defun generate-front-page-js ()

View File

@ -32,20 +32,9 @@
:day "numeric")))))
(defun format-relative-time (date-string)
(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))
(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))))))
@ -56,7 +45,7 @@
(+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago"))
((> diff-minutes 0)
(+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago"))
(t "Just now")))))
(t "Just now"))))
(defun format-duration (seconds)
(let ((hours (ps:chain -math (floor (/ seconds 3600))))
@ -120,6 +109,37 @@
(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")
@ -147,163 +167,6 @@
(catch (lambda (error)
(ps:chain console (error "Error loading top artists:" error))))))
(defvar *favorites-offset* 0)
(defun load-favorites ()
(ps:chain
(fetch "/api/asteroid/user/favorites")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(container (ps:chain document (get-element-by-id "favorites-list"))))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data favorites)
(> (ps:@ data favorites length) 0))
(progn
(setf (ps:@ container inner-h-t-m-l) "")
(ps:chain data favorites
(for-each (lambda (fav)
(let ((item (ps:chain document (create-element "div"))))
(setf (ps:@ item class-name) "track-item favorite-item")
(setf (ps:@ item inner-h-t-m-l)
(+ "<div class=\"track-info\">"
"<span class=\"track-title\">" (or (ps:@ fav title) "Unknown") "</span>"
"<span class=\"track-artist\">" (or (ps:@ fav artist) "") "</span>"
"</div>"
"<div class=\"track-meta\">"
"<span class=\"rating\">" (render-stars (or (ps:@ fav rating) 1)) "</span>"
"<button class=\"btn btn-small btn-danger\" onclick=\"removeFavorite('" (ps:chain (or (ps:@ fav title) "") (replace (ps:regex "/'/g") "\\'")) "')\">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 (title)
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/remove?title=" (encode-u-r-i-component title))
(ps:create :method "POST"))
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (json))
(throw (ps:new (-error "Request failed"))))))
(then (lambda (data)
;; 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")))))
(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")
(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 "<div class=\"chart-bars\">"))
(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-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)
"")))
(setf html (+ html "<div class=\"chart-bar-wrapper\">"
"<div class=\"chart-bar\" style=\"height: " height "%\" title=\"" date-str ": " count " tracks\"></div>"
"<span class=\"chart-day\">" day-label "</span>"
"</div>"))))))
(setf html (+ html "</div>"))
(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) "<p class=\"no-data\">No listening activity yet. Start listening to build your history!</p>"))))))
(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) "<p class=\"error\">Failed to load activity data</p>")))))))
(defun load-profile-data ()
(ps:chain console (log "Loading profile data..."))
@ -324,51 +187,14 @@
(show-error "Error loading profile data"))))
(load-listening-stats)
(load-favorites)
(load-top-artists)
(load-activity-chart)
(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
"<div class=\"my-request-item " status-class "\">"
"<div class=\"request-title\">" (ps:@ req title) "</div>"
"<div class=\"request-status\">"
"<span class=\"status-badge " status-class "\">" status-icon " " (ps:@ req status) "</span>"
"</div>"
"</div>"))))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-requests\">You haven't made any requests yet.</p>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading requests:" error))))))
(load-recent-tracks)
(load-top-artists))
;; Action functions
(defun load-more-recent-tracks ()
(ps:chain console (log "Loading more recent tracks..."))
(show-message "Loading more tracks..." "info"))
(defun edit-profile ()
(ps:chain console (log "Edit profile clicked"))
(show-message "Profile editing coming soon!" "info"))
@ -465,402 +291,11 @@
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
"<div class=\"playlist-item " status-class "\">"
"<div class=\"playlist-info\">"
"<span class=\"playlist-name\">" (or (ps:@ pl name) (aref pl "name")) "</span>"
"<span class=\"playlist-meta\">" (or (ps:@ pl track-count) (aref pl "track-count") 0) " tracks</span>"
"</div>"
"<div class=\"playlist-actions\">"
"<span class=\"status-badge " status-class "\">" status-icon " " (or (ps:@ pl status) (aref pl "status")) "</span>"
(if (= (or (ps:@ pl status) (aref pl "status")) "draft")
(+ "<button class=\"btn btn-small\" onclick=\"editPlaylist(" playlist-id ")\">Edit</button>")
"")
"</div>"
"</div>"))))))
(setf (ps:@ container inner-h-t-m-l) html)))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No playlists yet. Create one to get started!</p>"))))))
(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
"<div class=\"playlist-track-item\" data-index=\"" index "\">"
"<span class=\"track-number\">" (+ index 1) ".</span>"
"<span class=\"track-title\">" (ps:@ track title) "</span>"
"<span class=\"track-artist\">" (ps:@ track artist) "</span>"
"<div class=\"track-controls\">"
"<button class=\"btn btn-tiny\" onclick=\"moveTrackInPlaylist(" index ", -1)\" " (if (= index 0) "disabled" "") ">↑</button>"
"<button class=\"btn btn-tiny\" onclick=\"moveTrackInPlaylist(" index ", 1)\" " (if (= index (- (ps:@ *current-playlist-tracks* length) 1)) "disabled" "") ">↓</button>"
"<button class=\"btn btn-tiny btn-danger\" onclick=\"removeTrackFromPlaylist(" index ")\">✕</button>"
"</div>"
"</div>")))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"empty-message\">No tracks yet. Browse the library to add tracks!</p>")))))
(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
"<div class=\"library-track-item\">"
"<div class=\"track-info\">"
"<span class=\"track-title\">" (ps:@ track title) "</span>"
"<span class=\"track-artist\">" (ps:@ track artist) "</span>"
"<span class=\"track-album\">" (ps:@ track album) "</span>"
"</div>"
"<button class=\"btn btn-small btn-primary\" onclick=\"addTrackToPlaylist("
(ps:@ track id) ", '"
(ps:chain (ps:@ track title) (replace (ps:regex "/'/g") "\\'")) "', '"
(ps:chain (ps:@ track artist) (replace (ps:regex "/'/g") "\\'")) "', '"
(ps:chain (ps:@ track album) (replace (ps:regex "/'/g") "\\'")) "')\">+ Add</button>"
"</div>")))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No tracks found</p>")))
;; 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) "<option value=\"\">All Artists</option>")
(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) "<option value=\"\">Select playlist to add to...</option>")
(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"
(lambda ()
(load-profile-data)
(load-my-playlists))))))
load-profile-data))))
"Compiled JavaScript for profile page - generated at load time")
(defun generate-profile-js ()

View File

@ -198,144 +198,22 @@
(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)
;; 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*)))
(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)
(ps:@ response ok)))
(catch (lambda (error) nil)))))
;; Update mini now playing display (for persistent player frame)
(defun update-mini-now-playing ()
(let ((mount (get-current-mount)))
(ps:chain
(fetch (+ "/api/asteroid/partial/now-playing-json?mount=" mount))
(fetch (+ "/api/asteroid/partial/now-playing-inline?mount=" mount))
(then (lambda (response)
(if (ps:@ response ok)
(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...")))
(ps:chain response (text))
"")))
(then (lambda (text)
(let ((el (ps:chain document (get-element-by-id "mini-now-playing"))))
(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)
;; 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 ""))))
;; 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 " ❤️"))))))))))
(setf (ps:@ el text-content) text)))))
(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) "☆")
;; 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
(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) "★")
;; 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)))))))))))
;; Update popout now playing display (parses artist - title)
(defun update-popout-now-playing ()
(let ((mount (get-current-mount)))
@ -650,9 +528,6 @@
(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)
@ -766,7 +641,6 @@
(setf (ps:@ window init-popout-player) init-popout-player)
(setf (ps:@ window update-mini-now-playing) update-mini-now-playing)
(setf (ps:@ window update-popout-now-playing) update-popout-now-playing)
(setf (ps:@ window toggle-favorite-mini) toggle-favorite-mini)
;; Auto-initialize on DOMContentLoaded based on which elements exist
(ps:chain document

View File

@ -163,19 +163,11 @@
(sort (copy-list *playlist-schedule*) #'< :key #'car))
(defun get-available-playlists ()
"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
"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))))
;; 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)))))))
(directory (merge-pathnames "*.m3u" playlists-dir))))))
(defun get-server-time-info ()
"Get current server time information in both UTC and local timezone."

View File

@ -1,207 +1,144 @@
#EXTM3U
#PLAYLIST:Morning Drift
#PHASE:Morning Drift
#DURATION:6 hours (approx)
#PLAYLIST:Escape Velocity - A Christmas Journey Through Space
#PHASE:Escape Velocity
#DURATION:12 hours (approx)
#CURATOR:Asteroid Radio
#DESCRIPTION:Lighter, awakening ambient for the morning hours (06:00-12:00)
#DESCRIPTION:A festive 12-hour voyage blending Christmas classics with ambient, IDM, and space music for the holiday season
#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
# === 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,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
#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
/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

View File

@ -1,25 +0,0 @@
#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

View File

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

View File

@ -1274,624 +1274,4 @@
(.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"))
;; 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"))
;; 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")
;; 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"
(.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"))
;; 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<circle cx="60" cy="60" r="58" fill="#1a1a1a" stroke="#00cc00" stroke-width="2"/>
<circle cx="60" cy="45" r="20" fill="#00cc00"/>
<path d="M 25 95 Q 25 70 60 70 Q 95 70 95 95" fill="#00cc00"/>
</svg>

Before

Width:  |  Height:  |  Size: 294 B

View File

@ -102,22 +102,6 @@
</div>
</div>
<!-- Track Requests -->
<div class="admin-section">
<h2>🎵 Track Requests</h2>
<div class="request-tabs" style="margin-bottom: 15px;">
<button class="btn btn-tab active" id="tab-pending" onclick="showRequestTab('pending')">⏳ Pending</button>
<button class="btn btn-tab" id="tab-approved" onclick="showRequestTab('approved')">✓ Approved</button>
<button class="btn btn-tab" id="tab-rejected" onclick="showRequestTab('rejected')">✗ Rejected</button>
<button class="btn btn-tab" id="tab-played" onclick="showRequestTab('played')">🎵 Played</button>
<button class="btn btn-secondary" onclick="refreshTrackRequests()" style="margin-left: 15px;">🔄 Refresh</button>
<span id="requests-status" style="margin-left: 15px;"></span>
</div>
<div id="pending-requests-container">
<p class="loading">Loading requests...</p>
</div>
</div>
<!-- Music Library Management -->
<div class="admin-section">
<h2>Music Library Management</h2>
@ -362,15 +346,6 @@
</p>
</div>
<!-- User Playlist Review -->
<div class="admin-section">
<h2>📋 User Playlist Submissions</h2>
<p>Review and approve user-submitted playlists. Approved playlists will be available for scheduling.</p>
<div id="user-playlists-container">
<p class="loading-message">Loading submissions...</p>
</div>
</div>
<!-- User Management -->
<div class="admin-section">
<div class="card">

View File

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

View File

@ -92,24 +92,6 @@
<p class="loading">Loading...</p>
</div>
</div>
<!-- Track Request Section -->
<div class="request-panel">
<h3>🎵 Request a Track</h3>
<p class="request-description">Want to hear something specific? Submit a request and an admin will review it.</p>
<div class="request-form">
<input type="text" id="request-title" class="request-input" placeholder="Suggest a track, artist, or album...">
<input type="text" id="request-message" class="request-input" placeholder="Why do you want to hear this? (optional)">
<button class="btn btn-primary" onclick="submitTrackRequest()">Submit Request</button>
</div>
<div id="request-status" class="request-status" style="display: none;"></div>
<div class="recent-requests">
<h4>Recently Played Requests</h4>
<div id="recent-requests-list">
<p class="no-requests">Loading...</p>
</div>
</div>
</div>
</main>
<footer class="site-footer">

View File

@ -125,26 +125,6 @@
<p class="loading">Loading...</p>
</div>
</div>
<!-- Track Request Section -->
<div id="request-panel" class="request-panel">
<h3>🎵 Request a Track</h3>
<p class="request-description">Want to hear something specific? Submit a request!</p>
<div class="request-form">
<input type="text" id="request-title" placeholder="Suggest a track, artist, or album..." class="request-input">
<input type="text" id="request-message" placeholder="Why do you want to hear this? (optional)" class="request-input">
<button onclick="submitTrackRequest()" class="btn btn-primary">Submit Request</button>
</div>
<div id="request-status" class="request-status" style="display: none;"></div>
<!-- Recent Requests -->
<div id="recent-requests" class="recent-requests">
<h4>Recently Played Requests</h4>
<div id="recent-requests-list">
<p class="no-requests">No recent requests</p>
</div>
</div>
</div>
</main>
<footer class="site-footer">

View File

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

View File

@ -21,16 +21,6 @@
<!-- User Profile Header -->
<div class="admin-section">
<h2>🎧 User Profile</h2>
<div class="profile-header">
<div class="avatar-section">
<div class="avatar-container" id="avatar-container">
<img id="user-avatar" src="/asteroid/static/icons/default-avatar.svg" alt="User Avatar" class="avatar-image">
<div class="avatar-overlay" onclick="document.getElementById('avatar-input').click()">
<span>Change</span>
</div>
</div>
<input type="file" id="avatar-input" accept="image/*" style="display: none" onchange="uploadAvatar(this)">
</div>
<div class="profile-info">
<div class="info-group">
<span class="info-label">Username:</span>
@ -50,7 +40,6 @@
</div>
</div>
</div>
</div>
<!-- Listening Statistics -->
<div class="admin-section">
@ -75,35 +64,43 @@
</div>
</div>
<!-- My Track Requests -->
<!-- Recently Played Tracks -->
<div class="admin-section">
<h2>🎵 My Track Requests</h2>
<div id="my-requests-list" class="requests-list">
<p class="loading-message">Loading your requests...</p>
<h2>🎵 Recently Played</h2>
<div class="tracks-list" id="recent-tracks">
<div class="track-item">
<div class="track-info">
<span class="track-title" data-text="recent-track-1-title">No recent tracks</span>
<span class="track-artist" data-text="recent-track-1-artist"></span>
</div>
<div class="track-meta">
<span class="track-duration" data-text="recent-track-1-duration"></span>
<span class="track-played-at" data-text="recent-track-1-played-at"></span>
</div>
</div>
<!-- My Playlists -->
<div class="admin-section">
<h2>📝 My Playlists</h2>
<p class="section-description">Create custom playlists from the music library and submit them for station airplay!</p>
<div class="playlist-actions">
<button class="btn btn-primary" onclick="showCreatePlaylistModal()"> Create New Playlist</button>
<button class="btn btn-secondary" onclick="showLibraryBrowser()">🎵 Browse Library</button>
<div class="track-item">
<div class="track-info">
<span class="track-title" data-text="recent-track-2-title"></span>
<span class="track-artist" data-text="recent-track-2-artist"></span>
</div>
<div id="my-playlists-list" class="playlists-list">
<p class="loading-message">Loading your playlists...</p>
<div class="track-meta">
<span class="track-duration" data-text="recent-track-2-duration"></span>
<span class="track-played-at" data-text="recent-track-2-played-at"></span>
</div>
</div>
<div class="track-item">
<div class="track-info">
<span class="track-title" data-text="recent-track-3-title"></span>
<span class="track-artist" data-text="recent-track-3-artist"></span>
</div>
<div class="track-meta">
<span class="track-duration" data-text="recent-track-3-duration"></span>
<span class="track-played-at" data-text="recent-track-3-played-at"></span>
</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>
<button class="btn btn-secondary" onclick="loadMoreRecentTracks()">Load More</button>
</div>
</div>
@ -138,11 +135,18 @@
<div class="admin-section">
<h2>📈 Listening Activity</h2>
<div class="activity-chart">
<p>Tracks played over the last 30 days</p>
<div class="chart-container" id="activity-chart">
<p class="loading-message">Loading activity data...</p>
<p>Activity over the last 30 days</p>
<div class="chart-placeholder">
<div class="chart-bar" style="height: 20%" data-day="1"></div>
<div class="chart-bar" style="height: 45%" data-day="2"></div>
<div class="chart-bar" style="height: 30%" data-day="3"></div>
<div class="chart-bar" style="height: 60%" data-day="4"></div>
<div class="chart-bar" style="height: 80%" data-day="5"></div>
<div class="chart-bar" style="height: 25%" data-day="6"></div>
<div class="chart-bar" style="height: 40%" data-day="7"></div>
<!-- More bars would be generated dynamically -->
</div>
<p class="chart-note" id="activity-total">Total: 0 tracks</p>
<p class="chart-note">Listening hours per day</p>
</div>
</div>
@ -179,76 +183,6 @@
</div>
</div>
<!-- Create Playlist Modal -->
<div id="create-playlist-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="modal-close" onclick="hideCreatePlaylistModal()">&times;</span>
<h2>Create New Playlist</h2>
<form id="create-playlist-form" onsubmit="return createPlaylist(event)">
<div class="form-group">
<label for="playlist-name">Playlist Name:</label>
<input type="text" id="playlist-name" name="name" required maxlength="100" placeholder="My Awesome Playlist">
</div>
<div class="form-group">
<label for="playlist-description">Description (optional):</label>
<textarea id="playlist-description" name="description" maxlength="500" rows="3" placeholder="Describe your playlist..."></textarea>
</div>
<div id="create-playlist-message" class="message"></div>
<button type="submit" class="btn btn-primary">Create Playlist</button>
</form>
</div>
</div>
<!-- Library Browser Modal -->
<div id="library-browser-modal" class="modal" style="display: none; z-index: 1100;">
<div class="modal-content modal-large">
<span class="modal-close" onclick="hideLibraryBrowser()">&times;</span>
<h2>🎵 Music Library</h2>
<div class="library-controls">
<input type="text" id="library-search" placeholder="Search tracks..." onkeyup="searchLibrary()">
<select id="library-artist-filter" onchange="filterByArtist()">
<option value="">All Artists</option>
</select>
<select id="add-to-playlist-select">
<option value="">Select playlist to add to...</option>
</select>
</div>
<div id="library-tracks" class="library-tracks-list">
<p class="loading-message">Loading library...</p>
</div>
<div class="library-pagination">
<button class="btn btn-secondary" onclick="prevLibraryPage()" id="lib-prev-btn" disabled>← Previous</button>
<span id="library-page-info">Page 1</span>
<button class="btn btn-secondary" onclick="nextLibraryPage()" id="lib-next-btn">Next →</button>
</div>
</div>
</div>
<!-- Edit Playlist Modal -->
<div id="edit-playlist-modal" class="modal" style="display: none;">
<div class="modal-content modal-large">
<span class="modal-close" onclick="hideEditPlaylistModal()">&times;</span>
<h2 id="edit-playlist-title">Edit Playlist</h2>
<div class="playlist-edit-header">
<input type="text" id="edit-playlist-name" placeholder="Playlist name">
<textarea id="edit-playlist-description" placeholder="Description..." rows="2"></textarea>
<button class="btn btn-secondary" onclick="savePlaylistMetadata()">Save Details</button>
</div>
<div class="playlist-tracks-container">
<h3>Tracks in Playlist</h3>
<div id="playlist-tracks-list" class="playlist-tracks-sortable">
<p class="empty-message">No tracks yet. Browse the library to add tracks!</p>
</div>
</div>
<div class="playlist-edit-actions">
<button class="btn btn-secondary" onclick="showLibraryBrowserForPlaylist()"> Add Tracks</button>
<button class="btn btn-primary" onclick="submitPlaylistForReview()" id="submit-playlist-btn">📤 Submit for Review</button>
<button class="btn btn-danger" onclick="deleteCurrentPlaylist()">🗑️ Delete Playlist</button>
</div>
<input type="hidden" id="current-edit-playlist-id" value="">
</div>
</div>
<!-- Initialization handled by profile.js -->
</body>
</html>

View File

@ -1,246 +0,0 @@
(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-requests-by-status (status &key (limit 50))
"Get requests by status with user info"
(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 = '~a'
ORDER BY r.\"created-at\" DESC
LIMIT ~a" status 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/list (&optional (status "pending")) ()
"Get requests by status (pending, approved, rejected, played)"
(require-role :admin)
(with-error-handling
(let ((requests (get-requests-by-status status)))
(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)))))

View File

@ -156,10 +156,6 @@
(format t "Error getting current user: ~a~%" e)
nil)))
(defun get-current-user-id ()
"Get the currently authenticated user's ID from session"
(session:field "user-id"))
(defun require-authentication (&key (api nil))
"Require user to be authenticated.
Returns T if authenticated, NIL if not (after emitting error response).
@ -168,26 +164,24 @@
(let* ((user-id (session:field "user-id"))
(uri (radiance:path (radiance:uri *request*)))
;; Use explicit flag if provided, otherwise auto-detect from URI
;; Check for "api/" anywhere in the path
(is-api-request (if api t (or (search "/api/" uri)
(search "api/" uri)))))
(is-api-request (if api t (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 and signal to stop processing
(progn
;; Not authenticated - emit error
(if is-api-request
;; API request - return JSON error with 401 status using api-output
;; API request - emit JSON error and return the value from api-output
(progn
(format t "Authentication failed - returning JSON 401~%")
(api-output `(("status" . "error")
("message" . "Authentication required"))
:status 401))
;; Page request - redirect to login
(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")))))))
(radiance:redirect "/login"))))))
(defun require-role (role &key (api nil))
"Require user to have a specific role.

View File

@ -1,421 +0,0 @@
(in-package :asteroid)
;;; ==========================================================================
;;; User Playlists - Custom playlist creation and submission
;;; ==========================================================================
;;; Status values: "draft", "submitted", "approved", "rejected", "scheduled"
;; Helper to get value from Postmodern alist (keys are uppercase symbols)
(defun aget (key alist)
"Get value from alist using string-equal comparison for key"
(cdr (assoc key alist :test (lambda (a b) (string-equal (string a) (string b))))))
(defun get-user-playlists (user-id &optional status)
"Get all playlists for a user, optionally filtered by status"
(with-db
(if status
(postmodern:query
(:order-by
(:select '* :from 'user_playlists
:where (:and (:= 'user-id user-id)
(:= 'status status)))
(:desc 'created-date))
:alists)
(postmodern:query
(:order-by
(:select '* :from 'user_playlists
:where (:= 'user-id user-id))
(:desc 'created-date))
:alists))))
(defun get-user-playlist-by-id (playlist-id)
"Get a single playlist by ID"
(with-db
(first (postmodern:query
(:select '* :from 'user_playlists
:where (:= '_id playlist-id))
:alists))))
(defun create-user-playlist (user-id name description)
"Create a new user playlist"
(with-db
(postmodern:query
(:insert-into 'user_playlists
:set 'user-id user-id
'name name
'description (or description "")
'track-ids "[]"
'status "draft"
'created-date (:raw "EXTRACT(EPOCH FROM NOW())::INTEGER"))
:none)
;; Return the created playlist
(first (postmodern:query
(:order-by
(:select '* :from 'user_playlists
:where (:= 'user-id user-id))
(:desc '_id))
:alists))))
(defun update-user-playlist-tracks (playlist-id track-ids-json)
"Update the track list for a playlist"
(with-db
(postmodern:query
(:update 'user_playlists
:set 'track-ids track-ids-json
:where (:= '_id playlist-id))
:none)))
(defun update-user-playlist-metadata (playlist-id name description)
"Update playlist name and description"
(with-db
(postmodern:query
(:update 'user_playlists
:set 'name name
'description description
:where (:= '_id playlist-id))
:none)))
(defun submit-user-playlist (playlist-id)
"Submit a playlist for admin review"
(with-db
(postmodern:query
(:update 'user_playlists
:set 'status "submitted"
'submitted-date (:raw "EXTRACT(EPOCH FROM NOW())::INTEGER")
:where (:= '_id playlist-id))
:none)))
(defun get-submitted-playlists ()
"Get all submitted playlists awaiting review (admin)"
(with-db
(postmodern:query
(:order-by
(:select 'p.* 'u.username
:from (:as 'user_playlists 'p)
:left-join (:as (:raw "\"USERS\"") 'u) :on (:= 'p.user-id 'u._id)
:where (:= 'p.status "submitted"))
(:asc 'p.submitted-date))
:alists)))
(defun review-user-playlist (playlist-id admin-id status notes)
"Approve or reject a submitted playlist"
(with-db
(postmodern:query
(:update 'user_playlists
:set 'status status
'reviewed-date (:raw "EXTRACT(EPOCH FROM NOW())::INTEGER")
'reviewed-by admin-id
'review-notes (or notes "")
:where (:= '_id playlist-id))
:none)))
(defun generate-user-playlist-m3u (playlist-id)
"Generate M3U file content for a user playlist"
(let* ((playlist (get-user-playlist-by-id playlist-id))
(track-ids-json (aget "TRACK-IDS" playlist))
(track-ids (when (and track-ids-json (not (string= track-ids-json "[]")))
(cl-json:decode-json-from-string track-ids-json)))
(name (aget "NAME" playlist))
(description (aget "DESCRIPTION" playlist))
(user-id (aget "USER-ID" playlist))
(username (get-username-by-id user-id)))
(with-output-to-string (out)
(format out "#EXTM3U~%")
(format out "#PLAYLIST:~a~%" name)
;; Use description as the phase name if provided, otherwise use playlist name
(format out "#PHASE:~a~%" (if (and description (not (string= description "")))
description
name))
(format out "#CURATOR:~a~%" (or username "Anonymous"))
(format out "~%")
;; Add tracks
(dolist (track-id track-ids)
(let ((track (get-track-by-id track-id)))
(when track
(let* ((title (dm:field track "title"))
(artist (dm:field track "artist"))
(file-path (dm:field track "file-path"))
(docker-path (convert-to-docker-path file-path)))
(format out "#EXTINF:-1,~a - ~a~%" artist title)
(format out "~a~%" docker-path))))))))
(defun save-user-playlist-m3u (playlist-id)
"Save user playlist as M3U file in playlists/user-submissions/"
(let* ((playlist (get-user-playlist-by-id playlist-id))
(name (aget "NAME" playlist))
(user-id (aget "USER-ID" playlist))
(username (get-username-by-id user-id))
(safe-name (cl-ppcre:regex-replace-all "[^a-zA-Z0-9-_]" name "-"))
(filename (format nil "~a-~a-~a.m3u" username safe-name playlist-id))
(submissions-dir (merge-pathnames "playlists/user-submissions/"
(asdf:system-source-directory :asteroid)))
(filepath (merge-pathnames filename submissions-dir)))
;; Ensure directory exists
(ensure-directories-exist submissions-dir)
;; Write M3U file
(with-open-file (out filepath :direction :output
:if-exists :supersede
:if-does-not-exist :create)
(write-string (generate-user-playlist-m3u playlist-id) out))
filename))
(defun get-username-by-id (user-id)
"Get username for a user ID"
(with-db
(postmodern:query
(:select 'username :from (:raw "\"USERS\"") :where (:= '_id user-id))
:single)))
(defun delete-user-playlist (playlist-id user-id)
"Delete a user playlist (only if owned by user and in draft status)"
(with-db
(postmodern:query
(:delete-from 'user_playlists
:where (:and (:= '_id playlist-id)
(:= 'user-id user-id)
(:= 'status "draft")))
:none)))
;;; ==========================================================================
;;; API Endpoints
;;; ==========================================================================
(define-api asteroid/library/browse (&optional search artist album page) ()
"Browse the music library - available to all authenticated users"
(require-authentication)
(with-error-handling
(let* ((page-num (or (and page (parse-integer page :junk-allowed t)) 1))
(per-page 50)
(offset (* (1- page-num) per-page))
(tracks (with-db
(cond
;; Search by text
(search
(let ((search-pattern (format nil "%~a%" search)))
(postmodern:query
(:raw (format nil "SELECT * FROM tracks WHERE title ILIKE $1 OR artist ILIKE $1 OR album ILIKE $1 ORDER BY artist, album, title LIMIT ~a OFFSET ~a"
per-page offset))
search-pattern
:alists)))
;; Filter by artist
(artist
(postmodern:query
(:raw (format nil "SELECT * FROM tracks WHERE artist = $1 ORDER BY album, title LIMIT ~a OFFSET ~a"
per-page offset))
artist
:alists))
;; Filter by album
(album
(postmodern:query
(:raw (format nil "SELECT * FROM tracks WHERE album = $1 ORDER BY title LIMIT ~a OFFSET ~a"
per-page offset))
album
:alists))
;; All tracks
(t
(postmodern:query
(:raw (format nil "SELECT * FROM tracks ORDER BY artist, album, title LIMIT ~a OFFSET ~a"
per-page offset))
:alists)))))
;; Get unique artists for filtering
(artists (with-db
(postmodern:query
(:order-by
(:select (:distinct 'artist) :from 'tracks)
'artist)
:column)))
;; Get total count
(total-count (with-db
(postmodern:query
(:select (:count '*) :from 'tracks)
:single))))
(api-output `(("status" . "success")
("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(aget "-ID" track))
("title" . ,(aget "TITLE" track))
("artist" . ,(aget "ARTIST" track))
("album" . ,(aget "ALBUM" track))
("duration" . ,(aget "DURATION" track))
("format" . ,(aget "FORMAT" track))))
tracks))
("artists" . ,artists)
("page" . ,page-num)
("per-page" . ,per-page)
("total" . ,total-count))))))
(define-api asteroid/user/playlists () ()
"Get current user's playlists"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlists (get-user-playlists user-id)))
(api-output `(("status" . "success")
("playlists" . ,(mapcar (lambda (pl)
`(("id" . ,(aget "-ID" pl))
("name" . ,(aget "NAME" pl))
("description" . ,(aget "DESCRIPTION" pl))
("track-count" . ,(let ((ids (aget "TRACK-IDS" pl)))
(if (and ids (not (string= ids "[]")))
(length (cl-json:decode-json-from-string ids))
0)))
("status" . ,(aget "STATUS" pl))
("created-date" . ,(aget "CREATED-DATE" pl))))
playlists)))))))
(define-api asteroid/user/playlists/get (id) ()
"Get a specific playlist with full track details"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlist-id (when (and id (not (string= id "null"))) (parse-integer id :junk-allowed t)))
(playlist (when playlist-id (get-user-playlist-by-id playlist-id))))
(if (and playlist (= (aget "USER-ID" playlist) user-id))
(let* ((track-ids-json (aget "TRACK-IDS" playlist))
(track-ids (when (and track-ids-json (not (string= track-ids-json "[]")))
(cl-json:decode-json-from-string track-ids-json)))
;; Filter out null values from track-ids
(valid-track-ids (remove-if #'null track-ids))
(tracks (mapcar (lambda (tid)
(when (and tid (integerp tid))
(let ((track (get-track-by-id tid)))
(when track
`(("id" . ,tid)
("title" . ,(dm:field track "title"))
("artist" . ,(dm:field track "artist"))
("album" . ,(dm:field track "album")))))))
valid-track-ids)))
(api-output `(("status" . "success")
("playlist" . (("id" . ,(aget "-ID" playlist))
("name" . ,(aget "NAME" playlist))
("description" . ,(aget "DESCRIPTION" playlist))
("status" . ,(aget "STATUS" playlist))
("tracks" . ,(remove nil tracks)))))))
(api-output `(("status" . "error")
("message" . "Playlist not found"))
:status 404)))))
(define-api asteroid/user/playlists/create (name &optional description) ()
"Create a new playlist"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlist (create-user-playlist user-id name description)))
(api-output `(("status" . "success")
("message" . "Playlist created")
("playlist" . (("id" . ,(aget "-ID" playlist))
("name" . ,(aget "NAME" playlist)))))))))
(define-api asteroid/user/playlists/update (id &optional name description tracks) ()
"Update a playlist (name, description, or tracks)"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlist-id (parse-integer id :junk-allowed t))
(playlist (get-user-playlist-by-id playlist-id)))
(if (and playlist
(= (aget "USER-ID" playlist) user-id)
(string= (aget "STATUS" playlist) "draft"))
(progn
(when (or name description)
(update-user-playlist-metadata playlist-id
(or name (aget "NAME" playlist))
(or description (aget "DESCRIPTION" playlist))))
(when tracks
(update-user-playlist-tracks playlist-id tracks))
(api-output `(("status" . "success")
("message" . "Playlist updated"))))
(api-output `(("status" . "error")
("message" . "Cannot update playlist (not found, not owned, or already submitted)"))
:status 400)))))
(define-api asteroid/user/playlists/submit (id) ()
"Submit a playlist for admin review"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlist-id (parse-integer id :junk-allowed t))
(playlist (get-user-playlist-by-id playlist-id)))
(if (and playlist
(= (aget "USER-ID" playlist) user-id)
(string= (aget "STATUS" playlist) "draft"))
(let ((track-ids-json (aget "TRACK-IDS" playlist)))
(if (and track-ids-json
(not (string= track-ids-json "[]"))
(> (length (cl-json:decode-json-from-string track-ids-json)) 0))
(progn
(submit-user-playlist playlist-id)
;; Generate M3U file
(let ((filename (save-user-playlist-m3u playlist-id)))
(api-output `(("status" . "success")
("message" . "Playlist submitted for review")
("filename" . ,filename)))))
(api-output `(("status" . "error")
("message" . "Cannot submit empty playlist"))
:status 400)))
(api-output `(("status" . "error")
("message" . "Cannot submit playlist"))
:status 400)))))
(define-api asteroid/user/playlists/delete (id) ()
"Delete a draft playlist"
(require-authentication)
(with-error-handling
(let* ((user-id (get-current-user-id))
(playlist-id (parse-integer id :junk-allowed t)))
(delete-user-playlist playlist-id user-id)
(api-output `(("status" . "success")
("message" . "Playlist deleted"))))))
;;; Admin endpoints for reviewing user playlists
(define-api asteroid/admin/user-playlists () ()
"Get all submitted playlists awaiting review"
(require-role :admin)
(with-error-handling
(let ((playlists (get-submitted-playlists)))
(api-output `(("status" . "success")
("playlists" . ,(mapcar (lambda (pl)
(let* ((track-ids-json (aget "TRACK-IDS" pl))
(track-count (if (and track-ids-json
(stringp track-ids-json)
(not (string= track-ids-json "[]")))
(length (cl-json:decode-json-from-string track-ids-json))
0)))
`(("id" . ,(aget "-ID" pl))
("name" . ,(aget "NAME" pl))
("description" . ,(aget "DESCRIPTION" pl))
("username" . ,(aget "USERNAME" pl))
("trackCount" . ,track-count)
("submittedDate" . ,(aget "SUBMITTED-DATE" pl)))))
playlists)))))))
(define-api asteroid/admin/user-playlists/review (id action &optional notes) ()
"Approve or reject a submitted playlist"
(require-role :admin)
(with-error-handling
(let* ((admin-id (get-current-user-id))
(playlist-id (parse-integer id :junk-allowed t))
(new-status (cond ((string= action "approve") "approved")
((string= action "reject") "rejected")
(t nil))))
(if new-status
(progn
(review-user-playlist playlist-id admin-id new-status notes)
;; Generate/regenerate M3U file when approving
(when (string= action "approve")
(save-user-playlist-m3u playlist-id))
(api-output `(("status" . "success")
("message" . ,(format nil "Playlist ~a" new-status)))))
(api-output `(("status" . "error")
("message" . "Invalid action (use 'approve' or 'reject')"))
:status 400)))))
(define-api asteroid/admin/user-playlists/preview (id) ()
"Preview M3U content for a submitted playlist"
(require-role :admin)
(with-error-handling
(let* ((playlist-id (parse-integer id :junk-allowed t))
(m3u-content (generate-user-playlist-m3u playlist-id)))
(api-output `(("status" . "success")
("m3u" . ,m3u-content))))))

View File

@ -1,361 +0,0 @@
;;;; 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)))
(defun get-track-favorite-count (track-title)
"Get count of how many users have favorited a track by title"
(if (and track-title (not (string= track-title "")))
(handler-case
(with-db
(let* ((escaped-title (sql-escape-string track-title))
(result (postmodern:query
(:raw (format nil "SELECT COUNT(*) FROM user_favorites WHERE track_title = '~a'" escaped-title))
:single)))
(or result 0)))
(error (e)
(declare (ignore e))
0))
0))
;;; ==========================================================================
;;; 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.
Prevents duplicate entries for the same track within 60 seconds."
(with-db
;; 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"
(with-db
(postmodern:query
(: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
(: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 - extracts artist from track_title"
(with-db
;; Extract artist from 'Artist - Title' format in track_title
(postmodern:query
(: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
(: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
;;; ==========================================================================
(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 :track-id h)))
("title" . ,(or (cdr (assoc :track-title h))
(cdr (assoc :track_title h))))
("listened_at" . ,(cdr (assoc :listened-at h)))
("listen_duration" . ,(cdr (assoc :listen-duration h)))
("completed" . ,(let ((c (cdr (assoc :completed h))))
(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."
(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"
(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"))))))
(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)))))))
;;; ==========================================================================
;;; 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))))))