/ parenscript / admin.lisp
admin.lisp
  1  ;;;; admin.lisp - ParenScript version of admin.js
  2  ;;;; Admin Dashboard functionality including track management, queue controls, and player
  3  
  4  (in-package #:asteroid)
  5  
  6  (defparameter *admin-js*
  7    (ps:ps*
  8     '(progn
  9      
 10      ;; Global variables
 11      (defvar *tracks* (array))
 12      (defvar *current-track-id* nil)
 13      (defvar *current-page* 1)
 14      (defvar *tracks-per-page* 20)
 15      (defvar *filtered-tracks* (array))
 16      (defvar *stream-queue* (array))
 17      (defvar *queue-search-timeout* nil)
 18      (defvar *audio-player* nil)
 19      
 20      ;; Initialize admin dashboard on page load
 21      (ps:chain document
 22                (add-event-listener
 23                 "DOMContentLoaded"
 24                 (lambda ()
 25                   (load-tracks)
 26                   (setup-event-listeners)
 27                   (load-playlist-list)
 28                   (load-current-queue)
 29                   (refresh-liquidsoap-status)
 30                   ;; Update Liquidsoap status every 10 seconds
 31                   (set-interval refresh-liquidsoap-status 10000))))
 32      
 33      ;; Setup all event listeners
 34      (defun setup-event-listeners ()
 35        ;; Main controls
 36        (let ((scan-btn (ps:chain document (get-element-by-id "scan-library")))
 37              (refresh-btn (ps:chain document (get-element-by-id "refresh-tracks")))
 38              (search-input (ps:chain document (get-element-by-id "track-search")))
 39              (sort-select (ps:chain document (get-element-by-id "sort-tracks")))
 40              (copy-btn (ps:chain document (get-element-by-id "copy-files")))
 41              (open-btn (ps:chain document (get-element-by-id "open-incoming"))))
 42          
 43          (when scan-btn
 44            (ps:chain scan-btn (add-event-listener "click" scan-library)))
 45          (when refresh-btn
 46            (ps:chain refresh-btn (add-event-listener "click" load-tracks)))
 47          (when search-input
 48            (ps:chain search-input (add-event-listener "input" filter-tracks)))
 49          (when sort-select
 50            (ps:chain sort-select (add-event-listener "change" sort-tracks)))
 51          (when copy-btn
 52            (ps:chain copy-btn (add-event-listener "click" copy-files)))
 53          (when open-btn
 54            (ps:chain open-btn (add-event-listener "click" open-incoming-folder))))
 55        
 56        ;; Player controls
 57        (let ((play-btn (ps:chain document (get-element-by-id "player-play")))
 58              (pause-btn (ps:chain document (get-element-by-id "player-pause")))
 59              (stop-btn (ps:chain document (get-element-by-id "player-stop")))
 60              (resume-btn (ps:chain document (get-element-by-id "player-resume"))))
 61          
 62          (when play-btn
 63            (ps:chain play-btn (add-event-listener "click" 
 64                                                   (lambda () (play-track *current-track-id*)))))
 65          (when pause-btn
 66            (ps:chain pause-btn (add-event-listener "click" pause-player)))
 67          (when stop-btn
 68            (ps:chain stop-btn (add-event-listener "click" stop-player)))
 69          (when resume-btn
 70            (ps:chain resume-btn (add-event-listener "click" resume-player))))
 71        
 72        ;; Queue controls
 73        (let ((refresh-queue-btn (ps:chain document (get-element-by-id "refresh-queue")))
 74              (clear-queue-btn (ps:chain document (get-element-by-id "clear-queue-btn")))
 75              (save-queue-btn (ps:chain document (get-element-by-id "save-queue-btn")))
 76              (save-as-btn (ps:chain document (get-element-by-id "save-as-btn")))
 77              (add-random-btn (ps:chain document (get-element-by-id "add-random-tracks")))
 78              (queue-search-input (ps:chain document (get-element-by-id "queue-track-search")))
 79              ;; Playlist controls
 80              (playlist-select (ps:chain document (get-element-by-id "playlist-select")))
 81              (load-playlist-btn (ps:chain document (get-element-by-id "load-playlist-btn")))
 82              (refresh-playlists-btn (ps:chain document (get-element-by-id "refresh-playlists-btn"))))
 83          
 84          (when refresh-queue-btn
 85            (ps:chain refresh-queue-btn (add-event-listener "click" load-current-queue)))
 86          (when clear-queue-btn
 87            (ps:chain clear-queue-btn (add-event-listener "click" clear-stream-queue)))
 88          (when save-queue-btn
 89            (ps:chain save-queue-btn (add-event-listener "click" save-stream-queue)))
 90          (when save-as-btn
 91            (ps:chain save-as-btn (add-event-listener "click" save-queue-as-new)))
 92          (when add-random-btn
 93            (ps:chain add-random-btn (add-event-listener "click" add-random-tracks)))
 94          (when queue-search-input
 95            (ps:chain queue-search-input (add-event-listener "input" search-tracks-for-queue)))
 96          ;; Playlist controls
 97          (when load-playlist-btn
 98            (ps:chain load-playlist-btn (add-event-listener "click" load-selected-playlist)))
 99          (when refresh-playlists-btn
100            (ps:chain refresh-playlists-btn (add-event-listener "click" load-playlist-list))))
101        
102        ;; Liquidsoap controls
103        (let ((ls-refresh-btn (ps:chain document (get-element-by-id "ls-refresh-status")))
104              (ls-skip-btn (ps:chain document (get-element-by-id "ls-skip")))
105              (ls-reload-btn (ps:chain document (get-element-by-id "ls-reload")))
106              (ls-restart-btn (ps:chain document (get-element-by-id "ls-restart"))))
107          (when ls-refresh-btn
108            (ps:chain ls-refresh-btn (add-event-listener "click" refresh-liquidsoap-status)))
109          (when ls-skip-btn
110            (ps:chain ls-skip-btn (add-event-listener "click" liquidsoap-skip)))
111          (when ls-reload-btn
112            (ps:chain ls-reload-btn (add-event-listener "click" liquidsoap-reload)))
113          (when ls-restart-btn
114            (ps:chain ls-restart-btn (add-event-listener "click" liquidsoap-restart))))
115        
116        ;; Icecast restart
117        (let ((icecast-restart-btn (ps:chain document (get-element-by-id "icecast-restart"))))
118          (when icecast-restart-btn
119            (ps:chain icecast-restart-btn (add-event-listener "click" icecast-restart)))))
120      
121      ;; Load tracks from API
122      (defun load-tracks ()
123        (ps:chain
124         (fetch "/api/asteroid/admin/tracks")
125         (then (lambda (response) (ps:chain response (json))))
126         (then (lambda (result)
127                 ;; Handle Radiance API response format
128                 (let ((data (or (ps:@ result data) result)))
129                   (when (= (ps:@ data status) "success")
130                     (setf *tracks* (or (ps:@ data tracks) (array)))
131                     (let ((count-el (ps:chain document (get-element-by-id "track-count"))))
132                       (when count-el
133                         (setf (ps:@ count-el text-content) (ps:@ *tracks* length))))
134                     (display-tracks *tracks*)))))
135         (catch (lambda (error)
136                  (ps:chain console (error "Error loading tracks:" error))
137                  (let ((container (ps:chain document (get-element-by-id "tracks-container"))))
138                    (when container
139                      (setf (ps:@ container inner-h-t-m-l) 
140                            "<div class=\"error\">Error loading tracks</div>")))))))
141      
142      ;; Display tracks with pagination
143      (defun display-tracks (track-list)
144        (setf *filtered-tracks* track-list)
145        (setf *current-page* 1)
146        (render-page))
147      
148      ;; Render current page of tracks
149      (defun render-page ()
150        (let ((container (ps:chain document (get-element-by-id "tracks-container")))
151              (pagination-controls (ps:chain document (get-element-by-id "pagination-controls"))))
152          
153          (when (= (ps:@ *filtered-tracks* length) 0)
154            (when container
155              (setf (ps:@ container inner-h-t-m-l) 
156                    "<div class=\"no-tracks\">No tracks found. Click \"Scan Library\" to add tracks.</div>"))
157            (when pagination-controls
158              (setf (ps:@ pagination-controls style display) "none"))
159            (return))
160          
161          ;; Calculate pagination
162          (let* ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*))))
163                 (start-index (* (- *current-page* 1) *tracks-per-page*))
164                 (end-index (+ start-index *tracks-per-page*))
165                 (tracks-to-show (ps:chain *filtered-tracks* (slice start-index end-index))))
166            
167            ;; Render tracks for current page
168            (let ((tracks-html 
169                   (ps:chain tracks-to-show
170                             (map (lambda (track)
171                                    (+ "<div class=\"track-item\" data-track-id=\"" (ps:@ track id) "\">"
172                                       "<div class=\"track-info\">"
173                                       "<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>"
174                                       "<div class=\"track-artist\">" (or (ps:@ track artist) "Unknown Artist") "</div>"
175                                       "<div class=\"track-album\">" (or (ps:@ track album) "Unknown Album") "</div>"
176                                       "</div>"
177                                       "<div class=\"track-actions\">"
178                                       "<button onclick=\"addToQueue(" (ps:@ track id) ", 'end')\" class=\"btn btn-sm btn-primary\">➕ Add to Queue</button>"
179                                       "<button onclick=\"deleteTrack(" (ps:@ track id) ")\" class=\"btn btn-sm btn-danger\">🗑️ Delete</button>"
180                                       "</div>"
181                                       "</div>")))
182                             (join ""))))
183              
184              (when container
185                (setf (ps:@ container inner-h-t-m-l) tracks-html)))
186            
187            ;; Update pagination controls
188            (let ((page-info (ps:chain document (get-element-by-id "page-info"))))
189              (when page-info
190                (setf (ps:@ page-info text-content) 
191                      (+ "Page " *current-page* " of " total-pages " (" (ps:@ *filtered-tracks* length) " tracks)"))))
192            
193            (when pagination-controls
194              (setf (ps:@ pagination-controls style display) 
195                    (if (> total-pages 1) "block" "none"))))))
196      
197      ;; Pagination functions
198      (defun go-to-page (page)
199        (let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*)))))
200          (when (and (>= page 1) (<= page total-pages))
201            (setf *current-page* page)
202            (render-page))))
203      
204      (defun previous-page ()
205        (when (> *current-page* 1)
206          (setf *current-page* (- *current-page* 1))
207          (render-page)))
208      
209      (defun next-page ()
210        (let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*)))))
211          (when (< *current-page* total-pages)
212            (setf *current-page* (+ *current-page* 1))
213            (render-page))))
214      
215      (defun go-to-last-page ()
216        (let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*)))))
217          (setf *current-page* total-pages)
218          (render-page)))
219      
220      (defun change-tracks-per-page ()
221        (let ((select-el (ps:chain document (get-element-by-id "tracks-per-page"))))
222          (when select-el
223            (setf *tracks-per-page* (parse-int (ps:@ select-el value)))
224            (setf *current-page* 1)
225            (render-page))))
226      
227      ;; Scan music library
228      (defun scan-library ()
229        (let ((status-el (ps:chain document (get-element-by-id "scan-status")))
230              (scan-btn (ps:chain document (get-element-by-id "scan-library"))))
231          
232          (when status-el
233            (setf (ps:@ status-el text-content) "Scanning..."))
234          (when scan-btn
235            (setf (ps:@ scan-btn disabled) t))
236          
237          (ps:chain
238           (fetch "/api/asteroid/admin/scan-library" (ps:create :method "POST"))
239           (then (lambda (response) (ps:chain response (json))))
240           (then (lambda (result)
241                   (let ((data (or (ps:@ result data) result)))
242                     (if (= (ps:@ data status) "success")
243                         (progn
244                           (when status-el
245                             (setf (ps:@ status-el text-content) 
246                                   (+ "✅ Added " (ps:getprop data "tracks-added") " tracks")))
247                           (load-tracks))
248                         (when status-el
249                           (setf (ps:@ status-el text-content) "❌ Scan failed"))))))
250           (catch (lambda (error)
251                    (when status-el
252                      (setf (ps:@ status-el text-content) "❌ Scan error"))
253                    (ps:chain console (error "Error scanning library:" error))))
254           (finally (lambda ()
255                      (when scan-btn
256                        (setf (ps:@ scan-btn disabled) nil))
257                      (set-timeout (lambda ()
258                                     (when status-el
259                                       (setf (ps:@ status-el text-content) "")))
260                                   3000))))))
261      
262      ;; Filter tracks based on search
263      (defun filter-tracks ()
264        (let* ((search-input (ps:chain document (get-element-by-id "track-search")))
265               (query (when search-input (ps:chain (ps:@ search-input value) (to-lower-case))))
266               (filtered (ps:chain *tracks*
267                                   (filter (lambda (track)
268                                             (or (ps:chain (or (ps:@ track title) "") (to-lower-case) (includes query))
269                                                 (ps:chain (or (ps:@ track artist) "") (to-lower-case) (includes query))
270                                                 (ps:chain (or (ps:@ track album) "") (to-lower-case) (includes query))))))))
271          (display-tracks filtered)))
272      
273      ;; Sort tracks
274      (defun sort-tracks ()
275        (let* ((sort-select (ps:chain document (get-element-by-id "sort-tracks")))
276               (sort-by (when sort-select (ps:@ sort-select value)))
277               (sorted (ps:chain *tracks*
278                                 (slice)
279                                 (sort (lambda (a b)
280                                         (let ((a-val (or (ps:getprop a sort-by) ""))
281                                               (b-val (or (ps:getprop b sort-by) "")))
282                                           (ps:chain a-val (locale-compare b-val))))))))
283          (display-tracks sorted)))
284      
285      ;; Initialize audio player
286      (defun init-audio-player ()
287        (unless *audio-player*
288          (setf *audio-player* (new (-audio)))
289          (ps:chain *audio-player*
290                    (add-event-listener "ended" (lambda ()
291                                                  (setf *current-track-id* nil)
292                                                  (update-player-status))))
293          (ps:chain *audio-player*
294                    (add-event-listener "error" (lambda (e)
295                                                  (ps:chain console (error "Audio playback error:" e))
296                                                  (alert "Error playing audio file")))))
297        *audio-player*)
298      
299      ;; Player functions
300      (defun play-track (track-id)
301        (unless track-id
302          (alert "Please select a track to play")
303          (return))
304        
305        (ps:chain
306         (-promise (lambda (resolve reject)
307                     (let ((player (init-audio-player)))
308                       (setf (ps:@ player src) (+ "/asteroid/tracks/" track-id "/stream"))
309                       (ps:chain player (play))
310                       (setf *current-track-id* track-id)
311                       (update-player-status)
312                       (resolve))))
313         (catch (lambda (error)
314                  (ps:chain console (error "Play error:" error))
315                  (alert "Error playing track")))))
316      
317      (defun pause-player ()
318        (ps:chain
319         (-promise (lambda (resolve reject)
320                     (when (and *audio-player* (not (ps:@ *audio-player* paused)))
321                       (ps:chain *audio-player* (pause))
322                       (update-player-status))
323                     (resolve)))
324         (catch (lambda (error)
325                  (ps:chain console (error "Pause error:" error))))))
326      
327      (defun stop-player ()
328        (ps:chain
329         (-promise (lambda (resolve reject)
330                     (when *audio-player*
331                       (ps:chain *audio-player* (pause))
332                       (setf (ps:@ *audio-player* current-time) 0)
333                       (setf *current-track-id* nil)
334                       (update-player-status))
335                     (resolve)))
336         (catch (lambda (error)
337                  (ps:chain console (error "Stop error:" error))))))
338      
339      (defun resume-player ()
340        (ps:chain
341         (-promise (lambda (resolve reject)
342                     (when (and *audio-player* (ps:@ *audio-player* paused) *current-track-id*)
343                       (ps:chain *audio-player* (play))
344                       (update-player-status))
345                     (resolve)))
346         (catch (lambda (error)
347                  (ps:chain console (error "Resume error:" error))))))
348      
349      (defun update-player-status ()
350        (ps:chain
351         (fetch "/api/asteroid/player/status")
352         (then (lambda (response) (ps:chain response (json))))
353         (then (lambda (data)
354                 (when (= (ps:@ data status) "success")
355                   (let ((player (ps:@ data player))
356                         (state-el (ps:chain document (get-element-by-id "player-state")))
357                         (track-el (ps:chain document (get-element-by-id "current-track"))))
358                     (when state-el
359                       (setf (ps:@ state-el text-content) (ps:@ player state)))
360                     (when track-el
361                       (setf (ps:@ track-el text-content) (or (ps:getprop player "current-track") "None")))))))
362         (catch (lambda (error)
363                  (ps:chain console (error "Error updating player status:" error))))))
364      
365      ;; Utility functions
366      (defun stream-track (track-id)
367        (ps:chain window (open (+ "/asteroid/tracks/" track-id "/stream") "_blank")))
368      
369      (defun delete-track (track-id)
370        (when (confirm "Are you sure you want to delete this track?")
371          (alert "Track deletion not yet implemented")))
372      
373      (defun copy-files ()
374        (ps:chain
375         (fetch "/admin/copy-files")
376         (then (lambda (response) (ps:chain response (json))))
377         (then (lambda (data)
378                 (if (= (ps:@ data status) "success")
379                     (progn
380                       (alert (ps:@ data message))
381                       (load-tracks))
382                     (alert (+ "Error: " (ps:@ data message))))))
383         (catch (lambda (error)
384                  (ps:chain console (error "Error copying files:" error))
385                  (alert "Failed to copy files")))))
386      
387      (defun open-incoming-folder ()
388        (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."))
389      
390      ;; ========================================
391      ;; Stream Queue Management
392      ;; ========================================
393      
394      ;; Load current stream queue
395      (defun load-stream-queue ()
396        (ps:chain
397         (fetch "/api/asteroid/stream/queue")
398         (then (lambda (response) (ps:chain response (json))))
399         (then (lambda (result)
400                 (let ((data (or (ps:@ result data) result)))
401                   (when (= (ps:@ data status) "success")
402                     (setf *stream-queue* (or (ps:@ data queue) (array)))
403                     (display-stream-queue)))))
404         (catch (lambda (error)
405                  (ps:chain console (error "Error loading stream queue:" error))
406                  (let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
407                    (when container
408                      (setf (ps:@ container inner-h-t-m-l) 
409                            "<div class=\"error\">Error loading queue</div>")))))))
410      
411      ;; Display stream queue
412      (defun display-stream-queue ()
413        (let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
414          (when container
415            (if (= (ps:@ *stream-queue* length) 0)
416                (setf (ps:@ container inner-h-t-m-l) 
417                      "<div class=\"empty-state\">Queue is empty. Add tracks below.</div>")
418                (let ((html "<div class=\"queue-items\">"))
419                  (ps:chain *stream-queue*
420                            (for-each (lambda (item index)
421                                        (when item
422                                          (let ((is-first (= index 0))
423                                                (is-last (= index (- (ps:@ *stream-queue* length) 1))))
424                                            (setf html 
425                                                  (+ html
426                                                     "<div class=\"queue-item\" data-track-id=\"" (ps:@ item id) "\" data-index=\"" index "\">"
427                                                     "<span class=\"queue-position\">" (+ index 1) "</span>"
428                                                     "<div class=\"queue-track-info\">"
429                                                     "<div class=\"track-title\">" (or (ps:@ item title) "Unknown") "</div>"
430                                                     "<div class=\"track-artist\">" (or (ps:@ item artist) "Unknown Artist") "</div>"
431                                                     "</div>"
432                                                     "<div class=\"queue-actions\">"
433                                                     "<button class=\"btn btn-sm btn-secondary\" onclick=\"moveTrackUp(" index ")\" " (if is-first "disabled" "") ">⬆️</button>"
434                                                     "<button class=\"btn btn-sm btn-secondary\" onclick=\"moveTrackDown(" index ")\" " (if is-last "disabled" "") ">⬇️</button>"
435                                                     "<button class=\"btn btn-sm btn-danger\" onclick=\"removeFromQueue(" (ps:@ item id) ")\">Remove</button>"
436                                                     "</div>"
437                                                     "</div>")))))))
438                  (setf html (+ html "</div>"))
439                  (setf (ps:@ container inner-h-t-m-l) html))))))
440      
441      ;; Move track up in queue
442      (defun move-track-up (index)
443        (when (= index 0) (return))
444        
445        ;; Swap with previous track
446        (let ((new-queue (ps:chain *stream-queue* (slice))))
447          (let ((temp (ps:getprop new-queue (- index 1))))
448            (setf (ps:getprop new-queue (- index 1)) (ps:getprop new-queue index))
449            (setf (ps:getprop new-queue index) temp))
450          (reorder-queue new-queue)))
451      
452      ;; Move track down in queue
453      (defun move-track-down (index)
454        (when (= index (- (ps:@ *stream-queue* length) 1)) (return))
455        
456        ;; Swap with next track
457        (let ((new-queue (ps:chain *stream-queue* (slice))))
458          (let ((temp (ps:getprop new-queue index)))
459            (setf (ps:getprop new-queue index) (ps:getprop new-queue (+ index 1)))
460            (setf (ps:getprop new-queue (+ index 1)) temp))
461          (reorder-queue new-queue)))
462      
463      ;; Reorder the queue
464      (defun reorder-queue (new-queue)
465        (let ((track-ids (ps:chain new-queue
466                                   (map (lambda (track) (ps:@ track id)))
467                                   (join ","))))
468          (ps:chain
469           (fetch (+ "/api/asteroid/stream/queue/reorder?track-ids=" track-ids)
470                  (ps:create :method "POST"))
471           (then (lambda (response) (ps:chain response (json))))
472           (then (lambda (result)
473                   (let ((data (or (ps:@ result data) result)))
474                     (if (= (ps:@ data status) "success")
475                         (load-stream-queue)
476                         (alert (+ "Error reordering queue: " (or (ps:@ data message) "Unknown error")))))))
477           (catch (lambda (error)
478                    (ps:chain console (error "Error reordering queue:" error))
479                    (alert "Error reordering queue"))))))
480      
481      ;; Remove track from queue
482      (defun remove-from-queue (track-id)
483        (ps:chain
484         (fetch "/api/asteroid/stream/queue/remove"
485                (ps:create :method "POST"
486                           :headers (ps:create "Content-Type" "application/x-www-form-urlencoded")
487                           :body (+ "track-id=" track-id)))
488         (then (lambda (response) (ps:chain response (json))))
489         (then (lambda (result)
490                 (let ((data (or (ps:@ result data) result)))
491                   (if (= (ps:@ data status) "success")
492                       (load-stream-queue)
493                       (alert (+ "Error removing track: " (or (ps:@ data message) "Unknown error")))))))
494         (catch (lambda (error)
495                  (ps:chain console (error "Error removing track:" error))
496                  (alert "Error removing track")))))
497      
498      ;; Add track to queue
499      (defun add-to-queue (track-id &optional (position "end") (show-notification t))
500        (ps:chain
501         (fetch "/api/asteroid/stream/queue/add"
502                (ps:create :method "POST"
503                           :headers (ps:create "Content-Type" "application/x-www-form-urlencoded")
504                           :body (+ "track-id=" track-id "&position=" position)))
505         (then (lambda (response) (ps:chain response (json))))
506         (then (lambda (result)
507                 (let ((data (or (ps:@ result data) result)))
508                   (if (= (ps:@ data status) "success")
509                       (progn
510                         ;; Only reload queue if we're in the queue management section
511                         (let ((queue-container (ps:chain document (get-element-by-id "stream-queue-container"))))
512                           (when (and queue-container (not (= (ps:@ queue-container offset-parent) nil)))
513                             (load-stream-queue)))
514                         
515                         ;; Show brief success notification
516                         (when show-notification
517                           (show-toast "✓ Added to queue"))
518                         t)
519                       (progn
520                         (alert (+ "Error adding track: " (or (ps:@ data message) "Unknown error")))
521                         nil)))))
522         (catch (lambda (error)
523                  (ps:chain console (error "Error adding track:" error))
524                  (alert "Error adding track")
525                  nil))))
526      
527      ;; Simple toast notification
528      (defun show-toast (message)
529        (let ((toast (ps:chain document (create-element "div"))))
530          (setf (ps:@ toast text-content) message)
531          (setf (ps:@ toast style css-text)
532                "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;")
533          (ps:chain document body (append-child toast))
534          
535          (set-timeout (lambda ()
536                         (setf (ps:@ toast style opacity) "0")
537                         (setf (ps:@ toast style transition) "opacity 0.3s")
538                         (set-timeout (lambda () (ps:chain toast (remove))) 300))
539                       2000)))
540      
541      ;; Add random tracks to queue
542      (defun add-random-tracks ()
543        (when (= (ps:@ *tracks* length) 0)
544          (alert "No tracks available. Please scan the library first.")
545          (return))
546        
547        (let* ((count 10)
548               (shuffled (ps:chain *tracks* (slice) (sort (lambda () (- (ps:chain -math (random)) 0.5)))))
549               (selected (ps:chain shuffled (slice 0 (ps:chain -math (min count (ps:@ *tracks* length)))))))
550          
551          (ps:chain selected
552                    (for-each (lambda (track)
553                                (add-to-queue (ps:@ track id) "end" nil))))
554          
555          (show-toast (+ "✓ Added " (ps:@ selected length) " random tracks to queue"))))
556      
557      ;; Search tracks for adding to queue
558      (defun search-tracks-for-queue (event)
559        (clear-timeout *queue-search-timeout*)
560        (let ((query (ps:chain (ps:@ event target value) (to-lower-case))))
561          
562          (when (< (ps:@ query length) 2)
563            (let ((results-container (ps:chain document (get-element-by-id "queue-track-results"))))
564              (when results-container
565                (setf (ps:@ results-container inner-h-t-m-l) "")))
566            (return))
567          
568          (setf *queue-search-timeout*
569                (set-timeout (lambda ()
570                               (let ((results (ps:chain *tracks*
571                                                         (filter (lambda (track)
572                                                                   (or (and (ps:@ track title) 
573                                                                            (ps:chain (ps:@ track title) (to-lower-case) (includes query)))
574                                                                       (and (ps:@ track artist) 
575                                                                            (ps:chain (ps:@ track artist) (to-lower-case) (includes query)))
576                                                                       (and (ps:@ track album) 
577                                                                            (ps:chain (ps:@ track album) (to-lower-case) (includes query))))))
578                                                         (slice 0 20))))
579                                 (display-queue-search-results results)))
580                             300))))
581      
582      ;; Display search results for queue
583      (defun display-queue-search-results (results)
584        (let ((container (ps:chain document (get-element-by-id "queue-track-results"))))
585          (when container
586            (if (= (ps:@ results length) 0)
587                (setf (ps:@ container inner-h-t-m-l) 
588                      "<div class=\"empty-state\">No tracks found</div>")
589                (let ((html "<div class=\"search-results\">"))
590                  (ps:chain results
591                            (for-each (lambda (track)
592                                        (setf html
593                                              (+ html
594                                                 "<div class=\"search-result-item\">"
595                                                 "<div class=\"track-info\">"
596                                                 "<div class=\"track-title\">" (or (ps:@ track title) "Unknown") "</div>"
597                                                 "<div class=\"track-artist\">" (or (ps:@ track artist) "Unknown") " - " (or (ps:@ track album) "Unknown Album") "</div>"
598                                                 "</div>"
599                                                 "<div class=\"track-actions\">"
600                                                 "<button class=\"btn btn-sm btn-primary\" onclick=\"addToQueue(" (ps:@ track id) ", 'end')\">Add to End</button>"
601                                                 "<button class=\"btn btn-sm btn-success\" onclick=\"addToQueue(" (ps:@ track id) ", 'next')\">Play Next</button>"
602                                                 "</div>"
603                                                 "</div>")))))
604                  (setf html (+ html "</div>"))
605                  (setf (ps:@ container inner-h-t-m-l) html))))))
606      
607      ;; ========================================
608      ;; Playlist File Management
609      ;; ========================================
610      
611      ;; Load list of available playlists into dropdown
612      (defun load-playlist-list ()
613        (ps:chain
614         (fetch "/api/asteroid/stream/playlists")
615         (then (lambda (response) (ps:chain response (json))))
616         (then (lambda (result)
617                 (let ((data (or (ps:@ result data) result)))
618                   (when (= (ps:@ data status) "success")
619                     (let ((select (ps:chain document (get-element-by-id "playlist-select")))
620                           (playlists (or (ps:@ data playlists) (array))))
621                       (when select
622                         ;; Clear existing options except the first one
623                         (setf (ps:@ select inner-h-t-m-l) 
624                               "<option value=\"\">-- Select a playlist --</option>")
625                         ;; Add playlist options
626                         (ps:chain playlists
627                                   (for-each (lambda (name)
628                                               (let ((option (ps:chain document (create-element "option"))))
629                                                 (setf (ps:@ option value) name)
630                                                 (setf (ps:@ option text-content) name)
631                                                 (ps:chain select (append-child option))))))))))))
632         (catch (lambda (error)
633                  (ps:chain console (error "Error loading playlists:" error))))))
634      
635      ;; Load selected playlist
636      (defun load-selected-playlist ()
637        (let* ((select (ps:chain document (get-element-by-id "playlist-select")))
638               (name (ps:@ select value)))
639          (when (= name "")
640            (alert "Please select a playlist first")
641            (return))
642          
643          (unless (confirm (+ "Load playlist '" name "'? This will replace the current stream queue."))
644            (return))
645          
646          (ps:chain
647           (fetch (+ "/api/asteroid/stream/playlists/load?name=" (encode-u-r-i-component name))
648                  (ps:create :method "POST"))
649           (then (lambda (response) (ps:chain response (json))))
650           (then (lambda (result)
651                   (let ((data (or (ps:@ result data) result)))
652                     (if (= (ps:@ data status) "success")
653                         (progn
654                           (show-toast (+ "✓ Loaded " (ps:@ data count) " tracks from " name))
655                           (load-current-queue))
656                         (alert (+ "Error loading playlist: " (or (ps:@ data message) "Unknown error")))))))
657           (catch (lambda (error)
658                    (ps:chain console (error "Error loading playlist:" error))
659                    (alert "Error loading playlist"))))))
660      
661      ;; Load current queue contents (from stream-queue.m3u)
662      (defun load-current-queue ()
663        (ps:chain
664         (fetch "/api/asteroid/stream/playlists/current")
665         (then (lambda (response) (ps:chain response (json))))
666         (then (lambda (result)
667                 (let ((data (or (ps:@ result data) result)))
668                   (when (= (ps:@ data status) "success")
669                     (let ((tracks (or (ps:@ data tracks) (array)))
670                           (count (or (ps:@ data count) 0)))
671                       ;; Update count display
672                       (let ((count-el (ps:chain document (get-element-by-id "queue-count"))))
673                         (when count-el
674                           (setf (ps:@ count-el text-content) count)))
675                       ;; Display tracks
676                       (display-current-queue tracks))))))
677         (catch (lambda (error)
678                  (ps:chain console (error "Error loading current queue:" error))
679                  (let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
680                    (when container
681                      (setf (ps:@ container inner-h-t-m-l) 
682                            "<div class=\"error\">Error loading queue</div>")))))))
683      
684      ;; Display current queue contents
685      (defun display-current-queue (tracks)
686        (let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
687          (when container
688            (if (= (ps:@ tracks length) 0)
689                (setf (ps:@ container inner-h-t-m-l) 
690                      "<div class=\"empty-state\">Queue is empty. Liquidsoap will use random playback from the music library.</div>")
691                (let ((html "<div class=\"queue-items\">"))
692                  (ps:chain tracks
693                            (for-each (lambda (track index)
694                                        (setf html 
695                                              (+ html
696                                                 "<div class=\"queue-item\" data-index=\"" index "\">"
697                                                 "<span class=\"queue-position\">" (+ index 1) "</span>"
698                                                 "<div class=\"queue-track-info\">"
699                                                 "<div class=\"track-title\">" (or (ps:@ track title) "Unknown") "</div>"
700                                                 "<div class=\"track-artist\">" (or (ps:@ track artist) "Unknown Artist") 
701                                                 (if (ps:@ track album) (+ " - " (ps:@ track album)) "") "</div>"
702                                                 "</div>"
703                                                 "</div>")))))
704                  (setf html (+ html "</div>"))
705                  (setf (ps:@ container inner-h-t-m-l) html))))))
706      
707      ;; Save current queue to stream-queue.m3u
708      (defun save-stream-queue ()
709        (ps:chain
710         (fetch "/api/asteroid/stream/playlists/save" (ps:create :method "POST"))
711         (then (lambda (response) (ps:chain response (json))))
712         (then (lambda (result)
713                 (let ((data (or (ps:@ result data) result)))
714                   (if (= (ps:@ data status) "success")
715                       (show-toast "✓ Queue saved")
716                       (alert (+ "Error saving queue: " (or (ps:@ data message) "Unknown error")))))))
717         (catch (lambda (error)
718                  (ps:chain console (error "Error saving queue:" error))
719                  (alert "Error saving queue")))))
720      
721      ;; Save queue as new playlist
722      (defun save-queue-as-new ()
723        (let* ((input (ps:chain document (get-element-by-id "save-as-name")))
724               (name (ps:chain (ps:@ input value) (trim))))
725          (when (= name "")
726            (alert "Please enter a name for the new playlist")
727            (return))
728          
729          (ps:chain
730           (fetch (+ "/api/asteroid/stream/playlists/save-as?name=" (encode-u-r-i-component name))
731                  (ps:create :method "POST"))
732           (then (lambda (response) (ps:chain response (json))))
733           (then (lambda (result)
734                   (let ((data (or (ps:@ result data) result)))
735                     (if (= (ps:@ data status) "success")
736                         (progn
737                           (show-toast (+ "✓ Saved as " name))
738                           (setf (ps:@ input value) "")
739                           (load-playlist-list))
740                         (alert (+ "Error saving playlist: " (or (ps:@ data message) "Unknown error")))))))
741           (catch (lambda (error)
742                    (ps:chain console (error "Error saving playlist:" error))
743                    (alert "Error saving playlist"))))))
744      
745      ;; Clear stream queue (updated to use new API)
746      (defun clear-stream-queue ()
747        (unless (confirm "Clear the stream queue? Liquidsoap will fall back to random playback from the music library.")
748          (return))
749        
750        (ps:chain
751         (fetch "/api/asteroid/stream/playlists/clear" (ps:create :method "POST"))
752         (then (lambda (response) (ps:chain response (json))))
753         (then (lambda (result)
754                 (let ((data (or (ps:@ result data) result)))
755                   (if (= (ps:@ data status) "success")
756                       (progn
757                         (show-toast "✓ Queue cleared")
758                         (load-current-queue))
759                       (alert (+ "Error clearing queue: " (or (ps:@ data message) "Unknown error")))))))
760         (catch (lambda (error)
761                  (ps:chain console (error "Error clearing queue:" error))
762                  (alert "Error clearing queue")))))
763      
764      ;; ========================================
765      ;; Liquidsoap Control Functions
766      ;; ========================================
767      
768      ;; Refresh Liquidsoap status
769      (defun refresh-liquidsoap-status ()
770        (ps:chain
771         (fetch "/api/asteroid/liquidsoap/status")
772         (then (lambda (response) (ps:chain response (json))))
773         (then (lambda (result)
774                 (let ((data (or (ps:@ result data) result)))
775                   (when (= (ps:@ data status) "success")
776                     (let ((uptime-el (ps:chain document (get-element-by-id "ls-uptime")))
777                           (remaining-el (ps:chain document (get-element-by-id "ls-remaining")))
778                           (metadata-el (ps:chain document (get-element-by-id "ls-metadata"))))
779                       (when uptime-el
780                         (setf (ps:@ uptime-el text-content) (or (ps:@ data uptime) "--")))
781                       (when remaining-el
782                         (setf (ps:@ remaining-el text-content) (or (ps:@ data remaining) "--")))
783                       (when metadata-el
784                         (setf (ps:@ metadata-el text-content) (or (ps:@ data metadata) "--"))))))))
785         (catch (lambda (error)
786                  (ps:chain console (error "Error fetching Liquidsoap status:" error))))))
787      
788      ;; Skip current track
789      (defun liquidsoap-skip ()
790        (ps:chain
791         (fetch "/api/asteroid/liquidsoap/skip" (ps:create :method "POST"))
792         (then (lambda (response) (ps:chain response (json))))
793         (then (lambda (result)
794                 (let ((data (or (ps:@ result data) result)))
795                   (if (= (ps:@ data status) "success")
796                       (progn
797                         (show-toast "⏭️ Track skipped")
798                         (set-timeout refresh-liquidsoap-status 1000))
799                       (alert (+ "Error skipping track: " (or (ps:@ data message) "Unknown error")))))))
800         (catch (lambda (error)
801                  (ps:chain console (error "Error skipping track:" error))
802                  (alert "Error skipping track")))))
803      
804      ;; Reload playlist
805      (defun liquidsoap-reload ()
806        (ps:chain
807         (fetch "/api/asteroid/liquidsoap/reload" (ps:create :method "POST"))
808         (then (lambda (response) (ps:chain response (json))))
809         (then (lambda (result)
810                 (let ((data (or (ps:@ result data) result)))
811                   (if (= (ps:@ data status) "success")
812                       (show-toast "📂 Playlist reloaded")
813                       (alert (+ "Error reloading playlist: " (or (ps:@ data message) "Unknown error")))))))
814         (catch (lambda (error)
815                  (ps:chain console (error "Error reloading playlist:" error))
816                  (alert "Error reloading playlist")))))
817      
818      ;; Restart Liquidsoap container
819      (defun liquidsoap-restart ()
820        (unless (confirm "Restart Liquidsoap container? This will cause a brief interruption to the stream.")
821          (return))
822        
823        (show-toast "🔄 Restarting Liquidsoap...")
824        (ps:chain
825         (fetch "/api/asteroid/liquidsoap/restart" (ps:create :method "POST"))
826         (then (lambda (response) (ps:chain response (json))))
827         (then (lambda (result)
828                 (let ((data (or (ps:@ result data) result)))
829                   (if (= (ps:@ data status) "success")
830                       (progn
831                         (show-toast "✓ Liquidsoap restarting")
832                         ;; Refresh status after a delay to let container restart
833                         (set-timeout refresh-liquidsoap-status 5000))
834                       (alert (+ "Error restarting Liquidsoap: " (or (ps:@ data message) "Unknown error")))))))
835         (catch (lambda (error)
836                  (ps:chain console (error "Error restarting Liquidsoap:" error))
837                  (alert "Error restarting Liquidsoap")))))
838      
839      ;; Restart Icecast container
840      (defun icecast-restart ()
841        (unless (confirm "Restart Icecast container? This will disconnect all listeners temporarily.")
842          (return))
843        
844        (show-toast "🔄 Restarting Icecast...")
845        (ps:chain
846         (fetch "/api/asteroid/icecast/restart" (ps:create :method "POST"))
847         (then (lambda (response) (ps:chain response (json))))
848         (then (lambda (result)
849                 (let ((data (or (ps:@ result data) result)))
850                   (if (= (ps:@ data status) "success")
851                       (show-toast "✓ Icecast restarting - listeners will reconnect automatically")
852                       (alert (+ "Error restarting Icecast: " (or (ps:@ data message) "Unknown error")))))))
853         (catch (lambda (error)
854                  (ps:chain console (error "Error restarting Icecast:" error))
855                  (alert "Error restarting Icecast")))))
856      
857      ;; Make functions globally accessible for onclick handlers
858      (setf (ps:@ window go-to-page) go-to-page)
859      (setf (ps:@ window previous-page) previous-page)
860      (setf (ps:@ window next-page) next-page)
861      (setf (ps:@ window go-to-last-page) go-to-last-page)
862      (setf (ps:@ window change-tracks-per-page) change-tracks-per-page)
863      (setf (ps:@ window stream-track) stream-track)
864      (setf (ps:@ window delete-track) delete-track)
865      (setf (ps:@ window move-track-up) move-track-up)
866      (setf (ps:@ window move-track-down) move-track-down)
867      (setf (ps:@ window remove-from-queue) remove-from-queue)
868      (setf (ps:@ window add-to-queue) add-to-queue)
869      ))
870    "Compiled JavaScript for admin dashboard - generated at load time")
871  
872  (defun generate-admin-js ()
873    "Return the pre-compiled JavaScript for admin dashboard"
874    *admin-js*)