;;;; 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*)