Compare commits

...

10 Commits

Author SHA1 Message Date
glenneth c01d99da85 chore: Add .jj/ to gitignore for Jujutsu VCS 2025-12-22 21:42:06 -05:00
glenneth 20e5c37beb feat: Add YP directory listings for internet-radio.com and xiph.org
- Add internet-radio.com YP directory entry
- Add xiph.org (Icecast official) YP directory entry
- All mount points already have public=true in Liquidsoap config
2025-12-22 21:42:06 -05:00
glenneth 01b00d448c docs: Update TODO-next-features.org with completed tasks
- Mark Internet-Radio.com listing as complete
- Mark Listener Requests (library tracks, add to library) as complete
- Mark all Themed streams as complete (low orbit, deep space, darker ambient, underworld)
2025-12-22 21:42:06 -05:00
glenneth 868b13af3d feat: Custom user playlists with submission and admin review
- Add user playlist creation, editing, and track management
- Add library browser for adding tracks to playlists
- Add playlist submission workflow for station airing
- Add admin review interface with preview, approve, reject
- Generate M3U files on approval in playlists/user-submissions/
- Include user-submissions in playlist scheduler dropdown
- Use playlist description as PHASE tag in M3U
- Add database migration for user_playlists table
- Update TODO-next-features.org to mark feature complete
2025-12-22 21:42:06 -05:00
glenneth 7351d7f800 refactor: Remove Recently Played section from profile page
Removed the Recently Played UI section from profile as redundant.
The listening history backend and APIs remain intact for future use.
Previous commit (0359e59) preserves the full implementation.
2025-12-22 21:42:06 -05:00
glenneth 62dde5e3cf feat: Track requests, listening history, and profile enhancements
Track Requests:
- Database table for user track requests (migration 007)
- API endpoints for submit, approve, reject, play
- Front page UI for submitting requests
- Shows recently played requests section

Listening History:
- Auto-records tracks when playing (with 60s deduplication)
- Recently Played section on profile (has date formatting issues)
- Activity chart showing listening patterns by day
- Load More Tracks pagination

Profile Improvements:
- Fixed 401 errors returning proper JSON
- Fixed PostgreSQL boolean type for completed column
- Added offset parameter to recent-tracks API

Note: Recently Played section has date formatting issues showing
'20397 days ago' - may be removed in future commit if not needed.
The listening history backend works correctly.

For production: run migrations/007-track-requests.sql
2025-12-22 21:42:06 -05:00
glenneth adce831a95 feat: Add avatar upload and fix authentication errors
Avatars:
- Add avatar_path column to USERS table (migration 006)
- Upload API endpoint /api/asteroid/user/avatar/upload
- Profile page shows avatar with hover-to-change overlay
- Default SVG avatar for users without uploaded image
- Avatars stored in static/avatars/ directory

Fixes:
- 401 errors now return proper JSON instead of 500
- SQL escaping for history recording (single quotes)
- Added debug logging for history/record API
- Avatar container has background color for visibility

For production: run migrations/006-user-avatars.sql
2025-12-22 21:42:06 -05:00
glenneth 00ec59014d feat: Add listening activity chart to profile page
- New API endpoint /api/asteroid/user/activity for daily aggregation
- Bar chart showing tracks played per day (last 30 days)
- Hover tooltips show exact date and count
- Total tracks summary below chart
- Green gradient bars matching site theme
2025-12-22 21:42:06 -05:00
glenneth 254106de75 feat: Add listening history tracking and fix favorites
Listening History:
- Auto-record tracks when they change (logged-in users only)
- Track stored by title (no tracks table dependency)
- Profile page shows real recent tracks, top artists, listening stats
- APIs: /api/asteroid/user/history, /user/listening-stats, /user/recent-tracks, /user/top-artists

Favorites Fixes:
- Remove favorite now uses title instead of track-id
- Fixed response parsing to show green success message
- Profile page remove button works correctly

Migration Script Updated:
- track_title column added to both tables
- track-id now optional (nullable)
- Unique index on (user-id, track_title)
- No foreign key to tracks table (title-based storage)

For production: run migrations/005-user-favorites-history.sql
2025-12-22 21:42:06 -05:00
glenneth bfc33c8d4e feat: Add track favorites feature with star button
- Add user_favorites and listening_history database tables
- Add migration 005-user-favorites-history.sql
- Create user-profile.lisp with favorites/history API endpoints
- Add star button (☆/★) to Now Playing on main page
- Add star button to frame player bar
- Add Favorites section to profile page
- Show login prompt when unauthenticated user clicks star
- Use gold color (#ffcc00) for favorited state (space theme)
- Fix require-authentication to properly detect API routes
- Support title-based favorites (no track DB required)
2025-12-22 21:42:06 -05:00
34 changed files with 4337 additions and 315 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@ -1158,28 +1158,45 @@
(define-api asteroid/user/listening-stats () () (define-api asteroid/user/listening-stats () ()
"Get user listening statistics" "Get user listening statistics"
(require-authentication) (require-authentication)
(let* ((current-user (get-current-user)) (with-error-handling
(user-id (when current-user (dm:id current-user))) (let* ((user-id (session:field "user-id"))
(stats (if user-id (stats (get-listening-stats user-id)))
(get-user-listening-stats user-id)
(list :total-listen-time 0 :session-count 0 :tracks-played 0))))
(api-output `(("status" . "success") (api-output `(("status" . "success")
("stats" . (("total_listen_time" . ,(getf stats :total-listen-time 0)) ("stats" . (("total_listen_time" . ,(getf stats :total-listen-time 0))
("tracks_played" . ,(getf stats :tracks-played 0)) ("tracks_played" . ,(getf stats :tracks-played 0))
("session_count" . ,(getf stats :session-count 0)) ("session_count" . 0)
("favorite_genre" . "Unknown"))))))) ("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" "Get recently played tracks for user"
(require-authentication) (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") (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")) () (define-api asteroid/user/top-artists (&optional (limit "5")) ()
"Get top artists for user" "Get top artists for user"
(require-authentication) (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") (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) ;; Register page (GET)
(define-page register #@"/register" () (define-page register #@"/register" ()

View File

@ -51,6 +51,31 @@
(position :integer) (position :integer)
(added_date :integer)))) (added_date :integer))))
(unless (db:collection-exists-p "user_favorites")
(db:create "user_favorites" '((user-id :integer)
(track-id :integer)
(rating :integer)
(created-date :integer))))
(unless (db:collection-exists-p "listening_history")
(db:create "listening_history" '((user-id :integer)
(track-id :integer)
(listened-at :integer)
(listen-duration :integer)
(completed :integer))))
(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 ;; TODO: the radiance db interface is too basic to contain anything
;; but strings, integers, booleans, and maybe timestamps... we will ;; but strings, integers, booleans, and maybe timestamps... we will
;; need to rethink this. currently track/playlist relationships are ;; need to rethink this. currently track/playlist relationships are

View File

@ -22,6 +22,16 @@
<hostname>localhost</hostname> <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> <listen-socket>
<port>8000</port> <port>8000</port>
</listen-socket> </listen-socket>

View File

@ -1,5 +1,23 @@
(in-package :asteroid) (in-package :asteroid)
(defun find-track-by-title (title)
"Find a track in the database by its title. Returns track ID or nil."
(when (and title (not (string= title "Unknown")))
(handler-case
(with-db
(let* ((search-pattern (format nil "%~a%" title))
(result (postmodern:query
(:limit
(:select '_id
:from 'tracks
:where (:ilike 'title search-pattern))
1)
:single)))
result))
(error (e)
(declare (ignore e))
nil))))
(defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3")) (defun icecast-now-playing (icecast-base-url &optional (mount "asteroid.mp3"))
"Fetch now-playing information from Icecast server. "Fetch now-playing information from Icecast server.
@ -54,7 +72,8 @@
`((:listenurl . ,(format nil "~a/~a" *stream-base-url* mount)) `((:listenurl . ,(format nil "~a/~a" *stream-base-url* mount))
(:title . ,title) (:title . ,title)
(:listeners . ,total-listeners))))))) (:listeners . ,total-listeners)
(:track-id . ,(find-track-by-title title))))))))
(define-api asteroid/partial/now-playing (&optional mount) () (define-api asteroid/partial/now-playing (&optional mount) ()
"Get Partial HTML with live status from Icecast server. "Get Partial HTML with live status from Icecast server.
@ -69,13 +88,16 @@
(icecast-now-playing *stream-base-url* "asteroid-shuffle.mp3"))) (icecast-now-playing *stream-base-url* "asteroid-shuffle.mp3")))
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name))) (now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
(if now-playing-stats (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 ;; TODO: it should be able to define a custom api-output for this
;; (api-output <clip-parser> :format "html")) ;; (api-output <clip-parser> :format "html"))
(setf (header "Content-Type") "text/html") (setf (header "Content-Type") "text/html")
(clip:process-to-string (clip:process-to-string
(load-template "partial/now-playing") (load-template "partial/now-playing")
:stats now-playing-stats)) :stats now-playing-stats
:track-id (cdr (assoc :track-id now-playing-stats))
:favorite-count favorite-count))
(progn (progn
(setf (header "Content-Type") "text/html") (setf (header "Content-Type") "text/html")
(clip:process-to-string (clip:process-to-string
@ -97,6 +119,24 @@
(setf (header "Content-Type") "text/plain") (setf (header "Content-Type") "text/plain")
"Stream Offline"))))) "Stream Offline")))))
(define-api asteroid/partial/now-playing-json (&optional mount) ()
"Get JSON with now playing info including track ID for favorites.
Optional MOUNT parameter specifies which stream to get metadata from."
(with-error-handling
(let* ((mount-name (or mount "asteroid.mp3"))
(now-playing-stats (icecast-now-playing *stream-base-url* mount-name)))
(if now-playing-stats
(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 () () (define-api asteroid/channel-name () ()
"Get the current curated channel name for live updates. "Get the current curated channel name for live updates.
Returns JSON with the channel name from the current playlist's PHASE header." Returns JSON with the channel name from the current playlist's PHASE header."

View File

@ -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 $$;

View File

@ -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 $$;

View File

@ -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 $$;

View File

@ -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 $$;

View File

@ -29,6 +29,7 @@
(refresh-liquidsoap-status) (refresh-liquidsoap-status)
(setup-stats-refresh) (setup-stats-refresh)
(refresh-scheduler-status) (refresh-scheduler-status)
(refresh-track-requests)
;; Update Liquidsoap status every 10 seconds ;; Update Liquidsoap status every 10 seconds
(set-interval refresh-liquidsoap-status 10000) (set-interval refresh-liquidsoap-status 10000)
;; Update scheduler status every 30 seconds ;; Update scheduler status every 30 seconds
@ -1286,6 +1287,216 @@
(ps:chain console (error "Error loading scheduled playlist:" error)) (ps:chain console (error "Error loading scheduled playlist:" error))
(alert "Error loading scheduled playlist"))))) (alert "Error loading scheduled playlist")))))
;; ========================================
;; Track Requests Management
;; ========================================
(defvar *current-request-tab* "pending")
(defun format-request-time (timestamp)
"Format a timestamp for display"
(if (not timestamp)
""
(let* ((ts-str (+ "" timestamp))
(iso-str (if (ps:chain ts-str (includes " "))
(+ (ps:chain ts-str (replace " " "T")) "Z")
ts-str))
(date (ps:new (-date iso-str))))
(if (ps:chain -number (is-na-n (ps:chain date (get-time))))
"Recently"
(ps:chain date (to-locale-string))))))
(defun show-request-tab (tab)
(setf *current-request-tab* tab)
;; Update tab button styles
(let ((tabs (ps:chain document (query-selector-all ".btn-tab"))))
(ps:chain tabs (for-each (lambda (btn)
(ps:chain btn class-list (remove "active"))))))
(let ((active-tab (ps:chain document (get-element-by-id (+ "tab-" tab)))))
(when active-tab
(ps:chain active-tab class-list (add "active"))))
;; Load the appropriate requests
(refresh-track-requests))
(defun refresh-track-requests ()
(let ((container (ps:chain document (get-element-by-id "pending-requests-container")))
(status-el (ps:chain document (get-element-by-id "requests-status")))
(url (+ "/api/asteroid/admin/requests/list?status=" *current-request-tab*)))
(when status-el
(setf (ps:@ status-el text-content) "Loading..."))
(ps:chain
(fetch url)
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(when status-el
(setf (ps:@ status-el text-content) ""))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data requests)
(> (ps:@ data requests length) 0))
(let ((html ""))
(ps:chain (ps:@ data requests) (for-each (lambda (req)
(let ((actions-html
(cond
((= *current-request-tab* "pending")
(+ "<button class=\"btn btn-success btn-sm\" onclick=\"approveRequest(" (ps:@ req id) ")\">✓ Approve</button>"
"<button class=\"btn btn-danger btn-sm\" onclick=\"rejectRequest(" (ps:@ req id) ")\">✗ Reject</button>"))
((= *current-request-tab* "approved")
"<span class=\"status-badge status-approved\">✓ Approved</span>")
((= *current-request-tab* "rejected")
"<span class=\"status-badge status-rejected\">✗ Rejected</span>")
((= *current-request-tab* "played")
"<span class=\"status-badge status-played\">🎵 Played</span>")
(t ""))))
(setf html (+ html
"<div class=\"request-item-admin\" data-request-id=\"" (ps:@ req id) "\">"
"<div class=\"request-info\">"
"<strong>" (ps:@ req title) "</strong>"
"<span class=\"request-user\">Requested by @" (ps:@ req username) "</span>"
(if (ps:@ req message)
(+ "<p class=\"request-message\">\"" (ps:@ req message) "\"</p>")
"")
"<span class=\"request-time\">" (format-request-time (ps:@ req created_at)) "</span>"
"</div>"
"<div class=\"request-actions\">"
actions-html
"</div>"
"</div>"))))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) (+ "<p style=\"color: #888;\">No " *current-request-tab* " requests</p>")))))))
(catch (lambda (error)
(ps:chain console (error "Error loading requests:" error))
(when status-el
(setf (ps:@ status-el text-content) "Error loading requests")))))))
(defun approve-request (request-id)
(ps:chain
(fetch (+ "/api/asteroid/requests/approve?id=" request-id)
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-toast "✓ Request approved")
(refresh-track-requests))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error approving request:" error))
(alert "Error approving request")))))
(defun reject-request (request-id)
(when (confirm "Are you sure you want to reject this request?")
(ps:chain
(fetch (+ "/api/asteroid/requests/reject?id=" request-id)
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-toast "Request rejected")
(refresh-track-requests))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error rejecting request:" error))
(alert "Error rejecting request"))))))
;; ========================================
;; User Playlist Review Functions
;; ========================================
(defun load-user-playlist-submissions ()
(ps:chain
(fetch "/api/asteroid/admin/user-playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((container (ps:chain document (get-element-by-id "user-playlists-container")))
(data (or (ps:@ result data) result)))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data playlists)
(> (ps:@ data playlists length) 0))
(let ((html "<table class='admin-table'><thead><tr><th>Playlist</th><th>User</th><th>Tracks</th><th>Submitted</th><th>Actions</th></tr></thead><tbody>"))
(ps:chain (ps:@ data playlists) (for-each (lambda (pl)
(let* ((ts (aref pl "submittedDate"))
(submitted-date (if ts
(ps:chain (ps:new (*Date (* ts 1000))) (to-locale-string))
"N/A")))
(setf html (+ html
"<tr>"
"<td><strong>" (aref pl "name") "</strong>"
(if (aref pl "description") (+ "<br><small>" (aref pl "description") "</small>") "")
"</td>"
"<td>" (or (aref pl "username") "Unknown") "</td>"
"<td>" (or (aref pl "trackCount") 0) " tracks</td>"
"<td>" submitted-date "</td>"
"<td>"
"<button class='btn btn-info btn-sm' onclick='previewPlaylist(" (aref pl "id") ")'>👁 Preview</button> "
"<button class='btn btn-success btn-sm' onclick='approvePlaylist(" (aref pl "id") ")'>✓ Approve</button> "
"<button class='btn btn-danger btn-sm' onclick='rejectPlaylist(" (aref pl "id") ")'>✗ Reject</button>"
"</td>"
"</tr>"))))))
(setf html (+ html "</tbody></table>"))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class='no-data'>No playlists awaiting review</p>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading user playlists:" error))
(let ((container (ps:chain document (get-element-by-id "user-playlists-container"))))
(when container
(setf (ps:@ container inner-h-t-m-l) "<p class='error'>Error loading submissions</p>")))))))
(defun approve-playlist (playlist-id)
(when (confirm "Approve this playlist? It will be available for scheduling.")
(ps:chain
(fetch (+ "/api/asteroid/admin/user-playlists/review?id=" playlist-id "&action=approve")
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(alert "Playlist approved!")
(load-user-playlist-submissions))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error approving playlist:" error))
(alert "Error approving playlist"))))))
(defun reject-playlist (playlist-id)
(let ((notes (prompt "Reason for rejection (optional):")))
(ps:chain
(fetch (+ "/api/asteroid/admin/user-playlists/review?id=" playlist-id
"&action=reject&notes=" (encode-u-r-i-component (or notes "")))
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(alert "Playlist rejected.")
(load-user-playlist-submissions))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error rejecting playlist:" error))
(alert "Error rejecting playlist"))))))
(defun preview-playlist (playlist-id)
(ps:chain
(fetch (+ "/api/asteroid/admin/user-playlists/preview?id=" playlist-id))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(let ((m3u (aref data "m3u")))
;; Show in a modal or alert
(alert (+ "Playlist M3U Preview:\n\n" m3u)))
(alert (+ "Error: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error previewing playlist:" error))
(alert "Error previewing playlist")))))
;; Make functions globally accessible for onclick handlers ;; Make functions globally accessible for onclick handlers
(setf (ps:@ window go-to-page) go-to-page) (setf (ps:@ window go-to-page) go-to-page)
(setf (ps:@ window previous-page) previous-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 load-current-scheduled-playlist) load-current-scheduled-playlist)
(setf (ps:@ window add-schedule-entry) add-schedule-entry) (setf (ps:@ window add-schedule-entry) add-schedule-entry)
(setf (ps:@ window remove-schedule-entry) remove-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") "Compiled JavaScript for admin dashboard - generated at load time")

View File

@ -165,6 +165,56 @@
(get-stream-config (ps:@ stream-base-url value) channel quality)))) (get-stream-config (ps:@ stream-base-url value) channel quality))))
(if config (ps:@ config mount) "asteroid.mp3"))) (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 ;; Update now playing info from API
(defun update-now-playing () (defun update-now-playing ()
(let ((mount (get-current-mount))) (let ((mount (get-current-mount)))
@ -176,8 +226,32 @@
(ps:chain response (text)) (ps:chain response (text))
(throw (ps:new (-error "Error connecting to stream"))))))) (throw (ps:new (-error "Error connecting to stream")))))))
(then (lambda (data) (then (lambda (data)
(setf (ps:@ (ps:chain document (get-element-by-id "now-playing")) inner-h-t-m-l) (let ((now-playing-el (ps:chain document (get-element-by-id "now-playing"))))
data))) (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) (catch (lambda (error)
(ps:chain console (log "Could not fetch stream status:" error))))))) (ps:chain console (log "Could not fetch stream status:" error)))))))
@ -553,6 +627,9 @@
(when is-frameset-page (when is-frameset-page
(set-interval update-stream-information 10000))) (set-interval update-stream-information 10000)))
;; Load user's favorites for highlight feature
(load-favorites-cache)
;; Update now playing ;; Update now playing
(update-now-playing) (update-now-playing)
@ -592,6 +669,59 @@
(redirect-when-frame))))) (redirect-when-frame)))))
;; Toggle favorite for current track
(defun toggle-favorite ()
(let ((track-id-el (ps:chain document (get-element-by-id "current-track-id")))
(title-el (ps:chain document (get-element-by-id "current-track-title")))
(btn (ps:chain document (get-element-by-id "favorite-btn"))))
(let ((track-id (when track-id-el (ps:@ track-id-el value)))
(title (when title-el (ps:@ title-el text-content)))
(is-favorited (ps:chain btn class-list (contains "favorited"))))
;; Need either track-id or title
(when (or (and track-id (not (= track-id ""))) title)
(let ((params (+ "title=" (encode-u-r-i-component (or title ""))
(if (and track-id (not (= track-id "")))
(+ "&track-id=" track-id)
""))))
(if is-favorited
;; Remove favorite
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/remove?" params)
(ps:create :method "POST"))
(then (lambda (response)
(cond
((not (ps:@ response ok))
(alert "Please log in to manage favorites")
nil)
(t (ps:chain response (json))))))
(then (lambda (data)
(when (and data (or (= (ps:@ data status) "success")
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (remove "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
;; 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 ;; Update now playing every 5 seconds
(set-interval update-now-playing 5000) (set-interval update-now-playing 5000)
@ -642,7 +772,77 @@
(when (and *popout-window* (ps:@ *popout-window* closed)) (when (and *popout-window* (ps:@ *popout-window* closed))
(update-popout-button nil) (update-popout-button nil)
(setf *popout-window* 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") "Compiled JavaScript for front-page - generated at load time")
(defun generate-front-page-js () (defun generate-front-page-js ()

View File

@ -32,9 +32,20 @@
:day "numeric"))))) :day "numeric")))))
(defun format-relative-time (date-string) (defun format-relative-time (date-string)
(let* ((date (ps:new (-date date-string))) (when (not date-string)
(now (ps:new (-date))) (return-from format-relative-time "Unknown"))
(diff-ms (- now date)) ;; 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-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24)))))
(diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60))))) (diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60)))))
(diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 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-hours " hour" (if (> diff-hours 1) "s" "") " ago"))
((> diff-minutes 0) ((> diff-minutes 0)
(+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago")) (+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago"))
(t "Just now")))) (t "Just now")))))
(defun format-duration (seconds) (defun format-duration (seconds)
(let ((hours (ps:chain -math (floor (/ seconds 3600)))) (let ((hours (ps:chain -math (floor (/ seconds 3600))))
@ -109,37 +120,6 @@
(update-element "session-count" "0") (update-element "session-count" "0")
(update-element "favorite-genre" "Unknown"))))) (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 () (defun load-top-artists ()
(ps:chain (ps:chain
(fetch "/api/asteroid/user/top-artists?limit=5") (fetch "/api/asteroid/user/top-artists?limit=5")
@ -167,6 +147,163 @@
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error loading top artists:" error)))))) (ps:chain console (error "Error loading top artists:" error))))))
(defvar *favorites-offset* 0)
(defun load-favorites ()
(ps:chain
(fetch "/api/asteroid/user/favorites")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(container (ps:chain document (get-element-by-id "favorites-list"))))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data favorites)
(> (ps:@ data favorites length) 0))
(progn
(setf (ps:@ container inner-h-t-m-l) "")
(ps:chain data favorites
(for-each (lambda (fav)
(let ((item (ps:chain document (create-element "div"))))
(setf (ps:@ item class-name) "track-item favorite-item")
(setf (ps:@ item inner-h-t-m-l)
(+ "<div class=\"track-info\">"
"<span class=\"track-title\">" (or (ps:@ fav title) "Unknown") "</span>"
"<span class=\"track-artist\">" (or (ps:@ fav artist) "") "</span>"
"</div>"
"<div class=\"track-meta\">"
"<span class=\"rating\">" (render-stars (or (ps:@ fav rating) 1)) "</span>"
"<button class=\"btn btn-small btn-danger\" onclick=\"removeFavorite('" (ps: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 () (defun load-profile-data ()
(ps:chain console (log "Loading profile data...")) (ps:chain console (log "Loading profile data..."))
@ -187,14 +324,51 @@
(show-error "Error loading profile data")))) (show-error "Error loading profile data"))))
(load-listening-stats) (load-listening-stats)
(load-recent-tracks) (load-favorites)
(load-top-artists)) (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 ;; Action functions
(defun load-more-recent-tracks ()
(ps:chain console (log "Loading more recent tracks..."))
(show-message "Loading more tracks..." "info"))
(defun edit-profile () (defun edit-profile ()
(ps:chain console (log "Edit profile clicked")) (ps:chain console (log "Edit profile clicked"))
(show-message "Profile editing coming soon!" "info")) (show-message "Profile editing coming soon!" "info"))
@ -291,11 +465,402 @@
false)) 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 ;; Initialize on page load
(ps:chain window (ps:chain window
(add-event-listener (add-event-listener
"DOMContentLoaded" "DOMContentLoaded"
load-profile-data)))) (lambda ()
(load-profile-data)
(load-my-playlists))))))
"Compiled JavaScript for profile page - generated at load time") "Compiled JavaScript for profile page - generated at load time")
(defun generate-profile-js () (defun generate-profile-js ()

View File

@ -198,22 +198,144 @@
(config (get-stream-config stream-base-url channel quality))) (config (get-stream-config stream-base-url channel quality)))
(if config (ps:@ config mount) "asteroid.mp3"))) (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) ;; Update mini now playing display (for persistent player frame)
(defun update-mini-now-playing () (defun update-mini-now-playing ()
(let ((mount (get-current-mount))) (let ((mount (get-current-mount)))
(ps:chain (ps:chain
(fetch (+ "/api/asteroid/partial/now-playing-inline?mount=" mount)) (fetch (+ "/api/asteroid/partial/now-playing-json?mount=" mount))
(then (lambda (response) (then (lambda (response)
(if (ps:@ response ok) (if (ps:@ response ok)
(ps:chain response (text)) (ps:chain response (json))
""))) nil)))
(then (lambda (text) (then (lambda (data)
(let ((el (ps:chain document (get-element-by-id "mini-now-playing")))) (when data
(let ((el (ps:chain document (get-element-by-id "mini-now-playing")))
(track-id-el (ps:chain document (get-element-by-id "current-track-id-mini")))
(title (or (ps:@ data data title) (ps:@ data title) "Loading...")))
(when el (when el
(setf (ps:@ el text-content) text))))) ;; 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) (catch (lambda (error)
(ps:chain console (log "Could not fetch now playing:" error))))))) (ps:chain console (log "Could not fetch now playing:" error)))))))
;; Toggle favorite for mini player
(defun toggle-favorite-mini ()
(let ((track-id-el (ps:chain document (get-element-by-id "current-track-id-mini")))
(title-el (ps:chain document (get-element-by-id "mini-now-playing")))
(btn (ps:chain document (get-element-by-id "favorite-btn-mini"))))
(let ((track-id (when track-id-el (ps:@ track-id-el value)))
(title (when title-el (ps:@ title-el text-content)))
(is-favorited (ps:chain btn class-list (contains "favorited"))))
;; Need either track-id or title
(when (or (and track-id (not (= track-id ""))) title)
(let ((params (+ "title=" (encode-u-r-i-component (or title ""))
(if (and track-id (not (= track-id "")))
(+ "&track-id=" track-id)
""))))
(if is-favorited
;; Remove favorite
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/remove?" params)
(ps:create :method "POST"))
(then (lambda (response)
(cond
((not (ps:@ response ok))
(alert "Please log in to manage favorites")
nil)
(t (ps:chain response (json))))))
(then (lambda (data)
(when (and data (or (= (ps:@ data status) "success")
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (remove "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
;; 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) ;; Update popout now playing display (parses artist - title)
(defun update-popout-now-playing () (defun update-popout-now-playing ()
(let ((mount (get-current-mount))) (let ((mount (get-current-mount)))
@ -528,6 +650,9 @@
(defun init-persistent-player () (defun init-persistent-player ()
(let ((audio-element (ps:chain document (get-element-by-id "persistent-audio")))) (let ((audio-element (ps:chain document (get-element-by-id "persistent-audio"))))
(when audio-element (when audio-element
;; Load user's favorites for highlight feature
(load-favorites-cache-mini)
;; Try to enable low-latency mode if supported ;; Try to enable low-latency mode if supported
(when (ps:@ navigator media-session) (when (ps:@ navigator media-session)
(setf (ps:@ navigator media-session metadata) (setf (ps:@ navigator media-session metadata)
@ -641,6 +766,7 @@
(setf (ps:@ window init-popout-player) init-popout-player) (setf (ps:@ window init-popout-player) init-popout-player)
(setf (ps:@ window update-mini-now-playing) update-mini-now-playing) (setf (ps:@ window update-mini-now-playing) update-mini-now-playing)
(setf (ps:@ window update-popout-now-playing) update-popout-now-playing) (setf (ps:@ window update-popout-now-playing) update-popout-now-playing)
(setf (ps:@ window toggle-favorite-mini) toggle-favorite-mini)
;; Auto-initialize on DOMContentLoaded based on which elements exist ;; Auto-initialize on DOMContentLoaded based on which elements exist
(ps:chain document (ps:chain document

View File

@ -163,11 +163,19 @@
(sort (copy-list *playlist-schedule*) #'< :key #'car)) (sort (copy-list *playlist-schedule*) #'< :key #'car))
(defun get-available-playlists () (defun get-available-playlists ()
"Get list of available playlist files from the playlists directory." "Get list of available playlist files from the playlists directory and user-submissions."
(let ((playlists-dir (get-playlists-directory))) (let ((playlists-dir (get-playlists-directory))
(submissions-dir (merge-pathnames "user-submissions/" (get-playlists-directory))))
(append
;; Main playlists directory
(when (probe-file playlists-dir) (when (probe-file playlists-dir)
(mapcar #'file-namestring (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 () (defun get-server-time-info ()
"Get current server time information in both UTC and local timezone." "Get current server time information in both UTC and local timezone."

View File

@ -1,144 +1,207 @@
#EXTM3U #EXTM3U
#PLAYLIST:Escape Velocity - A Christmas Journey Through Space #PLAYLIST:Morning Drift
#PHASE:Escape Velocity #PHASE:Morning Drift
#DURATION:12 hours (approx) #DURATION:6 hours (approx)
#CURATOR:Asteroid Radio #CURATOR:Asteroid Radio
#DESCRIPTION:A festive 12-hour voyage blending Christmas classics with ambient, IDM, and space music for the holiday season #DESCRIPTION:Lighter, awakening ambient for the morning hours (06:00-12:00)
# === PHASE 1: WINTER AWAKENING (Ambient Beginnings) === #EXTINF:-1,Brian Eno - Emerald And Lime
#EXTINF:-1,Brian Eno - Snow /app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A1 Emerald And Lime.flac
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac #EXTINF:-1,Tycho - Glider
#EXTINF:-1,Brian Eno - Wintergreen /app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/01 - Glider.flac
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/04 Wintergreen.flac
#EXTINF:-1,Proem - Winter Wolves
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
#EXTINF:-1,Tim Hecker - Winter's Coming
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 10 Winter's Coming.flac
#EXTINF:-1,Biosphere - Drifter #EXTINF:-1,Biosphere - Drifter
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac /app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac
#EXTINF:-1,Dead Voices On Air - On Winters Gibbet #EXTINF:-1,Four Tet - Alap
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/02 - On Winters Gibbet.flac /app/music/Four Tet - New Energy {CD} [FLAC] (2017)/01 Alap.flac
#EXTINF:-1,Color Therapy - Wintering #EXTINF:-1,Johann Johannsson - Cambridge, 1963
/app/music/Color Therapy - Mr. Wolf Is Dead (2015) WEB FLAC/12 - Wintering.flac /app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/01 - Cambridge, 1963.flac
#EXTINF:-1,Ulrich Schnauss - Negative Sunrise (2019 Version)
# === PHASE 2: CHRISTMAS ARRIVAL (TSO Introduction) === /app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/09. Negative Sunrise (2019 Version).flac
#EXTINF:-1,Trans-Siberian Orchestra - The Ghosts Of Christmas Eve #EXTINF:-1,Kiasmos - Lit
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/01 The Ghosts Of Christmas Eve.flac /app/music/Kiasmos/2014 - Kiasmos/01 - Lit.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas In The Air #EXTINF:-1,FSOL - Mountain Path
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/14 Christmas In The Air.flac /app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/02 - Mountain Path.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Canon #EXTINF:-1,Brian Eno - Garden of Stars
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/08 Christmas Canon.flac /app/music/Brian Eno/2022 - ForeverAndEverNoMore/04 Garden of Stars.flac
#EXTINF:-1,Trans-Siberian Orchestra - Appalachian Snowfall #EXTINF:-1,Clark - Kiri's Glee
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/11 Appalachian Snowfall.flac /app/music/Clark - Kiri Variations (2019) [WEB FLAC]/04 - Kiri's Glee.flac
#EXTINF:-1,Trans-Siberian Orchestra - The Snow Came Down #EXTINF:-1,Tycho - Source
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/13 The Snow Came Down.flac /app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/07 - Source.flac
#EXTINF:-1,Biosphere - Out Of The Cradle
# === PHASE 3: AMBIENT INTERLUDE (Space & Atmosphere) === /app/music/Biosphere - Departed Glories (2016) - FLAC WEB/01 - Out Of The Cradle.flac
#EXTINF:-1,Biosphere - 10 Snurp 1937 #EXTINF:-1,Tangerine Dream - Token from Birdland
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 10 Snurp 1937.flac /app/music/Tangerine Dream - Ambient Monkeys (flac)/01 Tangerine Dream - Token from Birdland.flac
#EXTINF:-1,Biosphere - 05 Fluvialmorphologie #EXTINF:-1,Four Tet - Scientists
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 05 Fluvialmorphologie.flac /app/music/Four Tet - New Energy {CD} [FLAC] (2017)/06 Scientists.flac
#EXTINF:-1,God is an Astronaut - Winter Dusk-Awakening #EXTINF:-1,Ulrich Schnauss & Jonas Munk - Solitary Falling
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/03. Winter Dusk-Awakening.flac /app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/03. Solitary Falling.flac
#EXTINF:-1,Proem - Snow Drifts #EXTINF:-1,Proem - Modern Rope
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/11 - Snow Drifts.flac /app/music/Proem - 2018 Modern Rope (WEB)/05. Modern Rope.flac
#EXTINF:-1,Proem - Stick to Music Snowflake #EXTINF:-1,Johann Johannsson - Rowing
/app/music/Proem - Until Here for Years (n5md, 2019) flac/04 - Stick to Music Snowflake.flac /app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/02 - Rowing.flac
#EXTINF:-1,Four Tet - 04 Tremper #EXTINF:-1,Kiasmos - Held
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/04 Tremper.flac /app/music/Kiasmos/2014 - Kiasmos/02 - Held.flac
#EXTINF:-1,Brian Eno - Complex Heaven
# === PHASE 4: CHRISTMAS EVE STORIES === /app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A2 Complex Heaven.flac
#EXTINF:-1,Trans-Siberian Orchestra - First Snow (Instrumental) #EXTINF:-1,Biosphere - Skålbrekka
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/04 First Snow (Instrumental).flac /app/music/Biosphere - The Senja Recordings (2019) [FLAC]/01 - Skålbrekka.flac
#EXTINF:-1,Trans-Siberian Orchestra - The Silent Nutcracker (Instrumental) #EXTINF:-1,FSOL - Thought Pattern
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/05 The Silent Nutcracker (Instrumental).flac /app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/03 - Thought Pattern.flac
#EXTINF:-1,Trans-Siberian Orchestra - A Mad Russian's Christmas (Instrumental) #EXTINF:-1,Tycho - Into The Woods
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/06 A Mad Russian's Christmas (Instrumental).flac /app/music/Tycho - Simulcast (2020) [WEB FLAC]/04 - Into The Woods.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Eve,Sarajevo 12,24 (Instrumental) #EXTINF:-1,arovane - hymn
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/08 Christmas Eve,Sarajevo 12,24 (Instrumental).flac /app/music/arovane - Wirkung (2020) [WEB FLAC16]/12. arovane - hymn.flac
#EXTINF:-1,Trans-Siberian Orchestra - This Christmas Day #EXTINF:-1,Four Tet - Green
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/14 This Christmas Day.flac /app/music/Four Tet - Sixteen Oceans (2020) {Text Records - TEXT051} [CD FLAC]/12 - Four Tet - Green.flac
#EXTINF:-1,Clark - Primary Pluck
# === PHASE 5: ELECTRONIC DREAMS (IDM & Ambient) === /app/music/Clark - Kiri Variations (2019) [WEB FLAC]/08 - Primary Pluck.flac
#EXTINF:-1,Autechre - NTS Session 1-005-Autechre-carefree counter dronal #EXTINF:-1,Biosphere - Strandby
/app/music/Autechre - 2018 - NTS Session 1/NTS Session 1-005-Autechre-carefree counter dronal.flac /app/music/Biosphere - The Senja Recordings (2019) [FLAC]/02 - Strandby.flac
#EXTINF:-1,Clark - Living Fantasy #EXTINF:-1,Johann Johannsson - The Dreams That Stuff Is Made Of
/app/music/Clark - Death Peak (2017) [FLAC]/08 - Living Fantasy.flac /app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/11 - The Dreams That Stuff Is Made Of.flac
#EXTINF:-1,Clark - My Machines (Clark Remix) #EXTINF:-1,Ulrich Schnauss & Jonas Munk - Polychrome
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.17. Battles - My Machines (Clark Remix).flac /app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/08. Polychrome.flac
#EXTINF:-1,Plaid - Dancers #EXTINF:-1,Kiasmos - Swayed
/app/music/Plaid - Polymer (2019) [WEB FLAC]/07 - Dancers.flac /app/music/Kiasmos/2014 - Kiasmos/04 - Swayed.flac
#EXTINF:-1,Faux Tales - Avalon #EXTINF:-1,Brian Eno - These Small Noises
/app/music/Faux Tales - 2015 - Kairos [FLAC] {Kensai Records KNS006 WEB}/3 - Avalon.flac /app/music/Brian Eno/2022 - ForeverAndEverNoMore/09 These Small Noises.flac
#EXTINF:-1,Color Therapy - Expect Delays (feat. Ulrich Schnauss) #EXTINF:-1,Tycho - Ascension
/app/music/Color Therapy - Mr. Wolf Is Dead (2015) WEB FLAC/11 - Expect Delays (feat. Ulrich Schnauss).flac /app/music/Thievery Corporation and Tycho - Fragments Ascension EP (flac)/3. Tycho - Ascension.flac
#EXTINF:-1,FSOL - Imagined Friends
# === PHASE 6: THE LOST CHRISTMAS EVE === /app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/15 - Imagined Friends.flac
#EXTINF:-1,Trans-Siberian Orchestra - The Lost Christmas Eve #EXTINF:-1,Biosphere - Lysbotn
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/02 The Lost Christmas Eve.flac /app/music/Biosphere - The Senja Recordings (2019) [FLAC]/11 - Lysbotn.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Dreams #EXTINF:-1,Boards of Canada - In a Beautiful Place Out in the Country
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/03 Christmas Dreams.flac /app/music/Boards of Canada/In a Beautiful Place Out in the Country/01 Kid for Today.flac
#EXTINF:-1,Trans-Siberian Orchestra - Wizards in Winter #EXTINF:-1,Brian Eno - Making Gardens Out of Silence
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/04 Wizards in Winter.flac /app/music/Brian Eno/2022 - ForeverAndEverNoMore/10 Making Gardens Out of Silence.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Concerto #EXTINF:-1,Tangerine Dream - Virtue Is Its Own Reward
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/07 Christmas Concerto.flac /app/music/Tangerine Dream - Ambient Monkeys (flac)/10 Tangerine Dream - Virtue Is Its Own Reward.flac
#EXTINF:-1,Trans-Siberian Orchestra - Queen Of The Winter Night #EXTINF:-1,Brian Eno - Small Craft On A Milk Sea
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/08 Queen Of The Winter Night.flac /app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A3 Small Craft On A Milk Sea.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Nights In Blue #EXTINF:-1,Tycho - Horizon
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/09 Christmas Nights In Blue.flac /app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/02 - Horizon.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Jazz #EXTINF:-1,Four Tet - Two Thousand And Seventeen
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/10 Christmas Jazz.flac /app/music/Four Tet - New Energy {CD} [FLAC] (2017)/02 Two Thousand And Seventeen.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Jam #EXTINF:-1,Boards of Canada - Dayvan Cowboy
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/11 Christmas Jam.flac /app/music/Boards of Canada/Trans Canada Highway/01 - Dayvan Cowboy.mp3
#EXTINF:-1,Ulrich Schnauss - Melts into Air (2019 Version)
# === PHASE 7: CLASSICAL WINTER (Nutcracker & More) === /app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/01. Melts into Air (2019 Version).flac
#EXTINF:-1,Various Artists - Dance of the Sugar-Plum Fairy #EXTINF:-1,arovane - olopp_eleen
/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 /app/music/arovane - Wirkung (2020) [WEB FLAC16]/01. arovane - olopp_eleen.flac
#EXTINF:-1,Quaeschning and Ulrich Schnauss - Thirst #EXTINF:-1,Biosphere - Bergsbotn
/app/music/Quaeschning and Ulrich Schnauss - Synthwaves (2017) {vista003, GER, CD} [FLAC]/06 - Thirst.flac /app/music/Biosphere - The Senja Recordings (2019) [FLAC]/03 - Bergsbotn.flac
#EXTINF:-1,Proem - 04. Drawing Room Anguish #EXTINF:-1,F.S.Blumm & Nils Frahm - Perff
/app/music/Proem - 2018 Modern Rope (WEB)/04. Drawing Room Anguish.flac /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,Dead Voices On Air - 07. Dogger Doorlopende Split #EXTINF:-1,Clark - Simple Homecoming Loop
/app/music/Dead Voices On Air - Frankie Pett En De Onderzeer Boten (2017) web/07. Dogger Doorlopende Split.flac /app/music/Clark - Kiri Variations (2019) [WEB FLAC]/02 - Simple Homecoming Loop.flac
#EXTINF:-1,Tycho - Slack
# === PHASE 8: WISDOM & REFLECTION === /app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/03 - Slack.flac
#EXTINF:-1,Trans-Siberian Orchestra - What Is Christmas #EXTINF:-1,Johann Johannsson - A Game Of Croquet
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/13 What Is Christmas.flac /app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/07 - A Game Of Croquet.flac
#EXTINF:-1,Trans-Siberian Orchestra - The Wisdom Of Snow #EXTINF:-1,FSOL - Motioned
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/15 The Wisdom Of Snow.flac /app/music/The Future Sound of London - Environment Six (2016 - WEB - FLAC)/04 - Motioned.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Bells, Carousels & Time #EXTINF:-1,Brian Eno - Flint March
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/18 Christmas Bells, Carousels & Time.flac /app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A4 Flint March.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Canon Rock #EXTINF:-1,Four Tet - Lush
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/21 Christmas Canon Rock.flac /app/music/Four Tet - New Energy {CD} [FLAC] (2017)/05 Lush.flac
#EXTINF:-1,Trans-Siberian Orchestra - Midnight Christmas Eve #EXTINF:-1,Boards of Canada - Turquoise Hexagon Sun
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/05 Midnight Christmas Eve.flac /app/music/Boards of Canada/Hi Scores/02 - Turquoise Hexagon Sun.mp3
#EXTINF:-1,Trans-Siberian Orchestra - Dream Child (A Christmas Dream) #EXTINF:-1,Ulrich Schnauss - Love Grows Out of Thin Air (2019 Version)
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/15 Dream Child (A Christmas Dream).flac /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
# === PHASE 9: DEEP SPACE JOURNEY (Extended Ambient) === /app/music/arovane - Wirkung (2020) [WEB FLAC16]/02. arovane - wirkung.flac
#EXTINF:-1,Dead Voices On Air - Red Howls #EXTINF:-1,Biosphere - Berg
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/01 - Red Howls.flac /app/music/Biosphere - The Senja Recordings (2019) [FLAC]/05 - Berg.flac
#EXTINF:-1,Cut Copy - Airborne #EXTINF:-1,God is an Astronaut - Komorebi
/app/music/Cut Copy - Haiku From Zero (2017) [FLAC] {2557864014}/05 - Airborne.flac /app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/05. Komorebi.flac
#EXTINF:-1,Owl City - 01 Hot Air Balloon #EXTINF:-1,Tycho - Weather
/app/music/Owl City - Ocean Eyes (Deluxe Edition) [Flac,Cue,Logs]/Disc 2/01 Hot Air Balloon.flac /app/music/Tycho - Simulcast (2020) [WEB FLAC]/01 - Weather.flac
#EXTINF:-1,VA - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit] #EXTINF:-1,Port Blue - Sunset Cruiser
/app/music/VA - Melodic Vocal Trance 2017/24. Airborn, Bogdan Vix & KeyPlayer - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit].flac /app/music/Port Blue - The Airship (2007)/04. Sunset Cruiser.flac
#EXTINF:-1,VA - Winter Took Over (Radio Edit) #EXTINF:-1,F.S.Blumm & Nils Frahm - Exercising Levitation
/app/music/VA - Melodic Vocal Trance 2017/22. Bluskay, KeyPlayer & Esmee Bor Stotijn - Winter Took Over (Radio Edit).flac /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,Alison Krauss and Union Station - My Opening Farewell #EXTINF:-1,Four Tet - You Are Loved
/app/music/Alison Krauss and Union Station - Paper Airplane (flac)/11 - Alison Krauss & Union Station - My Opening Farewell.flac /app/music/Four Tet - New Energy {CD} [FLAC] (2017)/08 You Are Loved.flac
#EXTINF:-1,Bedouin Soundclash - Money Worries (E-Clair Refix) #EXTINF:-1,Ulrich Schnauss & Jonas Munk - Asteroid 2467
/app/music/Bedouin Soundclash - Sounding a Mosaic (2004) [FLAC] {SD1267}/14 - Money Worries (E-Clair Refix).flac /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
# === PHASE 10: RETURN TO WINTER (Closing Circle) === /app/music/Boards of Canada/Trans Canada Highway/02 - Left Side Drive.mp3
#EXTINF:-1,Brian Eno - Snow #EXTINF:-1,Brian Eno - Who Gives a Thought
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac /app/music/Brian Eno/2022 - ForeverAndEverNoMore/01 Who Gives a Thought.flac
#EXTINF:-1,Proem - Winter Wolves #EXTINF:-1,arovane - find
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac /app/music/arovane - Wirkung (2020) [WEB FLAC16]/03. arovane - find.flac
#EXTINF:-1,God is an Astronaut - Winter Dusk-Awakening #EXTINF:-1,Tycho - Receiver
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/03. Winter Dusk-Awakening.flac /app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/04 - Receiver.flac
#EXTINF:-1,Trans-Siberian Orchestra - The Snow Came Down #EXTINF:-1,Johann Johannsson - The Origins Of Time
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/13 The Snow Came Down.flac /app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/08 - The Origins Of Time.flac
#EXTINF:-1,Trans-Siberian Orchestra - Christmas In The Air #EXTINF:-1,FSOL - Lichaen
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/14 Christmas In The Air.flac /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

View File

@ -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

View File

@ -1546,3 +1546,795 @@ body.popout-body .status-mini{
opacity: 1; opacity: 1;
} }
} }
.site-footer{
text-align: center;
padding: 20px 0;
margin-top: 30px;
border-top: 1px solid #333;
font-size: 0.85em;
color: #666;
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.site-footer a{
color: #888;
text-decoration: none;
-moz-transition: color 0.2s ease;
-o-transition: color 0.2s ease;
-webkit-transition: color 0.2s ease;
-ms-transition: color 0.2s ease;
transition: color 0.2s ease;
}
.site-footer a:hover{
color: #00ff00;
}
.site-footer .craftering a{
margin: 0 5px;
}
.now-playing-track{
display: flex;
align-items: center;
gap: 10px;
}
.now-playing-track p{
margin: 0;
}
.btn-favorite{
background: transparent;
border: none;
cursor: pointer;
padding: 5px 10px;
font-size: 1.4em;
-moz-transition: transform 0.2s ease, color 0.2s ease;
-o-transition: transform 0.2s ease, color 0.2s ease;
-webkit-transition: transform 0.2s ease, color 0.2s ease;
-ms-transition: transform 0.2s ease, color 0.2s ease;
transition: transform 0.2s ease, color 0.2s ease;
}
.btn-favorite .star-icon{
color: #888;
}
.btn-favorite:hover{
-moz-transform: scale(1.2);
-o-transform: scale(1.2);
-webkit-transform: scale(1.2);
-ms-transform: scale(1.2);
transform: scale(1.2);
}
.btn-favorite:hover .star-icon{
color: #ffcc00;
}
.btn-favorite.favorited .star-icon{
color: #ffcc00;
}
.btn-favorite-mini{
background: transparent;
border: none;
cursor: pointer;
padding: 2px 8px;
font-size: 1.3em;
-moz-transition: transform 0.2s ease, color 0.2s ease;
-o-transition: transform 0.2s ease, color 0.2s ease;
-webkit-transition: transform 0.2s ease, color 0.2s ease;
-ms-transition: transform 0.2s ease, color 0.2s ease;
transition: transform 0.2s ease, color 0.2s ease;
margin-left: 8px;
}
.btn-favorite-mini .star-icon{
color: #00cc00;
}
.btn-favorite-mini:hover{
-moz-transform: scale(1.3);
-o-transform: scale(1.3);
-webkit-transform: scale(1.3);
-ms-transform: scale(1.3);
transform: scale(1.3);
}
.btn-favorite-mini:hover .star-icon{
color: #ffcc00;
}
.btn-favorite-mini.favorited .star-icon{
color: #ffcc00;
}
.favorites-list{
margin: 10px 0;
}
.favorites-list .favorite-item{
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
margin: 5px 0;
background: rgba(0, 255, 0, 0.05);
border: 1px solid #333;
border-radius: 4px;
-moz-transition: background 0.2s ease;
-o-transition: background 0.2s ease;
-webkit-transition: background 0.2s ease;
-ms-transition: background 0.2s ease;
transition: background 0.2s ease;
}
.favorites-list .favorite-item:hover{
background: rgba(0, 255, 0, 0.1);
}
.favorites-list .rating{
color: #ffcc00;
font-size: 1.1em;
margin-right: 10px;
}
.favorites-list .no-data{
color: #666;
font-style: italic;
text-align: center;
padding: 20px;
}
.favorites-list .btn-small{
padding: 4px 8px;
font-size: 0.8em;
}
.profile-header{
display: flex;
gap: 30px;
align-items: flex-start;
}
.avatar-section{
flex-shrink: 0;
}
.avatar-container{
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
overflow: hidden;
border: 3px solid #00cc00;
cursor: pointer;
background: #1a1a1a;
}
.avatar-image{
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-overlay{
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
color: #00cc00;
text-align: center;
padding: 8px;
font-size: 0.8em;
opacity: 0;
-moz-transition: opacity 0.2s ease;
-o-transition: opacity 0.2s ease;
-webkit-transition: opacity 0.2s ease;
-ms-transition: opacity 0.2s ease;
transition: opacity 0.2s ease;
}
.avatar-container:hover .avatar-overlay{
opacity: 1;
}
.request-panel{
background: rgba(0, 255, 0, 0.05);
border: 1px solid #333;
border-radius: 8px;
padding: 20px;
margin-top: 20px;
}
.request-description{
color: #888;
margin-bottom: 15px;
}
.request-form{
display: flex;
flex-direction: column;
gap: 10px;
}
.request-input{
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
padding: 10px;
color: #00cc00;
font-size: 1em;
}
.request-input:focus{
border-color: #00cc00;
outline: none;
}
.request-status{
padding: 10px;
border-radius: 4px;
margin-top: 10px;
text-align: center;
}
.request-status.success{
background: rgba(0, 255, 0, 0.2);
color: #00ff00;
}
.request-status.error{
background: rgba(255, 0, 0, 0.2);
color: #ff6b6b;
}
.request-status.info{
background: rgba(0, 150, 255, 0.2);
color: #66b3ff;
}
.recent-requests{
margin-top: 20px;
border-top: 1px solid #333;
padding-top: 15px;
}
.recent-requests h4{
color: #888;
margin-bottom: 10px;
}
.request-item{
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #222;
}
.request-title{
color: #00cc00;
}
.request-by{
color: #666;
font-size: 0.9em;
}
.no-requests{
color: #666;
font-style: italic;
}
.request-item-admin{
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 15px;
margin-bottom: 10px;
background: rgba(0, 255, 0, 0.05);
border: 1px solid #333;
border-radius: 8px;
}
.request-item-admin .request-info{
flex: 1;
}
.request-item-admin .request-info strong{
color: #00cc00;
font-size: 1.1em;
display: block;
margin-bottom: 5px;
}
.request-item-admin .request-info .request-user{
color: #888;
font-size: 0.9em;
display: block;
margin-bottom: 5px;
}
.request-item-admin .request-info .request-message{
color: #aaa;
font-style: italic;
margin: 8px 0;
}
.request-item-admin .request-info .request-time{
color: #666;
font-size: 0.8em;
}
.request-item-admin .request-actions{
display: flex;
gap: 8px;
margin-left: 15px;
}
.my-request-item{
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid #333;
border-radius: 6px;
}
.my-request-item .request-title{
color: #ddd;
flex: 1;
}
.my-request-item .request-status{
margin-left: 15px;
}
.status-badge{
padding: 4px 10px;
border-radius: 12px;
font-size: 0.85em;
text-transform: capitalize;
}
.favorite-count{
color: #ff6699;
font-size: 0.9em;
margin: 5px 0;
}
.favorite-count-mini{
color: #ff6699;
font-size: 0.85em;
margin-left: 8px;
}
.status-pending{
background: rgba(255, 200, 0, 0.2);
color: #ffcc00;
}
.status-approved{
background: rgba(0, 255, 0, 0.2);
color: #00ff00;
}
.status-rejected{
background: rgba(255, 0, 0, 0.2);
color: #ff6666;
}
.status-played{
background: rgba(0, 200, 255, 0.2);
color: #00ccff;
}
.btn-tab{
background: transparent;
border: 1px solid #444;
color: #888;
padding: 8px 16px;
margin-right: 5px;
cursor: pointer;
-moz-transition: all 0.2s;
-o-transition: all 0.2s;
-webkit-transition: all 0.2s;
-ms-transition: all 0.2s;
transition: all 0.2s;
}
.btn-tab:hover{
border-color: #00cc00;
color: #00cc00;
}
.btn-tab.active{
background: rgba(0, 255, 0, 0.1);
border-color: #00cc00;
color: #00cc00;
}
.activity-chart{
padding: 15px;
}
.activity-chart .chart-container{
min-height: 120px;
display: flex;
align-items: flex-end;
justify-content: center;
}
.activity-chart .chart-bars{
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
height: 100px;
width: 100%;
max-width: 600px;
}
.activity-chart .chart-bar-wrapper{
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
max-width: 20px;
}
.activity-chart .chart-bar{
width: 100%;
min-height: 2px;
background: linear-gradient(to top, #006600, #00cc00);
border-radius: 2px 2px 0 0;
-moz-transition: height 0.3s ease;
-o-transition: height 0.3s ease;
-webkit-transition: height 0.3s ease;
-ms-transition: height 0.3s ease;
transition: height 0.3s ease;
}
.activity-chart .chart-bar:hover{
background: linear-gradient(to top, #009900, #00ff00);
}
.activity-chart .chart-day{
font-size: 0.6em;
color: #666;
margin-top: 4px;
}
.activity-chart .chart-note{
text-align: center;
color: #888;
font-size: 0.9em;
margin-top: 10px;
}
.activity-chart .loading-message{
color: #666;
font-style: italic;
text-align: center;
}
.activity-chart .no-data{
color: #666;
font-style: italic;
text-align: center;
padding: 20px;
}
.section-description{
color: #888;
margin-bottom: 15px;
font-size: 0.9em;
}
.playlist-actions{
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.playlists-list{
display: flex;
flex-direction: column;
gap: 10px;
}
.playlist-item{
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid #333;
border-radius: 4px;
}
.playlist-info{
display: flex;
flex-direction: column;
gap: 4px;
}
.playlist-name{
font-weight: bold;
color: #00cc00;
}
.playlist-meta{
font-size: 0.85em;
color: #888;
}
.playlist-actions{
display: flex;
align-items: center;
gap: 10px;
}
.modal{
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content{
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
padding: 25px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
}
.modal-large{
max-width: 800px;
}
.modal-close{
position: absolute;
top: 10px;
right: 15px;
font-size: 24px;
color: #888;
cursor: pointer;
-moz-transition: color 0.2s;
-o-transition: color 0.2s;
-webkit-transition: color 0.2s;
-ms-transition: color 0.2s;
transition: color 0.2s;
}
.modal-close:hover{
color: #00cc00;
}
.library-controls{
display: flex;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.library-controls input{
flex: 1;
min-width: 200px;
padding: 8px 12px;
background: #222;
border: 1px solid #444;
color: #fff;
border-radius: 4px;
}
.library-controls select{
padding: 8px 12px;
background: #222;
border: 1px solid #444;
color: #fff;
border-radius: 4px;
}
.library-tracks-list{
max-height: 400px;
overflow-y: auto;
margin-bottom: 15px;
}
.library-track-item{
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #333;
}
.library-track-item:hover{
background: rgba(0, 255, 0, 0.05);
}
.library-track-item .track-info{
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.library-track-item .track-title{
color: #fff;
font-weight: bold;
}
.library-track-item .track-artist{
color: #00cc00;
font-size: 0.9em;
}
.library-track-item .track-album{
color: #666;
font-size: 0.85em;
}
.library-pagination{
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
}
.playlist-edit-header{
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.playlist-edit-header input{
padding: 10px;
background: #222;
border: 1px solid #444;
color: #fff;
border-radius: 4px;
font-size: 1.1em;
}
.playlist-edit-header textarea{
padding: 10px;
background: #222;
border: 1px solid #444;
color: #fff;
border-radius: 4px;
resize: vertical;
}
.playlist-tracks-container{
margin-bottom: 20px;
}
.playlist-tracks-sortable{
max-height: 300px;
overflow-y: auto;
border: 1px solid #333;
border-radius: 4px;
padding: 10px;
}
.playlist-track-item{
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
margin-bottom: 5px;
}
.playlist-track-item .track-number{
color: #666;
min-width: 25px;
}
.playlist-track-item .track-title{
flex: 1;
color: #fff;
}
.playlist-track-item .track-artist{
color: #00cc00;
font-size: 0.9em;
}
.playlist-track-item .track-controls{
display: flex;
gap: 5px;
}
.btn-tiny{
padding: 2px 6px;
font-size: 0.8em;
background: transparent;
border: 1px solid #444;
color: #888;
cursor: pointer;
border-radius: 3px;
}
.btn-tiny:hover{
border-color: #00cc00;
color: #00cc00;
}
.btn-tiny:disabled{
opacity: 0.3;
cursor: not-allowed;
}
.btn-danger{
border-color: #cc0000;
color: #cc0000;
}
.btn-danger:hover{
border-color: #ff0000;
color: #ff0000;
background: rgba(255, 0, 0, 0.1);
}
.playlist-edit-actions{
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.empty-message{
color: #666;
font-style: italic;
text-align: center;
padding: 20px;
}
.status-draft{
border-left: 3px solid #888;
}
.status-pending{
border-left: 3px solid #ffcc00;
}
.status-approved{
border-left: 3px solid #00cc00;
}
.status-rejected{
border-left: 3px solid #cc0000;
}

View File

@ -1274,4 +1274,624 @@
(.craftering (.craftering
(a :margin "0 5px"))) (a :margin "0 5px")))
;; Now playing favorite button
(.now-playing-track
:display "flex"
:align-items "center"
:gap "10px"
(p :margin 0))
(.btn-favorite
:background "transparent"
:border "none"
:cursor "pointer"
:padding "5px 10px"
:font-size "1.4em"
:transition "transform 0.2s ease, color 0.2s ease"
(.star-icon
:color "#888"))
((:and .btn-favorite :hover)
:transform "scale(1.2)"
(.star-icon
:color "#ffcc00"))
((:and .btn-favorite .favorited)
(.star-icon
:color "#ffcc00"))
;; Mini favorite button for frame player
(.btn-favorite-mini
:background "transparent"
:border "none"
:cursor "pointer"
:padding "2px 8px"
:font-size "1.3em"
:transition "transform 0.2s ease, color 0.2s ease"
:margin-left "8px"
(.star-icon
:color "#00cc00"))
((:and .btn-favorite-mini :hover)
:transform "scale(1.3)"
(.star-icon
:color "#ffcc00"))
((:and .btn-favorite-mini .favorited)
(.star-icon
:color "#ffcc00"))
;; Favorites list styling
(.favorites-list
:margin "10px 0"
(.favorite-item
:display "flex"
:justify-content "space-between"
:align-items "center"
:padding "10px 15px"
:margin "5px 0"
:background "rgba(0, 255, 0, 0.05)"
:border "1px solid #333"
:border-radius "4px"
:transition "background 0.2s ease")
((:and .favorite-item :hover)
:background "rgba(0, 255, 0, 0.1)")
(.rating
:color "#ffcc00"
:font-size "1.1em"
:margin-right "10px")
(.no-data
:color "#666"
:font-style "italic"
:text-align "center"
:padding "20px")
(.btn-small
:padding "4px 8px"
:font-size "0.8em"))
;; 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 ) ;; End of let block

0
static/avatars/.gitkeep Normal file
View File

BIN
static/avatars/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
static/avatars/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -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

View File

@ -102,6 +102,22 @@
</div> </div>
</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 --> <!-- Music Library Management -->
<div class="admin-section"> <div class="admin-section">
<h2>Music Library Management</h2> <h2>Music Library Management</h2>
@ -346,6 +362,15 @@
</p> </p>
</div> </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 --> <!-- User Management -->
<div class="admin-section"> <div class="admin-section">
<div class="card"> <div class="card">

View File

@ -36,6 +36,12 @@
</audio> </audio>
<span class="now-playing-mini" id="mini-now-playing">Loading...</span> <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"> <button id="reconnect-btn" onclick="reconnectStream()" class="persistent-reconnect-btn" title="Reconnect if audio stops working">
<img src="/asteroid/static/icons/sync.png" alt="Reconnect" style="width: 18px; height: 18px; vertical-align: middle; filter: invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%);"> <img src="/asteroid/static/icons/sync.png" alt="Reconnect" style="width: 18px; height: 18px; vertical-align: middle; filter: invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%);">

View File

@ -92,6 +92,24 @@
<p class="loading">Loading...</p> <p class="loading">Loading...</p>
</div> </div>
</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> </main>
<footer class="site-footer"> <footer class="site-footer">

View File

@ -125,6 +125,26 @@
<p class="loading">Loading...</p> <p class="loading">Loading...</p>
</div> </div>
</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> </main>
<footer class="site-footer"> <footer class="site-footer">

View File

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

View File

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

246
track-requests.lisp Normal file
View File

@ -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)))))

View File

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

421
user-playlists.lisp Normal file
View File

@ -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))))))

361
user-profile.lisp Normal file
View File

@ -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))))))