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