asteroid/parenscript/admin.lisp

875 lines
44 KiB
Common Lisp
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;;;; 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)
"<div class=\"error\">Error loading tracks</div>")))))))
;; 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)
"<div class=\"no-tracks\">No tracks found. Click \"Scan Library\" to add tracks.</div>"))
(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)
(+ "<div class=\"track-item\" data-track-id=\"" (ps:@ track id) "\">"
"<div class=\"track-info\">"
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>"
"<div class=\"track-artist\">" (or (ps:@ track artist) "Unknown Artist") "</div>"
"<div class=\"track-album\">" (or (ps:@ track album) "Unknown Album") "</div>"
"</div>"
"<div class=\"track-actions\">"
"<button onclick=\"addToQueue(" (ps:@ track id) ", 'end')\" class=\"btn btn-sm btn-primary\"> Add to Queue</button>"
"<button onclick=\"deleteTrack(" (ps:@ track id) ")\" class=\"btn btn-sm btn-danger\">🗑️ Delete</button>"
"</div>"
"</div>")))
(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)
"<div class=\"error\">Error loading queue</div>")))))))
;; 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)
"<div class=\"empty-state\">Queue is empty. Add tracks below.</div>")
(let ((html "<div class=\"queue-items\">"))
(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
"<div class=\"queue-item\" data-track-id=\"" (ps:@ item id) "\" data-index=\"" index "\">"
"<span class=\"queue-position\">" (+ index 1) "</span>"
"<div class=\"queue-track-info\">"
"<div class=\"track-title\">" (or (ps:@ item title) "Unknown") "</div>"
"<div class=\"track-artist\">" (or (ps:@ item artist) "Unknown Artist") "</div>"
"</div>"
"<div class=\"queue-actions\">"
"<button class=\"btn btn-sm btn-secondary\" onclick=\"moveTrackUp(" index ")\" " (if is-first "disabled" "") ">⬆️</button>"
"<button class=\"btn btn-sm btn-secondary\" onclick=\"moveTrackDown(" index ")\" " (if is-last "disabled" "") ">⬇️</button>"
"<button class=\"btn btn-sm btn-danger\" onclick=\"removeFromQueue(" (ps:@ item id) ")\">Remove</button>"
"</div>"
"</div>")))))))
(setf html (+ html "</div>"))
(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)
"<div class=\"empty-state\">No tracks found</div>")
(let ((html "<div class=\"search-results\">"))
(ps:chain results
(for-each (lambda (track)
(setf html
(+ html
"<div class=\"search-result-item\">"
"<div class=\"track-info\">"
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown") "</div>"
"<div class=\"track-artist\">" (or (ps:@ track artist) "Unknown") " - " (or (ps:@ track album) "Unknown Album") "</div>"
"</div>"
"<div class=\"track-actions\">"
"<button class=\"btn btn-sm btn-primary\" onclick=\"addToQueue(" (ps:@ track id) ", 'end')\">Add to End</button>"
"<button class=\"btn btn-sm btn-success\" onclick=\"addToQueue(" (ps:@ track id) ", 'next')\">Play Next</button>"
"</div>"
"</div>")))))
(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)
(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*)