;;;; admin.lisp - ParenScript version of admin.js
;;;; Admin Dashboard functionality including track management, queue controls, and player
(in-package #:asteroid)
(defparameter *admin-js*
(ps:ps*
'(progn
;; Global variables
(defvar *tracks* (array))
(defvar *current-track-id* nil)
(defvar *current-page* 1)
(defvar *tracks-per-page* 20)
(defvar *filtered-tracks* (array))
(defvar *stream-queue* (array))
(defvar *queue-search-timeout* nil)
(defvar *audio-player* nil)
;; Initialize admin dashboard on page load
(ps:chain document
(add-event-listener
"DOMContentLoaded"
(lambda ()
(load-tracks)
(setup-event-listeners)
(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 ()
;; Main controls
(let ((scan-btn (ps:chain document (get-element-by-id "scan-library")))
(refresh-btn (ps:chain document (get-element-by-id "refresh-tracks")))
(search-input (ps:chain document (get-element-by-id "track-search")))
(sort-select (ps:chain document (get-element-by-id "sort-tracks")))
(copy-btn (ps:chain document (get-element-by-id "copy-files")))
(open-btn (ps:chain document (get-element-by-id "open-incoming"))))
(when scan-btn
(ps:chain scan-btn (add-event-listener "click" scan-library)))
(when refresh-btn
(ps:chain refresh-btn (add-event-listener "click" load-tracks)))
(when search-input
(ps:chain search-input (add-event-listener "input" filter-tracks)))
(when sort-select
(ps:chain sort-select (add-event-listener "change" sort-tracks)))
(when copy-btn
(ps:chain copy-btn (add-event-listener "click" copy-files)))
(when open-btn
(ps:chain open-btn (add-event-listener "click" open-incoming-folder))))
;; Player controls
(let ((play-btn (ps:chain document (get-element-by-id "player-play")))
(pause-btn (ps:chain document (get-element-by-id "player-pause")))
(stop-btn (ps:chain document (get-element-by-id "player-stop")))
(resume-btn (ps:chain document (get-element-by-id "player-resume"))))
(when play-btn
(ps:chain play-btn (add-event-listener "click"
(lambda () (play-track *current-track-id*)))))
(when pause-btn
(ps:chain pause-btn (add-event-listener "click" pause-player)))
(when stop-btn
(ps:chain stop-btn (add-event-listener "click" stop-player)))
(when resume-btn
(ps:chain resume-btn (add-event-listener "click" resume-player))))
;; Queue controls
(let ((refresh-queue-btn (ps:chain document (get-element-by-id "refresh-queue")))
(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")))
;; 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-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)))
;; 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 ()
(ps:chain
(fetch "/api/asteroid/admin/tracks")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
;; Handle Radiance API response format
(let ((data (or (ps:@ result data) result)))
(when (= (ps:@ data status) "success")
(setf *tracks* (or (ps:@ data tracks) (array)))
(let ((count-el (ps:chain document (get-element-by-id "track-count"))))
(when count-el
(setf (ps:@ count-el text-content) (ps:@ *tracks* length))))
(display-tracks *tracks*)))))
(catch (lambda (error)
(ps:chain console (error "Error loading tracks:" error))
(let ((container (ps:chain document (get-element-by-id "tracks-container"))))
(when container
(setf (ps:@ container inner-h-t-m-l)
"
Error loading tracks
")))))))
;; Display tracks with pagination
(defun display-tracks (track-list)
(setf *filtered-tracks* track-list)
(setf *current-page* 1)
(render-page))
;; Render current page of tracks
(defun render-page ()
(let ((container (ps:chain document (get-element-by-id "tracks-container")))
(pagination-controls (ps:chain document (get-element-by-id "pagination-controls"))))
(when (= (ps:@ *filtered-tracks* length) 0)
(when container
(setf (ps:@ container inner-h-t-m-l)
"No tracks found. Click \"Scan Library\" to add tracks.
"))
(when pagination-controls
(setf (ps:@ pagination-controls style display) "none"))
(return))
;; Calculate pagination
(let* ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*))))
(start-index (* (- *current-page* 1) *tracks-per-page*))
(end-index (+ start-index *tracks-per-page*))
(tracks-to-show (ps:chain *filtered-tracks* (slice start-index end-index))))
;; Render tracks for current page
(let ((tracks-html
(ps:chain tracks-to-show
(map (lambda (track)
(+ ""
"
"
"
" (or (ps:@ track title) "Unknown Title") "
"
"
" (or (ps:@ track artist) "Unknown Artist") "
"
"
" (or (ps:@ track album) "Unknown Album") "
"
"
"
"
"
""
""
"
"
"
")))
(join ""))))
(when container
(setf (ps:@ container inner-h-t-m-l) tracks-html)))
;; Update pagination controls
(let ((page-info (ps:chain document (get-element-by-id "page-info"))))
(when page-info
(setf (ps:@ page-info text-content)
(+ "Page " *current-page* " of " total-pages " (" (ps:@ *filtered-tracks* length) " tracks)"))))
(when pagination-controls
(setf (ps:@ pagination-controls style display)
(if (> total-pages 1) "block" "none"))))))
;; Pagination functions
(defun go-to-page (page)
(let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*)))))
(when (and (>= page 1) (<= page total-pages))
(setf *current-page* page)
(render-page))))
(defun previous-page ()
(when (> *current-page* 1)
(setf *current-page* (- *current-page* 1))
(render-page)))
(defun next-page ()
(let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*)))))
(when (< *current-page* total-pages)
(setf *current-page* (+ *current-page* 1))
(render-page))))
(defun go-to-last-page ()
(let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*)))))
(setf *current-page* total-pages)
(render-page)))
(defun change-tracks-per-page ()
(let ((select-el (ps:chain document (get-element-by-id "tracks-per-page"))))
(when select-el
(setf *tracks-per-page* (parse-int (ps:@ select-el value)))
(setf *current-page* 1)
(render-page))))
;; Scan music library
(defun scan-library ()
(let ((status-el (ps:chain document (get-element-by-id "scan-status")))
(scan-btn (ps:chain document (get-element-by-id "scan-library"))))
(when status-el
(setf (ps:@ status-el text-content) "Scanning..."))
(when scan-btn
(setf (ps:@ scan-btn disabled) t))
(ps:chain
(fetch "/api/asteroid/admin/scan-library" (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
(when status-el
(setf (ps:@ status-el text-content)
(+ "✅ Added " (ps:getprop data "tracks-added") " tracks")))
(load-tracks))
(when status-el
(setf (ps:@ status-el text-content) "❌ Scan failed"))))))
(catch (lambda (error)
(when status-el
(setf (ps:@ status-el text-content) "❌ Scan error"))
(ps:chain console (error "Error scanning library:" error))))
(finally (lambda ()
(when scan-btn
(setf (ps:@ scan-btn disabled) nil))
(set-timeout (lambda ()
(when status-el
(setf (ps:@ status-el text-content) "")))
3000))))))
;; Filter tracks based on search
(defun filter-tracks ()
(let* ((search-input (ps:chain document (get-element-by-id "track-search")))
(query (when search-input (ps:chain (ps:@ search-input value) (to-lower-case))))
(filtered (ps:chain *tracks*
(filter (lambda (track)
(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)))
;; Sort tracks
(defun sort-tracks ()
(let* ((sort-select (ps:chain document (get-element-by-id "sort-tracks")))
(sort-by (when sort-select (ps:@ sort-select value)))
(sorted (ps:chain *tracks*
(slice)
(sort (lambda (a b)
(let ((a-val (or (ps:getprop a sort-by) ""))
(b-val (or (ps:getprop b sort-by) "")))
(ps:chain a-val (locale-compare b-val))))))))
(display-tracks sorted)))
;; Initialize audio player
(defun init-audio-player ()
(unless *audio-player*
(setf *audio-player* (new (-audio)))
(ps:chain *audio-player*
(add-event-listener "ended" (lambda ()
(setf *current-track-id* nil)
(update-player-status))))
(ps:chain *audio-player*
(add-event-listener "error" (lambda (e)
(ps:chain console (error "Audio playback error:" e))
(alert "Error playing audio file")))))
*audio-player*)
;; Player functions
(defun play-track (track-id)
(unless track-id
(alert "Please select a track to play")
(return))
(ps:chain
(-promise (lambda (resolve reject)
(let ((player (init-audio-player)))
(setf (ps:@ player src) (+ "/asteroid/tracks/" track-id "/stream"))
(ps:chain player (play))
(setf *current-track-id* track-id)
(update-player-status)
(resolve))))
(catch (lambda (error)
(ps:chain console (error "Play error:" error))
(alert "Error playing track")))))
(defun pause-player ()
(ps:chain
(-promise (lambda (resolve reject)
(when (and *audio-player* (not (ps:@ *audio-player* paused)))
(ps:chain *audio-player* (pause))
(update-player-status))
(resolve)))
(catch (lambda (error)
(ps:chain console (error "Pause error:" error))))))
(defun stop-player ()
(ps:chain
(-promise (lambda (resolve reject)
(when *audio-player*
(ps:chain *audio-player* (pause))
(setf (ps:@ *audio-player* current-time) 0)
(setf *current-track-id* nil)
(update-player-status))
(resolve)))
(catch (lambda (error)
(ps:chain console (error "Stop error:" error))))))
(defun resume-player ()
(ps:chain
(-promise (lambda (resolve reject)
(when (and *audio-player* (ps:@ *audio-player* paused) *current-track-id*)
(ps:chain *audio-player* (play))
(update-player-status))
(resolve)))
(catch (lambda (error)
(ps:chain console (error "Resume error:" error))))))
(defun update-player-status ()
(ps:chain
(fetch "/api/asteroid/player/status")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (data)
(when (= (ps:@ data status) "success")
(let ((player (ps:@ data player))
(state-el (ps:chain document (get-element-by-id "player-state")))
(track-el (ps:chain document (get-element-by-id "current-track"))))
(when state-el
(setf (ps:@ state-el text-content) (ps:@ player state)))
(when track-el
(setf (ps:@ track-el text-content) (or (ps:getprop player "current-track") "None")))))))
(catch (lambda (error)
(ps:chain console (error "Error updating player status:" error))))))
;; Utility functions
(defun stream-track (track-id)
(ps:chain window (open (+ "/asteroid/tracks/" track-id "/stream") "_blank")))
(defun delete-track (track-id)
(when (confirm "Are you sure you want to delete this track?")
(alert "Track deletion not yet implemented")))
(defun copy-files ()
(ps:chain
(fetch "/admin/copy-files")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (data)
(if (= (ps:@ data status) "success")
(progn
(alert (ps:@ data message))
(load-tracks))
(alert (+ "Error: " (ps:@ data message))))))
(catch (lambda (error)
(ps:chain console (error "Error copying files:" error))
(alert "Failed to copy files")))))
(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."))
;; ========================================
;; Stream Queue Management
;; ========================================
;; Load current stream queue
(defun load-stream-queue ()
(ps:chain
(fetch "/api/asteroid/stream/queue")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(when (= (ps:@ data status) "success")
(setf *stream-queue* (or (ps:@ data queue) (array)))
(display-stream-queue)))))
(catch (lambda (error)
(ps:chain console (error "Error loading stream queue:" error))
(let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
(when container
(setf (ps:@ container inner-h-t-m-l)
"Error loading queue
")))))))
;; Display stream queue
(defun display-stream-queue ()
(let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
(when container
(if (= (ps:@ *stream-queue* length) 0)
(setf (ps:@ container inner-h-t-m-l)
"Queue is empty. Add tracks below.
")
(let ((html ""))
(ps:chain *stream-queue*
(for-each (lambda (item index)
(when item
(let ((is-first (= index 0))
(is-last (= index (- (ps:@ *stream-queue* length) 1))))
(setf html
(+ html
"
"
"
" (+ index 1) ""
"
"
"
" (or (ps:@ item title) "Unknown") "
"
"
" (or (ps:@ item artist) "Unknown Artist") "
"
"
"
"
"
""
""
""
"
"
"
")))))))
(setf html (+ html "
"))
(setf (ps:@ container inner-h-t-m-l) html))))))
;; Move track up in queue
(defun move-track-up (index)
(when (= index 0) (return))
;; Swap with previous track
(let ((new-queue (ps:chain *stream-queue* (slice))))
(let ((temp (ps:getprop new-queue (- index 1))))
(setf (ps:getprop new-queue (- index 1)) (ps:getprop new-queue index))
(setf (ps:getprop new-queue index) temp))
(reorder-queue new-queue)))
;; Move track down in queue
(defun move-track-down (index)
(when (= index (- (ps:@ *stream-queue* length) 1)) (return))
;; Swap with next track
(let ((new-queue (ps:chain *stream-queue* (slice))))
(let ((temp (ps:getprop new-queue index)))
(setf (ps:getprop new-queue index) (ps:getprop new-queue (+ index 1)))
(setf (ps:getprop new-queue (+ index 1)) temp))
(reorder-queue new-queue)))
;; Reorder the queue
(defun reorder-queue (new-queue)
(let ((track-ids (ps:chain new-queue
(map (lambda (track) (ps:@ track id)))
(join ","))))
(ps:chain
(fetch (+ "/api/asteroid/stream/queue/reorder?track-ids=" track-ids)
(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")
(load-stream-queue)
(alert (+ "Error reordering queue: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error reordering queue:" error))
(alert "Error reordering queue"))))))
;; Remove track from queue
(defun remove-from-queue (track-id)
(ps:chain
(fetch "/api/asteroid/stream/queue/remove"
(ps:create :method "POST"
:headers (ps:create "Content-Type" "application/x-www-form-urlencoded")
:body (+ "track-id=" track-id)))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(load-stream-queue)
(alert (+ "Error removing track: " (or (ps:@ data message) "Unknown error")))))))
(catch (lambda (error)
(ps:chain console (error "Error removing track:" error))
(alert "Error removing track")))))
;; Add track to queue
(defun add-to-queue (track-id &optional (position "end") (show-notification t))
(ps:chain
(fetch "/api/asteroid/stream/queue/add"
(ps:create :method "POST"
:headers (ps:create "Content-Type" "application/x-www-form-urlencoded")
:body (+ "track-id=" track-id "&position=" position)))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(if (= (ps:@ data status) "success")
(progn
;; Only reload queue if we're in the queue management section
(let ((queue-container (ps:chain document (get-element-by-id "stream-queue-container"))))
(when (and queue-container (not (= (ps:@ queue-container offset-parent) nil)))
(load-stream-queue)))
;; Show brief success notification
(when show-notification
(show-toast "✓ Added to queue"))
t)
(progn
(alert (+ "Error adding track: " (or (ps:@ data message) "Unknown error")))
nil)))))
(catch (lambda (error)
(ps:chain console (error "Error adding track:" error))
(alert "Error adding track")
nil))))
;; Simple toast notification
(defun show-toast (message)
(let ((toast (ps:chain document (create-element "div"))))
(setf (ps:@ toast text-content) message)
(setf (ps:@ toast style css-text)
"position: fixed; bottom: 20px; right: 20px; background: #00ff00; color: #000; padding: 12px 20px; border-radius: 4px; font-weight: bold; z-index: 10000; animation: slideIn 0.3s ease-out;")
(ps:chain document body (append-child toast))
(set-timeout (lambda ()
(setf (ps:@ toast style opacity) "0")
(setf (ps:@ toast style transition) "opacity 0.3s")
(set-timeout (lambda () (ps:chain toast (remove))) 300))
2000)))
;; Add random tracks to queue
(defun add-random-tracks ()
(when (= (ps:@ *tracks* length) 0)
(alert "No tracks available. Please scan the library first.")
(return))
(let* ((count 10)
(shuffled (ps:chain *tracks* (slice) (sort (lambda () (- (ps:chain -math (random)) 0.5)))))
(selected (ps:chain shuffled (slice 0 (ps:chain -math (min count (ps:@ *tracks* length)))))))
(ps:chain selected
(for-each (lambda (track)
(add-to-queue (ps:@ track id) "end" nil))))
(show-toast (+ "✓ Added " (ps:@ selected length) " random tracks to queue"))))
;; Search tracks for adding to queue
(defun search-tracks-for-queue (event)
(clear-timeout *queue-search-timeout*)
(let ((query (ps:chain (ps:@ event target value) (to-lower-case))))
(when (< (ps:@ query length) 2)
(let ((results-container (ps:chain document (get-element-by-id "queue-track-results"))))
(when results-container
(setf (ps:@ results-container inner-h-t-m-l) "")))
(return))
(setf *queue-search-timeout*
(set-timeout (lambda ()
(let ((results (ps:chain *tracks*
(filter (lambda (track)
(or (and (ps:@ track title)
(ps:chain (ps:@ track title) (to-lower-case) (includes query)))
(and (ps:@ track artist)
(ps:chain (ps:@ track artist) (to-lower-case) (includes query)))
(and (ps:@ track album)
(ps:chain (ps:@ track album) (to-lower-case) (includes query))))))
(slice 0 20))))
(display-queue-search-results results)))
300))))
;; Display search results for queue
(defun display-queue-search-results (results)
(let ((container (ps:chain document (get-element-by-id "queue-track-results"))))
(when container
(if (= (ps:@ results length) 0)
(setf (ps:@ container inner-h-t-m-l)
"No tracks found
")
(let ((html ""))
(ps:chain results
(for-each (lambda (track)
(setf html
(+ html
"
"
"
"
"
" (or (ps:@ track title) "Unknown") "
"
"
" (or (ps:@ track artist) "Unknown") " - " (or (ps:@ track album) "Unknown Album") "
"
"
"
"
"
""
""
"
"
"
")))))
(setf html (+ html "
"))
(setf (ps:@ container inner-h-t-m-l) html))))))
;; ========================================
;; Playlist File Management
;; ========================================
;; Load list of available playlists into dropdown
(defun load-playlist-list ()
(ps:chain
(fetch "/api/asteroid/stream/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(let ((data (or (ps:@ result data) result)))
(when (= (ps:@ data status) "success")
(let ((select (ps:chain document (get-element-by-id "playlist-select")))
(playlists (or (ps:@ data playlists) (array))))
(when select
;; Clear existing options except the first one
(setf (ps:@ select inner-h-t-m-l)
"")
;; 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)
"Error loading queue
")))))))
;; 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)
"Queue is empty. Liquidsoap will use random playback from the music library.
")
(let ((html ""))
(ps:chain tracks
(for-each (lambda (track index)
(setf html
(+ html
"
"
"
" (+ index 1) ""
"
"
"
" (or (ps:@ track title) "Unknown") "
"
"
" (or (ps:@ track artist) "Unknown Artist")
(if (ps:@ track album) (+ " - " (ps:@ track album)) "") "
"
"
"
"
")))))
(setf html (+ html "
"))
(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)
(setf (ps:@ window next-page) next-page)
(setf (ps:@ window go-to-last-page) go-to-last-page)
(setf (ps:@ window change-tracks-per-page) change-tracks-per-page)
(setf (ps:@ window stream-track) stream-track)
(setf (ps:@ window delete-track) delete-track)
(setf (ps:@ window move-track-up) move-track-up)
(setf (ps:@ window move-track-down) move-track-down)
(setf (ps:@ window remove-from-queue) remove-from-queue)
(setf (ps:@ window add-to-queue) add-to-queue)
))
"Compiled JavaScript for admin dashboard - generated at load time")
(defun generate-admin-js ()
"Return the pre-compiled JavaScript for admin dashboard"
*admin-js*)