Compare commits
10 Commits
349fa31d8f
...
c01d99da85
| Author | SHA1 | Date |
|---|---|---|
|
|
c01d99da85 | |
|
|
20e5c37beb | |
|
|
01b00d448c | |
|
|
868b13af3d | |
|
|
7351d7f800 | |
|
|
62dde5e3cf | |
|
|
adce831a95 | |
|
|
00ec59014d | |
|
|
254106de75 | |
|
|
bfc33c8d4e |
|
|
@ -58,3 +58,4 @@ performance-logs/
|
|||
# Temporary files
|
||||
/static/asteroid.css
|
||||
stream-queue.m3u
|
||||
.jj/
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
1) [ ] [[https://www.radio.net/][Radio.net]]
|
||||
2) [ ] [[https://tunein.com/][TuneIn]] (requires application)
|
||||
3) [ ] [[https://streema.com/][Streema]]
|
||||
4) [ ] [[https://www.internet-radio.com/][Internet-Radio.com]]
|
||||
4) [X] [[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) [ ] Request library tracks
|
||||
2) [ ] Request tracks to add to library
|
||||
3) [ ] Tie into user playlists
|
||||
1) [X] Request library tracks
|
||||
2) [X] Request tracks to add to library
|
||||
3) [ ] Tie into user playlists - KIND OF COMPLETE!!??
|
||||
|
||||
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) [0/8] User Profile pages
|
||||
1) [ ] avatars
|
||||
4) [5/8] User Profile pages
|
||||
1) [X] avatars
|
||||
2) [ ] default playlist
|
||||
3) [ ] tarted up 'now playing' with highlights of previously upvoted tracks
|
||||
3) [X] tarted up 'now playing' with highlights of previously upvoted tracks
|
||||
4) [ ] polls
|
||||
5) [ ] Listener requests interface
|
||||
5) [X] Listener requests interface
|
||||
6) [ ] Calendar of upcoming scheduled 'shows'
|
||||
7) [ ] requests
|
||||
8) [ ] Custom user playlists, with submission for station airing
|
||||
7) [X] requests
|
||||
8) [X] Custom user playlists, with submission for station airing
|
||||
|
||||
5) [0/2] Shuffle/Random queue
|
||||
1) [ ] randomly run the whole library
|
||||
2) [ ] potentially weight 'random' by user prefs/voting records
|
||||
|
||||
6) [0/5] Themed streams
|
||||
1) [ ] Main curated amgbient 'low orbit'
|
||||
2) [ ] Random from full library 'deep space'
|
||||
3) [ ] Darker ambient
|
||||
4) [ ] Underworld and friends
|
||||
5) [ ] &c
|
||||
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
|
||||
|
||||
7) [0/5] Integrate with various social platforms
|
||||
1) [ ] Mastodon
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@
|
|||
(: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")))
|
||||
|
|
|
|||
|
|
@ -1158,28 +1158,45 @@
|
|||
(define-api asteroid/user/listening-stats () ()
|
||||
"Get user listening statistics"
|
||||
(require-authentication)
|
||||
(let* ((current-user (get-current-user))
|
||||
(user-id (when current-user (dm:id current-user)))
|
||||
(stats (if user-id
|
||||
(get-user-listening-stats user-id)
|
||||
(list :total-listen-time 0 :session-count 0 :tracks-played 0))))
|
||||
(with-error-handling
|
||||
(let* ((user-id (session:field "user-id"))
|
||||
(stats (get-listening-stats user-id)))
|
||||
(api-output `(("status" . "success")
|
||||
("stats" . (("total_listen_time" . ,(getf stats :total-listen-time 0))
|
||||
("tracks_played" . ,(getf stats :tracks-played 0))
|
||||
("session_count" . ,(getf stats :session-count 0))
|
||||
("favorite_genre" . "Unknown")))))))
|
||||
("session_count" . 0)
|
||||
("favorite_genre" . "Ambient"))))))))
|
||||
|
||||
(define-api asteroid/user/recent-tracks (&optional (limit "3")) ()
|
||||
(define-api asteroid/user/recent-tracks (&optional (limit "3") (offset "0")) ()
|
||||
"Get recently played tracks for user"
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let* ((user-id (session:field "user-id"))
|
||||
(limit-int (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" . ()))))
|
||||
("tracks" . ,(mapcar (lambda (h)
|
||||
`(("title" . ,(or (cdr (assoc :track-title h))
|
||||
(cdr (assoc :track_title h))))
|
||||
("artist" . "")
|
||||
("played_at" . ,(cdr (assoc :listened-at h)))
|
||||
("duration" . ,(or (cdr (assoc :listen-duration h)) 0))))
|
||||
history)))))))
|
||||
|
||||
(define-api asteroid/user/top-artists (&optional (limit "5")) ()
|
||||
"Get top artists for user"
|
||||
(require-authentication)
|
||||
(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" . ()))))
|
||||
("artists" . ,(mapcar (lambda (a)
|
||||
`(("name" . ,(or (cdr (assoc :artist a)) "Unknown"))
|
||||
("play_count" . ,(or (cdr (assoc :play-count a))
|
||||
(cdr (assoc :play_count a)) 0))))
|
||||
artists)))))))
|
||||
|
||||
;; Register page (GET)
|
||||
(define-page register #@"/register" ()
|
||||
|
|
|
|||
|
|
@ -51,6 +51,31 @@
|
|||
(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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,16 @@
|
|||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
(in-package :asteroid)
|
||||
|
||||
(defun find-track-by-title (title)
|
||||
"Find a track in the database by its title. Returns track ID or nil."
|
||||
(when (and title (not (string= title "Unknown")))
|
||||
(handler-case
|
||||
(with-db
|
||||
(let* ((search-pattern (format nil "%~a%" title))
|
||||
(result (postmodern:query
|
||||
(:limit
|
||||
(:select '_id
|
||||
:from 'tracks
|
||||
:where (:ilike 'title search-pattern))
|
||||
1)
|
||||
:single)))
|
||||
result))
|
||||
(error (e)
|
||||
(declare (ignore e))
|
||||
nil))))
|
||||
|
||||
(defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3"))
|
||||
"Fetch now-playing information from Icecast server.
|
||||
|
||||
|
|
@ -54,7 +72,8 @@
|
|||
|
||||
`((:listenurl . ,(format nil "~a/~a" *stream-base-url* mount))
|
||||
(:title . ,title)
|
||||
(:listeners . ,total-listeners)))))))
|
||||
(:listeners . ,total-listeners)
|
||||
(:track-id . ,(find-track-by-title title))))))))
|
||||
|
||||
(define-api asteroid/partial/now-playing (&optional mount) ()
|
||||
"Get Partial HTML with live status from Icecast server.
|
||||
|
|
@ -69,13 +88,16 @@
|
|||
(icecast-now-playing *stream-base-url* "asteroid-shuffle.mp3")))
|
||||
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
|
||||
(if now-playing-stats
|
||||
(progn
|
||||
(let* ((title (cdr (assoc :title now-playing-stats)))
|
||||
(favorite-count (or (get-track-favorite-count title) 0)))
|
||||
;; TODO: it should be able to define a custom api-output for this
|
||||
;; (api-output <clip-parser> :format "html"))
|
||||
(setf (header "Content-Type") "text/html")
|
||||
(clip:process-to-string
|
||||
(load-template "partial/now-playing")
|
||||
:stats now-playing-stats))
|
||||
:stats now-playing-stats
|
||||
:track-id (cdr (assoc :track-id now-playing-stats))
|
||||
:favorite-count favorite-count))
|
||||
(progn
|
||||
(setf (header "Content-Type") "text/html")
|
||||
(clip:process-to-string
|
||||
|
|
@ -97,6 +119,24 @@
|
|||
(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."
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
-- 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 $$;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
-- Migration 006: User Avatars
|
||||
-- Adds avatar support to user profiles
|
||||
|
||||
-- Add avatar_path column to USERS table
|
||||
ALTER TABLE "USERS" ADD COLUMN IF NOT EXISTS avatar_path TEXT;
|
||||
|
||||
-- Grant permissions
|
||||
GRANT ALL PRIVILEGES ON "USERS" TO asteroid;
|
||||
|
||||
-- Verification
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Migration 006: User avatars column added successfully!';
|
||||
END $$;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
-- Migration 007: Track Request System
|
||||
-- Allows users to request tracks for the stream with social attribution
|
||||
|
||||
-- Track requests table
|
||||
CREATE TABLE IF NOT EXISTS track_requests (
|
||||
_id SERIAL PRIMARY KEY,
|
||||
"user-id" INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE,
|
||||
track_title TEXT NOT NULL, -- Track title (Artist - Title format)
|
||||
track_path TEXT, -- Optional: path to file if known
|
||||
message TEXT, -- Optional message from requester
|
||||
status TEXT DEFAULT 'pending', -- pending, approved, rejected, played
|
||||
"created-at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
"reviewed-at" TIMESTAMP, -- When admin reviewed
|
||||
"reviewed-by" INTEGER REFERENCES "USERS"(_id),
|
||||
"played-at" TIMESTAMP -- When it was actually played
|
||||
);
|
||||
|
||||
-- Create indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_track_requests_user_id ON track_requests("user-id");
|
||||
CREATE INDEX IF NOT EXISTS idx_track_requests_status ON track_requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_track_requests_created ON track_requests("created-at");
|
||||
|
||||
-- Grant permissions
|
||||
GRANT ALL PRIVILEGES ON track_requests TO asteroid;
|
||||
GRANT ALL PRIVILEGES ON SEQUENCE track_requests__id_seq TO asteroid;
|
||||
|
||||
-- Verification
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Migration 007: Track requests table created successfully!';
|
||||
END $$;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
-- Migration 008: User Playlists
|
||||
-- Adds table for user-created playlists with submission/review workflow
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_playlists (
|
||||
_id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
track_ids TEXT DEFAULT '[]', -- JSON array of track IDs
|
||||
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'scheduled')),
|
||||
created_date INTEGER DEFAULT EXTRACT(EPOCH FROM NOW())::INTEGER,
|
||||
submitted_date INTEGER,
|
||||
reviewed_date INTEGER,
|
||||
reviewed_by INTEGER REFERENCES "USERS"(_id),
|
||||
review_notes TEXT
|
||||
);
|
||||
|
||||
-- Create indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_user_playlists_user_id ON user_playlists(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_playlists_status ON user_playlists(status);
|
||||
|
||||
-- Grant permissions
|
||||
GRANT ALL PRIVILEGES ON user_playlists TO asteroid;
|
||||
GRANT ALL PRIVILEGES ON SEQUENCE user_playlists__id_seq TO asteroid;
|
||||
|
||||
-- Verification
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Migration 008: User playlists table created successfully!';
|
||||
END $$;
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
(refresh-liquidsoap-status)
|
||||
(setup-stats-refresh)
|
||||
(refresh-scheduler-status)
|
||||
(refresh-track-requests)
|
||||
;; Update Liquidsoap status every 10 seconds
|
||||
(set-interval refresh-liquidsoap-status 10000)
|
||||
;; Update scheduler status every 30 seconds
|
||||
|
|
@ -1286,6 +1287,216 @@
|
|||
(ps:chain console (error "Error loading scheduled playlist:" error))
|
||||
(alert "Error loading scheduled playlist")))))
|
||||
|
||||
;; ========================================
|
||||
;; Track Requests Management
|
||||
;; ========================================
|
||||
|
||||
(defvar *current-request-tab* "pending")
|
||||
|
||||
(defun format-request-time (timestamp)
|
||||
"Format a timestamp for display"
|
||||
(if (not timestamp)
|
||||
""
|
||||
(let* ((ts-str (+ "" timestamp))
|
||||
(iso-str (if (ps:chain ts-str (includes " "))
|
||||
(+ (ps:chain ts-str (replace " " "T")) "Z")
|
||||
ts-str))
|
||||
(date (ps:new (-date iso-str))))
|
||||
(if (ps:chain -number (is-na-n (ps:chain date (get-time))))
|
||||
"Recently"
|
||||
(ps:chain date (to-locale-string))))))
|
||||
|
||||
(defun show-request-tab (tab)
|
||||
(setf *current-request-tab* tab)
|
||||
;; Update tab button styles
|
||||
(let ((tabs (ps:chain document (query-selector-all ".btn-tab"))))
|
||||
(ps:chain tabs (for-each (lambda (btn)
|
||||
(ps:chain btn class-list (remove "active"))))))
|
||||
(let ((active-tab (ps:chain document (get-element-by-id (+ "tab-" tab)))))
|
||||
(when active-tab
|
||||
(ps:chain active-tab class-list (add "active"))))
|
||||
;; Load the appropriate requests
|
||||
(refresh-track-requests))
|
||||
|
||||
(defun refresh-track-requests ()
|
||||
(let ((container (ps:chain document (get-element-by-id "pending-requests-container")))
|
||||
(status-el (ps:chain document (get-element-by-id "requests-status")))
|
||||
(url (+ "/api/asteroid/admin/requests/list?status=" *current-request-tab*)))
|
||||
(when status-el
|
||||
(setf (ps:@ status-el text-content) "Loading..."))
|
||||
(ps:chain
|
||||
(fetch url)
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when status-el
|
||||
(setf (ps:@ status-el text-content) ""))
|
||||
(when container
|
||||
(if (and (= (ps:@ data status) "success")
|
||||
(ps:@ data requests)
|
||||
(> (ps:@ data requests length) 0))
|
||||
(let ((html ""))
|
||||
(ps:chain (ps:@ data requests) (for-each (lambda (req)
|
||||
(let ((actions-html
|
||||
(cond
|
||||
((= *current-request-tab* "pending")
|
||||
(+ "<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¬es=" (encode-u-r-i-component (or notes "")))
|
||||
(ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(alert "Playlist rejected.")
|
||||
(load-user-playlist-submissions))
|
||||
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error rejecting playlist:" error))
|
||||
(alert "Error rejecting playlist"))))))
|
||||
|
||||
(defun preview-playlist (playlist-id)
|
||||
(ps:chain
|
||||
(fetch (+ "/api/asteroid/admin/user-playlists/preview?id=" playlist-id))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(let ((m3u (aref data "m3u")))
|
||||
;; Show in a modal or alert
|
||||
(alert (+ "Playlist M3U Preview:\n\n" m3u)))
|
||||
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error previewing playlist:" error))
|
||||
(alert "Error previewing playlist")))))
|
||||
|
||||
;; Make functions globally accessible for onclick handlers
|
||||
(setf (ps:@ window go-to-page) go-to-page)
|
||||
(setf (ps:@ window previous-page) previous-page)
|
||||
|
|
@ -1309,6 +1520,17 @@
|
|||
(setf (ps:@ window load-current-scheduled-playlist) load-current-scheduled-playlist)
|
||||
(setf (ps:@ window add-schedule-entry) add-schedule-entry)
|
||||
(setf (ps:@ window remove-schedule-entry) remove-schedule-entry)
|
||||
(setf (ps:@ window refresh-track-requests) refresh-track-requests)
|
||||
(setf (ps:@ window approve-request) approve-request)
|
||||
(setf (ps:@ window reject-request) reject-request)
|
||||
(setf (ps:@ window show-request-tab) show-request-tab)
|
||||
(setf (ps:@ window load-user-playlist-submissions) load-user-playlist-submissions)
|
||||
(setf (ps:@ window approve-playlist) approve-playlist)
|
||||
(setf (ps:@ window reject-playlist) reject-playlist)
|
||||
(setf (ps:@ window preview-playlist) preview-playlist)
|
||||
|
||||
;; Load user playlist submissions on page load
|
||||
(load-user-playlist-submissions)
|
||||
))
|
||||
"Compiled JavaScript for admin dashboard - generated at load time")
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,56 @@
|
|||
(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)))
|
||||
|
|
@ -176,8 +226,32 @@
|
|||
(ps:chain response (text))
|
||||
(throw (ps:new (-error "Error connecting to stream")))))))
|
||||
(then (lambda (data)
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "now-playing")) inner-h-t-m-l)
|
||||
data)))
|
||||
(let ((now-playing-el (ps:chain document (get-element-by-id "now-playing"))))
|
||||
(when now-playing-el
|
||||
;; Get current title before updating
|
||||
(let ((old-title-el (ps:chain now-playing-el (query-selector "#current-track-title"))))
|
||||
(setf (ps:@ now-playing-el inner-h-t-m-l) data)
|
||||
;; Get new title after updating
|
||||
(let ((new-title-el (ps:chain now-playing-el (query-selector "#current-track-title"))))
|
||||
(when new-title-el
|
||||
(let ((new-title (ps:@ new-title-el text-content)))
|
||||
;; Record if title changed
|
||||
(when (or (not old-title-el)
|
||||
(not (= (ps:@ old-title-el text-content) new-title)))
|
||||
(record-track-listen-main new-title))
|
||||
;; Check if this track is in user's favorites
|
||||
(check-favorite-status)
|
||||
;; Update favorite count display
|
||||
(let ((count-el (ps:chain document (get-element-by-id "favorite-count-display")))
|
||||
(count-val-el (ps:chain document (get-element-by-id "favorite-count-value"))))
|
||||
(when (and count-el count-val-el)
|
||||
(let ((fav-count (parse-int (or (ps:@ count-val-el value) "0") 10)))
|
||||
(if (> fav-count 0)
|
||||
(setf (ps:@ count-el text-content)
|
||||
(if (= fav-count 1)
|
||||
"1 person loves this track ❤️"
|
||||
(+ fav-count " people love this track ❤️")))
|
||||
(setf (ps:@ count-el text-content) "")))))))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (log "Could not fetch stream status:" error)))))))
|
||||
|
||||
|
|
@ -553,6 +627,9 @@
|
|||
(when is-frameset-page
|
||||
(set-interval update-stream-information 10000)))
|
||||
|
||||
;; Load user's favorites for highlight feature
|
||||
(load-favorites-cache)
|
||||
|
||||
;; Update now playing
|
||||
(update-now-playing)
|
||||
|
||||
|
|
@ -592,6 +669,59 @@
|
|||
|
||||
(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)
|
||||
|
||||
|
|
@ -642,7 +772,77 @@
|
|||
(when (and *popout-window* (ps:@ *popout-window* closed))
|
||||
(update-popout-button nil)
|
||||
(setf *popout-window* nil)))
|
||||
1000)))
|
||||
1000)
|
||||
|
||||
;; Track Request Functions
|
||||
(defun submit-track-request ()
|
||||
(let ((title-input (ps:chain document (get-element-by-id "request-title")))
|
||||
(message-input (ps:chain document (get-element-by-id "request-message")))
|
||||
(status-div (ps:chain document (get-element-by-id "request-status"))))
|
||||
(when (and title-input message-input status-div)
|
||||
(let ((title (ps:@ title-input value))
|
||||
(message (ps:@ message-input value)))
|
||||
(if (or (not title) (= title ""))
|
||||
(progn
|
||||
(setf (ps:@ status-div style display) "block")
|
||||
(setf (ps:@ status-div class-name) "request-status error")
|
||||
(setf (ps:@ status-div text-content) "Please enter a track title"))
|
||||
(progn
|
||||
(setf (ps:@ status-div style display) "block")
|
||||
(setf (ps:@ status-div class-name) "request-status info")
|
||||
(setf (ps:@ status-div text-content) "Submitting request...")
|
||||
(ps:chain
|
||||
(fetch (+ "/api/asteroid/requests/submit?title=" (encode-u-r-i-component title)
|
||||
(if message (+ "&message=" (encode-u-r-i-component message)) ""))
|
||||
(ps:create :method "POST"))
|
||||
(then (lambda (response)
|
||||
(if (ps:@ response ok)
|
||||
(ps:chain response (json))
|
||||
(progn
|
||||
(setf (ps:@ status-div class-name) "request-status error")
|
||||
(setf (ps:@ status-div text-content) "Please log in to submit requests")
|
||||
nil))))
|
||||
(then (lambda (data)
|
||||
(when data
|
||||
(let ((status (or (ps:@ data data status) (ps:@ data status))))
|
||||
(if (= status "success")
|
||||
(progn
|
||||
(setf (ps:@ status-div class-name) "request-status success")
|
||||
(setf (ps:@ status-div text-content) "Request submitted! An admin will review it soon.")
|
||||
(setf (ps:@ title-input value) "")
|
||||
(setf (ps:@ message-input value) ""))
|
||||
(progn
|
||||
(setf (ps:@ status-div class-name) "request-status error")
|
||||
(setf (ps:@ status-div text-content) "Failed to submit request")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error submitting request:" error))
|
||||
(setf (ps:@ status-div class-name) "request-status error")
|
||||
(setf (ps:@ status-div text-content) "Error submitting request"))))))))))
|
||||
|
||||
(defun load-recent-requests ()
|
||||
(let ((container (ps:chain document (get-element-by-id "recent-requests-list"))))
|
||||
(when container
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/requests/recent")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (and (= (ps:@ data status) "success")
|
||||
(ps:@ data requests)
|
||||
(> (ps:@ data requests length) 0))
|
||||
(let ((html ""))
|
||||
(ps:chain (ps:@ data requests) (for-each (lambda (req)
|
||||
(setf html (+ html "<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)))
|
||||
"Compiled JavaScript for front-page - generated at load time")
|
||||
|
||||
(defun generate-front-page-js ()
|
||||
|
|
|
|||
|
|
@ -32,9 +32,20 @@
|
|||
:day "numeric")))))
|
||||
|
||||
(defun format-relative-time (date-string)
|
||||
(let* ((date (ps:new (-date date-string)))
|
||||
(now (ps:new (-date)))
|
||||
(diff-ms (- now date))
|
||||
(when (not date-string)
|
||||
(return-from format-relative-time "Unknown"))
|
||||
;; Convert PostgreSQL timestamp format to ISO format
|
||||
;; "2025-12-21 09:22:58.215986" -> "2025-12-21T09:22:58.215986Z"
|
||||
(let* ((iso-string (if (and (ps:@ date-string replace)
|
||||
(ps:chain date-string (includes " ")))
|
||||
(+ (ps:chain date-string (replace " " "T")) "Z")
|
||||
date-string))
|
||||
(date (ps:new (-date iso-string)))
|
||||
(now (ps:new (-date))))
|
||||
;; Check if date is valid
|
||||
(when (ps:chain -number (is-na-n (ps:chain date (get-time))))
|
||||
(return-from format-relative-time "Recently"))
|
||||
(let* ((diff-ms (- now date))
|
||||
(diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24)))))
|
||||
(diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60)))))
|
||||
(diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60))))))
|
||||
|
|
@ -45,7 +56,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))))
|
||||
|
|
@ -109,37 +120,6 @@
|
|||
(update-element "session-count" "0")
|
||||
(update-element "favorite-genre" "Unknown")))))
|
||||
|
||||
(defun load-recent-tracks ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/recent-tracks?limit=3")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (and (= (ps:@ data status) "success")
|
||||
(ps:@ data tracks)
|
||||
(> (ps:@ data tracks length) 0))
|
||||
(ps:chain data tracks
|
||||
(for-each (lambda (track index)
|
||||
(let ((track-num (+ index 1)))
|
||||
(update-element (+ "recent-track-" track-num "-title")
|
||||
(or (ps:@ track title) "Unknown Track"))
|
||||
(update-element (+ "recent-track-" track-num "-artist")
|
||||
(or (ps:@ track artist) "Unknown Artist"))
|
||||
(update-element (+ "recent-track-" track-num "-duration")
|
||||
(format-duration (or (ps:@ track duration) 0)))
|
||||
(update-element (+ "recent-track-" track-num "-played-at")
|
||||
(format-relative-time (ps:@ track played_at)))))))
|
||||
(loop for i from 1 to 3
|
||||
do (let* ((track-item-selector (+ "[data-text=\"recent-track-" i "-title\"]"))
|
||||
(track-item-el (ps:chain document (query-selector track-item-selector)))
|
||||
(track-item (when track-item-el (ps:chain track-item-el (closest ".track-item")))))
|
||||
(when (and track-item
|
||||
(or (not (ps:@ data tracks))
|
||||
(not (ps:getprop (ps:@ data tracks) (- i 1)))))
|
||||
(setf (ps:@ track-item style display) "none"))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading recent tracks:" error))))))
|
||||
|
||||
(defun load-top-artists ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/top-artists?limit=5")
|
||||
|
|
@ -167,6 +147,163 @@
|
|||
(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..."))
|
||||
|
||||
|
|
@ -187,14 +324,51 @@
|
|||
(show-error "Error loading profile data"))))
|
||||
|
||||
(load-listening-stats)
|
||||
(load-recent-tracks)
|
||||
(load-top-artists))
|
||||
(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))))))
|
||||
|
||||
;; 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"))
|
||||
|
|
@ -291,11 +465,402 @@
|
|||
|
||||
false))
|
||||
|
||||
;; ========================================
|
||||
;; User Playlists functionality
|
||||
;; ========================================
|
||||
|
||||
(defvar *library-page* 1)
|
||||
(defvar *library-search* "")
|
||||
(defvar *library-artist* "")
|
||||
(defvar *library-total* 0)
|
||||
(defvar *current-playlist-tracks* (array))
|
||||
(defvar *user-playlists* (array))
|
||||
|
||||
;; Load user's playlists
|
||||
(defun load-my-playlists ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/playlists")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result))
|
||||
(container (ps:chain document (get-element-by-id "my-playlists-list"))))
|
||||
(when container
|
||||
(if (and (= (ps:@ data status) "success")
|
||||
(ps:@ data playlists)
|
||||
(> (ps:@ data playlists length) 0))
|
||||
(progn
|
||||
(setf *user-playlists* (ps:@ data playlists))
|
||||
(let ((html ""))
|
||||
(ps:chain (ps:@ data playlists) (for-each (lambda (pl)
|
||||
(let ((playlist-id (or (ps:@ pl id) (aref pl "id")))
|
||||
(status-class (cond
|
||||
((= (ps:@ pl status) "draft") "status-draft")
|
||||
((= (ps:@ pl status) "submitted") "status-pending")
|
||||
((= (ps:@ pl status) "approved") "status-approved")
|
||||
((= (ps:@ pl status) "rejected") "status-rejected")
|
||||
(t "")))
|
||||
(status-icon (cond
|
||||
((= (ps:@ pl status) "draft") "📝")
|
||||
((= (ps:@ pl status) "submitted") "⏳")
|
||||
((= (ps:@ pl status) "approved") "✓")
|
||||
((= (ps:@ pl status) "rejected") "✗")
|
||||
(t "?"))))
|
||||
(ps:chain console (log "Playlist:" pl "ID:" playlist-id))
|
||||
(setf html (+ html
|
||||
"<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"
|
||||
load-profile-data))))
|
||||
(lambda ()
|
||||
(load-profile-data)
|
||||
(load-my-playlists))))))
|
||||
"Compiled JavaScript for profile page - generated at load time")
|
||||
|
||||
(defun generate-profile-js ()
|
||||
|
|
|
|||
|
|
@ -198,22 +198,144 @@
|
|||
(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-inline?mount=" mount))
|
||||
(fetch (+ "/api/asteroid/partial/now-playing-json?mount=" mount))
|
||||
(then (lambda (response)
|
||||
(if (ps:@ response ok)
|
||||
(ps:chain response (text))
|
||||
"")))
|
||||
(then (lambda (text)
|
||||
(let ((el (ps:chain document (get-element-by-id "mini-now-playing"))))
|
||||
(ps:chain response (json))
|
||||
nil)))
|
||||
(then (lambda (data)
|
||||
(when data
|
||||
(let ((el (ps:chain document (get-element-by-id "mini-now-playing")))
|
||||
(track-id-el (ps:chain document (get-element-by-id "current-track-id-mini")))
|
||||
(title (or (ps:@ data data title) (ps:@ data title) "Loading...")))
|
||||
(when el
|
||||
(setf (ps:@ el text-content) text)))))
|
||||
;; 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 " ❤️"))))))))))
|
||||
(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)))
|
||||
|
|
@ -528,6 +650,9 @@
|
|||
(defun init-persistent-player ()
|
||||
(let ((audio-element (ps:chain document (get-element-by-id "persistent-audio"))))
|
||||
(when audio-element
|
||||
;; Load user's favorites for highlight feature
|
||||
(load-favorites-cache-mini)
|
||||
|
||||
;; Try to enable low-latency mode if supported
|
||||
(when (ps:@ navigator media-session)
|
||||
(setf (ps:@ navigator media-session metadata)
|
||||
|
|
@ -641,6 +766,7 @@
|
|||
(setf (ps:@ window init-popout-player) init-popout-player)
|
||||
(setf (ps:@ window update-mini-now-playing) update-mini-now-playing)
|
||||
(setf (ps:@ window update-popout-now-playing) update-popout-now-playing)
|
||||
(setf (ps:@ window toggle-favorite-mini) toggle-favorite-mini)
|
||||
|
||||
;; Auto-initialize on DOMContentLoaded based on which elements exist
|
||||
(ps:chain document
|
||||
|
|
|
|||
|
|
@ -163,11 +163,19 @@
|
|||
(sort (copy-list *playlist-schedule*) #'< :key #'car))
|
||||
|
||||
(defun get-available-playlists ()
|
||||
"Get list of available playlist files from the playlists directory."
|
||||
(let ((playlists-dir (get-playlists-directory)))
|
||||
"Get list of available playlist files from the playlists directory and user-submissions."
|
||||
(let ((playlists-dir (get-playlists-directory))
|
||||
(submissions-dir (merge-pathnames "user-submissions/" (get-playlists-directory))))
|
||||
(append
|
||||
;; Main playlists directory
|
||||
(when (probe-file playlists-dir)
|
||||
(mapcar #'file-namestring
|
||||
(directory (merge-pathnames "*.m3u" playlists-dir))))))
|
||||
(directory (merge-pathnames "*.m3u" playlists-dir))))
|
||||
;; User submissions directory (prefixed with user-submissions/)
|
||||
(when (probe-file submissions-dir)
|
||||
(mapcar (lambda (path)
|
||||
(format nil "user-submissions/~a" (file-namestring path)))
|
||||
(directory (merge-pathnames "*.m3u" submissions-dir)))))))
|
||||
|
||||
(defun get-server-time-info ()
|
||||
"Get current server time information in both UTC and local timezone."
|
||||
|
|
|
|||
|
|
@ -1,144 +1,207 @@
|
|||
#EXTM3U
|
||||
#PLAYLIST:Escape Velocity - A Christmas Journey Through Space
|
||||
#PHASE:Escape Velocity
|
||||
#DURATION:12 hours (approx)
|
||||
#PLAYLIST:Morning Drift
|
||||
#PHASE:Morning Drift
|
||||
#DURATION:6 hours (approx)
|
||||
#CURATOR:Asteroid Radio
|
||||
#DESCRIPTION:A festive 12-hour voyage blending Christmas classics with ambient, IDM, and space music for the holiday season
|
||||
#DESCRIPTION:Lighter, awakening ambient for the morning hours (06:00-12:00)
|
||||
|
||||
# === PHASE 1: WINTER AWAKENING (Ambient Beginnings) ===
|
||||
#EXTINF:-1,Brian Eno - Snow
|
||||
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac
|
||||
#EXTINF:-1,Brian Eno - Wintergreen
|
||||
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/04 Wintergreen.flac
|
||||
#EXTINF:-1,Proem - Winter Wolves
|
||||
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
|
||||
#EXTINF:-1,Tim Hecker - Winter's Coming
|
||||
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 10 Winter's Coming.flac
|
||||
#EXTINF:-1,Brian Eno - Emerald And Lime
|
||||
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A1 Emerald And Lime.flac
|
||||
#EXTINF:-1,Tycho - Glider
|
||||
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/01 - Glider.flac
|
||||
#EXTINF:-1,Biosphere - Drifter
|
||||
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac
|
||||
#EXTINF:-1,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
|
||||
#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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
#EXTM3U
|
||||
#PLAYLIST:glenneth
|
||||
#PHASE:Zero Gravity
|
||||
#CURATOR:admin
|
||||
|
||||
#EXTINF:-1,Kiasmos - 65
|
||||
/home/fade/Media/Music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/01. Kiasmos - 65.flac
|
||||
#EXTINF:-1,Kiasmos - Walled
|
||||
/home/fade/Media/Music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/02. Kiasmos - Walled.flac
|
||||
#EXTINF:-1,Kiasmos - Bound
|
||||
/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 05 Bound.flac
|
||||
#EXTINF:-1,Kiasmos - Burst
|
||||
/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 02 Burst.flac
|
||||
#EXTINF:-1,Kiasmos - Dazed
|
||||
/home/fade/Media/Music/Kiasmos/2024 - II/Kiasmos - II - 10 Dazed.flac
|
||||
#EXTINF:-1,Kiasmos - Held
|
||||
/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/02 - Held.flac
|
||||
#EXTINF:-1,Kiasmos - Dragged
|
||||
/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/06 - Dragged.flac
|
||||
#EXTINF:-1,Kiasmos - Lit
|
||||
/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/01 - Lit.flac
|
||||
#EXTINF:-1,Kiasmos - Looped
|
||||
/home/fade/Media/Music/Kiasmos/2014 - Kiasmos/03 - Looped.flac
|
||||
#EXTINF:-1,Kiasmos - Burnt [Lubomyr Melnyl Rework]
|
||||
/home/fade/Media/Music/Kiasmos/2015 - Looped/03 Burnt (Lubomyr Melnyl Rework).flac
|
||||
|
|
@ -1546,3 +1546,795 @@ 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;
|
||||
}
|
||||
|
|
@ -1274,4 +1274,624 @@
|
|||
|
||||
(.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.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -0,0 +1,5 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 294 B |
|
|
@ -102,6 +102,22 @@
|
|||
</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>
|
||||
|
|
@ -346,6 +362,15 @@
|
|||
</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">
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@
|
|||
</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%);">
|
||||
|
|
|
|||
|
|
@ -92,6 +92,24 @@
|
|||
<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">
|
||||
|
|
|
|||
|
|
@ -125,6 +125,26 @@
|
|||
<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">
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@
|
|||
<c:if test="stats">
|
||||
<c:then>
|
||||
<c:using value="stats">
|
||||
<!--<p>Artist: <span>The Void</span></p>-->
|
||||
<p>Track: <span lquery="(text title)">The Void - Silence</span></p>
|
||||
<div class="now-playing-track">
|
||||
<p>Track: <span lquery="(text title)" id="current-track-title">The Void - Silence</span></p>
|
||||
<button class="btn-favorite" id="favorite-btn" onclick="toggleFavorite()" title="Add to favorites">
|
||||
<span class="star-icon">☆</span>
|
||||
</button>
|
||||
</div>
|
||||
<p>Listeners: <span lquery="(text listeners)">1</span></p>
|
||||
</c:using>
|
||||
<input type="hidden" id="current-track-id" lquery="(val track-id)" value="">
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@
|
|||
<!-- 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>
|
||||
|
|
@ -40,6 +50,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening Statistics -->
|
||||
<div class="admin-section">
|
||||
|
|
@ -64,43 +75,35 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recently Played Tracks -->
|
||||
<!-- My Track Requests -->
|
||||
<div class="admin-section">
|
||||
<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>
|
||||
<h2>🎵 My Track Requests</h2>
|
||||
<div id="my-requests-list" class="requests-list">
|
||||
<p class="loading-message">Loading your requests...</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<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 id="my-playlists-list" class="playlists-list">
|
||||
<p class="loading-message">Loading your playlists...</p>
|
||||
</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="loadMoreRecentTracks()">Load More</button>
|
||||
<button class="btn btn-secondary" onclick="loadMoreFavorites()">Load More</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -135,18 +138,11 @@
|
|||
<div class="admin-section">
|
||||
<h2>📈 Listening Activity</h2>
|
||||
<div class="activity-chart">
|
||||
<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 -->
|
||||
<p>Tracks played over the last 30 days</p>
|
||||
<div class="chart-container" id="activity-chart">
|
||||
<p class="loading-message">Loading activity data...</p>
|
||||
</div>
|
||||
<p class="chart-note">Listening hours per day</p>
|
||||
<p class="chart-note" id="activity-total">Total: 0 tracks</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -183,6 +179,76 @@
|
|||
</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()">×</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()">×</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()">×</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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
(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)))))
|
||||
|
|
@ -156,6 +156,10 @@
|
|||
(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).
|
||||
|
|
@ -164,24 +168,26 @@
|
|||
(let* ((user-id (session:field "user-id"))
|
||||
(uri (radiance:path (radiance:uri *request*)))
|
||||
;; Use explicit flag if provided, otherwise auto-detect from URI
|
||||
(is-api-request (if api t (search "/api/" uri))))
|
||||
;; Check for "api/" anywhere in the path
|
||||
(is-api-request (if api t (or (search "/api/" uri)
|
||||
(search "api/" uri)))))
|
||||
(format t "Authentication check - User ID: ~a, URI: ~a, Is API: ~a~%"
|
||||
user-id uri (if is-api-request "YES" "NO"))
|
||||
(if user-id
|
||||
t ; Authenticated - return T to continue
|
||||
;; Not authenticated - emit error
|
||||
;; Not authenticated - emit error and signal to stop processing
|
||||
(progn
|
||||
(if is-api-request
|
||||
;; API request - emit JSON error and return the value from api-output
|
||||
;; API request - return JSON error with 401 status using api-output
|
||||
(progn
|
||||
(format t "Authentication failed - returning JSON 401~%")
|
||||
(radiance:api-output
|
||||
'(("error" . "Authentication required"))
|
||||
:status 401
|
||||
:message "You must be logged in to access this resource"))
|
||||
;; Page request - redirect to login (redirect doesn't return)
|
||||
(api-output `(("status" . "error")
|
||||
("message" . "Authentication required"))
|
||||
:status 401))
|
||||
;; Page request - redirect to login
|
||||
(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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,421 @@
|
|||
(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))))))
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
;;;; 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))))))
|
||||
Loading…
Reference in New Issue