Compare commits

...

14 Commits

Author SHA1 Message Date
Glenn Thompson f73d0ef007 Remove duplicate asteroid/recently-played API definition 2025-12-10 11:11:32 -05:00
Glenn Thompson 6fac97b6e1 Fix ParenScript constructor syntax: use ps:new for class instantiation
Change (new -ClassName) to (ps:new (-ClassName)) for proper ParenScript
macro expansion. Fixes Event and FormData constructors.

Co-authored-by: @easilok
2025-12-10 11:11:32 -05:00
Glenn Thompson 68a83390c9 Use local-time:now for last-login update (database agnostic)
Replace raw PostgreSQL SQL with data-model approach using local-time:now
as suggested by easilok. This keeps the code database backend agnostic
instead of being tied to PostgreSQL-specific syntax.
2025-12-10 11:11:32 -05:00
Glenn Thompson c1d71800ab Disable auto-generation of stream-queue.m3u from all tracks
- Comment out fallback that dumped entire library to stream-queue.m3u
- Add stream-queue.m3u to .gitignore (generated file with local paths)
- Delete orphaned stream-queue.m3u from project root

The curated playlists/stream-queue.m3u for Liquidsoap should be
manually managed, not auto-generated by the application.
2025-12-10 11:11:32 -05:00
Glenn Thompson b6c1baa473 Implement playlist management MVP for player page
Features:
- Create, view, load, and delete playlists
- Add tracks to playlists via dropdown menu (📋 button)
- View playlist contents (👁️ button)
- Load playlist to queue with auto-play (📂 button)
- Delete playlist with confirmation (🗑️ button)

Backend changes:
- Move get-db-connection-params to database.lisp for proper load order
- Update playlist functions to use playlist_tracks junction table
- Add get-playlist-tracks and get-playlist-track-count functions
- Add delete and remove-track API endpoints
- Fix stream-track endpoint to not wrap errors in JSON

Frontend changes (ParenScript):
- Add playlist display with action buttons
- Add showAddToPlaylistMenu dropdown
- Add deletePlaylist and viewPlaylist functions
- Fix forEach chaining with progn
- Fix let* scoping for sequential bindings
- Fix ps:regex syntax for string escaping
- Add console logging for playlist debugging
2025-12-10 11:11:32 -05:00
Glenn Thompson 135a6a8dee Apply easilok's ParenScript fixes and fix playlist creation
ParenScript player.lisp fixes (from easilok):
- Replace == with = for JavaScript equality
- Replace inner-html with inner-h-t-m-l
- Replace parseInt with parse-int
- Replace let with let* where needed for sequential bindings
- Replace new "FormData" with new -Form-data
- Fix audio event listener chaining
- Replace Promise chains with setTimeout callbacks

playlist-management.lisp:
- Remove track-ids field (column doesn't exist, using junction table)
2025-12-10 11:11:32 -05:00
Glenn Thompson 46d57e2775 Fix timestamp consistency across all tables
- Update USERS table schema to use TIMESTAMP instead of integer
- Add migration 003 to convert existing integer timestamps to TIMESTAMP
- Remove timestamp-to-unix calls in playlist-management.lisp (use DB default)
- Update user-management.lisp to use postmodern:execute for last-login update
- All timestamp columns now consistently use PostgreSQL TIMESTAMP type
2025-12-10 11:11:32 -05:00
Glenn Thompson 22b2a2d87e Add Liquidsoap/Icecast controls, fix library scan
- Add Liquidsoap control panel: status display, skip track, reload playlist, restart container
- Add Icecast restart button to System Status section
- Remove redundant Web Player Control section from admin
- Fix music library scan to follow symlinks (truename resolution)
- Fix database timestamp error (let PostgreSQL default handle added-date)
- Update docker-compose mount for stream-queue.m3u
- Clean up playlist path handling
2025-12-10 11:11:32 -05:00
Glenn Thompson 74a9448e9a Fix player.js bugs and update admin panel
- Remove Live Stream Monitor section (redundant with frame player)
- Fix player.lisp pagination start-index calculation
- Fix track property access (remove erroneous index 0)
- Fix if/else paren structure in play-next function
- Fix indentation in play-track function
- Update Music Library Management section to reflect Docker setup
- Add geostationary playlist file
2025-12-10 11:11:32 -05:00
Glenn Thompson c89e31b998 Add geo stats collection and improve admin dashboard UI
- Add geo IP lookup for listener locations (ip-api.com)
- Add geo stats table with country flags to admin dashboard
- Fix listener stats table alignment with proper centering
- Fix Now Playing display to update without requiring audio playback
- Add caching for geo lookups to reduce API calls
2025-12-10 11:11:32 -05:00
Glenn Thompson 4be3b83da1 Add listener statistics feature
- Add database schema for listener snapshots, sessions, and aggregates
- Implement background polling of Icecast admin XML stats
- Add API endpoints for current, daily, and geo stats
- Add listener stats section to admin dashboard with auto-refresh
- GDPR compliant: IP hashing, data retention cleanup
2025-12-10 11:11:32 -05:00
Glenn Thompson 63c32c25f3 Add listener statistics feature design document 2025-12-10 11:11:32 -05:00
Glenn Thompson 51b40fe8df Add status page for frameset mode and fix navigation 2025-12-10 11:11:32 -05:00
Glenn Thompson 8c19e0fbde Fix wedged player with reconnect button and volume preservation
- Add reconnect button (🔄) to frameset player bar
- Recreate audio element on reconnect to fix wedged MediaElementSource
- Properly close and reinitialize AudioContext for spectrum analyzer
- Preserve volume and muted state when reconnecting
- Show status messages for connection issues
- Reduce Now Playing update interval to 5 seconds
- Add front-page.js to player-content.ctml for Now Playing updates
- Create About pages (about.ctml and about-content.ctml)
- Add About link to navigation in both modes
2025-12-10 11:11:32 -05:00
34 changed files with 5072 additions and 1024 deletions

1
.gitignore vendored
View File

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

View File

@ -25,18 +25,18 @@
1) [X] Liquidsoap is exposing its management console via telnet on the exterior network interface of b612.asteroid.radio
2) [X] icecast is also binding the external interface on b612, which it
should not be. HAproxy is there to mediate this flow.
3) [ ] We're still on the built in i-lambdalite database
3) [X] We're still on the built in i-lambdalite database
4) [X] The templates still advertise the default administrator password,
which is no bueno.
5) [ ] We need to work out the TLS situation with letsencrypt, and
integrate it into HAproxy.
6) [ ] The administrative interface should be beefed up.
6.1) [ ] Deactivate users
6.2) [ ] Change user access permissions
6.1) [X] Deactivate users
6.2) [X] Change user access permissions
6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c
7) [ ] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused.
7) [X] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused.
8) [ ] User profile pages should probably be fleshed out.
9) [ ] the stream management features aren't there for Admins or DJs.
10) [ ] The "Scan Library" feature is not working in the main branch
@ -48,11 +48,11 @@
- [ ] strip hard coded configurations out of the system
- [ ] add configuration template file to the project
** [ ] Database [0/1]
** [X] Database [0/1]
- [-] PostgresQL [1/3]
- [X] Add a postgresql docker image to our docker-compose file.
- [ ] Configure radiance for postres.
- [ ] Migrate all schema to new database.
- [X] Configure radiance for postres.
- [X] Migrate all schema to new database.
** [X] Page Flow [2/2] ✅ COMPLETE

View File

@ -58,6 +58,7 @@
(:file "user-management")
(:file "playlist-management")
(:file "stream-control")
(:file "listener-stats")
(:file "auth-routes")
(:file "frontend-partials")
(:file "asteroid")))

View File

@ -86,26 +86,6 @@
("message" . "Library scan completed")
("tracks-added" . ,tracks-added))))))
(define-api asteroid/recently-played () ()
"Get the last 3 played tracks with MusicBrainz links"
(with-error-handling
(let ((tracks (get-recently-played)))
(api-output `(("status" . "success")
("tracks" . ,(mapcar (lambda (track)
(let* ((title (getf track :title))
(timestamp (getf track :timestamp))
(unix-timestamp (universal-time-to-unix timestamp))
(parsed (parse-track-title title))
(artist (getf parsed :artist))
(song (getf parsed :song))
(search-url (generate-music-search-url artist song)))
`(("title" . ,title)
("artist" . ,artist)
("song" . ,song)
("timestamp" . ,unix-timestamp)
("search_url" . ,search-url))))
tracks)))))))
(define-api asteroid/admin/tracks () ()
"API endpoint to view all tracks in database"
(require-authentication)
@ -133,17 +113,10 @@
(playlists (get-user-playlists user-id)))
(api-output `(("status" . "success")
("playlists" . ,(mapcar (lambda (playlist)
(let* ((track-ids (dm:field playlist "track-ids"))
;; Calculate track count from comma-separated string
;; Handle nil, empty string, or list containing empty string
(track-count (if (and track-ids
(stringp track-ids)
(not (string= track-ids "")))
(length (cl-ppcre:split "," track-ids))
0)))
(let ((track-count (get-playlist-track-count (dm:id playlist))))
`(("id" . ,(dm:id playlist))
("name" . ,(dm:field playlist "name"))
("description" . ,(dm:field playlist "description"))
("description" . ,(or (dm:field playlist "description") ""))
("track-count" . ,track-count)
("created-date" . ,(dm:field playlist "created-date")))))
playlists)))))))
@ -177,7 +150,7 @@
(let* ((id (parse-integer playlist-id :junk-allowed t))
(playlist (get-playlist-by-id id)))
(if playlist
(let* ((track-ids (dm:field playlist "tracks"))
(let* ((track-ids (get-playlist-tracks id))
(tracks (mapcar (lambda (track-id)
(dm:get-one "tracks" (db:query (:= '_id track-id))))
track-ids))
@ -185,6 +158,8 @@
(api-output `(("status" . "success")
("playlist" . (("id" . ,id)
("name" . ,(dm:field playlist "name"))
("description" . ,(or (dm:field playlist "description") ""))
("track-count" . ,(length valid-tracks))
("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(dm:id track))
("title" . ,(dm:field track "title"))
@ -195,6 +170,31 @@
("message" . "Playlist not found"))
:status 404)))))
(define-api asteroid/playlists/delete (playlist-id) ()
"Delete a playlist"
(require-authentication)
(with-error-handling
(let* ((id (parse-integer playlist-id :junk-allowed t))
(user-id (get-current-user-id)))
(if (delete-playlist id user-id)
(api-output `(("status" . "success")
("message" . "Playlist deleted")))
(api-output `(("status" . "error")
("message" . "Could not delete playlist (not found or not owned by you)"))
:status 403)))))
(define-api asteroid/playlists/remove-track (playlist-id track-id) ()
"Remove a track from a playlist"
(require-authentication)
(with-error-handling
(let ((pl-id (parse-integer playlist-id :junk-allowed t))
(tr-id (parse-integer track-id :junk-allowed t)))
(if (remove-track-from-playlist pl-id tr-id)
(api-output `(("status" . "success")
("message" . "Track removed from playlist")))
(api-output `(("status" . "error")
("message" . "Could not remove track")))))))
;; Recently played tracks API endpoint
(define-api asteroid/recently-played () ()
"Get the last 3 played tracks with AllMusic links"
@ -303,6 +303,246 @@
("message" . "Queue loaded from M3U file")
("count" . ,count))))))
;;; Playlist File Management APIs
;;; These manage .m3u files in the playlists/ directory
;;; stream-queue.m3u lives at PROJECT ROOT (for Docker mount), saved playlists in playlists/
(defun get-playlists-directory ()
"Get the path to the playlists directory (for saved playlists)"
(merge-pathnames "playlists/" (asdf:system-source-directory :asteroid)))
(defun get-stream-queue-path ()
"Get the path to stream-queue.m3u (in playlists/ directory for Docker mount)"
(merge-pathnames "playlists/stream-queue.m3u" (asdf:system-source-directory :asteroid)))
(defun list-playlist-files ()
"List all .m3u files in the playlists directory, excluding stream-queue.m3u"
(let ((playlist-dir (get-playlists-directory)))
(when (probe-file playlist-dir)
(remove-if (lambda (path)
(string= (file-namestring path) "stream-queue.m3u"))
(directory (merge-pathnames "*.m3u" playlist-dir))))))
(defun read-m3u-file-paths (filepath)
"Read an m3u file and return list of file paths (excluding comments)"
(when (probe-file filepath)
(with-open-file (stream filepath :direction :input)
(loop for line = (read-line stream nil)
while line
unless (or (string= line "")
(and (> (length line) 0) (char= (char line 0) #\#)))
collect (string-trim '(#\Space #\Tab #\Return #\Newline) line)))))
(defun copy-playlist-to-stream-queue (source-path)
"Copy a playlist file to stream-queue.m3u at project root"
(let ((dest-path (get-stream-queue-path)))
(with-open-file (in source-path :direction :input)
(with-open-file (out dest-path :direction :output
:if-exists :supersede
:if-does-not-exist :create)
(loop for line = (read-line in nil)
while line
do (write-line line out))))
t))
(define-api asteroid/stream/playlists () ()
"List available playlist files (excluding stream-queue.m3u)"
(require-role :admin)
(with-error-handling
(let ((files (list-playlist-files)))
(api-output `(("status" . "success")
("playlists" . ,(mapcar (lambda (path)
(file-namestring path))
files)))))))
(define-api asteroid/stream/playlists/load (name) ()
"Load a playlist file into stream-queue.m3u and return its contents"
(require-role :admin)
(with-error-handling
(let* ((playlist-path (merge-pathnames name (get-playlists-directory)))
(stream-queue-path (get-stream-queue-path)))
(if (probe-file playlist-path)
(progn
;; Copy playlist to stream-queue.m3u
(copy-playlist-to-stream-queue playlist-path)
;; Load into in-memory queue
(let ((count (load-queue-from-m3u-file)))
(api-output `(("status" . "success")
("message" . ,(format nil "Loaded playlist: ~a" name))
("count" . ,count)
("paths" . ,(read-m3u-file-paths stream-queue-path))))))
(api-output `(("status" . "error")
("message" . "Playlist file not found"))
:status 404)))))
(define-api asteroid/stream/playlists/save () ()
"Save current stream queue to stream-queue.m3u"
(require-role :admin)
(with-error-handling
(regenerate-stream-playlist)
(api-output `(("status" . "success")
("message" . "Stream queue saved")))))
(define-api asteroid/stream/playlists/save-as (name) ()
"Save current stream queue to a new playlist file"
(require-role :admin)
(with-error-handling
(let* ((safe-name (if (cl-ppcre:scan "\\.m3u$" name) name (format nil "~a.m3u" name)))
(playlist-path (merge-pathnames safe-name (get-playlists-directory))))
;; Generate playlist to the new file
(generate-m3u-playlist *stream-queue* playlist-path)
;; Also save to stream-queue.m3u
(regenerate-stream-playlist)
(api-output `(("status" . "success")
("message" . ,(format nil "Saved as: ~a" safe-name)))))))
(define-api asteroid/stream/playlists/clear () ()
"Clear stream-queue.m3u (Liquidsoap will fall back to random)"
(require-role :admin)
(with-error-handling
(let ((stream-queue-path (get-stream-queue-path)))
;; Write empty m3u file
(with-open-file (out stream-queue-path :direction :output
:if-exists :supersede
:if-does-not-exist :create)
(format out "#EXTM3U~%"))
;; Clear in-memory queue
(setf *stream-queue* '())
(api-output `(("status" . "success")
("message" . "Stream queue cleared - Liquidsoap will use random playback"))))))
(define-api asteroid/stream/playlists/current () ()
"Get current stream-queue.m3u contents with track info"
(require-role :admin)
(with-error-handling
(let* ((stream-queue-path (get-stream-queue-path))
(paths (read-m3u-file-paths stream-queue-path))
(all-tracks (dm:get "tracks" (db:query :all))))
(api-output `(("status" . "success")
("count" . ,(length paths))
("tracks" . ,(mapcar (lambda (docker-path)
(let* ((host-path (convert-from-docker-path docker-path))
(track (find-if
(lambda (trk)
(string= (dm:field trk "file-path") host-path))
all-tracks)))
(if track
`(("id" . ,(dm:id track))
("title" . ,(dm:field track "title"))
("artist" . ,(dm:field track "artist"))
("album" . ,(dm:field track "album"))
("path" . ,docker-path))
`(("id" . nil)
("title" . ,(file-namestring docker-path))
("artist" . "Unknown")
("album" . "Unknown")
("path" . ,docker-path)))))
paths)))))))
;;; Liquidsoap Control APIs
;;; Control Liquidsoap via telnet interface on port 1234
(defun liquidsoap-command (command)
"Send a command to Liquidsoap via telnet and return the response"
(handler-case
(let ((result (uiop:run-program
(format nil "echo '~a' | nc -q1 127.0.0.1 1234" command)
:output :string
:error-output :string
:ignore-error-status t)))
;; Remove the trailing "END" line
(let ((lines (cl-ppcre:split "\\n" result)))
(string-trim '(#\Space #\Newline #\Return)
(format nil "~{~a~^~%~}"
(remove-if (lambda (l) (string= (string-trim '(#\Space #\Return) l) "END"))
lines)))))
(error (e)
(format nil "Error: ~a" e))))
(defun parse-liquidsoap-metadata (raw-metadata)
"Parse Liquidsoap metadata string and extract current track info"
(when (and raw-metadata (> (length raw-metadata) 0))
;; The metadata contains multiple tracks, separated by --- N ---
;; --- 1 --- is the CURRENT track (most recent), at the end of the output
;; Split by --- N --- pattern and get the last section
(let* ((sections (cl-ppcre:split "---\\s*\\d+\\s*---" raw-metadata))
(current-section (car (last sections))))
(when current-section
(let ((artist (cl-ppcre:register-groups-bind (val)
("artist=\"([^\"]+)\"" current-section) val))
(title (cl-ppcre:register-groups-bind (val)
("title=\"([^\"]+)\"" current-section) val))
(album (cl-ppcre:register-groups-bind (val)
("album=\"([^\"]+)\"" current-section) val)))
(if (or artist title)
(format nil "~@[~a~]~@[ - ~a~]~@[ (~a)~]"
artist title album)
"Unknown"))))))
(defun format-remaining-time (seconds-str)
"Format remaining seconds as MM:SS"
(handler-case
(let ((seconds (parse-integer (cl-ppcre:regex-replace "\\..*" seconds-str ""))))
(format nil "~d:~2,'0d" (floor seconds 60) (mod seconds 60)))
(error () seconds-str)))
(define-api asteroid/liquidsoap/status () ()
"Get Liquidsoap status including uptime and current track"
(require-role :admin)
(with-error-handling
(let ((uptime (liquidsoap-command "uptime"))
(metadata-raw (liquidsoap-command "output.icecast.1.metadata"))
(remaining-raw (liquidsoap-command "output.icecast.1.remaining")))
(api-output `(("status" . "success")
("uptime" . ,(string-trim '(#\Space #\Newline #\Return) uptime))
("metadata" . ,(parse-liquidsoap-metadata metadata-raw))
("remaining" . ,(format-remaining-time
(string-trim '(#\Space #\Newline #\Return) remaining-raw))))))))
(define-api asteroid/liquidsoap/skip () ()
"Skip the current track in Liquidsoap"
(require-role :admin)
(with-error-handling
(let ((result (liquidsoap-command "stream-queue_m3u.skip")))
(api-output `(("status" . "success")
("message" . "Track skipped")
("result" . ,(string-trim '(#\Space #\Newline #\Return) result)))))))
(define-api asteroid/liquidsoap/reload () ()
"Force Liquidsoap to reload the playlist"
(require-role :admin)
(with-error-handling
(let ((result (liquidsoap-command "stream-queue_m3u.reload")))
(api-output `(("status" . "success")
("message" . "Playlist reloaded")
("result" . ,(string-trim '(#\Space #\Newline #\Return) result)))))))
(define-api asteroid/liquidsoap/restart () ()
"Restart the Liquidsoap Docker container"
(require-role :admin)
(with-error-handling
(let ((result (uiop:run-program
"docker restart asteroid-liquidsoap"
:output :string
:error-output :string
:ignore-error-status t)))
(api-output `(("status" . "success")
("message" . "Liquidsoap container restarting")
("result" . ,result))))))
(define-api asteroid/icecast/restart () ()
"Restart the Icecast Docker container"
(require-role :admin)
(with-error-handling
(let ((result (uiop:run-program
"docker restart asteroid-icecast"
:output :string
:error-output :string
:ignore-error-status t)))
(api-output `(("status" . "success")
("message" . "Icecast container restarting")
("result" . ,result))))))
(defun get-track-by-id (track-id)
"Get a track by its ID - handles type mismatches"
(dm:get-one "tracks" (db:query (:= '_id track-id))))
@ -318,28 +558,26 @@
(define-page stream-track #@"/tracks/(.*)/stream" (:uri-groups (track-id))
"Stream audio file by track ID"
(with-error-handling
(let* ((id (parse-integer track-id))
(track (get-track-by-id id)))
(unless track
(signal-not-found "track" id))
(let* ((file-path (dm:field track "file-path"))
(format (dm:field track "format"))
(file (probe-file file-path)))
(unless file
(error 'not-found-error
:message "Audio file not found on disk"
:resource-type "file"
:resource-id file-path))
;; Set appropriate headers for audio streaming
(setf (radiance:header "Content-Type") (get-mime-type-for-format format))
(setf (radiance:header "Accept-Ranges") "bytes")
(setf (radiance:header "Cache-Control") "public, max-age=3600")
;; Increment play count
(setf (dm:field track "play-count") (1+ (dm:field track "play-count")))
(data-model-save track)
;; Return file contents
(alexandria:read-file-into-byte-vector file)))))
(let* ((id (parse-integer track-id :junk-allowed t))
(track (when id (get-track-by-id id))))
(if (not track)
(progn
(setf (radiance:header "Content-Type") "text/plain")
"Track not found")
(let* ((file-path (dm:field track "file-path"))
(format (dm:field track "format"))
(file (probe-file file-path)))
(if (not file)
(progn
(setf (radiance:header "Content-Type") "text/plain")
"Audio file not found")
(progn
;; Set appropriate headers for audio streaming
(setf (radiance:header "Content-Type") (get-mime-type-for-format format))
(setf (radiance:header "Accept-Ranges") "bytes")
(setf (radiance:header "Cache-Control") "public, max-age=3600")
;; Return file contents
(alexandria:read-file-into-byte-vector file)))))))
;; Player state management
(defvar *current-track* nil "Currently playing track")
@ -823,11 +1061,16 @@
(define-api asteroid/user/listening-stats () ()
"Get user listening statistics"
(require-authentication)
(api-output `(("status" . "success")
("stats" . (("total_listen_time" . 0)
("tracks_played" . 0)
("session_count" . 0)
("favorite_genre" . "Unknown"))))))
(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")))))))
(define-api asteroid/user/recent-tracks (&optional (limit "3")) ()
"Get recently played tracks for user"
@ -930,6 +1173,27 @@
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
:default-stream-encoding "audio/aac"))
;; About page (non-frameset mode)
(define-page about-page #@"/about" ()
"About Asteroid Radio"
(clip:process-to-string
(load-template "about")
:title "About - Asteroid Radio"))
;; About content (for frameset mode)
(define-page about-content #@"/about-content" ()
"About page content (displayed in content frame)"
(clip:process-to-string
(load-template "about-content")
:title "About - Asteroid Radio"))
;; Status content (for frameset mode)
(define-page status-content #@"/status-content" ()
"Status page content (displayed in content frame)"
(clip:process-to-string
(load-template "status-content")
:title "Status - Asteroid Radio"))
(define-api asteroid/status () ()
"Get server status"
(api-output `(("status" . "running")
@ -979,6 +1243,39 @@
`(("error" . "Could not connect to Icecast server"))
:status 503)))))
;;; Listener Statistics API Endpoints
(define-api asteroid/stats/current () ()
"Get current listener count from recent snapshots"
(let ((listeners (get-current-listeners)))
(api-output `(("status" . "success")
("listeners" . ,listeners)
("timestamp" . ,(get-universal-time))))))
(define-api asteroid/stats/daily (&optional (days "30")) ()
"Get daily listener statistics (admin only)"
(require-role :admin)
(let ((stats (get-daily-stats (parse-integer days :junk-allowed t))))
(api-output `(("status" . "success")
("stats" . ,(mapcar (lambda (row)
`(("date" . ,(first row))
("mount" . ,(second row))
("unique_listeners" . ,(third row))
("peak_concurrent" . ,(fourth row))
("total_listen_minutes" . ,(fifth row))
("avg_session_minutes" . ,(sixth row))))
stats))))))
(define-api asteroid/stats/geo (&optional (days "7")) ()
"Get geographic distribution of listeners (admin only)"
(require-role :admin)
(let ((stats (get-geo-stats (parse-integer days :junk-allowed t))))
(api-output `(("status" . "success")
("geo" . ,(mapcar (lambda (row)
`(("country_code" . ,(first row))
("total_listeners" . ,(second row))
("total_minutes" . ,(third row))))
stats))))))
;; RADIANCE server management functions
@ -991,11 +1288,24 @@
;; (unless (radiance:environment)
;; (setf (radiance:environment) "asteroid"))
(radiance:startup))
(radiance:startup)
;; Start listener statistics polling
(handler-case
(progn
(format t "Starting listener statistics polling...~%")
(start-stats-polling))
(error (e)
(format t "Warning: Could not start stats polling: ~a~%" e))))
(defun stop-server ()
"Stop the Asteroid Radio RADIANCE server"
(format t "Stopping Asteroid Radio server...~%")
;; Stop listener statistics polling
(handler-case
(stop-stats-polling)
(error (e)
(format t "Warning: Error stopping stats polling: ~a~%" e)))
(radiance:shutdown)
(format t "Server stopped.~%"))

View File

@ -1,5 +1,19 @@
(in-package :asteroid)
;; Database connection parameters for direct postmodern queries
(defun get-db-connection-params ()
"Get database connection parameters for postmodern"
(list (or (uiop:getenv "ASTEROID_DB_NAME") "asteroid")
(or (uiop:getenv "ASTEROID_DB_USER") "asteroid")
(or (uiop:getenv "ASTEROID_DB_PASSWORD") "asteroid_db_2025")
(or (uiop:getenv "ASTEROID_DB_HOST") "localhost")
:port (parse-integer (or (uiop:getenv "ASTEROID_DB_PORT") "5432"))))
(defmacro with-db (&body body)
"Execute body with database connection"
`(postmodern:with-connection (get-db-connection-params)
,@body))
;; Database initialization - must be in db:connected trigger because
;; the system could load before the database is ready.

View File

@ -26,7 +26,7 @@ services:
volumes:
- ${MUSIC_LIBRARY:-../music/library}:/app/music:ro
- ./asteroid-radio-docker.liq:/app/asteroid-radio.liq:ro
- ${QUEUE_PLAYLIST:-../stream-queue.m3u}:/app/stream-queue.m3u:ro
- ${QUEUE_PLAYLIST:-../playlists/stream-queue.m3u}:/app/stream-queue.m3u:ro
restart: unless-stopped
networks:
- asteroid-network

View File

@ -12,9 +12,8 @@ CREATE TABLE IF NOT EXISTS "USERS" (
"password-hash" TEXT NOT NULL,
role VARCHAR(50) DEFAULT 'listener',
active integer DEFAULT 1,
-- "created-date" integer DEFAULT CURRENT_TIMESTAMP,
"created-date" integer,
"last-login" integer,
"created-date" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"last-login" TIMESTAMP,
CONSTRAINT valid_role CHECK (role IN ('listener', 'dj', 'admin'))
);

View File

@ -0,0 +1,337 @@
#+TITLE: Listener Statistics Feature Design
#+AUTHOR: Glenn / Cascade
#+DATE: 2025-12-08
#+OPTIONS: toc:2 num:t
* Overview
This document outlines the design for implementing listener statistics
in Asteroid Radio, including real-time listener counts, historical
trends, geographic distribution, and user engagement metrics.
* Requirements
** Functional Requirements
- [ ] Display current listener count per stream/mount
- [ ] Track peak listeners by hour/day/week/month
- [ ] Show geographic distribution of listeners (country/city)
- [ ] Track new vs returning listeners
- [ ] Calculate average listen duration
- [ ] Provide breakdown by time of day
- [ ] Export statistics as CSV/JSON
** Non-Functional Requirements
- Minimal performance impact on streaming
- Privacy-conscious data collection
- GDPR compliance for EU listeners
- Data retention policy (configurable)
* Architecture
** Data Sources
*** Icecast Statistics API
Icecast provides listener data via its admin interface:
| Endpoint | Format | Auth Required |
|-----------------------+--------+---------------|
| /admin/stats | XML | Yes |
| /status-json.xsl | JSON | No |
| /admin/listclients | XML | Yes |
Data available per listener:
- IP address
- User agent (browser/player)
- Connection duration
- Mount point
- Connected timestamp
*** Radiance User Sessions
For registered users:
- Login timestamps
- Session duration
- User preferences
** Data Flow
#+BEGIN_SRC
Icecast ──► Polling Service ──► PostgreSQL ──► Admin Dashboard
│ │ │
│ ▼ │
│ GeoIP Lookup │
│ │ │
└──────────────┴───────────────────┘
#+END_SRC
* Database Schema
** listener_snapshots
Periodic snapshots of listener counts (every 1-5 minutes).
#+BEGIN_SRC sql
CREATE TABLE listener_snapshots (
_id SERIAL PRIMARY KEY,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
mount VARCHAR(100) NOT NULL,
listener_count INTEGER NOT NULL,
INDEX idx_snapshots_timestamp (timestamp),
INDEX idx_snapshots_mount (mount)
);
#+END_SRC
** listener_sessions
Individual listener connection records.
#+BEGIN_SRC sql
CREATE TABLE listener_sessions (
_id SERIAL PRIMARY KEY,
session_id VARCHAR(64) UNIQUE NOT NULL,
session_start TIMESTAMP NOT NULL,
session_end TIMESTAMP,
ip_hash VARCHAR(64) NOT NULL, -- SHA256 hash for privacy
country_code VARCHAR(2),
city VARCHAR(100),
region VARCHAR(100),
user_agent TEXT,
mount VARCHAR(100) NOT NULL,
duration_seconds INTEGER,
INDEX idx_sessions_start (session_start),
INDEX idx_sessions_country (country_code)
);
#+END_SRC
** listener_daily_stats
Pre-aggregated daily statistics for efficient querying.
#+BEGIN_SRC sql
CREATE TABLE listener_daily_stats (
_id SERIAL PRIMARY KEY,
date DATE UNIQUE NOT NULL,
mount VARCHAR(100) NOT NULL,
unique_listeners INTEGER DEFAULT 0,
peak_concurrent INTEGER DEFAULT 0,
total_listen_minutes INTEGER DEFAULT 0,
new_listeners INTEGER DEFAULT 0,
returning_listeners INTEGER DEFAULT 0,
avg_session_minutes DECIMAL(10,2),
UNIQUE(date, mount)
);
#+END_SRC
** listener_hourly_stats
Hourly breakdown for time-of-day analysis.
#+BEGIN_SRC sql
CREATE TABLE listener_hourly_stats (
_id SERIAL PRIMARY KEY,
date DATE NOT NULL,
hour INTEGER NOT NULL CHECK (hour >= 0 AND hour <= 23),
mount VARCHAR(100) NOT NULL,
unique_listeners INTEGER DEFAULT 0,
peak_concurrent INTEGER DEFAULT 0,
UNIQUE(date, hour, mount)
);
#+END_SRC
** listener_geo_stats
Geographic aggregates.
#+BEGIN_SRC sql
CREATE TABLE listener_geo_stats (
_id SERIAL PRIMARY KEY,
date DATE NOT NULL,
country_code VARCHAR(2) NOT NULL,
city VARCHAR(100),
listener_count INTEGER DEFAULT 0,
listen_minutes INTEGER DEFAULT 0,
UNIQUE(date, country_code, city)
);
#+END_SRC
* Implementation Components
** 1. Icecast Polling Service
A background thread in Asteroid that polls Icecast periodically.
#+BEGIN_SRC lisp
(defvar *stats-polling-thread* nil)
(defvar *stats-polling-interval* 60) ; seconds
(defun start-stats-polling ()
"Start the background statistics polling thread"
(setf *stats-polling-thread*
(bt:make-thread
(lambda ()
(loop
(handler-case
(poll-icecast-stats)
(error (e)
(log:error "Stats polling error: ~a" e)))
(sleep *stats-polling-interval*)))
:name "stats-poller")))
(defun poll-icecast-stats ()
"Fetch current stats from Icecast and store snapshot"
(let* ((response (drakma:http-request
"http://localhost:8000/status-json.xsl"
:want-stream nil))
(stats (cl-json:decode-json-from-string response)))
(process-icecast-stats stats)))
#+END_SRC
** 2. GeoIP Integration
Options for geographic lookup:
*** Option A: MaxMind GeoLite2 (Recommended)
- Free database, requires account
- ~60MB database file, updated weekly
- No API rate limits
- Requires: cl-geoip or FFI to libmaxminddb
*** Option B: External API (ip-api.com)
- Free tier: 45 requests/minute
- No local database needed
- Simpler implementation
- Rate limiting concerns with many listeners
*** Option C: ipinfo.io
- Free tier: 50,000 requests/month
- Good accuracy
- Simple REST API
Recommended: MaxMind GeoLite2 for production, ip-api.com for development.
#+BEGIN_SRC lisp
(defun lookup-geo-ip (ip-address)
"Look up geographic location for an IP address"
(handler-case
(let* ((url (format nil "http://ip-api.com/json/~a" ip-address))
(response (drakma:http-request url))
(data (cl-json:decode-json-from-string response)))
(list :country (cdr (assoc :country-code data))
:city (cdr (assoc :city data))
:region (cdr (assoc :region-name data))))
(error () nil)))
#+END_SRC
** 3. Aggregation Jobs
Daily/hourly jobs to compute aggregates from raw data.
#+BEGIN_SRC lisp
(defun aggregate-daily-stats (date)
"Compute daily aggregates from listener_sessions"
(db:query
"INSERT INTO listener_daily_stats
(date, mount, unique_listeners, peak_concurrent, total_listen_minutes)
SELECT
$1::date,
mount,
COUNT(DISTINCT ip_hash),
(SELECT MAX(listener_count) FROM listener_snapshots
WHERE timestamp::date = $1::date),
SUM(duration_seconds) / 60
FROM listener_sessions
WHERE session_start::date = $1::date
GROUP BY mount
ON CONFLICT (date, mount) DO UPDATE SET
unique_listeners = EXCLUDED.unique_listeners,
peak_concurrent = EXCLUDED.peak_concurrent,
total_listen_minutes = EXCLUDED.total_listen_minutes"
date))
#+END_SRC
** 4. Admin Dashboard UI
New admin page showing:
- Real-time listener count (WebSocket or polling)
- Charts: listeners over time (Chart.js or similar)
- Geographic map (Leaflet.js)
- Tables: top countries, peak hours, user agents
* Privacy Considerations
** IP Address Handling
- Hash IP addresses before storage (SHA256)
- Original IPs only held in memory during GeoIP lookup
- Never log or store raw IPs
** Data Retention
- Raw session data: 30 days (configurable)
- Aggregated stats: indefinite
- Automated cleanup job
** GDPR Compliance
- No personally identifiable information stored
- Hashed IPs cannot be reversed
- Geographic data is approximate (city-level)
* API Endpoints
| Endpoint | Method | Description |
|---------------------------------+--------+--------------------------------|
| /api/asteroid/stats/current | GET | Current listener count |
| /api/asteroid/stats/daily | GET | Daily stats (date range) |
| /api/asteroid/stats/hourly | GET | Hourly breakdown |
| /api/asteroid/stats/geo | GET | Geographic distribution |
| /api/asteroid/stats/export | GET | Export as CSV/JSON |
* Implementation Phases
** Phase 1: Basic Polling & Storage [0/4]
- [ ] Create database tables
- [ ] Implement Icecast polling service
- [ ] Store listener snapshots
- [ ] Display current count in admin
** Phase 2: Session Tracking [0/3]
- [ ] Track individual listener sessions
- [ ] Implement IP hashing
- [ ] Calculate session durations
** Phase 3: Geographic Data [0/3]
- [ ] Integrate GeoIP lookup
- [ ] Store geographic data
- [ ] Display country/city breakdown
** Phase 4: Aggregation & Analytics [0/4]
- [ ] Daily aggregation job
- [ ] Hourly breakdown
- [ ] New vs returning listeners
- [ ] Charts in admin dashboard
** Phase 5: Advanced Features [0/3]
- [ ] Real-time updates (WebSocket)
- [ ] Geographic map visualization
- [ ] Export functionality
* Dependencies
** Lisp Libraries
- drakma (HTTP client) - already included
- cl-json (JSON parsing) - already included
- bordeaux-threads (background polling) - already included
- ironclad (IP hashing) - already included
** JavaScript Libraries (for dashboard)
- Chart.js - charting
- Leaflet.js - geographic maps (optional)
** External Services
- MaxMind GeoLite2 or ip-api.com for GeoIP
* Open Questions
1. What polling interval is acceptable? (1 min, 5 min?)
2. How long to retain raw session data?
3. Should we track user agents for browser/app breakdown?
4. Do we need real-time WebSocket updates or is polling OK?
5. Geographic map - worth the complexity?
* References
- Icecast Admin API: https://icecast.org/docs/icecast-2.4.1/admin-interface.html
- MaxMind GeoLite2: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
- ip-api.com: https://ip-api.com/docs

418
listener-stats.lisp Normal file
View File

@ -0,0 +1,418 @@
;;;; listener-stats.lisp - Listener Statistics Collection Service
;;;; Polls Icecast for listener data and stores with GDPR compliance
(in-package #:asteroid)
;;; Note: get-db-connection-params and with-db are defined in database.lisp
;;; Configuration
(defvar *stats-polling-interval* 60
"Seconds between Icecast polls")
(defvar *stats-polling-thread* nil
"Background thread for polling")
(defvar *stats-polling-active* nil
"Flag to control polling loop")
(defvar *icecast-stats-url* "http://localhost:8000/admin/stats"
"Icecast admin stats endpoint (XML)")
(defvar *icecast-admin-user* "admin"
"Icecast admin username")
(defvar *icecast-admin-pass* "asteroid_admin_2024"
"Icecast admin password")
(defvar *geoip-api-url* "http://ip-api.com/json/~a?fields=status,countryCode,city,regionName"
"GeoIP lookup API (free tier: 45 req/min)")
(defvar *session-retention-days* 30
"Days to retain individual session data")
;;; Active listener tracking (in-memory)
(defvar *active-listeners* (make-hash-table :test 'equal)
"Hash table tracking active listeners by IP hash")
;;; Geo lookup cache (IP hash -> country code)
(defvar *geo-cache* (make-hash-table :test 'equal)
"Cache of IP hash to country code mappings")
(defvar *geo-cache-ttl* 3600
"Seconds to cache geo lookups (1 hour)")
;;; Utility Functions
(defun hash-ip-address (ip-address)
"Hash an IP address using SHA256 for privacy-safe storage"
(let ((digest (ironclad:make-digest :sha256)))
(ironclad:update-digest digest (babel:string-to-octets ip-address))
(ironclad:byte-array-to-hex-string (ironclad:produce-digest digest))))
(defun generate-session-id ()
"Generate a unique session ID"
(let ((digest (ironclad:make-digest :sha256)))
(ironclad:update-digest digest
(babel:string-to-octets
(format nil "~a-~a" (get-universal-time) (random 1000000))))
(subseq (ironclad:byte-array-to-hex-string (ironclad:produce-digest digest)) 0 32)))
;;; GeoIP Lookup
(defun lookup-geoip (ip-address)
"Look up geographic location for an IP address.
Returns plist with :country-code :city :region or NIL on failure.
Note: Does not store the raw IP - only uses it for lookup."
(handler-case
(let* ((url (format nil *geoip-api-url* ip-address))
(response (drakma:http-request url :want-stream nil))
(data (cl-json:decode-json-from-string response)))
(when (string= (cdr (assoc :status data)) "success")
(list :country-code (cdr (assoc :country-code data))
:city (cdr (assoc :city data))
:region (cdr (assoc :region-name data)))))
(error (e)
(log:debug "GeoIP lookup failed for ~a: ~a" ip-address e)
nil)))
;;; Icecast Polling
(defun extract-xml-value (xml tag)
"Extract value between XML tags. Simple regex-based extraction."
(let ((pattern (format nil "<~a>([^<]*)</~a>" tag tag)))
(multiple-value-bind (match groups)
(cl-ppcre:scan-to-strings pattern xml)
(when match
(aref groups 0)))))
(defun extract-xml-sources (xml)
"Extract all source blocks from Icecast XML"
(let ((sources nil)
(pattern "<source mount=\"([^\"]+)\">(.*?)</source>"))
(cl-ppcre:do-register-groups (mount content) (pattern xml)
(let ((listeners (extract-xml-value content "listeners"))
(listener-peak (extract-xml-value content "listener_peak"))
(server-name (extract-xml-value content "server_name")))
(push (list :mount mount
:server-name server-name
:listeners (if listeners (parse-integer listeners :junk-allowed t) 0)
:listener-peak (if listener-peak (parse-integer listener-peak :junk-allowed t) 0))
sources)))
(nreverse sources)))
(defun fetch-icecast-stats ()
"Fetch current statistics from Icecast admin XML endpoint"
(handler-case
(let ((response (drakma:http-request *icecast-stats-url*
:want-stream nil
:connection-timeout 5
:basic-authorization (list *icecast-admin-user*
*icecast-admin-pass*))))
;; Response is XML, return as string for parsing
(if (stringp response)
response
(babel:octets-to-string response :encoding :utf-8)))
(error (e)
(log:warn "Failed to fetch Icecast stats: ~a" e)
nil)))
(defun parse-icecast-sources (xml-string)
"Parse Icecast XML stats and extract source/mount information.
Returns list of plists with mount info."
(when xml-string
(extract-xml-sources xml-string)))
(defun fetch-icecast-listclients (mount)
"Fetch listener list for a specific mount from Icecast admin"
(handler-case
(let* ((url (format nil "http://localhost:8000/admin/listclients?mount=~a" mount))
(response (drakma:http-request url
:want-stream nil
:connection-timeout 5
:basic-authorization (list *icecast-admin-user*
*icecast-admin-pass*))))
(if (stringp response)
response
(babel:octets-to-string response :encoding :utf-8)))
(error (e)
(log:debug "Failed to fetch listclients for ~a: ~a" mount e)
nil)))
(defun extract-listener-ips (xml-string)
"Extract listener IPs from Icecast listclients XML"
(let ((ips nil)
(pattern "<IP>([^<]+)</IP>"))
(cl-ppcre:do-register-groups (ip) (pattern xml-string)
(push ip ips))
(nreverse ips)))
;;; Database Operations
(defun store-listener-snapshot (mount listener-count)
"Store a listener count snapshot"
(handler-case
(with-db
(postmodern:query
(:insert-into 'listener_snapshots :set 'mount mount 'listener_count listener-count)))
(error (e)
(log:error "Failed to store snapshot: ~a" e))))
(defun store-listener-session (session-id ip-hash mount &key country-code city region user-agent user-id)
"Create a new listener session record"
(handler-case
(with-db
(postmodern:query
(:insert-into 'listener_sessions
:set 'session_id session-id
'ip_hash ip-hash
'mount mount
'country_code country-code
'city city
'region region
'user_agent user-agent
'user_id user-id)))
(error (e)
(log:error "Failed to store session: ~a" e))))
(defun end-listener-session (session-id)
"Mark a listener session as ended and calculate duration"
(handler-case
(with-db
(postmodern:execute
(format nil "UPDATE listener_sessions
SET session_end = NOW(),
duration_seconds = EXTRACT(EPOCH FROM (NOW() - session_start))::INTEGER
WHERE session_id = '~a' AND session_end IS NULL" session-id)))
(error (e)
(log:error "Failed to end session: ~a" e))))
(defun cleanup-old-sessions ()
"Remove session data older than retention period (GDPR compliance)"
(handler-case
(with-db
(let ((result (postmodern:query
(format nil "SELECT cleanup_old_listener_data(~a)" *session-retention-days*))))
(log:info "Session cleanup completed: ~a records removed" (caar result))))
(error (e)
(log:error "Session cleanup failed: ~a" e))))
(defun update-geo-stats (country-code listener-count)
"Update geo stats for today"
(when country-code
(handler-case
(with-db
(postmodern:execute
(format nil "INSERT INTO listener_geo_stats (date, country_code, listener_count, listen_minutes)
VALUES (CURRENT_DATE, '~a', ~a, 1)
ON CONFLICT (date, country_code)
DO UPDATE SET listener_count = listener_geo_stats.listener_count + ~a,
listen_minutes = listener_geo_stats.listen_minutes + 1"
country-code listener-count listener-count)))
(error (e)
(log:error "Failed to update geo stats: ~a" e)))))
;;; Statistics Aggregation
;;; Note: Complex aggregation queries use raw SQL via postmodern:execute
(defun aggregate-daily-stats (date)
"Compute daily aggregates from session data"
(handler-case
(with-db
(postmodern:execute
(format nil "INSERT INTO listener_daily_stats
(date, mount, unique_listeners, peak_concurrent, total_listen_minutes, avg_session_minutes)
SELECT
'~a'::date,
mount,
COUNT(DISTINCT ip_hash),
(SELECT COALESCE(MAX(listener_count), 0) FROM listener_snapshots
WHERE timestamp::date = '~a'::date AND listener_snapshots.mount = listener_sessions.mount),
COALESCE(SUM(duration_seconds) / 60, 0),
COALESCE(AVG(duration_seconds) / 60.0, 0)
FROM listener_sessions
WHERE session_start::date = '~a'::date
GROUP BY mount
ON CONFLICT (date, mount) DO UPDATE SET
unique_listeners = EXCLUDED.unique_listeners,
peak_concurrent = EXCLUDED.peak_concurrent,
total_listen_minutes = EXCLUDED.total_listen_minutes,
avg_session_minutes = EXCLUDED.avg_session_minutes" date date date)))
(error (e)
(log:error "Failed to aggregate daily stats: ~a" e))))
(defun aggregate-hourly-stats (date hour)
"Compute hourly aggregates"
(handler-case
(with-db
(postmodern:execute
(format nil "INSERT INTO listener_hourly_stats (date, hour, mount, unique_listeners, peak_concurrent)
SELECT
'~a'::date,
~a,
mount,
COUNT(DISTINCT ip_hash),
COALESCE(MAX(listener_count), 0)
FROM listener_sessions ls
LEFT JOIN listener_snapshots lsn ON lsn.mount = ls.mount
AND DATE_TRUNC('hour', lsn.timestamp) = DATE_TRUNC('hour', ls.session_start)
WHERE session_start::date = '~a'::date
AND EXTRACT(HOUR FROM session_start) = ~a
GROUP BY mount
ON CONFLICT (date, hour, mount) DO UPDATE SET
unique_listeners = EXCLUDED.unique_listeners,
peak_concurrent = EXCLUDED.peak_concurrent" date hour date hour)))
(error (e)
(log:error "Failed to aggregate hourly stats: ~a" e))))
;;; Query Functions (for API endpoints)
(defun get-current-listeners ()
"Get current listener count from most recent snapshot"
(handler-case
(with-db
(let ((result (postmodern:query
"SELECT mount, listener_count, timestamp
FROM listener_snapshots
WHERE timestamp > NOW() - INTERVAL '5 minutes'
ORDER BY timestamp DESC")))
(mapcar (lambda (row)
(list :mount (first row)
:listeners (second row)
:timestamp (third row)))
result)))
(error (e)
(log:error "Failed to get current listeners: ~a" e)
nil)))
(defun get-daily-stats (&optional (days 30))
"Get daily statistics for the last N days"
(handler-case
(with-db
(postmodern:query
(format nil "SELECT date, mount, unique_listeners, peak_concurrent, total_listen_minutes, avg_session_minutes
FROM listener_daily_stats
WHERE date > NOW() - INTERVAL '~a days'
ORDER BY date DESC" days)))
(error (e)
(log:error "Failed to get daily stats: ~a" e)
nil)))
(defun get-geo-stats (&optional (days 7))
"Get geographic distribution for the last N days"
(handler-case
(with-db
(postmodern:query
(format nil "SELECT country_code, SUM(listener_count) as total_listeners, SUM(listen_minutes) as total_minutes
FROM listener_geo_stats
WHERE date > NOW() - INTERVAL '~a days'
GROUP BY country_code
ORDER BY total_listeners DESC
LIMIT 20" days)))
(error (e)
(log:error "Failed to get geo stats: ~a" e)
nil)))
(defun get-user-listening-stats (user-id)
"Get listening statistics for a specific user"
(handler-case
(with-db
(let ((total-time (caar (postmodern:query
(format nil "SELECT COALESCE(SUM(duration_seconds), 0)
FROM listener_sessions WHERE user_id = ~a" user-id))))
(session-count (caar (postmodern:query
(format nil "SELECT COUNT(*) FROM listener_sessions WHERE user_id = ~a" user-id))))
(track-count (caar (postmodern:query
(format nil "SELECT COUNT(*) FROM user_listening_history WHERE user_id = ~a" user-id)))))
(list :total-listen-time (or total-time 0)
:session-count (or session-count 0)
:tracks-played (or track-count 0))))
(error (e)
(log:error "Failed to get user stats: ~a" e)
(list :total-listen-time 0 :session-count 0 :tracks-played 0))))
;;; Polling Service
(defun get-cached-geo (ip)
"Get cached geo data for IP, or lookup and cache"
(let* ((ip-hash (hash-ip-address ip))
(cached (gethash ip-hash *geo-cache*)))
(if (and cached (< (- (get-universal-time) (getf cached :time)) *geo-cache-ttl*))
(getf cached :country)
;; Lookup and cache
(let ((geo (lookup-geoip ip)))
(when geo
(let ((country (getf geo :country-code)))
(setf (gethash ip-hash *geo-cache*)
(list :country country :time (get-universal-time)))
country))))))
(defun collect-geo-stats-for-mount (mount)
"Collect geo stats for all listeners on a mount"
(let ((listclients-xml (fetch-icecast-listclients mount)))
(when listclients-xml
(let ((ips (extract-listener-ips listclients-xml))
(country-counts (make-hash-table :test 'equal)))
;; Group by country
(dolist (ip ips)
(let ((country (get-cached-geo ip)))
(when country
(incf (gethash country country-counts 0)))))
;; Store each country's count
(maphash (lambda (country count)
(update-geo-stats country count))
country-counts)))))
(defun poll-and-store-stats ()
"Single poll iteration: fetch stats and store"
(let ((stats (fetch-icecast-stats)))
(when stats
(let ((sources (parse-icecast-sources stats)))
(dolist (source sources)
(let ((mount (getf source :mount))
(listeners (getf source :listeners)))
(when mount
(store-listener-snapshot mount listeners)
;; Collect geo stats if there are listeners
(when (and listeners (> listeners 0))
(collect-geo-stats-for-mount mount))
(log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))))))
(defun stats-polling-loop ()
"Main polling loop - runs in background thread"
(log:info "Listener statistics polling started (interval: ~as)" *stats-polling-interval*)
(loop while *stats-polling-active*
do (handler-case
(poll-and-store-stats)
(error (e)
(log:error "Polling error: ~a" e)))
(sleep *stats-polling-interval*))
(log:info "Listener statistics polling stopped"))
(defun start-stats-polling ()
"Start the background statistics polling thread"
(when *stats-polling-thread*
(stop-stats-polling))
(setf *stats-polling-active* t)
(setf *stats-polling-thread*
(bt:make-thread #'stats-polling-loop :name "stats-poller"))
(log:info "Stats polling thread started"))
(defun stop-stats-polling ()
"Stop the background statistics polling thread"
(setf *stats-polling-active* nil)
(when (and *stats-polling-thread* (bt:thread-alive-p *stats-polling-thread*))
(bt:join-thread *stats-polling-thread* :timeout 5))
(setf *stats-polling-thread* nil)
(log:info "Stats polling thread stopped"))
;;; Initialization
(defun init-listener-stats ()
"Initialize the listener statistics system"
(log:info "Initializing listener statistics system...")
(start-stats-polling))
(defun shutdown-listener-stats ()
"Shutdown the listener statistics system"
(log:info "Shutting down listener statistics system...")
(stop-stats-polling))

View File

@ -0,0 +1,127 @@
-- Migration: Listener Statistics Tables
-- Version: 002
-- Date: 2025-12-08
-- Description: Add tables for tracking listener statistics with GDPR compliance
-- Listener snapshots: periodic counts from Icecast polling
CREATE TABLE IF NOT EXISTS listener_snapshots (
_id SERIAL PRIMARY KEY,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
mount VARCHAR(100) NOT NULL,
listener_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp ON listener_snapshots(timestamp);
CREATE INDEX IF NOT EXISTS idx_snapshots_mount ON listener_snapshots(mount);
-- Listener sessions: individual connection records (privacy-safe)
CREATE TABLE IF NOT EXISTS listener_sessions (
_id SERIAL PRIMARY KEY,
session_id VARCHAR(64) UNIQUE NOT NULL,
session_start TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
session_end TIMESTAMP,
ip_hash VARCHAR(64) NOT NULL, -- SHA256 hash, not reversible
country_code VARCHAR(2),
city VARCHAR(100),
region VARCHAR(100),
user_agent TEXT,
mount VARCHAR(100) NOT NULL,
duration_seconds INTEGER,
user_id INTEGER REFERENCES "USERS"(_id) ON DELETE SET NULL -- Optional link to registered user
);
CREATE INDEX IF NOT EXISTS idx_sessions_start ON listener_sessions(session_start);
CREATE INDEX IF NOT EXISTS idx_sessions_ip_hash ON listener_sessions(ip_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_country ON listener_sessions(country_code);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON listener_sessions(user_id);
-- Daily aggregated statistics (for efficient dashboard queries)
CREATE TABLE IF NOT EXISTS listener_daily_stats (
_id SERIAL PRIMARY KEY,
date DATE NOT NULL,
mount VARCHAR(100) NOT NULL,
unique_listeners INTEGER DEFAULT 0,
peak_concurrent INTEGER DEFAULT 0,
total_listen_minutes INTEGER DEFAULT 0,
new_listeners INTEGER DEFAULT 0,
returning_listeners INTEGER DEFAULT 0,
avg_session_minutes DECIMAL(10,2),
UNIQUE(date, mount)
);
CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON listener_daily_stats(date);
-- Hourly breakdown for time-of-day analysis
CREATE TABLE IF NOT EXISTS listener_hourly_stats (
_id SERIAL PRIMARY KEY,
date DATE NOT NULL,
hour INTEGER NOT NULL CHECK (hour >= 0 AND hour <= 23),
mount VARCHAR(100) NOT NULL,
unique_listeners INTEGER DEFAULT 0,
peak_concurrent INTEGER DEFAULT 0,
UNIQUE(date, hour, mount)
);
CREATE INDEX IF NOT EXISTS idx_hourly_stats_date ON listener_hourly_stats(date);
-- Geographic aggregates
CREATE TABLE IF NOT EXISTS listener_geo_stats (
_id SERIAL PRIMARY KEY,
date DATE NOT NULL,
country_code VARCHAR(2) NOT NULL,
city VARCHAR(100),
listener_count INTEGER DEFAULT 0,
listen_minutes INTEGER DEFAULT 0,
UNIQUE(date, country_code, city)
);
CREATE INDEX IF NOT EXISTS idx_geo_stats_date ON listener_geo_stats(date);
CREATE INDEX IF NOT EXISTS idx_geo_stats_country ON listener_geo_stats(country_code);
-- User listening history (for registered users only)
CREATE TABLE IF NOT EXISTS user_listening_history (
_id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES "USERS"(_id) ON DELETE CASCADE,
track_title VARCHAR(500),
track_artist VARCHAR(500),
listened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
duration_seconds INTEGER
);
CREATE INDEX IF NOT EXISTS idx_user_history_user ON user_listening_history(user_id);
CREATE INDEX IF NOT EXISTS idx_user_history_listened ON user_listening_history(listened_at);
-- Data retention: function to clean old session data (GDPR compliance)
CREATE OR REPLACE FUNCTION cleanup_old_listener_data(retention_days INTEGER DEFAULT 30)
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
-- Delete individual sessions older than retention period
DELETE FROM listener_sessions
WHERE session_start < NOW() - (retention_days || ' days')::INTERVAL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RAISE NOTICE 'Cleaned up % listener session records older than % days', deleted_count, retention_days;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Grant permissions
GRANT ALL PRIVILEGES ON listener_snapshots TO asteroid;
GRANT ALL PRIVILEGES ON listener_sessions TO asteroid;
GRANT ALL PRIVILEGES ON listener_daily_stats TO asteroid;
GRANT ALL PRIVILEGES ON listener_hourly_stats TO asteroid;
GRANT ALL PRIVILEGES ON listener_geo_stats TO asteroid;
GRANT ALL PRIVILEGES ON user_listening_history TO asteroid;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO asteroid;
-- Success message
DO $$
BEGIN
RAISE NOTICE 'Listener statistics tables created successfully!';
RAISE NOTICE 'Tables: listener_snapshots, listener_sessions, listener_daily_stats, listener_hourly_stats, listener_geo_stats, user_listening_history';
RAISE NOTICE 'GDPR: IP addresses are hashed, cleanup function available';
END $$;

View File

@ -0,0 +1,33 @@
-- Migration 003: Timestamp Consistency
-- Convert USERS table integer timestamps to TIMESTAMP type
-- This aligns USERS with tracks and playlists tables which already use TIMESTAMP
-- Step 1: Add new TIMESTAMP columns
ALTER TABLE "USERS" ADD COLUMN IF NOT EXISTS "created-date-new" TIMESTAMP;
ALTER TABLE "USERS" ADD COLUMN IF NOT EXISTS "last-login-new" TIMESTAMP;
-- Step 2: Convert existing epoch integers to timestamps
-- Only convert non-null values; epoch 0 or very old dates indicate no real value
UPDATE "USERS"
SET "created-date-new" = TO_TIMESTAMP("created-date")
WHERE "created-date" IS NOT NULL
AND "created-date" > 0;
UPDATE "USERS"
SET "last-login-new" = TO_TIMESTAMP("last-login")
WHERE "last-login" IS NOT NULL
AND "last-login" > 0;
-- Step 3: Drop old integer columns
ALTER TABLE "USERS" DROP COLUMN IF EXISTS "created-date";
ALTER TABLE "USERS" DROP COLUMN IF EXISTS "last-login";
-- Step 4: Rename new columns to original names
ALTER TABLE "USERS" RENAME COLUMN "created-date-new" TO "created-date";
ALTER TABLE "USERS" RENAME COLUMN "last-login-new" TO "last-login";
-- Step 5: Set default for created-date (new users get current timestamp)
ALTER TABLE "USERS" ALTER COLUMN "created-date" SET DEFAULT CURRENT_TIMESTAMP;
-- Verification query (run manually to check results):
-- SELECT _id, username, "created-date", "last-login" FROM "USERS";

View File

@ -23,15 +23,12 @@
"DOMContentLoaded"
(lambda ()
(load-tracks)
(update-player-status)
(setup-event-listeners)
(load-stream-queue)
(setup-live-stream-monitor)
(update-live-stream-info)
;; Update live stream info every 10 seconds
(set-interval update-live-stream-info 10000)
;; Update player status every 5 seconds
(set-interval update-player-status 5000))))
(load-playlist-list)
(load-current-queue)
(refresh-liquidsoap-status)
;; Update Liquidsoap status every 10 seconds
(set-interval refresh-liquidsoap-status 10000))))
;; Setup all event listeners
(defun setup-event-listeners ()
@ -74,21 +71,52 @@
;; Queue controls
(let ((refresh-queue-btn (ps:chain document (get-element-by-id "refresh-queue")))
(load-m3u-btn (ps:chain document (get-element-by-id "load-from-m3u")))
(clear-queue-btn (ps:chain document (get-element-by-id "clear-queue-btn")))
(save-queue-btn (ps:chain document (get-element-by-id "save-queue-btn")))
(save-as-btn (ps:chain document (get-element-by-id "save-as-btn")))
(add-random-btn (ps:chain document (get-element-by-id "add-random-tracks")))
(queue-search-input (ps:chain document (get-element-by-id "queue-track-search"))))
(queue-search-input (ps:chain document (get-element-by-id "queue-track-search")))
;; Playlist controls
(playlist-select (ps:chain document (get-element-by-id "playlist-select")))
(load-playlist-btn (ps:chain document (get-element-by-id "load-playlist-btn")))
(refresh-playlists-btn (ps:chain document (get-element-by-id "refresh-playlists-btn"))))
(when refresh-queue-btn
(ps:chain refresh-queue-btn (add-event-listener "click" load-stream-queue)))
(when load-m3u-btn
(ps:chain load-m3u-btn (add-event-listener "click" load-queue-from-m3u)))
(ps:chain refresh-queue-btn (add-event-listener "click" load-current-queue)))
(when clear-queue-btn
(ps:chain clear-queue-btn (add-event-listener "click" clear-stream-queue)))
(when save-queue-btn
(ps:chain save-queue-btn (add-event-listener "click" save-stream-queue)))
(when save-as-btn
(ps:chain save-as-btn (add-event-listener "click" save-queue-as-new)))
(when add-random-btn
(ps:chain add-random-btn (add-event-listener "click" add-random-tracks)))
(when queue-search-input
(ps:chain queue-search-input (add-event-listener "input" search-tracks-for-queue)))))
(ps:chain queue-search-input (add-event-listener "input" search-tracks-for-queue)))
;; Playlist controls
(when load-playlist-btn
(ps:chain load-playlist-btn (add-event-listener "click" load-selected-playlist)))
(when refresh-playlists-btn
(ps:chain refresh-playlists-btn (add-event-listener "click" load-playlist-list))))
;; Liquidsoap controls
(let ((ls-refresh-btn (ps:chain document (get-element-by-id "ls-refresh-status")))
(ls-skip-btn (ps:chain document (get-element-by-id "ls-skip")))
(ls-reload-btn (ps:chain document (get-element-by-id "ls-reload")))
(ls-restart-btn (ps:chain document (get-element-by-id "ls-restart"))))
(when ls-refresh-btn
(ps:chain ls-refresh-btn (add-event-listener "click" refresh-liquidsoap-status)))
(when ls-skip-btn
(ps:chain ls-skip-btn (add-event-listener "click" liquidsoap-skip)))
(when ls-reload-btn
(ps:chain ls-reload-btn (add-event-listener "click" liquidsoap-reload)))
(when ls-restart-btn
(ps:chain ls-restart-btn (add-event-listener "click" liquidsoap-restart))))
;; Icecast restart
(let ((icecast-restart-btn (ps:chain document (get-element-by-id "icecast-restart"))))
(when icecast-restart-btn
(ps:chain icecast-restart-btn (add-event-listener "click" icecast-restart)))))
;; Load tracks from API
(defun load-tracks ()
@ -359,37 +387,6 @@
(defun open-incoming-folder ()
(alert "Copy your MP3 files to: /home/glenn/Projects/Code/asteroid/music/incoming/\n\nThen click \"Copy Files to Library\" to add them to your music collection."))
;; Setup live stream monitor
(defun setup-live-stream-monitor ()
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
(when live-audio
(setf (ps:@ live-audio preload) "none"))))
;; Live stream info update
(defun update-live-stream-info ()
;; Don't update if stream is paused
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
(when (and live-audio (ps:@ live-audio paused))
(return)))
(ps:chain
(fetch "/api/asteroid/partial/now-playing-inline")
(then (lambda (response)
(let ((content-type (ps:chain response headers (get "content-type"))))
(unless (ps:chain content-type (includes "text/plain"))
(ps:chain console (error "Unexpected content type:" content-type))
(return))
(ps:chain response (text)))))
(then (lambda (now-playing-text)
(let ((now-playing-el (ps:chain document (get-element-by-id "live-now-playing"))))
(when now-playing-el
(setf (ps:@ now-playing-el text-content) now-playing-text)))))
(catch (lambda (error)
(ps:chain console (error "Could not fetch stream info:" error))
(let ((now-playing-el (ps:chain document (get-element-by-id "live-now-playing"))))
(when now-playing-el
(setf (ps:@ now-playing-el text-content) "Error loading stream info")))))))
;; ========================================
;; Stream Queue Management
;; ========================================
@ -441,44 +438,6 @@
(setf html (+ html "</div>"))
(setf (ps:@ container inner-h-t-m-l) html))))))
;; Clear stream queue
(defun clear-stream-queue ()
(unless (confirm "Clear the entire stream queue? This will stop playback until new tracks are added.")
(return))
(ps:chain
(fetch "/api/asteroid/stream/queue/clear" (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 "Queue cleared successfully")
(load-stream-queue))
(alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error clearing queue:" error))
(alert "Error clearing queue")))))
;; Load queue from M3U file
(defun load-queue-from-m3u ()
(unless (confirm "Load queue from stream-queue.m3u file? This will replace the current queue.")
(return))
(ps:chain
(fetch "/api/asteroid/stream/queue/load-m3u" (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 (+ "Successfully loaded " (ps:@ data count) " tracks from M3U file!"))
(load-stream-queue))
(alert (+ "Error loading from M3U: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error loading from M3U:" error))
(alert (+ "Error loading from M3U: " (ps:@ error message)))))))
;; Move track up in queue
(defun move-track-up (index)
(when (= index 0) (return))
@ -645,6 +604,256 @@
(setf html (+ html "</div>"))
(setf (ps:@ container inner-h-t-m-l) html))))))
;; ========================================
;; Playlist File Management
;; ========================================
;; Load list of available playlists into dropdown
(defun load-playlist-list ()
(ps:chain
(fetch "/api/asteroid/stream/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(when (= (ps:@ data status) "success")
(let ((select (ps:chain document (get-element-by-id "playlist-select")))
(playlists (or (ps:@ data playlists) (array))))
(when select
;; Clear existing options except the first one
(setf (ps:@ select inner-h-t-m-l)
"<option value=\"\">-- Select a playlist --</option>")
;; Add playlist options
(ps:chain playlists
(for-each (lambda (name)
(let ((option (ps:chain document (create-element "option"))))
(setf (ps:@ option value) name)
(setf (ps:@ option text-content) name)
(ps:chain select (append-child option))))))))))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlists:" error))))))
;; Load selected playlist
(defun load-selected-playlist ()
(let* ((select (ps:chain document (get-element-by-id "playlist-select")))
(name (ps:@ select value)))
(when (= name "")
(alert "Please select a playlist first")
(return))
(unless (confirm (+ "Load playlist '" name "'? This will replace the current stream queue."))
(return))
(ps:chain
(fetch (+ "/api/asteroid/stream/playlists/load?name=" (encode-u-r-i-component name))
(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 (+ "✓ Loaded " (ps:@ data count) " tracks from " name))
(load-current-queue))
(alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlist:" error))
(alert "Error loading playlist"))))))
;; Load current queue contents (from stream-queue.m3u)
(defun load-current-queue ()
(ps:chain
(fetch "/api/asteroid/stream/playlists/current")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(when (= (ps:@ data status) "success")
(let ((tracks (or (ps:@ data tracks) (array)))
(count (or (ps:@ data count) 0)))
;; Update count display
(let ((count-el (ps:chain document (get-element-by-id "queue-count"))))
(when count-el
(setf (ps:@ count-el text-content) count)))
;; Display tracks
(display-current-queue tracks))))))
(catch (lambda (error)
(ps:chain console (error "Error loading current queue:" error))
(let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
(when container
(setf (ps:@ container inner-h-t-m-l)
"<div class=\"error\">Error loading queue</div>")))))))
;; Display current queue contents
(defun display-current-queue (tracks)
(let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
(when container
(if (= (ps:@ tracks length) 0)
(setf (ps:@ container inner-h-t-m-l)
"<div class=\"empty-state\">Queue is empty. Liquidsoap will use random playback from the music library.</div>")
(let ((html "<div class=\"queue-items\">"))
(ps:chain tracks
(for-each (lambda (track index)
(setf html
(+ html
"<div class=\"queue-item\" data-index=\"" index "\">"
"<span class=\"queue-position\">" (+ index 1) "</span>"
"<div class=\"queue-track-info\">"
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown") "</div>"
"<div class=\"track-artist\">" (or (ps:@ track artist) "Unknown Artist")
(if (ps:@ track album) (+ " - " (ps:@ track album)) "") "</div>"
"</div>"
"</div>")))))
(setf html (+ html "</div>"))
(setf (ps:@ container inner-h-t-m-l) html))))))
;; Save current queue to stream-queue.m3u
(defun save-stream-queue ()
(ps:chain
(fetch "/api/asteroid/stream/playlists/save" (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")
(show-toast "✓ Queue saved")
(alert (+ "Error saving queue: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error saving queue:" error))
(alert "Error saving queue")))))
;; Save queue as new playlist
(defun save-queue-as-new ()
(let* ((input (ps:chain document (get-element-by-id "save-as-name")))
(name (ps:chain (ps:@ input value) (trim))))
(when (= name "")
(alert "Please enter a name for the new playlist")
(return))
(ps:chain
(fetch (+ "/api/asteroid/stream/playlists/save-as?name=" (encode-u-r-i-component name))
(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 (+ "✓ Saved as " name))
(setf (ps:@ input value) "")
(load-playlist-list))
(alert (+ "Error saving playlist: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error saving playlist:" error))
(alert "Error saving playlist"))))))
;; Clear stream queue (updated to use new API)
(defun clear-stream-queue ()
(unless (confirm "Clear the stream queue? Liquidsoap will fall back to random playback from the music library.")
(return))
(ps:chain
(fetch "/api/asteroid/stream/playlists/clear" (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 "✓ Queue cleared")
(load-current-queue))
(alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error clearing queue:" error))
(alert "Error clearing queue")))))
;; ========================================
;; Liquidsoap Control Functions
;; ========================================
;; Refresh Liquidsoap status
(defun refresh-liquidsoap-status ()
(ps:chain
(fetch "/api/asteroid/liquidsoap/status")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(when (= (ps:@ data status) "success")
(let ((uptime-el (ps:chain document (get-element-by-id "ls-uptime")))
(remaining-el (ps:chain document (get-element-by-id "ls-remaining")))
(metadata-el (ps:chain document (get-element-by-id "ls-metadata"))))
(when uptime-el
(setf (ps:@ uptime-el text-content) (or (ps:@ data uptime) "--")))
(when remaining-el
(setf (ps:@ remaining-el text-content) (or (ps:@ data remaining) "--")))
(when metadata-el
(setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--"))))))))
(catch (lambda (error)
(ps:chain console (error "Error fetching Liquidsoap status:" error))))))
;; Skip current track
(defun liquidsoap-skip ()
(ps:chain
(fetch "/api/asteroid/liquidsoap/skip" (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 "⏭️ Track skipped")
(set-timeout refresh-liquidsoap-status 1000))
(alert (+ "Error skipping track: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error skipping track:" error))
(alert "Error skipping track")))))
;; Reload playlist
(defun liquidsoap-reload ()
(ps:chain
(fetch "/api/asteroid/liquidsoap/reload" (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")
(show-toast "📂 Playlist reloaded")
(alert (+ "Error reloading playlist: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error reloading playlist:" error))
(alert "Error reloading playlist")))))
;; Restart Liquidsoap container
(defun liquidsoap-restart ()
(unless (confirm "Restart Liquidsoap container? This will cause a brief interruption to the stream.")
(return))
(show-toast "🔄 Restarting Liquidsoap...")
(ps:chain
(fetch "/api/asteroid/liquidsoap/restart" (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 "✓ Liquidsoap restarting")
;; Refresh status after a delay to let container restart
(set-timeout refresh-liquidsoap-status 5000))
(alert (+ "Error restarting Liquidsoap: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error restarting Liquidsoap:" error))
(alert "Error restarting Liquidsoap")))))
;; Restart Icecast container
(defun icecast-restart ()
(unless (confirm "Restart Icecast container? This will disconnect all listeners temporarily.")
(return))
(show-toast "🔄 Restarting Icecast...")
(ps:chain
(fetch "/api/asteroid/icecast/restart" (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")
(show-toast "✓ Icecast restarting - listeners will reconnect automatically")
(alert (+ "Error restarting Icecast: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error restarting Icecast:" error))
(alert "Error restarting Icecast")))))
;; Make functions globally accessible for onclick handlers
(setf (ps:@ window go-to-page) go-to-page)
(setf (ps:@ window previous-page) previous-page)

View File

@ -7,6 +7,12 @@
(ps:ps*
'(progn
;; Stream connection state
(defvar *stream-error-count* 0)
(defvar *last-play-attempt* 0)
(defvar *is-reconnecting* false)
(defvar *reconnect-timeout* nil)
;; Stream quality configuration
(defun get-stream-config (stream-base-url encoding)
(let ((config (ps:create
@ -137,6 +143,249 @@
(ps:chain local-storage (remove-item "useFrameset"))
(setf (ps:@ window location href) "/asteroid/"))
;; Stream status UI functions
(defun show-stream-status (message status-type)
"Show a status message to the user. status-type: 'error', 'warning', 'success', 'info'"
(let ((indicator (ps:chain document (get-element-by-id "stream-status-indicator"))))
(when indicator
(setf (ps:@ indicator inner-text) message)
(setf (ps:@ indicator style display) "block")
(setf (ps:@ indicator style background)
(cond
((= status-type "error") "#550000")
((= status-type "warning") "#554400")
((= status-type "success") "#005500")
(t "#003355")))
(setf (ps:@ indicator style border)
(cond
((= status-type "error") "1px solid #ff0000")
((= status-type "warning") "1px solid #ffaa00")
((= status-type "success") "1px solid #00ff00")
(t "1px solid #00aaff"))))))
(defun hide-stream-status ()
"Hide the status indicator"
(let ((indicator (ps:chain document (get-element-by-id "stream-status-indicator"))))
(when indicator
(setf (ps:@ indicator style display) "none"))))
(defun show-reconnect-button ()
"Show the reconnect button"
(let ((btn (ps:chain document (get-element-by-id "reconnect-btn"))))
(when btn
(setf (ps:@ btn style display) "inline-block"))))
(defun hide-reconnect-button ()
"Hide the reconnect button"
(let ((btn (ps:chain document (get-element-by-id "reconnect-btn"))))
(when btn
(setf (ps:@ btn style display) "none"))))
;; Recreate audio element to fix wedged state
(defun recreate-audio-element ()
"Recreate the audio element entirely to fix wedged MediaElementSource"
(let* ((container (ps:chain document (get-element-by-id "audio-container")))
(old-audio (ps:chain document (get-element-by-id "live-audio")))
(stream-base-url (ps:chain document (get-element-by-id "stream-base-url")))
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac"))
(config (get-stream-config (ps:@ stream-base-url value) stream-quality)))
(when (and container old-audio)
;; Reset spectrum analyzer before removing audio
(when (ps:@ window |resetSpectrumAnalyzer|)
(ps:chain window (reset-spectrum-analyzer)))
;; Remove old audio element
(ps:chain old-audio (pause))
(setf (ps:@ old-audio src) "")
(ps:chain old-audio (remove))
;; Create new audio element
(let ((new-audio (ps:chain document (create-element "audio"))))
(setf (ps:@ new-audio id) "live-audio")
(setf (ps:@ new-audio controls) t)
(setf (ps:@ new-audio crossorigin) "anonymous")
(setf (ps:@ new-audio style width) "100%")
(setf (ps:@ new-audio style margin) "10px 0")
;; Create source element
(let ((source (ps:chain document (create-element "source"))))
(setf (ps:@ source id) "audio-source")
(setf (ps:@ source src) (ps:@ config url))
(setf (ps:@ source type) (ps:@ config type))
(ps:chain new-audio (append-child source)))
;; Add to container
(ps:chain container (append-child new-audio))
;; Re-attach event listeners
(attach-audio-event-listeners new-audio)
(ps:chain console (log "Audio element recreated"))
new-audio))))
;; Main reconnect function
(defun reconnect-stream ()
"Reconnect the stream - called by user or automatically"
(when *is-reconnecting*
(return))
(setf *is-reconnecting* t)
(show-stream-status "🔄 Reconnecting to stream..." "info")
(hide-reconnect-button)
;; Clear any pending reconnect timeout
(when *reconnect-timeout*
(clear-timeout *reconnect-timeout*)
(setf *reconnect-timeout* nil))
(let ((audio-element (ps:chain document (get-element-by-id "live-audio"))))
(if audio-element
;; Try simple reload first
(progn
(ps:chain audio-element (pause))
(ps:chain audio-element (load))
;; Resume AudioContext if suspended
(when (ps:@ window |resetSpectrumAnalyzer|)
(ps:chain window (reset-spectrum-analyzer)))
;; Try to play after a short delay
(set-timeout
(lambda ()
(ps:chain audio-element (play)
(then (lambda ()
(setf *stream-error-count* 0)
(setf *is-reconnecting* false)
(show-stream-status "✓ Stream reconnected!" "success")
(set-timeout hide-stream-status 3000)
;; Reinitialize spectrum analyzer
(when (ps:@ window |initSpectrumAnalyzer|)
(set-timeout
(lambda ()
(ps:chain window (init-spectrum-analyzer)))
500))))
(catch (lambda (err)
(ps:chain console (log "Simple reconnect failed, recreating audio element:" err))
;; Simple reload failed, recreate the audio element
(let ((new-audio (recreate-audio-element)))
(when new-audio
(set-timeout
(lambda ()
(ps:chain new-audio (play)
(then (lambda ()
(setf *stream-error-count* 0)
(setf *is-reconnecting* false)
(show-stream-status "✓ Stream reconnected!" "success")
(set-timeout hide-stream-status 3000)))
(catch (lambda (err2)
(setf *is-reconnecting* false)
(incf *stream-error-count*)
(show-stream-status "❌ Could not reconnect. Click play to try again." "error")
(show-reconnect-button)
(ps:chain console (log "Reconnect failed:" err2))))))
500)))))))
500))
;; No audio element found, try to recreate
(let ((new-audio (recreate-audio-element)))
(if new-audio
(set-timeout
(lambda ()
(ps:chain new-audio (play)
(then (lambda ()
(setf *is-reconnecting* false)
(show-stream-status "✓ Stream connected!" "success")
(set-timeout hide-stream-status 3000)))
(catch (lambda (err)
(setf *is-reconnecting* false)
(show-stream-status "❌ Could not connect. Click play to try again." "error")
(show-reconnect-button)))))
500)
(progn
(setf *is-reconnecting* false)
(show-stream-status "❌ Could not create audio player. Please reload the page." "error")))))))
;; Attach event listeners to audio element
(defun attach-audio-event-listeners (audio-element)
"Attach all necessary event listeners to an audio element"
;; Error handler
(ps:chain audio-element
(add-event-listener "error"
(lambda (err)
(incf *stream-error-count*)
(ps:chain console (log "Stream error:" err))
(if (< *stream-error-count* 3)
;; Auto-retry for first few errors
(progn
(show-stream-status (+ "⚠️ Stream error. Reconnecting... (attempt " *stream-error-count* ")") "warning")
(setf *reconnect-timeout*
(set-timeout reconnect-stream 3000)))
;; Too many errors, show manual reconnect
(progn
(show-stream-status "❌ Stream connection lost. Click Reconnect to try again." "error")
(show-reconnect-button))))))
;; Stalled handler
(ps:chain audio-element
(add-event-listener "stalled"
(lambda ()
(ps:chain console (log "Stream stalled"))
(show-stream-status "⚠️ Stream stalled. Attempting to recover..." "warning")
(setf *reconnect-timeout*
(set-timeout
(lambda ()
;; Only reconnect if still stalled
(when (ps:@ audio-element paused)
(reconnect-stream)))
5000)))))
;; Waiting handler (buffering)
(ps:chain audio-element
(add-event-listener "waiting"
(lambda ()
(ps:chain console (log "Stream buffering..."))
(show-stream-status "⏳ Buffering..." "info"))))
;; Playing handler - clear any error states
(ps:chain audio-element
(add-event-listener "playing"
(lambda ()
(setf *stream-error-count* 0)
(hide-stream-status)
(hide-reconnect-button)
(when *reconnect-timeout*
(clear-timeout *reconnect-timeout*)
(setf *reconnect-timeout* nil)))))
;; Pause handler - track when paused for long pause detection
(ps:chain audio-element
(add-event-listener "pause"
(lambda ()
(setf *last-play-attempt* (ps:chain |Date| (now))))))
;; Play handler - detect long pauses that need reconnection
(ps:chain audio-element
(add-event-listener "play"
(lambda ()
(let ((pause-duration (- (ps:chain |Date| (now)) *last-play-attempt*)))
;; If paused for more than 30 seconds, reconnect to get fresh stream
(when (> pause-duration 30000)
(ps:chain console (log "Long pause detected, reconnecting for fresh stream..."))
(reconnect-stream))))))
;; Spectrum analyzer hooks
(when (ps:@ window |initSpectrumAnalyzer|)
(ps:chain audio-element (add-event-listener "play"
(lambda () (ps:chain window (init-spectrum-analyzer))))))
(when (ps:@ window |stopSpectrumAnalyzer|)
(ps:chain audio-element (add-event-listener "pause"
(lambda () (ps:chain window (stop-spectrum-analyzer)))))))
(defun redirect-when-frame ()
(let* ((path (ps:@ window location pathname))
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self))))
@ -164,80 +413,10 @@
;; Update now playing
(update-now-playing)
;; Auto-reconnect on stream errors
;; Attach event listeners to audio element
(let ((audio-element (ps:chain document (get-element-by-id "live-audio"))))
(when audio-element
(ps:chain audio-element
(add-event-listener
"error"
(lambda (err)
(ps:chain console (log "Stream error, attempting reconnect in 3 seconds..." err))
(set-timeout
(lambda ()
(ps:chain audio-element (load))
(ps:chain (ps:chain audio-element (play))
(catch (lambda (err)
(ps:chain console (log "Reconnect failed:" err))))))
3000))))
(ps:chain audio-element
(add-event-listener
"stalled"
(lambda ()
(ps:chain console (log "Stream stalled, reloading..."))
(ps:chain audio-element (load))
(ps:chain (ps:chain audio-element (play))
(catch (lambda (err)
(ps:chain console (log "Reload failed:" err))))))))
(let ((pause-timestamp nil)
(is-reconnecting false)
(needs-reconnect false)
(pause-reconnect-threshold 10000))
(ps:chain audio-element
(add-event-listener "pause"
(lambda ()
(setf pause-timestamp (ps:chain |Date| (now)))
(ps:chain console (log "Stream paused at:" pause-timestamp)))))
(ps:chain audio-element
(add-event-listener "play"
(lambda ()
(when (and (not is-reconnecting)
pause-timestamp
(> (- (ps:chain |Date| (now)) pause-timestamp) pause-reconnect-threshold))
(setf needs-reconnect true)
(ps:chain console (log "Long pause detected, will reconnect when playing starts...")))
(setf pause-timestamp nil))))
(ps:chain audio-element
(add-event-listener "playing"
(lambda ()
(when (and needs-reconnect (not is-reconnecting))
(setf is-reconnecting true)
(setf needs-reconnect false)
(ps:chain console (log "Reconnecting stream after long pause to clear stale buffers..."))
(ps:chain audio-element (pause))
(when (ps:@ window |resetSpectrumAnalyzer|)
(ps:chain window (reset-spectrum-analyzer)))
(ps:chain audio-element (load))
(set-timeout
(lambda ()
(ps:chain audio-element (play)
(catch (lambda (err)
(ps:chain console (log "Reconnect play failed:" err)))))
(when (ps:@ window |initSpectrumAnalyzer|)
(ps:chain window (init-spectrum-analyzer))
(ps:chain console (log "Spectrum analyzer reinitialized after reconnect")))
(setf is-reconnecting false))
200))))))))
(attach-audio-event-listeners audio-element)))
;; Check frameset preference
(let ((path (ps:@ window location pathname))
@ -249,8 +428,8 @@
(redirect-when-frame)))))
;; Update now playing every 10 seconds
(set-interval update-now-playing 10000)
;; Update now playing every 5 seconds
(set-interval update-now-playing 5000)
;; Listen for messages from popout window
(ps:chain window

View File

@ -93,15 +93,15 @@
;; Restore user quality preference
(let ((selector (ps:chain document (get-element-by-id "live-stream-quality")))
(stream-quality (ps:chain (ps:@ local-storage (get-item "stream-quality")) "aac")))
(when (and selector (not (== (ps:@ selector value) stream-quality)))
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac")))
(when (and selector (not (= (ps:@ selector value) stream-quality)))
(setf (ps:@ selector value) stream-quality)
(ps:chain selector (dispatch-event (new "Event" "change"))))))))
(ps:chain selector (dispatch-event (ps:new (-Event "change")))))))))
;; Frame redirection logic
(defun redirect-when-frame ()
(let ((path (ps:@ window location pathname))
(is-frameset-page (not (== (ps:@ window parent) (ps:@ window self))))
(let* ((path (ps:@ window location pathname))
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self))))
(is-content-frame (ps:chain path (includes "player-content"))))
(when (and is-frameset-page (not is-content-frame))
@ -133,13 +133,12 @@
(add-event-listener "input" update-volume))
;; Audio player events
(when *audio-player*
(ps:chain *audio-player*
(add-event-listener "loadedmetadata" update-time-display)
(add-event-listener "timeupdate" update-time-display)
(add-event-listener "ended" handle-track-end)
(add-event-listener "play" (lambda () (update-play-button "⏸️ Pause")))
(add-event-listener "pause" (lambda () (update-play-button "▶️ Play")))))
(when (and *audio-player* (ps:chain *audio-player* add-event-listener))
(ps:chain *audio-player* (add-event-listener "loadedmetadata" update-time-display))
(ps:chain *audio-player* (add-event-listener "timeupdate" update-time-display))
(ps:chain *audio-player* (add-event-listener "ended" handle-track-end))
(ps:chain *audio-player* (add-event-listener "play" (lambda () (update-play-button "⏸️ Pause"))))
(ps:chain *audio-player* (add-event-listener "pause" (lambda () (update-play-button "▶️ Play")))))
;; Playlist controls
(ps:chain (ps:chain document (get-element-by-id "create-playlist"))
@ -162,17 +161,17 @@
(then (lambda (result)
;; Handle RADIANCE API wrapper format
(let ((data (or (ps:@ result data) result)))
(if (== (ps:@ data status) "success")
(if (= (ps:@ data status) "success")
(progn
(setf *tracks* (or (ps:@ data tracks) (array)))
(display-tracks *tracks*))
(progn
(ps:chain console (error "Error loading tracks:" (ps:@ data error)))
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-html)
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-h-t-m-l)
"<div class=\"error\">Error loading tracks</div>"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading tracks:" error))
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-html)
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-h-t-m-l)
"<div class=\"error\">Error loading tracks</div>")))))
;; Display tracks in library
@ -186,15 +185,15 @@
(let ((container (ps:chain document (get-element-by-id "track-list")))
(pagination-controls (ps:chain document (get-element-by-id "library-pagination-controls"))))
(if (== (ps:@ *filtered-library-tracks* length) 0)
(if (= (ps:@ *filtered-library-tracks* length) 0)
(progn
(setf (ps:@ container inner-html) "<div class=\"no-tracks\">No tracks found</div>")
(setf (ps:@ container inner-h-t-m-l) "<div class=\"no-tracks\">No tracks found</div>")
(setf (ps:@ pagination-controls style display) "none")
(return)))
;; Calculate pagination
(let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*)))
(start-index (* (* *library-current-page* -1) *library-tracks-per-page* *library-tracks-per-page*))
(let* ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*)))
(start-index (* (- *library-current-page* 1) *library-tracks-per-page*))
(end-index (+ start-index *library-tracks-per-page*))
(tracks-to-show (ps:chain *filtered-library-tracks* (slice start-index end-index))))
@ -203,20 +202,21 @@
(map (lambda (track page-index)
;; Find the actual index in the full tracks array
(let ((actual-index (ps:chain *tracks*
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ track id)))))))
(find-index (lambda (trk) (= (ps:@ trk id) (ps:@ track id)))))))
(+ "<div class=\"track-item\" data-track-id=\"" (ps:@ track id) "\" data-index=\"" actual-index "\">"
"<div class=\"track-info\">"
"<div class=\"track-title\">" (or (ps:@ track title 0) "Unknown Title") "</div>"
"<div class=\"track-meta\">" (or (ps:@ track artist 0) "Unknown Artist") " • " (or (ps:@ track album 0) "Unknown Album") "</div>"
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>"
"<div class=\"track-meta\">" (or (ps:@ track artist) "Unknown Artist") " • " (or (ps:@ track album) "Unknown Album") "</div>"
"</div>"
"<div class=\"track-actions\">"
"<button onclick=\"playTrack(" actual-index ")\" class=\"btn btn-sm btn-success\">▶️</button>"
"<button onclick=\"addToQueue(" actual-index ")\" class=\"btn btn-sm btn-info\"></button>"
"<button onclick=\"playTrack(" actual-index ")\" class=\"btn btn-sm btn-success\" title=\"Play\">▶️</button>"
"<button onclick=\"addToQueue(" actual-index ")\" class=\"btn btn-sm btn-info\" title=\"Add to queue\"></button>"
"<button onclick=\"showAddToPlaylistMenu(" (ps:@ track id) ", event)\" class=\"btn btn-sm btn-secondary\" title=\"Add to playlist\">📋</button>"
"</div>"
"</div>"))))
(join ""))))
(setf (ps:@ container inner-html) tracks-html)
(setf (ps:@ container inner-h-t-m-l) tracks-html)
;; Update pagination controls
(setf (ps:chain (ps:chain document (get-element-by-id "library-page-info")) text-content)
@ -249,7 +249,7 @@
(defun change-library-tracks-per-page ()
(setf *library-tracks-per-page*
(parseInt (ps:chain (ps:chain document (get-element-by-id "library-tracks-per-page")) value)))
(parse-int (ps:chain (ps:chain document (get-element-by-id "library-tracks-per-page")) value)))
(setf *library-current-page* 1)
(render-library-page))
@ -258,29 +258,29 @@
(let ((query (ps:chain (ps:chain document (get-element-by-id "search-tracks")) value (to-lower-case))))
(let ((filtered (ps:chain *tracks*
(filter (lambda (track)
(or (ps:chain (or (ps:@ track title 0) "") (to-lower-case) (includes query))
(ps:chain (or (ps:@ track artist 0) "") (to-lower-case) (includes query))
(ps:chain (or (ps:@ track album 0) "") (to-lower-case) (includes query))))))))
(or (ps:chain (or (ps:@ track title) "") (to-lower-case) (includes query))
(ps:chain (or (ps:@ track artist) "") (to-lower-case) (includes query))
(ps:chain (or (ps:@ track album) "") (to-lower-case) (includes query))))))))
(display-tracks filtered))))
;; Play a specific track by index
(defun play-track (index)
(when (and (>= index 0) (< index (ps:@ *tracks* length)))
(setf *current-track* (aref *tracks* index))
(setf *current-track-index* index)
;; Load track into audio player
(setf (ps:@ *audio-player* src) (+ "/asteroid/tracks/" (ps:@ *current-track* id) "/stream"))
(ps:chain *audio-player* (load))
(ps:chain *audio-player*
(play)
(catch (lambda (error)
(ps:chain console (error "Playback error:" error))
(alert "Error playing track. The track may not be available."))))
(update-player-display)
;; Update server-side player state
(setf *current-track-index* index)
;; Load track into audio player
(setf (ps:@ *audio-player* src) (+ "/asteroid/tracks/" (ps:@ *current-track* id) "/stream"))
(ps:chain *audio-player* (load))
(ps:chain *audio-player*
(play)
(catch (lambda (error)
(ps:chain console (error "Playback error:" error))
(alert "Error playing track. The track may not be available."))))
(update-player-display)
;; Update server-side player state
(ps:chain (fetch (+ "/api/asteroid/player/play?track-id=" (ps:@ *current-track* id))
(ps:create :method "POST"))
(catch (lambda (error)
@ -312,11 +312,11 @@
;; Play from queue
(let ((next-track (ps:chain *play-queue* (shift))))
(play-track (ps:chain *tracks*
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ next-track id))))))
(find-index (lambda (trk) (= (ps:@ trk id) (ps:@ next-track id))))))
(update-queue-display))
;; Play next track in library
(let ((next-index (if *is-shuffled*
(floor (* (random) (ps:@ *tracks* length))))
(floor (* (random) (ps:@ *tracks* length)))
(mod (+ *current-track-index* 1) (ps:@ *tracks* length)))))
(play-track next-index))))
@ -344,7 +344,7 @@
;; Update volume
(defun update-volume ()
(let ((volume (/ (parseInt (ps:chain (ps:chain document (get-element-by-id "volume-slider")) value)) 100)))
(let ((volume (/ (parse-int (ps:chain (ps:chain document (get-element-by-id "volume-slider")) value)) 100)))
(when *audio-player*
(setf (ps:@ *audio-player* volume) volume))))
@ -386,8 +386,8 @@
;; Update queue display
(defun update-queue-display ()
(let ((container (ps:chain document (get-element-by-id "play-queue"))))
(if (== (ps:@ *play-queue* length) 0)
(setf (ps:@ container inner-html) "<div class=\"empty-queue\">Queue is empty</div>")
(if (= (ps:@ *play-queue* length) 0)
(setf (ps:@ container inner-h-t-m-l) "<div class=\"empty-queue\">Queue is empty</div>")
(let ((queue-html (ps:chain *play-queue*
(map (lambda (track index)
(+ "<div class=\"queue-item\">"
@ -398,7 +398,7 @@
"<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>"
"</div>")))
(join ""))))
(setf (ps:@ container inner-html) queue-html))))
(setf (ps:@ container inner-h-t-m-l) queue-html)))))
;; Remove track from queue
(defun remove-from-queue (index)
@ -410,28 +410,113 @@
(setf *play-queue* (array))
(update-queue-display))
;; Store playlists for the add-to-playlist menu
(defvar *user-playlists* (array))
;; Show add to playlist dropdown menu
(defun show-add-to-playlist-menu (track-id event)
(ps:chain event (stop-propagation))
;; Remove any existing menu
(let ((existing-menu (ps:chain document (get-element-by-id "playlist-dropdown-menu"))))
(when existing-menu
(ps:chain existing-menu (remove))))
;; Fetch playlists and show menu
(ps:chain (fetch "/api/asteroid/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let* ((data (or (ps:@ result data) result))
(playlists (or (ps:@ data playlists) (array)))
(menu (ps:chain document (create-element "div"))))
(setf *user-playlists* playlists)
(setf (ps:@ menu id) "playlist-dropdown-menu")
(setf (ps:@ menu class-name) "playlist-dropdown-menu")
(setf (ps:@ menu style position) "fixed")
(setf (ps:@ menu style left) (+ (ps:@ event client-x) "px"))
(setf (ps:@ menu style top) (+ (ps:@ event client-y) "px"))
(setf (ps:@ menu style z-index) "1000")
(setf (ps:@ menu style background) "#1a1a2e")
(setf (ps:@ menu style border) "1px solid #00ff00")
(setf (ps:@ menu style border-radius) "4px")
(setf (ps:@ menu style padding) "5px 0")
(setf (ps:@ menu style min-width) "150px")
(if (= (ps:@ playlists length) 0)
(setf (ps:@ menu inner-h-t-m-l)
"<div style=\"padding: 8px 12px; color: #888;\">No playlists yet</div>")
(setf (ps:@ menu inner-h-t-m-l)
(ps:chain playlists
(map (lambda (playlist)
(+ "<div class=\"playlist-menu-item\" onclick=\"addTrackToPlaylist("
(ps:@ playlist id) ", " track-id
")\" style=\"padding: 8px 12px; cursor: pointer; color: #00ff00;\" "
"onmouseover=\"this.style.background='#2a2a4e'\" "
"onmouseout=\"this.style.background='transparent'\">"
(ps:@ playlist name) " (" (ps:@ playlist "track-count") ")"
"</div>")))
(join ""))))
(ps:chain document body (append-child menu))
;; Close menu when clicking elsewhere
(let ((close-handler (lambda (e)
(when (not (ps:chain menu (contains (ps:@ e target))))
(ps:chain menu (remove))
(ps:chain document (remove-event-listener "click" close-handler))))))
(set-timeout (lambda ()
(ps:chain document (add-event-listener "click" close-handler)))
100)))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlists for menu:" error))))))
;; Add track to a specific playlist
(defun add-track-to-playlist (playlist-id track-id)
;; Close the menu
(let ((menu (ps:chain document (get-element-by-id "playlist-dropdown-menu"))))
(when menu (ps:chain menu (remove))))
(let ((form-data (ps:new (-Form-data))))
(ps:chain form-data (append "playlist-id" playlist-id))
(ps:chain form-data (append "track-id" track-id))
(ps:chain (fetch "/api/asteroid/playlists/add-track"
(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
;; Find playlist name for feedback
(let ((playlist (ps:chain *user-playlists*
(find (lambda (p) (= (ps:@ p id) playlist-id))))))
(alert (+ "Track added to \"" (if playlist (ps:@ playlist name) "playlist") "\"")))
(load-playlists))
(alert (+ "Error: " (ps:@ data message)))))))
(catch (lambda (error)
(ps:chain console (error "Error adding track to playlist:" error))
(alert "Error adding track to playlist"))))))
;; Create playlist
(defun create-playlist ()
(let ((name (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value (trim))))
(when (not (== name ""))
(let ((form-data (new "FormData")))
(when (not (= name ""))
(let ((form-data (ps:new (-Form-data))))
(ps:chain form-data (append "name" name))
(ps:chain form-data (append "description" ""))
(ps:chain (fetch "/api/asteroid/playlists/create"
(ps:create :method "POST" :body form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (response)
(ps:chain response (json))))
(then (lambda (result)
;; Handle RADIANCE API wrapper format
(let ((data (or (ps:@ result data) result)))
(if (== (ps:@ data status) "success")
(if (= (ps:@ data status) "success")
(progn
(alert (+ "Playlist \"" name "\" created successfully!"))
(setf (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value) "")
;; Wait a moment then reload playlists
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
(then (lambda () (load-playlists)))))
(set-timeout load-playlists 500))
(alert (+ "Error creating playlist: " (ps:@ data message)))))))
(catch (lambda (error)
(ps:chain console (error "Error creating playlist:" error))
@ -443,7 +528,7 @@
(let ((name (prompt "Enter playlist name:")))
(when name
;; Create the playlist
(let ((form-data (new "FormData")))
(let ((form-data (ps:new (-Form-data))))
(ps:chain form-data (append "name" name))
(ps:chain form-data (append "description" (+ "Created from queue with " (ps:@ *play-queue* length) " tracks")))
@ -453,54 +538,55 @@
(then (lambda (create-result)
;; Handle RADIANCE API wrapper format
(let ((create-data (or (ps:@ create-result data) create-result)))
(if (== (ps:@ create-data status) "success")
(if (= (ps:@ create-data status) "success")
(progn
;; Wait a moment for database to update
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
(then (lambda ()
;; Get the new playlist ID by fetching playlists
(ps:chain (fetch "/api/asteroid/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (playlists-result)
;; Handle RADIANCE API wrapper format
(let ((playlist-result-data (or (ps:@ playlists-result data) playlists-result)))
(if (and (== (ps:@ playlist-result-data status) "success")
(> (ps:@ playlist-result-data playlists length) 0))
(progn
;; Find the playlist with matching name (most recent)
(let ((new-playlist (or (ps:chain (ps:@ playlist-result-data playlists)
(find (lambda (p) (== (ps:@ p name) name))))
(aref (ps:@ playlist-result-data playlists)
(- (ps:@ playlist-result-data playlists length) 1)))))
;; Add all tracks from queue to playlist
(let ((added-count 0))
(ps:chain *play-queue*
(for-each (lambda (track)
(let ((track-id (ps:@ track id)))
(when track-id
(let ((add-form-data (new "FormData")))
(ps:chain add-form-data (append "playlist-id" (ps:@ new-playlist id)))
(ps:chain add-form-data (append "track-id" track-id))
(ps:chain (fetch "/api/asteroid/playlists/add-track"
(ps:create :method "POST" :body add-form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (add-result)
(when (== (ps:@ add-result data status) "success")
(setf added-count (+ added-count 1)))))
(catch (lambda (err)
(ps:chain console (log "Error adding track:" err)))))))))))
(alert (+ "Playlist \"" name "\" created with " added-count " tracks!"))
(load-playlists))))
(progn
(alert (+ "Playlist created but could not add tracks. Error: "
(or (ps:@ playlist-result-data message) "Unknown")))
(load-playlists))))))
(catch (lambda (error)
(ps:chain console (error "Error fetching playlists:" error))
(alert "Playlist created but could not add tracks"))))))))
;; Wait a moment for database to update, then fetch playlists
(set-timeout
(lambda ()
;; Get the new playlist ID by fetching playlists
(ps:chain (fetch "/api/asteroid/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (playlists-result)
;; Handle RADIANCE API wrapper format
(let ((playlist-result-data (or (ps:@ playlists-result data) playlists-result)))
(if (and (= (ps:@ playlist-result-data status) "success")
(> (ps:@ playlist-result-data playlists length) 0))
(progn
;; Find the playlist with matching name (most recent)
(let ((new-playlist (or (ps:chain (ps:@ playlist-result-data playlists)
(find (lambda (p) (= (ps:@ p name) name))))
(aref (ps:@ playlist-result-data playlists)
(- (ps:@ playlist-result-data playlists length) 1)))))
;; Add all tracks from queue to playlist
(let ((added-count 0))
(ps:chain *play-queue*
(for-each (lambda (track)
(let ((track-id (ps:@ track id)))
(when track-id
(let ((add-form-data (ps:new (-Form-data))))
(ps:chain add-form-data (append "playlist-id" (ps:@ new-playlist id)))
(ps:chain add-form-data (append "track-id" track-id))
(ps:chain (fetch "/api/asteroid/playlists/add-track"
(ps:create :method "POST" :body add-form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (add-result)
(when (= (ps:@ add-result data status) "success")
(setf added-count (+ added-count 1)))))
(catch (lambda (err)
(ps:chain console (log "Error adding track:" err)))))))))))
(alert (+ "Playlist \"" name "\" created with " added-count " tracks!"))
(load-playlists))))
(progn
(alert (+ "Playlist created but could not add tracks. Error: "
(or (ps:@ playlist-result-data message) "Unknown")))
(load-playlists))))))
(catch (lambda (error)
(ps:chain console (error "Error fetching playlists:" error))
(alert "Playlist created but could not add tracks")))))
500))
(alert (+ "Error creating playlist: " (ps:@ create-data message)))))))
(catch (lambda (error)
(ps:chain console (error "Error saving queue as playlist:" error))
@ -510,16 +596,21 @@
;; Load playlists from API
(defun load-playlists ()
(ps:chain
(ps:chain (fetch "/api/asteroid/playlists"))
(fetch "/api/asteroid/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(ps:chain console (log "Playlists API result:" result))
(let ((playlists (cond
((and (ps:@ result data) (== (ps:@ result data status) "success"))
((and (ps:@ result data) (= (ps:@ result data status) "success"))
(ps:chain console (log "Found playlists in result.data.playlists"))
(or (ps:@ result data playlists) (array)))
((== (ps:@ result status) "success")
((= (ps:@ result status) "success")
(ps:chain console (log "Found playlists in result.playlists"))
(or (ps:@ result playlists) (array)))
(t
(ps:chain console (log "No playlists found in response"))
(array)))))
(ps:chain console (log "Playlists to display:" playlists))
(display-playlists playlists))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlists:" error))
@ -529,22 +620,69 @@
(defun display-playlists (playlists)
(let ((container (ps:chain document (get-element-by-id "playlists-container"))))
(if (or (not playlists) (== (ps:@ playlists length) 0))
(setf (ps:@ container inner-html) "<div class=\"no-playlists\">No playlists created yet.</div>")
(if (or (not playlists) (= (ps:@ playlists length) 0))
(setf (ps:@ container inner-h-t-m-l) "<div class=\"no-playlists\">No playlists created yet.</div>")
(let ((playlists-html (ps:chain playlists
(map (lambda (playlist)
(+ "<div class=\"playlist-item\">"
(+ "<div class=\"playlist-item\" data-playlist-id=\"" (ps:@ playlist id) "\">"
"<div class=\"playlist-info\">"
"<div class=\"playlist-name\">" (ps:@ playlist name) "</div>"
"<div class=\"playlist-meta\">" (ps:@ playlist "track-count") " tracks</div>"
"</div>"
"<div class=\"playlist-actions\">"
"<button onclick=\"loadPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-info\">📂 Load</button>"
"<button onclick=\"viewPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-secondary\" title=\"View tracks\">👁️</button>"
"<button onclick=\"loadPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-info\" title=\"Load to queue\">📂</button>"
"<button onclick=\"deletePlaylist(" (ps:@ playlist id) ", '" (ps:chain (ps:@ playlist name) (replace (ps:regex "/'/g") "\\\\'")) "')\" class=\"btn btn-sm btn-danger\" title=\"Delete playlist\">🗑️</button>"
"</div>"
"</div>"))
(join "")))))
"</div>")))
(join ""))))
(setf (ps:@ container inner-html) playlists-html)))))
(setf (ps:@ container inner-h-t-m-l) playlists-html)))))
;; Delete playlist
(defun delete-playlist (playlist-id playlist-name)
(when (confirm (+ "Are you sure you want to delete playlist \"" playlist-name "\"?"))
(let ((form-data (ps:new (-Form-data))))
(ps:chain form-data (append "playlist-id" playlist-id))
(ps:chain (fetch "/api/asteroid/playlists/delete"
(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
(alert (+ "Playlist \"" playlist-name "\" deleted"))
(load-playlists))
(alert (+ "Error deleting playlist: " (ps:@ data message)))))))
(catch (lambda (error)
(ps:chain console (error "Error deleting playlist:" error))
(alert "Error deleting playlist")))))))
;; View playlist contents
(defun view-playlist (playlist-id)
(ps:chain
(fetch (+ "/api/asteroid/playlists/get?playlist-id=" playlist-id))
(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 playlist))
(let* ((playlist (ps:@ data playlist))
(tracks (or (ps:@ playlist tracks) (array)))
(track-list (if (> (ps:@ tracks length) 0)
(ps:chain tracks
(map (lambda (track index)
(+ (+ index 1) ". "
(or (ps:@ track artist) "Unknown") " - "
(or (ps:@ track title) "Unknown"))))
(join "\\n"))
"No tracks in playlist")))
(alert (+ "Playlist: " (ps:@ playlist name) "\\n"
"Tracks: " (ps:@ playlist "track-count") "\\n\\n"
track-list)))
(alert "Could not load playlist")))))
(catch (lambda (error)
(ps:chain console (error "Error viewing playlist:" error))
(alert "Error viewing playlist")))))
;; Load playlist into queue
(defun load-playlist (playlist-id)
@ -554,35 +692,38 @@
(then (lambda (result)
;; Handle RADIANCE API wrapper format
(let ((data (or (ps:@ result data) result)))
(if (and (== (ps:@ data status) "success") (ps:@ data playlist))
(if (and (= (ps:@ data status) "success") (ps:@ data playlist))
(let ((playlist (ps:@ data playlist)))
;; Clear current queue
(setf *play-queue* (array))
;; Add all playlist tracks to queue
(when (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
(ps:chain (ps:@ playlist tracks)
(for-each (lambda (track)
;; Find the full track object from our tracks array
(let ((full-track (ps:chain *tracks*
(find (lambda (trk) (== (ps:@ trk id) (ps:@ track id)))))))
(when full-track
(setf (aref *play-queue* (ps:@ *play-queue* length)) full-track)))))
(update-queue-display)
(alert (+ "Loaded " (ps:@ *play-queue* length) " tracks from \"" (ps:@ playlist name) "\" into queue!"))
;; Optionally start playing the first track
(when (> (ps:@ *play-queue* length) 0)
(let ((first-track (ps:chain *play-queue* (shift)))
(track-index (ps:chain *tracks*
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ first-track id))))))
)
(when (>= track-index 0)
(play-track track-index))))))
(when (or (not (ps:@ playlist tracks)) (== (ps:@ playlist tracks length) 0))
(alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
(if (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
(progn
(ps:chain (ps:@ playlist tracks)
(for-each (lambda (track)
;; Find the full track object from our tracks array
(let ((full-track (ps:chain *tracks*
(find (lambda (trk) (= (ps:@ trk id) (ps:@ track id)))))))
(when full-track
(setf (aref *play-queue* (ps:@ *play-queue* length)) full-track))))))
(update-queue-display)
(let ((loaded-count (ps:@ *play-queue* length)))
(alert (+ "Loaded " loaded-count " tracks from \"" (ps:@ playlist name) "\" into queue!"))
;; Optionally start playing the first track
(when (> loaded-count 0)
(let* ((first-track (aref *play-queue* 0))
(track-index (ps:chain *tracks*
(find-index (lambda (trk) (= (ps:@ trk id) (ps:@ first-track id)))))))
;; Remove first track from queue since we're playing it
(ps:chain *play-queue* (shift))
(update-queue-display)
(when (>= track-index 0)
(play-track track-index))))))
(alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
(alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlist:" error))
@ -631,23 +772,24 @@
;; Update now playing information
(defun update-now-playing ()
(ps:chain
(ps:chain (fetch "/api/asteroid/partial/now-playing"))
(fetch "/api/asteroid/partial/now-playing")
(then (lambda (response)
(let ((content-type (ps:chain response (headers) (get "content-type"))))
(let ((content-type (ps:chain response headers (get "content-type"))))
(if (ps:chain content-type (includes "text/html"))
(ps:chain response (text))
(progn
(ps:chain console (log "Error connecting to stream"))
"")))))
(then (lambda (data)
(setf (ps:chain (ps:chain document (get-element-by-id "now-playing")) inner-html) data)))
(setf (ps:chain document (get-element-by-id "now-playing") inner-h-t-m-l) data)))
(catch (lambda (error)
(ps:chain console (log "Could not fetch stream status:" error))))))
;; Initial update after 1 second
(ps:chain (setTimeout update-now-playing 1000))
(set-timeout update-now-playing 1000)
;; Update live stream info every 10 seconds
(ps:chain (set-interval update-now-playing 10000))
(set-interval update-now-playing 10000)
;; Make functions globally accessible for onclick handlers
(defvar window (ps:@ window))
@ -659,7 +801,11 @@
(setf (ps:@ window library-next-page) library-next-page)
(setf (ps:@ window library-go-to-last-page) library-go-to-last-page)
(setf (ps:@ window change-library-tracks-per-page) change-library-tracks-per-page)
(setf (ps:@ window load-playlist) load-playlist)))
(setf (ps:@ window load-playlist) load-playlist)
(setf (ps:@ window delete-playlist) delete-playlist)
(setf (ps:@ window view-playlist) view-playlist)
(setf (ps:@ window show-add-to-playlist-menu) show-add-to-playlist-menu)
(setf (ps:@ window add-track-to-playlist) add-track-to-playlist)))
"Compiled JavaScript for web player - generated at load time")
(defun generate-player-js ()

View File

@ -33,9 +33,16 @@
(when *animation-id*
(cancel-animation-frame *animation-id*)
(setf *animation-id* nil))
;; Close the old AudioContext if it exists
(when *audio-context*
(ps:try
(ps:chain *audio-context* (close))
(:catch (e)
(ps:chain console (log "Error closing AudioContext:" e)))))
(setf *audio-context* nil)
(setf *analyser* nil)
(setf *media-source* nil)
(setf *current-audio-element* nil)
(ps:chain console (log "Spectrum analyzer reset for reconnection")))
(defun init-spectrum-analyzer ()

View File

@ -14,10 +14,9 @@
(setf (dm:field playlist "user-id") user-id)
(setf (dm:field playlist "name") name)
(setf (dm:field playlist "description") (or description ""))
(setf (dm:field playlist "track-ids") "") ; Empty string for text field
(setf (dm:field playlist "created-date") (local-time:timestamp-to-unix (local-time:now)))
;; Note: track-ids column removed - using playlist_tracks junction table instead
;; Let database default handle created-date (CURRENT_TIMESTAMP)
(format t "Creating playlist with user-id: ~a (type: ~a)~%" user-id (type-of user-id))
(format t "Playlist data: ~a~%" (data-model-as-alist playlist))
(dm:insert playlist)
t))
@ -44,53 +43,72 @@
(dm:get-one "playlists" (db:query (:= '_id playlist-id))))
(defun add-track-to-playlist (playlist-id track-id)
"Add a track to a playlist"
(db:with-transaction ()
(let ((playlist (get-playlist-by-id playlist-id)))
(when playlist
(let* ((current-track-ids (dm:field playlist "track-ids"))
;; Parse comma-separated string into list
(tracks-list (if (and current-track-ids
(stringp current-track-ids)
(not (string= current-track-ids "")))
(mapcar #'parse-integer
(cl-ppcre:split "," current-track-ids))
nil))
(new-tracks (append tracks-list (list track-id)))
;; Convert back to comma-separated string
(track-ids-str (format nil "~{~a~^,~}" new-tracks)))
(format t "Adding track ~a to playlist ~a~%" track-id playlist-id)
(format t "Current track-ids raw: ~a (type: ~a)~%" current-track-ids-raw (type-of current-track-ids-raw))
(format t "Current track-ids: ~a~%" current-track-ids)
(format t "Tracks list: ~a~%" tracks-list)
(format t "New tracks: ~a~%" new-tracks)
(format t "Track IDs string: ~a~%" track-ids-str)
;; Update using track-ids field (defined in schema)
(setf (dm:field playlist "track-ids") track-ids-str)
(data-model-save playlist)
(format t "Update complete~%")
t)))))
"Add a track to a playlist using the playlist_tracks junction table"
(format t "Adding track ~a to playlist ~a~%" track-id playlist-id)
(handler-case
(postmodern:with-connection (get-db-connection-params)
;; Get the next position for this playlist
(let* ((max-pos-result (postmodern:query
(format nil "SELECT COALESCE(MAX(position), 0) FROM playlist_tracks WHERE playlist_id = ~a"
playlist-id)
:single))
(next-position (1+ (or max-pos-result 0))))
(postmodern:execute
(format nil "INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (~a, ~a, ~a)"
playlist-id track-id next-position))
(format t "Added track at position ~a~%" next-position)
t))
(error (e)
(format t "Error adding track to playlist: ~a~%" e)
nil)))
(defun remove-track-from-playlist (playlist-id track-id)
"Remove a track from a playlist"
(let ((playlist (get-playlist-by-id playlist-id)))
(when playlist
(let* ((current-track-ids (dm:field playlist "track-ids"))
;; Parse comma-separated string into list
(tracks-list (if (and current-track-ids
(stringp current-track-ids)
(not (string= current-track-ids "")))
(mapcar #'parse-integer
(cl-ppcre:split "," current-track-ids))
nil))
(new-tracks (remove track-id tracks-list :test #'equal))
;; Convert back to comma-separated string
(track-ids-str (format nil "~{~a~^,~}" new-tracks)))
(setf (dm:field playlist "track-ids") track-ids-str)
(data-model-save playlist)
t))))
"Remove a track from a playlist using the playlist_tracks junction table"
(format t "Removing track ~a from playlist ~a~%" track-id playlist-id)
(handler-case
(postmodern:with-connection (get-db-connection-params)
(postmodern:execute
(format nil "DELETE FROM playlist_tracks WHERE playlist_id = ~a AND track_id = ~a"
playlist-id track-id))
(format t "Track removed~%")
t)
(error (e)
(format t "Error removing track from playlist: ~a~%" e)
nil)))
(defun delete-playlist (playlist-id)
"Delete a playlist"
(dm:delete "playlists" (db:query (:= '_id playlist-id)))
t)
(defun get-playlist-tracks (playlist-id)
"Get all track IDs for a playlist from the junction table, ordered by position"
(handler-case
(postmodern:with-connection (get-db-connection-params)
(let ((results (postmodern:query
(format nil "SELECT track_id FROM playlist_tracks WHERE playlist_id = ~a ORDER BY position"
playlist-id))))
(mapcar #'first results)))
(error (e)
(format t "Error getting playlist tracks: ~a~%" e)
nil)))
(defun get-playlist-track-count (playlist-id)
"Get the number of tracks in a playlist"
(handler-case
(postmodern:with-connection (get-db-connection-params)
(let ((result (postmodern:query
(format nil "SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = ~a"
playlist-id)
:single)))
(format t "Track count for playlist ~a: ~a (type: ~a)~%" playlist-id result (type-of result))
;; Ensure we return an integer
(if (integerp result)
result
(if result (parse-integer (format nil "~a" result) :junk-allowed t) 0))))
(error (e)
(format t "Error getting playlist track count: ~a~%" e)
0)))
(defun delete-playlist (playlist-id user-id)
"Delete a playlist (only if owned by user)"
(let ((playlist (get-playlist-by-id playlist-id)))
(when (and playlist (equal (dm:field playlist "user-id") user-id))
;; Junction table entries will be deleted by CASCADE
(dm:delete "playlists" (db:query (:= '_id playlist-id)))
t)))

View File

@ -1,163 +1,98 @@
#EXTM3U
#EXTINF:370,Vector Lovers - City Lights From a Train
Vector Lovers/City Lights From a Train.flac
#EXTINF:400,The Black Dog - Psil-Cosyin
The Black Dog/Psil-Cosyin.flac
#EXTINF:320,Plaid - Eyen
Plaid/Eyen.flac
#EXTINF:330,ISAN - Birds Over Barges
ISAN/Birds Over Barges.flac
#EXTINF:360,Ochre - Bluebottle Farm
Ochre/Bluebottle Farm.flac
#EXTINF:390,Arovane - Theme
Arovane/Theme.flac
#EXTINF:380,Proem - Deep Like Airline Failure
Proem/Deep Like Airline Failure.flac
#EXTINF:310,Solvent - My Radio (Remix)
Solvent/My Radio (Remix).flac
#EXTINF:350,Bochum Welt - Marylebone (7th)
Bochum Welt/Marylebone (7th).flac
#EXTINF:290,Mrs Jynx - Shibuya Lullaby
Mrs Jynx/Shibuya Lullaby.flac
#EXTINF:340,Kettel - Whisper Me Wishes
Kettel/Whisper Me Wishes.flac
#EXTINF:360,Christ. - Perlandine Friday
Christ./Perlandine Friday.flac
#EXTINF:330,Cepia - Ithaca
Cepia/Ithaca.flac
#EXTINF:340,Datassette - Vacuform
Datassette/Vacuform.flac
#EXTINF:390,Plant43 - Dreams of the Sentient City
Plant43/Dreams of the Sentient City.flac
#EXTINF:410,Claro Intelecto - Peace of Mind (Electrosoul)
Claro Intelecto/Peace of Mind (Electrosoul).flac
#EXTINF:430,E.R.P. - Evoked
E.R.P./Evoked.flac
#EXTINF:310,Der Zyklus - Formenverwandler
Der Zyklus/Formenverwandler.flac
#EXTINF:330,Dopplereffekt - Infophysix
Dopplereffekt/Infophysix.flac
#EXTINF:350,Drexciya - Wavejumper
Drexciya/Wavejumper.flac
#EXTINF:375,The Other People Place - Sorrow & A Cup of Joe
The Other People Place/Sorrow & A Cup of Joe.flac
#EXTINF:340,Arpanet - Wireless Internet
Arpanet/Wireless Internet.flac
#EXTINF:380,Legowelt - Sturmvogel
Legowelt/Sturmvogel.flac
#EXTINF:310,DMX Krew - Space Paranoia
DMX Krew/Space Paranoia.flac
#EXTINF:360,Skywave Theory - Nova Drift
Skywave Theory/Nova Drift.flac
#EXTINF:460,Pye Corner Audio - Transmission Four
Pye Corner Audio/Transmission Four.flac
#EXTINF:390,B12 - Heaven Sent
B12/Heaven Sent.flac
#EXTINF:450,Higher Intelligence Agency - Tortoise
Higher Intelligence Agency/Tortoise.flac
#EXTINF:420,Biosphere - Kobresia
Biosphere/Kobresia.flac
#EXTINF:870,Global Communication - 14:31
Global Communication/14:31.flac
#EXTINF:500,Monolake - Cyan
Monolake/Cyan.flac
#EXTINF:660,Deepchord - Electromagnetic
Deepchord/Electromagnetic.flac
#EXTINF:1020,GAS - Pop 4
GAS/Pop 4.flac
#EXTINF:600,Yagya - Rigning Nýju
Yagya/Rigning Nýju.flac
#EXTINF:990,Voices From The Lake - Velo di Maya
Voices From The Lake/Velo di Maya.flac
#EXTINF:3720,ASC - Time Heals All
ASC/Time Heals All.flac
#EXTINF:540,36 - Room 237
36/Room 237.flac
#EXTINF:900,Loscil - Endless Falls
Loscil/Endless Falls.flac
#EXTINF:450,Kiasmos - Looped
Kiasmos/Looped.flac
#EXTINF:590,Underworld - Rez
Underworld/Rez.flac
#EXTINF:570,Orbital - Halcyon + On + On
Orbital/Halcyon + On + On.flac
#EXTINF:1080,The Orb - A Huge Ever Growing Pulsating Brain
The Orb/A Huge Ever Growing Pulsating Brain.flac
#EXTINF:360,Autechre - Slip
Autechre/Slip.flac
#EXTINF:400,Labradford - S (Mi Media Naranja)
Labradford/S (Mi Media Naranja).flac
#EXTINF:350,Vector Lovers - Rusting Cars and Wildflowers
Vector Lovers/Rusting Cars and Wildflowers.flac
#EXTINF:390,The Black Dog - Raxmus
The Black Dog/Raxmus.flac
#EXTINF:315,Plaid - Hawkmoth
Plaid/Hawkmoth.flac
#EXTINF:320,ISAN - What This Button Did
ISAN/What This Button Did.flac
#EXTINF:370,Ochre - Circadies
Ochre/Circadies.flac
#EXTINF:420,Arovane - Tides
Arovane/Tides.flac
#EXTINF:370,Proem - Nothing is as It Seems
Proem/Nothing is as It Seems.flac
#EXTINF:300,Solvent - Loss For Words
Solvent/Loss For Words.flac
#EXTINF:340,Bochum Welt - Saint (77sunset)
Bochum Welt/Saint (77sunset).flac
#EXTINF:280,Mrs Jynx - Stay Home
Mrs Jynx/Stay Home.flac
#EXTINF:330,Kettel - Church
Kettel/Church.flac
#EXTINF:370,Christ. - Cordate
Christ./Cordate.flac
#EXTINF:350,Datassette - Computers Elevate
Datassette/Computers Elevate.flac
#EXTINF:420,Plant43 - The Cold Surveyor
Plant43/The Cold Surveyor.flac
#EXTINF:380,Claro Intelecto - Section
Claro Intelecto/Section.flac
#EXTINF:440,E.R.P. - Vox Automaton
E.R.P./Vox Automaton.flac
#EXTINF:300,Dopplereffekt - Z-Boson
Dopplereffekt/Z-Boson.flac
#EXTINF:380,Drexciya - Digital Tsunami
Drexciya/Digital Tsunami.flac
#EXTINF:350,The Other People Place - You Said You Want Me
The Other People Place/You Said You Want Me.flac
#EXTINF:370,Legowelt - Star Gazing
Legowelt/Star Gazing.flac
#EXTINF:440,Pye Corner Audio - Electronic Rhythm Number 3
Pye Corner Audio/Electronic Rhythm Number 3.flac
#EXTINF:460,B12 - Infinite Lites (Classic Mix)
B12/Infinite Lites (Classic Mix).flac
#EXTINF:390,Biosphere - The Things I Tell You
Biosphere/The Things I Tell You.flac
#EXTINF:580,Global Communication - 9:39
Global Communication/9:39.flac
#EXTINF:460,Monolake - T-Channel
Monolake/T-Channel.flac
#EXTINF:690,Deepchord - Vantage Isle (Variant)
Deepchord/Vantage Isle (Variant).flac
#EXTINF:840,GAS - Königsforst 5
GAS/Königsforst 5.flac
#EXTINF:520,Yagya - The Salt on Her Cheeks
Yagya/The Salt on Her Cheeks.flac
#EXTINF:720,Voices From The Lake - Dream State
Voices From The Lake/Dream State.flac
#EXTINF:510,36 - Night Rain
36/Night Rain.flac
#EXTINF:470,Loscil - First Narrows
Loscil/First Narrows.flac
#EXTINF:400,Kiasmos - Burnt
Kiasmos/Burnt.flac
#EXTINF:570,Underworld - Jumbo (Extended)
Underworld/Jumbo (Extended).flac
#EXTINF:480,Orbital - Belfast
Orbital/Belfast.flac
#EXTINF:540,The Orb - Little Fluffy Clouds (Ambient Mix)
The Orb/Little Fluffy Clouds (Ambient Mix).flac
#EXTINF:390,Autechre - Nine
Autechre/Nine.flac
#EXTINF:380,Labradford - G (Mi Media Naranja)
Labradford/G (Mi Media Naranja).flac
#PLAYLIST:Asteroid Low Orbit - Ambient Electronic Journey
#CURATOR:Asteroid Radio
#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,Brian Eno - Complex Heaven
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A2 Complex Heaven.flac
#EXTINF:-1,Brian Eno - Dust Shuffle
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/B4 Dust Shuffle.flac
#EXTINF:-1,Brian Eno - Garden of Stars
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/04 Garden of Stars.flac
#EXTINF:-1,Brian Eno - There Were Bells
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/06 There Were Bells.flac
#EXTINF:-1,Biosphere - Drifter
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac
#EXTINF:-1,Biosphere - Black Mesa
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/02. Biosphere - Black Mesa.flac
#EXTINF:-1,Biosphere - Skålbrekka
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/01 - Skålbrekka.flac
#EXTINF:-1,Biosphere - Bjorvika
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/04 - Bjorvika.flac
#EXTINF:-1,Biosphere - Out Of The Cradle
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/01 - Out Of The Cradle.flac
#EXTINF:-1,Biosphere - Down On Ropes
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/03 - Down On Ropes.flac
#EXTINF:-1,Biosphere - Microtunneling
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 04 Microtunneling.flac
#EXTINF:-1,Autechre - Dael
/app/music/Autechre/1995 - Tri Repetae/01 Dael.flac
#EXTINF:-1,Autechre - Further
/app/music/Autechre/1994 - Amber/08 Further.flac
#EXTINF:-1,Autechre - north spiral
/app/music/Autechre - 2018 - NTS Session 1/NTS Session 1-006-Autechre-north spiral.flac
#EXTINF:-1,Four Tet - Alap
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/01 Alap.flac
#EXTINF:-1,Four Tet - Scientists
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/06 Scientists.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,Four Tet - Parallel 8
/app/music/Four Tet - Parallel (2020) - WEB FLAC/08. Parallel 8.flac
#EXTINF:-1,Clark - Spring But Dark
/app/music/Clark - Death Peak (2017) [FLAC]/01 - Spring But Dark.flac
#EXTINF:-1,Clark - Kiri's Glee
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/04 - Kiri's Glee.flac
#EXTINF:-1,Clark - Primary Pluck
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/08 - Primary Pluck.flac
#EXTINF:-1,Clark - Cannibal Homecoming
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/11 - Cannibal Homecoming.flac
#EXTINF:-1,Clark - Absence (Bibio Remix)
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.12. Clark - Absence (Bibio Remix).flac
#EXTINF:-1,Tycho - Glider
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/01 - Glider.flac
#EXTINF:-1,Tycho - Source
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/07 - Source.flac
#EXTINF:-1,Tycho - Rings
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/09 - Rings.flac
#EXTINF:-1,Tycho - Into The Woods
/app/music/Tycho - Simulcast (2020) [WEB FLAC]/04 - Into The Woods.flac
#EXTINF:-1,Tycho - Ascension
/app/music/Thievery Corporation and Tycho - Fragments Ascension EP (flac)/3. Tycho - Ascension.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,Ulrich Schnauss - Like a Ghost in Your Own Life
/app/music/Ulrich Schnauss - A Long Way To Fall - Rebound (2020) - WEB FLAC/03. Like a Ghost in Your Own Life.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,Ulrich Schnauss & Jonas Munk - Narkomfin
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/05. Narkomfin.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,Proem - Winter Wolves
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
#EXTINF:-1,Proem - Modern Rope
/app/music/Proem - 2018 Modern Rope (WEB)/05. Modern Rope.flac
#EXTINF:-1,Proem - Kids That Hate Live Things
/app/music/Proem - Until Here for Years (n5md, 2019) flac/11 - Kids That Hate Live Things.flac
#EXTINF:-1,Proem - End Tail
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/07 - End Tail.flac
#EXTINF:-1,Proem - In a Timeless, Lightless World
/app/music/Proem/2019 - As They Go/Proem - As They Go - 05 In a Timeless, Lightless World.flac
#EXTINF:-1,arovane - komposition no. 1
/app/music/arovane - Wirkung (2020) [WEB FLAC16]/17. arovane - komposition no. 1.flac
#EXTINF:-1,arovane - hymn
/app/music/arovane - Wirkung (2020) [WEB FLAC16]/12. arovane - hymn.flac
#EXTINF:-1,Aphex Twin - CHEETAHT7b
/app/music/Aphex Twin (2016) Cheetah EP [WEB] [FLAC]/Cheetah EP-002-Aphex Twin-CHEETAHT7b.flac
#EXTINF:-1,Aphex Twin - CHEETA2 ms800
/app/music/Aphex Twin (2016) Cheetah EP [WEB] [FLAC]/Cheetah EP-004-Aphex Twin-CHEETA2 ms800.flac
#EXTINF:-1,Plaid - Sun Electric - Tee (Plaid Mix)
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/17-Sun_Electric-Tee_(Plaid_Mix).flac
#EXTINF:-1,Plaid - Origamibiro - Impressions Of Football (Plaid Remix)
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/11-Origamibiro-Impressions_Of_Football_(Plaid_Remix).flac
#EXTINF:-1,Plaid - Ricardo Tobar - After The Movie (Plaid Remix)
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/21-Ricardo_Tobar-After_The_Movie_(Plaid_Remix).flac
#EXTINF:-1,Plaid - Esem - Yourturn (Plaid Remix)
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/26-Esem-Yourturn_(Plaid_Remix).flac

View File

@ -0,0 +1,294 @@
#EXTM3U
#PLAYLIST:Geostationary Orbit - Deep Space Electronic Journey
#PHASE:Geostationary
#DURATION:12 hours (approx)
#CURATOR:Asteroid Radio
#DESCRIPTION:A 12-hour journey through ambient, IDM, and experimental electronic music
#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,Cut Copy - Airborne
/app/music/Cut Copy - Haiku From Zero (2017) [FLAC] {2557864014}/05 - Airborne.flac
#EXTINF:-1,Faux Tales - Avalon
/app/music/Faux Tales - 2015 - Kairos [FLAC] {Kensai Records KNS006 WEB}/3 - Avalon.flac
#EXTINF:-1,Trans-Siberian Orchestra - 14 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,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,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,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
#EXTINF:-1,Clark - Living Fantasy
/app/music/Clark - Death Peak (2017) [FLAC]/08 - Living Fantasy.flac
#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,Smokey Robinson - A Silent Partner In A Three-Way Affair
/app/music/Smokey Robinson - The Solo Albums Vol. 1 (2010) [FLAC]/03 - A Silent Partner In A Three-Way Affair.flac
#EXTINF:-1,Biosphere - Drifter
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.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,Four Tet - 04 Tremper
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/04 Tremper.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
#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 - Red Howls
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/01 - Red Howls.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,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,Bedouin Soundclash - Money Worries (E-Clair Refix)
/app/music/Bedouin Soundclash - Sounding a Mosaic (2004) [FLAC] {SD1267}/14 - Money Worries (E-Clair Refix).flac
#EXTINF:-1,Biosphere - 05 Fluvialmorphologie
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 05 Fluvialmorphologie.flac
#EXTINF:-1,Dead Voices on Air - 05. on the silent wing
/app/music/Dead Voices on Air - [2010] The Silent Wing [FLAC 24bit]/05. on the silent wing.flac
#EXTINF:-1,Regina Spektor - Folding Chair
/app/music/Regina Spektor - Far (2009) - FLAC/04 - Folding Chair.flac
#EXTINF:-1,08 Album of Memory
/app/music/If I Had a Pair of Wings; Jamaican Doo Wop Vol. 1 [Death is Not the End, 2018] FLAC/08 Album of Memory.flac
#EXTINF:-1,Plaid - Drowned Sea
/app/music/Plaid - Polymer (2019) [WEB FLAC]/05 - Drowned Sea.flac
#EXTINF:-1,The Future Sound Of London - Old Empire
/app/music/The Future Sound Of London - My Kingdom (Re-imagined) (2018) FLAC/10 - Old Empire.flac
#EXTINF:-1,James - Crash
/app/music/James - Millionaires (Mercury Records, 1999) EAC-FLAC/01. James - Crash.flac
#EXTINF:-1,Ulrich Schnauss - 09. Ten Years
/app/music/Ulrich Schnauss - A Long Way To Fall - Rebound (2020) - WEB FLAC/09. Ten Years.flac
#EXTINF:-1,Tyler Bates & VA - O-O-H Child
/app/music/Tyler Bates & VA - Guardians of the Galaxy (Deluxe Edition) (2014) [FLAC]/Disc 1 (Awesome Mix Vol. 1)/11 - The Five Stairsteps - O-O-H Child.flac
#EXTINF:-1,Clark - The Galactic Tusk (Clark Remix)
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.29. Feynmanns Rainbow - The Galactic Tusk (Clark Remix).flac
#EXTINF:-1,Alison Krauss and Union Station - Dust Bowl Children
/app/music/Alison Krauss and Union Station - Paper Airplane (flac)/2 - Alison Krauss & Union Station - Dust Bowl Children.flac
#EXTINF:-1,Jethro Tull - Only Solitaire
/app/music/Jethro Tull - War Child (1974) - FLAC/Disc 1- 2014 Steven Wilson Mix/08 - Only Solitaire.flac
#EXTINF:-1,Plaid - Recall
/app/music/Plaid - Polymer (2019) [WEB FLAC]/09 - Recall.flac
#EXTINF:-1,Clark - Let's Get Clinical (Clark Remix)
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.21. Maximo Park - Let's Get Clinical (Clark Remix).flac
#EXTINF:-1,Aqualung - Thin Air
/app/music/Aqualung - Magnetic North (2010)/11 - Thin Air.flac
#EXTINF:-1,Tycho - Receiver
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/04 - Receiver.flac
#EXTINF:-1,Le Vent Du Nord - Les Larmes Aux Yeux
/app/music/Le Vent Du Nord - Dans Les Airs/08 - Les Larmes Aux Yeux.flac
#EXTINF:-1,Bach - French Suite No.4 In E Flat, BWV 815 6. Air
/app/music/Bach - The French Suites - Perhaia [flac]/23 - French Suite No.4 In E Flat, BWV 815 6. Air.flac
#EXTINF:-1,James - I Know What Im Here For
/app/music/James - Millionaires (Mercury Records, 1999) EAC-FLAC/03. James - I Know What Im Here For.flac
#EXTINF:-1,Biosphere - Departed Glories
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/09 - Departed Glories.flac
#EXTINF:-1,Ramones - Havana Affair
/app/music/Ramones - Transmission Impossible 2015 FLAC/CD 3/12 - Havana Affair.flac
#EXTINF:-1,Wumpscut - Burial On Demand
/app/music/Wumpscut - Women And Satan First (Concentrated Camp Edition) (2012) [FLAC WEB]/05 - Burial On Demand.flac
#EXTINF:-1,The Future Sound Of London - Water Garden
/app/music/The Future Sound Of London - My Kingdom (Re-imagined) (2018) FLAC/09 - Water Garden.flac
#EXTINF:-1,Clark - Red Light (Clark Remix)
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.16. Massive Attack - Red Light (Clark Remix).flac
#EXTINF:-1,Ulrich Schnauss - 05. No Further Ahead Than Today (2019 Version)
/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/05. No Further Ahead Than Today (2019 Version).flac
#EXTINF:-1,Plaid - 09-Min-Y-Llan-His-Hell_(Plaid_Remix)
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/09-Min-Y-Llan-His-Hell_(Plaid_Remix).flac
#EXTINF:-1,Biosphere - Bergsbotn II
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/09 - Bergsbotn II.flac
#EXTINF:-1,Dead Voices on Air - Form Wass
/app/music/Dead Voices on Air - CD Three, Never Too Much of Nothing - Tethera (2016) [WEB FLAC]/03 - Form Wass.flac
#EXTINF:-1,Plaid - Held
/app/music/Plaid - The Digging Remedy (2016) [FLAC]/11 - Held.flac
#EXTINF:-1,Plaid - 28-Rone.-Room_With_A_View_(Plaid_Remix)
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/28-Rone.-Room_With_A_View_(Plaid_Remix).flac
#EXTINF:-1,Proem - Dark Swole Waves
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/03 - Dark Swole Waves.flac
#EXTINF:-1,Dead Voices on Air - 02. Sprach
/app/music/Dead Voices on Air - Pieta (2012) [WEB-FLAC]/02. Sprach.flac
#EXTINF:-1,Plaid - Praze
/app/music/Plaid - Polymer (2019) [WEB FLAC]/13 - Praze.flac
#EXTINF:-1,Plaid - 08-Roel_Funcken-Textures_(Plaid_Remix)
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/08-Roel_Funcken-Textures_(Plaid_Remix).flac
#EXTINF:-1,Billie Holiday - The End Of A Love Affair
/app/music/Billie Holiday - Lady in Satin (flac)/12 - The End Of A Love Affair.flac
#EXTINF:-1,Plaid - Meds Fade
/app/music/Plaid - Polymer (2019) [WEB FLAC]/01 - Meds Fade.flac
#EXTINF:-1,Orbital - There Will Come A Time (Inst)
/app/music/Orbital - Monsters Exist (PledgeMusic Deluxe) (2018) (WEB) [FLAC]/16 - There Will Come A Time (Inst).flac
#EXTINF:-1,Ulrich Schnauss - 07. 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,Dead Voices On Air - PJB
/app/music/Dead Voices On Air - DVoA-CzE (FLAC)/05 - PJB.flac
#EXTINF:-1,Ulrich Schnauss - 04. Thoughtless Motion (2019 Version)
/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/04. Thoughtless Motion (2019 Version).flac
#EXTINF:-1,02. Brigden Fair
/app/music/The Wilderness of Manitoba [2017] The Tin Shop EP [Pheromone Recordings, WEB] [FLAC]/02. Brigden Fair.flac
#EXTINF:-1,Orbital - Hoo Hoo Ha Ha
/app/music/Orbital - Monsters Exist (PledgeMusic Deluxe) (2018) (WEB) [FLAC]/02 - Hoo Hoo Ha Ha.flac
#EXTINF:-1,Radiohead - 01 Airbag
/app/music/Radiohead - 5 Album Set (50999 972099 2 8, 2012)/Radiohead - OK Computer (1997)/01 Airbag.flac
#EXTINF:-1,Cheetah EP-002-Aphex Twin-CHEETAHT7b
/app/music/Aphex Twin (2016) Cheetah EP [WEB] [FLAC]/Cheetah EP-002-Aphex Twin-CHEETAHT7b.flac
#EXTINF:-1,The Future Sound of London - Wild Weather
/app/music/The Future Sound of London - (2016) Environment Six (FLAC)/09 - Wild Weather.flac
#EXTINF:-1,Plaid - Melifer
/app/music/Plaid - The Digging Remedy (2016) [FLAC]/05 - Melifer.flac
#EXTINF:-1,Four Tet - 04. Parallel 4
/app/music/Four Tet - Parallel (2020) - WEB FLAC/04. Parallel 4.flac
#EXTINF:-1,Dead Voices On Air - 04. Als Een God
/app/music/Dead Voices On Air - Frankie Pett En De Onderzeer Boten (2017) web/04. Als Een God.flac
#EXTINF:-1,Tycho - 06. For How Long (Harvey Sutherland Remix)
/app/music/Tycho - Weather Remixes (2020) - WEB FLAC/06. For How Long (Harvey Sutherland Remix).flac
#EXTINF:-1,Proem - Playing Against The Ghosts
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/02 - Playing Against The Ghosts.flac
#EXTINF:-1,Alison Krauss and Union Station - Miles to Go
/app/music/Alison Krauss and Union Station - Paper Airplane (flac)/8 - Alison Krauss & Union Station - Miles to Go.flac
#EXTINF:-1,Dead Voices On Air - Wistle Kjarra
/app/music/Dead Voices On Air - Flojt (2016) [FLAC 24bit] WEB/02 - Wistle Kjarra.flac
#EXTINF:-1,Ulrich Schnauss & Jonas Munk - 03. Solitary Falling
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/03. Solitary Falling.flac
#EXTINF:-1,The Future Sound of London - Imagined Friends
/app/music/The Future Sound of London - (2016) Environment Six (FLAC)/15 - Imagined Friends.flac
#EXTINF:-1,Tycho - Epoch
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/05 - Epoch.flac
#EXTINF:-1,Locrian - An Index of Air
/app/music/Locrian - Infinite Dissolution (2015) - WEB FLAC/05 - An Index of Air.flac
#EXTINF:-1,Death Cab For Cutie - Pictures In An Exhibition [Live]
/app/music/Death Cab For Cutie - Something About Airplanes (2008 Re-Issue)/Disc 2 - Live At The Crocodile Cafe 1998/07 - Pictures In An Exhibition [Live].flac
#EXTINF:-1,Death Cab For Cutie - President of What
/app/music/Death Cab For Cutie - Something About Airplanes (2008 Re-Issue)/Disc 1 - Something About Airplanes/02 - President of What.flac
#EXTINF:-1,Johann Johannsson - The Stairs
/app/music/Johann Johannsson - The Theory of Everything (2014) [FLAC]/13 - The Stairs.flac
#EXTINF:-1,Biosphere - Just A Kiss
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/05. Biosphere - Just A Kiss.flac
#EXTINF:-1,Ulrich Schnauss & Jonas Munk Weightless Memories (2010) {PEDS 02} [Web]/09 - In Odense...
/app/music/Ulrich Schnauss & Jonas Munk Weightless Memories (2010) {PEDS 02} [Web]/09 - In Odense....flac
#EXTINF:-1,Dead Voices on Air - 09. ge [bonus track]
/app/music/Dead Voices on Air - [2010] The Silent Wing [FLAC 24bit]/09. ge [bonus track].flac
#EXTINF:-1,Port Blue - 03. The Grand Staircase
/app/music/Port Blue - The Airship (2007)/03. The Grand Staircase.flac
#EXTINF:-1,Biosphere - 04 Microtunneling
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 04 Microtunneling.flac
#EXTINF:-1,Clark - Kiri's Glee
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/04 - Kiri's Glee.flac
#EXTINF:-1,Biosphere - Whole Forests Of Them Appearing
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/10 - Whole Forests Of Them Appearing.flac
#EXTINF:-1,Seba & Ulrich Schnauss - Snöflingor
/app/music/Seba & Ulrich Schnauss - Snöflingor EP [2017] [WEB_FLAC]/03. Seba & Ulrich Schnauss - Snöflingor.flac
#EXTINF:-1,Plaid - 07-Mary_Epworth-Gone_Rogue_(Plaid_Remix)
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/07-Mary_Epworth-Gone_Rogue_(Plaid_Remix).flac
#EXTINF:-1,05 Send Me
/app/music/If I Had a Pair of Wings; Jamaican Doo Wop Vol. 1 [Death is Not the End, 2018] FLAC/05 Send Me.flac
#EXTINF:-1,07 'Til the End of Time
/app/music/If I Had a Pair of Wings; Jamaican Doo Wop Vol. 1 [Death is Not the End, 2018] FLAC/07 'Til the End of Time.flac
#EXTINF:-1,Four Tet - Insect Near Piha Beach
/app/music/Four Tet - Sixteen Oceans (2020) {Text Records - TEXT051} [CD FLAC]/07 - Four Tet - Insect Near Piha Beach.flac
#EXTINF:-1,Clark - Ted (Bibio Remix)
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.13. Clark - Ted (Bibio Remix).flac
#EXTINF:-1,The Future Sound of London - Plausibility
/app/music/The Future Sound of London - (2016) Environment Six (FLAC)/17 - Plausibility.flac
#EXTINF:-1,The Lumineers - 01-Flowers In Your Hair
/app/music/The Lumineers - The Lumineers (flac)/01-Flowers In Your Hair.flac
#EXTINF:-1,Wumpscut - Gabi Grausam (AirForge Remix)
/app/music/Wumpscut - Madman Szpital (Concentrated Camp Edition) (2013) [FLAC WEB]/25 - Gabi Grausam (AirForge Remix).flac
#EXTINF:-1,Dead Voices on Air - Three
/app/music/Dead Voices on Air - Mirror Carrier (2018) [WEB-FLAC16-44]/03. Dead Voices on Air - Three.flac
#EXTINF:-1,The Future Sound Of London - Path 7
/app/music/The Future Sound Of London - My Kingdom (Re-imagined) (2018) FLAC/01 - My Kingdom - Path 7.flac
#EXTINF:-1,Biosphere - Invariable Cowhandler
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/11 - Invariable Cowhandler.flac
#EXTINF:-1,Dead Voices On Air - 9. Stacja.Fjall
/app/music/Dead Voices On Air - Flojt (2016) [FLAC 24bit] WEB/09 - 9. Stacja.Fjall.flac
#EXTINF:-1,The Clarks - 12 Train
/app/music/The Clarks - Fast Moving Cars (2004) {Razor & Tie 7930182918-2} [FLAC-CD]/12 Train.flac
#EXTINF:-1,Massive Attack - Voodoo In My Blood
/app/music/Massive Attack - Ritual Spirit (2016) [WEB FLAC]/03 - Voodoo In My Blood.flac
#EXTINF:-1,Quaeschning and Ulrich Schnauss - Main Theme
/app/music/Quaeschning and Ulrich Schnauss - Synthwaves (2017) {vista003, GER, CD} [FLAC]/01 - Main Theme.flac
#EXTINF:-1,Biosphere - Fjølhøgget
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/07 - Fjølhøgget.flac
#EXTINF:-1,Plaid - 03-Gareth_Whitehead,_Detroit_Grand_Pubahs,_Raymond,_Lindsay_&_Kendal-The_Villain_(Plaid_Remix)
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/03-Gareth_Whitehead,_Detroit_Grand_Pubahs,_Raymond,_Lindsay_&_Kendal-The_Villain_(Plaid_Remix).flac
#EXTINF:-1,Autechre - NTS Session 1-003-Autechre-debris_funk
/app/music/Autechre - 2018 - NTS Session 1/NTS Session 1-003-Autechre-debris_funk.flac
#EXTINF:-1,Dead Voices On Air - 01 Walde
/app/music/Dead Voices On Air - Doggerland (2017) [FLAC] {WEB}/01 Walde.flac
#EXTINF:-1,Orbital - There Will Come A Time
/app/music/Orbital - Monsters Exist (PledgeMusic Deluxe) (2018) (WEB) [FLAC]/09 - There Will Come A Time.flac
#EXTINF:-1,Clark - Absence (Bibio Remix)
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.12. Clark - Absence (Bibio Remix).flac
#EXTINF:-1,Ulrich Schnauss - 09. Negative Sunrise (2019 Version)
/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/09. Negative Sunrise (2019 Version).flac
#EXTINF:-1,Port Blue - 05. The Axial Catwalk
/app/music/Port Blue - The Airship (2007)/05. The Axial Catwalk.flac
#EXTINF:-1,Four Tet - This Is for You
/app/music/Four Tet - Sixteen Oceans (2020) {Text Records - TEXT051} [CD FLAC]/15 - Four Tet - This Is for You.flac
#EXTINF:-1,Proem - Pine the Bear
/app/music/Proem - Until Here for Years (n5md, 2019) flac/06 - Pine the Bear.flac
#EXTINF:-1,Clark - Yarraville Bird Phone
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/07 - Yarraville Bird Phone.flac
#EXTINF:-1,Gary Numan/Gary Numan - Airplane
/app/music/Gary Numan/Gary Numan - The Pleasure Principle (1979) {Beggars Banquet BEGA 10, PBTHAL 12530} [FLAC 24-96]/01 - Airplane.flac
#EXTINF:-1,The Monkees - Last Train to Clarksville
/app/music/The Monkees - 2016 - The Monkees 50 {FLAC, R2 554390}/CD1/02 - Last Train to Clarksville.flac
#EXTINF:-1,Death Cab for Cutie - I Will Possess Your Heart
/app/music/Death Cab for Cutie - Narrow Stairs [Atlantic Records]/02 - I Will Possess Your Heart.flac
#EXTINF:-1,The Clarks - 04 Wait a Minute
/app/music/The Clarks - Fast Moving Cars (2004) {Razor & Tie 7930182918-2} [FLAC-CD]/04 Wait a Minute.flac
#EXTINF:-1,The Clarks - 07 Fast Moving Cars
/app/music/The Clarks - Fast Moving Cars (2004) {Razor & Tie 7930182918-2} [FLAC-CD]/07 Fast Moving Cars.flac
#EXTINF:-1,Death Cab for Cutie - Long Division
/app/music/Death Cab for Cutie - Narrow Stairs [Atlantic Records]/09 - Long Division.flac
#EXTINF:-1,80's Dance Classix Top 100 (2008) [FLAC]/CD5/19 - White Rabbit
/app/music/80's Dance Classix Top 100 (2008) [FLAC]/CD5/19 - Airplane Crashers - White Rabbit.flac
#EXTINF:-1,Plaid - Nurula
/app/music/Plaid - Polymer (2019) [WEB FLAC]/08 - Nurula.flac
#EXTINF:-1,Gridlock - 05 364 (One Day With Proem)
/app/music/Gridlock - Engram (12'' 2002)/05 364 (One Day With Proem).flac
#EXTINF:-1,Alabama 3 - Who the Fuck is John Sinclair
/app/music/Alabama 3 - 2011 - Shoplifting 4 Jesus [flac]/12 - Who the Fuck is John Sinclair.flac
#EXTINF:-1,Proem - A Good Soaking
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/12 - A Good Soaking.flac
#EXTINF:-1,Four Tet - Something in the Sadness
/app/music/Four Tet - Sixteen Oceans (2020) {Text Records - TEXT051} [CD FLAC]/10 - Four Tet - Something in the Sadness.flac
#EXTINF:-1,Clark - Bench
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/03 - Bench.flac
#EXTINF:-1,Proem - 08. Night Brain
/app/music/Proem - 2018 Modern Rope (WEB)/08. Night Brain.flac
#EXTINF:-1,Ulrich Schnauss & Jonas Munk Weightless Memories (2010) {PEDS 02} [Web]/04 - Sonnenblumenstrahl
/app/music/Ulrich Schnauss & Jonas Munk Weightless Memories (2010) {PEDS 02} [Web]/04 - Sonnenblumenstrahl.flac
#EXTINF:-1,Clark - Banished Hymnal
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/12 - Banished Hymnal.flac
#EXTINF:-1,Tycho - PCH
/app/music/Tycho - Simulcast (2020) [WEB FLAC]/06 - PCH.flac
#EXTINF:-1,80's Dance Classix Top 100 (2008) [FLAC]/CD3/04 - Our Darkness
/app/music/80's Dance Classix Top 100 (2008) [FLAC]/CD3/04 - Anne Clark - Our Darkness.flac
#EXTINF:-1,Four Tet - 14 Planet
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/14 Planet.flac
#EXTINF:-1,Dead Voices on Air - 05. Live in Saint Petersburg, Feb. 2012
/app/music/Dead Voices on Air - Pieta (2012) [WEB-FLAC]/05. Live in Saint Petersburg, Feb. 2012.flac
#EXTINF:-1,Orbital - Kinetic 2017
/app/music/Orbital - Monsters Exist (PledgeMusic Deluxe) (2018) (WEB) [FLAC]/19 - Kinetic 2017.flac
#EXTINF:-1,Amon Tobin - 04 Io
/app/music/Amon Tobin - 2015 - Dark Jovian [FLAC]/04 Io.flac
#EXTINF:-1,Tycho - Division (Heathered Pearls Remix)
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/15 - Division (Heathered Pearls Remix).flac
#EXTINF:-1,Orbital - Dressing Up In Other People's Clothes
/app/music/Orbital - Monsters Exist (PledgeMusic Deluxe) (2018) (WEB) [FLAC]/14 - Dressing Up In Other People's Clothes.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,Biosphere - 09 Redfolks
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 09 Redfolks.flac
#EXTINF:-1,Dead Voices On Air - 01. Trophic Gehoord
/app/music/Dead Voices On Air - Frankie Pett En De Onderzeer Boten (2017) web/01. Trophic Gehoord.flac
#EXTINF:-1,Autechre - NTS Session 1-008-Autechre-four of seven
/app/music/Autechre - 2018 - NTS Session 1/NTS Session 1-008-Autechre-four of seven.flac
#EXTINF:-1,Four Tet - School
/app/music/Four Tet - Sixteen Oceans (2020) {Text Records - TEXT051} [CD FLAC]/01 - Four Tet - School.flac
#EXTINF:-1,Biosphere - 02 Naust
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 02 Naust.flac

View File

@ -1,371 +1,144 @@
#EXTM3U
#EXTINF:-1,Underworld - Underworld - Confusion The Waitress
/app/music/Underworld/1996 - Second Toughest In The Infants/03. Underworld - Confusion The Waitress.flac
#EXTINF:-1,The Orb - Towers Of Dub
/app/music/The Orb/1992 - UFOrb/04-Towers Of Dub.mp3
#EXTINF:-1,Drexciya - Drexciya - Intensified Magnetron
/app/music/Drexciya/2013 - Journey Of The Deep Sea Dweller III/04. Drexciya - Intensified Magnetron.mp3
#EXTINF:-1,Labradford - Balanced on It's Own Flame
/app/music/Labradford/1995 - A Stable Reference/6 Balanced on It's Own Flame.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,Labradford - Leta O'Steen. Design assistance by John Piper
/app/music/Labradford/1999 - E luxo so/6. Leta O'Steen. Design assistance by John Piper.flac
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 1   Reel One
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/01 Tape Loop Orchestra - Chapter 1   Reel One.mp3
#EXTINF:-1,Orbital - Time Becomes
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Time Becomes.mp3
#EXTINF:-1,Proem - Proem - You Shall Have Ever Been - 05 No You Are $
/app/music/Proem/2006 - You Shall Have Ever Been/Proem - You Shall Have Ever Been - 05 No You Are $.flac
#EXTINF:-1,Pye Corner Audio - Pye Corner Audio - The Simplest Equation
/app/music/Pye Corner Audio/EP's & Singles/2016 - Pye Corner Audio With Dalhous - Run For The Shadows EP (WEB, #LPS13)/02 - Pye Corner Audio - The Simplest Equation.mp3
#EXTINF:-1,Brian Eno - Emerald and Lime
/app/music/Brian Eno/2024 - Eno (Original Motion Picture Soundtrack)/12. Emerald and Lime.flac
#EXTINF:-1,Bark Psychosis - (07) [Bark Psychosis] A Street Scene
/app/music/Bark Psychosis/1994 - Game Over/(07) [Bark Psychosis] A Street Scene.flac
#EXTINF:-1,Model 500 - model_500-digital_solutions
/app/music/Model 500/2015 - Digital Solutions/08-model_500-digital_solutions.flac
#EXTINF:-1,Labradford - Banco
/app/music/Labradford/1995 - A Stable Reference/4 Banco.flac
#EXTINF:-1,Labradford - Skyward With Motion
/app/music/Labradford/1993 - Prazision LP/11 Skyward With Motion.flac
#EXTINF:-1,Pye Corner Audio - The Mirror Ball Cracked
/app/music/Pye Corner Audio/2012 - Sleep Games (WEB, #GBX017)/08 - The Mirror Ball Cracked.mp3
#EXTINF:-1,Brian Eno - Foreign Affairs
/app/music/Brian Eno/1978 - After The Heat/01 - Foreign Affairs.flac
#EXTINF:-1,The Other People Place - B1 - Moonlight Rendezvous
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/B1 - Moonlight Rendezvous.flac
#EXTINF:-1,Drexciya - Unknown Journey IX
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/10. Unknown Journey IX.mp3
#EXTINF:-1,Orbital - Crash And Carry
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/04. Crash And Carry.mp3
#EXTINF:-1,Proem - Proem - Before it finds you - 09 We can watch it burn to the ground
/app/music/Proem/2013 - Before it finds you/Proem - Before it finds you - 09 We can watch it burn to the ground.flac
#EXTINF:-1,Proem - Proem - Before it finds you - 01 Stone into gravel
/app/music/Proem/2013 - Before it finds you/Proem - Before it finds you - 01 Stone into gravel.flac
#EXTINF:-1,Drexciya - Intro (The Unknown Aquazone)
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/01. Intro (The Unknown Aquazone).mp3
#EXTINF:-1,Teeth Of The Sea - Get With the Program
/app/music/Teeth Of The Sea/2023 - Hive/02 Get With the Program.flac
#EXTINF:-1,Proem - Proem - Vault ep.4-4 - 02 Little girls
/app/music/Proem/2015 - Vault ep.4-4/Proem - Vault ep.4-4 - 02 Little girls.flac
#EXTINF:-1,Drexciya - Black Sea
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/14. Black Sea.mp3
#EXTINF:-1,Autechre - Yulquen
/app/music/Autechre/1994 - Amber/09 Yulquen.flac
#EXTINF:-1,The Other People Place - C2 - Running From Love
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/C2 - Running From Love.flac
#EXTINF:-1,Brian Eno - D2 Written, Forgotten
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/D2 Written, Forgotten.flac
#EXTINF:-1,Autechre - Stud
/app/music/Autechre/1995 - Tri Repetae/05 Stud.flac
#EXTINF:-1,Model 500 - model_500-electric_night
/app/music/Model 500/2015 - Digital Solutions/02-model_500-electric_night.flac
#EXTINF:-1,The Orb - Close Encounters
/app/music/The Orb/1992 - UFOrb/05-Close Encounters.mp3
#EXTINF:-1,Model 500 - model_500-hi_nrg
/app/music/Model 500/2015 - Digital Solutions/01-model_500-hi_nrg.flac
#EXTINF:-1,Brian Eno - B3 Bone Jump
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/B3 Bone Jump.flac
#EXTINF:-1,Labradford - by Chris Johnston, Craig Markva, Jamie Evans,
/app/music/Labradford/1999 - E luxo so/4. by Chris Johnston, Craig Markva, Jamie Evans,.flac
#EXTINF:-1,The Orb - Star 6 & 7 8 9
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/09 Star 6 & 7 8 9.mp3
#EXTINF:-1,Proem - Proem - Vault ep.1-4 (Noise) - 02 Half a Heart
/app/music/Proem/2016 - Vault ep.1-4 (Noise)/Proem - Vault ep.1-4 (Noise) - 02 Half a Heart.flac
#EXTINF:-1,Dopplereffekt - Spirangle
/app/music/Dopplereffekt/2017 - Cellular Automata/08. Spirangle.flac
#EXTINF:-1,Drexciya - Unknown Journey VII
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/06. Unknown Journey VII.mp3
#EXTINF:-1,Drexciya - Mantaray
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/04. Mantaray.mp3
#EXTINF:-1,Pye Corner Audio - Pye Corner Audio - Untitled
/app/music/Pye Corner Audio/EP's & Singles/2017 - Pye Corner Audio, Silent Servant, Not Waving - Limited Edition EP (Vinyl, #E031COL)/02 - Pye Corner Audio - Untitled.mp3
#EXTINF:-1,Underworld - Underworld - Juanita, Kiteless, To Dream Of Love
/app/music/Underworld/1996 - Second Toughest In The Infants/01. Underworld - Juanita, Kiteless, To Dream Of Love.flac
#EXTINF:-1,Proem - Proem - As They Go - 05 In a Timeless, Lightless World
/app/music/Proem/2019 - As They Go/Proem - As They Go - 05 In a Timeless, Lightless World.flac
#EXTINF:-1,Model 500 - model_500-standing_in_tomorow
/app/music/Model 500/2015 - Digital Solutions/03-model_500-standing_in_tomorow.flac
#EXTINF:-1,The Orb - Plum Island
/app/music/The Orb/2001 - Cydonia/09-Plum Island.mp3
#EXTINF:-1,Orbital - Lush 3-2
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Lush 3-2.mp3
#EXTINF:-1,Dopplereffekt - Exponential Decay
/app/music/Dopplereffekt/2017 - Cellular Automata/09. Exponential Decay.flac
#EXTINF:-1,Brian Eno - Garden of Stars
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/04 Garden of Stars.flac
#EXTINF:-1,The Other People Place - B2 - You Said You Want Me
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/B2 - You Said You Want Me.flac
#EXTINF:-1,Dopplereffekt - Mandelbrot Set
/app/music/Dopplereffekt/2017 - Cellular Automata/07. Mandelbrot Set.flac
#EXTINF:-1,Autechre - Foil
/app/music/Autechre/1994 - Amber/01 Foil.flac
#EXTINF:-1,Proem - Proem - As They Go - 04 What is Needed
/app/music/Proem/2019 - As They Go/Proem - As They Go - 04 What is Needed.flac
#EXTINF:-1,Vector Lovers - Post Arctic Industries
/app/music/Vector Lovers/2005 - Capsule For One/06 - Post Arctic Industries.mp3
#EXTINF:-1,Proem - proem - Negativ - 12 Skylup
/app/music/Proem/2001 - Negativ/proem - Negativ - 12 Skylup.flac
#EXTINF:-1,Model 500 - model_500-encounter
/app/music/Model 500/2015 - Digital Solutions/04-model_500-encounter.flac
#EXTINF:-1,Kraftwerk - Pocket Calculator
/app/music/Kraftwerk/1981 - Computer World/02 - Pocket Calculator.flac
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 13   Reel Two End
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/13 Tape Loop Orchestra - Chapter 13   Reel Two End.mp3
#EXTINF:-1,Pye Corner Audio - Corrupt Data
/app/music/Pye Corner Audio/2017 - Half-Light (Prower Remixed) (WEB, #MTH011)/01 - Corrupt Data.mp3
#EXTINF:-1,Kiasmos - Kiasmos - II - 04 Laced
/app/music/Kiasmos/2024 - II/Kiasmos - II - 04 Laced.flac
#EXTINF:-1,Pye Corner Audio - Mindshaft
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/05 - Mindshaft.mp3
#EXTINF:-1,Labradford - G
/app/music/Labradford/1997 - Mi Media Naranja/2 G.flac
#EXTINF:-1,Dopplereffekt - Isotropy
/app/music/Dopplereffekt/2017 - Cellular Automata/04. Isotropy.flac
#EXTINF:-1,Autechre - Further
/app/music/Autechre/1994 - Amber/08 Further.flac
#EXTINF:-1,Proem - Proem - You Shall Have Ever Been - 02 Eck The Badly Drawn
/app/music/Proem/2006 - You Shall Have Ever Been/Proem - You Shall Have Ever Been - 02 Eck The Badly Drawn.flac
#EXTINF:-1,Autechre - C-Pach
/app/music/Autechre/1995 - Tri Repetae/07 C-Pach.flac
#EXTINF:-1,Kraftwerk - Neon Lights
/app/music/Kraftwerk/1978 - The Man-Machine/05 - Neon Lights.flac
#EXTINF:-1,Labradford - twenty
/app/music/Labradford/2001 - fixed..context/1 twenty.flac
#EXTINF:-1,Bark Psychosis - (01) [Bark Psychosis] Blue
/app/music/Bark Psychosis/1994 - Game Over/(01) [Bark Psychosis] Blue.flac
#EXTINF:-1,Vector Lovers - Substrata
/app/music/Vector Lovers/2005 - Capsule For One/03 - Substrata.mp3
#EXTINF:-1,Kraftwerk - Computer World
/app/music/Kraftwerk/1981 - Computer World/01 - Computer World.flac
#EXTINF:-1,Underworld - Underworld - Air Towel
/app/music/Underworld/1996 - Second Toughest In The Infants/06. Underworld - Air Towel.flac
#EXTINF:-1,Underworld - Underworld - Blueski
/app/music/Underworld/1996 - Second Toughest In The Infants/07. Underworld - Blueski.flac
#EXTINF:-1,Labradford - Experience The Gated Oscillator
/app/music/Labradford/1993 - Prazision LP/05 Experience The Gated Oscillator.flac
#EXTINF:-1,Dopplereffekt - Gestalt Intelligence
/app/music/Dopplereffekt/2017 - Cellular Automata/03. Gestalt Intelligence.flac
#EXTINF:-1,Labradford - Dulcimers played by Peter Neff. Strings played
/app/music/Labradford/1999 - E luxo so/3. Dulcimers played by Peter Neff. Strings played.flac
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 14   Tails Out
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/14 Tape Loop Orchestra - Chapter 14   Tails Out.mp3
#EXTINF:-1,Labradford - up to pizmo
/app/music/Labradford/2001 - fixed..context/2 up to pizmo.flac
#EXTINF:-1,Orbital - Sad But True
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/03. Sad But True.mp3
#EXTINF:-1,Orbital - Lush 3-1
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Lush 3-1.mp3
#EXTINF:-1,Dopplereffekt - Cellular Automata
/app/music/Dopplereffekt/2017 - Cellular Automata/01. Cellular Automata.flac
#EXTINF:-1,The Orb - Little Fluffy Clouds
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/01 Little Fluffy Clouds.mp3
#EXTINF:-1,Bark Psychosis - All Different Things
/app/music/Bark Psychosis/1994 - Independency/03 - All Different Things.flac
#EXTINF:-1,Orbital - Kein Trink Wasser
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/07. Kein Trink Wasser.mp3
#EXTINF:-1,The Orb - Perpetual Dawn
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/06 Perpetual Dawn.mp3
#EXTINF:-1,Underworld - Underworld - Stagger
/app/music/Underworld/1996 - Second Toughest In The Infants/08. Underworld - Stagger.flac
#EXTINF:-1,Dopplereffekt - von Neumann Probe
/app/music/Dopplereffekt/2017 - Cellular Automata/02. von Neumann Probe.flac
#EXTINF:-1,The Orb - EDM
/app/music/The Orb/2001 - Cydonia/12-EDM.mp3
#EXTINF:-1,Teeth Of The Sea - Reaper
/app/music/Teeth Of The Sea/2013 - Master/02 - Reaper.mp3
#EXTINF:-1,Drexciya - Drexciya - Aquabahn
/app/music/Drexciya/2013 - Journey Of The Deep Sea Dweller III/03. Drexciya - Aquabahn.mp3
#EXTINF:-1,Autechre - Rsdio
/app/music/Autechre/1995 - Tri Repetae/10 Rsdio.flac
#EXTINF:-1,Teeth Of The Sea - Transfinite
/app/music/Teeth Of The Sea/2010 - Your Mercury/01 Transfinite.mp3
#EXTINF:-1,Orbital - Philosophy By Numbers
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/06. Philosophy By Numbers.mp3
#EXTINF:-1,Autechre - Dael
/app/music/Autechre/1995 - Tri Repetae/01 Dael.flac
#EXTINF:-1,Vector Lovers - Boulevard
/app/music/Vector Lovers/2005 - Capsule For One/09 - Boulevard.mp3
#EXTINF:-1,Brian Eno - Reflection
/app/music/Brian Eno/2017 - Reflection/01. Reflection.mp3
#EXTINF:-1,Kiasmos - Thrown
/app/music/Kiasmos/2012 - Thrown EP/01 - Thrown.flac
#EXTINF:-1,The Other People Place - A2 - It's Your Love
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/A2 - It's Your Love.flac
#EXTINF:-1,Drexciya - Unknown Journey VIII
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/07. Unknown Journey VIII.mp3
#EXTINF:-1,Bark Psychosis - I Know
/app/music/Bark Psychosis/1994 - Independency/01 - I Know.flac
#EXTINF:-1,Underworld - Underworld - Rowla
/app/music/Underworld/1996 - Second Toughest In The Infants/04. Underworld - Rowla.flac
#EXTINF:-1,Orbital - Forever
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/01. Forever.mp3
#EXTINF:-1,Autechre - Clipper
/app/music/Autechre/1995 - Tri Repetae/02 Clipper.flac
#EXTINF:-1,The Other People Place - C1 - Let Me Be Me
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/C1 - Let Me Be Me.flac
#EXTINF:-1,Kraftwerk - Metropolis
/app/music/Kraftwerk/1978 - The Man-Machine/03 - Metropolis.flac
#EXTINF:-1,Labradford - The Cipher
/app/music/Labradford/1996 - Labradford/4 The Cipher.flac
#EXTINF:-1,Autechre - Silverside
/app/music/Autechre/1994 - Amber/03 Silverside.flac
#EXTINF:-1,Autechre - Nine
/app/music/Autechre/1994 - Amber/07 Nine.flac
#EXTINF:-1,Kraftwerk - Numbers
/app/music/Kraftwerk/1981 - Computer World/03 - Numbers.flac
#EXTINF:-1,Pye Corner Audio - Recrypt
/app/music/Pye Corner Audio/2011 - Black Mill Tapes Volume 2 - Do You Synthesize (WEB, #pca002)/06 - Recrypt.mp3
#EXTINF:-1,The Other People Place - D1 - Lifestyles Of The Casual
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/D1 - Lifestyles Of The Casual.flac
#EXTINF:-1,Autechre - Teartear
/app/music/Autechre/1994 - Amber/11 Teartear.flac
#EXTINF:-1,Teeth Of The Sea - in the space capsule (love theme)
/app/music/Teeth Of The Sea/2011 - Hypnoticon/01 in the space capsule (love theme).mp3
#EXTINF:-1,Kiasmos - Dragged
/app/music/Kiasmos/2014 - Kiasmos/06 - Dragged.flac
#EXTINF:-1,Dopplereffekt - Ulams Spiral
/app/music/Dopplereffekt/2017 - Cellular Automata/06. Ulams Spiral.flac
#EXTINF:-1,Brian Eno - Stiff
/app/music/Brian Eno/2024 - Eno (Original Motion Picture Soundtrack)/11. Stiff.flac
#EXTINF:-1,The Orb - A Huge Ever Growing Pulsating Brain That Rules From The Centre Of The Ultraworld_ Live Mix Mk 10
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/10 A Huge Ever Growing Pulsating Brain That Rules From The Centre Of The Ultraworld_ Live Mix Mk 10.mp3
#EXTINF:-1,The Orb - Sticky End
/app/music/The Orb/1992 - UFOrb/07-Sticky End.mp3
#EXTINF:-1,Vector Lovers - Microtron
/app/music/Vector Lovers/2005 - Capsule For One/04 - Microtron.mp3
#EXTINF:-1,Kraftwerk - Home Computer
/app/music/Kraftwerk/1981 - Computer World/06 - Home Computer.flac
#EXTINF:-1,Pye Corner Audio - Foreshadowed
/app/music/Pye Corner Audio/2012 - Black Mill Tapes Volume 3 - All Pathways Open (WEB, #pca003)/08 - Foreshadowed.mp3
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 2   Yasujiro Ozu
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/02 Tape Loop Orchestra - Chapter 2   Yasujiro Ozu.mp3
#EXTINF:-1,Brian Eno - Verdigris
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/08 Verdigris.flac
#EXTINF:-1,Tape Loop Orchestra - The Word On My Lips Is Your Name
/app/music/Tape Loop Orchestra/2012 - The Word On My Lips Is Your Name/Disc 1 - The Word On My Lips Is Your Name/01 - The Word On My Lips Is Your Name.flac
#EXTINF:-1,Vector Lovers - Melodies And Memory
/app/music/Vector Lovers/2005 - Capsule For One/07 - Melodies And Memory.mp3
#EXTINF:-1,Vector Lovers - To The Stars
/app/music/Vector Lovers/2005 - Capsule For One/12 - To The Stars.mp3
#EXTINF:-1,Dopplereffekt - Pascal's Recursion
/app/music/Dopplereffekt/2017 - Cellular Automata/05. Pascal's Recursion.flac
#EXTINF:-1,Orbital - Walk Now
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Walk Now.mp3
#EXTINF:-1,Orbital - Quality Seconds
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/08. Quality Seconds.mp3
#EXTINF:-1,Vector Lovers - Nostalgia 4 The Future
/app/music/Vector Lovers/2005 - Capsule For One/05 - Nostalgia 4 The Future.mp3
#EXTINF:-1,Brian Eno - D3 Late Anthropocene
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/D3 Late Anthropocene.flac
#EXTINF:-1,Autechre - Gnit
/app/music/Autechre/1995 - Tri Repetae/08 Gnit.flac
#EXTINF:-1,Pye Corner Audio - Electronic Rhythm Number Seven
/app/music/Pye Corner Audio/2011 - Black Mill Tapes Volume 2 - Do You Synthesize (WEB, #pca002)/02 - Electronic Rhythm Number Seven.mp3
#EXTINF:-1,Proem - Proem - Socially Inept - 05 Pinching Point
/app/music/Proem/2004 - Socially Inept/Proem - Socially Inept - 05 Pinching Point.flac
#EXTINF:-1,Vector Lovers - Empty Buildings, Falling Rain
/app/music/Vector Lovers/2005 - Capsule For One/08 - Empty Buildings, Falling Rain.mp3
#EXTINF:-1,Model 500 - model_500-control
/app/music/Model 500/2015 - Digital Solutions/09-model_500-control.flac
#EXTINF:-1,Proem - Proem - Vault ep.2-4 (Drone) - 02 Another Dull Moment
/app/music/Proem/2016 - Vault ep.2-4 (Drone)/Proem - Vault ep.2-4 (Drone) - 02 Another Dull Moment.flac
#EXTINF:-1,Teeth Of The Sea - Her Wraith
/app/music/Teeth Of The Sea/2019 - WRAITH/06 - Her Wraith.flac
#EXTINF:-1,Brian Eno - Slow Movement Sand
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/18 Slow Movement Sand.flac
#EXTINF:-1,Drexciya - Drexciya - You Don't Know
/app/music/Drexciya/2013 - Journey Of The Deep Sea Dweller III/13. Drexciya - You Don't Know.mp3
#EXTINF:-1,Proem - Proem - You Shall Have Ever Been - 07 Reddings
/app/music/Proem/2006 - You Shall Have Ever Been/Proem - You Shall Have Ever Been - 07 Reddings.flac
#EXTINF:-1,Teeth Of The Sea - Fortean Steed
/app/music/Teeth Of The Sea/2019 - WRAITH/04 - Fortean Steed.flac
#EXTINF:-1,Kraftwerk - The Model
/app/music/Kraftwerk/1978 - The Man-Machine/04 - The Model.flac
#EXTINF:-1,Pye Corner Audio - Yesterday's Entertainment
/app/music/Pye Corner Audio/2012 - Sleep Games (WEB, #GBX017)/07 - Yesterday's Entertainment.mp3
#EXTINF:-1,Brian Eno - B2 Forms Of Anger
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/B2 Forms Of Anger.flac
#EXTINF:-1,Vector Lovers - Neon Sky Rain
/app/music/Vector Lovers/2005 - Capsule For One/10 - Neon Sky Rain.mp3
#EXTINF:-1,Vector Lovers - Capsule For One
/app/music/Vector Lovers/2005 - Capsule For One/11 - Capsule For One.mp3
#EXTINF:-1,Pye Corner Audio - Solar Waves
/app/music/Pye Corner Audio/EP's & Singles/2019 - Dark Phase EP (WEB, #AF025)/02 - Solar Waves.mp3
#EXTINF:-1,Kraftwerk - Computer Love
/app/music/Kraftwerk/1981 - Computer World/05 - Computer Love.flac
#EXTINF:-1,Kiasmos - Held (Dauwd Remix)
/app/music/Kiasmos/2015 - Looped/02 Held (Dauwd Remix).flac
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 9   Setsu Ko Hara
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/09 Tape Loop Orchestra - Chapter 9   Setsu Ko Hara.mp3
#EXTINF:-1,The Orb - Back Side of the Moon
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/04 Back Side of the Moon.mp3
#EXTINF:-1,Model 500 - model_500-storm
/app/music/Model 500/2015 - Digital Solutions/05-model_500-storm.flac
#EXTINF:-1,Autechre - Montreal
/app/music/Autechre/1994 - Amber/02 Montreal.flac
#EXTINF:-1,Bark Psychosis - bark psychosis - eyes & smiles
/app/music/Bark Psychosis/1994 - Hex/05 - bark psychosis - eyes & smiles.mp3
#EXTINF:-1,The Other People Place - D2 - Sunrays
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/D2 - Sunrays.flac
#EXTINF:-1,Bark Psychosis - (06) [Bark Psychosis] Bloodrush
/app/music/Bark Psychosis/1994 - Game Over/(06) [Bark Psychosis] Bloodrush.flac
#EXTINF:-1,Bark Psychosis - bark psychosis - pendulum man
/app/music/Bark Psychosis/1994 - Hex/07 - bark psychosis - pendulum man.mp3
#EXTINF:-1,Autechre - Slip
/app/music/Autechre/1994 - Amber/04 Slip.flac
#EXTINF:-1,Kiasmos - Swayed
/app/music/Kiasmos/2014 - Kiasmos/04 - Swayed.flac
#EXTINF:-1,The Other People Place - A1 - Eye Contact
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/A1 - Eye Contact.flac
#EXTINF:-1,Kiasmos - Rival Consoles - Milo
/app/music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/03. Rival Consoles - Milo.flac
#EXTINF:-1,Teeth Of The Sea - Butterfly House
/app/music/Teeth Of The Sea/2023 - Hive/03 Butterfly House.flac
#EXTINF:-1,The Orb - A Mile Long Lump of Lard
/app/music/The Orb/2001 - Cydonia/07-A Mile Long Lump of Lard.mp3
#EXTINF:-1,Autechre - Eutow
/app/music/Autechre/1995 - Tri Repetae/06 Eutow.flac
#EXTINF:-1,Model 500 - model_500-the_groove
/app/music/Model 500/2015 - Digital Solutions/06-model_500-the_groove.flac
#EXTINF:-1,Bark Psychosis - bark psychosis - big shot
/app/music/Bark Psychosis/1994 - Hex/04 - bark psychosis - big shot.mp3
#EXTINF:-1,Kiasmos - Rival Consoles - ARP
/app/music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/05. Rival Consoles - ARP.flac
#EXTINF:-1,Kraftwerk - Spacelab
/app/music/Kraftwerk/1978 - The Man-Machine/02 - Spacelab.flac
#EXTINF:-1,Brian Eno - Who Gives a Thought
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/01 Who Gives a Thought.flac
#EXTINF:-1,Proem - Proem - Vault ep.4-4 - 04 v. jirku 1
/app/music/Proem/2015 - Vault ep.4-4/Proem - Vault ep.4-4 - 04 v. jirku 1.flac
#EXTINF:-1,Tape Loop Orchestra - The Burnley Brass Band Plays On In My Heart
/app/music/Tape Loop Orchestra/2012 - The Word On My Lips Is Your Name/Disc 2 - The Burnley Brass Band Plays On In My Heart/01 - The Burnley Brass Band Plays On In My Heart.flac
#EXTINF:-1,Proem - Proem - Before it finds you - 06 Pretense for piano and synth
/app/music/Proem/2013 - Before it finds you/Proem - Before it finds you - 06 Pretense for piano and synth.flac
#EXTINF:-1,Orbital - Remind
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Remind.mp3
#EXTINF:-1,Underworld - Underworld - Pearls Girl
/app/music/Underworld/1996 - Second Toughest In The Infants/05. Underworld - Pearls Girl.flac
#EXTINF:-1,Underworld - Underworld - Banstyle Sappys Curry
/app/music/Underworld/1996 - Second Toughest In The Infants/02. Underworld - Banstyle Sappys Curry.flac
#EXTINF:-1,Orbital - Attached
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/10. Attached.mp3
#EXTINF:-1,Labradford - P
/app/music/Labradford/1997 - Mi Media Naranja/7 P.flac
#EXTINF:-1,Drexciya - Drexciya - Vampire Island
/app/music/Drexciya/2013 - Journey Of The Deep Sea Dweller III/10. Drexciya - Vampire Island.mp3
#EXTINF:-1,Vector Lovers - Arrival, Metropolis
/app/music/Vector Lovers/2005 - Capsule For One/02 - Arrival, Metropolis.mp3
#EXTINF:-1,Teeth Of The Sea - Teeth Of The Sea - Highly Deadly Black Tarantula - 03 Field Punishment
/app/music/Teeth Of The Sea/2015 - Highly Deadly Black Tarantula/Teeth Of The Sea - Highly Deadly Black Tarantula - 03 Field Punishment.flac
#EXTINF:-1,Kiasmos - Swept (Tale of Us remix)
/app/music/Kiasmos/2015 - Swept EP/04 - Swept (Tale of Us remix).mp3
#EXTINF:-1,Proem - Proem - Vault ep.1-4 (Noise) - 05 Only Eat the Grey Wolves
/app/music/Proem/2016 - Vault ep.1-4 (Noise)/Proem - Vault ep.1-4 (Noise) - 05 Only Eat the Grey Wolves.flac
#EXTINF:-1,The Orb - Firestar
/app/music/The Orb/2001 - Cydonia/06-Firestar.mp3
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 11   Late Autumn
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/11 Tape Loop Orchestra - Chapter 11   Late Autumn.mp3
#EXTINF:-1,Kraftwerk - The Man·Machine
/app/music/Kraftwerk/1978 - The Man-Machine/06 - The Man·Machine.flac
#PLAYLIST:Escape Velocity - A Christmas Journey Through Space
#PHASE:Escape Velocity
#DURATION:12 hours (approx)
#CURATOR:Asteroid Radio
#DESCRIPTION:A festive 12-hour voyage blending Christmas classics with ambient, IDM, and space music for the holiday season
# === PHASE 1: WINTER AWAKENING (Ambient Beginnings) ===
#EXTINF:-1,Brian Eno - Snow
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac
#EXTINF:-1,Brian Eno - Wintergreen
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/04 Wintergreen.flac
#EXTINF:-1,Proem - Winter Wolves
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
#EXTINF:-1,Tim Hecker - Winter's Coming
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 10 Winter's Coming.flac
#EXTINF:-1,Biosphere - Drifter
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac
#EXTINF:-1,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

1508
static/asteroid.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -978,7 +978,43 @@
(.stat-label :color "#ccc"
:font-size 0.875rem
:margin-top 0.5rem))
:margin-top 0.5rem)
(.stat-detail :color "#888"
:font-size 0.75rem
:margin-top 0.25rem)
(.listener-stats-table
:width 100%
:border-collapse collapse
:background "#111"
:border "1px solid #333"
:table-layout fixed
(th :background "#1a1a1a"
:color "#00ff00"
:padding "12px 20px"
:text-align center
:border "1px solid #333"
:font-size "0.9rem"
:width "25%")
(td :padding "12px 20px"
:text-align "center"
:border "1px solid #333"
:vertical-align "middle"
:width "25%")
(.stat-number :font-size 1.75rem
:font-weight bold
:color "#00ffff"
:display block
:text-align "center")
(.stat-peak-row
:font-size 0.8rem
:color "#888"
:background "#0a0a0a")))
;; Center alignment for player page
;; (body.player-page
@ -1041,6 +1077,20 @@
:flex 1
:min-width "300px")
(.persistent-reconnect-btn
:background transparent
:color "#00ff00"
:border "1px solid #00ff00"
:padding "5px 10px"
:cursor "pointer"
:font-family #(main-font)
:font-size "0.85em"
:white-space nowrap
:margin-right "5px")
((:and .persistent-reconnect-btn :hover)
:background "#2a3441")
(.persistent-disable-btn
:background transparent
:color "#00ff00"

View File

@ -90,17 +90,21 @@
t)
(defun regenerate-stream-playlist ()
"Regenerate the main stream playlist from the current queue"
"Regenerate the main stream playlist from the current queue.
NOTE: This writes to project root stream-queue.m3u, NOT playlists/stream-queue.m3u
which is what Liquidsoap actually reads. This function may be deprecated."
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
(asdf:system-source-directory :asteroid))))
(if (null *stream-queue*)
;; If queue is empty, generate from all tracks (fallback)
(let ((all-tracks (dm:get "tracks" (db:query :all))))
(generate-m3u-playlist
(mapcar (lambda (track)
(dm:id track))
all-tracks)
playlist-path))
;; DISABLED: Don't dump all tracks when queue is empty
;; This was overwriting files with all library tracks unexpectedly
;; (let ((all-tracks (dm:get "tracks" (db:query :all))))
;; (generate-m3u-playlist
;; (mapcar (lambda (track)
;; (dm:id track))
;; all-tracks)
;; playlist-path))
(format t "Stream queue is empty, not generating playlist file~%")
;; Generate from queue
(generate-m3u-playlist *stream-queue* playlist-path))))

View File

@ -10,13 +10,14 @@
"Recursively scan directory for supported audio files"
(when (cl-fad:directory-exists-p directory)
(remove-if-not #'supported-audio-file-p
(cl-fad:list-directory directory :follow-symlinks nil))))
(cl-fad:list-directory directory :follow-symlinks t))))
(defun scan-directory-for-music-recursively (path)
"Recursively scan directory and all subdirectories for music files"
(let ((files-in-current-dir (scan-directory-for-music path))
(files-in-subdirs (loop for directory in (uiop:subdirectories path)
appending (scan-directory-for-music-recursively directory))))
(let* ((resolved-path (truename path))
(files-in-current-dir (scan-directory-for-music resolved-path))
(files-in-subdirs (loop for directory in (uiop:subdirectories resolved-path)
appending (scan-directory-for-music-recursively directory))))
(append files-in-current-dir files-in-subdirs)))
(defun extract-metadata-with-taglib (file-path)
@ -92,16 +93,25 @@
(setf (dm:field track "file-path") file-path)
(setf (dm:field track "format") (getf metadata :format))
(setf (dm:field track "bitrate") (getf metadata :bitrate))
(setf (dm:field track "added-date") (local-time:timestamp-to-unix (local-time:now)))
;; Let database default handle added-date (CURRENT_TIMESTAMP)
(setf (dm:field track "play-count") 0)
(dm:insert track)
t))))
(defun scan-music-library (&optional (directory *music-library-path*))
"Scan music library directory and add tracks to database"
(format t "~%=== SCAN DEBUG ===~%")
(format t "Input directory: ~a~%" directory)
(format t "Directory exists: ~a~%" (probe-file directory))
(handler-case
(format t "Resolved path: ~a~%" (truename directory))
(error (e) (format t "Cannot resolve truename: ~a~%" e)))
(let ((audio-files (scan-directory-for-music-recursively directory))
(added-count 0)
(skipped-count 0))
(format t "Found ~a audio files~%" (length audio-files))
(when (> (length audio-files) 0)
(format t "First few files: ~{~a~%~}~%" (subseq audio-files 0 (min 3 (length audio-files)))))
(dolist (file audio-files)
(let ((metadata (extract-metadata-with-taglib file)))
(when metadata
@ -111,6 +121,7 @@
(incf skipped-count))
(error (e)
(format t "Error adding ~a: ~a~%" file e))))))
(format t "Added: ~a, Skipped: ~a~%" added-count skipped-count)
added-count))
;; Initialize music directory structure

106
template/about-content.ctml Normal file
View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>About - Asteroid Radio</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
</head>
<body>
<div class="container">
<header>
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
<span>ABOUT ASTEROID RADIO</span>
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
</h1>
<nav class="nav">
<a href="/asteroid/content" target="_self">Home</a>
<a href="/asteroid/player-content" target="_self">Player</a>
<a href="/asteroid/about-content" target="_self">About</a>
<a href="/asteroid/status" target="_self">Status</a>
<a href="/asteroid/profile" target="_self" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" target="_self" data-show-if-admin>Admin</a>
<a href="/asteroid/login" target="_self" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" target="_self" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
</nav>
</header>
<main style="max-width: 800px; margin: 0 auto; padding: 20px;">
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🎵 Asteroid Music for Hackers</h2>
<p style="line-height: 1.6;">
Asteroid Radio is a community-driven internet radio station born from the SystemCrafters community.
We celebrate the intersection of music, technology, and hacker culture—broadcasting for those who
appreciate both great code and great music.
</p>
<p style="line-height: 1.6;">
We met through a shared set of technical biases and a love for building systems from first principles.
Asteroid Radio embodies that ethos: <strong>music for hackers, built by hackers</strong>.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🛠️ Built with Common Lisp</h2>
<p style="line-height: 1.6;">
This entire platform is built using <strong>Common Lisp</strong>, demonstrating the power and elegance
of Lisp for modern web applications. We use:
</p>
<ul style="line-height: 1.8; margin-left: 20px;">
<li><strong><a href="https://codeberg.org/shirakumo/radiance" style="color: #00ff00;">Radiance</a></strong> - Web application framework</li>
<li><strong><a href="https://codeberg.org/shinmera/clip" style="color: #00ff00;">Clip</a></strong> - HTML5-compliant template engine</li>
<li><strong><a href="https://codeberg.org/shinmera/LASS" style="color: #00ff00;">LASS</a></strong> - Lisp Augmented Style Sheets</li>
<li><strong><a href="https://gitlab.common-lisp.net/parenscript/parenscript" style="color: #00ff00;">ParenScript</a></strong> - Lisp-to-JavaScript compiler</li>
<li><strong><a href="https://icecast.org/" style="color: #00ff00;">Icecast</a></strong> - Streaming media server</li>
<li><strong><a href="https://www.liquidsoap.info/" style="color: #00ff00;">Liquidsoap</a></strong> - Audio stream generation</li>
</ul>
<p style="line-height: 1.6;">
By building in Common Lisp, we're doubling down on our technical values and creating features
for "our people"—those who appreciate the elegance of Lisp and the power of understanding your tools deeply.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">📖 Open Source & AGPL Licensed</h2>
<p style="line-height: 1.6;">
Asteroid Radio is <strong>free and open source software</strong>, licensed under the
<strong><a href="https://www.gnu.org/licenses/agpl-3.0.en.html" style="color: #00ff00;">GNU Affero General Public License v3.0 (AGPL)</a></strong>.
</p>
<p style="line-height: 1.6;">
The source code is available at:
<a href="https://github.com/Fade/asteroid" style="color: #00ff00; font-weight: bold;">https://github.com/Fade/asteroid</a>
</p>
<p style="line-height: 1.6;">
We believe in transparency, collaboration, and the freedom to study, modify, and share the software we use.
The AGPL ensures that improvements to Asteroid Radio remain free and available to everyone.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🎧 Features</h2>
<ul style="line-height: 1.8; margin-left: 20px;">
<li><strong>Live Streaming</strong> - Multiple quality options (AAC, MP3)</li>
<li><strong>Persistent Player</strong> - Frameset mode for uninterrupted playback while browsing</li>
<li><strong>Spectrum Analyzer</strong> - Real-time audio visualization with customizable themes</li>
<li><strong>Track Library</strong> - Browse and search the music collection</li>
<li><strong>User Profiles</strong> - Track your listening history</li>
<li><strong>Admin Tools</strong> - Manage tracks, users, and playlists</li>
</ul>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🤝 Community</h2>
<p style="line-height: 1.6;">
We're part of the <strong><a href="https://systemcrafters.net/" style="color: #00ff00;">SystemCrafters</a></strong>
community—a group of developers, hackers, and enthusiasts who believe in building systems from first principles,
understanding our tools deeply, and sharing knowledge freely.
</p>
<p style="line-height: 1.6;">
Join us in celebrating the intersection of great music and great code!
</p>
</section>
</main>
</div>
</body>
</html>

109
template/about.ctml Normal file
View File

@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>About - Asteroid Radio</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/asteroid/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
</head>
<body>
<div class="container">
<header>
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
<span>ABOUT ASTEROID RADIO</span>
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
</h1>
<nav class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/player">Player</a>
<a href="/asteroid/about">About</a>
<a href="/asteroid/status">Status</a>
<a href="/asteroid/profile" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" data-show-if-admin>Admin</a>
<a href="/asteroid/login" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
</nav>
</header>
<main style="max-width: 800px; margin: 0 auto; padding: 20px;">
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🎵 Asteroid Music for Hackers</h2>
<p style="line-height: 1.6;">
Asteroid Radio is a community-driven internet radio station born from the SystemCrafters community.
We celebrate the intersection of music, technology, and hacker culture—broadcasting for those who
appreciate both great code and great music.
</p>
<p style="line-height: 1.6;">
We met through a shared set of technical biases and a love for building systems from first principles.
Asteroid Radio embodies that ethos: <strong>music for hackers, built by hackers</strong>.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🛠️ Built with Common Lisp</h2>
<p style="line-height: 1.6;">
This entire platform is built using <strong>Common Lisp</strong>, demonstrating the power and elegance
of Lisp for modern web applications. We use:
</p>
<ul style="line-height: 1.8; margin-left: 20px;">
<li><strong><a href="https://codeberg.org/shirakumo/radiance" style="color: #00ff00;">Radiance</a></strong> - Web application framework</li>
<li><strong><a href="https://codeberg.org/shinmera/clip" style="color: #00ff00;">Clip</a></strong> - HTML5-compliant template engine</li>
<li><strong><a href="https://codeberg.org/shinmera/LASS" style="color: #00ff00;">LASS</a></strong> - Lisp Augmented Style Sheets</li>
<li><strong><a href="https://gitlab.common-lisp.net/parenscript/parenscript" style="color: #00ff00;">ParenScript</a></strong> - Lisp-to-JavaScript compiler</li>
<li><strong><a href="https://icecast.org/" style="color: #00ff00;">Icecast</a></strong> - Streaming media server</li>
<li><strong><a href="https://www.liquidsoap.info/" style="color: #00ff00;">Liquidsoap</a></strong> - Audio stream generation</li>
</ul>
<p style="line-height: 1.6;">
By building in Common Lisp, we're doubling down on our technical values and creating features
for "our people"—those who appreciate the elegance of Lisp and the power of understanding your tools deeply.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">📖 Open Source & AGPL Licensed</h2>
<p style="line-height: 1.6;">
Asteroid Radio is <strong>free and open source software</strong>, licensed under the
<strong><a href="https://www.gnu.org/licenses/agpl-3.0.en.html" style="color: #00ff00;">GNU Affero General Public License v3.0 (AGPL)</a></strong>.
</p>
<p style="line-height: 1.6;">
The source code is available at:
<a href="https://github.com/Fade/asteroid" style="color: #00ff00; font-weight: bold;">https://github.com/Fade/asteroid</a>
</p>
<p style="line-height: 1.6;">
We believe in transparency, collaboration, and the freedom to study, modify, and share the software we use.
The AGPL ensures that improvements to Asteroid Radio remain free and available to everyone.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🎧 Features</h2>
<ul style="line-height: 1.8; margin-left: 20px;">
<li><strong>Live Streaming</strong> - Multiple quality options (AAC, MP3)</li>
<li><strong>Persistent Player</strong> - Frameset mode for uninterrupted playback while browsing</li>
<li><strong>Spectrum Analyzer</strong> - Real-time audio visualization with customizable themes</li>
<li><strong>Track Library</strong> - Browse and search the music collection</li>
<li><strong>User Profiles</strong> - Track your listening history</li>
<li><strong>Admin Tools</strong> - Manage tracks, users, and playlists</li>
</ul>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🤝 Community</h2>
<p style="line-height: 1.6;">
We're part of the <strong><a href="https://systemcrafters.net/" style="color: #00ff00;">SystemCrafters</a></strong>
community—a group of developers, hackers, and enthusiasts who believe in building systems from first principles,
understanding our tools deeply, and sharing knowledge freely.
</p>
<p style="line-height: 1.6;">
Join us in celebrating the intersection of great music and great code!
</p>
</section>
</main>
</div>
</body>
</html>

View File

@ -38,29 +38,82 @@
<div class="status-card">
<h3>Icecast Status</h3>
<p class="status-error" data-text="icecast-status">🔴 Not Running</p>
<button id="icecast-restart" class="btn btn-danger btn-sm" style="margin-top: 8px;">🔄 Restart</button>
</div>
</div>
</div>
<!-- Listener Statistics -->
<div class="admin-section">
<h2>📊 Current Listeners</h2>
<table class="listener-stats-table" style="table-layout: fixed; width: 100%;">
<colgroup>
<col style="width: 25%;">
<col style="width: 25%;">
<col style="width: 25%;">
<col style="width: 25%;">
</colgroup>
<thead>
<tr>
<th>🎵 MP3</th>
<th>🎧 AAC</th>
<th>📱 Low</th>
<th>📈 Total</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: center;"><span class="stat-number" id="listeners-mp3">0</span></td>
<td style="text-align: center;"><span class="stat-number" id="listeners-aac">0</span></td>
<td style="text-align: center;"><span class="stat-number" id="listeners-low">0</span></td>
<td style="text-align: center;"><span class="stat-number" id="listeners-total">0</span></td>
</tr>
<tr class="stat-peak-row">
<td style="text-align: center;">Peak: <span id="peak-mp3">0</span></td>
<td style="text-align: center;">Peak: <span id="peak-aac">0</span></td>
<td style="text-align: center;">Peak: <span id="peak-low">0</span></td>
<td style="text-align: center;">Updated: <span id="stats-updated">--</span></td>
</tr>
</tbody>
</table>
<div class="admin-controls" style="margin-top: 10px;">
<button id="refresh-stats" class="btn btn-secondary" onclick="refreshListenerStats()">🔄 Refresh</button>
<span id="stats-status" style="margin-left: 15px;"></span>
</div>
<!-- Geo Stats -->
<h3 style="margin-top: 20px;">🌍 Listener Locations (Last 7 Days)</h3>
<div id="geo-stats-container">
<table class="listener-stats-table" id="geo-stats-table">
<thead>
<tr>
<th>Country</th>
<th>Listeners</th>
<th>Minutes</th>
</tr>
</thead>
<tbody id="geo-stats-body">
<tr><td colspan="3" style="color: #888;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Music Library Management -->
<div class="admin-section">
<h2>Music Library Management</h2>
<!-- File Upload -->
<!-- Music Library Info -->
<div class="upload-section">
<h3>Add Music Files</h3>
<h3>Music Library</h3>
<div class="upload-info">
<p><strong>To add your own MP3 files:</strong></p>
<p>The music library is mounted from your local filesystem into the Liquidsoap container.</p>
<p><strong>To add music:</strong></p>
<ol>
<li>Copy your MP3/FLAC/OGG/WAV files to: <code>/home/glenn/Projects/Code/asteroid/music/incoming/</code></li>
<li>Click "Copy Files to Library" below</li>
<li>Files will be moved to the library and added to the database</li>
<li>Add files to your music library directory (set via <code>MUSIC_LIBRARY</code> env var)</li>
<li>Click "Scan Library" to index new tracks into the database</li>
</ol>
<p><em>Supported formats: MP3, FLAC, OGG, WAV</em></p>
</div>
<div class="upload-controls">
<button id="copy-files" class="btn btn-success">📁 Copy Files to Library</button>
<button id="open-incoming" class="btn btn-info">📂 Open Incoming Folder</button>
<p><em>Supported formats: MP3, FLAC, OGG, WAV, OPUS</em></p>
</div>
</div>
@ -107,59 +160,94 @@
</div>
</div>
<!-- Live Stream Monitor -->
<div class="admin-section">
<h2>📻 Live Stream Monitor</h2>
<div class="live-stream-monitor">
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
<p><strong>Now Playing:</strong> <span id="live-now-playing">Loading...</span></p>
<audio id="live-stream-audio" controls style="width: 100%; max-width: 600px;">
<source id="live-stream-source" lquery="(attr :src default-stream-url)" type="audio/aac">
Your browser does not support the audio element.
</audio>
</div>
</div>
<!-- Stream Queue Management -->
<div class="admin-section">
<h2>🎵 Stream Queue Management</h2>
<p>Manage the live stream playback queue. Changes take effect within 5-10 seconds.</p>
<p>Manage the live stream playback queue. Liquidsoap watches <code>stream-queue.m3u</code> and reloads automatically.</p>
<div class="queue-controls">
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
<button id="load-from-m3u" class="btn btn-success">📂 Load Queue from M3U</button>
<button id="clear-queue-btn" class="btn btn-warning">🗑️ Clear Queue</button>
<button id="add-random-tracks" class="btn btn-info">🎲 Add 10 Random Tracks</button>
<!-- Playlist Selection -->
<div class="playlist-controls" style="margin-bottom: 20px; padding: 15px; background: #2a2a2a; border-radius: 8px;">
<h3 style="margin-top: 0;">📋 Load Playlist</h3>
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<select id="playlist-select" class="sort-select" style="min-width: 250px;">
<option value="">-- Select a playlist --</option>
</select>
<button id="load-playlist-btn" class="btn btn-success">📂 Load Selected</button>
<button id="refresh-playlists-btn" class="btn btn-secondary">🔄 Refresh List</button>
</div>
<p style="margin: 10px 0 0 0; font-size: 0.9em; color: #888;">
Loading a playlist will copy it to <code>stream-queue.m3u</code> and Liquidsoap will start playing it.
</p>
</div>
<div id="stream-queue-container" class="queue-list">
<!-- Queue Controls -->
<div class="queue-controls" style="margin-bottom: 15px;">
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
<button id="save-queue-btn" class="btn btn-primary">💾 Save Queue</button>
<button id="clear-queue-btn" class="btn btn-warning">🗑️ Clear Queue</button>
<button id="add-random-tracks" class="btn btn-info">🎲 Add 10 Random</button>
</div>
<!-- Save As -->
<div style="margin-bottom: 15px; display: flex; gap: 10px; align-items: center;">
<input type="text" id="save-as-name" placeholder="New playlist name..." class="search-input" style="max-width: 250px;">
<button id="save-as-btn" class="btn btn-success">💾 Save As New Playlist</button>
</div>
<!-- Queue Status -->
<div id="queue-status" style="margin-bottom: 15px; padding: 10px; background: #1a1a1a; border-radius: 4px;">
<span id="queue-count">0</span> tracks in queue
</div>
<!-- Queue Contents -->
<div id="stream-queue-container" class="queue-list" style="max-height: 400px; overflow-y: auto;">
<div class="loading">Loading queue...</div>
</div>
<div class="queue-actions">
<div class="queue-actions" style="margin-top: 20px;">
<h3>Add Tracks to Queue</h3>
<input type="text" id="queue-track-search" placeholder="Search tracks to add..." class="search-input">
<div id="queue-track-results" class="track-results"></div>
</div>
</div>
<!-- Player Control -->
<!-- Liquidsoap Stream Control -->
<div class="admin-section">
<h2>Player Control</h2>
<div class="card">
<h3>🎵 Player Control</h3>
<div class="player-controls">
<button id="player-play" class="btn btn-primary" onclick="playTrack()">▶️ Play</button>
<button id="player-pause" class="btn btn-secondary" onclick="pausePlayer()">⏸️ Pause</button>
<button id="player-stop" class="btn btn-secondary" onclick="stopPlayer()">⏹️ Stop</button>
<button id="player-resume" class="btn btn-secondary" onclick="resumePlayer()">▶️ Resume</button>
<h2>📡 Stream Control (Liquidsoap)</h2>
<p>Control the live audio stream. Commands are sent directly to Liquidsoap.</p>
<!-- Status Display -->
<div id="liquidsoap-status" style="margin-bottom: 20px; padding: 15px; background: #1a1a1a; border-radius: 8px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div>
<strong>Uptime:</strong> <span id="ls-uptime">--</span>
</div>
<div>
<strong>Remaining:</strong> <span id="ls-remaining">--</span>
</div>
</div>
<div id="player-status" class="status-info">
Status: <span id="player-state">Unknown</span><br>
Current Track: <span id="current-track">None</span>
<div style="margin-top: 10px;">
<strong>Now Playing:</strong> <span id="ls-metadata">--</span>
</div>
</div>
<!-- Control Buttons -->
<div class="queue-controls" style="margin-bottom: 15px;">
<button id="ls-refresh-status" class="btn btn-secondary">🔄 Refresh Status</button>
<button id="ls-skip" class="btn btn-warning">⏭️ Skip Track</button>
<button id="ls-reload" class="btn btn-info">📂 Reload Playlist</button>
<button id="ls-restart" class="btn btn-danger">🔄 Restart Container</button>
</div>
<p style="font-size: 0.9em; color: #888;">
<strong>Skip Track:</strong> Immediately skip to the next track in the playlist.<br>
<strong>Reload Playlist:</strong> Force Liquidsoap to re-read stream-queue.m3u.<br>
<strong>Restart Container:</strong> Restart the Liquidsoap Docker container (causes brief stream interruption).
</p>
</div>
<!-- User Management -->
<div class="admin-section">
<div class="card">
<h3>👥 User Management</h3>
<p>Manage user accounts, roles, and permissions.</p>
@ -192,6 +280,102 @@
</div>
<script>
// Listener Statistics
function refreshListenerStats() {
const statusEl = document.getElementById('stats-status');
statusEl.textContent = 'Loading...';
fetch('/api/asteroid/stats/current')
.then(response => response.json())
.then(result => {
const data = result.data || result;
if (data.status === 'success' && data.listeners) {
// Process listener data - get most recent for each mount
const mounts = {};
data.listeners.forEach(item => {
// item is [mount, "/asteroid.mp3", listeners, 1, timestamp, 123456]
const mount = item[1];
const listeners = item[3];
if (!mounts[mount] || item[5] > mounts[mount].timestamp) {
mounts[mount] = { listeners: listeners, timestamp: item[5] };
}
});
// Update UI
const mp3 = mounts['/asteroid.mp3']?.listeners || 0;
const aac = mounts['/asteroid.aac']?.listeners || 0;
const low = mounts['/asteroid-low.mp3']?.listeners || 0;
document.getElementById('listeners-mp3').textContent = mp3;
document.getElementById('listeners-aac').textContent = aac;
document.getElementById('listeners-low').textContent = low;
document.getElementById('listeners-total').textContent = mp3 + aac + low;
const now = new Date();
document.getElementById('stats-updated').textContent =
now.toLocaleTimeString();
statusEl.textContent = '';
} else {
statusEl.textContent = 'No data available';
}
})
.catch(error => {
console.error('Error fetching stats:', error);
statusEl.textContent = 'Error loading stats';
});
}
// Country code to flag emoji
function countryToFlag(countryCode) {
if (!countryCode || countryCode.length !== 2) return '🌍';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
// Fetch and display geo stats
function refreshGeoStats() {
fetch('/api/asteroid/stats/geo?days=7')
.then(response => response.json())
.then(result => {
const data = result.data || result;
const tbody = document.getElementById('geo-stats-body');
if (data.status === 'success' && data.geo && data.geo.length > 0) {
tbody.innerHTML = data.geo.map(item => {
const country = item.country_code || item[0];
const listeners = item.total_listeners || item[1] || 0;
const minutes = item.total_minutes || item[2] || 0;
return `<tr>
<td>${countryToFlag(country)} ${country}</td>
<td>${listeners}</td>
<td>${minutes}</td>
</tr>`;
}).join('');
} else {
tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>';
}
})
.catch(error => {
console.error('Error fetching geo stats:', error);
document.getElementById('geo-stats-body').innerHTML =
'<tr><td colspan="3" style="color: #ff6666;">Error loading geo data</td></tr>';
});
}
// Auto-refresh stats every 30 seconds
setInterval(refreshListenerStats, 30000);
setInterval(refreshGeoStats, 60000);
// Initial load
document.addEventListener('DOMContentLoaded', function() {
refreshListenerStats();
refreshGeoStats();
});
// Admin password reset handler
function resetUserPassword(event) {
event.preventDefault();

View File

@ -28,10 +28,17 @@
<span class="now-playing-mini" id="mini-now-playing">Loading...</span>
<button id="reconnect-btn" onclick="reconnectStream()" class="persistent-reconnect-btn" title="Reconnect if audio stops working">
🔄
</button>
<button onclick="disableFramesetMode()" class="persistent-disable-btn">
✕ Disable
</button>
</div>
<!-- Status indicator for connection issues -->
<div id="stream-status" style="display: none; background: #550000; color: #ff6666; padding: 4px 10px; text-align: center; font-size: 0.85em;"></div>
<script>
// Configure audio element for better streaming
@ -134,6 +141,145 @@
// Redirect parent window to regular view
window.parent.location.href = '/asteroid/';
}
// Show status message
function showStatus(message, isError) {
const status = document.getElementById('stream-status');
if (status) {
status.textContent = message;
status.style.display = 'block';
status.style.background = isError ? '#550000' : '#005500';
status.style.color = isError ? '#ff6666' : '#66ff66';
if (!isError) {
setTimeout(() => { status.style.display = 'none'; }, 3000);
}
}
}
function hideStatus() {
const status = document.getElementById('stream-status');
if (status) {
status.style.display = 'none';
}
}
// Reconnect stream - recreates audio element to fix wedged state
function reconnectStream() {
console.log('Reconnecting stream...');
showStatus('🔄 Reconnecting...', false);
const container = document.querySelector('.persistent-player');
const oldAudio = document.getElementById('persistent-audio');
const streamBaseUrl = document.getElementById('stream-base-url').value;
const streamQuality = localStorage.getItem('stream-quality') || 'aac';
const config = getStreamConfig(streamBaseUrl, streamQuality);
if (!container || !oldAudio) {
showStatus('❌ Could not reconnect - reload page', true);
return;
}
// Save current volume and muted state
const savedVolume = oldAudio.volume;
const savedMuted = oldAudio.muted;
console.log('Saving volume:', savedVolume, 'muted:', savedMuted);
// Reset spectrum analyzer if it exists
if (window.resetSpectrumAnalyzer) {
window.resetSpectrumAnalyzer();
}
// Stop and remove old audio
oldAudio.pause();
oldAudio.src = '';
oldAudio.load();
// Create new audio element
const newAudio = document.createElement('audio');
newAudio.id = 'persistent-audio';
newAudio.controls = true;
newAudio.preload = 'metadata';
newAudio.crossOrigin = 'anonymous';
// Restore volume and muted state
newAudio.volume = savedVolume;
newAudio.muted = savedMuted;
// Create source
const source = document.createElement('source');
source.id = 'audio-source';
source.src = config.url;
source.type = config.type;
newAudio.appendChild(source);
// Replace old audio with new
oldAudio.replaceWith(newAudio);
// Re-attach event listeners
attachAudioListeners(newAudio);
// Try to play
setTimeout(() => {
newAudio.play()
.then(() => {
console.log('Reconnected successfully');
showStatus('✓ Reconnected!', false);
// Reinitialize spectrum analyzer - try in this frame first
if (window.initSpectrumAnalyzer) {
setTimeout(() => window.initSpectrumAnalyzer(), 500);
}
// Also try in content frame (where spectrum canvas usually is)
try {
const contentFrame = window.parent.frames['content-frame'];
if (contentFrame && contentFrame.initSpectrumAnalyzer) {
setTimeout(() => {
if (contentFrame.resetSpectrumAnalyzer) {
contentFrame.resetSpectrumAnalyzer();
}
contentFrame.initSpectrumAnalyzer();
console.log('Spectrum analyzer reinitialized in content frame');
}, 600);
}
} catch(e) {
console.log('Could not reinit spectrum in content frame:', e);
}
})
.catch(err => {
console.log('Reconnect play failed:', err);
showStatus('Click play to start stream', false);
});
}, 300);
}
// Attach event listeners to audio element
function attachAudioListeners(audioElement) {
audioElement.addEventListener('waiting', function() {
console.log('Audio buffering...');
});
audioElement.addEventListener('playing', function() {
console.log('Audio playing');
hideStatus();
});
audioElement.addEventListener('error', function(e) {
console.error('Audio error:', e);
showStatus('⚠️ Stream error - click 🔄 to reconnect', true);
});
audioElement.addEventListener('stalled', function() {
console.log('Audio stalled');
showStatus('⚠️ Stream stalled - click 🔄 if no audio', true);
});
}
// Attach listeners to initial audio element
document.addEventListener('DOMContentLoaded', function() {
const audioElement = document.getElementById('persistent-audio');
if (audioElement) {
attachAudioListeners(audioElement);
}
});
</script>
</body>
</html>

View File

@ -51,13 +51,14 @@
</div>
<nav class="nav">
<a href="/asteroid/content" target="content-frame">Home</a>
<a href="/asteroid/player-content" target="content-frame">Player</a>
<a href="/asteroid/status" target="content-frame">Status</a>
<a href="/asteroid/profile" target="content-frame" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" target="content-frame" data-show-if-admin>Admin</a>
<a href="/asteroid/login" target="content-frame" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" target="content-frame" data-show-if-logged-out>Register</a>
<a href="/asteroid/content" target="_self">Home</a>
<a href="/asteroid/player-content" target="_self">Player</a>
<a href="/asteroid/about-content" target="_self">About</a>
<a href="/asteroid/status-content" target="_self">Status</a>
<a href="/asteroid/profile" target="_self" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" target="_self" data-show-if-admin>Admin</a>
<a href="/asteroid/login" target="_self" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" target="_self" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
</nav>
</header>

View File

@ -53,6 +53,7 @@
<nav class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/player">Player</a>
<a href="/asteroid/about">About</a>
<a href="/asteroid/status">Status</a>
<a href="/asteroid/profile" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" data-show-if-admin>Admin</a>
@ -84,6 +85,9 @@
<p><strong class="live-stream-label" class="live-stream-label">BROADCASTING:</strong> <span id="stream-status" style="">Asteroid music for Hackers</span></p>
<div style="display: flex; gap: 10px; justify-content: end; margin-bottom: 20px;">
<button id="reconnect-btn" class="btn btn-warning" onclick="reconnectStream()" style="font-size: 0.9em; display: none;">
🔄 Reconnect Stream
</button>
<button id="popout-btn" class="btn btn-info" onclick="openPopoutPlayer()" style="font-size: 0.9em;">
🗗 Pop Out Player
</button>
@ -92,10 +96,15 @@
</button>
</div>
<audio id="live-audio" controls crossorigin="anonymous" style="width: 100%; margin: 10px 0;">
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
Your browser does not support the audio element.
</audio>
<!-- Stream connection status -->
<div id="stream-status-indicator" style="display: none; padding: 8px; margin-bottom: 10px; border-radius: 4px; text-align: center;"></div>
<div id="audio-container">
<audio id="live-audio" controls crossorigin="anonymous" style="width: 100%; margin: 10px 0;">
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
Your browser does not support the audio element.
</audio>
</div>
</div>
<div id="now-playing" class="now-playing"></div>

View File

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
<script src="/asteroid/static/js/front-page.js"></script>
<script src="/asteroid/static/js/player.js"></script>
<script src="/api/asteroid/spectrum-analyzer.js"></script>
</head>

View File

@ -9,6 +9,7 @@
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
<script src="/asteroid/static/js/front-page.js"></script>
<script src="/asteroid/static/js/player.js"></script>
</head>
<body>

View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Asteroid Radio - Status</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
</head>
<body>
<div class="container">
<header>
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
<span>📡 SYSTEM STATUS</span>
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
</h1>
<nav class="nav">
<a href="/asteroid/content" target="_self">Home</a>
<a href="/asteroid/player-content" target="_self">Player</a>
<a href="/asteroid/about-content" target="_self">About</a>
<a href="/asteroid/status-content" target="_self">Status</a>
<a href="/asteroid/profile" target="_self" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" target="_self" data-show-if-admin>Admin</a>
<a href="/asteroid/login" target="_self" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" target="_self" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
</nav>
</header>
<main style="max-width: 800px; margin: 0 auto; padding: 20px;">
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🟢 Server Status</h2>
<p style="line-height: 1.6;">
Asteroid Radio is currently online and broadcasting.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">📊 Stream Information</h2>
<ul style="line-height: 1.8;">
<li><strong>Status:</strong> 🟢 Live</li>
<li><strong>Formats:</strong> AAC 96kbps, MP3 128kbps, MP3 64kbps</li>
<li><strong>Server:</strong> Icecast</li>
</ul>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;"> Additional Information</h2>
<p style="line-height: 1.6;">
For detailed system status and administration, please visit the <a href="/asteroid/admin" style="color: #00ff00;" data-show-if-admin>Admin Dashboard</a>.
</p>
</section>
</main>
</div>
</body>
</html>

57
template/status.ctml Normal file
View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Asteroid Radio - Status</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
</head>
<body>
<div class="container">
<header>
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
<span>📡 SYSTEM STATUS</span>
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
</h1>
<nav class="nav">
<a href="/asteroid/frameset">Home</a>
<a href="/asteroid/player">Player</a>
<a href="/asteroid/about">About</a>
<a href="/asteroid/status">Status</a>
<a href="/asteroid/profile" data-show-if-logged-in>Profile</a>
<a href="/asteroid/admin" data-show-if-admin>Admin</a>
<a href="/asteroid/login" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout" onclick="event.preventDefault(); fetch('/asteroid/logout').then(() => window.location.href='/asteroid/frameset');">Logout</a>
</nav>
</header>
<main style="max-width: 800px; margin: 0 auto; padding: 20px;">
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">🟢 Server Status</h2>
<p style="line-height: 1.6;">
Asteroid Radio is currently online and broadcasting.
</p>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;">📊 Stream Information</h2>
<ul style="line-height: 1.8;">
<li><strong>Status:</strong> 🟢 Live</li>
<li><strong>Formats:</strong> AAC 96kbps, MP3 128kbps, MP3 64kbps</li>
<li><strong>Server:</strong> Icecast</li>
</ul>
</section>
<section style="margin-bottom: 30px;">
<h2 style="color: #00ff00; border-bottom: 2px solid #00ff00; padding-bottom: 10px;"> Additional Information</h2>
<p style="line-height: 1.6;">
For detailed system status and administration, please visit the <a href="/asteroid/admin" style="color: #00ff00;" data-show-if-admin>Admin Dashboard</a>.
</p>
</section>
</main>
</div>
</body>
</html>

View File

@ -16,7 +16,7 @@
(dm:field user "password-hash") password-hash
(dm:field user "role") (string-downcase (symbol-name role))
(dm:field user "active") (if active 1 0)
(dm:field user "created-date") (local-time:timestamp-to-unix (local-time:now))
;; Let database defaults handle created-date (CURRENT_TIMESTAMP)
(dm:field user "last-login") nil)
(handler-case
(db:with-transaction ()
@ -69,10 +69,13 @@
(format t "Error during user data access: ~a~%" e)))
(when (and (= 1 user-active)
(verify-password password user-password))
;; Update last login
(setf (dm:field user "last-login") (local-time:timestamp-to-unix (local-time:now)))
;; (dm:save user)
(data-model-save user)
;; Update last login using data-model (database agnostic)
(handler-case
(progn
(setf (dm:field user "last-login") (format nil "~a" (local-time:now)))
(dm:save user))
(error (e)
(format t "Warning: Could not update last-login: ~a~%" e)))
user)))))
(defun hash-password (password)