875 lines
44 KiB
Common Lisp
875 lines
44 KiB
Common Lisp
;;;; 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*)
|