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
|
||||
/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
|
||||
2) [X] icecast is also binding the external interface on b612, which it
|
||||
should not be. HAproxy is there to mediate this flow.
|
||||
3) [ ] We're still on the built in i-lambdalite database
|
||||
3) [X] We're still on the built in i-lambdalite database
|
||||
4) [X] The templates still advertise the default administrator password,
|
||||
which is no bueno.
|
||||
5) [ ] We need to work out the TLS situation with letsencrypt, and
|
||||
integrate it into HAproxy.
|
||||
|
||||
6) [ ] The administrative interface should be beefed up.
|
||||
6.1) [ ] Deactivate users
|
||||
6.2) [ ] Change user access permissions
|
||||
6.1) [X] Deactivate users
|
||||
6.2) [X] Change user access permissions
|
||||
6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c
|
||||
|
||||
7) [ ] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused.
|
||||
7) [X] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused.
|
||||
8) [ ] User profile pages should probably be fleshed out.
|
||||
9) [ ] the stream management features aren't there for Admins or DJs.
|
||||
10) [ ] The "Scan Library" feature is not working in the main branch
|
||||
|
|
@ -48,11 +48,11 @@
|
|||
- [ ] strip hard coded configurations out of the system
|
||||
- [ ] add configuration template file to the project
|
||||
|
||||
** [ ] Database [0/1]
|
||||
** [X] Database [0/1]
|
||||
- [-] PostgresQL [1/3]
|
||||
- [X] Add a postgresql docker image to our docker-compose file.
|
||||
- [ ] Configure radiance for postres.
|
||||
- [ ] Migrate all schema to new database.
|
||||
- [X] Configure radiance for postres.
|
||||
- [X] Migrate all schema to new database.
|
||||
|
||||
|
||||
** [X] Page Flow [2/2] ✅ COMPLETE
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
(:file "user-management")
|
||||
(:file "playlist-management")
|
||||
(:file "stream-control")
|
||||
(:file "listener-stats")
|
||||
(:file "auth-routes")
|
||||
(:file "frontend-partials")
|
||||
(:file "asteroid")))
|
||||
|
|
|
|||
426
asteroid.lisp
426
asteroid.lisp
|
|
@ -86,26 +86,6 @@
|
|||
("message" . "Library scan completed")
|
||||
("tracks-added" . ,tracks-added))))))
|
||||
|
||||
(define-api asteroid/recently-played () ()
|
||||
"Get the last 3 played tracks with MusicBrainz links"
|
||||
(with-error-handling
|
||||
(let ((tracks (get-recently-played)))
|
||||
(api-output `(("status" . "success")
|
||||
("tracks" . ,(mapcar (lambda (track)
|
||||
(let* ((title (getf track :title))
|
||||
(timestamp (getf track :timestamp))
|
||||
(unix-timestamp (universal-time-to-unix timestamp))
|
||||
(parsed (parse-track-title title))
|
||||
(artist (getf parsed :artist))
|
||||
(song (getf parsed :song))
|
||||
(search-url (generate-music-search-url artist song)))
|
||||
`(("title" . ,title)
|
||||
("artist" . ,artist)
|
||||
("song" . ,song)
|
||||
("timestamp" . ,unix-timestamp)
|
||||
("search_url" . ,search-url))))
|
||||
tracks)))))))
|
||||
|
||||
(define-api asteroid/admin/tracks () ()
|
||||
"API endpoint to view all tracks in database"
|
||||
(require-authentication)
|
||||
|
|
@ -133,17 +113,10 @@
|
|||
(playlists (get-user-playlists user-id)))
|
||||
(api-output `(("status" . "success")
|
||||
("playlists" . ,(mapcar (lambda (playlist)
|
||||
(let* ((track-ids (dm:field playlist "track-ids"))
|
||||
;; Calculate track count from comma-separated string
|
||||
;; Handle nil, empty string, or list containing empty string
|
||||
(track-count (if (and track-ids
|
||||
(stringp track-ids)
|
||||
(not (string= track-ids "")))
|
||||
(length (cl-ppcre:split "," track-ids))
|
||||
0)))
|
||||
(let ((track-count (get-playlist-track-count (dm:id playlist))))
|
||||
`(("id" . ,(dm:id playlist))
|
||||
("name" . ,(dm:field playlist "name"))
|
||||
("description" . ,(dm:field playlist "description"))
|
||||
("description" . ,(or (dm:field playlist "description") ""))
|
||||
("track-count" . ,track-count)
|
||||
("created-date" . ,(dm:field playlist "created-date")))))
|
||||
playlists)))))))
|
||||
|
|
@ -177,7 +150,7 @@
|
|||
(let* ((id (parse-integer playlist-id :junk-allowed t))
|
||||
(playlist (get-playlist-by-id id)))
|
||||
(if playlist
|
||||
(let* ((track-ids (dm:field playlist "tracks"))
|
||||
(let* ((track-ids (get-playlist-tracks id))
|
||||
(tracks (mapcar (lambda (track-id)
|
||||
(dm:get-one "tracks" (db:query (:= '_id track-id))))
|
||||
track-ids))
|
||||
|
|
@ -185,6 +158,8 @@
|
|||
(api-output `(("status" . "success")
|
||||
("playlist" . (("id" . ,id)
|
||||
("name" . ,(dm:field playlist "name"))
|
||||
("description" . ,(or (dm:field playlist "description") ""))
|
||||
("track-count" . ,(length valid-tracks))
|
||||
("tracks" . ,(mapcar (lambda (track)
|
||||
`(("id" . ,(dm:id track))
|
||||
("title" . ,(dm:field track "title"))
|
||||
|
|
@ -195,6 +170,31 @@
|
|||
("message" . "Playlist not found"))
|
||||
:status 404)))))
|
||||
|
||||
(define-api asteroid/playlists/delete (playlist-id) ()
|
||||
"Delete a playlist"
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let* ((id (parse-integer playlist-id :junk-allowed t))
|
||||
(user-id (get-current-user-id)))
|
||||
(if (delete-playlist id user-id)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Playlist deleted")))
|
||||
(api-output `(("status" . "error")
|
||||
("message" . "Could not delete playlist (not found or not owned by you)"))
|
||||
:status 403)))))
|
||||
|
||||
(define-api asteroid/playlists/remove-track (playlist-id track-id) ()
|
||||
"Remove a track from a playlist"
|
||||
(require-authentication)
|
||||
(with-error-handling
|
||||
(let ((pl-id (parse-integer playlist-id :junk-allowed t))
|
||||
(tr-id (parse-integer track-id :junk-allowed t)))
|
||||
(if (remove-track-from-playlist pl-id tr-id)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Track removed from playlist")))
|
||||
(api-output `(("status" . "error")
|
||||
("message" . "Could not remove track")))))))
|
||||
|
||||
;; Recently played tracks API endpoint
|
||||
(define-api asteroid/recently-played () ()
|
||||
"Get the last 3 played tracks with AllMusic links"
|
||||
|
|
@ -303,6 +303,246 @@
|
|||
("message" . "Queue loaded from M3U file")
|
||||
("count" . ,count))))))
|
||||
|
||||
;;; Playlist File Management APIs
|
||||
;;; These manage .m3u files in the playlists/ directory
|
||||
;;; stream-queue.m3u lives at PROJECT ROOT (for Docker mount), saved playlists in playlists/
|
||||
|
||||
(defun get-playlists-directory ()
|
||||
"Get the path to the playlists directory (for saved playlists)"
|
||||
(merge-pathnames "playlists/" (asdf:system-source-directory :asteroid)))
|
||||
|
||||
(defun get-stream-queue-path ()
|
||||
"Get the path to stream-queue.m3u (in playlists/ directory for Docker mount)"
|
||||
(merge-pathnames "playlists/stream-queue.m3u" (asdf:system-source-directory :asteroid)))
|
||||
|
||||
(defun list-playlist-files ()
|
||||
"List all .m3u files in the playlists directory, excluding stream-queue.m3u"
|
||||
(let ((playlist-dir (get-playlists-directory)))
|
||||
(when (probe-file playlist-dir)
|
||||
(remove-if (lambda (path)
|
||||
(string= (file-namestring path) "stream-queue.m3u"))
|
||||
(directory (merge-pathnames "*.m3u" playlist-dir))))))
|
||||
|
||||
(defun read-m3u-file-paths (filepath)
|
||||
"Read an m3u file and return list of file paths (excluding comments)"
|
||||
(when (probe-file filepath)
|
||||
(with-open-file (stream filepath :direction :input)
|
||||
(loop for line = (read-line stream nil)
|
||||
while line
|
||||
unless (or (string= line "")
|
||||
(and (> (length line) 0) (char= (char line 0) #\#)))
|
||||
collect (string-trim '(#\Space #\Tab #\Return #\Newline) line)))))
|
||||
|
||||
(defun copy-playlist-to-stream-queue (source-path)
|
||||
"Copy a playlist file to stream-queue.m3u at project root"
|
||||
(let ((dest-path (get-stream-queue-path)))
|
||||
(with-open-file (in source-path :direction :input)
|
||||
(with-open-file (out dest-path :direction :output
|
||||
:if-exists :supersede
|
||||
:if-does-not-exist :create)
|
||||
(loop for line = (read-line in nil)
|
||||
while line
|
||||
do (write-line line out))))
|
||||
t))
|
||||
|
||||
(define-api asteroid/stream/playlists () ()
|
||||
"List available playlist files (excluding stream-queue.m3u)"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((files (list-playlist-files)))
|
||||
(api-output `(("status" . "success")
|
||||
("playlists" . ,(mapcar (lambda (path)
|
||||
(file-namestring path))
|
||||
files)))))))
|
||||
|
||||
(define-api asteroid/stream/playlists/load (name) ()
|
||||
"Load a playlist file into stream-queue.m3u and return its contents"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let* ((playlist-path (merge-pathnames name (get-playlists-directory)))
|
||||
(stream-queue-path (get-stream-queue-path)))
|
||||
(if (probe-file playlist-path)
|
||||
(progn
|
||||
;; Copy playlist to stream-queue.m3u
|
||||
(copy-playlist-to-stream-queue playlist-path)
|
||||
;; Load into in-memory queue
|
||||
(let ((count (load-queue-from-m3u-file)))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . ,(format nil "Loaded playlist: ~a" name))
|
||||
("count" . ,count)
|
||||
("paths" . ,(read-m3u-file-paths stream-queue-path))))))
|
||||
(api-output `(("status" . "error")
|
||||
("message" . "Playlist file not found"))
|
||||
:status 404)))))
|
||||
|
||||
(define-api asteroid/stream/playlists/save () ()
|
||||
"Save current stream queue to stream-queue.m3u"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(regenerate-stream-playlist)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Stream queue saved")))))
|
||||
|
||||
(define-api asteroid/stream/playlists/save-as (name) ()
|
||||
"Save current stream queue to a new playlist file"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let* ((safe-name (if (cl-ppcre:scan "\\.m3u$" name) name (format nil "~a.m3u" name)))
|
||||
(playlist-path (merge-pathnames safe-name (get-playlists-directory))))
|
||||
;; Generate playlist to the new file
|
||||
(generate-m3u-playlist *stream-queue* playlist-path)
|
||||
;; Also save to stream-queue.m3u
|
||||
(regenerate-stream-playlist)
|
||||
(api-output `(("status" . "success")
|
||||
("message" . ,(format nil "Saved as: ~a" safe-name)))))))
|
||||
|
||||
(define-api asteroid/stream/playlists/clear () ()
|
||||
"Clear stream-queue.m3u (Liquidsoap will fall back to random)"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((stream-queue-path (get-stream-queue-path)))
|
||||
;; Write empty m3u file
|
||||
(with-open-file (out stream-queue-path :direction :output
|
||||
:if-exists :supersede
|
||||
:if-does-not-exist :create)
|
||||
(format out "#EXTM3U~%"))
|
||||
;; Clear in-memory queue
|
||||
(setf *stream-queue* '())
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Stream queue cleared - Liquidsoap will use random playback"))))))
|
||||
|
||||
(define-api asteroid/stream/playlists/current () ()
|
||||
"Get current stream-queue.m3u contents with track info"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let* ((stream-queue-path (get-stream-queue-path))
|
||||
(paths (read-m3u-file-paths stream-queue-path))
|
||||
(all-tracks (dm:get "tracks" (db:query :all))))
|
||||
(api-output `(("status" . "success")
|
||||
("count" . ,(length paths))
|
||||
("tracks" . ,(mapcar (lambda (docker-path)
|
||||
(let* ((host-path (convert-from-docker-path docker-path))
|
||||
(track (find-if
|
||||
(lambda (trk)
|
||||
(string= (dm:field trk "file-path") host-path))
|
||||
all-tracks)))
|
||||
(if track
|
||||
`(("id" . ,(dm:id track))
|
||||
("title" . ,(dm:field track "title"))
|
||||
("artist" . ,(dm:field track "artist"))
|
||||
("album" . ,(dm:field track "album"))
|
||||
("path" . ,docker-path))
|
||||
`(("id" . nil)
|
||||
("title" . ,(file-namestring docker-path))
|
||||
("artist" . "Unknown")
|
||||
("album" . "Unknown")
|
||||
("path" . ,docker-path)))))
|
||||
paths)))))))
|
||||
|
||||
;;; Liquidsoap Control APIs
|
||||
;;; Control Liquidsoap via telnet interface on port 1234
|
||||
|
||||
(defun liquidsoap-command (command)
|
||||
"Send a command to Liquidsoap via telnet and return the response"
|
||||
(handler-case
|
||||
(let ((result (uiop:run-program
|
||||
(format nil "echo '~a' | nc -q1 127.0.0.1 1234" command)
|
||||
:output :string
|
||||
:error-output :string
|
||||
:ignore-error-status t)))
|
||||
;; Remove the trailing "END" line
|
||||
(let ((lines (cl-ppcre:split "\\n" result)))
|
||||
(string-trim '(#\Space #\Newline #\Return)
|
||||
(format nil "~{~a~^~%~}"
|
||||
(remove-if (lambda (l) (string= (string-trim '(#\Space #\Return) l) "END"))
|
||||
lines)))))
|
||||
(error (e)
|
||||
(format nil "Error: ~a" e))))
|
||||
|
||||
(defun parse-liquidsoap-metadata (raw-metadata)
|
||||
"Parse Liquidsoap metadata string and extract current track info"
|
||||
(when (and raw-metadata (> (length raw-metadata) 0))
|
||||
;; The metadata contains multiple tracks, separated by --- N ---
|
||||
;; --- 1 --- is the CURRENT track (most recent), at the end of the output
|
||||
;; Split by --- N --- pattern and get the last section
|
||||
(let* ((sections (cl-ppcre:split "---\\s*\\d+\\s*---" raw-metadata))
|
||||
(current-section (car (last sections))))
|
||||
(when current-section
|
||||
(let ((artist (cl-ppcre:register-groups-bind (val)
|
||||
("artist=\"([^\"]+)\"" current-section) val))
|
||||
(title (cl-ppcre:register-groups-bind (val)
|
||||
("title=\"([^\"]+)\"" current-section) val))
|
||||
(album (cl-ppcre:register-groups-bind (val)
|
||||
("album=\"([^\"]+)\"" current-section) val)))
|
||||
(if (or artist title)
|
||||
(format nil "~@[~a~]~@[ - ~a~]~@[ (~a)~]"
|
||||
artist title album)
|
||||
"Unknown"))))))
|
||||
|
||||
(defun format-remaining-time (seconds-str)
|
||||
"Format remaining seconds as MM:SS"
|
||||
(handler-case
|
||||
(let ((seconds (parse-integer (cl-ppcre:regex-replace "\\..*" seconds-str ""))))
|
||||
(format nil "~d:~2,'0d" (floor seconds 60) (mod seconds 60)))
|
||||
(error () seconds-str)))
|
||||
|
||||
(define-api asteroid/liquidsoap/status () ()
|
||||
"Get Liquidsoap status including uptime and current track"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((uptime (liquidsoap-command "uptime"))
|
||||
(metadata-raw (liquidsoap-command "output.icecast.1.metadata"))
|
||||
(remaining-raw (liquidsoap-command "output.icecast.1.remaining")))
|
||||
(api-output `(("status" . "success")
|
||||
("uptime" . ,(string-trim '(#\Space #\Newline #\Return) uptime))
|
||||
("metadata" . ,(parse-liquidsoap-metadata metadata-raw))
|
||||
("remaining" . ,(format-remaining-time
|
||||
(string-trim '(#\Space #\Newline #\Return) remaining-raw))))))))
|
||||
|
||||
(define-api asteroid/liquidsoap/skip () ()
|
||||
"Skip the current track in Liquidsoap"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((result (liquidsoap-command "stream-queue_m3u.skip")))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Track skipped")
|
||||
("result" . ,(string-trim '(#\Space #\Newline #\Return) result)))))))
|
||||
|
||||
(define-api asteroid/liquidsoap/reload () ()
|
||||
"Force Liquidsoap to reload the playlist"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((result (liquidsoap-command "stream-queue_m3u.reload")))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Playlist reloaded")
|
||||
("result" . ,(string-trim '(#\Space #\Newline #\Return) result)))))))
|
||||
|
||||
(define-api asteroid/liquidsoap/restart () ()
|
||||
"Restart the Liquidsoap Docker container"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((result (uiop:run-program
|
||||
"docker restart asteroid-liquidsoap"
|
||||
:output :string
|
||||
:error-output :string
|
||||
:ignore-error-status t)))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Liquidsoap container restarting")
|
||||
("result" . ,result))))))
|
||||
|
||||
(define-api asteroid/icecast/restart () ()
|
||||
"Restart the Icecast Docker container"
|
||||
(require-role :admin)
|
||||
(with-error-handling
|
||||
(let ((result (uiop:run-program
|
||||
"docker restart asteroid-icecast"
|
||||
:output :string
|
||||
:error-output :string
|
||||
:ignore-error-status t)))
|
||||
(api-output `(("status" . "success")
|
||||
("message" . "Icecast container restarting")
|
||||
("result" . ,result))))))
|
||||
|
||||
(defun get-track-by-id (track-id)
|
||||
"Get a track by its ID - handles type mismatches"
|
||||
(dm:get-one "tracks" (db:query (:= '_id track-id))))
|
||||
|
|
@ -318,28 +558,26 @@
|
|||
|
||||
(define-page stream-track #@"/tracks/(.*)/stream" (:uri-groups (track-id))
|
||||
"Stream audio file by track ID"
|
||||
(with-error-handling
|
||||
(let* ((id (parse-integer track-id))
|
||||
(track (get-track-by-id id)))
|
||||
(unless track
|
||||
(signal-not-found "track" id))
|
||||
(let* ((file-path (dm:field track "file-path"))
|
||||
(format (dm:field track "format"))
|
||||
(file (probe-file file-path)))
|
||||
(unless file
|
||||
(error 'not-found-error
|
||||
:message "Audio file not found on disk"
|
||||
:resource-type "file"
|
||||
:resource-id file-path))
|
||||
;; Set appropriate headers for audio streaming
|
||||
(setf (radiance:header "Content-Type") (get-mime-type-for-format format))
|
||||
(setf (radiance:header "Accept-Ranges") "bytes")
|
||||
(setf (radiance:header "Cache-Control") "public, max-age=3600")
|
||||
;; Increment play count
|
||||
(setf (dm:field track "play-count") (1+ (dm:field track "play-count")))
|
||||
(data-model-save track)
|
||||
;; Return file contents
|
||||
(alexandria:read-file-into-byte-vector file)))))
|
||||
(let* ((id (parse-integer track-id :junk-allowed t))
|
||||
(track (when id (get-track-by-id id))))
|
||||
(if (not track)
|
||||
(progn
|
||||
(setf (radiance:header "Content-Type") "text/plain")
|
||||
"Track not found")
|
||||
(let* ((file-path (dm:field track "file-path"))
|
||||
(format (dm:field track "format"))
|
||||
(file (probe-file file-path)))
|
||||
(if (not file)
|
||||
(progn
|
||||
(setf (radiance:header "Content-Type") "text/plain")
|
||||
"Audio file not found")
|
||||
(progn
|
||||
;; Set appropriate headers for audio streaming
|
||||
(setf (radiance:header "Content-Type") (get-mime-type-for-format format))
|
||||
(setf (radiance:header "Accept-Ranges") "bytes")
|
||||
(setf (radiance:header "Cache-Control") "public, max-age=3600")
|
||||
;; Return file contents
|
||||
(alexandria:read-file-into-byte-vector file)))))))
|
||||
|
||||
;; Player state management
|
||||
(defvar *current-track* nil "Currently playing track")
|
||||
|
|
@ -823,11 +1061,16 @@
|
|||
(define-api asteroid/user/listening-stats () ()
|
||||
"Get user listening statistics"
|
||||
(require-authentication)
|
||||
(api-output `(("status" . "success")
|
||||
("stats" . (("total_listen_time" . 0)
|
||||
("tracks_played" . 0)
|
||||
("session_count" . 0)
|
||||
("favorite_genre" . "Unknown"))))))
|
||||
(let* ((current-user (get-current-user))
|
||||
(user-id (when current-user (dm:id current-user)))
|
||||
(stats (if user-id
|
||||
(get-user-listening-stats user-id)
|
||||
(list :total-listen-time 0 :session-count 0 :tracks-played 0))))
|
||||
(api-output `(("status" . "success")
|
||||
("stats" . (("total_listen_time" . ,(getf stats :total-listen-time 0))
|
||||
("tracks_played" . ,(getf stats :tracks-played 0))
|
||||
("session_count" . ,(getf stats :session-count 0))
|
||||
("favorite_genre" . "Unknown")))))))
|
||||
|
||||
(define-api asteroid/user/recent-tracks (&optional (limit "3")) ()
|
||||
"Get recently played tracks for user"
|
||||
|
|
@ -930,6 +1173,27 @@
|
|||
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
|
||||
:default-stream-encoding "audio/aac"))
|
||||
|
||||
;; About page (non-frameset mode)
|
||||
(define-page about-page #@"/about" ()
|
||||
"About Asteroid Radio"
|
||||
(clip:process-to-string
|
||||
(load-template "about")
|
||||
:title "About - Asteroid Radio"))
|
||||
|
||||
;; About content (for frameset mode)
|
||||
(define-page about-content #@"/about-content" ()
|
||||
"About page content (displayed in content frame)"
|
||||
(clip:process-to-string
|
||||
(load-template "about-content")
|
||||
:title "About - Asteroid Radio"))
|
||||
|
||||
;; Status content (for frameset mode)
|
||||
(define-page status-content #@"/status-content" ()
|
||||
"Status page content (displayed in content frame)"
|
||||
(clip:process-to-string
|
||||
(load-template "status-content")
|
||||
:title "Status - Asteroid Radio"))
|
||||
|
||||
(define-api asteroid/status () ()
|
||||
"Get server status"
|
||||
(api-output `(("status" . "running")
|
||||
|
|
@ -979,6 +1243,39 @@
|
|||
`(("error" . "Could not connect to Icecast server"))
|
||||
:status 503)))))
|
||||
|
||||
;;; Listener Statistics API Endpoints
|
||||
|
||||
(define-api asteroid/stats/current () ()
|
||||
"Get current listener count from recent snapshots"
|
||||
(let ((listeners (get-current-listeners)))
|
||||
(api-output `(("status" . "success")
|
||||
("listeners" . ,listeners)
|
||||
("timestamp" . ,(get-universal-time))))))
|
||||
|
||||
(define-api asteroid/stats/daily (&optional (days "30")) ()
|
||||
"Get daily listener statistics (admin only)"
|
||||
(require-role :admin)
|
||||
(let ((stats (get-daily-stats (parse-integer days :junk-allowed t))))
|
||||
(api-output `(("status" . "success")
|
||||
("stats" . ,(mapcar (lambda (row)
|
||||
`(("date" . ,(first row))
|
||||
("mount" . ,(second row))
|
||||
("unique_listeners" . ,(third row))
|
||||
("peak_concurrent" . ,(fourth row))
|
||||
("total_listen_minutes" . ,(fifth row))
|
||||
("avg_session_minutes" . ,(sixth row))))
|
||||
stats))))))
|
||||
|
||||
(define-api asteroid/stats/geo (&optional (days "7")) ()
|
||||
"Get geographic distribution of listeners (admin only)"
|
||||
(require-role :admin)
|
||||
(let ((stats (get-geo-stats (parse-integer days :junk-allowed t))))
|
||||
(api-output `(("status" . "success")
|
||||
("geo" . ,(mapcar (lambda (row)
|
||||
`(("country_code" . ,(first row))
|
||||
("total_listeners" . ,(second row))
|
||||
("total_minutes" . ,(third row))))
|
||||
stats))))))
|
||||
|
||||
;; RADIANCE server management functions
|
||||
|
||||
|
|
@ -991,11 +1288,24 @@
|
|||
;; (unless (radiance:environment)
|
||||
;; (setf (radiance:environment) "asteroid"))
|
||||
|
||||
(radiance:startup))
|
||||
(radiance:startup)
|
||||
|
||||
;; Start listener statistics polling
|
||||
(handler-case
|
||||
(progn
|
||||
(format t "Starting listener statistics polling...~%")
|
||||
(start-stats-polling))
|
||||
(error (e)
|
||||
(format t "Warning: Could not start stats polling: ~a~%" e))))
|
||||
|
||||
(defun stop-server ()
|
||||
"Stop the Asteroid Radio RADIANCE server"
|
||||
(format t "Stopping Asteroid Radio server...~%")
|
||||
;; Stop listener statistics polling
|
||||
(handler-case
|
||||
(stop-stats-polling)
|
||||
(error (e)
|
||||
(format t "Warning: Error stopping stats polling: ~a~%" e)))
|
||||
(radiance:shutdown)
|
||||
(format t "Server stopped.~%"))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
(in-package :asteroid)
|
||||
|
||||
;; Database connection parameters for direct postmodern queries
|
||||
(defun get-db-connection-params ()
|
||||
"Get database connection parameters for postmodern"
|
||||
(list (or (uiop:getenv "ASTEROID_DB_NAME") "asteroid")
|
||||
(or (uiop:getenv "ASTEROID_DB_USER") "asteroid")
|
||||
(or (uiop:getenv "ASTEROID_DB_PASSWORD") "asteroid_db_2025")
|
||||
(or (uiop:getenv "ASTEROID_DB_HOST") "localhost")
|
||||
:port (parse-integer (or (uiop:getenv "ASTEROID_DB_PORT") "5432"))))
|
||||
|
||||
(defmacro with-db (&body body)
|
||||
"Execute body with database connection"
|
||||
`(postmodern:with-connection (get-db-connection-params)
|
||||
,@body))
|
||||
|
||||
;; Database initialization - must be in db:connected trigger because
|
||||
;; the system could load before the database is ready.
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ services:
|
|||
volumes:
|
||||
- ${MUSIC_LIBRARY:-../music/library}:/app/music:ro
|
||||
- ./asteroid-radio-docker.liq:/app/asteroid-radio.liq:ro
|
||||
- ${QUEUE_PLAYLIST:-../stream-queue.m3u}:/app/stream-queue.m3u:ro
|
||||
- ${QUEUE_PLAYLIST:-../playlists/stream-queue.m3u}:/app/stream-queue.m3u:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- asteroid-network
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ CREATE TABLE IF NOT EXISTS "USERS" (
|
|||
"password-hash" TEXT NOT NULL,
|
||||
role VARCHAR(50) DEFAULT 'listener',
|
||||
active integer DEFAULT 1,
|
||||
-- "created-date" integer DEFAULT CURRENT_TIMESTAMP,
|
||||
"created-date" integer,
|
||||
"last-login" integer,
|
||||
"created-date" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
"last-login" TIMESTAMP,
|
||||
CONSTRAINT valid_role CHECK (role IN ('listener', 'dj', 'admin'))
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
(lambda ()
|
||||
(load-tracks)
|
||||
(update-player-status)
|
||||
(setup-event-listeners)
|
||||
(load-stream-queue)
|
||||
(setup-live-stream-monitor)
|
||||
(update-live-stream-info)
|
||||
;; Update live stream info every 10 seconds
|
||||
(set-interval update-live-stream-info 10000)
|
||||
;; Update player status every 5 seconds
|
||||
(set-interval update-player-status 5000))))
|
||||
(load-playlist-list)
|
||||
(load-current-queue)
|
||||
(refresh-liquidsoap-status)
|
||||
;; Update Liquidsoap status every 10 seconds
|
||||
(set-interval refresh-liquidsoap-status 10000))))
|
||||
|
||||
;; Setup all event listeners
|
||||
(defun setup-event-listeners ()
|
||||
|
|
@ -74,21 +71,52 @@
|
|||
|
||||
;; Queue controls
|
||||
(let ((refresh-queue-btn (ps:chain document (get-element-by-id "refresh-queue")))
|
||||
(load-m3u-btn (ps:chain document (get-element-by-id "load-from-m3u")))
|
||||
(clear-queue-btn (ps:chain document (get-element-by-id "clear-queue-btn")))
|
||||
(save-queue-btn (ps:chain document (get-element-by-id "save-queue-btn")))
|
||||
(save-as-btn (ps:chain document (get-element-by-id "save-as-btn")))
|
||||
(add-random-btn (ps:chain document (get-element-by-id "add-random-tracks")))
|
||||
(queue-search-input (ps:chain document (get-element-by-id "queue-track-search"))))
|
||||
(queue-search-input (ps:chain document (get-element-by-id "queue-track-search")))
|
||||
;; Playlist controls
|
||||
(playlist-select (ps:chain document (get-element-by-id "playlist-select")))
|
||||
(load-playlist-btn (ps:chain document (get-element-by-id "load-playlist-btn")))
|
||||
(refresh-playlists-btn (ps:chain document (get-element-by-id "refresh-playlists-btn"))))
|
||||
|
||||
(when refresh-queue-btn
|
||||
(ps:chain refresh-queue-btn (add-event-listener "click" load-stream-queue)))
|
||||
(when load-m3u-btn
|
||||
(ps:chain load-m3u-btn (add-event-listener "click" load-queue-from-m3u)))
|
||||
(ps:chain refresh-queue-btn (add-event-listener "click" load-current-queue)))
|
||||
(when clear-queue-btn
|
||||
(ps:chain clear-queue-btn (add-event-listener "click" clear-stream-queue)))
|
||||
(when save-queue-btn
|
||||
(ps:chain save-queue-btn (add-event-listener "click" save-stream-queue)))
|
||||
(when save-as-btn
|
||||
(ps:chain save-as-btn (add-event-listener "click" save-queue-as-new)))
|
||||
(when add-random-btn
|
||||
(ps:chain add-random-btn (add-event-listener "click" add-random-tracks)))
|
||||
(when queue-search-input
|
||||
(ps:chain queue-search-input (add-event-listener "input" search-tracks-for-queue)))))
|
||||
(ps:chain queue-search-input (add-event-listener "input" search-tracks-for-queue)))
|
||||
;; Playlist controls
|
||||
(when load-playlist-btn
|
||||
(ps:chain load-playlist-btn (add-event-listener "click" load-selected-playlist)))
|
||||
(when refresh-playlists-btn
|
||||
(ps:chain refresh-playlists-btn (add-event-listener "click" load-playlist-list))))
|
||||
|
||||
;; Liquidsoap controls
|
||||
(let ((ls-refresh-btn (ps:chain document (get-element-by-id "ls-refresh-status")))
|
||||
(ls-skip-btn (ps:chain document (get-element-by-id "ls-skip")))
|
||||
(ls-reload-btn (ps:chain document (get-element-by-id "ls-reload")))
|
||||
(ls-restart-btn (ps:chain document (get-element-by-id "ls-restart"))))
|
||||
(when ls-refresh-btn
|
||||
(ps:chain ls-refresh-btn (add-event-listener "click" refresh-liquidsoap-status)))
|
||||
(when ls-skip-btn
|
||||
(ps:chain ls-skip-btn (add-event-listener "click" liquidsoap-skip)))
|
||||
(when ls-reload-btn
|
||||
(ps:chain ls-reload-btn (add-event-listener "click" liquidsoap-reload)))
|
||||
(when ls-restart-btn
|
||||
(ps:chain ls-restart-btn (add-event-listener "click" liquidsoap-restart))))
|
||||
|
||||
;; Icecast restart
|
||||
(let ((icecast-restart-btn (ps:chain document (get-element-by-id "icecast-restart"))))
|
||||
(when icecast-restart-btn
|
||||
(ps:chain icecast-restart-btn (add-event-listener "click" icecast-restart)))))
|
||||
|
||||
;; Load tracks from API
|
||||
(defun load-tracks ()
|
||||
|
|
@ -359,37 +387,6 @@
|
|||
(defun open-incoming-folder ()
|
||||
(alert "Copy your MP3 files to: /home/glenn/Projects/Code/asteroid/music/incoming/\n\nThen click \"Copy Files to Library\" to add them to your music collection."))
|
||||
|
||||
;; Setup live stream monitor
|
||||
(defun setup-live-stream-monitor ()
|
||||
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
|
||||
(when live-audio
|
||||
(setf (ps:@ live-audio preload) "none"))))
|
||||
|
||||
;; Live stream info update
|
||||
(defun update-live-stream-info ()
|
||||
;; Don't update if stream is paused
|
||||
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
|
||||
(when (and live-audio (ps:@ live-audio paused))
|
||||
(return)))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/partial/now-playing-inline")
|
||||
(then (lambda (response)
|
||||
(let ((content-type (ps:chain response headers (get "content-type"))))
|
||||
(unless (ps:chain content-type (includes "text/plain"))
|
||||
(ps:chain console (error "Unexpected content type:" content-type))
|
||||
(return))
|
||||
(ps:chain response (text)))))
|
||||
(then (lambda (now-playing-text)
|
||||
(let ((now-playing-el (ps:chain document (get-element-by-id "live-now-playing"))))
|
||||
(when now-playing-el
|
||||
(setf (ps:@ now-playing-el text-content) now-playing-text)))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Could not fetch stream info:" error))
|
||||
(let ((now-playing-el (ps:chain document (get-element-by-id "live-now-playing"))))
|
||||
(when now-playing-el
|
||||
(setf (ps:@ now-playing-el text-content) "Error loading stream info")))))))
|
||||
|
||||
;; ========================================
|
||||
;; Stream Queue Management
|
||||
;; ========================================
|
||||
|
|
@ -441,44 +438,6 @@
|
|||
(setf html (+ html "</div>"))
|
||||
(setf (ps:@ container inner-h-t-m-l) html))))))
|
||||
|
||||
;; Clear stream queue
|
||||
(defun clear-stream-queue ()
|
||||
(unless (confirm "Clear the entire stream queue? This will stop playback until new tracks are added.")
|
||||
(return))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/queue/clear" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(alert "Queue cleared successfully")
|
||||
(load-stream-queue))
|
||||
(alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error clearing queue:" error))
|
||||
(alert "Error clearing queue")))))
|
||||
|
||||
;; Load queue from M3U file
|
||||
(defun load-queue-from-m3u ()
|
||||
(unless (confirm "Load queue from stream-queue.m3u file? This will replace the current queue.")
|
||||
(return))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/queue/load-m3u" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(alert (+ "Successfully loaded " (ps:@ data count) " tracks from M3U file!"))
|
||||
(load-stream-queue))
|
||||
(alert (+ "Error loading from M3U: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading from M3U:" error))
|
||||
(alert (+ "Error loading from M3U: " (ps:@ error message)))))))
|
||||
|
||||
;; Move track up in queue
|
||||
(defun move-track-up (index)
|
||||
(when (= index 0) (return))
|
||||
|
|
@ -645,6 +604,256 @@
|
|||
(setf html (+ html "</div>"))
|
||||
(setf (ps:@ container inner-h-t-m-l) html))))))
|
||||
|
||||
;; ========================================
|
||||
;; Playlist File Management
|
||||
;; ========================================
|
||||
|
||||
;; Load list of available playlists into dropdown
|
||||
(defun load-playlist-list ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/playlists")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when (= (ps:@ data status) "success")
|
||||
(let ((select (ps:chain document (get-element-by-id "playlist-select")))
|
||||
(playlists (or (ps:@ data playlists) (array))))
|
||||
(when select
|
||||
;; Clear existing options except the first one
|
||||
(setf (ps:@ select inner-h-t-m-l)
|
||||
"<option value=\"\">-- Select a playlist --</option>")
|
||||
;; Add playlist options
|
||||
(ps:chain playlists
|
||||
(for-each (lambda (name)
|
||||
(let ((option (ps:chain document (create-element "option"))))
|
||||
(setf (ps:@ option value) name)
|
||||
(setf (ps:@ option text-content) name)
|
||||
(ps:chain select (append-child option))))))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading playlists:" error))))))
|
||||
|
||||
;; Load selected playlist
|
||||
(defun load-selected-playlist ()
|
||||
(let* ((select (ps:chain document (get-element-by-id "playlist-select")))
|
||||
(name (ps:@ select value)))
|
||||
(when (= name "")
|
||||
(alert "Please select a playlist first")
|
||||
(return))
|
||||
|
||||
(unless (confirm (+ "Load playlist '" name "'? This will replace the current stream queue."))
|
||||
(return))
|
||||
|
||||
(ps:chain
|
||||
(fetch (+ "/api/asteroid/stream/playlists/load?name=" (encode-u-r-i-component name))
|
||||
(ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(show-toast (+ "✓ Loaded " (ps:@ data count) " tracks from " name))
|
||||
(load-current-queue))
|
||||
(alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading playlist:" error))
|
||||
(alert "Error loading playlist"))))))
|
||||
|
||||
;; Load current queue contents (from stream-queue.m3u)
|
||||
(defun load-current-queue ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/playlists/current")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when (= (ps:@ data status) "success")
|
||||
(let ((tracks (or (ps:@ data tracks) (array)))
|
||||
(count (or (ps:@ data count) 0)))
|
||||
;; Update count display
|
||||
(let ((count-el (ps:chain document (get-element-by-id "queue-count"))))
|
||||
(when count-el
|
||||
(setf (ps:@ count-el text-content) count)))
|
||||
;; Display tracks
|
||||
(display-current-queue tracks))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading current queue:" error))
|
||||
(let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
|
||||
(when container
|
||||
(setf (ps:@ container inner-h-t-m-l)
|
||||
"<div class=\"error\">Error loading queue</div>")))))))
|
||||
|
||||
;; Display current queue contents
|
||||
(defun display-current-queue (tracks)
|
||||
(let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
|
||||
(when container
|
||||
(if (= (ps:@ tracks length) 0)
|
||||
(setf (ps:@ container inner-h-t-m-l)
|
||||
"<div class=\"empty-state\">Queue is empty. Liquidsoap will use random playback from the music library.</div>")
|
||||
(let ((html "<div class=\"queue-items\">"))
|
||||
(ps:chain tracks
|
||||
(for-each (lambda (track index)
|
||||
(setf html
|
||||
(+ html
|
||||
"<div class=\"queue-item\" data-index=\"" index "\">"
|
||||
"<span class=\"queue-position\">" (+ index 1) "</span>"
|
||||
"<div class=\"queue-track-info\">"
|
||||
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown") "</div>"
|
||||
"<div class=\"track-artist\">" (or (ps:@ track artist) "Unknown Artist")
|
||||
(if (ps:@ track album) (+ " - " (ps:@ track album)) "") "</div>"
|
||||
"</div>"
|
||||
"</div>")))))
|
||||
(setf html (+ html "</div>"))
|
||||
(setf (ps:@ container inner-h-t-m-l) html))))))
|
||||
|
||||
;; Save current queue to stream-queue.m3u
|
||||
(defun save-stream-queue ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/playlists/save" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(show-toast "✓ Queue saved")
|
||||
(alert (+ "Error saving queue: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error saving queue:" error))
|
||||
(alert "Error saving queue")))))
|
||||
|
||||
;; Save queue as new playlist
|
||||
(defun save-queue-as-new ()
|
||||
(let* ((input (ps:chain document (get-element-by-id "save-as-name")))
|
||||
(name (ps:chain (ps:@ input value) (trim))))
|
||||
(when (= name "")
|
||||
(alert "Please enter a name for the new playlist")
|
||||
(return))
|
||||
|
||||
(ps:chain
|
||||
(fetch (+ "/api/asteroid/stream/playlists/save-as?name=" (encode-u-r-i-component name))
|
||||
(ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(show-toast (+ "✓ Saved as " name))
|
||||
(setf (ps:@ input value) "")
|
||||
(load-playlist-list))
|
||||
(alert (+ "Error saving playlist: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error saving playlist:" error))
|
||||
(alert "Error saving playlist"))))))
|
||||
|
||||
;; Clear stream queue (updated to use new API)
|
||||
(defun clear-stream-queue ()
|
||||
(unless (confirm "Clear the stream queue? Liquidsoap will fall back to random playback from the music library.")
|
||||
(return))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/playlists/clear" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(show-toast "✓ Queue cleared")
|
||||
(load-current-queue))
|
||||
(alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error clearing queue:" error))
|
||||
(alert "Error clearing queue")))))
|
||||
|
||||
;; ========================================
|
||||
;; Liquidsoap Control Functions
|
||||
;; ========================================
|
||||
|
||||
;; Refresh Liquidsoap status
|
||||
(defun refresh-liquidsoap-status ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/liquidsoap/status")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when (= (ps:@ data status) "success")
|
||||
(let ((uptime-el (ps:chain document (get-element-by-id "ls-uptime")))
|
||||
(remaining-el (ps:chain document (get-element-by-id "ls-remaining")))
|
||||
(metadata-el (ps:chain document (get-element-by-id "ls-metadata"))))
|
||||
(when uptime-el
|
||||
(setf (ps:@ uptime-el text-content) (or (ps:@ data uptime) "--")))
|
||||
(when remaining-el
|
||||
(setf (ps:@ remaining-el text-content) (or (ps:@ data remaining) "--")))
|
||||
(when metadata-el
|
||||
(setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--"))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error fetching Liquidsoap status:" error))))))
|
||||
|
||||
;; Skip current track
|
||||
(defun liquidsoap-skip ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/liquidsoap/skip" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(show-toast "⏭️ Track skipped")
|
||||
(set-timeout refresh-liquidsoap-status 1000))
|
||||
(alert (+ "Error skipping track: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error skipping track:" error))
|
||||
(alert "Error skipping track")))))
|
||||
|
||||
;; Reload playlist
|
||||
(defun liquidsoap-reload ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/liquidsoap/reload" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(show-toast "📂 Playlist reloaded")
|
||||
(alert (+ "Error reloading playlist: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error reloading playlist:" error))
|
||||
(alert "Error reloading playlist")))))
|
||||
|
||||
;; Restart Liquidsoap container
|
||||
(defun liquidsoap-restart ()
|
||||
(unless (confirm "Restart Liquidsoap container? This will cause a brief interruption to the stream.")
|
||||
(return))
|
||||
|
||||
(show-toast "🔄 Restarting Liquidsoap...")
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/liquidsoap/restart" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(show-toast "✓ Liquidsoap restarting")
|
||||
;; Refresh status after a delay to let container restart
|
||||
(set-timeout refresh-liquidsoap-status 5000))
|
||||
(alert (+ "Error restarting Liquidsoap: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error restarting Liquidsoap:" error))
|
||||
(alert "Error restarting Liquidsoap")))))
|
||||
|
||||
;; Restart Icecast container
|
||||
(defun icecast-restart ()
|
||||
(unless (confirm "Restart Icecast container? This will disconnect all listeners temporarily.")
|
||||
(return))
|
||||
|
||||
(show-toast "🔄 Restarting Icecast...")
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/icecast/restart" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(show-toast "✓ Icecast restarting - listeners will reconnect automatically")
|
||||
(alert (+ "Error restarting Icecast: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error restarting Icecast:" error))
|
||||
(alert "Error restarting Icecast")))))
|
||||
|
||||
;; Make functions globally accessible for onclick handlers
|
||||
(setf (ps:@ window go-to-page) go-to-page)
|
||||
(setf (ps:@ window previous-page) previous-page)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@
|
|||
(ps:ps*
|
||||
'(progn
|
||||
|
||||
;; Stream connection state
|
||||
(defvar *stream-error-count* 0)
|
||||
(defvar *last-play-attempt* 0)
|
||||
(defvar *is-reconnecting* false)
|
||||
(defvar *reconnect-timeout* nil)
|
||||
|
||||
;; Stream quality configuration
|
||||
(defun get-stream-config (stream-base-url encoding)
|
||||
(let ((config (ps:create
|
||||
|
|
@ -137,6 +143,249 @@
|
|||
(ps:chain local-storage (remove-item "useFrameset"))
|
||||
(setf (ps:@ window location href) "/asteroid/"))
|
||||
|
||||
;; Stream status UI functions
|
||||
(defun show-stream-status (message status-type)
|
||||
"Show a status message to the user. status-type: 'error', 'warning', 'success', 'info'"
|
||||
(let ((indicator (ps:chain document (get-element-by-id "stream-status-indicator"))))
|
||||
(when indicator
|
||||
(setf (ps:@ indicator inner-text) message)
|
||||
(setf (ps:@ indicator style display) "block")
|
||||
(setf (ps:@ indicator style background)
|
||||
(cond
|
||||
((= status-type "error") "#550000")
|
||||
((= status-type "warning") "#554400")
|
||||
((= status-type "success") "#005500")
|
||||
(t "#003355")))
|
||||
(setf (ps:@ indicator style border)
|
||||
(cond
|
||||
((= status-type "error") "1px solid #ff0000")
|
||||
((= status-type "warning") "1px solid #ffaa00")
|
||||
((= status-type "success") "1px solid #00ff00")
|
||||
(t "1px solid #00aaff"))))))
|
||||
|
||||
(defun hide-stream-status ()
|
||||
"Hide the status indicator"
|
||||
(let ((indicator (ps:chain document (get-element-by-id "stream-status-indicator"))))
|
||||
(when indicator
|
||||
(setf (ps:@ indicator style display) "none"))))
|
||||
|
||||
(defun show-reconnect-button ()
|
||||
"Show the reconnect button"
|
||||
(let ((btn (ps:chain document (get-element-by-id "reconnect-btn"))))
|
||||
(when btn
|
||||
(setf (ps:@ btn style display) "inline-block"))))
|
||||
|
||||
(defun hide-reconnect-button ()
|
||||
"Hide the reconnect button"
|
||||
(let ((btn (ps:chain document (get-element-by-id "reconnect-btn"))))
|
||||
(when btn
|
||||
(setf (ps:@ btn style display) "none"))))
|
||||
|
||||
;; Recreate audio element to fix wedged state
|
||||
(defun recreate-audio-element ()
|
||||
"Recreate the audio element entirely to fix wedged MediaElementSource"
|
||||
(let* ((container (ps:chain document (get-element-by-id "audio-container")))
|
||||
(old-audio (ps:chain document (get-element-by-id "live-audio")))
|
||||
(stream-base-url (ps:chain document (get-element-by-id "stream-base-url")))
|
||||
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac"))
|
||||
(config (get-stream-config (ps:@ stream-base-url value) stream-quality)))
|
||||
|
||||
(when (and container old-audio)
|
||||
;; Reset spectrum analyzer before removing audio
|
||||
(when (ps:@ window |resetSpectrumAnalyzer|)
|
||||
(ps:chain window (reset-spectrum-analyzer)))
|
||||
|
||||
;; Remove old audio element
|
||||
(ps:chain old-audio (pause))
|
||||
(setf (ps:@ old-audio src) "")
|
||||
(ps:chain old-audio (remove))
|
||||
|
||||
;; Create new audio element
|
||||
(let ((new-audio (ps:chain document (create-element "audio"))))
|
||||
(setf (ps:@ new-audio id) "live-audio")
|
||||
(setf (ps:@ new-audio controls) t)
|
||||
(setf (ps:@ new-audio crossorigin) "anonymous")
|
||||
(setf (ps:@ new-audio style width) "100%")
|
||||
(setf (ps:@ new-audio style margin) "10px 0")
|
||||
|
||||
;; Create source element
|
||||
(let ((source (ps:chain document (create-element "source"))))
|
||||
(setf (ps:@ source id) "audio-source")
|
||||
(setf (ps:@ source src) (ps:@ config url))
|
||||
(setf (ps:@ source type) (ps:@ config type))
|
||||
(ps:chain new-audio (append-child source)))
|
||||
|
||||
;; Add to container
|
||||
(ps:chain container (append-child new-audio))
|
||||
|
||||
;; Re-attach event listeners
|
||||
(attach-audio-event-listeners new-audio)
|
||||
|
||||
(ps:chain console (log "Audio element recreated"))
|
||||
new-audio))))
|
||||
|
||||
;; Main reconnect function
|
||||
(defun reconnect-stream ()
|
||||
"Reconnect the stream - called by user or automatically"
|
||||
(when *is-reconnecting*
|
||||
(return))
|
||||
|
||||
(setf *is-reconnecting* t)
|
||||
(show-stream-status "🔄 Reconnecting to stream..." "info")
|
||||
(hide-reconnect-button)
|
||||
|
||||
;; Clear any pending reconnect timeout
|
||||
(when *reconnect-timeout*
|
||||
(clear-timeout *reconnect-timeout*)
|
||||
(setf *reconnect-timeout* nil))
|
||||
|
||||
(let ((audio-element (ps:chain document (get-element-by-id "live-audio"))))
|
||||
(if audio-element
|
||||
;; Try simple reload first
|
||||
(progn
|
||||
(ps:chain audio-element (pause))
|
||||
(ps:chain audio-element (load))
|
||||
|
||||
;; Resume AudioContext if suspended
|
||||
(when (ps:@ window |resetSpectrumAnalyzer|)
|
||||
(ps:chain window (reset-spectrum-analyzer)))
|
||||
|
||||
;; Try to play after a short delay
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain audio-element (play)
|
||||
(then (lambda ()
|
||||
(setf *stream-error-count* 0)
|
||||
(setf *is-reconnecting* false)
|
||||
(show-stream-status "✓ Stream reconnected!" "success")
|
||||
(set-timeout hide-stream-status 3000)
|
||||
|
||||
;; Reinitialize spectrum analyzer
|
||||
(when (ps:@ window |initSpectrumAnalyzer|)
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain window (init-spectrum-analyzer)))
|
||||
500))))
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Simple reconnect failed, recreating audio element:" err))
|
||||
;; Simple reload failed, recreate the audio element
|
||||
(let ((new-audio (recreate-audio-element)))
|
||||
(when new-audio
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain new-audio (play)
|
||||
(then (lambda ()
|
||||
(setf *stream-error-count* 0)
|
||||
(setf *is-reconnecting* false)
|
||||
(show-stream-status "✓ Stream reconnected!" "success")
|
||||
(set-timeout hide-stream-status 3000)))
|
||||
(catch (lambda (err2)
|
||||
(setf *is-reconnecting* false)
|
||||
(incf *stream-error-count*)
|
||||
(show-stream-status "❌ Could not reconnect. Click play to try again." "error")
|
||||
(show-reconnect-button)
|
||||
(ps:chain console (log "Reconnect failed:" err2))))))
|
||||
500)))))))
|
||||
500))
|
||||
|
||||
;; No audio element found, try to recreate
|
||||
(let ((new-audio (recreate-audio-element)))
|
||||
(if new-audio
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain new-audio (play)
|
||||
(then (lambda ()
|
||||
(setf *is-reconnecting* false)
|
||||
(show-stream-status "✓ Stream connected!" "success")
|
||||
(set-timeout hide-stream-status 3000)))
|
||||
(catch (lambda (err)
|
||||
(setf *is-reconnecting* false)
|
||||
(show-stream-status "❌ Could not connect. Click play to try again." "error")
|
||||
(show-reconnect-button)))))
|
||||
500)
|
||||
(progn
|
||||
(setf *is-reconnecting* false)
|
||||
(show-stream-status "❌ Could not create audio player. Please reload the page." "error")))))))
|
||||
|
||||
;; Attach event listeners to audio element
|
||||
(defun attach-audio-event-listeners (audio-element)
|
||||
"Attach all necessary event listeners to an audio element"
|
||||
|
||||
;; Error handler
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "error"
|
||||
(lambda (err)
|
||||
(incf *stream-error-count*)
|
||||
(ps:chain console (log "Stream error:" err))
|
||||
|
||||
(if (< *stream-error-count* 3)
|
||||
;; Auto-retry for first few errors
|
||||
(progn
|
||||
(show-stream-status (+ "⚠️ Stream error. Reconnecting... (attempt " *stream-error-count* ")") "warning")
|
||||
(setf *reconnect-timeout*
|
||||
(set-timeout reconnect-stream 3000)))
|
||||
;; Too many errors, show manual reconnect
|
||||
(progn
|
||||
(show-stream-status "❌ Stream connection lost. Click Reconnect to try again." "error")
|
||||
(show-reconnect-button))))))
|
||||
|
||||
;; Stalled handler
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "stalled"
|
||||
(lambda ()
|
||||
(ps:chain console (log "Stream stalled"))
|
||||
(show-stream-status "⚠️ Stream stalled. Attempting to recover..." "warning")
|
||||
(setf *reconnect-timeout*
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
;; Only reconnect if still stalled
|
||||
(when (ps:@ audio-element paused)
|
||||
(reconnect-stream)))
|
||||
5000)))))
|
||||
|
||||
;; Waiting handler (buffering)
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "waiting"
|
||||
(lambda ()
|
||||
(ps:chain console (log "Stream buffering..."))
|
||||
(show-stream-status "⏳ Buffering..." "info"))))
|
||||
|
||||
;; Playing handler - clear any error states
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "playing"
|
||||
(lambda ()
|
||||
(setf *stream-error-count* 0)
|
||||
(hide-stream-status)
|
||||
(hide-reconnect-button)
|
||||
(when *reconnect-timeout*
|
||||
(clear-timeout *reconnect-timeout*)
|
||||
(setf *reconnect-timeout* nil)))))
|
||||
|
||||
;; Pause handler - track when paused for long pause detection
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "pause"
|
||||
(lambda ()
|
||||
(setf *last-play-attempt* (ps:chain |Date| (now))))))
|
||||
|
||||
;; Play handler - detect long pauses that need reconnection
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "play"
|
||||
(lambda ()
|
||||
(let ((pause-duration (- (ps:chain |Date| (now)) *last-play-attempt*)))
|
||||
;; If paused for more than 30 seconds, reconnect to get fresh stream
|
||||
(when (> pause-duration 30000)
|
||||
(ps:chain console (log "Long pause detected, reconnecting for fresh stream..."))
|
||||
(reconnect-stream))))))
|
||||
|
||||
;; Spectrum analyzer hooks
|
||||
(when (ps:@ window |initSpectrumAnalyzer|)
|
||||
(ps:chain audio-element (add-event-listener "play"
|
||||
(lambda () (ps:chain window (init-spectrum-analyzer))))))
|
||||
|
||||
(when (ps:@ window |stopSpectrumAnalyzer|)
|
||||
(ps:chain audio-element (add-event-listener "pause"
|
||||
(lambda () (ps:chain window (stop-spectrum-analyzer)))))))
|
||||
|
||||
(defun redirect-when-frame ()
|
||||
(let* ((path (ps:@ window location pathname))
|
||||
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self))))
|
||||
|
|
@ -164,80 +413,10 @@
|
|||
;; Update now playing
|
||||
(update-now-playing)
|
||||
|
||||
;; Auto-reconnect on stream errors
|
||||
;; Attach event listeners to audio element
|
||||
(let ((audio-element (ps:chain document (get-element-by-id "live-audio"))))
|
||||
(when audio-element
|
||||
(ps:chain audio-element
|
||||
(add-event-listener
|
||||
"error"
|
||||
(lambda (err)
|
||||
(ps:chain console (log "Stream error, attempting reconnect in 3 seconds..." err))
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain audio-element (load))
|
||||
(ps:chain (ps:chain audio-element (play))
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Reconnect failed:" err))))))
|
||||
3000))))
|
||||
|
||||
(ps:chain audio-element
|
||||
(add-event-listener
|
||||
"stalled"
|
||||
(lambda ()
|
||||
(ps:chain console (log "Stream stalled, reloading..."))
|
||||
(ps:chain audio-element (load))
|
||||
(ps:chain (ps:chain audio-element (play))
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Reload failed:" err))))))))
|
||||
|
||||
(let ((pause-timestamp nil)
|
||||
(is-reconnecting false)
|
||||
(needs-reconnect false)
|
||||
(pause-reconnect-threshold 10000))
|
||||
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "pause"
|
||||
(lambda ()
|
||||
(setf pause-timestamp (ps:chain |Date| (now)))
|
||||
(ps:chain console (log "Stream paused at:" pause-timestamp)))))
|
||||
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "play"
|
||||
(lambda ()
|
||||
(when (and (not is-reconnecting)
|
||||
pause-timestamp
|
||||
(> (- (ps:chain |Date| (now)) pause-timestamp) pause-reconnect-threshold))
|
||||
(setf needs-reconnect true)
|
||||
(ps:chain console (log "Long pause detected, will reconnect when playing starts...")))
|
||||
(setf pause-timestamp nil))))
|
||||
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "playing"
|
||||
(lambda ()
|
||||
(when (and needs-reconnect (not is-reconnecting))
|
||||
(setf is-reconnecting true)
|
||||
(setf needs-reconnect false)
|
||||
(ps:chain console (log "Reconnecting stream after long pause to clear stale buffers..."))
|
||||
|
||||
(ps:chain audio-element (pause))
|
||||
|
||||
(when (ps:@ window |resetSpectrumAnalyzer|)
|
||||
(ps:chain window (reset-spectrum-analyzer)))
|
||||
|
||||
(ps:chain audio-element (load))
|
||||
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain audio-element (play)
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Reconnect play failed:" err)))))
|
||||
|
||||
(when (ps:@ window |initSpectrumAnalyzer|)
|
||||
(ps:chain window (init-spectrum-analyzer))
|
||||
(ps:chain console (log "Spectrum analyzer reinitialized after reconnect")))
|
||||
|
||||
(setf is-reconnecting false))
|
||||
200))))))))
|
||||
(attach-audio-event-listeners audio-element)))
|
||||
|
||||
;; Check frameset preference
|
||||
(let ((path (ps:@ window location pathname))
|
||||
|
|
@ -249,8 +428,8 @@
|
|||
|
||||
(redirect-when-frame)))))
|
||||
|
||||
;; Update now playing every 10 seconds
|
||||
(set-interval update-now-playing 10000)
|
||||
;; Update now playing every 5 seconds
|
||||
(set-interval update-now-playing 5000)
|
||||
|
||||
;; Listen for messages from popout window
|
||||
(ps:chain window
|
||||
|
|
|
|||
|
|
@ -93,15 +93,15 @@
|
|||
|
||||
;; Restore user quality preference
|
||||
(let ((selector (ps:chain document (get-element-by-id "live-stream-quality")))
|
||||
(stream-quality (ps:chain (ps:@ local-storage (get-item "stream-quality")) "aac")))
|
||||
(when (and selector (not (== (ps:@ selector value) stream-quality)))
|
||||
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac")))
|
||||
(when (and selector (not (= (ps:@ selector value) stream-quality)))
|
||||
(setf (ps:@ selector value) stream-quality)
|
||||
(ps:chain selector (dispatch-event (new "Event" "change"))))))))
|
||||
(ps:chain selector (dispatch-event (ps:new (-Event "change")))))))))
|
||||
|
||||
;; Frame redirection logic
|
||||
(defun redirect-when-frame ()
|
||||
(let ((path (ps:@ window location pathname))
|
||||
(is-frameset-page (not (== (ps:@ window parent) (ps:@ window self))))
|
||||
(let* ((path (ps:@ window location pathname))
|
||||
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self))))
|
||||
(is-content-frame (ps:chain path (includes "player-content"))))
|
||||
|
||||
(when (and is-frameset-page (not is-content-frame))
|
||||
|
|
@ -133,13 +133,12 @@
|
|||
(add-event-listener "input" update-volume))
|
||||
|
||||
;; Audio player events
|
||||
(when *audio-player*
|
||||
(ps:chain *audio-player*
|
||||
(add-event-listener "loadedmetadata" update-time-display)
|
||||
(add-event-listener "timeupdate" update-time-display)
|
||||
(add-event-listener "ended" handle-track-end)
|
||||
(add-event-listener "play" (lambda () (update-play-button "⏸️ Pause")))
|
||||
(add-event-listener "pause" (lambda () (update-play-button "▶️ Play")))))
|
||||
(when (and *audio-player* (ps:chain *audio-player* add-event-listener))
|
||||
(ps:chain *audio-player* (add-event-listener "loadedmetadata" update-time-display))
|
||||
(ps:chain *audio-player* (add-event-listener "timeupdate" update-time-display))
|
||||
(ps:chain *audio-player* (add-event-listener "ended" handle-track-end))
|
||||
(ps:chain *audio-player* (add-event-listener "play" (lambda () (update-play-button "⏸️ Pause"))))
|
||||
(ps:chain *audio-player* (add-event-listener "pause" (lambda () (update-play-button "▶️ Play")))))
|
||||
|
||||
;; Playlist controls
|
||||
(ps:chain (ps:chain document (get-element-by-id "create-playlist"))
|
||||
|
|
@ -162,17 +161,17 @@
|
|||
(then (lambda (result)
|
||||
;; Handle RADIANCE API wrapper format
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (== (ps:@ data status) "success")
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(setf *tracks* (or (ps:@ data tracks) (array)))
|
||||
(display-tracks *tracks*))
|
||||
(progn
|
||||
(ps:chain console (error "Error loading tracks:" (ps:@ data error)))
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-html)
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-h-t-m-l)
|
||||
"<div class=\"error\">Error loading tracks</div>"))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading tracks:" error))
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-html)
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-h-t-m-l)
|
||||
"<div class=\"error\">Error loading tracks</div>")))))
|
||||
|
||||
;; Display tracks in library
|
||||
|
|
@ -186,15 +185,15 @@
|
|||
(let ((container (ps:chain document (get-element-by-id "track-list")))
|
||||
(pagination-controls (ps:chain document (get-element-by-id "library-pagination-controls"))))
|
||||
|
||||
(if (== (ps:@ *filtered-library-tracks* length) 0)
|
||||
(if (= (ps:@ *filtered-library-tracks* length) 0)
|
||||
(progn
|
||||
(setf (ps:@ container inner-html) "<div class=\"no-tracks\">No tracks found</div>")
|
||||
(setf (ps:@ container inner-h-t-m-l) "<div class=\"no-tracks\">No tracks found</div>")
|
||||
(setf (ps:@ pagination-controls style display) "none")
|
||||
(return)))
|
||||
|
||||
;; Calculate pagination
|
||||
(let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*)))
|
||||
(start-index (* (* *library-current-page* -1) *library-tracks-per-page* *library-tracks-per-page*))
|
||||
(let* ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*)))
|
||||
(start-index (* (- *library-current-page* 1) *library-tracks-per-page*))
|
||||
(end-index (+ start-index *library-tracks-per-page*))
|
||||
(tracks-to-show (ps:chain *filtered-library-tracks* (slice start-index end-index))))
|
||||
|
||||
|
|
@ -203,20 +202,21 @@
|
|||
(map (lambda (track page-index)
|
||||
;; Find the actual index in the full tracks array
|
||||
(let ((actual-index (ps:chain *tracks*
|
||||
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ track id)))))))
|
||||
(find-index (lambda (trk) (= (ps:@ trk id) (ps:@ track id)))))))
|
||||
(+ "<div class=\"track-item\" data-track-id=\"" (ps:@ track id) "\" data-index=\"" actual-index "\">"
|
||||
"<div class=\"track-info\">"
|
||||
"<div class=\"track-title\">" (or (ps:@ track title 0) "Unknown Title") "</div>"
|
||||
"<div class=\"track-meta\">" (or (ps:@ track artist 0) "Unknown Artist") " • " (or (ps:@ track album 0) "Unknown Album") "</div>"
|
||||
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>"
|
||||
"<div class=\"track-meta\">" (or (ps:@ track artist) "Unknown Artist") " • " (or (ps:@ track album) "Unknown Album") "</div>"
|
||||
"</div>"
|
||||
"<div class=\"track-actions\">"
|
||||
"<button onclick=\"playTrack(" actual-index ")\" class=\"btn btn-sm btn-success\">▶️</button>"
|
||||
"<button onclick=\"addToQueue(" actual-index ")\" class=\"btn btn-sm btn-info\">➕</button>"
|
||||
"<button onclick=\"playTrack(" actual-index ")\" class=\"btn btn-sm btn-success\" title=\"Play\">▶️</button>"
|
||||
"<button onclick=\"addToQueue(" actual-index ")\" class=\"btn btn-sm btn-info\" title=\"Add to queue\">➕</button>"
|
||||
"<button onclick=\"showAddToPlaylistMenu(" (ps:@ track id) ", event)\" class=\"btn btn-sm btn-secondary\" title=\"Add to playlist\">📋</button>"
|
||||
"</div>"
|
||||
"</div>"))))
|
||||
(join ""))))
|
||||
|
||||
(setf (ps:@ container inner-html) tracks-html)
|
||||
(setf (ps:@ container inner-h-t-m-l) tracks-html)
|
||||
|
||||
;; Update pagination controls
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "library-page-info")) text-content)
|
||||
|
|
@ -249,7 +249,7 @@
|
|||
|
||||
(defun change-library-tracks-per-page ()
|
||||
(setf *library-tracks-per-page*
|
||||
(parseInt (ps:chain (ps:chain document (get-element-by-id "library-tracks-per-page")) value)))
|
||||
(parse-int (ps:chain (ps:chain document (get-element-by-id "library-tracks-per-page")) value)))
|
||||
(setf *library-current-page* 1)
|
||||
(render-library-page))
|
||||
|
||||
|
|
@ -258,29 +258,29 @@
|
|||
(let ((query (ps:chain (ps:chain document (get-element-by-id "search-tracks")) value (to-lower-case))))
|
||||
(let ((filtered (ps:chain *tracks*
|
||||
(filter (lambda (track)
|
||||
(or (ps:chain (or (ps:@ track title 0) "") (to-lower-case) (includes query))
|
||||
(ps:chain (or (ps:@ track artist 0) "") (to-lower-case) (includes query))
|
||||
(ps:chain (or (ps:@ track album 0) "") (to-lower-case) (includes query))))))))
|
||||
(or (ps:chain (or (ps:@ track title) "") (to-lower-case) (includes query))
|
||||
(ps:chain (or (ps:@ track artist) "") (to-lower-case) (includes query))
|
||||
(ps:chain (or (ps:@ track album) "") (to-lower-case) (includes query))))))))
|
||||
(display-tracks filtered))))
|
||||
|
||||
;; Play a specific track by index
|
||||
(defun play-track (index)
|
||||
(when (and (>= index 0) (< index (ps:@ *tracks* length)))
|
||||
(setf *current-track* (aref *tracks* index))
|
||||
(setf *current-track-index* index)
|
||||
|
||||
;; Load track into audio player
|
||||
(setf (ps:@ *audio-player* src) (+ "/asteroid/tracks/" (ps:@ *current-track* id) "/stream"))
|
||||
(ps:chain *audio-player* (load))
|
||||
(ps:chain *audio-player*
|
||||
(play)
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Playback error:" error))
|
||||
(alert "Error playing track. The track may not be available."))))
|
||||
|
||||
(update-player-display)
|
||||
|
||||
;; Update server-side player state
|
||||
(setf *current-track-index* index)
|
||||
|
||||
;; Load track into audio player
|
||||
(setf (ps:@ *audio-player* src) (+ "/asteroid/tracks/" (ps:@ *current-track* id) "/stream"))
|
||||
(ps:chain *audio-player* (load))
|
||||
(ps:chain *audio-player*
|
||||
(play)
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Playback error:" error))
|
||||
(alert "Error playing track. The track may not be available."))))
|
||||
|
||||
(update-player-display)
|
||||
|
||||
;; Update server-side player state
|
||||
(ps:chain (fetch (+ "/api/asteroid/player/play?track-id=" (ps:@ *current-track* id))
|
||||
(ps:create :method "POST"))
|
||||
(catch (lambda (error)
|
||||
|
|
@ -312,11 +312,11 @@
|
|||
;; Play from queue
|
||||
(let ((next-track (ps:chain *play-queue* (shift))))
|
||||
(play-track (ps:chain *tracks*
|
||||
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ next-track id))))))
|
||||
(find-index (lambda (trk) (= (ps:@ trk id) (ps:@ next-track id))))))
|
||||
(update-queue-display))
|
||||
;; Play next track in library
|
||||
(let ((next-index (if *is-shuffled*
|
||||
(floor (* (random) (ps:@ *tracks* length))))
|
||||
(floor (* (random) (ps:@ *tracks* length)))
|
||||
(mod (+ *current-track-index* 1) (ps:@ *tracks* length)))))
|
||||
(play-track next-index))))
|
||||
|
||||
|
|
@ -344,7 +344,7 @@
|
|||
|
||||
;; Update volume
|
||||
(defun update-volume ()
|
||||
(let ((volume (/ (parseInt (ps:chain (ps:chain document (get-element-by-id "volume-slider")) value)) 100)))
|
||||
(let ((volume (/ (parse-int (ps:chain (ps:chain document (get-element-by-id "volume-slider")) value)) 100)))
|
||||
(when *audio-player*
|
||||
(setf (ps:@ *audio-player* volume) volume))))
|
||||
|
||||
|
|
@ -386,8 +386,8 @@
|
|||
;; Update queue display
|
||||
(defun update-queue-display ()
|
||||
(let ((container (ps:chain document (get-element-by-id "play-queue"))))
|
||||
(if (== (ps:@ *play-queue* length) 0)
|
||||
(setf (ps:@ container inner-html) "<div class=\"empty-queue\">Queue is empty</div>")
|
||||
(if (= (ps:@ *play-queue* length) 0)
|
||||
(setf (ps:@ container inner-h-t-m-l) "<div class=\"empty-queue\">Queue is empty</div>")
|
||||
(let ((queue-html (ps:chain *play-queue*
|
||||
(map (lambda (track index)
|
||||
(+ "<div class=\"queue-item\">"
|
||||
|
|
@ -398,7 +398,7 @@
|
|||
"<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>"
|
||||
"</div>")))
|
||||
(join ""))))
|
||||
(setf (ps:@ container inner-html) queue-html))))
|
||||
(setf (ps:@ container inner-h-t-m-l) queue-html)))))
|
||||
|
||||
;; Remove track from queue
|
||||
(defun remove-from-queue (index)
|
||||
|
|
@ -410,28 +410,113 @@
|
|||
(setf *play-queue* (array))
|
||||
(update-queue-display))
|
||||
|
||||
;; Store playlists for the add-to-playlist menu
|
||||
(defvar *user-playlists* (array))
|
||||
|
||||
;; Show add to playlist dropdown menu
|
||||
(defun show-add-to-playlist-menu (track-id event)
|
||||
(ps:chain event (stop-propagation))
|
||||
;; Remove any existing menu
|
||||
(let ((existing-menu (ps:chain document (get-element-by-id "playlist-dropdown-menu"))))
|
||||
(when existing-menu
|
||||
(ps:chain existing-menu (remove))))
|
||||
|
||||
;; Fetch playlists and show menu
|
||||
(ps:chain (fetch "/api/asteroid/playlists")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let* ((data (or (ps:@ result data) result))
|
||||
(playlists (or (ps:@ data playlists) (array)))
|
||||
(menu (ps:chain document (create-element "div"))))
|
||||
(setf *user-playlists* playlists)
|
||||
(setf (ps:@ menu id) "playlist-dropdown-menu")
|
||||
(setf (ps:@ menu class-name) "playlist-dropdown-menu")
|
||||
(setf (ps:@ menu style position) "fixed")
|
||||
(setf (ps:@ menu style left) (+ (ps:@ event client-x) "px"))
|
||||
(setf (ps:@ menu style top) (+ (ps:@ event client-y) "px"))
|
||||
(setf (ps:@ menu style z-index) "1000")
|
||||
(setf (ps:@ menu style background) "#1a1a2e")
|
||||
(setf (ps:@ menu style border) "1px solid #00ff00")
|
||||
(setf (ps:@ menu style border-radius) "4px")
|
||||
(setf (ps:@ menu style padding) "5px 0")
|
||||
(setf (ps:@ menu style min-width) "150px")
|
||||
|
||||
(if (= (ps:@ playlists length) 0)
|
||||
(setf (ps:@ menu inner-h-t-m-l)
|
||||
"<div style=\"padding: 8px 12px; color: #888;\">No playlists yet</div>")
|
||||
(setf (ps:@ menu inner-h-t-m-l)
|
||||
(ps:chain playlists
|
||||
(map (lambda (playlist)
|
||||
(+ "<div class=\"playlist-menu-item\" onclick=\"addTrackToPlaylist("
|
||||
(ps:@ playlist id) ", " track-id
|
||||
")\" style=\"padding: 8px 12px; cursor: pointer; color: #00ff00;\" "
|
||||
"onmouseover=\"this.style.background='#2a2a4e'\" "
|
||||
"onmouseout=\"this.style.background='transparent'\">"
|
||||
(ps:@ playlist name) " (" (ps:@ playlist "track-count") ")"
|
||||
"</div>")))
|
||||
(join ""))))
|
||||
|
||||
(ps:chain document body (append-child menu))
|
||||
|
||||
;; Close menu when clicking elsewhere
|
||||
(let ((close-handler (lambda (e)
|
||||
(when (not (ps:chain menu (contains (ps:@ e target))))
|
||||
(ps:chain menu (remove))
|
||||
(ps:chain document (remove-event-listener "click" close-handler))))))
|
||||
(set-timeout (lambda ()
|
||||
(ps:chain document (add-event-listener "click" close-handler)))
|
||||
100)))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading playlists for menu:" error))))))
|
||||
|
||||
;; Add track to a specific playlist
|
||||
(defun add-track-to-playlist (playlist-id track-id)
|
||||
;; Close the menu
|
||||
(let ((menu (ps:chain document (get-element-by-id "playlist-dropdown-menu"))))
|
||||
(when menu (ps:chain menu (remove))))
|
||||
|
||||
(let ((form-data (ps:new (-Form-data))))
|
||||
(ps:chain form-data (append "playlist-id" playlist-id))
|
||||
(ps:chain form-data (append "track-id" track-id))
|
||||
(ps:chain (fetch "/api/asteroid/playlists/add-track"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
;; Find playlist name for feedback
|
||||
(let ((playlist (ps:chain *user-playlists*
|
||||
(find (lambda (p) (= (ps:@ p id) playlist-id))))))
|
||||
(alert (+ "Track added to \"" (if playlist (ps:@ playlist name) "playlist") "\"")))
|
||||
(load-playlists))
|
||||
(alert (+ "Error: " (ps:@ data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error adding track to playlist:" error))
|
||||
(alert "Error adding track to playlist"))))))
|
||||
|
||||
;; Create playlist
|
||||
(defun create-playlist ()
|
||||
(let ((name (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value (trim))))
|
||||
(when (not (== name ""))
|
||||
(let ((form-data (new "FormData")))
|
||||
(when (not (= name ""))
|
||||
(let ((form-data (ps:new (-Form-data))))
|
||||
(ps:chain form-data (append "name" name))
|
||||
(ps:chain form-data (append "description" ""))
|
||||
|
||||
(ps:chain (fetch "/api/asteroid/playlists/create"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (response)
|
||||
(ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Handle RADIANCE API wrapper format
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (== (ps:@ data status) "success")
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(alert (+ "Playlist \"" name "\" created successfully!"))
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value) "")
|
||||
|
||||
;; Wait a moment then reload playlists
|
||||
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
|
||||
(then (lambda () (load-playlists)))))
|
||||
(set-timeout load-playlists 500))
|
||||
(alert (+ "Error creating playlist: " (ps:@ data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error creating playlist:" error))
|
||||
|
|
@ -443,7 +528,7 @@
|
|||
(let ((name (prompt "Enter playlist name:")))
|
||||
(when name
|
||||
;; Create the playlist
|
||||
(let ((form-data (new "FormData")))
|
||||
(let ((form-data (ps:new (-Form-data))))
|
||||
(ps:chain form-data (append "name" name))
|
||||
(ps:chain form-data (append "description" (+ "Created from queue with " (ps:@ *play-queue* length) " tracks")))
|
||||
|
||||
|
|
@ -453,54 +538,55 @@
|
|||
(then (lambda (create-result)
|
||||
;; Handle RADIANCE API wrapper format
|
||||
(let ((create-data (or (ps:@ create-result data) create-result)))
|
||||
(if (== (ps:@ create-data status) "success")
|
||||
(if (= (ps:@ create-data status) "success")
|
||||
(progn
|
||||
;; Wait a moment for database to update
|
||||
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
|
||||
(then (lambda ()
|
||||
;; Get the new playlist ID by fetching playlists
|
||||
(ps:chain (fetch "/api/asteroid/playlists")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (playlists-result)
|
||||
;; Handle RADIANCE API wrapper format
|
||||
(let ((playlist-result-data (or (ps:@ playlists-result data) playlists-result)))
|
||||
(if (and (== (ps:@ playlist-result-data status) "success")
|
||||
(> (ps:@ playlist-result-data playlists length) 0))
|
||||
(progn
|
||||
;; Find the playlist with matching name (most recent)
|
||||
(let ((new-playlist (or (ps:chain (ps:@ playlist-result-data playlists)
|
||||
(find (lambda (p) (== (ps:@ p name) name))))
|
||||
(aref (ps:@ playlist-result-data playlists)
|
||||
(- (ps:@ playlist-result-data playlists length) 1)))))
|
||||
|
||||
;; Add all tracks from queue to playlist
|
||||
(let ((added-count 0))
|
||||
(ps:chain *play-queue*
|
||||
(for-each (lambda (track)
|
||||
(let ((track-id (ps:@ track id)))
|
||||
(when track-id
|
||||
(let ((add-form-data (new "FormData")))
|
||||
(ps:chain add-form-data (append "playlist-id" (ps:@ new-playlist id)))
|
||||
(ps:chain add-form-data (append "track-id" track-id))
|
||||
|
||||
(ps:chain (fetch "/api/asteroid/playlists/add-track"
|
||||
(ps:create :method "POST" :body add-form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (add-result)
|
||||
(when (== (ps:@ add-result data status) "success")
|
||||
(setf added-count (+ added-count 1)))))
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Error adding track:" err)))))))))))
|
||||
|
||||
(alert (+ "Playlist \"" name "\" created with " added-count " tracks!"))
|
||||
(load-playlists))))
|
||||
(progn
|
||||
(alert (+ "Playlist created but could not add tracks. Error: "
|
||||
(or (ps:@ playlist-result-data message) "Unknown")))
|
||||
(load-playlists))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error fetching playlists:" error))
|
||||
(alert "Playlist created but could not add tracks"))))))))
|
||||
;; Wait a moment for database to update, then fetch playlists
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
;; Get the new playlist ID by fetching playlists
|
||||
(ps:chain (fetch "/api/asteroid/playlists")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (playlists-result)
|
||||
;; Handle RADIANCE API wrapper format
|
||||
(let ((playlist-result-data (or (ps:@ playlists-result data) playlists-result)))
|
||||
(if (and (= (ps:@ playlist-result-data status) "success")
|
||||
(> (ps:@ playlist-result-data playlists length) 0))
|
||||
(progn
|
||||
;; Find the playlist with matching name (most recent)
|
||||
(let ((new-playlist (or (ps:chain (ps:@ playlist-result-data playlists)
|
||||
(find (lambda (p) (= (ps:@ p name) name))))
|
||||
(aref (ps:@ playlist-result-data playlists)
|
||||
(- (ps:@ playlist-result-data playlists length) 1)))))
|
||||
|
||||
;; Add all tracks from queue to playlist
|
||||
(let ((added-count 0))
|
||||
(ps:chain *play-queue*
|
||||
(for-each (lambda (track)
|
||||
(let ((track-id (ps:@ track id)))
|
||||
(when track-id
|
||||
(let ((add-form-data (ps:new (-Form-data))))
|
||||
(ps:chain add-form-data (append "playlist-id" (ps:@ new-playlist id)))
|
||||
(ps:chain add-form-data (append "track-id" track-id))
|
||||
|
||||
(ps:chain (fetch "/api/asteroid/playlists/add-track"
|
||||
(ps:create :method "POST" :body add-form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (add-result)
|
||||
(when (= (ps:@ add-result data status) "success")
|
||||
(setf added-count (+ added-count 1)))))
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Error adding track:" err)))))))))))
|
||||
|
||||
(alert (+ "Playlist \"" name "\" created with " added-count " tracks!"))
|
||||
(load-playlists))))
|
||||
(progn
|
||||
(alert (+ "Playlist created but could not add tracks. Error: "
|
||||
(or (ps:@ playlist-result-data message) "Unknown")))
|
||||
(load-playlists))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error fetching playlists:" error))
|
||||
(alert "Playlist created but could not add tracks")))))
|
||||
500))
|
||||
(alert (+ "Error creating playlist: " (ps:@ create-data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error saving queue as playlist:" error))
|
||||
|
|
@ -510,16 +596,21 @@
|
|||
;; Load playlists from API
|
||||
(defun load-playlists ()
|
||||
(ps:chain
|
||||
(ps:chain (fetch "/api/asteroid/playlists"))
|
||||
(fetch "/api/asteroid/playlists")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(ps:chain console (log "Playlists API result:" result))
|
||||
(let ((playlists (cond
|
||||
((and (ps:@ result data) (== (ps:@ result data status) "success"))
|
||||
((and (ps:@ result data) (= (ps:@ result data status) "success"))
|
||||
(ps:chain console (log "Found playlists in result.data.playlists"))
|
||||
(or (ps:@ result data playlists) (array)))
|
||||
((== (ps:@ result status) "success")
|
||||
((= (ps:@ result status) "success")
|
||||
(ps:chain console (log "Found playlists in result.playlists"))
|
||||
(or (ps:@ result playlists) (array)))
|
||||
(t
|
||||
(ps:chain console (log "No playlists found in response"))
|
||||
(array)))))
|
||||
(ps:chain console (log "Playlists to display:" playlists))
|
||||
(display-playlists playlists))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading playlists:" error))
|
||||
|
|
@ -529,22 +620,69 @@
|
|||
(defun display-playlists (playlists)
|
||||
(let ((container (ps:chain document (get-element-by-id "playlists-container"))))
|
||||
|
||||
(if (or (not playlists) (== (ps:@ playlists length) 0))
|
||||
(setf (ps:@ container inner-html) "<div class=\"no-playlists\">No playlists created yet.</div>")
|
||||
(if (or (not playlists) (= (ps:@ playlists length) 0))
|
||||
(setf (ps:@ container inner-h-t-m-l) "<div class=\"no-playlists\">No playlists created yet.</div>")
|
||||
(let ((playlists-html (ps:chain playlists
|
||||
(map (lambda (playlist)
|
||||
(+ "<div class=\"playlist-item\">"
|
||||
(+ "<div class=\"playlist-item\" data-playlist-id=\"" (ps:@ playlist id) "\">"
|
||||
"<div class=\"playlist-info\">"
|
||||
"<div class=\"playlist-name\">" (ps:@ playlist name) "</div>"
|
||||
"<div class=\"playlist-meta\">" (ps:@ playlist "track-count") " tracks</div>"
|
||||
"</div>"
|
||||
"<div class=\"playlist-actions\">"
|
||||
"<button onclick=\"loadPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-info\">📂 Load</button>"
|
||||
"<button onclick=\"viewPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-secondary\" title=\"View tracks\">👁️</button>"
|
||||
"<button onclick=\"loadPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-info\" title=\"Load to queue\">📂</button>"
|
||||
"<button onclick=\"deletePlaylist(" (ps:@ playlist id) ", '" (ps:chain (ps:@ playlist name) (replace (ps:regex "/'/g") "\\\\'")) "')\" class=\"btn btn-sm btn-danger\" title=\"Delete playlist\">🗑️</button>"
|
||||
"</div>"
|
||||
"</div>"))
|
||||
(join "")))))
|
||||
"</div>")))
|
||||
(join ""))))
|
||||
|
||||
(setf (ps:@ container inner-html) playlists-html)))))
|
||||
(setf (ps:@ container inner-h-t-m-l) playlists-html)))))
|
||||
|
||||
;; Delete playlist
|
||||
(defun delete-playlist (playlist-id playlist-name)
|
||||
(when (confirm (+ "Are you sure you want to delete playlist \"" playlist-name "\"?"))
|
||||
(let ((form-data (ps:new (-Form-data))))
|
||||
(ps:chain form-data (append "playlist-id" playlist-id))
|
||||
(ps:chain (fetch "/api/asteroid/playlists/delete"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(alert (+ "Playlist \"" playlist-name "\" deleted"))
|
||||
(load-playlists))
|
||||
(alert (+ "Error deleting playlist: " (ps:@ data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error deleting playlist:" error))
|
||||
(alert "Error deleting playlist")))))))
|
||||
|
||||
;; View playlist contents
|
||||
(defun view-playlist (playlist-id)
|
||||
(ps:chain
|
||||
(fetch (+ "/api/asteroid/playlists/get?playlist-id=" playlist-id))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (and (= (ps:@ data status) "success") (ps:@ data playlist))
|
||||
(let* ((playlist (ps:@ data playlist))
|
||||
(tracks (or (ps:@ playlist tracks) (array)))
|
||||
(track-list (if (> (ps:@ tracks length) 0)
|
||||
(ps:chain tracks
|
||||
(map (lambda (track index)
|
||||
(+ (+ index 1) ". "
|
||||
(or (ps:@ track artist) "Unknown") " - "
|
||||
(or (ps:@ track title) "Unknown"))))
|
||||
(join "\\n"))
|
||||
"No tracks in playlist")))
|
||||
(alert (+ "Playlist: " (ps:@ playlist name) "\\n"
|
||||
"Tracks: " (ps:@ playlist "track-count") "\\n\\n"
|
||||
track-list)))
|
||||
(alert "Could not load playlist")))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error viewing playlist:" error))
|
||||
(alert "Error viewing playlist")))))
|
||||
|
||||
;; Load playlist into queue
|
||||
(defun load-playlist (playlist-id)
|
||||
|
|
@ -554,35 +692,38 @@
|
|||
(then (lambda (result)
|
||||
;; Handle RADIANCE API wrapper format
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (and (== (ps:@ data status) "success") (ps:@ data playlist))
|
||||
(if (and (= (ps:@ data status) "success") (ps:@ data playlist))
|
||||
(let ((playlist (ps:@ data playlist)))
|
||||
|
||||
;; Clear current queue
|
||||
(setf *play-queue* (array))
|
||||
|
||||
;; Add all playlist tracks to queue
|
||||
(when (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
|
||||
(ps:chain (ps:@ playlist tracks)
|
||||
(for-each (lambda (track)
|
||||
;; Find the full track object from our tracks array
|
||||
(let ((full-track (ps:chain *tracks*
|
||||
(find (lambda (trk) (== (ps:@ trk id) (ps:@ track id)))))))
|
||||
(when full-track
|
||||
(setf (aref *play-queue* (ps:@ *play-queue* length)) full-track)))))
|
||||
|
||||
(update-queue-display)
|
||||
(alert (+ "Loaded " (ps:@ *play-queue* length) " tracks from \"" (ps:@ playlist name) "\" into queue!"))
|
||||
|
||||
;; Optionally start playing the first track
|
||||
(when (> (ps:@ *play-queue* length) 0)
|
||||
(let ((first-track (ps:chain *play-queue* (shift)))
|
||||
(track-index (ps:chain *tracks*
|
||||
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ first-track id))))))
|
||||
)
|
||||
(when (>= track-index 0)
|
||||
(play-track track-index))))))
|
||||
(when (or (not (ps:@ playlist tracks)) (== (ps:@ playlist tracks length) 0))
|
||||
(alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
|
||||
(if (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
|
||||
(progn
|
||||
(ps:chain (ps:@ playlist tracks)
|
||||
(for-each (lambda (track)
|
||||
;; Find the full track object from our tracks array
|
||||
(let ((full-track (ps:chain *tracks*
|
||||
(find (lambda (trk) (= (ps:@ trk id) (ps:@ track id)))))))
|
||||
(when full-track
|
||||
(setf (aref *play-queue* (ps:@ *play-queue* length)) full-track))))))
|
||||
|
||||
(update-queue-display)
|
||||
(let ((loaded-count (ps:@ *play-queue* length)))
|
||||
(alert (+ "Loaded " loaded-count " tracks from \"" (ps:@ playlist name) "\" into queue!"))
|
||||
|
||||
;; Optionally start playing the first track
|
||||
(when (> loaded-count 0)
|
||||
(let* ((first-track (aref *play-queue* 0))
|
||||
(track-index (ps:chain *tracks*
|
||||
(find-index (lambda (trk) (= (ps:@ trk id) (ps:@ first-track id)))))))
|
||||
;; Remove first track from queue since we're playing it
|
||||
(ps:chain *play-queue* (shift))
|
||||
(update-queue-display)
|
||||
(when (>= track-index 0)
|
||||
(play-track track-index))))))
|
||||
(alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
|
||||
(alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading playlist:" error))
|
||||
|
|
@ -631,23 +772,24 @@
|
|||
;; Update now playing information
|
||||
(defun update-now-playing ()
|
||||
(ps:chain
|
||||
(ps:chain (fetch "/api/asteroid/partial/now-playing"))
|
||||
(fetch "/api/asteroid/partial/now-playing")
|
||||
(then (lambda (response)
|
||||
(let ((content-type (ps:chain response (headers) (get "content-type"))))
|
||||
(let ((content-type (ps:chain response headers (get "content-type"))))
|
||||
(if (ps:chain content-type (includes "text/html"))
|
||||
(ps:chain response (text))
|
||||
(progn
|
||||
(ps:chain console (log "Error connecting to stream"))
|
||||
"")))))
|
||||
(then (lambda (data)
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "now-playing")) inner-html) data)))
|
||||
(setf (ps:chain document (get-element-by-id "now-playing") inner-h-t-m-l) data)))
|
||||
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (log "Could not fetch stream status:" error))))))
|
||||
|
||||
;; Initial update after 1 second
|
||||
(ps:chain (setTimeout update-now-playing 1000))
|
||||
(set-timeout update-now-playing 1000)
|
||||
;; Update live stream info every 10 seconds
|
||||
(ps:chain (set-interval update-now-playing 10000))
|
||||
(set-interval update-now-playing 10000)
|
||||
|
||||
;; Make functions globally accessible for onclick handlers
|
||||
(defvar window (ps:@ window))
|
||||
|
|
@ -659,7 +801,11 @@
|
|||
(setf (ps:@ window library-next-page) library-next-page)
|
||||
(setf (ps:@ window library-go-to-last-page) library-go-to-last-page)
|
||||
(setf (ps:@ window change-library-tracks-per-page) change-library-tracks-per-page)
|
||||
(setf (ps:@ window load-playlist) load-playlist)))
|
||||
(setf (ps:@ window load-playlist) load-playlist)
|
||||
(setf (ps:@ window delete-playlist) delete-playlist)
|
||||
(setf (ps:@ window view-playlist) view-playlist)
|
||||
(setf (ps:@ window show-add-to-playlist-menu) show-add-to-playlist-menu)
|
||||
(setf (ps:@ window add-track-to-playlist) add-track-to-playlist)))
|
||||
"Compiled JavaScript for web player - generated at load time")
|
||||
|
||||
(defun generate-player-js ()
|
||||
|
|
|
|||
|
|
@ -33,9 +33,16 @@
|
|||
(when *animation-id*
|
||||
(cancel-animation-frame *animation-id*)
|
||||
(setf *animation-id* nil))
|
||||
;; Close the old AudioContext if it exists
|
||||
(when *audio-context*
|
||||
(ps:try
|
||||
(ps:chain *audio-context* (close))
|
||||
(:catch (e)
|
||||
(ps:chain console (log "Error closing AudioContext:" e)))))
|
||||
(setf *audio-context* nil)
|
||||
(setf *analyser* nil)
|
||||
(setf *media-source* nil)
|
||||
(setf *current-audio-element* nil)
|
||||
(ps:chain console (log "Spectrum analyzer reset for reconnection")))
|
||||
|
||||
(defun init-spectrum-analyzer ()
|
||||
|
|
|
|||
|
|
@ -14,10 +14,9 @@
|
|||
(setf (dm:field playlist "user-id") user-id)
|
||||
(setf (dm:field playlist "name") name)
|
||||
(setf (dm:field playlist "description") (or description ""))
|
||||
(setf (dm:field playlist "track-ids") "") ; Empty string for text field
|
||||
(setf (dm:field playlist "created-date") (local-time:timestamp-to-unix (local-time:now)))
|
||||
;; Note: track-ids column removed - using playlist_tracks junction table instead
|
||||
;; Let database default handle created-date (CURRENT_TIMESTAMP)
|
||||
(format t "Creating playlist with user-id: ~a (type: ~a)~%" user-id (type-of user-id))
|
||||
(format t "Playlist data: ~a~%" (data-model-as-alist playlist))
|
||||
(dm:insert playlist)
|
||||
t))
|
||||
|
||||
|
|
@ -44,53 +43,72 @@
|
|||
(dm:get-one "playlists" (db:query (:= '_id playlist-id))))
|
||||
|
||||
(defun add-track-to-playlist (playlist-id track-id)
|
||||
"Add a track to a playlist"
|
||||
(db:with-transaction ()
|
||||
(let ((playlist (get-playlist-by-id playlist-id)))
|
||||
(when playlist
|
||||
(let* ((current-track-ids (dm:field playlist "track-ids"))
|
||||
;; Parse comma-separated string into list
|
||||
(tracks-list (if (and current-track-ids
|
||||
(stringp current-track-ids)
|
||||
(not (string= current-track-ids "")))
|
||||
(mapcar #'parse-integer
|
||||
(cl-ppcre:split "," current-track-ids))
|
||||
nil))
|
||||
(new-tracks (append tracks-list (list track-id)))
|
||||
;; Convert back to comma-separated string
|
||||
(track-ids-str (format nil "~{~a~^,~}" new-tracks)))
|
||||
(format t "Adding track ~a to playlist ~a~%" track-id playlist-id)
|
||||
(format t "Current track-ids raw: ~a (type: ~a)~%" current-track-ids-raw (type-of current-track-ids-raw))
|
||||
(format t "Current track-ids: ~a~%" current-track-ids)
|
||||
(format t "Tracks list: ~a~%" tracks-list)
|
||||
(format t "New tracks: ~a~%" new-tracks)
|
||||
(format t "Track IDs string: ~a~%" track-ids-str)
|
||||
;; Update using track-ids field (defined in schema)
|
||||
(setf (dm:field playlist "track-ids") track-ids-str)
|
||||
(data-model-save playlist)
|
||||
(format t "Update complete~%")
|
||||
t)))))
|
||||
"Add a track to a playlist using the playlist_tracks junction table"
|
||||
(format t "Adding track ~a to playlist ~a~%" track-id playlist-id)
|
||||
(handler-case
|
||||
(postmodern:with-connection (get-db-connection-params)
|
||||
;; Get the next position for this playlist
|
||||
(let* ((max-pos-result (postmodern:query
|
||||
(format nil "SELECT COALESCE(MAX(position), 0) FROM playlist_tracks WHERE playlist_id = ~a"
|
||||
playlist-id)
|
||||
:single))
|
||||
(next-position (1+ (or max-pos-result 0))))
|
||||
(postmodern:execute
|
||||
(format nil "INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (~a, ~a, ~a)"
|
||||
playlist-id track-id next-position))
|
||||
(format t "Added track at position ~a~%" next-position)
|
||||
t))
|
||||
(error (e)
|
||||
(format t "Error adding track to playlist: ~a~%" e)
|
||||
nil)))
|
||||
|
||||
(defun remove-track-from-playlist (playlist-id track-id)
|
||||
"Remove a track from a playlist"
|
||||
(let ((playlist (get-playlist-by-id playlist-id)))
|
||||
(when playlist
|
||||
(let* ((current-track-ids (dm:field playlist "track-ids"))
|
||||
;; Parse comma-separated string into list
|
||||
(tracks-list (if (and current-track-ids
|
||||
(stringp current-track-ids)
|
||||
(not (string= current-track-ids "")))
|
||||
(mapcar #'parse-integer
|
||||
(cl-ppcre:split "," current-track-ids))
|
||||
nil))
|
||||
(new-tracks (remove track-id tracks-list :test #'equal))
|
||||
;; Convert back to comma-separated string
|
||||
(track-ids-str (format nil "~{~a~^,~}" new-tracks)))
|
||||
(setf (dm:field playlist "track-ids") track-ids-str)
|
||||
(data-model-save playlist)
|
||||
t))))
|
||||
"Remove a track from a playlist using the playlist_tracks junction table"
|
||||
(format t "Removing track ~a from playlist ~a~%" track-id playlist-id)
|
||||
(handler-case
|
||||
(postmodern:with-connection (get-db-connection-params)
|
||||
(postmodern:execute
|
||||
(format nil "DELETE FROM playlist_tracks WHERE playlist_id = ~a AND track_id = ~a"
|
||||
playlist-id track-id))
|
||||
(format t "Track removed~%")
|
||||
t)
|
||||
(error (e)
|
||||
(format t "Error removing track from playlist: ~a~%" e)
|
||||
nil)))
|
||||
|
||||
(defun delete-playlist (playlist-id)
|
||||
"Delete a playlist"
|
||||
(dm:delete "playlists" (db:query (:= '_id playlist-id)))
|
||||
t)
|
||||
(defun get-playlist-tracks (playlist-id)
|
||||
"Get all track IDs for a playlist from the junction table, ordered by position"
|
||||
(handler-case
|
||||
(postmodern:with-connection (get-db-connection-params)
|
||||
(let ((results (postmodern:query
|
||||
(format nil "SELECT track_id FROM playlist_tracks WHERE playlist_id = ~a ORDER BY position"
|
||||
playlist-id))))
|
||||
(mapcar #'first results)))
|
||||
(error (e)
|
||||
(format t "Error getting playlist tracks: ~a~%" e)
|
||||
nil)))
|
||||
|
||||
(defun get-playlist-track-count (playlist-id)
|
||||
"Get the number of tracks in a playlist"
|
||||
(handler-case
|
||||
(postmodern:with-connection (get-db-connection-params)
|
||||
(let ((result (postmodern:query
|
||||
(format nil "SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = ~a"
|
||||
playlist-id)
|
||||
:single)))
|
||||
(format t "Track count for playlist ~a: ~a (type: ~a)~%" playlist-id result (type-of result))
|
||||
;; Ensure we return an integer
|
||||
(if (integerp result)
|
||||
result
|
||||
(if result (parse-integer (format nil "~a" result) :junk-allowed t) 0))))
|
||||
(error (e)
|
||||
(format t "Error getting playlist track count: ~a~%" e)
|
||||
0)))
|
||||
|
||||
(defun delete-playlist (playlist-id user-id)
|
||||
"Delete a playlist (only if owned by user)"
|
||||
(let ((playlist (get-playlist-by-id playlist-id)))
|
||||
(when (and playlist (equal (dm:field playlist "user-id") user-id))
|
||||
;; Junction table entries will be deleted by CASCADE
|
||||
(dm:delete "playlists" (db:query (:= '_id playlist-id)))
|
||||
t)))
|
||||
|
|
|
|||
|
|
@ -1,163 +1,98 @@
|
|||
#EXTM3U
|
||||
#EXTINF:370,Vector Lovers - City Lights From a Train
|
||||
Vector Lovers/City Lights From a Train.flac
|
||||
#EXTINF:400,The Black Dog - Psil-Cosyin
|
||||
The Black Dog/Psil-Cosyin.flac
|
||||
#EXTINF:320,Plaid - Eyen
|
||||
Plaid/Eyen.flac
|
||||
#EXTINF:330,ISAN - Birds Over Barges
|
||||
ISAN/Birds Over Barges.flac
|
||||
#EXTINF:360,Ochre - Bluebottle Farm
|
||||
Ochre/Bluebottle Farm.flac
|
||||
#EXTINF:390,Arovane - Theme
|
||||
Arovane/Theme.flac
|
||||
#EXTINF:380,Proem - Deep Like Airline Failure
|
||||
Proem/Deep Like Airline Failure.flac
|
||||
#EXTINF:310,Solvent - My Radio (Remix)
|
||||
Solvent/My Radio (Remix).flac
|
||||
#EXTINF:350,Bochum Welt - Marylebone (7th)
|
||||
Bochum Welt/Marylebone (7th).flac
|
||||
#EXTINF:290,Mrs Jynx - Shibuya Lullaby
|
||||
Mrs Jynx/Shibuya Lullaby.flac
|
||||
#EXTINF:340,Kettel - Whisper Me Wishes
|
||||
Kettel/Whisper Me Wishes.flac
|
||||
#EXTINF:360,Christ. - Perlandine Friday
|
||||
Christ./Perlandine Friday.flac
|
||||
#EXTINF:330,Cepia - Ithaca
|
||||
Cepia/Ithaca.flac
|
||||
#EXTINF:340,Datassette - Vacuform
|
||||
Datassette/Vacuform.flac
|
||||
#EXTINF:390,Plant43 - Dreams of the Sentient City
|
||||
Plant43/Dreams of the Sentient City.flac
|
||||
#EXTINF:410,Claro Intelecto - Peace of Mind (Electrosoul)
|
||||
Claro Intelecto/Peace of Mind (Electrosoul).flac
|
||||
#EXTINF:430,E.R.P. - Evoked
|
||||
E.R.P./Evoked.flac
|
||||
#EXTINF:310,Der Zyklus - Formenverwandler
|
||||
Der Zyklus/Formenverwandler.flac
|
||||
#EXTINF:330,Dopplereffekt - Infophysix
|
||||
Dopplereffekt/Infophysix.flac
|
||||
#EXTINF:350,Drexciya - Wavejumper
|
||||
Drexciya/Wavejumper.flac
|
||||
#EXTINF:375,The Other People Place - Sorrow & A Cup of Joe
|
||||
The Other People Place/Sorrow & A Cup of Joe.flac
|
||||
#EXTINF:340,Arpanet - Wireless Internet
|
||||
Arpanet/Wireless Internet.flac
|
||||
#EXTINF:380,Legowelt - Sturmvogel
|
||||
Legowelt/Sturmvogel.flac
|
||||
#EXTINF:310,DMX Krew - Space Paranoia
|
||||
DMX Krew/Space Paranoia.flac
|
||||
#EXTINF:360,Skywave Theory - Nova Drift
|
||||
Skywave Theory/Nova Drift.flac
|
||||
#EXTINF:460,Pye Corner Audio - Transmission Four
|
||||
Pye Corner Audio/Transmission Four.flac
|
||||
#EXTINF:390,B12 - Heaven Sent
|
||||
B12/Heaven Sent.flac
|
||||
#EXTINF:450,Higher Intelligence Agency - Tortoise
|
||||
Higher Intelligence Agency/Tortoise.flac
|
||||
#EXTINF:420,Biosphere - Kobresia
|
||||
Biosphere/Kobresia.flac
|
||||
#EXTINF:870,Global Communication - 14:31
|
||||
Global Communication/14:31.flac
|
||||
#EXTINF:500,Monolake - Cyan
|
||||
Monolake/Cyan.flac
|
||||
#EXTINF:660,Deepchord - Electromagnetic
|
||||
Deepchord/Electromagnetic.flac
|
||||
#EXTINF:1020,GAS - Pop 4
|
||||
GAS/Pop 4.flac
|
||||
#EXTINF:600,Yagya - Rigning Nýju
|
||||
Yagya/Rigning Nýju.flac
|
||||
#EXTINF:990,Voices From The Lake - Velo di Maya
|
||||
Voices From The Lake/Velo di Maya.flac
|
||||
#EXTINF:3720,ASC - Time Heals All
|
||||
ASC/Time Heals All.flac
|
||||
#EXTINF:540,36 - Room 237
|
||||
36/Room 237.flac
|
||||
#EXTINF:900,Loscil - Endless Falls
|
||||
Loscil/Endless Falls.flac
|
||||
#EXTINF:450,Kiasmos - Looped
|
||||
Kiasmos/Looped.flac
|
||||
#EXTINF:590,Underworld - Rez
|
||||
Underworld/Rez.flac
|
||||
#EXTINF:570,Orbital - Halcyon + On + On
|
||||
Orbital/Halcyon + On + On.flac
|
||||
#EXTINF:1080,The Orb - A Huge Ever Growing Pulsating Brain
|
||||
The Orb/A Huge Ever Growing Pulsating Brain.flac
|
||||
#EXTINF:360,Autechre - Slip
|
||||
Autechre/Slip.flac
|
||||
#EXTINF:400,Labradford - S (Mi Media Naranja)
|
||||
Labradford/S (Mi Media Naranja).flac
|
||||
#EXTINF:350,Vector Lovers - Rusting Cars and Wildflowers
|
||||
Vector Lovers/Rusting Cars and Wildflowers.flac
|
||||
#EXTINF:390,The Black Dog - Raxmus
|
||||
The Black Dog/Raxmus.flac
|
||||
#EXTINF:315,Plaid - Hawkmoth
|
||||
Plaid/Hawkmoth.flac
|
||||
#EXTINF:320,ISAN - What This Button Did
|
||||
ISAN/What This Button Did.flac
|
||||
#EXTINF:370,Ochre - Circadies
|
||||
Ochre/Circadies.flac
|
||||
#EXTINF:420,Arovane - Tides
|
||||
Arovane/Tides.flac
|
||||
#EXTINF:370,Proem - Nothing is as It Seems
|
||||
Proem/Nothing is as It Seems.flac
|
||||
#EXTINF:300,Solvent - Loss For Words
|
||||
Solvent/Loss For Words.flac
|
||||
#EXTINF:340,Bochum Welt - Saint (77sunset)
|
||||
Bochum Welt/Saint (77sunset).flac
|
||||
#EXTINF:280,Mrs Jynx - Stay Home
|
||||
Mrs Jynx/Stay Home.flac
|
||||
#EXTINF:330,Kettel - Church
|
||||
Kettel/Church.flac
|
||||
#EXTINF:370,Christ. - Cordate
|
||||
Christ./Cordate.flac
|
||||
#EXTINF:350,Datassette - Computers Elevate
|
||||
Datassette/Computers Elevate.flac
|
||||
#EXTINF:420,Plant43 - The Cold Surveyor
|
||||
Plant43/The Cold Surveyor.flac
|
||||
#EXTINF:380,Claro Intelecto - Section
|
||||
Claro Intelecto/Section.flac
|
||||
#EXTINF:440,E.R.P. - Vox Automaton
|
||||
E.R.P./Vox Automaton.flac
|
||||
#EXTINF:300,Dopplereffekt - Z-Boson
|
||||
Dopplereffekt/Z-Boson.flac
|
||||
#EXTINF:380,Drexciya - Digital Tsunami
|
||||
Drexciya/Digital Tsunami.flac
|
||||
#EXTINF:350,The Other People Place - You Said You Want Me
|
||||
The Other People Place/You Said You Want Me.flac
|
||||
#EXTINF:370,Legowelt - Star Gazing
|
||||
Legowelt/Star Gazing.flac
|
||||
#EXTINF:440,Pye Corner Audio - Electronic Rhythm Number 3
|
||||
Pye Corner Audio/Electronic Rhythm Number 3.flac
|
||||
#EXTINF:460,B12 - Infinite Lites (Classic Mix)
|
||||
B12/Infinite Lites (Classic Mix).flac
|
||||
#EXTINF:390,Biosphere - The Things I Tell You
|
||||
Biosphere/The Things I Tell You.flac
|
||||
#EXTINF:580,Global Communication - 9:39
|
||||
Global Communication/9:39.flac
|
||||
#EXTINF:460,Monolake - T-Channel
|
||||
Monolake/T-Channel.flac
|
||||
#EXTINF:690,Deepchord - Vantage Isle (Variant)
|
||||
Deepchord/Vantage Isle (Variant).flac
|
||||
#EXTINF:840,GAS - Königsforst 5
|
||||
GAS/Königsforst 5.flac
|
||||
#EXTINF:520,Yagya - The Salt on Her Cheeks
|
||||
Yagya/The Salt on Her Cheeks.flac
|
||||
#EXTINF:720,Voices From The Lake - Dream State
|
||||
Voices From The Lake/Dream State.flac
|
||||
#EXTINF:510,36 - Night Rain
|
||||
36/Night Rain.flac
|
||||
#EXTINF:470,Loscil - First Narrows
|
||||
Loscil/First Narrows.flac
|
||||
#EXTINF:400,Kiasmos - Burnt
|
||||
Kiasmos/Burnt.flac
|
||||
#EXTINF:570,Underworld - Jumbo (Extended)
|
||||
Underworld/Jumbo (Extended).flac
|
||||
#EXTINF:480,Orbital - Belfast
|
||||
Orbital/Belfast.flac
|
||||
#EXTINF:540,The Orb - Little Fluffy Clouds (Ambient Mix)
|
||||
The Orb/Little Fluffy Clouds (Ambient Mix).flac
|
||||
#EXTINF:390,Autechre - Nine
|
||||
Autechre/Nine.flac
|
||||
#EXTINF:380,Labradford - G (Mi Media Naranja)
|
||||
Labradford/G (Mi Media Naranja).flac
|
||||
#PLAYLIST:Asteroid Low Orbit - Ambient Electronic Journey
|
||||
#CURATOR:Asteroid Radio
|
||||
|
||||
#EXTINF:-1,Brian Eno - Emerald And Lime
|
||||
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A1 Emerald And Lime.flac
|
||||
#EXTINF:-1,Brian Eno - Complex Heaven
|
||||
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/A2 Complex Heaven.flac
|
||||
#EXTINF:-1,Brian Eno - Dust Shuffle
|
||||
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/B4 Dust Shuffle.flac
|
||||
#EXTINF:-1,Brian Eno - Garden of Stars
|
||||
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/04 Garden of Stars.flac
|
||||
#EXTINF:-1,Brian Eno - There Were Bells
|
||||
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/06 There Were Bells.flac
|
||||
#EXTINF:-1,Biosphere - Drifter
|
||||
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac
|
||||
#EXTINF:-1,Biosphere - Black Mesa
|
||||
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/02. Biosphere - Black Mesa.flac
|
||||
#EXTINF:-1,Biosphere - Skålbrekka
|
||||
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/01 - Skålbrekka.flac
|
||||
#EXTINF:-1,Biosphere - Bjorvika
|
||||
/app/music/Biosphere - The Senja Recordings (2019) [FLAC]/04 - Bjorvika.flac
|
||||
#EXTINF:-1,Biosphere - Out Of The Cradle
|
||||
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/01 - Out Of The Cradle.flac
|
||||
#EXTINF:-1,Biosphere - Down On Ropes
|
||||
/app/music/Biosphere - Departed Glories (2016) - FLAC WEB/03 - Down On Ropes.flac
|
||||
#EXTINF:-1,Biosphere - Microtunneling
|
||||
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 04 Microtunneling.flac
|
||||
#EXTINF:-1,Autechre - Dael
|
||||
/app/music/Autechre/1995 - Tri Repetae/01 Dael.flac
|
||||
#EXTINF:-1,Autechre - Further
|
||||
/app/music/Autechre/1994 - Amber/08 Further.flac
|
||||
#EXTINF:-1,Autechre - north spiral
|
||||
/app/music/Autechre - 2018 - NTS Session 1/NTS Session 1-006-Autechre-north spiral.flac
|
||||
#EXTINF:-1,Four Tet - Alap
|
||||
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/01 Alap.flac
|
||||
#EXTINF:-1,Four Tet - Scientists
|
||||
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/06 Scientists.flac
|
||||
#EXTINF:-1,Four Tet - Green
|
||||
/app/music/Four Tet - Sixteen Oceans (2020) {Text Records - TEXT051} [CD FLAC]/12 - Four Tet - Green.flac
|
||||
#EXTINF:-1,Four Tet - Parallel 8
|
||||
/app/music/Four Tet - Parallel (2020) - WEB FLAC/08. Parallel 8.flac
|
||||
#EXTINF:-1,Clark - Spring But Dark
|
||||
/app/music/Clark - Death Peak (2017) [FLAC]/01 - Spring But Dark.flac
|
||||
#EXTINF:-1,Clark - Kiri's Glee
|
||||
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/04 - Kiri's Glee.flac
|
||||
#EXTINF:-1,Clark - Primary Pluck
|
||||
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/08 - Primary Pluck.flac
|
||||
#EXTINF:-1,Clark - Cannibal Homecoming
|
||||
/app/music/Clark - Kiri Variations (2019) [WEB FLAC]/11 - Cannibal Homecoming.flac
|
||||
#EXTINF:-1,Clark - Absence (Bibio Remix)
|
||||
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.12. Clark - Absence (Bibio Remix).flac
|
||||
#EXTINF:-1,Tycho - Glider
|
||||
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/01 - Glider.flac
|
||||
#EXTINF:-1,Tycho - Source
|
||||
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/07 - Source.flac
|
||||
#EXTINF:-1,Tycho - Rings
|
||||
/app/music/Tycho - Epoch (Deluxe Version) (2019) [WEB FLAC16-44.1]/09 - Rings.flac
|
||||
#EXTINF:-1,Tycho - Into The Woods
|
||||
/app/music/Tycho - Simulcast (2020) [WEB FLAC]/04 - Into The Woods.flac
|
||||
#EXTINF:-1,Tycho - Ascension
|
||||
/app/music/Thievery Corporation and Tycho - Fragments Ascension EP (flac)/3. Tycho - Ascension.flac
|
||||
#EXTINF:-1,Ulrich Schnauss - Negative Sunrise (2019 Version)
|
||||
/app/music/Ulrich Schnauss - No Further Ahead Than Tomorrow (2020) - WEB FLAC/09. Negative Sunrise (2019 Version).flac
|
||||
#EXTINF:-1,Ulrich Schnauss - Like a Ghost in Your Own Life
|
||||
/app/music/Ulrich Schnauss - A Long Way To Fall - Rebound (2020) - WEB FLAC/03. Like a Ghost in Your Own Life.flac
|
||||
#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Solitary Falling
|
||||
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/03. Solitary Falling.flac
|
||||
#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Narkomfin
|
||||
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/05. Narkomfin.flac
|
||||
#EXTINF:-1,Ulrich Schnauss & Jonas Munk - Polychrome
|
||||
/app/music/Ulrich Schnauss & Jonas Munk - Eight Fragments Of An Illusion (2021) - WEB FLAC/08. Polychrome.flac
|
||||
#EXTINF:-1,Proem - Winter Wolves
|
||||
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
|
||||
#EXTINF:-1,Proem - Modern Rope
|
||||
/app/music/Proem - 2018 Modern Rope (WEB)/05. Modern Rope.flac
|
||||
#EXTINF:-1,Proem - Kids That Hate Live Things
|
||||
/app/music/Proem - Until Here for Years (n5md, 2019) flac/11 - Kids That Hate Live Things.flac
|
||||
#EXTINF:-1,Proem - End Tail
|
||||
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/07 - End Tail.flac
|
||||
#EXTINF:-1,Proem - In a Timeless, Lightless World
|
||||
/app/music/Proem/2019 - As They Go/Proem - As They Go - 05 In a Timeless, Lightless World.flac
|
||||
#EXTINF:-1,arovane - komposition no. 1
|
||||
/app/music/arovane - Wirkung (2020) [WEB FLAC16]/17. arovane - komposition no. 1.flac
|
||||
#EXTINF:-1,arovane - hymn
|
||||
/app/music/arovane - Wirkung (2020) [WEB FLAC16]/12. arovane - hymn.flac
|
||||
#EXTINF:-1,Aphex Twin - CHEETAHT7b
|
||||
/app/music/Aphex Twin (2016) Cheetah EP [WEB] [FLAC]/Cheetah EP-002-Aphex Twin-CHEETAHT7b.flac
|
||||
#EXTINF:-1,Aphex Twin - CHEETA2 ms800
|
||||
/app/music/Aphex Twin (2016) Cheetah EP [WEB] [FLAC]/Cheetah EP-004-Aphex Twin-CHEETA2 ms800.flac
|
||||
#EXTINF:-1,Plaid - Sun Electric - Tee (Plaid Mix)
|
||||
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/17-Sun_Electric-Tee_(Plaid_Mix).flac
|
||||
#EXTINF:-1,Plaid - Origamibiro - Impressions Of Football (Plaid Remix)
|
||||
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/11-Origamibiro-Impressions_Of_Football_(Plaid_Remix).flac
|
||||
#EXTINF:-1,Plaid - Ricardo Tobar - After The Movie (Plaid Remix)
|
||||
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/21-Ricardo_Tobar-After_The_Movie_(Plaid_Remix).flac
|
||||
#EXTINF:-1,Plaid - Esem - Yourturn (Plaid Remix)
|
||||
/app/music/Plaid - Stem Sell (Plaid Remixes) [2021] (WEB - FLAC - Lossless)/26-Esem-Yourturn_(Plaid_Remix).flac
|
||||
|
|
|
|||
|
|
@ -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
|
||||
#EXTINF:-1,Underworld - Underworld - Confusion The Waitress
|
||||
/app/music/Underworld/1996 - Second Toughest In The Infants/03. Underworld - Confusion The Waitress.flac
|
||||
#EXTINF:-1,The Orb - Towers Of Dub
|
||||
/app/music/The Orb/1992 - UFOrb/04-Towers Of Dub.mp3
|
||||
#EXTINF:-1,Drexciya - Drexciya - Intensified Magnetron
|
||||
/app/music/Drexciya/2013 - Journey Of The Deep Sea Dweller III/04. Drexciya - Intensified Magnetron.mp3
|
||||
#EXTINF:-1,Labradford - Balanced on It's Own Flame
|
||||
/app/music/Labradford/1995 - A Stable Reference/6 Balanced on It's Own Flame.flac
|
||||
#EXTINF:-1,Vector Lovers - City Lights From A Train
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/01 - City Lights From A Train.mp3
|
||||
#EXTINF:-1,Labradford - Leta O'Steen. Design assistance by John Piper
|
||||
/app/music/Labradford/1999 - E luxo so/6. Leta O'Steen. Design assistance by John Piper.flac
|
||||
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 1 Reel One
|
||||
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/01 Tape Loop Orchestra - Chapter 1 Reel One.mp3
|
||||
#EXTINF:-1,Orbital - Time Becomes
|
||||
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Time Becomes.mp3
|
||||
#EXTINF:-1,Proem - Proem - You Shall Have Ever Been - 05 No You Are $
|
||||
/app/music/Proem/2006 - You Shall Have Ever Been/Proem - You Shall Have Ever Been - 05 No You Are $.flac
|
||||
#EXTINF:-1,Pye Corner Audio - Pye Corner Audio - The Simplest Equation
|
||||
/app/music/Pye Corner Audio/EP's & Singles/2016 - Pye Corner Audio With Dalhous - Run For The Shadows EP (WEB, #LPS13)/02 - Pye Corner Audio - The Simplest Equation.mp3
|
||||
#EXTINF:-1,Brian Eno - Emerald and Lime
|
||||
/app/music/Brian Eno/2024 - Eno (Original Motion Picture Soundtrack)/12. Emerald and Lime.flac
|
||||
#EXTINF:-1,Bark Psychosis - (07) [Bark Psychosis] A Street Scene
|
||||
/app/music/Bark Psychosis/1994 - Game Over/(07) [Bark Psychosis] A Street Scene.flac
|
||||
#EXTINF:-1,Model 500 - model_500-digital_solutions
|
||||
/app/music/Model 500/2015 - Digital Solutions/08-model_500-digital_solutions.flac
|
||||
#EXTINF:-1,Labradford - Banco
|
||||
/app/music/Labradford/1995 - A Stable Reference/4 Banco.flac
|
||||
#EXTINF:-1,Labradford - Skyward With Motion
|
||||
/app/music/Labradford/1993 - Prazision LP/11 Skyward With Motion.flac
|
||||
#EXTINF:-1,Pye Corner Audio - The Mirror Ball Cracked
|
||||
/app/music/Pye Corner Audio/2012 - Sleep Games (WEB, #GBX017)/08 - The Mirror Ball Cracked.mp3
|
||||
#EXTINF:-1,Brian Eno - Foreign Affairs
|
||||
/app/music/Brian Eno/1978 - After The Heat/01 - Foreign Affairs.flac
|
||||
#EXTINF:-1,The Other People Place - B1 - Moonlight Rendezvous
|
||||
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/B1 - Moonlight Rendezvous.flac
|
||||
#EXTINF:-1,Drexciya - Unknown Journey IX
|
||||
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/10. Unknown Journey IX.mp3
|
||||
#EXTINF:-1,Orbital - Crash And Carry
|
||||
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/04. Crash And Carry.mp3
|
||||
#EXTINF:-1,Proem - Proem - Before it finds you - 09 We can watch it burn to the ground
|
||||
/app/music/Proem/2013 - Before it finds you/Proem - Before it finds you - 09 We can watch it burn to the ground.flac
|
||||
#EXTINF:-1,Proem - Proem - Before it finds you - 01 Stone into gravel
|
||||
/app/music/Proem/2013 - Before it finds you/Proem - Before it finds you - 01 Stone into gravel.flac
|
||||
#EXTINF:-1,Drexciya - Intro (The Unknown Aquazone)
|
||||
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/01. Intro (The Unknown Aquazone).mp3
|
||||
#EXTINF:-1,Teeth Of The Sea - Get With the Program
|
||||
/app/music/Teeth Of The Sea/2023 - Hive/02 Get With the Program.flac
|
||||
#EXTINF:-1,Proem - Proem - Vault ep.4-4 - 02 Little girls
|
||||
/app/music/Proem/2015 - Vault ep.4-4/Proem - Vault ep.4-4 - 02 Little girls.flac
|
||||
#EXTINF:-1,Drexciya - Black Sea
|
||||
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/14. Black Sea.mp3
|
||||
#EXTINF:-1,Autechre - Yulquen
|
||||
/app/music/Autechre/1994 - Amber/09 Yulquen.flac
|
||||
#EXTINF:-1,The Other People Place - C2 - Running From Love
|
||||
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/C2 - Running From Love.flac
|
||||
#EXTINF:-1,Brian Eno - D2 Written, Forgotten
|
||||
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/D2 Written, Forgotten.flac
|
||||
#EXTINF:-1,Autechre - Stud
|
||||
/app/music/Autechre/1995 - Tri Repetae/05 Stud.flac
|
||||
#EXTINF:-1,Model 500 - model_500-electric_night
|
||||
/app/music/Model 500/2015 - Digital Solutions/02-model_500-electric_night.flac
|
||||
#EXTINF:-1,The Orb - Close Encounters
|
||||
/app/music/The Orb/1992 - UFOrb/05-Close Encounters.mp3
|
||||
#EXTINF:-1,Model 500 - model_500-hi_nrg
|
||||
/app/music/Model 500/2015 - Digital Solutions/01-model_500-hi_nrg.flac
|
||||
#EXTINF:-1,Brian Eno - B3 Bone Jump
|
||||
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/B3 Bone Jump.flac
|
||||
#EXTINF:-1,Labradford - by Chris Johnston, Craig Markva, Jamie Evans,
|
||||
/app/music/Labradford/1999 - E luxo so/4. by Chris Johnston, Craig Markva, Jamie Evans,.flac
|
||||
#EXTINF:-1,The Orb - Star 6 & 7 8 9
|
||||
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/09 Star 6 & 7 8 9.mp3
|
||||
#EXTINF:-1,Proem - Proem - Vault ep.1-4 (Noise) - 02 Half a Heart
|
||||
/app/music/Proem/2016 - Vault ep.1-4 (Noise)/Proem - Vault ep.1-4 (Noise) - 02 Half a Heart.flac
|
||||
#EXTINF:-1,Dopplereffekt - Spirangle
|
||||
/app/music/Dopplereffekt/2017 - Cellular Automata/08. Spirangle.flac
|
||||
#EXTINF:-1,Drexciya - Unknown Journey VII
|
||||
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/06. Unknown Journey VII.mp3
|
||||
#EXTINF:-1,Drexciya - Mantaray
|
||||
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/04. Mantaray.mp3
|
||||
#EXTINF:-1,Pye Corner Audio - Pye Corner Audio - Untitled
|
||||
/app/music/Pye Corner Audio/EP's & Singles/2017 - Pye Corner Audio, Silent Servant, Not Waving - Limited Edition EP (Vinyl, #E031COL)/02 - Pye Corner Audio - Untitled.mp3
|
||||
#EXTINF:-1,Underworld - Underworld - Juanita, Kiteless, To Dream Of Love
|
||||
/app/music/Underworld/1996 - Second Toughest In The Infants/01. Underworld - Juanita, Kiteless, To Dream Of Love.flac
|
||||
#EXTINF:-1,Proem - Proem - As They Go - 05 In a Timeless, Lightless World
|
||||
/app/music/Proem/2019 - As They Go/Proem - As They Go - 05 In a Timeless, Lightless World.flac
|
||||
#EXTINF:-1,Model 500 - model_500-standing_in_tomorow
|
||||
/app/music/Model 500/2015 - Digital Solutions/03-model_500-standing_in_tomorow.flac
|
||||
#EXTINF:-1,The Orb - Plum Island
|
||||
/app/music/The Orb/2001 - Cydonia/09-Plum Island.mp3
|
||||
#EXTINF:-1,Orbital - Lush 3-2
|
||||
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Lush 3-2.mp3
|
||||
#EXTINF:-1,Dopplereffekt - Exponential Decay
|
||||
/app/music/Dopplereffekt/2017 - Cellular Automata/09. Exponential Decay.flac
|
||||
#EXTINF:-1,Brian Eno - Garden of Stars
|
||||
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/04 Garden of Stars.flac
|
||||
#EXTINF:-1,The Other People Place - B2 - You Said You Want Me
|
||||
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/B2 - You Said You Want Me.flac
|
||||
#EXTINF:-1,Dopplereffekt - Mandelbrot Set
|
||||
/app/music/Dopplereffekt/2017 - Cellular Automata/07. Mandelbrot Set.flac
|
||||
#EXTINF:-1,Autechre - Foil
|
||||
/app/music/Autechre/1994 - Amber/01 Foil.flac
|
||||
#EXTINF:-1,Proem - Proem - As They Go - 04 What is Needed
|
||||
/app/music/Proem/2019 - As They Go/Proem - As They Go - 04 What is Needed.flac
|
||||
#EXTINF:-1,Vector Lovers - Post Arctic Industries
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/06 - Post Arctic Industries.mp3
|
||||
#EXTINF:-1,Proem - proem - Negativ - 12 Skylup
|
||||
/app/music/Proem/2001 - Negativ/proem - Negativ - 12 Skylup.flac
|
||||
#EXTINF:-1,Model 500 - model_500-encounter
|
||||
/app/music/Model 500/2015 - Digital Solutions/04-model_500-encounter.flac
|
||||
#EXTINF:-1,Kraftwerk - Pocket Calculator
|
||||
/app/music/Kraftwerk/1981 - Computer World/02 - Pocket Calculator.flac
|
||||
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 13 Reel Two End
|
||||
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/13 Tape Loop Orchestra - Chapter 13 Reel Two End.mp3
|
||||
#EXTINF:-1,Pye Corner Audio - Corrupt Data
|
||||
/app/music/Pye Corner Audio/2017 - Half-Light (Prower Remixed) (WEB, #MTH011)/01 - Corrupt Data.mp3
|
||||
#EXTINF:-1,Kiasmos - Kiasmos - II - 04 Laced
|
||||
/app/music/Kiasmos/2024 - II/Kiasmos - II - 04 Laced.flac
|
||||
#EXTINF:-1,Pye Corner Audio - Mindshaft
|
||||
/app/music/Pye Corner Audio/2019 - Hollow Earth (WEB, #GBX032 DL)/05 - Mindshaft.mp3
|
||||
#EXTINF:-1,Labradford - G
|
||||
/app/music/Labradford/1997 - Mi Media Naranja/2 G.flac
|
||||
#EXTINF:-1,Dopplereffekt - Isotropy
|
||||
/app/music/Dopplereffekt/2017 - Cellular Automata/04. Isotropy.flac
|
||||
#EXTINF:-1,Autechre - Further
|
||||
/app/music/Autechre/1994 - Amber/08 Further.flac
|
||||
#EXTINF:-1,Proem - Proem - You Shall Have Ever Been - 02 Eck The Badly Drawn
|
||||
/app/music/Proem/2006 - You Shall Have Ever Been/Proem - You Shall Have Ever Been - 02 Eck The Badly Drawn.flac
|
||||
#EXTINF:-1,Autechre - C-Pach
|
||||
/app/music/Autechre/1995 - Tri Repetae/07 C-Pach.flac
|
||||
#EXTINF:-1,Kraftwerk - Neon Lights
|
||||
/app/music/Kraftwerk/1978 - The Man-Machine/05 - Neon Lights.flac
|
||||
#EXTINF:-1,Labradford - twenty
|
||||
/app/music/Labradford/2001 - fixed..context/1 twenty.flac
|
||||
#EXTINF:-1,Bark Psychosis - (01) [Bark Psychosis] Blue
|
||||
/app/music/Bark Psychosis/1994 - Game Over/(01) [Bark Psychosis] Blue.flac
|
||||
#EXTINF:-1,Vector Lovers - Substrata
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/03 - Substrata.mp3
|
||||
#EXTINF:-1,Kraftwerk - Computer World
|
||||
/app/music/Kraftwerk/1981 - Computer World/01 - Computer World.flac
|
||||
#EXTINF:-1,Underworld - Underworld - Air Towel
|
||||
/app/music/Underworld/1996 - Second Toughest In The Infants/06. Underworld - Air Towel.flac
|
||||
#EXTINF:-1,Underworld - Underworld - Blueski
|
||||
/app/music/Underworld/1996 - Second Toughest In The Infants/07. Underworld - Blueski.flac
|
||||
#EXTINF:-1,Labradford - Experience The Gated Oscillator
|
||||
/app/music/Labradford/1993 - Prazision LP/05 Experience The Gated Oscillator.flac
|
||||
#EXTINF:-1,Dopplereffekt - Gestalt Intelligence
|
||||
/app/music/Dopplereffekt/2017 - Cellular Automata/03. Gestalt Intelligence.flac
|
||||
#EXTINF:-1,Labradford - Dulcimers played by Peter Neff. Strings played
|
||||
/app/music/Labradford/1999 - E luxo so/3. Dulcimers played by Peter Neff. Strings played.flac
|
||||
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 14 Tails Out
|
||||
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/14 Tape Loop Orchestra - Chapter 14 Tails Out.mp3
|
||||
#EXTINF:-1,Labradford - up to pizmo
|
||||
/app/music/Labradford/2001 - fixed..context/2 up to pizmo.flac
|
||||
#EXTINF:-1,Orbital - Sad But True
|
||||
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/03. Sad But True.mp3
|
||||
#EXTINF:-1,Orbital - Lush 3-1
|
||||
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Lush 3-1.mp3
|
||||
#EXTINF:-1,Dopplereffekt - Cellular Automata
|
||||
/app/music/Dopplereffekt/2017 - Cellular Automata/01. Cellular Automata.flac
|
||||
#EXTINF:-1,The Orb - Little Fluffy Clouds
|
||||
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/01 Little Fluffy Clouds.mp3
|
||||
#EXTINF:-1,Bark Psychosis - All Different Things
|
||||
/app/music/Bark Psychosis/1994 - Independency/03 - All Different Things.flac
|
||||
#EXTINF:-1,Orbital - Kein Trink Wasser
|
||||
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/07. Kein Trink Wasser.mp3
|
||||
#EXTINF:-1,The Orb - Perpetual Dawn
|
||||
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/06 Perpetual Dawn.mp3
|
||||
#EXTINF:-1,Underworld - Underworld - Stagger
|
||||
/app/music/Underworld/1996 - Second Toughest In The Infants/08. Underworld - Stagger.flac
|
||||
#EXTINF:-1,Dopplereffekt - von Neumann Probe
|
||||
/app/music/Dopplereffekt/2017 - Cellular Automata/02. von Neumann Probe.flac
|
||||
#EXTINF:-1,The Orb - EDM
|
||||
/app/music/The Orb/2001 - Cydonia/12-EDM.mp3
|
||||
#EXTINF:-1,Teeth Of The Sea - Reaper
|
||||
/app/music/Teeth Of The Sea/2013 - Master/02 - Reaper.mp3
|
||||
#EXTINF:-1,Drexciya - Drexciya - Aquabahn
|
||||
/app/music/Drexciya/2013 - Journey Of The Deep Sea Dweller III/03. Drexciya - Aquabahn.mp3
|
||||
#EXTINF:-1,Autechre - Rsdio
|
||||
/app/music/Autechre/1995 - Tri Repetae/10 Rsdio.flac
|
||||
#EXTINF:-1,Teeth Of The Sea - Transfinite
|
||||
/app/music/Teeth Of The Sea/2010 - Your Mercury/01 Transfinite.mp3
|
||||
#EXTINF:-1,Orbital - Philosophy By Numbers
|
||||
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/06. Philosophy By Numbers.mp3
|
||||
#EXTINF:-1,Autechre - Dael
|
||||
/app/music/Autechre/1995 - Tri Repetae/01 Dael.flac
|
||||
#EXTINF:-1,Vector Lovers - Boulevard
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/09 - Boulevard.mp3
|
||||
#EXTINF:-1,Brian Eno - Reflection
|
||||
/app/music/Brian Eno/2017 - Reflection/01. Reflection.mp3
|
||||
#EXTINF:-1,Kiasmos - Thrown
|
||||
/app/music/Kiasmos/2012 - Thrown EP/01 - Thrown.flac
|
||||
#EXTINF:-1,The Other People Place - A2 - It's Your Love
|
||||
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/A2 - It's Your Love.flac
|
||||
#EXTINF:-1,Drexciya - Unknown Journey VIII
|
||||
/app/music/Drexciya/2013 - Journey of the Deep Sea Dweller IV/07. Unknown Journey VIII.mp3
|
||||
#EXTINF:-1,Bark Psychosis - I Know
|
||||
/app/music/Bark Psychosis/1994 - Independency/01 - I Know.flac
|
||||
#EXTINF:-1,Underworld - Underworld - Rowla
|
||||
/app/music/Underworld/1996 - Second Toughest In The Infants/04. Underworld - Rowla.flac
|
||||
#EXTINF:-1,Orbital - Forever
|
||||
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/01. Forever.mp3
|
||||
#EXTINF:-1,Autechre - Clipper
|
||||
/app/music/Autechre/1995 - Tri Repetae/02 Clipper.flac
|
||||
#EXTINF:-1,The Other People Place - C1 - Let Me Be Me
|
||||
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/C1 - Let Me Be Me.flac
|
||||
#EXTINF:-1,Kraftwerk - Metropolis
|
||||
/app/music/Kraftwerk/1978 - The Man-Machine/03 - Metropolis.flac
|
||||
#EXTINF:-1,Labradford - The Cipher
|
||||
/app/music/Labradford/1996 - Labradford/4 The Cipher.flac
|
||||
#EXTINF:-1,Autechre - Silverside
|
||||
/app/music/Autechre/1994 - Amber/03 Silverside.flac
|
||||
#EXTINF:-1,Autechre - Nine
|
||||
/app/music/Autechre/1994 - Amber/07 Nine.flac
|
||||
#EXTINF:-1,Kraftwerk - Numbers
|
||||
/app/music/Kraftwerk/1981 - Computer World/03 - Numbers.flac
|
||||
#EXTINF:-1,Pye Corner Audio - Recrypt
|
||||
/app/music/Pye Corner Audio/2011 - Black Mill Tapes Volume 2 - Do You Synthesize (WEB, #pca002)/06 - Recrypt.mp3
|
||||
#EXTINF:-1,The Other People Place - D1 - Lifestyles Of The Casual
|
||||
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/D1 - Lifestyles Of The Casual.flac
|
||||
#EXTINF:-1,Autechre - Teartear
|
||||
/app/music/Autechre/1994 - Amber/11 Teartear.flac
|
||||
#EXTINF:-1,Teeth Of The Sea - in the space capsule (love theme)
|
||||
/app/music/Teeth Of The Sea/2011 - Hypnoticon/01 in the space capsule (love theme).mp3
|
||||
#EXTINF:-1,Kiasmos - Dragged
|
||||
/app/music/Kiasmos/2014 - Kiasmos/06 - Dragged.flac
|
||||
#EXTINF:-1,Dopplereffekt - Ulams Spiral
|
||||
/app/music/Dopplereffekt/2017 - Cellular Automata/06. Ulams Spiral.flac
|
||||
#EXTINF:-1,Brian Eno - Stiff
|
||||
/app/music/Brian Eno/2024 - Eno (Original Motion Picture Soundtrack)/11. Stiff.flac
|
||||
#EXTINF:-1,The Orb - A Huge Ever Growing Pulsating Brain That Rules From The Centre Of The Ultraworld_ Live Mix Mk 10
|
||||
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/10 A Huge Ever Growing Pulsating Brain That Rules From The Centre Of The Ultraworld_ Live Mix Mk 10.mp3
|
||||
#EXTINF:-1,The Orb - Sticky End
|
||||
/app/music/The Orb/1992 - UFOrb/07-Sticky End.mp3
|
||||
#EXTINF:-1,Vector Lovers - Microtron
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/04 - Microtron.mp3
|
||||
#EXTINF:-1,Kraftwerk - Home Computer
|
||||
/app/music/Kraftwerk/1981 - Computer World/06 - Home Computer.flac
|
||||
#EXTINF:-1,Pye Corner Audio - Foreshadowed
|
||||
/app/music/Pye Corner Audio/2012 - Black Mill Tapes Volume 3 - All Pathways Open (WEB, #pca003)/08 - Foreshadowed.mp3
|
||||
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 2 Yasujiro Ozu
|
||||
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/02 Tape Loop Orchestra - Chapter 2 Yasujiro Ozu.mp3
|
||||
#EXTINF:-1,Brian Eno - Verdigris
|
||||
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/08 Verdigris.flac
|
||||
#EXTINF:-1,Tape Loop Orchestra - The Word On My Lips Is Your Name
|
||||
/app/music/Tape Loop Orchestra/2012 - The Word On My Lips Is Your Name/Disc 1 - The Word On My Lips Is Your Name/01 - The Word On My Lips Is Your Name.flac
|
||||
#EXTINF:-1,Vector Lovers - Melodies And Memory
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/07 - Melodies And Memory.mp3
|
||||
#EXTINF:-1,Vector Lovers - To The Stars
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/12 - To The Stars.mp3
|
||||
#EXTINF:-1,Dopplereffekt - Pascal's Recursion
|
||||
/app/music/Dopplereffekt/2017 - Cellular Automata/05. Pascal's Recursion.flac
|
||||
#EXTINF:-1,Orbital - Walk Now
|
||||
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Walk Now.mp3
|
||||
#EXTINF:-1,Orbital - Quality Seconds
|
||||
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/08. Quality Seconds.mp3
|
||||
#EXTINF:-1,Vector Lovers - Nostalgia 4 The Future
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/05 - Nostalgia 4 The Future.mp3
|
||||
#EXTINF:-1,Brian Eno - D3 Late Anthropocene
|
||||
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/D3 Late Anthropocene.flac
|
||||
#EXTINF:-1,Autechre - Gnit
|
||||
/app/music/Autechre/1995 - Tri Repetae/08 Gnit.flac
|
||||
#EXTINF:-1,Pye Corner Audio - Electronic Rhythm Number Seven
|
||||
/app/music/Pye Corner Audio/2011 - Black Mill Tapes Volume 2 - Do You Synthesize (WEB, #pca002)/02 - Electronic Rhythm Number Seven.mp3
|
||||
#EXTINF:-1,Proem - Proem - Socially Inept - 05 Pinching Point
|
||||
/app/music/Proem/2004 - Socially Inept/Proem - Socially Inept - 05 Pinching Point.flac
|
||||
#EXTINF:-1,Vector Lovers - Empty Buildings, Falling Rain
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/08 - Empty Buildings, Falling Rain.mp3
|
||||
#EXTINF:-1,Model 500 - model_500-control
|
||||
/app/music/Model 500/2015 - Digital Solutions/09-model_500-control.flac
|
||||
#EXTINF:-1,Proem - Proem - Vault ep.2-4 (Drone) - 02 Another Dull Moment
|
||||
/app/music/Proem/2016 - Vault ep.2-4 (Drone)/Proem - Vault ep.2-4 (Drone) - 02 Another Dull Moment.flac
|
||||
#EXTINF:-1,Teeth Of The Sea - Her Wraith
|
||||
/app/music/Teeth Of The Sea/2019 - WRAITH/06 - Her Wraith.flac
|
||||
#EXTINF:-1,Brian Eno - Slow Movement Sand
|
||||
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/18 Slow Movement Sand.flac
|
||||
#EXTINF:-1,Drexciya - Drexciya - You Don't Know
|
||||
/app/music/Drexciya/2013 - Journey Of The Deep Sea Dweller III/13. Drexciya - You Don't Know.mp3
|
||||
#EXTINF:-1,Proem - Proem - You Shall Have Ever Been - 07 Reddings
|
||||
/app/music/Proem/2006 - You Shall Have Ever Been/Proem - You Shall Have Ever Been - 07 Reddings.flac
|
||||
#EXTINF:-1,Teeth Of The Sea - Fortean Steed
|
||||
/app/music/Teeth Of The Sea/2019 - WRAITH/04 - Fortean Steed.flac
|
||||
#EXTINF:-1,Kraftwerk - The Model
|
||||
/app/music/Kraftwerk/1978 - The Man-Machine/04 - The Model.flac
|
||||
#EXTINF:-1,Pye Corner Audio - Yesterday's Entertainment
|
||||
/app/music/Pye Corner Audio/2012 - Sleep Games (WEB, #GBX017)/07 - Yesterday's Entertainment.mp3
|
||||
#EXTINF:-1,Brian Eno - B2 Forms Of Anger
|
||||
/app/music/Brian Eno/2011 - Small Craft On a Milk Sea/B2 Forms Of Anger.flac
|
||||
#EXTINF:-1,Vector Lovers - Neon Sky Rain
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/10 - Neon Sky Rain.mp3
|
||||
#EXTINF:-1,Vector Lovers - Capsule For One
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/11 - Capsule For One.mp3
|
||||
#EXTINF:-1,Pye Corner Audio - Solar Waves
|
||||
/app/music/Pye Corner Audio/EP's & Singles/2019 - Dark Phase EP (WEB, #AF025)/02 - Solar Waves.mp3
|
||||
#EXTINF:-1,Kraftwerk - Computer Love
|
||||
/app/music/Kraftwerk/1981 - Computer World/05 - Computer Love.flac
|
||||
#EXTINF:-1,Kiasmos - Held (Dauwd Remix)
|
||||
/app/music/Kiasmos/2015 - Looped/02 Held (Dauwd Remix).flac
|
||||
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 9 Setsu Ko Hara
|
||||
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/09 Tape Loop Orchestra - Chapter 9 Setsu Ko Hara.mp3
|
||||
#EXTINF:-1,The Orb - Back Side of the Moon
|
||||
/app/music/The Orb/1991 - The Orb's Adventures Beyond the Ultraworld (Double Album)/04 Back Side of the Moon.mp3
|
||||
#EXTINF:-1,Model 500 - model_500-storm
|
||||
/app/music/Model 500/2015 - Digital Solutions/05-model_500-storm.flac
|
||||
#EXTINF:-1,Autechre - Montreal
|
||||
/app/music/Autechre/1994 - Amber/02 Montreal.flac
|
||||
#EXTINF:-1,Bark Psychosis - bark psychosis - eyes & smiles
|
||||
/app/music/Bark Psychosis/1994 - Hex/05 - bark psychosis - eyes & smiles.mp3
|
||||
#EXTINF:-1,The Other People Place - D2 - Sunrays
|
||||
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/D2 - Sunrays.flac
|
||||
#EXTINF:-1,Bark Psychosis - (06) [Bark Psychosis] Bloodrush
|
||||
/app/music/Bark Psychosis/1994 - Game Over/(06) [Bark Psychosis] Bloodrush.flac
|
||||
#EXTINF:-1,Bark Psychosis - bark psychosis - pendulum man
|
||||
/app/music/Bark Psychosis/1994 - Hex/07 - bark psychosis - pendulum man.mp3
|
||||
#EXTINF:-1,Autechre - Slip
|
||||
/app/music/Autechre/1994 - Amber/04 Slip.flac
|
||||
#EXTINF:-1,Kiasmos - Swayed
|
||||
/app/music/Kiasmos/2014 - Kiasmos/04 - Swayed.flac
|
||||
#EXTINF:-1,The Other People Place - A1 - Eye Contact
|
||||
/app/music/The Other People Place/2017 - Lifestyles Of The Laptop Café/A1 - Eye Contact.flac
|
||||
#EXTINF:-1,Kiasmos - Rival Consoles - Milo
|
||||
/app/music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/03. Rival Consoles - Milo.flac
|
||||
#EXTINF:-1,Teeth Of The Sea - Butterfly House
|
||||
/app/music/Teeth Of The Sea/2023 - Hive/03 Butterfly House.flac
|
||||
#EXTINF:-1,The Orb - A Mile Long Lump of Lard
|
||||
/app/music/The Orb/2001 - Cydonia/07-A Mile Long Lump of Lard.mp3
|
||||
#EXTINF:-1,Autechre - Eutow
|
||||
/app/music/Autechre/1995 - Tri Repetae/06 Eutow.flac
|
||||
#EXTINF:-1,Model 500 - model_500-the_groove
|
||||
/app/music/Model 500/2015 - Digital Solutions/06-model_500-the_groove.flac
|
||||
#EXTINF:-1,Bark Psychosis - bark psychosis - big shot
|
||||
/app/music/Bark Psychosis/1994 - Hex/04 - bark psychosis - big shot.mp3
|
||||
#EXTINF:-1,Kiasmos - Rival Consoles - ARP
|
||||
/app/music/Kiasmos/2009 - 65, Milo (Kiasmos & Rival Consoles) (WEB)/05. Rival Consoles - ARP.flac
|
||||
#EXTINF:-1,Kraftwerk - Spacelab
|
||||
/app/music/Kraftwerk/1978 - The Man-Machine/02 - Spacelab.flac
|
||||
#EXTINF:-1,Brian Eno - Who Gives a Thought
|
||||
/app/music/Brian Eno/2022 - ForeverAndEverNoMore/01 Who Gives a Thought.flac
|
||||
#EXTINF:-1,Proem - Proem - Vault ep.4-4 - 04 v. jirku 1
|
||||
/app/music/Proem/2015 - Vault ep.4-4/Proem - Vault ep.4-4 - 04 v. jirku 1.flac
|
||||
#EXTINF:-1,Tape Loop Orchestra - The Burnley Brass Band Plays On In My Heart
|
||||
/app/music/Tape Loop Orchestra/2012 - The Word On My Lips Is Your Name/Disc 2 - The Burnley Brass Band Plays On In My Heart/01 - The Burnley Brass Band Plays On In My Heart.flac
|
||||
#EXTINF:-1,Proem - Proem - Before it finds you - 06 Pretense for piano and synth
|
||||
/app/music/Proem/2013 - Before it finds you/Proem - Before it finds you - 06 Pretense for piano and synth.flac
|
||||
#EXTINF:-1,Orbital - Remind
|
||||
/app/music/Orbital/1993 - Orbital - Orbital 2 (Brown Album - TRUCD2, 828 386.2)/00. Remind.mp3
|
||||
#EXTINF:-1,Underworld - Underworld - Pearls Girl
|
||||
/app/music/Underworld/1996 - Second Toughest In The Infants/05. Underworld - Pearls Girl.flac
|
||||
#EXTINF:-1,Underworld - Underworld - Banstyle Sappys Curry
|
||||
/app/music/Underworld/1996 - Second Toughest In The Infants/02. Underworld - Banstyle Sappys Curry.flac
|
||||
#EXTINF:-1,Orbital - Attached
|
||||
/app/music/Orbital/1994 - Orbital - Snivilisation (TRUCD5, 828 536.2)/10. Attached.mp3
|
||||
#EXTINF:-1,Labradford - P
|
||||
/app/music/Labradford/1997 - Mi Media Naranja/7 P.flac
|
||||
#EXTINF:-1,Drexciya - Drexciya - Vampire Island
|
||||
/app/music/Drexciya/2013 - Journey Of The Deep Sea Dweller III/10. Drexciya - Vampire Island.mp3
|
||||
#EXTINF:-1,Vector Lovers - Arrival, Metropolis
|
||||
/app/music/Vector Lovers/2005 - Capsule For One/02 - Arrival, Metropolis.mp3
|
||||
#EXTINF:-1,Teeth Of The Sea - Teeth Of The Sea - Highly Deadly Black Tarantula - 03 Field Punishment
|
||||
/app/music/Teeth Of The Sea/2015 - Highly Deadly Black Tarantula/Teeth Of The Sea - Highly Deadly Black Tarantula - 03 Field Punishment.flac
|
||||
#EXTINF:-1,Kiasmos - Swept (Tale of Us remix)
|
||||
/app/music/Kiasmos/2015 - Swept EP/04 - Swept (Tale of Us remix).mp3
|
||||
#EXTINF:-1,Proem - Proem - Vault ep.1-4 (Noise) - 05 Only Eat the Grey Wolves
|
||||
/app/music/Proem/2016 - Vault ep.1-4 (Noise)/Proem - Vault ep.1-4 (Noise) - 05 Only Eat the Grey Wolves.flac
|
||||
#EXTINF:-1,The Orb - Firestar
|
||||
/app/music/The Orb/2001 - Cydonia/06-Firestar.mp3
|
||||
#EXTINF:-1,Tape Loop Orchestra - Tape Loop Orchestra - Chapter 11 Late Autumn
|
||||
/app/music/Tape Loop Orchestra/2009 - 1953 Culture Festival/11 Tape Loop Orchestra - Chapter 11 Late Autumn.mp3
|
||||
#EXTINF:-1,Kraftwerk - The Man·Machine
|
||||
/app/music/Kraftwerk/1978 - The Man-Machine/06 - The Man·Machine.flac
|
||||
#PLAYLIST:Escape Velocity - A Christmas Journey Through Space
|
||||
#PHASE:Escape Velocity
|
||||
#DURATION:12 hours (approx)
|
||||
#CURATOR:Asteroid Radio
|
||||
#DESCRIPTION:A festive 12-hour voyage blending Christmas classics with ambient, IDM, and space music for the holiday season
|
||||
|
||||
# === PHASE 1: WINTER AWAKENING (Ambient Beginnings) ===
|
||||
#EXTINF:-1,Brian Eno - Snow
|
||||
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac
|
||||
#EXTINF:-1,Brian Eno - Wintergreen
|
||||
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/04 Wintergreen.flac
|
||||
#EXTINF:-1,Proem - Winter Wolves
|
||||
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
|
||||
#EXTINF:-1,Tim Hecker - Winter's Coming
|
||||
/app/music/Tim Hecker - The North Water Original Score (2021 - WEB - FLAC)/Tim Hecker - The North Water (Original Score) - 10 Winter's Coming.flac
|
||||
#EXTINF:-1,Biosphere - Drifter
|
||||
/app/music/Biosphere - The Petrified Forest (2017) - CD FLAC/01. Biosphere - Drifter.flac
|
||||
#EXTINF:-1,Dead Voices On Air - On Winters Gibbet
|
||||
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/02 - On Winters Gibbet.flac
|
||||
#EXTINF:-1,Color Therapy - Wintering
|
||||
/app/music/Color Therapy - Mr. Wolf Is Dead (2015) WEB FLAC/12 - Wintering.flac
|
||||
|
||||
# === PHASE 2: CHRISTMAS ARRIVAL (TSO Introduction) ===
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - The Ghosts Of Christmas Eve
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/01 The Ghosts Of Christmas Eve.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas In The Air
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/14 Christmas In The Air.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Canon
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/08 Christmas Canon.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Appalachian Snowfall
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/11 Appalachian Snowfall.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - The Snow Came Down
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/13 The Snow Came Down.flac
|
||||
|
||||
# === PHASE 3: AMBIENT INTERLUDE (Space & Atmosphere) ===
|
||||
#EXTINF:-1,Biosphere - 10 Snurp 1937
|
||||
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 10 Snurp 1937.flac
|
||||
#EXTINF:-1,Biosphere - 05 Fluvialmorphologie
|
||||
/app/music/Biosphere - Sound Installations -2000-2009 [FLAC]/Biosphere - Sound Installations -2000-2009- - 05 Fluvialmorphologie.flac
|
||||
#EXTINF:-1,God is an Astronaut - Winter Dusk-Awakening
|
||||
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/03. Winter Dusk-Awakening.flac
|
||||
#EXTINF:-1,Proem - Snow Drifts
|
||||
/app/music/Proem - Twelve Tails-(2021) @FLAC [16-48]/11 - Snow Drifts.flac
|
||||
#EXTINF:-1,Proem - Stick to Music Snowflake
|
||||
/app/music/Proem - Until Here for Years (n5md, 2019) flac/04 - Stick to Music Snowflake.flac
|
||||
#EXTINF:-1,Four Tet - 04 Tremper
|
||||
/app/music/Four Tet - New Energy {CD} [FLAC] (2017)/04 Tremper.flac
|
||||
|
||||
# === PHASE 4: CHRISTMAS EVE STORIES ===
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - First Snow (Instrumental)
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/04 First Snow (Instrumental).flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - The Silent Nutcracker (Instrumental)
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/05 The Silent Nutcracker (Instrumental).flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - A Mad Russian's Christmas (Instrumental)
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/06 A Mad Russian's Christmas (Instrumental).flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Eve,Sarajevo 12,24 (Instrumental)
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/08 Christmas Eve,Sarajevo 12,24 (Instrumental).flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - This Christmas Day
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/Christmas Eve and Other Stories/14 This Christmas Day.flac
|
||||
|
||||
# === PHASE 5: ELECTRONIC DREAMS (IDM & Ambient) ===
|
||||
#EXTINF:-1,Autechre - NTS Session 1-005-Autechre-carefree counter dronal
|
||||
/app/music/Autechre - 2018 - NTS Session 1/NTS Session 1-005-Autechre-carefree counter dronal.flac
|
||||
#EXTINF:-1,Clark - Living Fantasy
|
||||
/app/music/Clark - Death Peak (2017) [FLAC]/08 - Living Fantasy.flac
|
||||
#EXTINF:-1,Clark - My Machines (Clark Remix)
|
||||
/app/music/Clark - Feast Beast (2013) [24 Bit WEB FLAC] [16-44]/1.17. Battles - My Machines (Clark Remix).flac
|
||||
#EXTINF:-1,Plaid - Dancers
|
||||
/app/music/Plaid - Polymer (2019) [WEB FLAC]/07 - Dancers.flac
|
||||
#EXTINF:-1,Faux Tales - Avalon
|
||||
/app/music/Faux Tales - 2015 - Kairos [FLAC] {Kensai Records KNS006 WEB}/3 - Avalon.flac
|
||||
#EXTINF:-1,Color Therapy - Expect Delays (feat. Ulrich Schnauss)
|
||||
/app/music/Color Therapy - Mr. Wolf Is Dead (2015) WEB FLAC/11 - Expect Delays (feat. Ulrich Schnauss).flac
|
||||
|
||||
# === PHASE 6: THE LOST CHRISTMAS EVE ===
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - The Lost Christmas Eve
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/02 The Lost Christmas Eve.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Dreams
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/03 Christmas Dreams.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Wizards in Winter
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/04 Wizards in Winter.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Concerto
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/07 Christmas Concerto.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Queen Of The Winter Night
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/08 Queen Of The Winter Night.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Nights In Blue
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/09 Christmas Nights In Blue.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Jazz
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/10 Christmas Jazz.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Jam
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/11 Christmas Jam.flac
|
||||
|
||||
# === PHASE 7: CLASSICAL WINTER (Nutcracker & More) ===
|
||||
#EXTINF:-1,Various Artists - Dance of the Sugar-Plum Fairy
|
||||
/app/music/Various Artists - The 50 Darkest Pieces of Classical Music (2011) - FLAC/CD 1/02 - Tchaikovsky - The Nutcracker - Dance of the Sugar-Plum Fairy.flac
|
||||
#EXTINF:-1,Quaeschning and Ulrich Schnauss - Thirst
|
||||
/app/music/Quaeschning and Ulrich Schnauss - Synthwaves (2017) {vista003, GER, CD} [FLAC]/06 - Thirst.flac
|
||||
#EXTINF:-1,Proem - 04. Drawing Room Anguish
|
||||
/app/music/Proem - 2018 Modern Rope (WEB)/04. Drawing Room Anguish.flac
|
||||
#EXTINF:-1,Dead Voices On Air - 07. Dogger Doorlopende Split
|
||||
/app/music/Dead Voices On Air - Frankie Pett En De Onderzeer Boten (2017) web/07. Dogger Doorlopende Split.flac
|
||||
|
||||
# === PHASE 8: WISDOM & REFLECTION ===
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - What Is Christmas
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/13 What Is Christmas.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - The Wisdom Of Snow
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/15 The Wisdom Of Snow.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Bells, Carousels & Time
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/18 Christmas Bells, Carousels & Time.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas Canon Rock
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Lost Christmas Eve/21 Christmas Canon Rock.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Midnight Christmas Eve
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/05 Midnight Christmas Eve.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Dream Child (A Christmas Dream)
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/15 Dream Child (A Christmas Dream).flac
|
||||
|
||||
# === PHASE 9: DEEP SPACE JOURNEY (Extended Ambient) ===
|
||||
#EXTINF:-1,Dead Voices On Air - Red Howls
|
||||
/app/music/Dead Voices On Air - Ghohst Stories (FLAC)/01 - Red Howls.flac
|
||||
#EXTINF:-1,Cut Copy - Airborne
|
||||
/app/music/Cut Copy - Haiku From Zero (2017) [FLAC] {2557864014}/05 - Airborne.flac
|
||||
#EXTINF:-1,Owl City - 01 Hot Air Balloon
|
||||
/app/music/Owl City - Ocean Eyes (Deluxe Edition) [Flac,Cue,Logs]/Disc 2/01 Hot Air Balloon.flac
|
||||
#EXTINF:-1,VA - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit]
|
||||
/app/music/VA - Melodic Vocal Trance 2017/24. Airborn, Bogdan Vix & KeyPlayer - What Is Loneliness (feat. Danny Claire) [Skylex Radio Edit].flac
|
||||
#EXTINF:-1,VA - Winter Took Over (Radio Edit)
|
||||
/app/music/VA - Melodic Vocal Trance 2017/22. Bluskay, KeyPlayer & Esmee Bor Stotijn - Winter Took Over (Radio Edit).flac
|
||||
#EXTINF:-1,Alison Krauss and Union Station - My Opening Farewell
|
||||
/app/music/Alison Krauss and Union Station - Paper Airplane (flac)/11 - Alison Krauss & Union Station - My Opening Farewell.flac
|
||||
#EXTINF:-1,Bedouin Soundclash - Money Worries (E-Clair Refix)
|
||||
/app/music/Bedouin Soundclash - Sounding a Mosaic (2004) [FLAC] {SD1267}/14 - Money Worries (E-Clair Refix).flac
|
||||
|
||||
# === PHASE 10: RETURN TO WINTER (Closing Circle) ===
|
||||
#EXTINF:-1,Brian Eno - Snow
|
||||
/app/music/Brian Eno/2020 - Roger Eno and Brian Eno - Mixing Colours/09 Snow.flac
|
||||
#EXTINF:-1,Proem - Winter Wolves
|
||||
/app/music/Proem - 2018 Modern Rope (WEB)/01. Winter Wolves.flac
|
||||
#EXTINF:-1,God is an Astronaut - Winter Dusk-Awakening
|
||||
/app/music/God is an Astronaut - Epitaph (2018) WEB FLAC/03. Winter Dusk-Awakening.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - The Snow Came Down
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/13 The Snow Came Down.flac
|
||||
#EXTINF:-1,Trans-Siberian Orchestra - Christmas In The Air
|
||||
/app/music/Trans-Siberian Orchestra - The Christmas Trilogy (2004) [FLAC]/The Christmas Attic/14 Christmas In The Air.flac
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -978,7 +978,43 @@
|
|||
|
||||
(.stat-label :color "#ccc"
|
||||
:font-size 0.875rem
|
||||
:margin-top 0.5rem))
|
||||
:margin-top 0.5rem)
|
||||
|
||||
(.stat-detail :color "#888"
|
||||
:font-size 0.75rem
|
||||
:margin-top 0.25rem)
|
||||
|
||||
(.listener-stats-table
|
||||
:width 100%
|
||||
:border-collapse collapse
|
||||
:background "#111"
|
||||
:border "1px solid #333"
|
||||
:table-layout fixed
|
||||
|
||||
(th :background "#1a1a1a"
|
||||
:color "#00ff00"
|
||||
:padding "12px 20px"
|
||||
:text-align center
|
||||
:border "1px solid #333"
|
||||
:font-size "0.9rem"
|
||||
:width "25%")
|
||||
|
||||
(td :padding "12px 20px"
|
||||
:text-align "center"
|
||||
:border "1px solid #333"
|
||||
:vertical-align "middle"
|
||||
:width "25%")
|
||||
|
||||
(.stat-number :font-size 1.75rem
|
||||
:font-weight bold
|
||||
:color "#00ffff"
|
||||
:display block
|
||||
:text-align "center")
|
||||
|
||||
(.stat-peak-row
|
||||
:font-size 0.8rem
|
||||
:color "#888"
|
||||
:background "#0a0a0a")))
|
||||
|
||||
;; Center alignment for player page
|
||||
;; (body.player-page
|
||||
|
|
@ -1041,6 +1077,20 @@
|
|||
:flex 1
|
||||
:min-width "300px")
|
||||
|
||||
(.persistent-reconnect-btn
|
||||
:background transparent
|
||||
:color "#00ff00"
|
||||
:border "1px solid #00ff00"
|
||||
:padding "5px 10px"
|
||||
:cursor "pointer"
|
||||
:font-family #(main-font)
|
||||
:font-size "0.85em"
|
||||
:white-space nowrap
|
||||
:margin-right "5px")
|
||||
|
||||
((:and .persistent-reconnect-btn :hover)
|
||||
:background "#2a3441")
|
||||
|
||||
(.persistent-disable-btn
|
||||
:background transparent
|
||||
:color "#00ff00"
|
||||
|
|
|
|||
|
|
@ -90,17 +90,21 @@
|
|||
t)
|
||||
|
||||
(defun regenerate-stream-playlist ()
|
||||
"Regenerate the main stream playlist from the current queue"
|
||||
"Regenerate the main stream playlist from the current queue.
|
||||
NOTE: This writes to project root stream-queue.m3u, NOT playlists/stream-queue.m3u
|
||||
which is what Liquidsoap actually reads. This function may be deprecated."
|
||||
(let ((playlist-path (merge-pathnames "stream-queue.m3u"
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
(if (null *stream-queue*)
|
||||
;; If queue is empty, generate from all tracks (fallback)
|
||||
(let ((all-tracks (dm:get "tracks" (db:query :all))))
|
||||
(generate-m3u-playlist
|
||||
(mapcar (lambda (track)
|
||||
(dm:id track))
|
||||
all-tracks)
|
||||
playlist-path))
|
||||
;; DISABLED: Don't dump all tracks when queue is empty
|
||||
;; This was overwriting files with all library tracks unexpectedly
|
||||
;; (let ((all-tracks (dm:get "tracks" (db:query :all))))
|
||||
;; (generate-m3u-playlist
|
||||
;; (mapcar (lambda (track)
|
||||
;; (dm:id track))
|
||||
;; all-tracks)
|
||||
;; playlist-path))
|
||||
(format t "Stream queue is empty, not generating playlist file~%")
|
||||
;; Generate from queue
|
||||
(generate-m3u-playlist *stream-queue* playlist-path))))
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@
|
|||
"Recursively scan directory for supported audio files"
|
||||
(when (cl-fad:directory-exists-p directory)
|
||||
(remove-if-not #'supported-audio-file-p
|
||||
(cl-fad:list-directory directory :follow-symlinks nil))))
|
||||
(cl-fad:list-directory directory :follow-symlinks t))))
|
||||
|
||||
(defun scan-directory-for-music-recursively (path)
|
||||
"Recursively scan directory and all subdirectories for music files"
|
||||
(let ((files-in-current-dir (scan-directory-for-music path))
|
||||
(files-in-subdirs (loop for directory in (uiop:subdirectories path)
|
||||
appending (scan-directory-for-music-recursively directory))))
|
||||
(let* ((resolved-path (truename path))
|
||||
(files-in-current-dir (scan-directory-for-music resolved-path))
|
||||
(files-in-subdirs (loop for directory in (uiop:subdirectories resolved-path)
|
||||
appending (scan-directory-for-music-recursively directory))))
|
||||
(append files-in-current-dir files-in-subdirs)))
|
||||
|
||||
(defun extract-metadata-with-taglib (file-path)
|
||||
|
|
@ -92,16 +93,25 @@
|
|||
(setf (dm:field track "file-path") file-path)
|
||||
(setf (dm:field track "format") (getf metadata :format))
|
||||
(setf (dm:field track "bitrate") (getf metadata :bitrate))
|
||||
(setf (dm:field track "added-date") (local-time:timestamp-to-unix (local-time:now)))
|
||||
;; Let database default handle added-date (CURRENT_TIMESTAMP)
|
||||
(setf (dm:field track "play-count") 0)
|
||||
(dm:insert track)
|
||||
t))))
|
||||
|
||||
(defun scan-music-library (&optional (directory *music-library-path*))
|
||||
"Scan music library directory and add tracks to database"
|
||||
(format t "~%=== SCAN DEBUG ===~%")
|
||||
(format t "Input directory: ~a~%" directory)
|
||||
(format t "Directory exists: ~a~%" (probe-file directory))
|
||||
(handler-case
|
||||
(format t "Resolved path: ~a~%" (truename directory))
|
||||
(error (e) (format t "Cannot resolve truename: ~a~%" e)))
|
||||
(let ((audio-files (scan-directory-for-music-recursively directory))
|
||||
(added-count 0)
|
||||
(skipped-count 0))
|
||||
(format t "Found ~a audio files~%" (length audio-files))
|
||||
(when (> (length audio-files) 0)
|
||||
(format t "First few files: ~{~a~%~}~%" (subseq audio-files 0 (min 3 (length audio-files)))))
|
||||
(dolist (file audio-files)
|
||||
(let ((metadata (extract-metadata-with-taglib file)))
|
||||
(when metadata
|
||||
|
|
@ -111,6 +121,7 @@
|
|||
(incf skipped-count))
|
||||
(error (e)
|
||||
(format t "Error adding ~a: ~a~%" file e))))))
|
||||
(format t "Added: ~a, Skipped: ~a~%" added-count skipped-count)
|
||||
added-count))
|
||||
|
||||
;; Initialize music directory structure
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<h3>Icecast Status</h3>
|
||||
<p class="status-error" data-text="icecast-status">🔴 Not Running</p>
|
||||
<button id="icecast-restart" class="btn btn-danger btn-sm" style="margin-top: 8px;">🔄 Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listener Statistics -->
|
||||
<div class="admin-section">
|
||||
<h2>📊 Current Listeners</h2>
|
||||
<table class="listener-stats-table" style="table-layout: fixed; width: 100%;">
|
||||
<colgroup>
|
||||
<col style="width: 25%;">
|
||||
<col style="width: 25%;">
|
||||
<col style="width: 25%;">
|
||||
<col style="width: 25%;">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>🎵 MP3</th>
|
||||
<th>🎧 AAC</th>
|
||||
<th>📱 Low</th>
|
||||
<th>📈 Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="text-align: center;"><span class="stat-number" id="listeners-mp3">0</span></td>
|
||||
<td style="text-align: center;"><span class="stat-number" id="listeners-aac">0</span></td>
|
||||
<td style="text-align: center;"><span class="stat-number" id="listeners-low">0</span></td>
|
||||
<td style="text-align: center;"><span class="stat-number" id="listeners-total">0</span></td>
|
||||
</tr>
|
||||
<tr class="stat-peak-row">
|
||||
<td style="text-align: center;">Peak: <span id="peak-mp3">0</span></td>
|
||||
<td style="text-align: center;">Peak: <span id="peak-aac">0</span></td>
|
||||
<td style="text-align: center;">Peak: <span id="peak-low">0</span></td>
|
||||
<td style="text-align: center;">Updated: <span id="stats-updated">--</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="admin-controls" style="margin-top: 10px;">
|
||||
<button id="refresh-stats" class="btn btn-secondary" onclick="refreshListenerStats()">🔄 Refresh</button>
|
||||
<span id="stats-status" style="margin-left: 15px;"></span>
|
||||
</div>
|
||||
|
||||
<!-- Geo Stats -->
|
||||
<h3 style="margin-top: 20px;">🌍 Listener Locations (Last 7 Days)</h3>
|
||||
<div id="geo-stats-container">
|
||||
<table class="listener-stats-table" id="geo-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Country</th>
|
||||
<th>Listeners</th>
|
||||
<th>Minutes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="geo-stats-body">
|
||||
<tr><td colspan="3" style="color: #888;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Music Library Management -->
|
||||
<div class="admin-section">
|
||||
<h2>Music Library Management</h2>
|
||||
|
||||
<!-- File Upload -->
|
||||
<!-- Music Library Info -->
|
||||
<div class="upload-section">
|
||||
<h3>Add Music Files</h3>
|
||||
<h3>Music Library</h3>
|
||||
<div class="upload-info">
|
||||
<p><strong>To add your own MP3 files:</strong></p>
|
||||
<p>The music library is mounted from your local filesystem into the Liquidsoap container.</p>
|
||||
<p><strong>To add music:</strong></p>
|
||||
<ol>
|
||||
<li>Copy your MP3/FLAC/OGG/WAV files to: <code>/home/glenn/Projects/Code/asteroid/music/incoming/</code></li>
|
||||
<li>Click "Copy Files to Library" below</li>
|
||||
<li>Files will be moved to the library and added to the database</li>
|
||||
<li>Add files to your music library directory (set via <code>MUSIC_LIBRARY</code> env var)</li>
|
||||
<li>Click "Scan Library" to index new tracks into the database</li>
|
||||
</ol>
|
||||
<p><em>Supported formats: MP3, FLAC, OGG, WAV</em></p>
|
||||
</div>
|
||||
<div class="upload-controls">
|
||||
<button id="copy-files" class="btn btn-success">📁 Copy Files to Library</button>
|
||||
<button id="open-incoming" class="btn btn-info">📂 Open Incoming Folder</button>
|
||||
<p><em>Supported formats: MP3, FLAC, OGG, WAV, OPUS</em></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -107,59 +160,94 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Stream Monitor -->
|
||||
<div class="admin-section">
|
||||
<h2>📻 Live Stream Monitor</h2>
|
||||
<div class="live-stream-monitor">
|
||||
<input type="hidden" id="stream-base-url" lquery="(val stream-base-url)">
|
||||
<p><strong>Now Playing:</strong> <span id="live-now-playing">Loading...</span></p>
|
||||
<audio id="live-stream-audio" controls style="width: 100%; max-width: 600px;">
|
||||
<source id="live-stream-source" lquery="(attr :src default-stream-url)" type="audio/aac">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stream Queue Management -->
|
||||
<div class="admin-section">
|
||||
<h2>🎵 Stream Queue Management</h2>
|
||||
<p>Manage the live stream playback queue. Changes take effect within 5-10 seconds.</p>
|
||||
<p>Manage the live stream playback queue. Liquidsoap watches <code>stream-queue.m3u</code> and reloads automatically.</p>
|
||||
|
||||
<div class="queue-controls">
|
||||
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
|
||||
<button id="load-from-m3u" class="btn btn-success">📂 Load Queue from M3U</button>
|
||||
<button id="clear-queue-btn" class="btn btn-warning">🗑️ Clear Queue</button>
|
||||
<button id="add-random-tracks" class="btn btn-info">🎲 Add 10 Random Tracks</button>
|
||||
<!-- Playlist Selection -->
|
||||
<div class="playlist-controls" style="margin-bottom: 20px; padding: 15px; background: #2a2a2a; border-radius: 8px;">
|
||||
<h3 style="margin-top: 0;">📋 Load Playlist</h3>
|
||||
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
|
||||
<select id="playlist-select" class="sort-select" style="min-width: 250px;">
|
||||
<option value="">-- Select a playlist --</option>
|
||||
</select>
|
||||
<button id="load-playlist-btn" class="btn btn-success">📂 Load Selected</button>
|
||||
<button id="refresh-playlists-btn" class="btn btn-secondary">🔄 Refresh List</button>
|
||||
</div>
|
||||
<p style="margin: 10px 0 0 0; font-size: 0.9em; color: #888;">
|
||||
Loading a playlist will copy it to <code>stream-queue.m3u</code> and Liquidsoap will start playing it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="stream-queue-container" class="queue-list">
|
||||
<!-- Queue Controls -->
|
||||
<div class="queue-controls" style="margin-bottom: 15px;">
|
||||
<button id="refresh-queue" class="btn btn-secondary">🔄 Refresh Queue</button>
|
||||
<button id="save-queue-btn" class="btn btn-primary">💾 Save Queue</button>
|
||||
<button id="clear-queue-btn" class="btn btn-warning">🗑️ Clear Queue</button>
|
||||
<button id="add-random-tracks" class="btn btn-info">🎲 Add 10 Random</button>
|
||||
</div>
|
||||
|
||||
<!-- Save As -->
|
||||
<div style="margin-bottom: 15px; display: flex; gap: 10px; align-items: center;">
|
||||
<input type="text" id="save-as-name" placeholder="New playlist name..." class="search-input" style="max-width: 250px;">
|
||||
<button id="save-as-btn" class="btn btn-success">💾 Save As New Playlist</button>
|
||||
</div>
|
||||
|
||||
<!-- Queue Status -->
|
||||
<div id="queue-status" style="margin-bottom: 15px; padding: 10px; background: #1a1a1a; border-radius: 4px;">
|
||||
<span id="queue-count">0</span> tracks in queue
|
||||
</div>
|
||||
|
||||
<!-- Queue Contents -->
|
||||
<div id="stream-queue-container" class="queue-list" style="max-height: 400px; overflow-y: auto;">
|
||||
<div class="loading">Loading queue...</div>
|
||||
</div>
|
||||
|
||||
<div class="queue-actions">
|
||||
<div class="queue-actions" style="margin-top: 20px;">
|
||||
<h3>Add Tracks to Queue</h3>
|
||||
<input type="text" id="queue-track-search" placeholder="Search tracks to add..." class="search-input">
|
||||
<div id="queue-track-results" class="track-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Control -->
|
||||
<!-- Liquidsoap Stream Control -->
|
||||
<div class="admin-section">
|
||||
<h2>Player Control</h2>
|
||||
<div class="card">
|
||||
<h3>🎵 Player Control</h3>
|
||||
<div class="player-controls">
|
||||
<button id="player-play" class="btn btn-primary" onclick="playTrack()">▶️ Play</button>
|
||||
<button id="player-pause" class="btn btn-secondary" onclick="pausePlayer()">⏸️ Pause</button>
|
||||
<button id="player-stop" class="btn btn-secondary" onclick="stopPlayer()">⏹️ Stop</button>
|
||||
<button id="player-resume" class="btn btn-secondary" onclick="resumePlayer()">▶️ Resume</button>
|
||||
<h2>📡 Stream Control (Liquidsoap)</h2>
|
||||
<p>Control the live audio stream. Commands are sent directly to Liquidsoap.</p>
|
||||
|
||||
<!-- Status Display -->
|
||||
<div id="liquidsoap-status" style="margin-bottom: 20px; padding: 15px; background: #1a1a1a; border-radius: 8px;">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||||
<div>
|
||||
<strong>Uptime:</strong> <span id="ls-uptime">--</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Remaining:</strong> <span id="ls-remaining">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="player-status" class="status-info">
|
||||
Status: <span id="player-state">Unknown</span><br>
|
||||
Current Track: <span id="current-track">None</span>
|
||||
<div style="margin-top: 10px;">
|
||||
<strong>Now Playing:</strong> <span id="ls-metadata">--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="queue-controls" style="margin-bottom: 15px;">
|
||||
<button id="ls-refresh-status" class="btn btn-secondary">🔄 Refresh Status</button>
|
||||
<button id="ls-skip" class="btn btn-warning">⏭️ Skip Track</button>
|
||||
<button id="ls-reload" class="btn btn-info">📂 Reload Playlist</button>
|
||||
<button id="ls-restart" class="btn btn-danger">🔄 Restart Container</button>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 0.9em; color: #888;">
|
||||
<strong>Skip Track:</strong> Immediately skip to the next track in the playlist.<br>
|
||||
<strong>Reload Playlist:</strong> Force Liquidsoap to re-read stream-queue.m3u.<br>
|
||||
<strong>Restart Container:</strong> Restart the Liquidsoap Docker container (causes brief stream interruption).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- User Management -->
|
||||
<div class="admin-section">
|
||||
<div class="card">
|
||||
<h3>👥 User Management</h3>
|
||||
<p>Manage user accounts, roles, and permissions.</p>
|
||||
|
|
@ -192,6 +280,102 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
// Listener Statistics
|
||||
function refreshListenerStats() {
|
||||
const statusEl = document.getElementById('stats-status');
|
||||
statusEl.textContent = 'Loading...';
|
||||
|
||||
fetch('/api/asteroid/stats/current')
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
const data = result.data || result;
|
||||
if (data.status === 'success' && data.listeners) {
|
||||
// Process listener data - get most recent for each mount
|
||||
const mounts = {};
|
||||
data.listeners.forEach(item => {
|
||||
// item is [mount, "/asteroid.mp3", listeners, 1, timestamp, 123456]
|
||||
const mount = item[1];
|
||||
const listeners = item[3];
|
||||
if (!mounts[mount] || item[5] > mounts[mount].timestamp) {
|
||||
mounts[mount] = { listeners: listeners, timestamp: item[5] };
|
||||
}
|
||||
});
|
||||
|
||||
// Update UI
|
||||
const mp3 = mounts['/asteroid.mp3']?.listeners || 0;
|
||||
const aac = mounts['/asteroid.aac']?.listeners || 0;
|
||||
const low = mounts['/asteroid-low.mp3']?.listeners || 0;
|
||||
|
||||
document.getElementById('listeners-mp3').textContent = mp3;
|
||||
document.getElementById('listeners-aac').textContent = aac;
|
||||
document.getElementById('listeners-low').textContent = low;
|
||||
document.getElementById('listeners-total').textContent = mp3 + aac + low;
|
||||
|
||||
const now = new Date();
|
||||
document.getElementById('stats-updated').textContent =
|
||||
now.toLocaleTimeString();
|
||||
|
||||
statusEl.textContent = '';
|
||||
} else {
|
||||
statusEl.textContent = 'No data available';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching stats:', error);
|
||||
statusEl.textContent = 'Error loading stats';
|
||||
});
|
||||
}
|
||||
|
||||
// Country code to flag emoji
|
||||
function countryToFlag(countryCode) {
|
||||
if (!countryCode || countryCode.length !== 2) return '🌍';
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
}
|
||||
|
||||
// Fetch and display geo stats
|
||||
function refreshGeoStats() {
|
||||
fetch('/api/asteroid/stats/geo?days=7')
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
const data = result.data || result;
|
||||
const tbody = document.getElementById('geo-stats-body');
|
||||
|
||||
if (data.status === 'success' && data.geo && data.geo.length > 0) {
|
||||
tbody.innerHTML = data.geo.map(item => {
|
||||
const country = item.country_code || item[0];
|
||||
const listeners = item.total_listeners || item[1] || 0;
|
||||
const minutes = item.total_minutes || item[2] || 0;
|
||||
return `<tr>
|
||||
<td>${countryToFlag(country)} ${country}</td>
|
||||
<td>${listeners}</td>
|
||||
<td>${minutes}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching geo stats:', error);
|
||||
document.getElementById('geo-stats-body').innerHTML =
|
||||
'<tr><td colspan="3" style="color: #ff6666;">Error loading geo data</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-refresh stats every 30 seconds
|
||||
setInterval(refreshListenerStats, 30000);
|
||||
setInterval(refreshGeoStats, 60000);
|
||||
|
||||
// Initial load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
refreshListenerStats();
|
||||
refreshGeoStats();
|
||||
});
|
||||
|
||||
// Admin password reset handler
|
||||
function resetUserPassword(event) {
|
||||
event.preventDefault();
|
||||
|
|
|
|||
|
|
@ -28,10 +28,17 @@
|
|||
|
||||
<span class="now-playing-mini" id="mini-now-playing">Loading...</span>
|
||||
|
||||
<button id="reconnect-btn" onclick="reconnectStream()" class="persistent-reconnect-btn" title="Reconnect if audio stops working">
|
||||
🔄
|
||||
</button>
|
||||
|
||||
<button onclick="disableFramesetMode()" class="persistent-disable-btn">
|
||||
✕ Disable
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status indicator for connection issues -->
|
||||
<div id="stream-status" style="display: none; background: #550000; color: #ff6666; padding: 4px 10px; text-align: center; font-size: 0.85em;"></div>
|
||||
|
||||
<script>
|
||||
// Configure audio element for better streaming
|
||||
|
|
@ -134,6 +141,145 @@
|
|||
// Redirect parent window to regular view
|
||||
window.parent.location.href = '/asteroid/';
|
||||
}
|
||||
|
||||
// Show status message
|
||||
function showStatus(message, isError) {
|
||||
const status = document.getElementById('stream-status');
|
||||
if (status) {
|
||||
status.textContent = message;
|
||||
status.style.display = 'block';
|
||||
status.style.background = isError ? '#550000' : '#005500';
|
||||
status.style.color = isError ? '#ff6666' : '#66ff66';
|
||||
if (!isError) {
|
||||
setTimeout(() => { status.style.display = 'none'; }, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
const status = document.getElementById('stream-status');
|
||||
if (status) {
|
||||
status.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect stream - recreates audio element to fix wedged state
|
||||
function reconnectStream() {
|
||||
console.log('Reconnecting stream...');
|
||||
showStatus('🔄 Reconnecting...', false);
|
||||
|
||||
const container = document.querySelector('.persistent-player');
|
||||
const oldAudio = document.getElementById('persistent-audio');
|
||||
const streamBaseUrl = document.getElementById('stream-base-url').value;
|
||||
const streamQuality = localStorage.getItem('stream-quality') || 'aac';
|
||||
const config = getStreamConfig(streamBaseUrl, streamQuality);
|
||||
|
||||
if (!container || !oldAudio) {
|
||||
showStatus('❌ Could not reconnect - reload page', true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current volume and muted state
|
||||
const savedVolume = oldAudio.volume;
|
||||
const savedMuted = oldAudio.muted;
|
||||
console.log('Saving volume:', savedVolume, 'muted:', savedMuted);
|
||||
|
||||
// Reset spectrum analyzer if it exists
|
||||
if (window.resetSpectrumAnalyzer) {
|
||||
window.resetSpectrumAnalyzer();
|
||||
}
|
||||
|
||||
// Stop and remove old audio
|
||||
oldAudio.pause();
|
||||
oldAudio.src = '';
|
||||
oldAudio.load();
|
||||
|
||||
// Create new audio element
|
||||
const newAudio = document.createElement('audio');
|
||||
newAudio.id = 'persistent-audio';
|
||||
newAudio.controls = true;
|
||||
newAudio.preload = 'metadata';
|
||||
newAudio.crossOrigin = 'anonymous';
|
||||
|
||||
// Restore volume and muted state
|
||||
newAudio.volume = savedVolume;
|
||||
newAudio.muted = savedMuted;
|
||||
|
||||
// Create source
|
||||
const source = document.createElement('source');
|
||||
source.id = 'audio-source';
|
||||
source.src = config.url;
|
||||
source.type = config.type;
|
||||
newAudio.appendChild(source);
|
||||
|
||||
// Replace old audio with new
|
||||
oldAudio.replaceWith(newAudio);
|
||||
|
||||
// Re-attach event listeners
|
||||
attachAudioListeners(newAudio);
|
||||
|
||||
// Try to play
|
||||
setTimeout(() => {
|
||||
newAudio.play()
|
||||
.then(() => {
|
||||
console.log('Reconnected successfully');
|
||||
showStatus('✓ Reconnected!', false);
|
||||
// Reinitialize spectrum analyzer - try in this frame first
|
||||
if (window.initSpectrumAnalyzer) {
|
||||
setTimeout(() => window.initSpectrumAnalyzer(), 500);
|
||||
}
|
||||
// Also try in content frame (where spectrum canvas usually is)
|
||||
try {
|
||||
const contentFrame = window.parent.frames['content-frame'];
|
||||
if (contentFrame && contentFrame.initSpectrumAnalyzer) {
|
||||
setTimeout(() => {
|
||||
if (contentFrame.resetSpectrumAnalyzer) {
|
||||
contentFrame.resetSpectrumAnalyzer();
|
||||
}
|
||||
contentFrame.initSpectrumAnalyzer();
|
||||
console.log('Spectrum analyzer reinitialized in content frame');
|
||||
}, 600);
|
||||
}
|
||||
} catch(e) {
|
||||
console.log('Could not reinit spectrum in content frame:', e);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('Reconnect play failed:', err);
|
||||
showStatus('Click play to start stream', false);
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Attach event listeners to audio element
|
||||
function attachAudioListeners(audioElement) {
|
||||
audioElement.addEventListener('waiting', function() {
|
||||
console.log('Audio buffering...');
|
||||
});
|
||||
|
||||
audioElement.addEventListener('playing', function() {
|
||||
console.log('Audio playing');
|
||||
hideStatus();
|
||||
});
|
||||
|
||||
audioElement.addEventListener('error', function(e) {
|
||||
console.error('Audio error:', e);
|
||||
showStatus('⚠️ Stream error - click 🔄 to reconnect', true);
|
||||
});
|
||||
|
||||
audioElement.addEventListener('stalled', function() {
|
||||
console.log('Audio stalled');
|
||||
showStatus('⚠️ Stream stalled - click 🔄 if no audio', true);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach listeners to initial audio element
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const audioElement = document.getElementById('persistent-audio');
|
||||
if (audioElement) {
|
||||
attachAudioListeners(audioElement);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -51,13 +51,14 @@
|
|||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<a href="/asteroid/content" target="content-frame">Home</a>
|
||||
<a href="/asteroid/player-content" target="content-frame">Player</a>
|
||||
<a href="/asteroid/status" target="content-frame">Status</a>
|
||||
<a href="/asteroid/profile" target="content-frame" data-show-if-logged-in>Profile</a>
|
||||
<a href="/asteroid/admin" target="content-frame" data-show-if-admin>Admin</a>
|
||||
<a href="/asteroid/login" target="content-frame" data-show-if-logged-out>Login</a>
|
||||
<a href="/asteroid/register" target="content-frame" data-show-if-logged-out>Register</a>
|
||||
<a href="/asteroid/content" target="_self">Home</a>
|
||||
<a href="/asteroid/player-content" target="_self">Player</a>
|
||||
<a href="/asteroid/about-content" target="_self">About</a>
|
||||
<a href="/asteroid/status-content" target="_self">Status</a>
|
||||
<a href="/asteroid/profile" target="_self" data-show-if-logged-in>Profile</a>
|
||||
<a href="/asteroid/admin" target="_self" data-show-if-admin>Admin</a>
|
||||
<a href="/asteroid/login" target="_self" data-show-if-logged-out>Login</a>
|
||||
<a href="/asteroid/register" target="_self" data-show-if-logged-out>Register</a>
|
||||
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
<nav class="nav">
|
||||
<a href="/asteroid/">Home</a>
|
||||
<a href="/asteroid/player">Player</a>
|
||||
<a href="/asteroid/about">About</a>
|
||||
<a href="/asteroid/status">Status</a>
|
||||
<a href="/asteroid/profile" data-show-if-logged-in>Profile</a>
|
||||
<a href="/asteroid/admin" data-show-if-admin>Admin</a>
|
||||
|
|
@ -84,6 +85,9 @@
|
|||
<p><strong class="live-stream-label" class="live-stream-label">BROADCASTING:</strong> <span id="stream-status" style="">Asteroid music for Hackers</span></p>
|
||||
|
||||
<div style="display: flex; gap: 10px; justify-content: end; margin-bottom: 20px;">
|
||||
<button id="reconnect-btn" class="btn btn-warning" onclick="reconnectStream()" style="font-size: 0.9em; display: none;">
|
||||
🔄 Reconnect Stream
|
||||
</button>
|
||||
<button id="popout-btn" class="btn btn-info" onclick="openPopoutPlayer()" style="font-size: 0.9em;">
|
||||
🗗 Pop Out Player
|
||||
</button>
|
||||
|
|
@ -92,10 +96,15 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<audio id="live-audio" controls crossorigin="anonymous" style="width: 100%; margin: 10px 0;">
|
||||
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<!-- Stream connection status -->
|
||||
<div id="stream-status-indicator" style="display: none; padding: 8px; margin-bottom: 10px; border-radius: 4px; text-align: center;"></div>
|
||||
|
||||
<div id="audio-container">
|
||||
<audio id="live-audio" controls crossorigin="anonymous" style="width: 100%; margin: 10px 0;">
|
||||
<source id="audio-source" lquery="(attr :src default-stream-url :type default-stream-encoding)">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="now-playing" class="now-playing"></div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/front-page.js"></script>
|
||||
<script src="/asteroid/static/js/player.js"></script>
|
||||
<script src="/api/asteroid/spectrum-analyzer.js"></script>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/front-page.js"></script>
|
||||
<script src="/asteroid/static/js/player.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -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 "role") (string-downcase (symbol-name role))
|
||||
(dm:field user "active") (if active 1 0)
|
||||
(dm:field user "created-date") (local-time:timestamp-to-unix (local-time:now))
|
||||
;; Let database defaults handle created-date (CURRENT_TIMESTAMP)
|
||||
(dm:field user "last-login") nil)
|
||||
(handler-case
|
||||
(db:with-transaction ()
|
||||
|
|
@ -69,10 +69,13 @@
|
|||
(format t "Error during user data access: ~a~%" e)))
|
||||
(when (and (= 1 user-active)
|
||||
(verify-password password user-password))
|
||||
;; Update last login
|
||||
(setf (dm:field user "last-login") (local-time:timestamp-to-unix (local-time:now)))
|
||||
;; (dm:save user)
|
||||
(data-model-save user)
|
||||
;; Update last login using data-model (database agnostic)
|
||||
(handler-case
|
||||
(progn
|
||||
(setf (dm:field user "last-login") (format nil "~a" (local-time:now)))
|
||||
(dm:save user))
|
||||
(error (e)
|
||||
(format t "Warning: Could not update last-login: ~a~%" e)))
|
||||
user)))))
|
||||
|
||||
(defun hash-password (password)
|
||||
|
|
|
|||
Loading…
Reference in New Issue