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
/static/asteroid.css
stream-queue.m3u
.jj/

View File

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

View File

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

View File

@ -1158,28 +1158,45 @@
(define-api asteroid/user/listening-stats () ()
"Get user listening statistics"
(require-authentication)
(let* ((current-user (get-current-user))
(user-id (when current-user (dm:id current-user)))
(stats (if user-id
(get-user-listening-stats user-id)
(list :total-listen-time 0 :session-count 0 :tracks-played 0))))
(api-output `(("status" . "success")
("stats" . (("total_listen_time" . ,(getf stats :total-listen-time 0))
("tracks_played" . ,(getf stats :tracks-played 0))
("session_count" . ,(getf stats :session-count 0))
("favorite_genre" . "Unknown")))))))
(with-error-handling
(let* ((user-id (session:field "user-id"))
(stats (get-listening-stats user-id)))
(api-output `(("status" . "success")
("stats" . (("total_listen_time" . ,(getf stats :total-listen-time 0))
("tracks_played" . ,(getf stats :tracks-played 0))
("session_count" . 0)
("favorite_genre" . "Ambient"))))))))
(define-api asteroid/user/recent-tracks (&optional (limit "3")) ()
(define-api asteroid/user/recent-tracks (&optional (limit "3") (offset "0")) ()
"Get recently played tracks for user"
(require-authentication)
(api-output `(("status" . "success")
("tracks" . ()))))
(with-error-handling
(let* ((user-id (session:field "user-id"))
(limit-int (or (parse-integer limit :junk-allowed t) 3))
(offset-int (or (parse-integer offset :junk-allowed t) 0))
(history (get-listening-history user-id :limit limit-int :offset offset-int)))
(api-output `(("status" . "success")
("tracks" . ,(mapcar (lambda (h)
`(("title" . ,(or (cdr (assoc :track-title h))
(cdr (assoc :track_title h))))
("artist" . "")
("played_at" . ,(cdr (assoc :listened-at h)))
("duration" . ,(or (cdr (assoc :listen-duration h)) 0))))
history)))))))
(define-api asteroid/user/top-artists (&optional (limit "5")) ()
"Get top artists for user"
(require-authentication)
(api-output `(("status" . "success")
("artists" . ()))))
(with-error-handling
(let* ((user-id (session:field "user-id"))
(limit-int (parse-integer limit :junk-allowed t))
(artists (get-top-artists user-id :limit (or limit-int 5))))
(api-output `(("status" . "success")
("artists" . ,(mapcar (lambda (a)
`(("name" . ,(or (cdr (assoc :artist a)) "Unknown"))
("play_count" . ,(or (cdr (assoc :play-count a))
(cdr (assoc :play_count a)) 0))))
artists)))))))
;; Register page (GET)
(define-page register #@"/register" ()

View File

@ -51,6 +51,31 @@
(position :integer)
(added_date :integer))))
(unless (db:collection-exists-p "user_favorites")
(db:create "user_favorites" '((user-id :integer)
(track-id :integer)
(rating :integer)
(created-date :integer))))
(unless (db:collection-exists-p "listening_history")
(db:create "listening_history" '((user-id :integer)
(track-id :integer)
(listened-at :integer)
(listen-duration :integer)
(completed :integer))))
(unless (db:collection-exists-p "user_playlists")
(db:create "user_playlists" '((user-id :integer)
(name :text)
(description :text)
(track-ids :text)
(status :text)
(created-date :integer)
(submitted-date :integer)
(reviewed-date :integer)
(reviewed-by :integer)
(review-notes :text))))
;; TODO: the radiance db interface is too basic to contain anything
;; but strings, integers, booleans, and maybe timestamps... we will
;; need to rethink this. currently track/playlist relationships are

View File

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

View File

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

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

View File

@ -165,6 +165,56 @@
(get-stream-config (ps:@ stream-base-url value) channel quality))))
(if config (ps:@ config mount) "asteroid.mp3")))
;; Track last recorded title to avoid duplicate history entries
(defvar *last-recorded-title-main* nil)
;; Cache of user's favorite track titles for quick lookup
(defvar *user-favorites-cache* (array))
;; Load user's favorites into cache
(defun load-favorites-cache ()
(ps:chain
(fetch "/api/asteroid/user/favorites")
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (json))
nil)))
(then (lambda (data)
(when (and data (ps:@ data data) (ps:@ data data favorites))
(setf *user-favorites-cache*
(ps:chain (ps:@ data data favorites)
(map (lambda (f) (ps:@ f title))))))))
(catch (lambda (error) nil))))
;; Check if current track is in favorites and update UI
(defun check-favorite-status ()
(let ((title-el (ps:chain document (get-element-by-id "current-track-title")))
(btn (ps:chain document (get-element-by-id "favorite-btn"))))
(when (and title-el btn)
(let ((title (ps:@ title-el text-content))
(star-icon (ps:chain btn (query-selector ".star-icon"))))
(if (ps:chain *user-favorites-cache* (includes title))
(progn
(ps:chain btn class-list (add "favorited"))
(when star-icon (setf (ps:@ star-icon text-content) "★")))
(progn
(ps:chain btn class-list (remove "favorited"))
(when star-icon (setf (ps:@ star-icon text-content) "☆"))))))))
;; Record track to listening history (only if logged in)
(defun record-track-listen-main (title)
(when (and title (not (= title "")) (not (= title "Loading..."))
(not (= title "NA")) (not (= title *last-recorded-title-main*)))
(setf *last-recorded-title-main* title)
(ps:chain
(fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title))
(ps:create :method "POST"))
(then (lambda (response)
(ps:@ response ok)))
(catch (lambda (error)
;; Silently fail - user might not be logged in
nil)))))
;; Update now playing info from API
(defun update-now-playing ()
(let ((mount (get-current-mount)))
@ -176,8 +226,32 @@
(ps:chain response (text))
(throw (ps:new (-error "Error connecting to stream")))))))
(then (lambda (data)
(setf (ps:@ (ps:chain document (get-element-by-id "now-playing")) inner-h-t-m-l)
data)))
(let ((now-playing-el (ps:chain document (get-element-by-id "now-playing"))))
(when now-playing-el
;; Get current title before updating
(let ((old-title-el (ps:chain now-playing-el (query-selector "#current-track-title"))))
(setf (ps:@ now-playing-el inner-h-t-m-l) data)
;; Get new title after updating
(let ((new-title-el (ps:chain now-playing-el (query-selector "#current-track-title"))))
(when new-title-el
(let ((new-title (ps:@ new-title-el text-content)))
;; Record if title changed
(when (or (not old-title-el)
(not (= (ps:@ old-title-el text-content) new-title)))
(record-track-listen-main new-title))
;; Check if this track is in user's favorites
(check-favorite-status)
;; Update favorite count display
(let ((count-el (ps:chain document (get-element-by-id "favorite-count-display")))
(count-val-el (ps:chain document (get-element-by-id "favorite-count-value"))))
(when (and count-el count-val-el)
(let ((fav-count (parse-int (or (ps:@ count-val-el value) "0") 10)))
(if (> fav-count 0)
(setf (ps:@ count-el text-content)
(if (= fav-count 1)
"1 person loves this track ❤️"
(+ fav-count " people love this track ❤️")))
(setf (ps:@ count-el text-content) "")))))))))))))
(catch (lambda (error)
(ps:chain console (log "Could not fetch stream status:" error)))))))
@ -553,6 +627,9 @@
(when is-frameset-page
(set-interval update-stream-information 10000)))
;; Load user's favorites for highlight feature
(load-favorites-cache)
;; Update now playing
(update-now-playing)
@ -592,6 +669,59 @@
(redirect-when-frame)))))
;; Toggle favorite for current track
(defun toggle-favorite ()
(let ((track-id-el (ps:chain document (get-element-by-id "current-track-id")))
(title-el (ps:chain document (get-element-by-id "current-track-title")))
(btn (ps:chain document (get-element-by-id "favorite-btn"))))
(let ((track-id (when track-id-el (ps:@ track-id-el value)))
(title (when title-el (ps:@ title-el text-content)))
(is-favorited (ps:chain btn class-list (contains "favorited"))))
;; Need either track-id or title
(when (or (and track-id (not (= track-id ""))) title)
(let ((params (+ "title=" (encode-u-r-i-component (or title ""))
(if (and track-id (not (= track-id "")))
(+ "&track-id=" track-id)
""))))
(if is-favorited
;; Remove favorite
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/remove?" params)
(ps:create :method "POST"))
(then (lambda (response)
(cond
((not (ps:@ response ok))
(alert "Please log in to manage favorites")
nil)
(t (ps:chain response (json))))))
(then (lambda (data)
(when (and data (or (= (ps:@ data status) "success")
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (remove "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
;; Refresh now playing to update favorite count
(update-now-playing))))
(catch (lambda (error)
(ps:chain console (error "Error removing favorite:" error)))))
;; Add favorite
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/add?" params)
(ps:create :method "POST"))
(then (lambda (response)
(cond
((not (ps:@ response ok))
(alert "Please log in to save favorites")
nil)
(t (ps:chain response (json))))))
(then (lambda (data)
(when (and data (or (= (ps:@ data status) "success")
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (add "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")
(update-now-playing))))
(catch (lambda (error)
(ps:chain console (error "Error adding favorite:" error)))))))))))
;; Update now playing every 5 seconds
(set-interval update-now-playing 5000)
@ -642,7 +772,77 @@
(when (and *popout-window* (ps:@ *popout-window* closed))
(update-popout-button nil)
(setf *popout-window* nil)))
1000)))
1000)
;; Track Request Functions
(defun submit-track-request ()
(let ((title-input (ps:chain document (get-element-by-id "request-title")))
(message-input (ps:chain document (get-element-by-id "request-message")))
(status-div (ps:chain document (get-element-by-id "request-status"))))
(when (and title-input message-input status-div)
(let ((title (ps:@ title-input value))
(message (ps:@ message-input value)))
(if (or (not title) (= title ""))
(progn
(setf (ps:@ status-div style display) "block")
(setf (ps:@ status-div class-name) "request-status error")
(setf (ps:@ status-div text-content) "Please enter a track title"))
(progn
(setf (ps:@ status-div style display) "block")
(setf (ps:@ status-div class-name) "request-status info")
(setf (ps:@ status-div text-content) "Submitting request...")
(ps:chain
(fetch (+ "/api/asteroid/requests/submit?title=" (encode-u-r-i-component title)
(if message (+ "&message=" (encode-u-r-i-component message)) ""))
(ps:create :method "POST"))
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (json))
(progn
(setf (ps:@ status-div class-name) "request-status error")
(setf (ps:@ status-div text-content) "Please log in to submit requests")
nil))))
(then (lambda (data)
(when data
(let ((status (or (ps:@ data data status) (ps:@ data status))))
(if (= status "success")
(progn
(setf (ps:@ status-div class-name) "request-status success")
(setf (ps:@ status-div text-content) "Request submitted! An admin will review it soon.")
(setf (ps:@ title-input value) "")
(setf (ps:@ message-input value) ""))
(progn
(setf (ps:@ status-div class-name) "request-status error")
(setf (ps:@ status-div text-content) "Failed to submit request")))))))
(catch (lambda (error)
(ps:chain console (error "Error submitting request:" error))
(setf (ps:@ status-div class-name) "request-status error")
(setf (ps:@ status-div text-content) "Error submitting request"))))))))))
(defun load-recent-requests ()
(let ((container (ps:chain document (get-element-by-id "recent-requests-list"))))
(when container
(ps:chain
(fetch "/api/asteroid/requests/recent")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (and (= (ps:@ data status) "success")
(ps:@ data requests)
(> (ps:@ data requests length) 0))
(let ((html ""))
(ps:chain (ps:@ data requests) (for-each (lambda (req)
(setf html (+ html "<div class=\"request-item\">"
"<span class=\"request-title\">" (ps:@ req title) "</span>"
"<span class=\"request-by\">Requested by @" (ps:@ req username) "</span>"
"</div>")))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-requests\">No recent requests yet. Be the first!</p>")))))
(catch (lambda (error)
(ps:chain console (log "Could not load recent requests:" error))))))))
;; Load recent requests on page load
(load-recent-requests)))
"Compiled JavaScript for front-page - generated at load time")
(defun generate-front-page-js ()

View File

@ -32,20 +32,31 @@
:day "numeric")))))
(defun format-relative-time (date-string)
(let* ((date (ps:new (-date date-string)))
(now (ps:new (-date)))
(diff-ms (- now date))
(diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24)))))
(diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60)))))
(diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60))))))
(cond
((> diff-days 0)
(+ diff-days " day" (if (> diff-days 1) "s" "") " ago"))
((> diff-hours 0)
(+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago"))
((> diff-minutes 0)
(+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago"))
(t "Just now"))))
(when (not date-string)
(return-from format-relative-time "Unknown"))
;; Convert PostgreSQL timestamp format to ISO format
;; "2025-12-21 09:22:58.215986" -> "2025-12-21T09:22:58.215986Z"
(let* ((iso-string (if (and (ps:@ date-string replace)
(ps:chain date-string (includes " ")))
(+ (ps:chain date-string (replace " " "T")) "Z")
date-string))
(date (ps:new (-date iso-string)))
(now (ps:new (-date))))
;; Check if date is valid
(when (ps:chain -number (is-na-n (ps:chain date (get-time))))
(return-from format-relative-time "Recently"))
(let* ((diff-ms (- now date))
(diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24)))))
(diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60)))))
(diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60))))))
(cond
((> diff-days 0)
(+ diff-days " day" (if (> diff-days 1) "s" "") " ago"))
((> diff-hours 0)
(+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago"))
((> diff-minutes 0)
(+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago"))
(t "Just now")))))
(defun format-duration (seconds)
(let ((hours (ps:chain -math (floor (/ seconds 3600))))
@ -109,37 +120,6 @@
(update-element "session-count" "0")
(update-element "favorite-genre" "Unknown")))))
(defun load-recent-tracks ()
(ps:chain
(fetch "/api/asteroid/user/recent-tracks?limit=3")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (and (= (ps:@ data status) "success")
(ps:@ data tracks)
(> (ps:@ data tracks length) 0))
(ps:chain data tracks
(for-each (lambda (track index)
(let ((track-num (+ index 1)))
(update-element (+ "recent-track-" track-num "-title")
(or (ps:@ track title) "Unknown Track"))
(update-element (+ "recent-track-" track-num "-artist")
(or (ps:@ track artist) "Unknown Artist"))
(update-element (+ "recent-track-" track-num "-duration")
(format-duration (or (ps:@ track duration) 0)))
(update-element (+ "recent-track-" track-num "-played-at")
(format-relative-time (ps:@ track played_at)))))))
(loop for i from 1 to 3
do (let* ((track-item-selector (+ "[data-text=\"recent-track-" i "-title\"]"))
(track-item-el (ps:chain document (query-selector track-item-selector)))
(track-item (when track-item-el (ps:chain track-item-el (closest ".track-item")))))
(when (and track-item
(or (not (ps:@ data tracks))
(not (ps:getprop (ps:@ data tracks) (- i 1)))))
(setf (ps:@ track-item style display) "none"))))))))
(catch (lambda (error)
(ps:chain console (error "Error loading recent tracks:" error))))))
(defun load-top-artists ()
(ps:chain
(fetch "/api/asteroid/user/top-artists?limit=5")
@ -167,6 +147,163 @@
(catch (lambda (error)
(ps:chain console (error "Error loading top artists:" error))))))
(defvar *favorites-offset* 0)
(defun load-favorites ()
(ps:chain
(fetch "/api/asteroid/user/favorites")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(container (ps:chain document (get-element-by-id "favorites-list"))))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data favorites)
(> (ps:@ data favorites length) 0))
(progn
(setf (ps:@ container inner-h-t-m-l) "")
(ps:chain data favorites
(for-each (lambda (fav)
(let ((item (ps:chain document (create-element "div"))))
(setf (ps:@ item class-name) "track-item favorite-item")
(setf (ps:@ item inner-h-t-m-l)
(+ "<div class=\"track-info\">"
"<span class=\"track-title\">" (or (ps:@ fav title) "Unknown") "</span>"
"<span class=\"track-artist\">" (or (ps:@ fav artist) "") "</span>"
"</div>"
"<div class=\"track-meta\">"
"<span class=\"rating\">" (render-stars (or (ps:@ fav rating) 1)) "</span>"
"<button class=\"btn btn-small btn-danger\" onclick=\"removeFavorite('" (ps:chain (or (ps:@ fav title) "") (replace (ps:regex "/'/g") "\\'")) "')\">Remove</button>"
"</div>"))
(ps:chain container (append-child item)))))))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No favorites yet. Like tracks while listening!</p>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading favorites:" error))
(let ((container (ps:chain document (get-element-by-id "favorites-list"))))
(when container
(setf (ps:@ container inner-h-t-m-l) "<p class=\"error\">Failed to load favorites</p>")))))))
(defun render-stars (rating)
(let ((stars ""))
(dotimes (i 5)
(setf stars (+ stars (if (< i rating) "★" "☆"))))
stars))
(defun remove-favorite (title)
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/remove?title=" (encode-u-r-i-component title))
(ps:create :method "POST"))
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (json))
(throw (ps:new (-error "Request failed"))))))
(then (lambda (data)
;; API returns {"status": 200, "data": {"status": "success"}}
(let ((inner-status (or (ps:@ data data status) (ps:@ data status))))
(if (or (= inner-status "success") (= (ps:@ data status) 200))
(progn
(show-message "Removed from favorites" "success")
(load-favorites))
(show-message "Failed to remove favorite" "error")))))
(catch (lambda (error)
(ps:chain console (error "Error removing favorite:" error))
(show-message "Error removing favorite" "error")))))
(defun load-more-favorites ()
(show-message "Loading more favorites..." "info"))
(defun load-avatar ()
(ps:chain
(fetch "/api/asteroid/user/avatar")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(when (and (= (ps:@ data status) "success")
(ps:@ data avatar_path))
(let ((img (ps:chain document (get-element-by-id "user-avatar"))))
(when img
(setf (ps:@ img src) (ps:@ data avatar_path))))))))
(catch (lambda (error)
(ps:chain console (log "No avatar set or error loading:" error))))))
(defun upload-avatar (input)
(let ((file (ps:getprop (ps:@ input files) 0)))
(when file
(let ((form-data (ps:new (-form-data))))
(ps:chain form-data (append "avatar" file))
(ps:chain form-data (append "filename" (ps:@ file name)))
(show-message "Uploading avatar..." "info")
(ps:chain
(fetch "/api/asteroid/user/avatar/upload"
(ps:create :method "POST"
:body form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(let ((img (ps:chain document (get-element-by-id "user-avatar"))))
(when img
(setf (ps:@ img src) (+ (ps:@ data avatar_path) "?" (ps:chain -date (now))))))
(show-message "Avatar updated!" "success"))
(show-message "Failed to upload avatar" "error")))))
(catch (lambda (error)
(ps:chain console (error "Error uploading avatar:" error))
(show-message "Error uploading avatar" "error"))))))))
(defun load-activity-chart ()
(ps:chain
(fetch "/api/asteroid/user/activity?days=30")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(container (ps:chain document (get-element-by-id "activity-chart")))
(total-el (ps:chain document (get-element-by-id "activity-total"))))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data activity)
(> (ps:@ data activity length) 0))
(let ((activity (ps:@ data activity))
(max-count 1)
(total 0))
;; Find max for scaling
(ps:chain activity (for-each (lambda (day)
(let ((count (or (ps:@ day track_count) 0)))
(setf total (+ total count))
(when (> count max-count)
(setf max-count count))))))
;; Build chart HTML
(let ((html "<div class=\"chart-bars\">"))
(ps:chain activity (for-each (lambda (day)
(let* ((count (or (ps:@ day track_count) 0))
(height (ps:chain -math (round (* (/ count max-count) 100))))
(date-raw (ps:@ day day))
(date-str (if (and date-raw (ps:@ date-raw to-string))
(ps:chain date-raw (to-string))
(+ "" date-raw)))
(date-parts (if (and date-str (ps:@ date-str split))
(ps:chain date-str (split "-"))
(array)))
(day-label (if (> (ps:@ date-parts length) 2)
(ps:getprop date-parts 2)
"")))
(setf html (+ html "<div class=\"chart-bar-wrapper\">"
"<div class=\"chart-bar\" style=\"height: " height "%\" title=\"" date-str ": " count " tracks\"></div>"
"<span class=\"chart-day\">" day-label "</span>"
"</div>"))))))
(setf html (+ html "</div>"))
(setf (ps:@ container inner-h-t-m-l) html))
;; Update total
(when total-el
(setf (ps:@ total-el text-content) (+ "Total: " total " tracks in the last 30 days"))))
;; No data
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No listening activity yet. Start listening to build your history!</p>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading activity:" error))
(let ((container (ps:chain document (get-element-by-id "activity-chart"))))
(when container
(setf (ps:@ container inner-h-t-m-l) "<p class=\"error\">Failed to load activity data</p>")))))))
(defun load-profile-data ()
(ps:chain console (log "Loading profile data..."))
@ -187,14 +324,51 @@
(show-error "Error loading profile data"))))
(load-listening-stats)
(load-recent-tracks)
(load-top-artists))
(load-favorites)
(load-top-artists)
(load-activity-chart)
(load-avatar)
(load-my-requests))
;; Load user's track requests
(defun load-my-requests ()
(ps:chain
(fetch "/api/asteroid/requests/my")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(container (ps:chain document (get-element-by-id "my-requests-list"))))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data requests)
(> (ps:@ data requests length) 0))
(let ((html ""))
(ps:chain (ps:@ data requests) (for-each (lambda (req)
(let ((status-class (cond
((= (ps:@ req status) "pending") "status-pending")
((= (ps:@ req status) "approved") "status-approved")
((= (ps:@ req status) "rejected") "status-rejected")
((= (ps:@ req status) "played") "status-played")
(t "")))
(status-icon (cond
((= (ps:@ req status) "pending") "⏳")
((= (ps:@ req status) "approved") "✓")
((= (ps:@ req status) "rejected") "✗")
((= (ps:@ req status) "played") "🎵")
(t "?"))))
(setf html (+ html
"<div class=\"my-request-item " status-class "\">"
"<div class=\"request-title\">" (ps:@ req title) "</div>"
"<div class=\"request-status\">"
"<span class=\"status-badge " status-class "\">" status-icon " " (ps:@ req status) "</span>"
"</div>"
"</div>"))))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-requests\">You haven't made any requests yet.</p>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading requests:" error))))))
;; Action functions
(defun load-more-recent-tracks ()
(ps:chain console (log "Loading more recent tracks..."))
(show-message "Loading more tracks..." "info"))
(defun edit-profile ()
(ps:chain console (log "Edit profile clicked"))
(show-message "Profile editing coming soon!" "info"))
@ -291,11 +465,402 @@
false))
;; ========================================
;; User Playlists functionality
;; ========================================
(defvar *library-page* 1)
(defvar *library-search* "")
(defvar *library-artist* "")
(defvar *library-total* 0)
(defvar *current-playlist-tracks* (array))
(defvar *user-playlists* (array))
;; Load user's playlists
(defun load-my-playlists ()
(ps:chain
(fetch "/api/asteroid/user/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(container (ps:chain document (get-element-by-id "my-playlists-list"))))
(when container
(if (and (= (ps:@ data status) "success")
(ps:@ data playlists)
(> (ps:@ data playlists length) 0))
(progn
(setf *user-playlists* (ps:@ data playlists))
(let ((html ""))
(ps:chain (ps:@ data playlists) (for-each (lambda (pl)
(let ((playlist-id (or (ps:@ pl id) (aref pl "id")))
(status-class (cond
((= (ps:@ pl status) "draft") "status-draft")
((= (ps:@ pl status) "submitted") "status-pending")
((= (ps:@ pl status) "approved") "status-approved")
((= (ps:@ pl status) "rejected") "status-rejected")
(t "")))
(status-icon (cond
((= (ps:@ pl status) "draft") "📝")
((= (ps:@ pl status) "submitted") "⏳")
((= (ps:@ pl status) "approved") "✓")
((= (ps:@ pl status) "rejected") "✗")
(t "?"))))
(ps:chain console (log "Playlist:" pl "ID:" playlist-id))
(setf html (+ html
"<div class=\"playlist-item " status-class "\">"
"<div class=\"playlist-info\">"
"<span class=\"playlist-name\">" (or (ps:@ pl name) (aref pl "name")) "</span>"
"<span class=\"playlist-meta\">" (or (ps:@ pl track-count) (aref pl "track-count") 0) " tracks</span>"
"</div>"
"<div class=\"playlist-actions\">"
"<span class=\"status-badge " status-class "\">" status-icon " " (or (ps:@ pl status) (aref pl "status")) "</span>"
(if (= (or (ps:@ pl status) (aref pl "status")) "draft")
(+ "<button class=\"btn btn-small\" onclick=\"editPlaylist(" playlist-id ")\">Edit</button>")
"")
"</div>"
"</div>"))))))
(setf (ps:@ container inner-h-t-m-l) html)))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No playlists yet. Create one to get started!</p>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlists:" error))))))
;; Modal functions
(defun show-create-playlist-modal ()
(let ((modal (ps:chain document (get-element-by-id "create-playlist-modal"))))
(when modal
(setf (ps:@ modal style display) "flex"))))
(defun hide-create-playlist-modal ()
(let ((modal (ps:chain document (get-element-by-id "create-playlist-modal"))))
(when modal
(setf (ps:@ modal style display) "none")
(ps:chain (ps:chain document (get-element-by-id "create-playlist-form")) (reset)))))
(defun show-library-browser ()
(let ((modal (ps:chain document (get-element-by-id "library-browser-modal"))))
(when modal
(setf (ps:@ modal style display) "flex")
(load-library-tracks)
(update-playlist-select))))
(defun hide-library-browser ()
(let ((modal (ps:chain document (get-element-by-id "library-browser-modal"))))
(when modal
(setf (ps:@ modal style display) "none"))))
(defun show-library-browser-for-playlist ()
(show-library-browser))
(defun show-edit-playlist-modal ()
(let ((modal (ps:chain document (get-element-by-id "edit-playlist-modal"))))
(when modal
(setf (ps:@ modal style display) "flex"))))
(defun hide-edit-playlist-modal ()
(let ((modal (ps:chain document (get-element-by-id "edit-playlist-modal"))))
(when modal
(setf (ps:@ modal style display) "none"))))
;; Create playlist
(defun create-playlist (event)
(ps:chain event (prevent-default))
(let ((name (ps:@ (ps:chain document (get-element-by-id "playlist-name")) value))
(description (ps:@ (ps:chain document (get-element-by-id "playlist-description")) value))
(message-div (ps:chain document (get-element-by-id "create-playlist-message"))))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/create?name=" (encode-u-r-i-component name)
"&description=" (encode-u-r-i-component description))
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-message "Playlist created!" "success")
(hide-create-playlist-modal)
(load-my-playlists)
;; Open the new playlist for editing
(when (ps:@ data playlist id)
(edit-playlist (ps:@ data playlist id))))
(progn
(setf (ps:@ message-div text-content) (or (ps:@ data message) "Failed to create playlist"))
(setf (ps:@ message-div class-name) "message error"))))))
(catch (lambda (error)
(ps:chain console (error "Error creating playlist:" error))
(setf (ps:@ message-div text-content) "Error creating playlist")
(setf (ps:@ message-div class-name) "message error")))))
false)
;; Edit playlist
(defun edit-playlist (playlist-id)
(ps:chain console (log "edit-playlist called with id:" playlist-id))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/get?id=" playlist-id))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(ps:chain console (log "edit-playlist response:" result))
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(let* ((pl (ps:@ data playlist))
(pl-id (or (ps:@ pl id) (aref pl "id")))
(pl-name (or (ps:@ pl name) (aref pl "name")))
(pl-desc (or (ps:@ pl description) (aref pl "description") ""))
(pl-tracks (or (ps:@ pl tracks) (aref pl "tracks") (array))))
(ps:chain console (log "Playlist id:" pl-id "name:" pl-name))
(setf (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value) pl-id)
(setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-name")) value) pl-name)
(setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-description")) value) pl-desc)
(setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-title")) text-content) (+ "Edit: " pl-name))
(setf *current-playlist-tracks* pl-tracks)
(render-playlist-tracks)
(show-edit-playlist-modal))
(show-message "Failed to load playlist" "error")))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlist:" error))
(show-message "Error loading playlist" "error")))))
(defun render-playlist-tracks ()
(let ((container (ps:chain document (get-element-by-id "playlist-tracks-list"))))
(when container
(if (> (ps:@ *current-playlist-tracks* length) 0)
(let ((html ""))
(ps:chain *current-playlist-tracks* (for-each (lambda (track index)
(setf html (+ html
"<div class=\"playlist-track-item\" data-index=\"" index "\">"
"<span class=\"track-number\">" (+ index 1) ".</span>"
"<span class=\"track-title\">" (ps:@ track title) "</span>"
"<span class=\"track-artist\">" (ps:@ track artist) "</span>"
"<div class=\"track-controls\">"
"<button class=\"btn btn-tiny\" onclick=\"moveTrackInPlaylist(" index ", -1)\" " (if (= index 0) "disabled" "") ">↑</button>"
"<button class=\"btn btn-tiny\" onclick=\"moveTrackInPlaylist(" index ", 1)\" " (if (= index (- (ps:@ *current-playlist-tracks* length) 1)) "disabled" "") ">↓</button>"
"<button class=\"btn btn-tiny btn-danger\" onclick=\"removeTrackFromPlaylist(" index ")\">✕</button>"
"</div>"
"</div>")))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"empty-message\">No tracks yet. Browse the library to add tracks!</p>")))))
(defun move-track-in-playlist (index direction)
(let ((new-index (+ index direction)))
(when (and (>= new-index 0) (< new-index (ps:@ *current-playlist-tracks* length)))
(let ((track (ps:chain *current-playlist-tracks* (splice index 1))))
(ps:chain *current-playlist-tracks* (splice new-index 0 (ps:getprop track 0)))
(render-playlist-tracks)
(save-playlist-tracks)))))
(defun remove-track-from-playlist (index)
(ps:chain *current-playlist-tracks* (splice index 1))
(render-playlist-tracks)
(save-playlist-tracks))
(defun add-track-to-playlist (track-id title artist album)
(ps:chain console (log "addTrackToPlaylist called with track-id:" track-id "title:" title))
(let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value)))
(when (not playlist-id)
;; No playlist open, use the select dropdown
(let ((select (ps:chain document (get-element-by-id "add-to-playlist-select"))))
(when select
(setf playlist-id (ps:@ select value)))))
(when (not playlist-id)
(show-message "Please select a playlist first" "warning")
(return))
;; Add to current tracks array
(ps:chain console (log "Adding track with id:" track-id "to playlist:" playlist-id))
;; Create object and set id property explicitly
(let ((track-obj (ps:create)))
(setf (ps:@ track-obj id) track-id)
(setf (ps:@ track-obj title) title)
(setf (ps:@ track-obj artist) artist)
(setf (ps:@ track-obj album) album)
(ps:chain *current-playlist-tracks* (push track-obj)))
(ps:chain console (log "Current tracks:" *current-playlist-tracks*))
(render-playlist-tracks)
(save-playlist-tracks)
(show-message (+ "Added: " title) "success")))
(defun save-playlist-tracks ()
(let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value)))
(when playlist-id
;; Access id property directly - use 'trk' not 't' (t is boolean true in Lisp/ParenScript)
(let ((track-ids (ps:chain *current-playlist-tracks* (map (lambda (trk) (ps:@ trk id))))))
(ps:chain console (log "Saving track-ids:" track-ids))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/update?id=" playlist-id
"&tracks=" (encode-u-r-i-component (ps:chain -j-s-o-n (stringify track-ids))))
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(catch (lambda (error)
(ps:chain console (error "Error saving playlist:" error)))))))))
(defun save-playlist-metadata ()
(let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value))
(name (ps:@ (ps:chain document (get-element-by-id "edit-playlist-name")) value))
(description (ps:@ (ps:chain document (get-element-by-id "edit-playlist-description")) value)))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/update?id=" playlist-id
"&name=" (encode-u-r-i-component name)
"&description=" (encode-u-r-i-component description))
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-message "Playlist saved!" "success")
(setf (ps:@ (ps:chain document (get-element-by-id "edit-playlist-title")) text-content) (+ "Edit: " name))
(load-my-playlists))
(show-message "Failed to save playlist" "error")))))
(catch (lambda (error)
(ps:chain console (error "Error saving playlist:" error))
(show-message "Error saving playlist" "error"))))))
(defun submit-playlist-for-review ()
(let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value)))
(when (not (confirm "Submit this playlist for admin review? You won't be able to edit it after submission."))
(return))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/submit?id=" playlist-id)
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-message "Playlist submitted for review!" "success")
(hide-edit-playlist-modal)
(load-my-playlists))
(show-message (or (ps:@ data message) "Failed to submit playlist") "error")))))
(catch (lambda (error)
(ps:chain console (error "Error submitting playlist:" error))
(show-message "Error submitting playlist" "error"))))))
(defun delete-current-playlist ()
(let ((playlist-id (ps:@ (ps:chain document (get-element-by-id "current-edit-playlist-id")) value)))
(when (not (confirm "Delete this playlist? This cannot be undone."))
(return))
(ps:chain
(fetch (+ "/api/asteroid/user/playlists/delete?id=" playlist-id)
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
(show-message "Playlist deleted" "success")
(hide-edit-playlist-modal)
(load-my-playlists))
(show-message "Failed to delete playlist" "error")))))
(catch (lambda (error)
(ps:chain console (error "Error deleting playlist:" error))
(show-message "Error deleting playlist" "error"))))))
;; Library browsing
(defun load-library-tracks ()
(let ((url (+ "/api/asteroid/library/browse?page=" *library-page*)))
(when (and *library-search* (> (ps:@ *library-search* length) 0))
(setf url (+ url "&search=" (encode-u-r-i-component *library-search*))))
(when (and *library-artist* (> (ps:@ *library-artist* length) 0))
(setf url (+ url "&artist=" (encode-u-r-i-component *library-artist*))))
(ps:chain
(fetch url)
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result))
(container (ps:chain document (get-element-by-id "library-tracks")))
(artist-select (ps:chain document (get-element-by-id "library-artist-filter"))))
(when container
(setf *library-total* (or (ps:@ data total) 0))
(if (and (= (ps:@ data status) "success")
(ps:@ data tracks)
(> (ps:@ data tracks length) 0))
(let ((html ""))
(ps:chain (ps:@ data tracks) (for-each (lambda (track)
(setf html (+ html
"<div class=\"library-track-item\">"
"<div class=\"track-info\">"
"<span class=\"track-title\">" (ps:@ track title) "</span>"
"<span class=\"track-artist\">" (ps:@ track artist) "</span>"
"<span class=\"track-album\">" (ps:@ track album) "</span>"
"</div>"
"<button class=\"btn btn-small btn-primary\" onclick=\"addTrackToPlaylist("
(ps:@ track id) ", '"
(ps:chain (ps:@ track title) (replace (ps:regex "/'/g") "\\'")) "', '"
(ps:chain (ps:@ track artist) (replace (ps:regex "/'/g") "\\'")) "', '"
(ps:chain (ps:@ track album) (replace (ps:regex "/'/g") "\\'")) "')\">+ Add</button>"
"</div>")))))
(setf (ps:@ container inner-h-t-m-l) html))
(setf (ps:@ container inner-h-t-m-l) "<p class=\"no-data\">No tracks found</p>")))
;; Update artist filter
(when (and artist-select (ps:@ data artists))
(let ((current-val (ps:@ artist-select value)))
(setf (ps:@ artist-select inner-h-t-m-l) "<option value=\"\">All Artists</option>")
(ps:chain (ps:@ data artists) (for-each (lambda (artist)
(let ((opt (ps:chain document (create-element "option"))))
(setf (ps:@ opt value) artist)
(setf (ps:@ opt text-content) artist)
(ps:chain artist-select (append-child opt))))))
(setf (ps:@ artist-select value) current-val)))
;; Update pagination
(update-library-pagination))))
(catch (lambda (error)
(ps:chain console (error "Error loading library:" error)))))))
(defun update-library-pagination ()
(let ((page-info (ps:chain document (get-element-by-id "library-page-info")))
(prev-btn (ps:chain document (get-element-by-id "lib-prev-btn")))
(next-btn (ps:chain document (get-element-by-id "lib-next-btn")))
(total-pages (ps:chain -math (ceil (/ *library-total* 50)))))
(when page-info
(setf (ps:@ page-info text-content) (+ "Page " *library-page* " of " total-pages)))
(when prev-btn
(setf (ps:@ prev-btn disabled) (<= *library-page* 1)))
(when next-btn
(setf (ps:@ next-btn disabled) (>= *library-page* total-pages)))))
(defun prev-library-page ()
(when (> *library-page* 1)
(setf *library-page* (- *library-page* 1))
(load-library-tracks)))
(defun next-library-page ()
(setf *library-page* (+ *library-page* 1))
(load-library-tracks))
(defvar *search-timeout* nil)
(defun search-library ()
(when *search-timeout*
(clear-timeout *search-timeout*))
(setf *search-timeout*
(set-timeout
(lambda ()
(setf *library-search* (ps:@ (ps:chain document (get-element-by-id "library-search")) value))
(setf *library-page* 1)
(load-library-tracks))
300)))
(defun filter-by-artist ()
(setf *library-artist* (ps:@ (ps:chain document (get-element-by-id "library-artist-filter")) value))
(setf *library-page* 1)
(load-library-tracks))
(defun update-playlist-select ()
(let ((select (ps:chain document (get-element-by-id "add-to-playlist-select"))))
(when select
(setf (ps:@ select inner-h-t-m-l) "<option value=\"\">Select playlist to add to...</option>")
(ps:chain *user-playlists* (for-each (lambda (pl)
(when (= (ps:@ pl status) "draft")
(let ((opt (ps:chain document (create-element "option"))))
(setf (ps:@ opt value) (ps:@ pl id))
(setf (ps:@ opt text-content) (ps:@ pl name))
(ps:chain select (append-child opt))))))))))
;; Initialize on page load
(ps:chain window
(add-event-listener
"DOMContentLoaded"
load-profile-data))))
(lambda ()
(load-profile-data)
(load-my-playlists))))))
"Compiled JavaScript for profile page - generated at load time")
(defun generate-profile-js ()

View File

@ -198,22 +198,144 @@
(config (get-stream-config stream-base-url channel quality)))
(if config (ps:@ config mount) "asteroid.mp3")))
;; Track the last recorded title to avoid duplicate history entries
(defvar *last-recorded-title* nil)
;; Cache of user's favorite track titles for quick lookup (mini player)
(defvar *user-favorites-cache-mini* (array))
;; Load user's favorites into cache (mini player)
(defun load-favorites-cache-mini ()
(ps:chain
(fetch "/api/asteroid/user/favorites")
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (json))
nil)))
(then (lambda (data)
(when (and data (ps:@ data data) (ps:@ data data favorites))
(setf *user-favorites-cache-mini*
(ps:chain (ps:@ data data favorites)
(map (lambda (f) (ps:@ f title))))))))
(catch (lambda (error) nil))))
;; Check if current track is in favorites and update mini player UI
(defun check-favorite-status-mini ()
(let ((title-el (ps:chain document (get-element-by-id "mini-now-playing")))
(btn (ps:chain document (get-element-by-id "favorite-btn-mini"))))
(when (and title-el btn)
(let ((title (ps:@ title-el text-content))
(star-icon (ps:chain btn (query-selector ".star-icon"))))
(if (ps:chain *user-favorites-cache-mini* (includes title))
(progn
(ps:chain btn class-list (add "favorited"))
(when star-icon (setf (ps:@ star-icon text-content) "★")))
(progn
(ps:chain btn class-list (remove "favorited"))
(when star-icon (setf (ps:@ star-icon text-content) "☆"))))))))
;; Record track to listening history (only if logged in)
(defun record-track-listen (title)
(when (and title (not (= title "")) (not (= title "Loading...")) (not (= title *last-recorded-title*)))
(setf *last-recorded-title* title)
(ps:chain
(fetch (+ "/api/asteroid/user/history/record?title=" (encode-u-r-i-component title))
(ps:create :method "POST"))
(then (lambda (response)
(ps:@ response ok)))
(catch (lambda (error) nil)))))
;; Update mini now playing display (for persistent player frame)
(defun update-mini-now-playing ()
(let ((mount (get-current-mount)))
(ps:chain
(fetch (+ "/api/asteroid/partial/now-playing-inline?mount=" mount))
(fetch (+ "/api/asteroid/partial/now-playing-json?mount=" mount))
(then (lambda (response)
(if (ps:@ response ok)
(ps:chain response (text))
"")))
(then (lambda (text)
(let ((el (ps:chain document (get-element-by-id "mini-now-playing"))))
(when el
(setf (ps:@ el text-content) text)))))
(ps:chain response (json))
nil)))
(then (lambda (data)
(when data
(let ((el (ps:chain document (get-element-by-id "mini-now-playing")))
(track-id-el (ps:chain document (get-element-by-id "current-track-id-mini")))
(title (or (ps:@ data data title) (ps:@ data title) "Loading...")))
(when el
;; Check if track changed and record to history
(when (not (= (ps:@ el text-content) title))
(record-track-listen title))
(setf (ps:@ el text-content) title)
;; Check if this track is in user's favorites
(check-favorite-status-mini))
(when track-id-el
(let ((track-id (or (ps:@ data data track_id) (ps:@ data track_id))))
(setf (ps:@ track-id-el value) (or track-id ""))))
;; Update favorite count display
(let ((count-el (ps:chain document (get-element-by-id "favorite-count-mini")))
(fav-count (or (ps:@ data data favorite_count) (ps:@ data favorite_count) 0)))
(when count-el
(cond
((= fav-count 0) (setf (ps:@ count-el text-content) ""))
((= fav-count 1) (setf (ps:@ count-el text-content) "1 ❤️"))
(t (setf (ps:@ count-el text-content) (+ fav-count " ❤️"))))))))))
(catch (lambda (error)
(ps:chain console (log "Could not fetch now playing:" error)))))))
;; Toggle favorite for mini player
(defun toggle-favorite-mini ()
(let ((track-id-el (ps:chain document (get-element-by-id "current-track-id-mini")))
(title-el (ps:chain document (get-element-by-id "mini-now-playing")))
(btn (ps:chain document (get-element-by-id "favorite-btn-mini"))))
(let ((track-id (when track-id-el (ps:@ track-id-el value)))
(title (when title-el (ps:@ title-el text-content)))
(is-favorited (ps:chain btn class-list (contains "favorited"))))
;; Need either track-id or title
(when (or (and track-id (not (= track-id ""))) title)
(let ((params (+ "title=" (encode-u-r-i-component (or title ""))
(if (and track-id (not (= track-id "")))
(+ "&track-id=" track-id)
""))))
(if is-favorited
;; Remove favorite
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/remove?" params)
(ps:create :method "POST"))
(then (lambda (response)
(cond
((not (ps:@ response ok))
(alert "Please log in to manage favorites")
nil)
(t (ps:chain response (json))))))
(then (lambda (data)
(when (and data (or (= (ps:@ data status) "success")
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (remove "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "☆")
;; Reload cache and refresh display to update favorite count
(load-favorites-cache-mini)
(update-mini-now-playing))))
(catch (lambda (error)
(ps:chain console (error "Error removing favorite:" error)))))
;; Add favorite
(ps:chain
(fetch (+ "/api/asteroid/user/favorites/add?" params)
(ps:create :method "POST"))
(then (lambda (response)
(cond
((not (ps:@ response ok))
(alert "Please log in to save favorites")
nil)
(t (ps:chain response (json))))))
(then (lambda (data)
(when (and data (or (= (ps:@ data status) "success")
(= (ps:@ data data status) "success")))
(ps:chain btn class-list (add "favorited"))
(setf (ps:@ (ps:chain btn (query-selector ".star-icon")) text-content) "★")
;; Reload cache and refresh display to update favorite count
(load-favorites-cache-mini)
(update-mini-now-playing))))
(catch (lambda (error)
(ps:chain console (error "Error adding favorite:" error)))))))))))
;; Update popout now playing display (parses artist - title)
(defun update-popout-now-playing ()
(let ((mount (get-current-mount)))
@ -528,6 +650,9 @@
(defun init-persistent-player ()
(let ((audio-element (ps:chain document (get-element-by-id "persistent-audio"))))
(when audio-element
;; Load user's favorites for highlight feature
(load-favorites-cache-mini)
;; Try to enable low-latency mode if supported
(when (ps:@ navigator media-session)
(setf (ps:@ navigator media-session metadata)
@ -641,6 +766,7 @@
(setf (ps:@ window init-popout-player) init-popout-player)
(setf (ps:@ window update-mini-now-playing) update-mini-now-playing)
(setf (ps:@ window update-popout-now-playing) update-popout-now-playing)
(setf (ps:@ window toggle-favorite-mini) toggle-favorite-mini)
;; Auto-initialize on DOMContentLoaded based on which elements exist
(ps:chain document

View File

@ -163,11 +163,19 @@
(sort (copy-list *playlist-schedule*) #'< :key #'car))
(defun get-available-playlists ()
"Get list of available playlist files from the playlists directory."
(let ((playlists-dir (get-playlists-directory)))
(when (probe-file playlists-dir)
(mapcar #'file-namestring
(directory (merge-pathnames "*.m3u" playlists-dir))))))
"Get list of available playlist files from the playlists directory and user-submissions."
(let ((playlists-dir (get-playlists-directory))
(submissions-dir (merge-pathnames "user-submissions/" (get-playlists-directory))))
(append
;; Main playlists directory
(when (probe-file playlists-dir)
(mapcar #'file-namestring
(directory (merge-pathnames "*.m3u" playlists-dir))))
;; User submissions directory (prefixed with user-submissions/)
(when (probe-file submissions-dir)
(mapcar (lambda (path)
(format nil "user-submissions/~a" (file-namestring path)))
(directory (merge-pathnames "*.m3u" submissions-dir)))))))
(defun get-server-time-info ()
"Get current server time information in both UTC and local timezone."

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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