Compare commits
14 Commits
8fd0b06b69
...
f73d0ef007
| Author | SHA1 | Date |
|---|---|---|
|
|
f73d0ef007 | |
|
|
6fac97b6e1 | |
|
|
68a83390c9 | |
|
|
c1d71800ab | |
|
|
b6c1baa473 | |
|
|
135a6a8dee | |
|
|
46d57e2775 | |
|
|
22b2a2d87e | |
|
|
74a9448e9a | |
|
|
c89e31b998 | |
|
|
4be3b83da1 | |
|
|
63c32c25f3 | |
|
|
51b40fe8df | |
|
|
8c19e0fbde |
|
|
@ -57,3 +57,4 @@ performance-logs/
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
/static/asteroid.css
|
/static/asteroid.css
|
||||||
|
stream-queue.m3u
|
||||||
|
|
|
||||||
14
TODO.org
14
TODO.org
|
|
@ -25,18 +25,18 @@
|
||||||
1) [X] Liquidsoap is exposing its management console via telnet on the exterior network interface of b612.asteroid.radio
|
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
|
2) [X] icecast is also binding the external interface on b612, which it
|
||||||
should not be. HAproxy is there to mediate this flow.
|
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,
|
4) [X] The templates still advertise the default administrator password,
|
||||||
which is no bueno.
|
which is no bueno.
|
||||||
5) [ ] We need to work out the TLS situation with letsencrypt, and
|
5) [ ] We need to work out the TLS situation with letsencrypt, and
|
||||||
integrate it into HAproxy.
|
integrate it into HAproxy.
|
||||||
|
|
||||||
6) [ ] The administrative interface should be beefed up.
|
6) [ ] The administrative interface should be beefed up.
|
||||||
6.1) [ ] Deactivate users
|
6.1) [X] Deactivate users
|
||||||
6.2) [ ] Change user access permissions
|
6.2) [X] Change user access permissions
|
||||||
6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c
|
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.
|
8) [ ] User profile pages should probably be fleshed out.
|
||||||
9) [ ] the stream management features aren't there for Admins or DJs.
|
9) [ ] the stream management features aren't there for Admins or DJs.
|
||||||
10) [ ] The "Scan Library" feature is not working in the main branch
|
10) [ ] The "Scan Library" feature is not working in the main branch
|
||||||
|
|
@ -48,11 +48,11 @@
|
||||||
- [ ] strip hard coded configurations out of the system
|
- [ ] strip hard coded configurations out of the system
|
||||||
- [ ] add configuration template file to the project
|
- [ ] add configuration template file to the project
|
||||||
|
|
||||||
** [ ] Database [0/1]
|
** [X] Database [0/1]
|
||||||
- [-] PostgresQL [1/3]
|
- [-] PostgresQL [1/3]
|
||||||
- [X] Add a postgresql docker image to our docker-compose file.
|
- [X] Add a postgresql docker image to our docker-compose file.
|
||||||
- [ ] Configure radiance for postres.
|
- [X] Configure radiance for postres.
|
||||||
- [ ] Migrate all schema to new database.
|
- [X] Migrate all schema to new database.
|
||||||
|
|
||||||
|
|
||||||
** [X] Page Flow [2/2] ✅ COMPLETE
|
** [X] Page Flow [2/2] ✅ COMPLETE
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
(:file "user-management")
|
(:file "user-management")
|
||||||
(:file "playlist-management")
|
(:file "playlist-management")
|
||||||
(:file "stream-control")
|
(:file "stream-control")
|
||||||
|
(:file "listener-stats")
|
||||||
(:file "auth-routes")
|
(:file "auth-routes")
|
||||||
(:file "frontend-partials")
|
(:file "frontend-partials")
|
||||||
(:file "asteroid")))
|
(:file "asteroid")))
|
||||||
|
|
|
||||||
408
asteroid.lisp
408
asteroid.lisp
|
|
@ -86,26 +86,6 @@
|
||||||
("message" . "Library scan completed")
|
("message" . "Library scan completed")
|
||||||
("tracks-added" . ,tracks-added))))))
|
("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 () ()
|
(define-api asteroid/admin/tracks () ()
|
||||||
"API endpoint to view all tracks in database"
|
"API endpoint to view all tracks in database"
|
||||||
(require-authentication)
|
(require-authentication)
|
||||||
|
|
@ -133,17 +113,10 @@
|
||||||
(playlists (get-user-playlists user-id)))
|
(playlists (get-user-playlists user-id)))
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("playlists" . ,(mapcar (lambda (playlist)
|
("playlists" . ,(mapcar (lambda (playlist)
|
||||||
(let* ((track-ids (dm:field playlist "track-ids"))
|
(let ((track-count (get-playlist-track-count (dm:id playlist))))
|
||||||
;; 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)))
|
|
||||||
`(("id" . ,(dm:id playlist))
|
`(("id" . ,(dm:id playlist))
|
||||||
("name" . ,(dm:field playlist "name"))
|
("name" . ,(dm:field playlist "name"))
|
||||||
("description" . ,(dm:field playlist "description"))
|
("description" . ,(or (dm:field playlist "description") ""))
|
||||||
("track-count" . ,track-count)
|
("track-count" . ,track-count)
|
||||||
("created-date" . ,(dm:field playlist "created-date")))))
|
("created-date" . ,(dm:field playlist "created-date")))))
|
||||||
playlists)))))))
|
playlists)))))))
|
||||||
|
|
@ -177,7 +150,7 @@
|
||||||
(let* ((id (parse-integer playlist-id :junk-allowed t))
|
(let* ((id (parse-integer playlist-id :junk-allowed t))
|
||||||
(playlist (get-playlist-by-id id)))
|
(playlist (get-playlist-by-id id)))
|
||||||
(if playlist
|
(if playlist
|
||||||
(let* ((track-ids (dm:field playlist "tracks"))
|
(let* ((track-ids (get-playlist-tracks id))
|
||||||
(tracks (mapcar (lambda (track-id)
|
(tracks (mapcar (lambda (track-id)
|
||||||
(dm:get-one "tracks" (db:query (:= '_id track-id))))
|
(dm:get-one "tracks" (db:query (:= '_id track-id))))
|
||||||
track-ids))
|
track-ids))
|
||||||
|
|
@ -185,6 +158,8 @@
|
||||||
(api-output `(("status" . "success")
|
(api-output `(("status" . "success")
|
||||||
("playlist" . (("id" . ,id)
|
("playlist" . (("id" . ,id)
|
||||||
("name" . ,(dm:field playlist "name"))
|
("name" . ,(dm:field playlist "name"))
|
||||||
|
("description" . ,(or (dm:field playlist "description") ""))
|
||||||
|
("track-count" . ,(length valid-tracks))
|
||||||
("tracks" . ,(mapcar (lambda (track)
|
("tracks" . ,(mapcar (lambda (track)
|
||||||
`(("id" . ,(dm:id track))
|
`(("id" . ,(dm:id track))
|
||||||
("title" . ,(dm:field track "title"))
|
("title" . ,(dm:field track "title"))
|
||||||
|
|
@ -195,6 +170,31 @@
|
||||||
("message" . "Playlist not found"))
|
("message" . "Playlist not found"))
|
||||||
:status 404)))))
|
: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
|
;; Recently played tracks API endpoint
|
||||||
(define-api asteroid/recently-played () ()
|
(define-api asteroid/recently-played () ()
|
||||||
"Get the last 3 played tracks with AllMusic links"
|
"Get the last 3 played tracks with AllMusic links"
|
||||||
|
|
@ -303,6 +303,246 @@
|
||||||
("message" . "Queue loaded from M3U file")
|
("message" . "Queue loaded from M3U file")
|
||||||
("count" . ,count))))))
|
("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)
|
(defun get-track-by-id (track-id)
|
||||||
"Get a track by its ID - handles type mismatches"
|
"Get a track by its ID - handles type mismatches"
|
||||||
(dm:get-one "tracks" (db:query (:= '_id track-id))))
|
(dm:get-one "tracks" (db:query (:= '_id track-id))))
|
||||||
|
|
@ -318,28 +558,26 @@
|
||||||
|
|
||||||
(define-page stream-track #@"/tracks/(.*)/stream" (:uri-groups (track-id))
|
(define-page stream-track #@"/tracks/(.*)/stream" (:uri-groups (track-id))
|
||||||
"Stream audio file by track ID"
|
"Stream audio file by track ID"
|
||||||
(with-error-handling
|
(let* ((id (parse-integer track-id :junk-allowed t))
|
||||||
(let* ((id (parse-integer track-id))
|
(track (when id (get-track-by-id id))))
|
||||||
(track (get-track-by-id id)))
|
(if (not track)
|
||||||
(unless track
|
(progn
|
||||||
(signal-not-found "track" id))
|
(setf (radiance:header "Content-Type") "text/plain")
|
||||||
|
"Track not found")
|
||||||
(let* ((file-path (dm:field track "file-path"))
|
(let* ((file-path (dm:field track "file-path"))
|
||||||
(format (dm:field track "format"))
|
(format (dm:field track "format"))
|
||||||
(file (probe-file file-path)))
|
(file (probe-file file-path)))
|
||||||
(unless file
|
(if (not file)
|
||||||
(error 'not-found-error
|
(progn
|
||||||
:message "Audio file not found on disk"
|
(setf (radiance:header "Content-Type") "text/plain")
|
||||||
:resource-type "file"
|
"Audio file not found")
|
||||||
:resource-id file-path))
|
(progn
|
||||||
;; Set appropriate headers for audio streaming
|
;; Set appropriate headers for audio streaming
|
||||||
(setf (radiance:header "Content-Type") (get-mime-type-for-format format))
|
(setf (radiance:header "Content-Type") (get-mime-type-for-format format))
|
||||||
(setf (radiance:header "Accept-Ranges") "bytes")
|
(setf (radiance:header "Accept-Ranges") "bytes")
|
||||||
(setf (radiance:header "Cache-Control") "public, max-age=3600")
|
(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
|
;; Return file contents
|
||||||
(alexandria:read-file-into-byte-vector file)))))
|
(alexandria:read-file-into-byte-vector file)))))))
|
||||||
|
|
||||||
;; Player state management
|
;; Player state management
|
||||||
(defvar *current-track* nil "Currently playing track")
|
(defvar *current-track* nil "Currently playing track")
|
||||||
|
|
@ -823,11 +1061,16 @@
|
||||||
(define-api asteroid/user/listening-stats () ()
|
(define-api asteroid/user/listening-stats () ()
|
||||||
"Get user listening statistics"
|
"Get user listening statistics"
|
||||||
(require-authentication)
|
(require-authentication)
|
||||||
|
(let* ((current-user (get-current-user))
|
||||||
|
(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")
|
(api-output `(("status" . "success")
|
||||||
("stats" . (("total_listen_time" . 0)
|
("stats" . (("total_listen_time" . ,(getf stats :total-listen-time 0))
|
||||||
("tracks_played" . 0)
|
("tracks_played" . ,(getf stats :tracks-played 0))
|
||||||
("session_count" . 0)
|
("session_count" . ,(getf stats :session-count 0))
|
||||||
("favorite_genre" . "Unknown"))))))
|
("favorite_genre" . "Unknown")))))))
|
||||||
|
|
||||||
(define-api asteroid/user/recent-tracks (&optional (limit "3")) ()
|
(define-api asteroid/user/recent-tracks (&optional (limit "3")) ()
|
||||||
"Get recently played tracks for user"
|
"Get recently played tracks for user"
|
||||||
|
|
@ -930,6 +1173,27 @@
|
||||||
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
|
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
|
||||||
:default-stream-encoding "audio/aac"))
|
: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 () ()
|
(define-api asteroid/status () ()
|
||||||
"Get server status"
|
"Get server status"
|
||||||
(api-output `(("status" . "running")
|
(api-output `(("status" . "running")
|
||||||
|
|
@ -979,6 +1243,39 @@
|
||||||
`(("error" . "Could not connect to Icecast server"))
|
`(("error" . "Could not connect to Icecast server"))
|
||||||
:status 503)))))
|
: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
|
;; RADIANCE server management functions
|
||||||
|
|
||||||
|
|
@ -991,11 +1288,24 @@
|
||||||
;; (unless (radiance:environment)
|
;; (unless (radiance:environment)
|
||||||
;; (setf (radiance:environment) "asteroid"))
|
;; (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 ()
|
(defun stop-server ()
|
||||||
"Stop the Asteroid Radio RADIANCE server"
|
"Stop the Asteroid Radio RADIANCE server"
|
||||||
(format t "Stopping Asteroid Radio 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)
|
(radiance:shutdown)
|
||||||
(format t "Server stopped.~%"))
|
(format t "Server stopped.~%"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
(in-package :asteroid)
|
(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
|
;; Database initialization - must be in db:connected trigger because
|
||||||
;; the system could load before the database is ready.
|
;; the system could load before the database is ready.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ${MUSIC_LIBRARY:-../music/library}:/app/music:ro
|
- ${MUSIC_LIBRARY:-../music/library}:/app/music:ro
|
||||||
- ./asteroid-radio-docker.liq:/app/asteroid-radio.liq: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
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- asteroid-network
|
- asteroid-network
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,8 @@ CREATE TABLE IF NOT EXISTS "USERS" (
|
||||||
"password-hash" TEXT NOT NULL,
|
"password-hash" TEXT NOT NULL,
|
||||||
role VARCHAR(50) DEFAULT 'listener',
|
role VARCHAR(50) DEFAULT 'listener',
|
||||||
active integer DEFAULT 1,
|
active integer DEFAULT 1,
|
||||||
-- "created-date" integer DEFAULT CURRENT_TIMESTAMP,
|
"created-date" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
"created-date" integer,
|
"last-login" TIMESTAMP,
|
||||||
"last-login" integer,
|
|
||||||
CONSTRAINT valid_role CHECK (role IN ('listener', 'dj', 'admin'))
|
CONSTRAINT valid_role CHECK (role IN ('listener', 'dj', 'admin'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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 $$;
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -23,15 +23,12 @@
|
||||||
"DOMContentLoaded"
|
"DOMContentLoaded"
|
||||||
(lambda ()
|
(lambda ()
|
||||||
(load-tracks)
|
(load-tracks)
|
||||||
(update-player-status)
|
|
||||||
(setup-event-listeners)
|
(setup-event-listeners)
|
||||||
(load-stream-queue)
|
(load-playlist-list)
|
||||||
(setup-live-stream-monitor)
|
(load-current-queue)
|
||||||
(update-live-stream-info)
|
(refresh-liquidsoap-status)
|
||||||
;; Update live stream info every 10 seconds
|
;; Update Liquidsoap status every 10 seconds
|
||||||
(set-interval update-live-stream-info 10000)
|
(set-interval refresh-liquidsoap-status 10000))))
|
||||||
;; Update player status every 5 seconds
|
|
||||||
(set-interval update-player-status 5000))))
|
|
||||||
|
|
||||||
;; Setup all event listeners
|
;; Setup all event listeners
|
||||||
(defun setup-event-listeners ()
|
(defun setup-event-listeners ()
|
||||||
|
|
@ -74,21 +71,52 @@
|
||||||
|
|
||||||
;; Queue controls
|
;; Queue controls
|
||||||
(let ((refresh-queue-btn (ps:chain document (get-element-by-id "refresh-queue")))
|
(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")))
|
(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")))
|
(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
|
(when refresh-queue-btn
|
||||||
(ps:chain refresh-queue-btn (add-event-listener "click" load-stream-queue)))
|
(ps:chain refresh-queue-btn (add-event-listener "click" load-current-queue)))
|
||||||
(when load-m3u-btn
|
|
||||||
(ps:chain load-m3u-btn (add-event-listener "click" load-queue-from-m3u)))
|
|
||||||
(when clear-queue-btn
|
(when clear-queue-btn
|
||||||
(ps:chain clear-queue-btn (add-event-listener "click" clear-stream-queue)))
|
(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
|
(when add-random-btn
|
||||||
(ps:chain add-random-btn (add-event-listener "click" add-random-tracks)))
|
(ps:chain add-random-btn (add-event-listener "click" add-random-tracks)))
|
||||||
(when queue-search-input
|
(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
|
;; Load tracks from API
|
||||||
(defun load-tracks ()
|
(defun load-tracks ()
|
||||||
|
|
@ -359,37 +387,6 @@
|
||||||
(defun open-incoming-folder ()
|
(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."))
|
(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
|
;; Stream Queue Management
|
||||||
;; ========================================
|
;; ========================================
|
||||||
|
|
@ -441,44 +438,6 @@
|
||||||
(setf html (+ html "</div>"))
|
(setf html (+ html "</div>"))
|
||||||
(setf (ps:@ container inner-h-t-m-l) html))))))
|
(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
|
;; Move track up in queue
|
||||||
(defun move-track-up (index)
|
(defun move-track-up (index)
|
||||||
(when (= index 0) (return))
|
(when (= index 0) (return))
|
||||||
|
|
@ -645,6 +604,256 @@
|
||||||
(setf html (+ html "</div>"))
|
(setf html (+ html "</div>"))
|
||||||
(setf (ps:@ container inner-h-t-m-l) html))))))
|
(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
|
;; Make functions globally accessible for onclick handlers
|
||||||
(setf (ps:@ window go-to-page) go-to-page)
|
(setf (ps:@ window go-to-page) go-to-page)
|
||||||
(setf (ps:@ window previous-page) previous-page)
|
(setf (ps:@ window previous-page) previous-page)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@
|
||||||
(ps:ps*
|
(ps:ps*
|
||||||
'(progn
|
'(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
|
;; Stream quality configuration
|
||||||
(defun get-stream-config (stream-base-url encoding)
|
(defun get-stream-config (stream-base-url encoding)
|
||||||
(let ((config (ps:create
|
(let ((config (ps:create
|
||||||
|
|
@ -137,6 +143,249 @@
|
||||||
(ps:chain local-storage (remove-item "useFrameset"))
|
(ps:chain local-storage (remove-item "useFrameset"))
|
||||||
(setf (ps:@ window location href) "/asteroid/"))
|
(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 ()
|
(defun redirect-when-frame ()
|
||||||
(let* ((path (ps:@ window location pathname))
|
(let* ((path (ps:@ window location pathname))
|
||||||
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self))))
|
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self))))
|
||||||
|
|
@ -164,80 +413,10 @@
|
||||||
;; Update now playing
|
;; Update now playing
|
||||||
(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"))))
|
(let ((audio-element (ps:chain document (get-element-by-id "live-audio"))))
|
||||||
(when audio-element
|
(when audio-element
|
||||||
(ps:chain audio-element
|
(attach-audio-event-listeners 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))))))))
|
|
||||||
|
|
||||||
;; Check frameset preference
|
;; Check frameset preference
|
||||||
(let ((path (ps:@ window location pathname))
|
(let ((path (ps:@ window location pathname))
|
||||||
|
|
@ -249,8 +428,8 @@
|
||||||
|
|
||||||
(redirect-when-frame)))))
|
(redirect-when-frame)))))
|
||||||
|
|
||||||
;; Update now playing every 10 seconds
|
;; Update now playing every 5 seconds
|
||||||
(set-interval update-now-playing 10000)
|
(set-interval update-now-playing 5000)
|
||||||
|
|
||||||
;; Listen for messages from popout window
|
;; Listen for messages from popout window
|
||||||
(ps:chain window
|
(ps:chain window
|
||||||
|
|
|
||||||
|
|
@ -93,15 +93,15 @@
|
||||||
|
|
||||||
;; Restore user quality preference
|
;; Restore user quality preference
|
||||||
(let ((selector (ps:chain document (get-element-by-id "live-stream-quality")))
|
(let ((selector (ps:chain document (get-element-by-id "live-stream-quality")))
|
||||||
(stream-quality (ps:chain (ps:@ local-storage (get-item "stream-quality")) "aac")))
|
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac")))
|
||||||
(when (and selector (not (== (ps:@ selector value) stream-quality)))
|
(when (and selector (not (= (ps:@ selector value) stream-quality)))
|
||||||
(setf (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
|
;; Frame redirection logic
|
||||||
(defun redirect-when-frame ()
|
(defun redirect-when-frame ()
|
||||||
(let ((path (ps:@ window location pathname))
|
(let* ((path (ps:@ window location pathname))
|
||||||
(is-frameset-page (not (== (ps:@ window parent) (ps:@ window self))))
|
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self))))
|
||||||
(is-content-frame (ps:chain path (includes "player-content"))))
|
(is-content-frame (ps:chain path (includes "player-content"))))
|
||||||
|
|
||||||
(when (and is-frameset-page (not is-content-frame))
|
(when (and is-frameset-page (not is-content-frame))
|
||||||
|
|
@ -133,13 +133,12 @@
|
||||||
(add-event-listener "input" update-volume))
|
(add-event-listener "input" update-volume))
|
||||||
|
|
||||||
;; Audio player events
|
;; Audio player events
|
||||||
(when *audio-player*
|
(when (and *audio-player* (ps:chain *audio-player* add-event-listener))
|
||||||
(ps:chain *audio-player*
|
(ps:chain *audio-player* (add-event-listener "loadedmetadata" update-time-display))
|
||||||
(add-event-listener "loadedmetadata" update-time-display)
|
(ps:chain *audio-player* (add-event-listener "timeupdate" update-time-display))
|
||||||
(add-event-listener "timeupdate" update-time-display)
|
(ps:chain *audio-player* (add-event-listener "ended" handle-track-end))
|
||||||
(add-event-listener "ended" handle-track-end)
|
(ps:chain *audio-player* (add-event-listener "play" (lambda () (update-play-button "⏸️ Pause"))))
|
||||||
(add-event-listener "play" (lambda () (update-play-button "⏸️ Pause")))
|
(ps:chain *audio-player* (add-event-listener "pause" (lambda () (update-play-button "▶️ Play")))))
|
||||||
(add-event-listener "pause" (lambda () (update-play-button "▶️ Play")))))
|
|
||||||
|
|
||||||
;; Playlist controls
|
;; Playlist controls
|
||||||
(ps:chain (ps:chain document (get-element-by-id "create-playlist"))
|
(ps:chain (ps:chain document (get-element-by-id "create-playlist"))
|
||||||
|
|
@ -162,17 +161,17 @@
|
||||||
(then (lambda (result)
|
(then (lambda (result)
|
||||||
;; Handle RADIANCE API wrapper format
|
;; Handle RADIANCE API wrapper format
|
||||||
(let ((data (or (ps:@ result data) result)))
|
(let ((data (or (ps:@ result data) result)))
|
||||||
(if (== (ps:@ data status) "success")
|
(if (= (ps:@ data status) "success")
|
||||||
(progn
|
(progn
|
||||||
(setf *tracks* (or (ps:@ data tracks) (array)))
|
(setf *tracks* (or (ps:@ data tracks) (array)))
|
||||||
(display-tracks *tracks*))
|
(display-tracks *tracks*))
|
||||||
(progn
|
(progn
|
||||||
(ps:chain console (error "Error loading tracks:" (ps:@ data error)))
|
(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>"))))))
|
"<div class=\"error\">Error loading tracks</div>"))))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error loading tracks:" 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>")))))
|
"<div class=\"error\">Error loading tracks</div>")))))
|
||||||
|
|
||||||
;; Display tracks in library
|
;; Display tracks in library
|
||||||
|
|
@ -186,15 +185,15 @@
|
||||||
(let ((container (ps:chain document (get-element-by-id "track-list")))
|
(let ((container (ps:chain document (get-element-by-id "track-list")))
|
||||||
(pagination-controls (ps:chain document (get-element-by-id "library-pagination-controls"))))
|
(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
|
(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")
|
(setf (ps:@ pagination-controls style display) "none")
|
||||||
(return)))
|
(return)))
|
||||||
|
|
||||||
;; Calculate pagination
|
;; Calculate pagination
|
||||||
(let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *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* *library-tracks-per-page*))
|
(start-index (* (- *library-current-page* 1) *library-tracks-per-page*))
|
||||||
(end-index (+ start-index *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))))
|
(tracks-to-show (ps:chain *filtered-library-tracks* (slice start-index end-index))))
|
||||||
|
|
||||||
|
|
@ -203,20 +202,21 @@
|
||||||
(map (lambda (track page-index)
|
(map (lambda (track page-index)
|
||||||
;; Find the actual index in the full tracks array
|
;; Find the actual index in the full tracks array
|
||||||
(let ((actual-index (ps:chain *tracks*
|
(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-item\" data-track-id=\"" (ps:@ track id) "\" data-index=\"" actual-index "\">"
|
||||||
"<div class=\"track-info\">"
|
"<div class=\"track-info\">"
|
||||||
"<div class=\"track-title\">" (or (ps:@ track title 0) "Unknown Title") "</div>"
|
"<div class=\"track-title\">" (or (ps:@ track title) "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-meta\">" (or (ps:@ track artist) "Unknown Artist") " • " (or (ps:@ track album) "Unknown Album") "</div>"
|
||||||
"</div>"
|
"</div>"
|
||||||
"<div class=\"track-actions\">"
|
"<div class=\"track-actions\">"
|
||||||
"<button onclick=\"playTrack(" actual-index ")\" class=\"btn btn-sm btn-success\">▶️</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\">➕</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>"
|
||||||
"</div>"))))
|
"</div>"))))
|
||||||
(join ""))))
|
(join ""))))
|
||||||
|
|
||||||
(setf (ps:@ container inner-html) tracks-html)
|
(setf (ps:@ container inner-h-t-m-l) tracks-html)
|
||||||
|
|
||||||
;; Update pagination controls
|
;; Update pagination controls
|
||||||
(setf (ps:chain (ps:chain document (get-element-by-id "library-page-info")) text-content)
|
(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 ()
|
(defun change-library-tracks-per-page ()
|
||||||
(setf *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)
|
(setf *library-current-page* 1)
|
||||||
(render-library-page))
|
(render-library-page))
|
||||||
|
|
||||||
|
|
@ -258,9 +258,9 @@
|
||||||
(let ((query (ps:chain (ps:chain document (get-element-by-id "search-tracks")) value (to-lower-case))))
|
(let ((query (ps:chain (ps:chain document (get-element-by-id "search-tracks")) value (to-lower-case))))
|
||||||
(let ((filtered (ps:chain *tracks*
|
(let ((filtered (ps:chain *tracks*
|
||||||
(filter (lambda (track)
|
(filter (lambda (track)
|
||||||
(or (ps:chain (or (ps:@ track title 0) "") (to-lower-case) (includes query))
|
(or (ps:chain (or (ps:@ track title) "") (to-lower-case) (includes query))
|
||||||
(ps:chain (or (ps:@ track artist 0) "") (to-lower-case) (includes query))
|
(ps:chain (or (ps:@ track artist) "") (to-lower-case) (includes query))
|
||||||
(ps:chain (or (ps:@ track album 0) "") (to-lower-case) (includes query))))))))
|
(ps:chain (or (ps:@ track album) "") (to-lower-case) (includes query))))))))
|
||||||
(display-tracks filtered))))
|
(display-tracks filtered))))
|
||||||
|
|
||||||
;; Play a specific track by index
|
;; Play a specific track by index
|
||||||
|
|
@ -312,11 +312,11 @@
|
||||||
;; Play from queue
|
;; Play from queue
|
||||||
(let ((next-track (ps:chain *play-queue* (shift))))
|
(let ((next-track (ps:chain *play-queue* (shift))))
|
||||||
(play-track (ps:chain *tracks*
|
(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))
|
(update-queue-display))
|
||||||
;; Play next track in library
|
;; Play next track in library
|
||||||
(let ((next-index (if *is-shuffled*
|
(let ((next-index (if *is-shuffled*
|
||||||
(floor (* (random) (ps:@ *tracks* length))))
|
(floor (* (random) (ps:@ *tracks* length)))
|
||||||
(mod (+ *current-track-index* 1) (ps:@ *tracks* length)))))
|
(mod (+ *current-track-index* 1) (ps:@ *tracks* length)))))
|
||||||
(play-track next-index))))
|
(play-track next-index))))
|
||||||
|
|
||||||
|
|
@ -344,7 +344,7 @@
|
||||||
|
|
||||||
;; Update volume
|
;; Update volume
|
||||||
(defun 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*
|
(when *audio-player*
|
||||||
(setf (ps:@ *audio-player* volume) volume))))
|
(setf (ps:@ *audio-player* volume) volume))))
|
||||||
|
|
||||||
|
|
@ -386,8 +386,8 @@
|
||||||
;; Update queue display
|
;; Update queue display
|
||||||
(defun update-queue-display ()
|
(defun update-queue-display ()
|
||||||
(let ((container (ps:chain document (get-element-by-id "play-queue"))))
|
(let ((container (ps:chain document (get-element-by-id "play-queue"))))
|
||||||
(if (== (ps:@ *play-queue* length) 0)
|
(if (= (ps:@ *play-queue* length) 0)
|
||||||
(setf (ps:@ container inner-html) "<div class=\"empty-queue\">Queue is empty</div>")
|
(setf (ps:@ container inner-h-t-m-l) "<div class=\"empty-queue\">Queue is empty</div>")
|
||||||
(let ((queue-html (ps:chain *play-queue*
|
(let ((queue-html (ps:chain *play-queue*
|
||||||
(map (lambda (track index)
|
(map (lambda (track index)
|
||||||
(+ "<div class=\"queue-item\">"
|
(+ "<div class=\"queue-item\">"
|
||||||
|
|
@ -398,7 +398,7 @@
|
||||||
"<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>"
|
"<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>"
|
||||||
"</div>")))
|
"</div>")))
|
||||||
(join ""))))
|
(join ""))))
|
||||||
(setf (ps:@ container inner-html) queue-html))))
|
(setf (ps:@ container inner-h-t-m-l) queue-html)))))
|
||||||
|
|
||||||
;; Remove track from queue
|
;; Remove track from queue
|
||||||
(defun remove-from-queue (index)
|
(defun remove-from-queue (index)
|
||||||
|
|
@ -410,28 +410,113 @@
|
||||||
(setf *play-queue* (array))
|
(setf *play-queue* (array))
|
||||||
(update-queue-display))
|
(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
|
;; Create playlist
|
||||||
(defun create-playlist ()
|
(defun create-playlist ()
|
||||||
(let ((name (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value (trim))))
|
(let ((name (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value (trim))))
|
||||||
(when (not (== name ""))
|
(when (not (= name ""))
|
||||||
(let ((form-data (new "FormData")))
|
(let ((form-data (ps:new (-Form-data))))
|
||||||
(ps:chain form-data (append "name" name))
|
(ps:chain form-data (append "name" name))
|
||||||
(ps:chain form-data (append "description" ""))
|
(ps:chain form-data (append "description" ""))
|
||||||
|
|
||||||
(ps:chain (fetch "/api/asteroid/playlists/create"
|
(ps:chain (fetch "/api/asteroid/playlists/create"
|
||||||
(ps:create :method "POST" :body form-data))
|
(ps:create :method "POST" :body form-data))
|
||||||
(then (lambda (response) (ps:chain response (json))))
|
(then (lambda (response)
|
||||||
|
(ps:chain response (json))))
|
||||||
(then (lambda (result)
|
(then (lambda (result)
|
||||||
;; Handle RADIANCE API wrapper format
|
;; Handle RADIANCE API wrapper format
|
||||||
(let ((data (or (ps:@ result data) result)))
|
(let ((data (or (ps:@ result data) result)))
|
||||||
(if (== (ps:@ data status) "success")
|
(if (= (ps:@ data status) "success")
|
||||||
(progn
|
(progn
|
||||||
(alert (+ "Playlist \"" name "\" created successfully!"))
|
(alert (+ "Playlist \"" name "\" created successfully!"))
|
||||||
(setf (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value) "")
|
(setf (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value) "")
|
||||||
|
|
||||||
;; Wait a moment then reload playlists
|
;; Wait a moment then reload playlists
|
||||||
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
|
(set-timeout load-playlists 500))
|
||||||
(then (lambda () (load-playlists)))))
|
|
||||||
(alert (+ "Error creating playlist: " (ps:@ data message)))))))
|
(alert (+ "Error creating playlist: " (ps:@ data message)))))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error creating playlist:" error))
|
(ps:chain console (error "Error creating playlist:" error))
|
||||||
|
|
@ -443,7 +528,7 @@
|
||||||
(let ((name (prompt "Enter playlist name:")))
|
(let ((name (prompt "Enter playlist name:")))
|
||||||
(when name
|
(when name
|
||||||
;; Create the playlist
|
;; 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 "name" name))
|
||||||
(ps:chain form-data (append "description" (+ "Created from queue with " (ps:@ *play-queue* length) " tracks")))
|
(ps:chain form-data (append "description" (+ "Created from queue with " (ps:@ *play-queue* length) " tracks")))
|
||||||
|
|
||||||
|
|
@ -453,23 +538,23 @@
|
||||||
(then (lambda (create-result)
|
(then (lambda (create-result)
|
||||||
;; Handle RADIANCE API wrapper format
|
;; Handle RADIANCE API wrapper format
|
||||||
(let ((create-data (or (ps:@ create-result data) create-result)))
|
(let ((create-data (or (ps:@ create-result data) create-result)))
|
||||||
(if (== (ps:@ create-data status) "success")
|
(if (= (ps:@ create-data status) "success")
|
||||||
(progn
|
(progn
|
||||||
;; Wait a moment for database to update
|
;; Wait a moment for database to update, then fetch playlists
|
||||||
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
|
(set-timeout
|
||||||
(then (lambda ()
|
(lambda ()
|
||||||
;; Get the new playlist ID by fetching playlists
|
;; Get the new playlist ID by fetching playlists
|
||||||
(ps:chain (fetch "/api/asteroid/playlists")
|
(ps:chain (fetch "/api/asteroid/playlists")
|
||||||
(then (lambda (response) (ps:chain response (json))))
|
(then (lambda (response) (ps:chain response (json))))
|
||||||
(then (lambda (playlists-result)
|
(then (lambda (playlists-result)
|
||||||
;; Handle RADIANCE API wrapper format
|
;; Handle RADIANCE API wrapper format
|
||||||
(let ((playlist-result-data (or (ps:@ playlists-result data) playlists-result)))
|
(let ((playlist-result-data (or (ps:@ playlists-result data) playlists-result)))
|
||||||
(if (and (== (ps:@ playlist-result-data status) "success")
|
(if (and (= (ps:@ playlist-result-data status) "success")
|
||||||
(> (ps:@ playlist-result-data playlists length) 0))
|
(> (ps:@ playlist-result-data playlists length) 0))
|
||||||
(progn
|
(progn
|
||||||
;; Find the playlist with matching name (most recent)
|
;; Find the playlist with matching name (most recent)
|
||||||
(let ((new-playlist (or (ps:chain (ps:@ playlist-result-data playlists)
|
(let ((new-playlist (or (ps:chain (ps:@ playlist-result-data playlists)
|
||||||
(find (lambda (p) (== (ps:@ p name) name))))
|
(find (lambda (p) (= (ps:@ p name) name))))
|
||||||
(aref (ps:@ playlist-result-data playlists)
|
(aref (ps:@ playlist-result-data playlists)
|
||||||
(- (ps:@ playlist-result-data playlists length) 1)))))
|
(- (ps:@ playlist-result-data playlists length) 1)))))
|
||||||
|
|
||||||
|
|
@ -479,7 +564,7 @@
|
||||||
(for-each (lambda (track)
|
(for-each (lambda (track)
|
||||||
(let ((track-id (ps:@ track id)))
|
(let ((track-id (ps:@ track id)))
|
||||||
(when track-id
|
(when track-id
|
||||||
(let ((add-form-data (new "FormData")))
|
(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 "playlist-id" (ps:@ new-playlist id)))
|
||||||
(ps:chain add-form-data (append "track-id" track-id))
|
(ps:chain add-form-data (append "track-id" track-id))
|
||||||
|
|
||||||
|
|
@ -487,7 +572,7 @@
|
||||||
(ps:create :method "POST" :body add-form-data))
|
(ps:create :method "POST" :body add-form-data))
|
||||||
(then (lambda (response) (ps:chain response (json))))
|
(then (lambda (response) (ps:chain response (json))))
|
||||||
(then (lambda (add-result)
|
(then (lambda (add-result)
|
||||||
(when (== (ps:@ add-result data status) "success")
|
(when (= (ps:@ add-result data status) "success")
|
||||||
(setf added-count (+ added-count 1)))))
|
(setf added-count (+ added-count 1)))))
|
||||||
(catch (lambda (err)
|
(catch (lambda (err)
|
||||||
(ps:chain console (log "Error adding track:" err)))))))))))
|
(ps:chain console (log "Error adding track:" err)))))))))))
|
||||||
|
|
@ -500,7 +585,8 @@
|
||||||
(load-playlists))))))
|
(load-playlists))))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error fetching playlists:" error))
|
(ps:chain console (error "Error fetching playlists:" error))
|
||||||
(alert "Playlist created but could not add tracks"))))))))
|
(alert "Playlist created but could not add tracks")))))
|
||||||
|
500))
|
||||||
(alert (+ "Error creating playlist: " (ps:@ create-data message)))))))
|
(alert (+ "Error creating playlist: " (ps:@ create-data message)))))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error saving queue as playlist:" error))
|
(ps:chain console (error "Error saving queue as playlist:" error))
|
||||||
|
|
@ -510,16 +596,21 @@
|
||||||
;; Load playlists from API
|
;; Load playlists from API
|
||||||
(defun load-playlists ()
|
(defun load-playlists ()
|
||||||
(ps:chain
|
(ps:chain
|
||||||
(ps:chain (fetch "/api/asteroid/playlists"))
|
(fetch "/api/asteroid/playlists")
|
||||||
(then (lambda (response) (ps:chain response (json))))
|
(then (lambda (response) (ps:chain response (json))))
|
||||||
(then (lambda (result)
|
(then (lambda (result)
|
||||||
|
(ps:chain console (log "Playlists API result:" result))
|
||||||
(let ((playlists (cond
|
(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)))
|
(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)))
|
(or (ps:@ result playlists) (array)))
|
||||||
(t
|
(t
|
||||||
|
(ps:chain console (log "No playlists found in response"))
|
||||||
(array)))))
|
(array)))))
|
||||||
|
(ps:chain console (log "Playlists to display:" playlists))
|
||||||
(display-playlists playlists))))
|
(display-playlists playlists))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
(ps:chain console (error "Error loading playlists:" error))
|
(ps:chain console (error "Error loading playlists:" error))
|
||||||
|
|
@ -529,22 +620,69 @@
|
||||||
(defun display-playlists (playlists)
|
(defun display-playlists (playlists)
|
||||||
(let ((container (ps:chain document (get-element-by-id "playlists-container"))))
|
(let ((container (ps:chain document (get-element-by-id "playlists-container"))))
|
||||||
|
|
||||||
(if (or (not playlists) (== (ps:@ playlists length) 0))
|
(if (or (not playlists) (= (ps:@ playlists length) 0))
|
||||||
(setf (ps:@ container inner-html) "<div class=\"no-playlists\">No playlists created yet.</div>")
|
(setf (ps:@ container inner-h-t-m-l) "<div class=\"no-playlists\">No playlists created yet.</div>")
|
||||||
(let ((playlists-html (ps:chain playlists
|
(let ((playlists-html (ps:chain playlists
|
||||||
(map (lambda (playlist)
|
(map (lambda (playlist)
|
||||||
(+ "<div class=\"playlist-item\">"
|
(+ "<div class=\"playlist-item\" data-playlist-id=\"" (ps:@ playlist id) "\">"
|
||||||
"<div class=\"playlist-info\">"
|
"<div class=\"playlist-info\">"
|
||||||
"<div class=\"playlist-name\">" (ps:@ playlist name) "</div>"
|
"<div class=\"playlist-name\">" (ps:@ playlist name) "</div>"
|
||||||
"<div class=\"playlist-meta\">" (ps:@ playlist "track-count") " tracks</div>"
|
"<div class=\"playlist-meta\">" (ps:@ playlist "track-count") " tracks</div>"
|
||||||
"</div>"
|
"</div>"
|
||||||
"<div class=\"playlist-actions\">"
|
"<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>"
|
||||||
"</div>"))
|
"</div>")))
|
||||||
(join "")))))
|
(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
|
;; Load playlist into queue
|
||||||
(defun load-playlist (playlist-id)
|
(defun load-playlist (playlist-id)
|
||||||
|
|
@ -554,34 +692,37 @@
|
||||||
(then (lambda (result)
|
(then (lambda (result)
|
||||||
;; Handle RADIANCE API wrapper format
|
;; Handle RADIANCE API wrapper format
|
||||||
(let ((data (or (ps:@ result data) result)))
|
(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)))
|
(let ((playlist (ps:@ data playlist)))
|
||||||
|
|
||||||
;; Clear current queue
|
;; Clear current queue
|
||||||
(setf *play-queue* (array))
|
(setf *play-queue* (array))
|
||||||
|
|
||||||
;; Add all playlist tracks to queue
|
;; Add all playlist tracks to queue
|
||||||
(when (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
|
(if (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
|
||||||
|
(progn
|
||||||
(ps:chain (ps:@ playlist tracks)
|
(ps:chain (ps:@ playlist tracks)
|
||||||
(for-each (lambda (track)
|
(for-each (lambda (track)
|
||||||
;; Find the full track object from our tracks array
|
;; Find the full track object from our tracks array
|
||||||
(let ((full-track (ps:chain *tracks*
|
(let ((full-track (ps:chain *tracks*
|
||||||
(find (lambda (trk) (== (ps:@ trk id) (ps:@ track id)))))))
|
(find (lambda (trk) (= (ps:@ trk id) (ps:@ track id)))))))
|
||||||
(when full-track
|
(when full-track
|
||||||
(setf (aref *play-queue* (ps:@ *play-queue* length)) full-track)))))
|
(setf (aref *play-queue* (ps:@ *play-queue* length)) full-track))))))
|
||||||
|
|
||||||
(update-queue-display)
|
(update-queue-display)
|
||||||
(alert (+ "Loaded " (ps:@ *play-queue* length) " tracks from \"" (ps:@ playlist name) "\" into queue!"))
|
(let ((loaded-count (ps:@ *play-queue* length)))
|
||||||
|
(alert (+ "Loaded " loaded-count " tracks from \"" (ps:@ playlist name) "\" into queue!"))
|
||||||
|
|
||||||
;; Optionally start playing the first track
|
;; Optionally start playing the first track
|
||||||
(when (> (ps:@ *play-queue* length) 0)
|
(when (> loaded-count 0)
|
||||||
(let ((first-track (ps:chain *play-queue* (shift)))
|
(let* ((first-track (aref *play-queue* 0))
|
||||||
(track-index (ps:chain *tracks*
|
(track-index (ps:chain *tracks*
|
||||||
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ first-track id))))))
|
(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)
|
(when (>= track-index 0)
|
||||||
(play-track track-index))))))
|
(play-track track-index))))))
|
||||||
(when (or (not (ps:@ playlist tracks)) (== (ps:@ playlist tracks length) 0))
|
|
||||||
(alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
|
(alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
|
||||||
(alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error")))))))
|
(alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error")))))))
|
||||||
(catch (lambda (error)
|
(catch (lambda (error)
|
||||||
|
|
@ -631,23 +772,24 @@
|
||||||
;; Update now playing information
|
;; Update now playing information
|
||||||
(defun update-now-playing ()
|
(defun update-now-playing ()
|
||||||
(ps:chain
|
(ps:chain
|
||||||
(ps:chain (fetch "/api/asteroid/partial/now-playing"))
|
(fetch "/api/asteroid/partial/now-playing")
|
||||||
(then (lambda (response)
|
(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"))
|
(if (ps:chain content-type (includes "text/html"))
|
||||||
(ps:chain response (text))
|
(ps:chain response (text))
|
||||||
(progn
|
(progn
|
||||||
(ps:chain console (log "Error connecting to stream"))
|
(ps:chain console (log "Error connecting to stream"))
|
||||||
"")))))
|
"")))))
|
||||||
(then (lambda (data)
|
(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)
|
(catch (lambda (error)
|
||||||
(ps:chain console (log "Could not fetch stream status:" error))))))
|
(ps:chain console (log "Could not fetch stream status:" error))))))
|
||||||
|
|
||||||
;; Initial update after 1 second
|
;; 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
|
;; 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
|
;; Make functions globally accessible for onclick handlers
|
||||||
(defvar window (ps:@ window))
|
(defvar window (ps:@ window))
|
||||||
|
|
@ -659,7 +801,11 @@
|
||||||
(setf (ps:@ window library-next-page) library-next-page)
|
(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 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 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")
|
"Compiled JavaScript for web player - generated at load time")
|
||||||
|
|
||||||
(defun generate-player-js ()
|
(defun generate-player-js ()
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,16 @@
|
||||||
(when *animation-id*
|
(when *animation-id*
|
||||||
(cancel-animation-frame *animation-id*)
|
(cancel-animation-frame *animation-id*)
|
||||||
(setf *animation-id* nil))
|
(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 *audio-context* nil)
|
||||||
(setf *analyser* nil)
|
(setf *analyser* nil)
|
||||||
(setf *media-source* nil)
|
(setf *media-source* nil)
|
||||||
|
(setf *current-audio-element* nil)
|
||||||
(ps:chain console (log "Spectrum analyzer reset for reconnection")))
|
(ps:chain console (log "Spectrum analyzer reset for reconnection")))
|
||||||
|
|
||||||
(defun init-spectrum-analyzer ()
|
(defun init-spectrum-analyzer ()
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@
|
||||||
(setf (dm:field playlist "user-id") user-id)
|
(setf (dm:field playlist "user-id") user-id)
|
||||||
(setf (dm:field playlist "name") name)
|
(setf (dm:field playlist "name") name)
|
||||||
(setf (dm:field playlist "description") (or description ""))
|
(setf (dm:field playlist "description") (or description ""))
|
||||||
(setf (dm:field playlist "track-ids") "") ; Empty string for text field
|
;; Note: track-ids column removed - using playlist_tracks junction table instead
|
||||||
(setf (dm:field playlist "created-date") (local-time:timestamp-to-unix (local-time:now)))
|
;; 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 "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)
|
(dm:insert playlist)
|
||||||
t))
|
t))
|
||||||
|
|
||||||
|
|
@ -44,53 +43,72 @@
|
||||||
(dm:get-one "playlists" (db:query (:= '_id playlist-id))))
|
(dm:get-one "playlists" (db:query (:= '_id playlist-id))))
|
||||||
|
|
||||||
(defun add-track-to-playlist (playlist-id track-id)
|
(defun add-track-to-playlist (playlist-id track-id)
|
||||||
"Add a track to a playlist"
|
"Add a track to a playlist using the playlist_tracks junction table"
|
||||||
(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 "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))
|
(handler-case
|
||||||
(format t "Current track-ids: ~a~%" current-track-ids)
|
(postmodern:with-connection (get-db-connection-params)
|
||||||
(format t "Tracks list: ~a~%" tracks-list)
|
;; Get the next position for this playlist
|
||||||
(format t "New tracks: ~a~%" new-tracks)
|
(let* ((max-pos-result (postmodern:query
|
||||||
(format t "Track IDs string: ~a~%" track-ids-str)
|
(format nil "SELECT COALESCE(MAX(position), 0) FROM playlist_tracks WHERE playlist_id = ~a"
|
||||||
;; Update using track-ids field (defined in schema)
|
playlist-id)
|
||||||
(setf (dm:field playlist "track-ids") track-ids-str)
|
:single))
|
||||||
(data-model-save playlist)
|
(next-position (1+ (or max-pos-result 0))))
|
||||||
(format t "Update complete~%")
|
(postmodern:execute
|
||||||
t)))))
|
(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)
|
(defun remove-track-from-playlist (playlist-id track-id)
|
||||||
"Remove a track from a playlist"
|
"Remove a track from a playlist using the playlist_tracks junction table"
|
||||||
(let ((playlist (get-playlist-by-id playlist-id)))
|
(format t "Removing track ~a from playlist ~a~%" track-id playlist-id)
|
||||||
(when playlist
|
(handler-case
|
||||||
(let* ((current-track-ids (dm:field playlist "track-ids"))
|
(postmodern:with-connection (get-db-connection-params)
|
||||||
;; Parse comma-separated string into list
|
(postmodern:execute
|
||||||
(tracks-list (if (and current-track-ids
|
(format nil "DELETE FROM playlist_tracks WHERE playlist_id = ~a AND track_id = ~a"
|
||||||
(stringp current-track-ids)
|
playlist-id track-id))
|
||||||
(not (string= current-track-ids "")))
|
(format t "Track removed~%")
|
||||||
(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))))
|
|
||||||
|
|
||||||
(defun delete-playlist (playlist-id)
|
|
||||||
"Delete a playlist"
|
|
||||||
(dm:delete "playlists" (db:query (:= '_id playlist-id)))
|
|
||||||
t)
|
t)
|
||||||
|
(error (e)
|
||||||
|
(format t "Error removing track from playlist: ~a~%" e)
|
||||||
|
nil)))
|
||||||
|
|
||||||
|
(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)))
|
||||||
|
|
|
||||||
|
|
@ -1,163 +1,98 @@
|
||||||
#EXTM3U
|
#EXTM3U
|
||||||
#EXTINF:370,Vector Lovers - City Lights From a Train
|
#PLAYLIST:Asteroid Low Orbit - Ambient Electronic Journey
|
||||||
Vector Lovers/City Lights From a Train.flac
|
#CURATOR:Asteroid Radio
|
||||||
#EXTINF:400,The Black Dog - Psil-Cosyin
|
|
||||||
The Black Dog/Psil-Cosyin.flac
|
#EXTINF:-1,Brian Eno - Emerald And Lime
|
||||||
#EXTINF:320,Plaid - Eyen
|
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A1 Emerald And Lime.flac
|
||||||
Plaid/Eyen.flac
|
#EXTINF:-1,Brian Eno - Complex Heaven
|
||||||
#EXTINF:330,ISAN - Birds Over Barges
|
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A2 Complex Heaven.flac
|
||||||
ISAN/Birds Over Barges.flac
|
#EXTINF:-1,Brian Eno - Dust Shuffle
|
||||||
#EXTINF:360,Ochre - Bluebottle Farm
|
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/B4 Dust Shuffle.flac
|
||||||
Ochre/Bluebottle Farm.flac
|
#EXTINF:-1,Brian Eno - Garden of Stars
|
||||||
#EXTINF:390,Arovane - Theme
|
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/04 Garden of Stars.flac
|
||||||
Arovane/Theme.flac
|
#EXTINF:-1,Brian Eno - There Were Bells
|
||||||
#EXTINF:380,Proem - Deep Like Airline Failure
|
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/06 There Were Bells.flac
|
||||||
Proem/Deep Like Airline Failure.flac
|
#EXTINF:-1,Biosphere - Drifter
|
||||||
#EXTINF:310,Solvent - My Radio (Remix)
|
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac
|
||||||
Solvent/My Radio (Remix).flac
|
#EXTINF:-1,Biosphere - Black Mesa
|
||||||
#EXTINF:350,Bochum Welt - Marylebone (7th)
|
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/02. Biosphere - Black Mesa.flac
|
||||||
Bochum Welt/Marylebone (7th).flac
|
#EXTINF:-1,Biosphere - Skålbrekka
|
||||||
#EXTINF:290,Mrs Jynx - Shibuya Lullaby
|
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/01 - Skålbrekka.flac
|
||||||
Mrs Jynx/Shibuya Lullaby.flac
|
#EXTINF:-1,Biosphere - Bjorvika
|
||||||
#EXTINF:340,Kettel - Whisper Me Wishes
|
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/04 - Bjorvika.flac
|
||||||
Kettel/Whisper Me Wishes.flac
|
#EXTINF:-1,Biosphere - Out Of The Cradle
|
||||||
#EXTINF:360,Christ. - Perlandine Friday
|
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/01 - Out Of The Cradle.flac
|
||||||
Christ./Perlandine Friday.flac
|
#EXTINF:-1,Biosphere - Down On Ropes
|
||||||
#EXTINF:330,Cepia - Ithaca
|
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/03 - Down On Ropes.flac
|
||||||
Cepia/Ithaca.flac
|
#EXTINF:-1,Biosphere - Microtunneling
|
||||||
#EXTINF:340,Datassette - Vacuform
|
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 04 Microtunneling.flac
|
||||||
Datassette/Vacuform.flac
|
#EXTINF:-1,Autechre - Dael
|
||||||
#EXTINF:390,Plant43 - Dreams of the Sentient City
|
/app/music/Autechre/1995 - Tri Repetae/01 Dael.flac
|
||||||
Plant43/Dreams of the Sentient City.flac
|
#EXTINF:-1,Autechre - Further
|
||||||
#EXTINF:410,Claro Intelecto - Peace of Mind (Electrosoul)
|
/app/music/Autechre/1994 - Amber/08 Further.flac
|
||||||
Claro Intelecto/Peace of Mind (Electrosoul).flac
|
#EXTINF:-1,Autechre - north spiral
|
||||||
#EXTINF:430,E.R.P. - Evoked
|
/app/music/Autechre - 2018 - NTS Session 1/NTS Session 1-006-Autechre-north spiral.flac
|
||||||
E.R.P./Evoked.flac
|
#EXTINF:-1,Four Tet - Alap
|
||||||
#EXTINF:310,Der Zyklus - Formenverwandler
|
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/01 Alap.flac
|
||||||
Der Zyklus/Formenverwandler.flac
|
#EXTINF:-1,Four Tet - Scientists
|
||||||
#EXTINF:330,Dopplereffekt - Infophysix
|
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/06 Scientists.flac
|
||||||
Dopplereffekt/Infophysix.flac
|
#EXTINF:-1,Four Tet - Green
|
||||||
#EXTINF:350,Drexciya - Wavejumper
|
/app/music/Four Tet - Sixteen Oceans (2020) {Text Records - TEXT051} [CD FLAC]/12 - Four Tet - Green.flac
|
||||||
Drexciya/Wavejumper.flac
|
#EXTINF:-1,Four Tet - Parallel 8
|
||||||
#EXTINF:375,The Other People Place - Sorrow & A Cup of Joe
|
/app/music/Four Tet - Parallel (2020) - WEB FLAC/08. Parallel 8.flac
|
||||||
The Other People Place/Sorrow & A Cup of Joe.flac
|
#EXTINF:-1,Clark - Spring But Dark
|
||||||
#EXTINF:340,Arpanet - Wireless Internet
|
/app/music/Clark - Death Peak (2017) [FLAC]/01 - Spring But Dark.flac
|
||||||
Arpanet/Wireless Internet.flac
|
#EXTINF:-1,Clark - Kiri's Glee
|
||||||
#EXTINF:380,Legowelt - Sturmvogel
|
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/04 - Kiri's Glee.flac
|
||||||
Legowelt/Sturmvogel.flac
|
#EXTINF:-1,Clark - Primary Pluck
|
||||||
#EXTINF:310,DMX Krew - Space Paranoia
|
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/08 - Primary Pluck.flac
|
||||||
DMX Krew/Space Paranoia.flac
|
#EXTINF:-1,Clark - Cannibal Homecoming
|
||||||
#EXTINF:360,Skywave Theory - Nova Drift
|
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/11 - Cannibal Homecoming.flac
|
||||||
Skywave Theory/Nova Drift.flac
|
#EXTINF:-1,Clark - Absence (Bibio Remix)
|
||||||
#EXTINF:460,Pye Corner Audio - Transmission Four
|
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.12. Clark - Absence (Bibio Remix).flac
|
||||||
Pye Corner Audio/Transmission Four.flac
|
#EXTINF:-1,Tycho - Glider
|
||||||
#EXTINF:390,B12 - Heaven Sent
|
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/01 - Glider.flac
|
||||||
B12/Heaven Sent.flac
|
#EXTINF:-1,Tycho - Source
|
||||||
#EXTINF:450,Higher Intelligence Agency - Tortoise
|
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/07 - Source.flac
|
||||||
Higher Intelligence Agency/Tortoise.flac
|
#EXTINF:-1,Tycho - Rings
|
||||||
#EXTINF:420,Biosphere - Kobresia
|
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/09 - Rings.flac
|
||||||
Biosphere/Kobresia.flac
|
#EXTINF:-1,Tycho - Into The Woods
|
||||||
#EXTINF:870,Global Communication - 14:31
|
/app/music/Tycho - Simulcast (2020) [WEB FLAC]/04 - Into The Woods.flac
|
||||||
Global Communication/14:31.flac
|
#EXTINF:-1,Tycho - Ascension
|
||||||
#EXTINF:500,Monolake - Cyan
|
/app/music/Thievery Corporation and Tycho - Fragments Ascension EP (flac)/3. Tycho - Ascension.flac
|
||||||
Monolake/Cyan.flac
|
#EXTINF:-1,Ulrich Schnauss - Negative Sunrise (2019 Version)
|
||||||
#EXTINF:660,Deepchord - Electromagnetic
|
/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/09. Negative Sunrise (2019 Version).flac
|
||||||
Deepchord/Electromagnetic.flac
|
#EXTINF:-1,Ulrich Schnauss - Like a Ghost in Your Own Life
|
||||||
#EXTINF:1020,GAS - Pop 4
|
/app/music/Ulrich Schnauss - A Long Way To Fall - Rebound (2020) - WEB FLAC/03. Like a Ghost in Your Own Life.flac
|
||||||
GAS/Pop 4.flac
|
#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Solitary Falling
|
||||||
#EXTINF:600,Yagya - Rigning Nýju
|
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/03. Solitary Falling.flac
|
||||||
Yagya/Rigning Nýju.flac
|
#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Narkomfin
|
||||||
#EXTINF:990,Voices From The Lake - Velo di Maya
|
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/05. Narkomfin.flac
|
||||||
Voices From The Lake/Velo di Maya.flac
|
#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Polychrome
|
||||||
#EXTINF:3720,ASC - Time Heals All
|
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/08. Polychrome.flac
|
||||||
ASC/Time Heals All.flac
|
#EXTINF:-1,Proem - Winter Wolves
|
||||||
#EXTINF:540,36 - Room 237
|
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
|
||||||
36/Room 237.flac
|
#EXTINF:-1,Proem - Modern Rope
|
||||||
#EXTINF:900,Loscil - Endless Falls
|
/app/music/Proem - 2018 Modern Rope (WEB)/05. Modern Rope.flac
|
||||||
Loscil/Endless Falls.flac
|
#EXTINF:-1,Proem - Kids That Hate Live Things
|
||||||
#EXTINF:450,Kiasmos - Looped
|
/app/music/Proem - Until Here for Years (n5md, 2019) flac/11 - Kids That Hate Live Things.flac
|
||||||
Kiasmos/Looped.flac
|
#EXTINF:-1,Proem - End Tail
|
||||||
#EXTINF:590,Underworld - Rez
|
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/07 - End Tail.flac
|
||||||
Underworld/Rez.flac
|
#EXTINF:-1,Proem - In a Timeless, Lightless World
|
||||||
#EXTINF:570,Orbital - Halcyon + On + On
|
/app/music/Proem/2019 - As They Go/Proem - As They Go - 05 In a Timeless, Lightless World.flac
|
||||||
Orbital/Halcyon + On + On.flac
|
#EXTINF:-1,arovane - komposition no. 1
|
||||||
#EXTINF:1080,The Orb - A Huge Ever Growing Pulsating Brain
|
/app/music/arovane - Wirkung (2020) [WEB FLAC16]/17. arovane - komposition no. 1.flac
|
||||||
The Orb/A Huge Ever Growing Pulsating Brain.flac
|
#EXTINF:-1,arovane - hymn
|
||||||
#EXTINF:360,Autechre - Slip
|
/app/music/arovane - Wirkung (2020) [WEB FLAC16]/12. arovane - hymn.flac
|
||||||
Autechre/Slip.flac
|
#EXTINF:-1,Aphex Twin - CHEETAHT7b
|
||||||
#EXTINF:400,Labradford - S (Mi Media Naranja)
|
/app/music/Aphex Twin (2016) Cheetah EP [WEB] [FLAC]/Cheetah EP-002-Aphex Twin-CHEETAHT7b.flac
|
||||||
Labradford/S (Mi Media Naranja).flac
|
#EXTINF:-1,Aphex Twin - CHEETA2 ms800
|
||||||
#EXTINF:350,Vector Lovers - Rusting Cars and Wildflowers
|
/app/music/Aphex Twin (2016) Cheetah EP [WEB] [FLAC]/Cheetah EP-004-Aphex Twin-CHEETA2 ms800.flac
|
||||||
Vector Lovers/Rusting Cars and Wildflowers.flac
|
#EXTINF:-1,Plaid - Sun Electric - Tee (Plaid Mix)
|
||||||
#EXTINF:390,The Black Dog - Raxmus
|
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/17-Sun_Electric-Tee_(Plaid_Mix).flac
|
||||||
The Black Dog/Raxmus.flac
|
#EXTINF:-1,Plaid - Origamibiro - Impressions Of Football (Plaid Remix)
|
||||||
#EXTINF:315,Plaid - Hawkmoth
|
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/11-Origamibiro-Impressions_Of_Football_(Plaid_Remix).flac
|
||||||
Plaid/Hawkmoth.flac
|
#EXTINF:-1,Plaid - Ricardo Tobar - After The Movie (Plaid Remix)
|
||||||
#EXTINF:320,ISAN - What This Button Did
|
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/21-Ricardo_Tobar-After_The_Movie_(Plaid_Remix).flac
|
||||||
ISAN/What This Button Did.flac
|
#EXTINF:-1,Plaid - Esem - Yourturn (Plaid Remix)
|
||||||
#EXTINF:370,Ochre - Circadies
|
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/26-Esem-Yourturn_(Plaid_Remix).flac
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -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 I’m Here For
|
||||||
|
/app/music/James - Millionaires (Mercury Records, 1999) EAC-FLAC/03. James - I Know What I’m 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
|
||||||
|
|
@ -1,371 +1,144 @@
|
||||||
#EXTM3U
|
#EXTM3U
|
||||||
#EXTINF:-1,Underworld - Underworld - Confusion The Waitress
|
#PLAYLIST:Escape Velocity - A Christmas Journey Through Space
|
||||||
/app/music/Underworld/1996 - Second Toughest In The Infants/03. Underworld - Confusion The Waitress.flac
|
#PHASE:Escape Velocity
|
||||||
#EXTINF:-1,The Orb - Towers Of Dub
|
#DURATION:12 hours (approx)
|
||||||
/app/music/The Orb/1992 - UFOrb/04-Towers Of Dub.mp3
|
#CURATOR:Asteroid Radio
|
||||||
#EXTINF:-1,Drexciya - Drexciya - Intensified Magnetron
|
#DESCRIPTION:A festive 12-hour voyage blending Christmas classics with ambient, IDM, and space music for the holiday season
|
||||||
/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
|
# === PHASE 1: WINTER AWAKENING (Ambient Beginnings) ===
|
||||||
/app/music/Labradford/1995 - A Stable Reference/6 Balanced on It's Own Flame.flac
|
#EXTINF:-1,Brian Eno - Snow
|
||||||
#EXTINF:-1,Vector Lovers - City Lights From A Train
|
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac
|
||||||
/app/music/Vector Lovers/2005 - Capsule For One/01 - City Lights From A Train.mp3
|
#EXTINF:-1,Brian Eno - Wintergreen
|
||||||
#EXTINF:-1,Labradford - Leta O'Steen. Design assistance by John Piper
|
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/04 Wintergreen.flac
|
||||||
/app/music/Labradford/1999 - E luxo so/6. Leta O'Steen. Design assistance by John Piper.flac
|
#EXTINF:-1,Proem - Winter Wolves
|
||||||
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 1 Reel One
|
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
|
||||||
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/01 Tape Loop Orchestra - Chapter 1 Reel One.mp3
|
#EXTINF:-1,Tim Hecker - Winter's Coming
|
||||||
#EXTINF:-1,Orbital - Time Becomes
|
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 10 Winter's Coming.flac
|
||||||
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Time Becomes.mp3
|
#EXTINF:-1,Biosphere - Drifter
|
||||||
#EXTINF:-1,Proem - Proem - You Shall Have Ever Been - 05 No You Are $
|
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac
|
||||||
/app/music/Proem/2006 - You Shall Have Ever Been/Proem - You Shall Have Ever Been - 05 No You Are $.flac
|
#EXTINF:-1,Dead Voices On Air - On Winters Gibbet
|
||||||
#EXTINF:-1,Pye Corner Audio - Pye Corner Audio - The Simplest Equation
|
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/02 - On Winters Gibbet.flac
|
||||||
/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,Color Therapy - Wintering
|
||||||
#EXTINF:-1,Brian Eno - Emerald and Lime
|
/app/music/Color Therapy - Mr. Wolf Is Dead (2015) WEB FLAC/12 - Wintering.flac
|
||||||
/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
|
# === PHASE 2: CHRISTMAS ARRIVAL (TSO Introduction) ===
|
||||||
/app/music/Bark Psychosis/1994 - Game Over/(07) [Bark Psychosis] A Street Scene.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - The Ghosts Of Christmas Eve
|
||||||
#EXTINF:-1,Model 500 - model_500-digital_solutions
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/01 The Ghosts Of Christmas Eve.flac
|
||||||
/app/music/Model 500/2015 - Digital Solutions/08-model_500-digital_solutions.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - Christmas In The Air
|
||||||
#EXTINF:-1,Labradford - Banco
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/14 Christmas In The Air.flac
|
||||||
/app/music/Labradford/1995 - A Stable Reference/4 Banco.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Canon
|
||||||
#EXTINF:-1,Labradford - Skyward With Motion
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/08 Christmas Canon.flac
|
||||||
/app/music/Labradford/1993 - Prazision LP/11 Skyward With Motion.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - Appalachian Snowfall
|
||||||
#EXTINF:-1,Pye Corner Audio - The Mirror Ball Cracked
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/11 Appalachian Snowfall.flac
|
||||||
/app/music/Pye Corner Audio/2012 - Sleep Games (WEB, #GBX017)/08 - The Mirror Ball Cracked.mp3
|
#EXTINF:-1,Trans-Siberian Orchestra - The Snow Came Down
|
||||||
#EXTINF:-1,Brian Eno - Foreign Affairs
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/13 The Snow Came Down.flac
|
||||||
/app/music/Brian Eno/1978 - After The Heat/01 - Foreign Affairs.flac
|
|
||||||
#EXTINF:-1,The Other People Place - B1 - Moonlight Rendezvous
|
# === PHASE 3: AMBIENT INTERLUDE (Space & Atmosphere) ===
|
||||||
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/B1 - Moonlight Rendezvous.flac
|
#EXTINF:-1,Biosphere - 10 Snurp 1937
|
||||||
#EXTINF:-1,Drexciya - Unknown Journey IX
|
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 10 Snurp 1937.flac
|
||||||
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/10. Unknown Journey IX.mp3
|
#EXTINF:-1,Biosphere - 05 Fluvialmorphologie
|
||||||
#EXTINF:-1,Orbital - Crash And Carry
|
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 05 Fluvialmorphologie.flac
|
||||||
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/04. Crash And Carry.mp3
|
#EXTINF:-1,God is an Astronaut - Winter Dusk-Awakening
|
||||||
#EXTINF:-1,Proem - Proem - Before it finds you - 09 We can watch it burn to the ground
|
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/03. Winter Dusk-Awakening.flac
|
||||||
/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 - Snow Drifts
|
||||||
#EXTINF:-1,Proem - Proem - Before it finds you - 01 Stone into gravel
|
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/11 - Snow Drifts.flac
|
||||||
/app/music/Proem/2013 - Before it finds you/Proem - Before it finds you - 01 Stone into gravel.flac
|
#EXTINF:-1,Proem - Stick to Music Snowflake
|
||||||
#EXTINF:-1,Drexciya - Intro (The Unknown Aquazone)
|
/app/music/Proem - Until Here for Years (n5md, 2019) flac/04 - Stick to Music Snowflake.flac
|
||||||
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/01. Intro (The Unknown Aquazone).mp3
|
#EXTINF:-1,Four Tet - 04 Tremper
|
||||||
#EXTINF:-1,Teeth Of The Sea - Get With the Program
|
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/04 Tremper.flac
|
||||||
/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
|
# === PHASE 4: CHRISTMAS EVE STORIES ===
|
||||||
/app/music/Proem/2015 - Vault ep.4-4/Proem - Vault ep.4-4 - 02 Little girls.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - First Snow (Instrumental)
|
||||||
#EXTINF:-1,Drexciya - Black Sea
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/04 First Snow (Instrumental).flac
|
||||||
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/14. Black Sea.mp3
|
#EXTINF:-1,Trans-Siberian Orchestra - The Silent Nutcracker (Instrumental)
|
||||||
#EXTINF:-1,Autechre - Yulquen
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/05 The Silent Nutcracker (Instrumental).flac
|
||||||
/app/music/Autechre/1994 - Amber/09 Yulquen.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - A Mad Russian's Christmas (Instrumental)
|
||||||
#EXTINF:-1,The Other People Place - C2 - Running From Love
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/06 A Mad Russian's Christmas (Instrumental).flac
|
||||||
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/C2 - Running From Love.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Eve,Sarajevo 12,24 (Instrumental)
|
||||||
#EXTINF:-1,Brian Eno - D2 Written, Forgotten
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/08 Christmas Eve,Sarajevo 12,24 (Instrumental).flac
|
||||||
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/D2 Written, Forgotten.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - This Christmas Day
|
||||||
#EXTINF:-1,Autechre - Stud
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/14 This Christmas Day.flac
|
||||||
/app/music/Autechre/1995 - Tri Repetae/05 Stud.flac
|
|
||||||
#EXTINF:-1,Model 500 - model_500-electric_night
|
# === PHASE 5: ELECTRONIC DREAMS (IDM & Ambient) ===
|
||||||
/app/music/Model 500/2015 - Digital Solutions/02-model_500-electric_night.flac
|
#EXTINF:-1,Autechre - NTS Session 1-005-Autechre-carefree counter dronal
|
||||||
#EXTINF:-1,The Orb - Close Encounters
|
/app/music/Autechre - 2018 - NTS Session 1/NTS Session 1-005-Autechre-carefree counter dronal.flac
|
||||||
/app/music/The Orb/1992 - UFOrb/05-Close Encounters.mp3
|
#EXTINF:-1,Clark - Living Fantasy
|
||||||
#EXTINF:-1,Model 500 - model_500-hi_nrg
|
/app/music/Clark - Death Peak (2017) [FLAC]/08 - Living Fantasy.flac
|
||||||
/app/music/Model 500/2015 - Digital Solutions/01-model_500-hi_nrg.flac
|
#EXTINF:-1,Clark - My Machines (Clark Remix)
|
||||||
#EXTINF:-1,Brian Eno - B3 Bone Jump
|
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.17. Battles - My Machines (Clark Remix).flac
|
||||||
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/B3 Bone Jump.flac
|
#EXTINF:-1,Plaid - Dancers
|
||||||
#EXTINF:-1,Labradford - by Chris Johnston, Craig Markva, Jamie Evans,
|
/app/music/Plaid - Polymer (2019) [WEB FLAC]/07 - Dancers.flac
|
||||||
/app/music/Labradford/1999 - E luxo so/4. by Chris Johnston, Craig Markva, Jamie Evans,.flac
|
#EXTINF:-1,Faux Tales - Avalon
|
||||||
#EXTINF:-1,The Orb - Star 6 & 7 8 9
|
/app/music/Faux Tales - 2015 - Kairos [FLAC] {Kensai Records KNS006 WEB}/3 - Avalon.flac
|
||||||
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/09 Star 6 & 7 8 9.mp3
|
#EXTINF:-1,Color Therapy - Expect Delays (feat. Ulrich Schnauss)
|
||||||
#EXTINF:-1,Proem - Proem - Vault ep.1-4 (Noise) - 02 Half a Heart
|
/app/music/Color Therapy - Mr. Wolf Is Dead (2015) WEB FLAC/11 - Expect Delays (feat. Ulrich Schnauss).flac
|
||||||
/app/music/Proem/2016 - Vault ep.1-4 (Noise)/Proem - Vault ep.1-4 (Noise) - 02 Half a Heart.flac
|
|
||||||
#EXTINF:-1,Dopplereffekt - Spirangle
|
# === PHASE 6: THE LOST CHRISTMAS EVE ===
|
||||||
/app/music/Dopplereffekt/2017 - Cellular Automata/08. Spirangle.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - The Lost Christmas Eve
|
||||||
#EXTINF:-1,Drexciya - Unknown Journey VII
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/02 The Lost Christmas Eve.flac
|
||||||
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/06. Unknown Journey VII.mp3
|
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Dreams
|
||||||
#EXTINF:-1,Drexciya - Mantaray
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/03 Christmas Dreams.flac
|
||||||
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/04. Mantaray.mp3
|
#EXTINF:-1,Trans-Siberian Orchestra - Wizards in Winter
|
||||||
#EXTINF:-1,Pye Corner Audio - Pye Corner Audio - Untitled
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/04 Wizards in Winter.flac
|
||||||
/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,Trans-Siberian Orchestra - Christmas Concerto
|
||||||
#EXTINF:-1,Underworld - Underworld - Juanita, Kiteless, To Dream Of Love
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/07 Christmas Concerto.flac
|
||||||
/app/music/Underworld/1996 - Second Toughest In The Infants/01. Underworld - Juanita, Kiteless, To Dream Of Love.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - Queen Of The Winter Night
|
||||||
#EXTINF:-1,Proem - Proem - As They Go - 05 In a Timeless, Lightless World
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/08 Queen Of The Winter Night.flac
|
||||||
/app/music/Proem/2019 - As They Go/Proem - As They Go - 05 In a Timeless, Lightless World.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Nights In Blue
|
||||||
#EXTINF:-1,Model 500 - model_500-standing_in_tomorow
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/09 Christmas Nights In Blue.flac
|
||||||
/app/music/Model 500/2015 - Digital Solutions/03-model_500-standing_in_tomorow.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Jazz
|
||||||
#EXTINF:-1,The Orb - Plum Island
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/10 Christmas Jazz.flac
|
||||||
/app/music/The Orb/2001 - Cydonia/09-Plum Island.mp3
|
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Jam
|
||||||
#EXTINF:-1,Orbital - Lush 3-2
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/11 Christmas Jam.flac
|
||||||
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Lush 3-2.mp3
|
|
||||||
#EXTINF:-1,Dopplereffekt - Exponential Decay
|
# === PHASE 7: CLASSICAL WINTER (Nutcracker & More) ===
|
||||||
/app/music/Dopplereffekt/2017 - Cellular Automata/09. Exponential Decay.flac
|
#EXTINF:-1,Various Artists - Dance of the Sugar-Plum Fairy
|
||||||
#EXTINF:-1,Brian Eno - Garden of Stars
|
/app/music/Various Artists - The 50 Darkest Pieces of Classical Music (2011) - FLAC/CD 1/02 - Tchaikovsky - The Nutcracker - Dance of the Sugar-Plum Fairy.flac
|
||||||
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/04 Garden of Stars.flac
|
#EXTINF:-1,Quaeschning and Ulrich Schnauss - Thirst
|
||||||
#EXTINF:-1,The Other People Place - B2 - You Said You Want Me
|
/app/music/Quaeschning and Ulrich Schnauss - Synthwaves (2017) {vista003, GER, CD} [FLAC]/06 - Thirst.flac
|
||||||
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/B2 - You Said You Want Me.flac
|
#EXTINF:-1,Proem - 04. Drawing Room Anguish
|
||||||
#EXTINF:-1,Dopplereffekt - Mandelbrot Set
|
/app/music/Proem - 2018 Modern Rope (WEB)/04. Drawing Room Anguish.flac
|
||||||
/app/music/Dopplereffekt/2017 - Cellular Automata/07. Mandelbrot Set.flac
|
#EXTINF:-1,Dead Voices On Air - 07. Dogger Doorlopende Split
|
||||||
#EXTINF:-1,Autechre - Foil
|
/app/music/Dead Voices On Air - Frankie Pett En De Onderzeer Boten (2017) web/07. Dogger Doorlopende Split.flac
|
||||||
/app/music/Autechre/1994 - Amber/01 Foil.flac
|
|
||||||
#EXTINF:-1,Proem - Proem - As They Go - 04 What is Needed
|
# === PHASE 8: WISDOM & REFLECTION ===
|
||||||
/app/music/Proem/2019 - As They Go/Proem - As They Go - 04 What is Needed.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - What Is Christmas
|
||||||
#EXTINF:-1,Vector Lovers - Post Arctic Industries
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/13 What Is Christmas.flac
|
||||||
/app/music/Vector Lovers/2005 - Capsule For One/06 - Post Arctic Industries.mp3
|
#EXTINF:-1,Trans-Siberian Orchestra - The Wisdom Of Snow
|
||||||
#EXTINF:-1,Proem - proem - Negativ - 12 Skylup
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/15 The Wisdom Of Snow.flac
|
||||||
/app/music/Proem/2001 - Negativ/proem - Negativ - 12 Skylup.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Bells, Carousels & Time
|
||||||
#EXTINF:-1,Model 500 - model_500-encounter
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/18 Christmas Bells, Carousels & Time.flac
|
||||||
/app/music/Model 500/2015 - Digital Solutions/04-model_500-encounter.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Canon Rock
|
||||||
#EXTINF:-1,Kraftwerk - Pocket Calculator
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/21 Christmas Canon Rock.flac
|
||||||
/app/music/Kraftwerk/1981 - Computer World/02 - Pocket Calculator.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - Midnight Christmas Eve
|
||||||
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 13 Reel Two End
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/05 Midnight Christmas Eve.flac
|
||||||
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/13 Tape Loop Orchestra - Chapter 13 Reel Two End.mp3
|
#EXTINF:-1,Trans-Siberian Orchestra - Dream Child (A Christmas Dream)
|
||||||
#EXTINF:-1,Pye Corner Audio - Corrupt Data
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/15 Dream Child (A Christmas Dream).flac
|
||||||
/app/music/Pye Corner Audio/2017 - Half-Light (Prower Remixed) (WEB, #MTH011)/01 - Corrupt Data.mp3
|
|
||||||
#EXTINF:-1,Kiasmos - Kiasmos - II - 04 Laced
|
# === PHASE 9: DEEP SPACE JOURNEY (Extended Ambient) ===
|
||||||
/app/music/Kiasmos/2024 - II/Kiasmos - II - 04 Laced.flac
|
#EXTINF:-1,Dead Voices On Air - Red Howls
|
||||||
#EXTINF:-1,Pye Corner Audio - Mindshaft
|
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/01 - Red Howls.flac
|
||||||
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/05 - Mindshaft.mp3
|
#EXTINF:-1,Cut Copy - Airborne
|
||||||
#EXTINF:-1,Labradford - G
|
/app/music/Cut Copy - Haiku From Zero (2017) [FLAC] {2557864014}/05 - Airborne.flac
|
||||||
/app/music/Labradford/1997 - Mi Media Naranja/2 G.flac
|
#EXTINF:-1,Owl City - 01 Hot Air Balloon
|
||||||
#EXTINF:-1,Dopplereffekt - Isotropy
|
/app/music/Owl City - Ocean Eyes (Deluxe Edition) [Flac,Cue,Logs]/Disc 2/01 Hot Air Balloon.flac
|
||||||
/app/music/Dopplereffekt/2017 - Cellular Automata/04. Isotropy.flac
|
#EXTINF:-1,VA - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit]
|
||||||
#EXTINF:-1,Autechre - Further
|
/app/music/VA - Melodic Vocal Trance 2017/24. Airborn, Bogdan Vix & KeyPlayer - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit].flac
|
||||||
/app/music/Autechre/1994 - Amber/08 Further.flac
|
#EXTINF:-1,VA - Winter Took Over (Radio Edit)
|
||||||
#EXTINF:-1,Proem - Proem - You Shall Have Ever Been - 02 Eck The Badly Drawn
|
/app/music/VA - Melodic Vocal Trance 2017/22. Bluskay, KeyPlayer & Esmee Bor Stotijn - Winter Took Over (Radio Edit).flac
|
||||||
/app/music/Proem/2006 - You Shall Have Ever Been/Proem - You Shall Have Ever Been - 02 Eck The Badly Drawn.flac
|
#EXTINF:-1,Alison Krauss and Union Station - My Opening Farewell
|
||||||
#EXTINF:-1,Autechre - C-Pach
|
/app/music/Alison Krauss and Union Station - Paper Airplane (flac)/11 - Alison Krauss & Union Station - My Opening Farewell.flac
|
||||||
/app/music/Autechre/1995 - Tri Repetae/07 C-Pach.flac
|
#EXTINF:-1,Bedouin Soundclash - Money Worries (E-Clair Refix)
|
||||||
#EXTINF:-1,Kraftwerk - Neon Lights
|
/app/music/Bedouin Soundclash - Sounding a Mosaic (2004) [FLAC] {SD1267}/14 - Money Worries (E-Clair Refix).flac
|
||||||
/app/music/Kraftwerk/1978 - The Man-Machine/05 - Neon Lights.flac
|
|
||||||
#EXTINF:-1,Labradford - twenty
|
# === PHASE 10: RETURN TO WINTER (Closing Circle) ===
|
||||||
/app/music/Labradford/2001 - fixed..context/1 twenty.flac
|
#EXTINF:-1,Brian Eno - Snow
|
||||||
#EXTINF:-1,Bark Psychosis - (01) [Bark Psychosis] Blue
|
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac
|
||||||
/app/music/Bark Psychosis/1994 - Game Over/(01) [Bark Psychosis] Blue.flac
|
#EXTINF:-1,Proem - Winter Wolves
|
||||||
#EXTINF:-1,Vector Lovers - Substrata
|
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
|
||||||
/app/music/Vector Lovers/2005 - Capsule For One/03 - Substrata.mp3
|
#EXTINF:-1,God is an Astronaut - Winter Dusk-Awakening
|
||||||
#EXTINF:-1,Kraftwerk - Computer World
|
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/03. Winter Dusk-Awakening.flac
|
||||||
/app/music/Kraftwerk/1981 - Computer World/01 - Computer World.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - The Snow Came Down
|
||||||
#EXTINF:-1,Underworld - Underworld - Air Towel
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/13 The Snow Came Down.flac
|
||||||
/app/music/Underworld/1996 - Second Toughest In The Infants/06. Underworld - Air Towel.flac
|
#EXTINF:-1,Trans-Siberian Orchestra - Christmas In The Air
|
||||||
#EXTINF:-1,Underworld - Underworld - Blueski
|
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/14 Christmas In The Air.flac
|
||||||
/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
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -978,7 +978,43 @@
|
||||||
|
|
||||||
(.stat-label :color "#ccc"
|
(.stat-label :color "#ccc"
|
||||||
:font-size 0.875rem
|
: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
|
;; Center alignment for player page
|
||||||
;; (body.player-page
|
;; (body.player-page
|
||||||
|
|
@ -1041,6 +1077,20 @@
|
||||||
:flex 1
|
:flex 1
|
||||||
:min-width "300px")
|
: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
|
(.persistent-disable-btn
|
||||||
:background transparent
|
:background transparent
|
||||||
:color "#00ff00"
|
:color "#00ff00"
|
||||||
|
|
|
||||||
|
|
@ -90,17 +90,21 @@
|
||||||
t)
|
t)
|
||||||
|
|
||||||
(defun regenerate-stream-playlist ()
|
(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"
|
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
|
||||||
(asdf:system-source-directory :asteroid))))
|
(asdf:system-source-directory :asteroid))))
|
||||||
(if (null *stream-queue*)
|
(if (null *stream-queue*)
|
||||||
;; If queue is empty, generate from all tracks (fallback)
|
;; DISABLED: Don't dump all tracks when queue is empty
|
||||||
(let ((all-tracks (dm:get "tracks" (db:query :all))))
|
;; This was overwriting files with all library tracks unexpectedly
|
||||||
(generate-m3u-playlist
|
;; (let ((all-tracks (dm:get "tracks" (db:query :all))))
|
||||||
(mapcar (lambda (track)
|
;; (generate-m3u-playlist
|
||||||
(dm:id track))
|
;; (mapcar (lambda (track)
|
||||||
all-tracks)
|
;; (dm:id track))
|
||||||
playlist-path))
|
;; all-tracks)
|
||||||
|
;; playlist-path))
|
||||||
|
(format t "Stream queue is empty, not generating playlist file~%")
|
||||||
;; Generate from queue
|
;; Generate from queue
|
||||||
(generate-m3u-playlist *stream-queue* playlist-path))))
|
(generate-m3u-playlist *stream-queue* playlist-path))))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,13 @@
|
||||||
"Recursively scan directory for supported audio files"
|
"Recursively scan directory for supported audio files"
|
||||||
(when (cl-fad:directory-exists-p directory)
|
(when (cl-fad:directory-exists-p directory)
|
||||||
(remove-if-not #'supported-audio-file-p
|
(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)
|
(defun scan-directory-for-music-recursively (path)
|
||||||
"Recursively scan directory and all subdirectories for music files"
|
"Recursively scan directory and all subdirectories for music files"
|
||||||
(let ((files-in-current-dir (scan-directory-for-music path))
|
(let* ((resolved-path (truename path))
|
||||||
(files-in-subdirs (loop for directory in (uiop:subdirectories 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))))
|
appending (scan-directory-for-music-recursively directory))))
|
||||||
(append files-in-current-dir files-in-subdirs)))
|
(append files-in-current-dir files-in-subdirs)))
|
||||||
|
|
||||||
|
|
@ -92,16 +93,25 @@
|
||||||
(setf (dm:field track "file-path") file-path)
|
(setf (dm:field track "file-path") file-path)
|
||||||
(setf (dm:field track "format") (getf metadata :format))
|
(setf (dm:field track "format") (getf metadata :format))
|
||||||
(setf (dm:field track "bitrate") (getf metadata :bitrate))
|
(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)
|
(setf (dm:field track "play-count") 0)
|
||||||
(dm:insert track)
|
(dm:insert track)
|
||||||
t))))
|
t))))
|
||||||
|
|
||||||
(defun scan-music-library (&optional (directory *music-library-path*))
|
(defun scan-music-library (&optional (directory *music-library-path*))
|
||||||
"Scan music library directory and add tracks to database"
|
"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))
|
(let ((audio-files (scan-directory-for-music-recursively directory))
|
||||||
(added-count 0)
|
(added-count 0)
|
||||||
(skipped-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)
|
(dolist (file audio-files)
|
||||||
(let ((metadata (extract-metadata-with-taglib file)))
|
(let ((metadata (extract-metadata-with-taglib file)))
|
||||||
(when metadata
|
(when metadata
|
||||||
|
|
@ -111,6 +121,7 @@
|
||||||
(incf skipped-count))
|
(incf skipped-count))
|
||||||
(error (e)
|
(error (e)
|
||||||
(format t "Error adding ~a: ~a~%" file e))))))
|
(format t "Error adding ~a: ~a~%" file e))))))
|
||||||
|
(format t "Added: ~a, Skipped: ~a~%" added-count skipped-count)
|
||||||
added-count))
|
added-count))
|
||||||
|
|
||||||
;; Initialize music directory structure
|
;; Initialize music directory structure
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -38,29 +38,82 @@
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
<h3>Icecast Status</h3>
|
<h3>Icecast Status</h3>
|
||||||
<p class="status-error" data-text="icecast-status">🔴 Not Running</p>
|
<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>
|
</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 -->
|
<!-- Music Library Management -->
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h2>Music Library Management</h2>
|
<h2>Music Library Management</h2>
|
||||||
|
|
||||||
<!-- File Upload -->
|
<!-- Music Library Info -->
|
||||||
<div class="upload-section">
|
<div class="upload-section">
|
||||||
<h3>Add Music Files</h3>
|
<h3>Music Library</h3>
|
||||||
<div class="upload-info">
|
<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>
|
<ol>
|
||||||
<li>Copy your MP3/FLAC/OGG/WAV files to: <code>/home/glenn/Projects/Code/asteroid/music/incoming/</code></li>
|
<li>Add files to your music library directory (set via <code>MUSIC_LIBRARY</code> env var)</li>
|
||||||
<li>Click "Copy Files to Library" below</li>
|
<li>Click "Scan Library" to index new tracks into the database</li>
|
||||||
<li>Files will be moved to the library and added to the database</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
<p><em>Supported formats: MP3, FLAC, OGG, WAV</em></p>
|
<p><em>Supported formats: MP3, FLAC, OGG, WAV, OPUS</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -107,59 +160,94 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Stream Queue Management -->
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h2>🎵 Stream Queue Management</h2>
|
<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">
|
<!-- Playlist Selection -->
|
||||||
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
|
<div class="playlist-controls" style="margin-bottom: 20px; padding: 15px; background: #2a2a2a; border-radius: 8px;">
|
||||||
<button id="load-from-m3u" class="btn btn-success">📂 Load Queue from M3U</button>
|
<h3 style="margin-top: 0;">📋 Load Playlist</h3>
|
||||||
<button id="clear-queue-btn" class="btn btn-warning">🗑️ Clear Queue</button>
|
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
|
||||||
<button id="add-random-tracks" class="btn btn-info">🎲 Add 10 Random Tracks</button>
|
<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>
|
||||||
|
|
||||||
<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 class="loading">Loading queue...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="queue-actions">
|
<div class="queue-actions" style="margin-top: 20px;">
|
||||||
<h3>Add Tracks to Queue</h3>
|
<h3>Add Tracks to Queue</h3>
|
||||||
<input type="text" id="queue-track-search" placeholder="Search tracks to add..." class="search-input">
|
<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 id="queue-track-results" class="track-results"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Player Control -->
|
<!-- Liquidsoap Stream Control -->
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h2>Player Control</h2>
|
<h2>📡 Stream Control (Liquidsoap)</h2>
|
||||||
<div class="card">
|
<p>Control the live audio stream. Commands are sent directly to Liquidsoap.</p>
|
||||||
<h3>🎵 Player Control</h3>
|
|
||||||
<div class="player-controls">
|
<!-- Status Display -->
|
||||||
<button id="player-play" class="btn btn-primary" onclick="playTrack()">▶️ Play</button>
|
<div id="liquidsoap-status" style="margin-bottom: 20px; padding: 15px; background: #1a1a1a; border-radius: 8px;">
|
||||||
<button id="player-pause" class="btn btn-secondary" onclick="pausePlayer()">⏸️ Pause</button>
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||||||
<button id="player-stop" class="btn btn-secondary" onclick="stopPlayer()">⏹️ Stop</button>
|
<div>
|
||||||
<button id="player-resume" class="btn btn-secondary" onclick="resumePlayer()">▶️ Resume</button>
|
<strong>Uptime:</strong> <span id="ls-uptime">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="player-status" class="status-info">
|
<div>
|
||||||
Status: <span id="player-state">Unknown</span><br>
|
<strong>Remaining:</strong> <span id="ls-remaining">--</span>
|
||||||
Current Track: <span id="current-track">None</span>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<strong>Now Playing:</strong> <span id="ls-metadata">--</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="card">
|
||||||
<h3>👥 User Management</h3>
|
<h3>👥 User Management</h3>
|
||||||
<p>Manage user accounts, roles, and permissions.</p>
|
<p>Manage user accounts, roles, and permissions.</p>
|
||||||
|
|
@ -192,6 +280,102 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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
|
// Admin password reset handler
|
||||||
function resetUserPassword(event) {
|
function resetUserPassword(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,18 @@
|
||||||
|
|
||||||
<span class="now-playing-mini" id="mini-now-playing">Loading...</span>
|
<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">
|
<button onclick="disableFramesetMode()" class="persistent-disable-btn">
|
||||||
✕ Disable
|
✕ Disable
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<script>
|
||||||
// Configure audio element for better streaming
|
// Configure audio element for better streaming
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
@ -134,6 +141,145 @@
|
||||||
// Redirect parent window to regular view
|
// Redirect parent window to regular view
|
||||||
window.parent.location.href = '/asteroid/';
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/asteroid/content" target="content-frame">Home</a>
|
<a href="/asteroid/content" target="_self">Home</a>
|
||||||
<a href="/asteroid/player-content" target="content-frame">Player</a>
|
<a href="/asteroid/player-content" target="_self">Player</a>
|
||||||
<a href="/asteroid/status" target="content-frame">Status</a>
|
<a href="/asteroid/about-content" target="_self">About</a>
|
||||||
<a href="/asteroid/profile" target="content-frame" data-show-if-logged-in>Profile</a>
|
<a href="/asteroid/status-content" target="_self">Status</a>
|
||||||
<a href="/asteroid/admin" target="content-frame" data-show-if-admin>Admin</a>
|
<a href="/asteroid/profile" target="_self" data-show-if-logged-in>Profile</a>
|
||||||
<a href="/asteroid/login" target="content-frame" data-show-if-logged-out>Login</a>
|
<a href="/asteroid/admin" target="_self" data-show-if-admin>Admin</a>
|
||||||
<a href="/asteroid/register" target="content-frame" data-show-if-logged-out>Register</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>
|
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/asteroid/">Home</a>
|
<a href="/asteroid/">Home</a>
|
||||||
<a href="/asteroid/player">Player</a>
|
<a href="/asteroid/player">Player</a>
|
||||||
|
<a href="/asteroid/about">About</a>
|
||||||
<a href="/asteroid/status">Status</a>
|
<a href="/asteroid/status">Status</a>
|
||||||
<a href="/asteroid/profile" data-show-if-logged-in>Profile</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/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>
|
<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;">
|
<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;">
|
<button id="popout-btn" class="btn btn-info" onclick="openPopoutPlayer()" style="font-size: 0.9em;">
|
||||||
🗗 Pop Out Player
|
🗗 Pop Out Player
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -92,11 +96,16 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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;">
|
<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)">
|
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
|
||||||
Your browser does not support the audio element.
|
Your browser does not support the audio element.
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="now-playing" class="now-playing"></div>
|
<div id="now-playing" class="now-playing"></div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
<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/auth-ui.js"></script>
|
||||||
|
<script src="/asteroid/static/js/front-page.js"></script>
|
||||||
<script src="/asteroid/static/js/player.js"></script>
|
<script src="/asteroid/static/js/player.js"></script>
|
||||||
<script src="/api/asteroid/spectrum-analyzer.js"></script>
|
<script src="/api/asteroid/spectrum-analyzer.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.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">
|
<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/auth-ui.js"></script>
|
||||||
|
<script src="/asteroid/static/js/front-page.js"></script>
|
||||||
<script src="/asteroid/static/js/player.js"></script>
|
<script src="/asteroid/static/js/player.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
(dm:field user "password-hash") password-hash
|
(dm:field user "password-hash") password-hash
|
||||||
(dm:field user "role") (string-downcase (symbol-name role))
|
(dm:field user "role") (string-downcase (symbol-name role))
|
||||||
(dm:field user "active") (if active 1 0)
|
(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)
|
(dm:field user "last-login") nil)
|
||||||
(handler-case
|
(handler-case
|
||||||
(db:with-transaction ()
|
(db:with-transaction ()
|
||||||
|
|
@ -69,10 +69,13 @@
|
||||||
(format t "Error during user data access: ~a~%" e)))
|
(format t "Error during user data access: ~a~%" e)))
|
||||||
(when (and (= 1 user-active)
|
(when (and (= 1 user-active)
|
||||||
(verify-password password user-password))
|
(verify-password password user-password))
|
||||||
;; Update last login
|
;; Update last login using data-model (database agnostic)
|
||||||
(setf (dm:field user "last-login") (local-time:timestamp-to-unix (local-time:now)))
|
(handler-case
|
||||||
;; (dm:save user)
|
(progn
|
||||||
(data-model-save user)
|
(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)))))
|
user)))))
|
||||||
|
|
||||||
(defun hash-password (password)
|
(defun hash-password (password)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue