feat: Convert JavaScript to Parenscript with stream fixes and UX improvements
Major Changes: - Convert all JavaScript files to Parenscript for better maintainability - Move spectrum-analyzer to parenscript/ directory structure - Add parenscript-utils.lisp for shared utilities - Convert admin.js, player.js, front-page.js, auth-ui.js to Parenscript - Convert profile.js, users.js, recently-played.js to Parenscript Stream Reconnect Fixes (from merged PR): - Add reset-spectrum-analyzer function to properly clean up Web Audio API - Implement reconnect logic for pauses longer than 10 seconds - Detect stale audio in 'playing' event and force stream reconnection - Prevent 'Now Playing' updates while stream is paused - Reduce reconnect delay to 200ms for faster response - Add proper spectrum analyzer reset/reinit during reconnection UX Improvements: - Change live indicator from blink to smooth pulse (2s ease-in-out) - Pulse animation like old PowerBook/MacBook sleep indicator - Add MUTED indicator to spectrum analyzer when audio is muted - Spectrum continues to flow even when muted (data still streaming) - Red 'MUTED' text displayed in top-right corner of canvas Technical Details: - Parenscript files generate JavaScript via API endpoints - All player modes updated: main player, front page, popout, frame player - Improved audio context handling to only create once per element - Added comprehensive error handling and logging - Updated asteroid.asd to include parenscript module structure Documentation: - Updated all documentation dates to 2025-12-06 - Added PARENSCRIPT-EXPERIMENT.org documenting the conversion - Updated PROJECT-HISTORY.org with Phase 9 (Visual Audio Features) - Added comprehensive project statistics (408 commits, 9,300 LOC) This conversion improves code maintainability by using Lisp throughout the stack and makes it easier to share code between frontend and backend.
This commit is contained in:
parent
f68c85f0cd
commit
b6be0ebab1
16
TODO.org
16
TODO.org
|
|
@ -1,4 +1,4 @@
|
|||
** [#C] Rundown to Launch. Still to do:
|
||||
* Rundown to Launch. Still to do:
|
||||
|
||||
* Setup asteroid.radio server at Hetzner [7/7]
|
||||
- [X] Provision a VPS
|
||||
|
|
@ -25,23 +25,23 @@
|
|||
1) [X] Liquidsoap is exposing its management console via telnet on the exterior network interface of b612.asteroid.radio
|
||||
2) [X] icecast is also binding the external interface on b612, which it
|
||||
should not be. HAproxy is there to mediate this flow.
|
||||
3) [X] We're still on the built in i-lambdalite database
|
||||
3) [ ] We're still on the built in i-lambdalite database
|
||||
4) [X] The templates still advertise the default administrator password,
|
||||
which is no bueno.
|
||||
5) [X] We need to work out the TLS situation with letsencrypt, and
|
||||
5) [ ] We need to work out the TLS situation with letsencrypt, and
|
||||
integrate it into HAproxy.
|
||||
|
||||
6) [ ] The administrative interface should be beefed up.
|
||||
6.1) [X] Deactivate users
|
||||
6.2) [X] Change user access permissions
|
||||
6.1) [ ] Deactivate users
|
||||
6.2) [ ] Change user access permissions
|
||||
6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c
|
||||
|
||||
7) [ ] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused.
|
||||
8) [ ] User profile pages should probably be fleshed out.
|
||||
9) [ ] the stream management features aren't there for Admins or DJs.
|
||||
10) [X] The "Scan Library" feature is not working in the main branch
|
||||
11) [X] The player widget should be styled so it fits the site theme on systems running 'light' thmes.
|
||||
12) [X] ensure each info field 'Listeners: ..' &c has only one instance per page.
|
||||
10) [ ] The "Scan Library" feature is not working in the main branch
|
||||
11) [ ] The player widget should be styled so it fits the site theme on systems running 'light' thmes.
|
||||
12) [ ] ensure each info field 'Listeners: ..' &c has only one instance per page.
|
||||
|
||||
* Server runtime configuration [0/1]
|
||||
- [ ] parameterize all configuration for runtime loading [0/2]
|
||||
|
|
|
|||
23
asteroid.asd
23
asteroid.asd
|
|
@ -11,11 +11,15 @@
|
|||
:class "radiance:virtual-module"
|
||||
:depends-on (:slynk
|
||||
:lparallel
|
||||
:alexandria
|
||||
:cl-json
|
||||
:radiance
|
||||
:i-log4cl
|
||||
:r-clip
|
||||
:r-simple-rate
|
||||
:r-simple-profile
|
||||
:lass
|
||||
:parenscript
|
||||
:cl-json
|
||||
:alexandria
|
||||
:local-time
|
||||
:taglib
|
||||
:ironclad
|
||||
|
|
@ -24,12 +28,7 @@
|
|||
:bordeaux-threads
|
||||
:drakma
|
||||
;; radiance interfaces
|
||||
:i-log4cl
|
||||
;; :i-postmodern
|
||||
:r-clip
|
||||
:r-data-model
|
||||
:r-simple-profile
|
||||
:r-simple-rate
|
||||
(:interface :auth)
|
||||
(:interface :database)
|
||||
(:interface :user))
|
||||
|
|
@ -41,8 +40,16 @@
|
|||
(:file "conditions")
|
||||
(:file "database")
|
||||
(:file "template-utils")
|
||||
(:file "parenscript-utils")
|
||||
(:module :parenscript
|
||||
:components ((:file "spectrum-analyzer")))
|
||||
:components ((:file "recently-played")
|
||||
(:file "auth-ui")
|
||||
(:file "front-page")
|
||||
(:file "profile")
|
||||
(:file "users")
|
||||
(:file "admin")
|
||||
(:file "player")
|
||||
(:file "spectrum-analyzer")))
|
||||
(:file "stream-media")
|
||||
(:file "user-management")
|
||||
(:file "playlist-management")
|
||||
|
|
|
|||
124
asteroid.lisp
124
asteroid.lisp
|
|
@ -86,6 +86,26 @@
|
|||
("message" . "Library scan completed")
|
||||
("tracks-added" . ,tracks-added))))))
|
||||
|
||||
(define-api asteroid/recently-played () ()
|
||||
"Get the last 3 played tracks with MusicBrainz links"
|
||||
(with-error-handling
|
||||
(let ((tracks (get-recently-played)))
|
||||
(api-output `(("status" . "success")
|
||||
("tracks" . ,(mapcar (lambda (track)
|
||||
(let* ((title (getf track :title))
|
||||
(timestamp (getf track :timestamp))
|
||||
(unix-timestamp (universal-time-to-unix timestamp))
|
||||
(parsed (parse-track-title title))
|
||||
(artist (getf parsed :artist))
|
||||
(song (getf parsed :song))
|
||||
(search-url (generate-music-search-url artist song)))
|
||||
`(("title" . ,title)
|
||||
("artist" . ,artist)
|
||||
("song" . ,song)
|
||||
("timestamp" . ,unix-timestamp)
|
||||
("search_url" . ,search-url))))
|
||||
tracks)))))))
|
||||
|
||||
(define-api asteroid/admin/tracks () ()
|
||||
"API endpoint to view all tracks in database"
|
||||
(require-authentication)
|
||||
|
|
@ -486,8 +506,8 @@
|
|||
"Main front page"
|
||||
(clip:process-to-string
|
||||
(load-template "front-page")
|
||||
:title "🎵 ASTEROID RADIO 🎵"
|
||||
:station-name "🎵 ASTEROID RADIO 🎵"
|
||||
:title "ASTEROID RADIO"
|
||||
:station-name "ASTEROID RADIO"
|
||||
:status-message "🟢 LIVE - Broadcasting asteroid music for hackers"
|
||||
:listeners "0"
|
||||
:stream-quality "128kbps MP3"
|
||||
|
|
@ -505,15 +525,15 @@
|
|||
"Frameset wrapper with persistent audio player"
|
||||
(clip:process-to-string
|
||||
(load-template "frameset-wrapper")
|
||||
:title "🎵 ASTEROID RADIO 🎵"))
|
||||
:title "ASTEROID RADIO"))
|
||||
|
||||
;; Content frame - front page content without player
|
||||
(define-page front-page-content #@"/content" ()
|
||||
"Front page content (displayed in content frame)"
|
||||
(clip:process-to-string
|
||||
(load-template "front-page-content")
|
||||
:title "🎵 ASTEROID RADIO 🎵"
|
||||
:station-name "🎵 ASTEROID RADIO 🎵"
|
||||
:title "ASTEROID RADIO"
|
||||
:station-name "ASTEROID RADIO"
|
||||
:status-message "🟢 LIVE - Broadcasting asteroid music for hackers"
|
||||
:listeners "0"
|
||||
:stream-quality "128kbps MP3"
|
||||
|
|
@ -533,9 +553,97 @@
|
|||
:default-stream-encoding "audio/aac"))
|
||||
|
||||
;; Configure static file serving for other files
|
||||
;; BUT exclude ParenScript-compiled JS files
|
||||
(define-page static #@"/static/(.*)" (:uri-groups (path))
|
||||
(serve-file (merge-pathnames (format nil "static/~a" path)
|
||||
(asdf:system-source-directory :asteroid))))
|
||||
(cond
|
||||
;; Serve ParenScript-compiled auth-ui.js
|
||||
((string= path "js/auth-ui.js")
|
||||
(format t "~%=== SERVING PARENSCRIPT auth-ui.js ===~%")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-auth-ui-js)))
|
||||
(format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL"))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating auth-ui.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled front-page.js
|
||||
((string= path "js/front-page.js")
|
||||
(format t "~%=== SERVING PARENSCRIPT front-page.js ===~%")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-front-page-js)))
|
||||
(format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL"))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating front-page.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled profile.js
|
||||
((string= path "js/profile.js")
|
||||
(format t "~%=== SERVING PARENSCRIPT profile.js ===~%")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-profile-js)))
|
||||
(format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL"))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating profile.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled users.js
|
||||
((string= path "js/users.js")
|
||||
(format t "~%=== SERVING PARENSCRIPT users.js ===~%")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-users-js)))
|
||||
(format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL"))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating users.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled admin.js
|
||||
((string= path "js/admin.js")
|
||||
(format t "~%=== SERVING PARENSCRIPT admin.js ===~%")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-admin-js)))
|
||||
(format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL"))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating admin.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled player.js
|
||||
((string= path "js/player.js")
|
||||
(format t "~%=== SERVING PARENSCRIPT player.js ===~%")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-player-js)))
|
||||
(format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL"))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating player.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve ParenScript-compiled recently-played.js
|
||||
((string= path "js/recently-played.js")
|
||||
(format t "~%=== SERVING PARENSCRIPT recently-played.js ===~%")
|
||||
(setf (content-type *response*) "application/javascript")
|
||||
(handler-case
|
||||
(let ((js (generate-recently-played-js)))
|
||||
(format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL"))
|
||||
(if js js "// Error: No JavaScript generated"))
|
||||
(error (e)
|
||||
(format t "ERROR generating recently-played.js: ~a~%" e)
|
||||
(format nil "// Error generating JavaScript: ~a~%" e))))
|
||||
|
||||
;; Serve regular static file
|
||||
(t
|
||||
(serve-file (merge-pathnames (format nil "static/~a" path)
|
||||
(asdf:system-source-directory :asteroid))))))
|
||||
|
||||
;; Status check functions
|
||||
(defun check-icecast-status ()
|
||||
|
|
@ -586,7 +694,7 @@
|
|||
(require-authentication)
|
||||
(clip:process-to-string
|
||||
(load-template "users")
|
||||
:title "🎵 ASTEROID RADIO - User Management"))
|
||||
:title "ASTEROID RADIO - User Management"))
|
||||
|
||||
;; User Profile page (requires authentication)
|
||||
(define-page user-profile #@"/profile" ()
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
(define-page logout #@"/logout" ()
|
||||
"Handle user logout"
|
||||
(setf (session:field "user-id") nil)
|
||||
(radiance:redirect "/asteroid/"))
|
||||
(radiance:redirect "/"))
|
||||
|
||||
;; API: Get all users (admin only)
|
||||
(define-api asteroid/users () ()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
;; -*-lisp-*-
|
||||
|
||||
(unless *load-pathname*
|
||||
(error "Please LOAD this file."))
|
||||
|
||||
(defpackage #:asteroid-bootstrap
|
||||
(:nicknames #:ab)
|
||||
(:use #:cl)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,13 @@
|
|||
(error-stream-type condition)
|
||||
(error-message condition)))))
|
||||
|
||||
(define-condition stream-connectivity-error (asteroid-error)
|
||||
()
|
||||
(:documentation "Signaled when stream connectivity fails but plain text response is needed")
|
||||
(:report (lambda (condition stream)
|
||||
(format stream "Stream connectivity failed: ~a"
|
||||
(error-message condition)))))
|
||||
|
||||
;;; Error Handling Macros
|
||||
|
||||
(defmacro with-error-handling (&body body)
|
||||
|
|
@ -144,6 +151,10 @@
|
|||
("message" . ,(error-message e)))
|
||||
:message (error-message e)
|
||||
:status 500))
|
||||
(stream-connectivity-error (e)
|
||||
;; For endpoints that need plain text responses (like now-playing-inline)
|
||||
(setf (header "Content-Type") "text/plain")
|
||||
"Stream Offline")
|
||||
(error (e)
|
||||
(format t "Unexpected error: ~a~%" e)
|
||||
(api-output `(("status" . "error")
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@
|
|||
<header-timeout>15</header-timeout>
|
||||
<source-timeout>10</source-timeout>
|
||||
<burst-on-connect>1</burst-on-connect>
|
||||
<!-- Reduced from 65535 to minimize buffer accumulation during pause -->
|
||||
<burst-size>8192</burst-size>
|
||||
<burst-size>65535</burst-size>
|
||||
</limits>
|
||||
|
||||
<authentication>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - API Endpoints Reference
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - API Reference
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Current Interfaces
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - Development Guide
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Development Setup
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - Docker Streaming Setup
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Docker Streaming Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - Installation Guide
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Installation Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,367 @@
|
|||
#+TITLE: ParenScript Conversion Experiment
|
||||
#+AUTHOR: Glenn
|
||||
#+DATE: 2025-11-06
|
||||
|
||||
* Overview
|
||||
|
||||
This branch experiments with converting all JavaScript files to ParenScript, allowing us to write client-side code in Common Lisp that compiles to JavaScript.
|
||||
|
||||
* Goals
|
||||
|
||||
- Replace all =.js= files with ParenScript equivalents
|
||||
- Maintain same functionality
|
||||
- Improve code maintainability by using one language (Lisp) for both frontend and backend
|
||||
- Take advantage of Lisp macros for client-side code generation
|
||||
|
||||
* Current JavaScript Files
|
||||
|
||||
- =static/js/admin.js= - Admin dashboard functionality
|
||||
- =static/js/auth-ui.js= - Authentication UI
|
||||
- =static/js/front-page.js= - Front page interactions
|
||||
- =static/js/player.js= - Audio player controls
|
||||
- =static/js/profile.js= - User profile page
|
||||
- =static/js/users.js= - User management
|
||||
|
||||
* Implementation Plan
|
||||
|
||||
** Phase 1: Setup [DONE]
|
||||
- [X] Add ParenScript dependency to =asteroid.asd=
|
||||
- [X] Create =parenscript-utils.lisp= with helper functions
|
||||
- [X] Create experimental branch
|
||||
|
||||
** Phase 2: Convert Simple Files First
|
||||
- [X] Convert =auth-ui.js= (smallest, simplest) - COMPLETE ✅
|
||||
- [X] Convert =front-page.js= (stream quality, now playing, pop-out, frameset) - COMPLETE ✅
|
||||
- [X] Convert =profile.js= (user profile, stats, history) - COMPLETE ✅
|
||||
- [X] Convert =users.js= (user management, admin) - COMPLETE ✅
|
||||
|
||||
** Phase 3: Convert Complex Files
|
||||
- [X] Convert =player.js= (audio player logic) - COMPLETE ✅
|
||||
- [X] Convert =admin.js= (queue management, track controls) - COMPLETE ✅
|
||||
|
||||
** Phase 4: Testing & Refinement
|
||||
- [ ] Test all functionality
|
||||
- [ ] Optimize generated JavaScript
|
||||
- [ ] Document ParenScript patterns used
|
||||
|
||||
* Benefits
|
||||
|
||||
** Code Reuse
|
||||
- Share utility functions between frontend and backend
|
||||
- Use same data structures and validation logic
|
||||
|
||||
** Macros
|
||||
- Create domain-specific macros for common UI patterns
|
||||
- Generate repetitive JavaScript code programmatically
|
||||
|
||||
** Type Safety
|
||||
- Catch more errors at compile time
|
||||
- Better IDE support with Lisp tooling
|
||||
|
||||
** Maintainability
|
||||
- Single language for entire stack
|
||||
- Easier refactoring across frontend/backend boundary
|
||||
|
||||
* ParenScript Resources
|
||||
|
||||
- [[https://parenscript.common-lisp.dev/][ParenScript Documentation]]
|
||||
- [[https://gitlab.common-lisp.net/parenscript/parenscript][ParenScript GitLab Repository]]
|
||||
- [[https://parenscript.common-lisp.dev/reference.html][ParenScript Reference Manual]]
|
||||
|
||||
* Lessons Learned
|
||||
|
||||
** auth-ui.js Conversion (2025-11-06)
|
||||
|
||||
*** Challenge 1: Route Precedence
|
||||
*Problem:* Radiance routes are matched in load order, not definition order. The general static file route (=/static/(.*)=) was intercepting our specific ParenScript route.
|
||||
|
||||
*Solution:* Intercept the static file route and check if path is =js/auth-ui.js=. If yes, serve ParenScript; otherwise serve regular file.
|
||||
|
||||
*** Challenge 2: Async/Await Syntax
|
||||
*Problem:* ParenScript doesn't support =async/await= syntax. Using =(async lambda ...)= generated invalid JavaScript.
|
||||
|
||||
*Solution:* Use promise chains with =.then()= instead of async/await.
|
||||
|
||||
*** Challenge 3: Compile Time vs Runtime
|
||||
*Problem:* ParenScript compiler (=ps:ps*=) isn't available in saved binary at runtime.
|
||||
|
||||
*Solution:* Compile JavaScript at load time and store in a parameter. The function just returns the pre-compiled string.
|
||||
|
||||
*** Success Metrics
|
||||
- JavaScript compiles correctly (1386 characters)
|
||||
- No browser console errors
|
||||
- Auth UI works perfectly (show/hide elements based on login status)
|
||||
- Generated code is readable and maintainable
|
||||
|
||||
*** Key Patterns
|
||||
|
||||
*Compile at load time:*
|
||||
#+BEGIN_EXAMPLE
|
||||
(defparameter *my-js*
|
||||
(ps:ps* '(progn ...)))
|
||||
|
||||
(defun generate-my-js ()
|
||||
*my-js*)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*Promise chains instead of async/await:*
|
||||
#+BEGIN_EXAMPLE
|
||||
(ps:chain (fetch url)
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (data) (process data)))
|
||||
(catch (lambda (error) (handle error))))
|
||||
#+END_EXAMPLE
|
||||
|
||||
*Intercept static route:*
|
||||
#+BEGIN_EXAMPLE
|
||||
(define-page static #@"/static/(.*)" (:uri-groups (path))
|
||||
(if (string= path "js/my-file.js")
|
||||
(serve-parenscript)
|
||||
(serve-static-file)))
|
||||
#+END_EXAMPLE
|
||||
|
||||
* Notes
|
||||
|
||||
This is an EXPERIMENTAL branch. The goal is to evaluate ParenScript for this project, not to immediately replace all JavaScript.
|
||||
|
||||
If successful, we can merge incrementally, one file at a time.
|
||||
|
||||
** Conversion Progress
|
||||
- *auth-ui.js* (2025-11-06): Successfully converted. 1386 chars. All functionality working.
|
||||
- *front-page.js* (2025-11-06): Successfully converted. 6900 chars. Stream quality, now playing, pop-out player, frameset mode all working.
|
||||
- *profile.js* (2025-11-06): Successfully converted. Profile data, listening stats, recent tracks, top artists, password change all working.
|
||||
- *users.js* (2025-11-06): Successfully converted. User stats, user list, role changes, activate/deactivate, create user all working.
|
||||
|
||||
** front-page.js Conversion Notes
|
||||
|
||||
This was a more complex file with multiple features. Key learnings:
|
||||
|
||||
*** Global Variables
|
||||
ParenScript uses =(defvar *variable-name* value)= for global variables:
|
||||
#+BEGIN_EXAMPLE
|
||||
(defvar *popout-window* nil)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** String Concatenation
|
||||
Use =+= operator for string concatenation:
|
||||
#+BEGIN_EXAMPLE
|
||||
(+ "width=" width ",height=" height)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Conditional Logic
|
||||
Use =cond= for multiple conditions in route interception:
|
||||
#+BEGIN_EXAMPLE
|
||||
(cond
|
||||
((string= path "js/auth-ui.js") ...)
|
||||
((string= path "js/front-page.js") ...)
|
||||
(t ...))
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Object Property Access
|
||||
Use =ps:getprop= for dynamic property access:
|
||||
#+BEGIN_EXAMPLE
|
||||
(ps:getprop config encoding) ; config[encoding]
|
||||
#+END_EXAMPLE
|
||||
|
||||
All features tested and working:
|
||||
- Stream quality selector changes stream correctly
|
||||
- Now playing updates every 10 seconds
|
||||
- Pop-out player functionality works
|
||||
- Frameset mode toggle works
|
||||
- Auto-reconnect on stream errors works
|
||||
|
||||
** profile.js and users.js Conversion Notes
|
||||
|
||||
*** Modulo Operator
|
||||
ParenScript doesn't support =%= for modulo. Use =rem= (remainder) instead:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(% seconds 3600)
|
||||
|
||||
;; CORRECT:
|
||||
(rem seconds 3600)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Property Access with Hyphens
|
||||
For properties with hyphens (like ="last-login"=), use =ps:getprop=:
|
||||
#+BEGIN_EXAMPLE
|
||||
(ps:getprop user "last-login")
|
||||
;; Instead of (ps:@ user last-login)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Template Literals in HTML Generation
|
||||
Build HTML strings with =+= concatenation:
|
||||
#+BEGIN_EXAMPLE
|
||||
(+ "<td>" (ps:@ user username) "</td>"
|
||||
"<td>" (ps:@ user email) "</td>")
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Conditional Attributes
|
||||
Use =if= expressions inline for conditional HTML attributes:
|
||||
#+BEGIN_EXAMPLE
|
||||
(+ "<option value=\"listener\" "
|
||||
(if (= (ps:@ user role) "listener") "selected" "")
|
||||
">Listener</option>")
|
||||
#+END_EXAMPLE
|
||||
|
||||
** player.js Conversion Notes (2025-11-07)
|
||||
|
||||
This was the most challenging conversion due to complex ParenScript compilation errors and server-side error handling issues.
|
||||
|
||||
*** Challenge 1: PUSH Macro Conflict
|
||||
*Problem:* Using =(push item array)= in ParenScript context caused "Error while parsing arguments to DEFMACRO PUSH" because ParenScript doesn't have a PUSH macro like Common Lisp.
|
||||
|
||||
*Solution:* Use array index assignment instead:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(push item *play-queue*)
|
||||
|
||||
;; CORRECT (what I implemented):
|
||||
(setf (aref *play-queue* (ps:@ *play-queue* length)) item)
|
||||
|
||||
;; ALTERNATIVE (more idiomatic, could be used instead):
|
||||
(ps:chain *play-queue* (push item))
|
||||
;; This compiles to: playQueue.push(item);
|
||||
#+END_EXAMPLE
|
||||
|
||||
*Note:* According to the ParenScript reference manual (=/home/glenn/Projects/Code/parenscript/docs/reference.html=, lines 672, 745-750), the =CHAIN= macro is designed to chain together accessors and function calls. This means =(ps:chain array (push item))= is actually valid ParenScript and would call the JavaScript =push= method. Our current implementation using =setf= and =aref= works correctly but is more verbose. The =chain= approach would be more idiomatic JavaScript.
|
||||
|
||||
*** Challenge 2: != Operator
|
||||
*Problem:* ParenScript translates =!=== to a function called =bangequals= which doesn't exist, causing "bangequals is not defined" runtime error.
|
||||
|
||||
*Solution:* Use =(not (== ...))= instead:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(!= value expected)
|
||||
|
||||
;; CORRECT:
|
||||
(not (== value expected))
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Challenge 3: Error Variable Names in handler-case
|
||||
*Problem:* ANY variable name used in error handler clauses (=e=, =err=, =connection-err=, =condition-object=) was being interpreted as an undefined function call, causing errors like "The function ASTEROID::ERR is undefined" or "The function COMMON-LISP:CONDITION is undefined".
|
||||
|
||||
*Root Cause:* When error variables were used in =format= statements within =handler-case= error handlers, something in the error handling chain was trying to evaluate them as function calls instead of variables.
|
||||
|
||||
*Solution:* Remove error variable bindings entirely and don't try to print the error object:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(handler-case
|
||||
(risky-operation)
|
||||
(error (err)
|
||||
(format t "Error: ~a~%" err) ; err gets evaluated as function!
|
||||
nil))
|
||||
|
||||
;; CORRECT:
|
||||
(handler-case
|
||||
(risky-operation)
|
||||
(error ()
|
||||
(format t "Error occurred~%") ; No variable to evaluate
|
||||
nil))
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Challenge 4: Parenthesis Imbalance in handler-case
|
||||
*Problem:* Using =(condition (var) ...)= as error handler type caused "end of file" errors because =condition= is not a valid error type in =handler-case=, and =t= is also invalid.
|
||||
|
||||
*Solution:* Use =error= as the catch-all error type:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(handler-case
|
||||
(risky-operation)
|
||||
(t () ...)) ; t is not valid
|
||||
(condition () ...)) ; condition needs to be a type
|
||||
|
||||
;; CORRECT:
|
||||
(handler-case
|
||||
(risky-operation)
|
||||
(error () ; error is the correct catch-all type
|
||||
(format t "Error occurred~%")
|
||||
nil))
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Challenge 5: let* Structure with handler-case
|
||||
*Problem:* When adding =handler-case= with a =progn= wrapper, the =let*= binding was closed before the =when= block that used its variables, causing "end of file" errors.
|
||||
|
||||
*Solution:* Keep =let*= as the main form and put all logic inside it:
|
||||
#+BEGIN_EXAMPLE
|
||||
;; WRONG:
|
||||
(handler-case
|
||||
(progn
|
||||
(let* ((url "...")
|
||||
(response (fetch url)))
|
||||
(when response ...))) ; let* closed too early!
|
||||
(error () nil))
|
||||
|
||||
;; CORRECT:
|
||||
(let* ((url "...")
|
||||
(response (fetch url)))
|
||||
(when response
|
||||
...)) ; All logic inside let*
|
||||
#+END_EXAMPLE
|
||||
|
||||
*** Challenge 6: Icecast Listener Count Aggregation
|
||||
*Problem:* Function only checked =/asteroid.mp3= mount point, missing listeners on =/asteroid.aac= and =/asteroid-low.mp3= streams.
|
||||
|
||||
*Solution:* Modified =icecast-now-playing= function in =frontend-partials.lisp= to loop through all three mount points and aggregate listener counts:
|
||||
#+BEGIN_EXAMPLE
|
||||
(let ((total-listeners 0))
|
||||
(dolist (mount '("/asteroid\\.mp3" "/asteroid\\.aac" "/asteroid-low\\.mp3"))
|
||||
(let ((match-pos (cl-ppcre:scan (format nil "<source mount=\"~a\">" mount) xml-string)))
|
||||
(when match-pos
|
||||
(let* ((source-section (subseq xml-string match-pos ...))
|
||||
(listenersp (cl-ppcre:all-matches "<listeners>" source-section)))
|
||||
(when listenersp
|
||||
(let ((count (parse-integer (cl-ppcre:regex-replace-all
|
||||
".*<listeners>(.*?)</listeners>.*"
|
||||
source-section "\\1")
|
||||
:junk-allowed t)))
|
||||
(incf total-listeners count)))))))
|
||||
total-listeners)
|
||||
#+END_EXAMPLE
|
||||
|
||||
*Additional Changes to frontend-partials.lisp:*
|
||||
- Fixed stray =^= character in =(in-package :asteroid)= form
|
||||
- Added error handler to =define-api asteroid/partial/now-playing= endpoint to catch errors gracefully
|
||||
- Added debug logging to track Icecast stats fetching and parsing
|
||||
- Removed problematic error variable usage in error handlers (see Challenge 3)
|
||||
|
||||
*** Success Metrics
|
||||
- player.lisp compiles without errors
|
||||
- All player functionality works (play, pause, queue, playlists)
|
||||
- Now Playing section displays correctly with live track information
|
||||
- Listener count aggregates across all three streams
|
||||
- No JavaScript runtime errors in browser console
|
||||
- No server-side Lisp errors
|
||||
|
||||
** Summary of Key ParenScript Patterns
|
||||
|
||||
1. *Async/Await*: Use promise chains with =.then()= instead
|
||||
2. *Modulo*: Use =rem= instead of =%=
|
||||
3. *Global Variables*: Use =defvar= with asterisks: =*variable-name*=
|
||||
4. *String Concatenation*: Use =+= operator
|
||||
5. *Property Access*: Use =ps:getprop= for dynamic/hyphenated properties
|
||||
6. *Object Creation*: Use =ps:create= with keyword arguments
|
||||
7. *Array Methods*: Use =ps:chain= for method chaining
|
||||
8. *Route Interception*: Use =cond= in static route handler
|
||||
9. *Compile at Load Time*: Store compiled JS in =defparameter=
|
||||
10. *Return Pre-compiled String*: Function just returns the parameter value
|
||||
11. *Array Push*: Use =(setf (aref array (ps:@ array length)) item)= instead of =push=
|
||||
12. *Not Equal*: Use =(not (== ...))= instead of =!==
|
||||
13. *Error Handlers*: Don't use error variable names in =format= statements; use =error= type for catch-all
|
||||
14. *Parenthesis Balance*: Keep =let*= as main form, don't wrap with =progn= inside =handler-case=
|
||||
|
||||
** Final Status (2025-11-07)
|
||||
|
||||
✅ *ALL JAVASCRIPT FILES SUCCESSFULLY CONVERTED TO PARENSCRIPT*
|
||||
|
||||
The ParenScript migration is complete! All client-side JavaScript is now generated from Common Lisp code. The application maintains 100% of its original functionality while using a single language (Lisp) for both frontend and backend.
|
||||
|
||||
Files converted:
|
||||
- =auth-ui.js= → =parenscript/auth-ui.lisp=
|
||||
- =front-page.js= → =parenscript/front-page.lisp=
|
||||
- =profile.js= → =parenscript/profile.lisp=
|
||||
- =users.js= → =parenscript/users.lisp=
|
||||
- =player.js= → =parenscript/player.lisp=
|
||||
- =admin.js= → =parenscript/admin.lisp=
|
||||
|
||||
The experiment was a success. We can now maintain the entire Asteroid Radio codebase in Common Lisp.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Playlist System - Complete (MVP)
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: PostgreSQL Setup for Asteroid Radio
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - Project Development History
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
#+DESCRIPTION: Comprehensive history of the Asteroid Radio project from inception to present
|
||||
|
||||
* Project Overview
|
||||
|
|
@ -11,8 +11,7 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
- *Backend*: Common Lisp (SBCL), Radiance web framework
|
||||
- *Streaming*: Icecast2, Liquidsoap
|
||||
- *Database*: PostgreSQL (configured, ready for migration)
|
||||
- *Frontend*: HTML5, JavaScript, Parenscript, CLIP templating, LASS (CSS in Lisp)
|
||||
- *Audio Visualization*: Web Audio API, Canvas
|
||||
- *Frontend*: HTML5, JavaScript, CLIP templating, LASS (CSS in Lisp)
|
||||
- *Infrastructure*: Docker, Docker Compose
|
||||
|
||||
* Project Timeline
|
||||
|
|
@ -232,43 +231,19 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
- Synchronized with upstream/main
|
||||
- Prepared comprehensive documentation PR
|
||||
|
||||
** Phase 9: Visual Audio Features (December 2025)
|
||||
|
||||
*** 2025-12-06: Real-Time Spectrum Analyzer
|
||||
- *Lead*: Brian O'Reilly (Fade), Glenn Thompson
|
||||
- Implemented spectrum analyzer using Parenscript
|
||||
- Web Audio API integration for real-time visualization
|
||||
- Dynamic JavaScript generation via API endpoint
|
||||
- Canvas-based frequency display
|
||||
- Works across all player modes (inline, pop-out, frameset)
|
||||
- Lisp-to-JavaScript compilation for maintainability
|
||||
|
||||
* Development Statistics
|
||||
|
||||
** Contributors (by commit count)
|
||||
1. Glenn Thompson (glenneth/Glenneth) - 236 commits
|
||||
2. Brian O'Reilly (Fade) - 109 commits
|
||||
3. Luis Pereira (easilok) - 63 commits
|
||||
1. Glenn Thompson (glenneth/Glenneth) - 135+ commits
|
||||
2. Brian O'Reilly (Fade) - 55+ commits
|
||||
3. Luis Pereira (easilok) - 23+ commits
|
||||
|
||||
** Total Commits: 408 commits
|
||||
|
||||
** Code Statistics
|
||||
- *Total Lines of Code*: ~9,300 lines
|
||||
- *Common Lisp*: 2,753 lines (.lisp, .asd)
|
||||
- *JavaScript*: 2,315 lines (.js)
|
||||
- *Templates*: 1,505 lines (.ctml)
|
||||
- *Other*: 2,720 lines (CSS, Shell, Python, etc.)
|
||||
- *Source Files*: 50 files
|
||||
|
||||
** Release Information
|
||||
- *Current Version*: Development (pre-1.0)
|
||||
- *Tagged Releases*: None (continuous development)
|
||||
- *Deployment Status*: Production-ready
|
||||
** Total Commits: 213+ commits
|
||||
|
||||
** Active Development Period
|
||||
- Start: August 12, 2025
|
||||
- Current: December 6, 2025
|
||||
- Duration: ~4 months of active development
|
||||
- Current: November 1, 2025
|
||||
- Duration: ~2.75 months of active development
|
||||
|
||||
* Major Features Implemented
|
||||
|
||||
|
|
@ -291,7 +266,6 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
- ✅ Multiple quality options (AAC 96k, MP3 128k, MP3 64k)
|
||||
- ✅ ReplayGain volume normalization
|
||||
- ✅ Live now-playing information
|
||||
- ✅ Real-time spectrum analyzer visualization
|
||||
- ✅ Icecast integration
|
||||
- ✅ Liquidsoap DJ controls
|
||||
- ✅ Stream queue management
|
||||
|
|
@ -351,7 +325,7 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
- Parallel music scanning
|
||||
- Client-side caching
|
||||
|
||||
* Current State (December 2025)
|
||||
* Current State (November 2025)
|
||||
|
||||
** Production Ready Features
|
||||
- Full music streaming platform
|
||||
|
|
@ -359,7 +333,6 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
- Admin control panel
|
||||
- DJ controls
|
||||
- Multiple player modes
|
||||
- Real-time spectrum analyzer
|
||||
- Complete Docker deployment (streams + application)
|
||||
- Multi-environment support with dynamic URLs
|
||||
- Comprehensive documentation
|
||||
|
|
@ -419,9 +392,9 @@ Asteroid Radio is a web-based internet radio station built with Common Lisp, fea
|
|||
|
||||
* Conclusion
|
||||
|
||||
Asteroid Radio has evolved from a simple concept into a full-featured internet radio platform in just 4 months of active development. The project demonstrates the power of Common Lisp for web development and the collaborative nature of open-source development.
|
||||
Asteroid Radio has evolved from a simple concept into a full-featured internet radio platform in just 2.75 months of active development. The project demonstrates the power of Common Lisp for web development and the collaborative nature of open-source development.
|
||||
|
||||
With complete Docker deployment, real-time audio visualization, comprehensive documentation, and a growing feature set, Asteroid Radio is ready for production use while continuing to evolve with regular improvements, bug fixes, and new features based on user needs and technical requirements.
|
||||
With complete Docker deployment, comprehensive documentation, and a growing feature set, Asteroid Radio is ready for production use while continuing to evolve with regular improvements, bug fixes, and new features based on user needs and technical requirements.
|
||||
|
||||
** Project Links
|
||||
- Repository: https://github.com/fade/asteroid
|
||||
|
|
@ -430,4 +403,4 @@ With complete Docker deployment, real-time audio visualization, comprehensive do
|
|||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-12-06*
|
||||
*Last Updated: 2025-11-01*
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio - Project Overview
|
||||
#+AUTHOR: Glenn Thompson & Brian O'Reilly (Fade)
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* 🎯 Mission
|
||||
|
||||
|
|
@ -45,8 +45,6 @@ Asteroid Radio is a modern, web-based music streaming platform designed for hack
|
|||
- **HTML5** with semantic templates
|
||||
- **CSS3** with dark hacker theme
|
||||
- **JavaScript** for interactive features
|
||||
- **Parenscript** - Lisp-to-JavaScript compiler for spectrum analyzer
|
||||
- **Web Audio API** - Real-time audio visualization
|
||||
- **VT323 Font** for retro terminal aesthetic
|
||||
|
||||
**Streaming:**
|
||||
|
|
@ -83,7 +81,6 @@ Asteroid Radio is a modern, web-based music streaming platform designed for hack
|
|||
- ✅ **Music Library** - Track management with pagination, search, and filtering
|
||||
- ✅ **User Playlists** - Create, manage, and play personal music collections
|
||||
- ✅ **Multiple Player Modes** - Inline, pop-out, and persistent frameset players
|
||||
- ✅ **Real-Time Spectrum Analyzer** - Visual audio frequency display using Web Audio API and Parenscript
|
||||
- ✅ **Stream Queue Control** - Admin control over broadcast stream queue (M3U-based)
|
||||
- ✅ **REST API** - Comprehensive JSON API with 15+ endpoints
|
||||
- ✅ **Music Streaming** - Multiple quality formats (128k MP3, 96k AAC, 64k MP3)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ Pagination system for efficient browsing of large music libraries.
|
|||
- **Music Library**: Track management with pagination, search, and filtering
|
||||
- **Playlists**: User playlists with creation and playback
|
||||
- **Multiple Player Modes**: Inline, pop-out, and persistent frameset players
|
||||
- **Real-Time Spectrum Analyzer**: Visual audio frequency display using Web Audio API
|
||||
- **Stream Queue Control**: Admin control over broadcast stream queue
|
||||
- **Docker Streaming Infrastructure**: Icecast2 + Liquidsoap containers
|
||||
- **Three Quality Streams**: 128kbps MP3, 96kbps AAC, 64kbps MP3
|
||||
|
|
@ -148,5 +147,5 @@ For detailed technical information, see the **[[file:PROJECT-OVERVIEW.org][Proje
|
|||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-12-06*
|
||||
*Documentation Version: 3.1*
|
||||
*Last Updated: 2025-10-26*
|
||||
*Documentation Version: 3.0*
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Stream Queue Control System
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Asteroid Radio Testing Guide
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: Track Pagination System - Complete
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#+TITLE: User Management System - Complete
|
||||
#+AUTHOR: Asteroid Radio Development Team
|
||||
#+DATE: 2025-12-06
|
||||
#+DATE: 2025-10-26
|
||||
|
||||
* Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -10,39 +10,43 @@
|
|||
(response (drakma:http-request icecast-url
|
||||
:want-stream nil
|
||||
:basic-authorization '("admin" "asteroid_admin_2024"))))
|
||||
(format t "DEBUG: Fetching Icecast stats from ~a~%" icecast-url)
|
||||
(when response
|
||||
(let ((xml-string (if (stringp response)
|
||||
response
|
||||
(babel:octets-to-string response :encoding :utf-8))))
|
||||
;; Extract total listener count from root <listeners> tag (sums all mount points)
|
||||
;; Extract title from asteroid.mp3 mount point
|
||||
(let* ((total-listeners (multiple-value-bind (match groups)
|
||||
(cl-ppcre:scan-to-strings "<listeners>(\\d+)</listeners>" xml-string)
|
||||
(if (and match groups)
|
||||
(parse-integer (aref groups 0) :junk-allowed t)
|
||||
0)))
|
||||
;; Get title from asteroid.mp3 mount point
|
||||
(mount-start (cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string))
|
||||
(title (if mount-start
|
||||
(let* ((source-section (subseq xml-string mount-start
|
||||
(or (cl-ppcre:scan "</source>" xml-string :start mount-start)
|
||||
(length xml-string)))))
|
||||
(multiple-value-bind (match groups)
|
||||
(cl-ppcre:scan-to-strings "<title>(.*?)</title>" source-section)
|
||||
(if (and match groups)
|
||||
(aref groups 0)
|
||||
"Unknown")))
|
||||
"Unknown")))
|
||||
;; Track recently played if title changed
|
||||
(when (and title
|
||||
(not (string= title "Unknown"))
|
||||
(not (equal title *last-known-track*)))
|
||||
(setf *last-known-track* title)
|
||||
(add-recently-played (list :title title
|
||||
:timestamp (get-universal-time))))
|
||||
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||
(:title . ,title)
|
||||
(:listeners . ,total-listeners)))))))
|
||||
(let ((xml-string (if (stringp response)
|
||||
response
|
||||
(babel:octets-to-string response :encoding :utf-8))))
|
||||
;; Extract total listener count from root <listeners> tag (sums all mount points)
|
||||
;; Extract title from asteroid.mp3 mount point
|
||||
(let* ((total-listeners (multiple-value-bind (match groups)
|
||||
(cl-ppcre:scan-to-strings "<listeners>(\\d+)</listeners>" xml-string)
|
||||
(if (and match groups)
|
||||
(parse-integer (aref groups 0) :junk-allowed t)
|
||||
0)))
|
||||
;; Get title from asteroid.mp3 mount point
|
||||
(mount-start (cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string))
|
||||
(title (if mount-start
|
||||
(let* ((source-section (subseq xml-string mount-start
|
||||
(or (cl-ppcre:scan "</source>" xml-string :start mount-start)
|
||||
(length xml-string)))))
|
||||
(multiple-value-bind (match groups)
|
||||
(cl-ppcre:scan-to-strings "<title>(.*?)</title>" source-section)
|
||||
(if (and match groups)
|
||||
(aref groups 0)
|
||||
"Unknown")))
|
||||
"Unknown")))
|
||||
(format t "DEBUG: Parsed title=~a, total-listeners=~a~%" title total-listeners)
|
||||
|
||||
;; Track recently played if title changed
|
||||
(when (and title
|
||||
(not (string= title "Unknown"))
|
||||
(not (equal title *last-known-track*)))
|
||||
(setf *last-known-track* title)
|
||||
(add-recently-played (list :title title
|
||||
:timestamp (get-universal-time))))
|
||||
|
||||
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||
(:title . ,title)
|
||||
(:listeners . ,total-listeners)))))))
|
||||
|
||||
(define-api asteroid/partial/now-playing () ()
|
||||
"Get Partial HTML with live status from Icecast server"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
;;;; parenscript-utils.lisp
|
||||
;;;; Utilities for generating JavaScript from ParenScript
|
||||
|
||||
(in-package :asteroid)
|
||||
|
||||
;;; ParenScript compilation utilities
|
||||
|
||||
(defun compile-ps-to-js (ps-code)
|
||||
"Compile ParenScript code to JavaScript string"
|
||||
(ps:ps* ps-code))
|
||||
|
||||
(defmacro define-js-route (name (&rest args) &body parenscript-body)
|
||||
"Define a route that serves compiled ParenScript as JavaScript"
|
||||
`(define-page ,name (,@args)
|
||||
(:content-type "application/javascript")
|
||||
(ps:ps ,@parenscript-body)))
|
||||
|
||||
;;; Common ParenScript macros and utilities
|
||||
|
||||
(defmacro ps-defun (name args &body body)
|
||||
"Define a ParenScript function"
|
||||
`(ps:defun ,name ,args ,@body))
|
||||
|
||||
(defmacro ps-api-call (endpoint method data success-callback error-callback)
|
||||
"Generate ParenScript for making API calls with fetch"
|
||||
`(ps:ps
|
||||
(fetch ,endpoint
|
||||
(ps:create :method ,method
|
||||
:headers (ps:create "Content-Type" "application/json")
|
||||
:body (ps:chain -j-s-o-n (stringify ,data))))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then ,success-callback)
|
||||
(catch ,error-callback)))
|
||||
|
|
@ -0,0 +1,665 @@
|
|||
;;;; 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)
|
||||
(update-player-status)
|
||||
(setup-event-listeners)
|
||||
(load-stream-queue)
|
||||
(setup-live-stream-monitor)
|
||||
(update-live-stream-info)
|
||||
;; Update live stream info every 10 seconds
|
||||
(set-interval update-live-stream-info 10000)
|
||||
;; Update player status every 5 seconds
|
||||
(set-interval update-player-status 5000))))
|
||||
|
||||
;; 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")))
|
||||
(load-m3u-btn (ps:chain document (get-element-by-id "load-from-m3u")))
|
||||
(clear-queue-btn (ps:chain document (get-element-by-id "clear-queue-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"))))
|
||||
|
||||
(when refresh-queue-btn
|
||||
(ps:chain refresh-queue-btn (add-event-listener "click" load-stream-queue)))
|
||||
(when load-m3u-btn
|
||||
(ps:chain load-m3u-btn (add-event-listener "click" load-queue-from-m3u)))
|
||||
(when clear-queue-btn
|
||||
(ps:chain clear-queue-btn (add-event-listener "click" clear-stream-queue)))
|
||||
(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)))))
|
||||
|
||||
;; Load tracks from API
|
||||
(defun load-tracks ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/admin/tracks")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Handle Radiance API response format
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when (= (ps:@ data status) "success")
|
||||
(setf *tracks* (or (ps:@ data tracks) (array)))
|
||||
(let ((count-el (ps:chain document (get-element-by-id "track-count"))))
|
||||
(when count-el
|
||||
(setf (ps:@ count-el text-content) (ps:@ *tracks* length))))
|
||||
(display-tracks *tracks*)))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading tracks:" error))
|
||||
(let ((container (ps:chain document (get-element-by-id "tracks-container"))))
|
||||
(when container
|
||||
(setf (ps:@ container inner-h-t-m-l)
|
||||
"<div class=\"error\">Error loading tracks</div>")))))))
|
||||
|
||||
;; Display tracks with pagination
|
||||
(defun display-tracks (track-list)
|
||||
(setf *filtered-tracks* track-list)
|
||||
(setf *current-page* 1)
|
||||
(render-page))
|
||||
|
||||
;; Render current page of tracks
|
||||
(defun render-page ()
|
||||
(let ((container (ps:chain document (get-element-by-id "tracks-container")))
|
||||
(pagination-controls (ps:chain document (get-element-by-id "pagination-controls"))))
|
||||
|
||||
(when (= (ps:@ *filtered-tracks* length) 0)
|
||||
(when container
|
||||
(setf (ps:@ container inner-h-t-m-l)
|
||||
"<div class=\"no-tracks\">No tracks found. Click \"Scan Library\" to add tracks.</div>"))
|
||||
(when pagination-controls
|
||||
(setf (ps:@ pagination-controls style display) "none"))
|
||||
(return))
|
||||
|
||||
;; Calculate pagination
|
||||
(let* ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*))))
|
||||
(start-index (* (- *current-page* 1) *tracks-per-page*))
|
||||
(end-index (+ start-index *tracks-per-page*))
|
||||
(tracks-to-show (ps:chain *filtered-tracks* (slice start-index end-index))))
|
||||
|
||||
;; Render tracks for current page
|
||||
(let ((tracks-html
|
||||
(ps:chain tracks-to-show
|
||||
(map (lambda (track)
|
||||
(+ "<div class=\"track-item\" data-track-id=\"" (ps:@ track id) "\">"
|
||||
"<div class=\"track-info\">"
|
||||
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>"
|
||||
"<div class=\"track-artist\">" (or (ps:@ track artist) "Unknown Artist") "</div>"
|
||||
"<div class=\"track-album\">" (or (ps:@ track album) "Unknown Album") "</div>"
|
||||
"</div>"
|
||||
"<div class=\"track-actions\">"
|
||||
"<button onclick=\"addToQueue(" (ps:@ track id) ", 'end')\" class=\"btn btn-sm btn-primary\">➕ Add to Queue</button>"
|
||||
"<button onclick=\"deleteTrack(" (ps:@ track id) ")\" class=\"btn btn-sm btn-danger\">🗑️ Delete</button>"
|
||||
"</div>"
|
||||
"</div>")))
|
||||
(join ""))))
|
||||
|
||||
(when container
|
||||
(setf (ps:@ container inner-h-t-m-l) tracks-html)))
|
||||
|
||||
;; Update pagination controls
|
||||
(let ((page-info (ps:chain document (get-element-by-id "page-info"))))
|
||||
(when page-info
|
||||
(setf (ps:@ page-info text-content)
|
||||
(+ "Page " *current-page* " of " total-pages " (" (ps:@ *filtered-tracks* length) " tracks)"))))
|
||||
|
||||
(when pagination-controls
|
||||
(setf (ps:@ pagination-controls style display)
|
||||
(if (> total-pages 1) "block" "none"))))))
|
||||
|
||||
;; Pagination functions
|
||||
(defun go-to-page (page)
|
||||
(let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*)))))
|
||||
(when (and (>= page 1) (<= page total-pages))
|
||||
(setf *current-page* page)
|
||||
(render-page))))
|
||||
|
||||
(defun previous-page ()
|
||||
(when (> *current-page* 1)
|
||||
(setf *current-page* (- *current-page* 1))
|
||||
(render-page)))
|
||||
|
||||
(defun next-page ()
|
||||
(let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*)))))
|
||||
(when (< *current-page* total-pages)
|
||||
(setf *current-page* (+ *current-page* 1))
|
||||
(render-page))))
|
||||
|
||||
(defun go-to-last-page ()
|
||||
(let ((total-pages (ps:chain -math (ceil (/ (ps:@ *filtered-tracks* length) *tracks-per-page*)))))
|
||||
(setf *current-page* total-pages)
|
||||
(render-page)))
|
||||
|
||||
(defun change-tracks-per-page ()
|
||||
(let ((select-el (ps:chain document (get-element-by-id "tracks-per-page"))))
|
||||
(when select-el
|
||||
(setf *tracks-per-page* (parse-int (ps:@ select-el value)))
|
||||
(setf *current-page* 1)
|
||||
(render-page))))
|
||||
|
||||
;; Scan music library
|
||||
(defun scan-library ()
|
||||
(let ((status-el (ps:chain document (get-element-by-id "scan-status")))
|
||||
(scan-btn (ps:chain document (get-element-by-id "scan-library"))))
|
||||
|
||||
(when status-el
|
||||
(setf (ps:@ status-el text-content) "Scanning..."))
|
||||
(when scan-btn
|
||||
(setf (ps:@ scan-btn disabled) t))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/admin/scan-library" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(when status-el
|
||||
(setf (ps:@ status-el text-content)
|
||||
(+ "✅ Added " (ps:getprop data "tracks-added") " tracks")))
|
||||
(load-tracks))
|
||||
(when status-el
|
||||
(setf (ps:@ status-el text-content) "❌ Scan failed"))))))
|
||||
(catch (lambda (error)
|
||||
(when status-el
|
||||
(setf (ps:@ status-el text-content) "❌ Scan error"))
|
||||
(ps:chain console (error "Error scanning library:" error))))
|
||||
(finally (lambda ()
|
||||
(when scan-btn
|
||||
(setf (ps:@ scan-btn disabled) nil))
|
||||
(set-timeout (lambda ()
|
||||
(when status-el
|
||||
(setf (ps:@ status-el text-content) "")))
|
||||
3000))))))
|
||||
|
||||
;; Filter tracks based on search
|
||||
(defun filter-tracks ()
|
||||
(let* ((search-input (ps:chain document (get-element-by-id "track-search")))
|
||||
(query (when search-input (ps:chain (ps:@ search-input value) (to-lower-case))))
|
||||
(filtered (ps:chain *tracks*
|
||||
(filter (lambda (track)
|
||||
(or (ps:chain (or (ps:@ track title) "") (to-lower-case) (includes query))
|
||||
(ps:chain (or (ps:@ track artist) "") (to-lower-case) (includes query))
|
||||
(ps:chain (or (ps:@ track album) "") (to-lower-case) (includes query))))))))
|
||||
(display-tracks filtered)))
|
||||
|
||||
;; Sort tracks
|
||||
(defun sort-tracks ()
|
||||
(let* ((sort-select (ps:chain document (get-element-by-id "sort-tracks")))
|
||||
(sort-by (when sort-select (ps:@ sort-select value)))
|
||||
(sorted (ps:chain *tracks*
|
||||
(slice)
|
||||
(sort (lambda (a b)
|
||||
(let ((a-val (or (ps:getprop a sort-by) ""))
|
||||
(b-val (or (ps:getprop b sort-by) "")))
|
||||
(ps:chain a-val (locale-compare b-val))))))))
|
||||
(display-tracks sorted)))
|
||||
|
||||
;; Initialize audio player
|
||||
(defun init-audio-player ()
|
||||
(unless *audio-player*
|
||||
(setf *audio-player* (new (-audio)))
|
||||
(ps:chain *audio-player*
|
||||
(add-event-listener "ended" (lambda ()
|
||||
(setf *current-track-id* nil)
|
||||
(update-player-status))))
|
||||
(ps:chain *audio-player*
|
||||
(add-event-listener "error" (lambda (e)
|
||||
(ps:chain console (error "Audio playback error:" e))
|
||||
(alert "Error playing audio file")))))
|
||||
*audio-player*)
|
||||
|
||||
;; Player functions
|
||||
(defun play-track (track-id)
|
||||
(unless track-id
|
||||
(alert "Please select a track to play")
|
||||
(return))
|
||||
|
||||
(ps:chain
|
||||
(-promise (lambda (resolve reject)
|
||||
(let ((player (init-audio-player)))
|
||||
(setf (ps:@ player src) (+ "/asteroid/tracks/" track-id "/stream"))
|
||||
(ps:chain player (play))
|
||||
(setf *current-track-id* track-id)
|
||||
(update-player-status)
|
||||
(resolve))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Play error:" error))
|
||||
(alert "Error playing track")))))
|
||||
|
||||
(defun pause-player ()
|
||||
(ps:chain
|
||||
(-promise (lambda (resolve reject)
|
||||
(when (and *audio-player* (not (ps:@ *audio-player* paused)))
|
||||
(ps:chain *audio-player* (pause))
|
||||
(update-player-status))
|
||||
(resolve)))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Pause error:" error))))))
|
||||
|
||||
(defun stop-player ()
|
||||
(ps:chain
|
||||
(-promise (lambda (resolve reject)
|
||||
(when *audio-player*
|
||||
(ps:chain *audio-player* (pause))
|
||||
(setf (ps:@ *audio-player* current-time) 0)
|
||||
(setf *current-track-id* nil)
|
||||
(update-player-status))
|
||||
(resolve)))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Stop error:" error))))))
|
||||
|
||||
(defun resume-player ()
|
||||
(ps:chain
|
||||
(-promise (lambda (resolve reject)
|
||||
(when (and *audio-player* (ps:@ *audio-player* paused) *current-track-id*)
|
||||
(ps:chain *audio-player* (play))
|
||||
(update-player-status))
|
||||
(resolve)))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Resume error:" error))))))
|
||||
|
||||
(defun update-player-status ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/player/status")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (data)
|
||||
(when (= (ps:@ data status) "success")
|
||||
(let ((player (ps:@ data player))
|
||||
(state-el (ps:chain document (get-element-by-id "player-state")))
|
||||
(track-el (ps:chain document (get-element-by-id "current-track"))))
|
||||
(when state-el
|
||||
(setf (ps:@ state-el text-content) (ps:@ player state)))
|
||||
(when track-el
|
||||
(setf (ps:@ track-el text-content) (or (ps:getprop player "current-track") "None")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error updating player status:" error))))))
|
||||
|
||||
;; Utility functions
|
||||
(defun stream-track (track-id)
|
||||
(ps:chain window (open (+ "/asteroid/tracks/" track-id "/stream") "_blank")))
|
||||
|
||||
(defun delete-track (track-id)
|
||||
(when (confirm "Are you sure you want to delete this track?")
|
||||
(alert "Track deletion not yet implemented")))
|
||||
|
||||
(defun copy-files ()
|
||||
(ps:chain
|
||||
(fetch "/admin/copy-files")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (data)
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(alert (ps:@ data message))
|
||||
(load-tracks))
|
||||
(alert (+ "Error: " (ps:@ data message))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error copying files:" error))
|
||||
(alert "Failed to copy files")))))
|
||||
|
||||
(defun open-incoming-folder ()
|
||||
(alert "Copy your MP3 files to: /home/glenn/Projects/Code/asteroid/music/incoming/\n\nThen click \"Copy Files to Library\" to add them to your music collection."))
|
||||
|
||||
;; Setup live stream monitor
|
||||
(defun setup-live-stream-monitor ()
|
||||
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
|
||||
(when live-audio
|
||||
(setf (ps:@ live-audio preload) "none"))))
|
||||
|
||||
;; Live stream info update
|
||||
(defun update-live-stream-info ()
|
||||
;; Don't update if stream is paused
|
||||
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
|
||||
(when (and live-audio (ps:@ live-audio paused))
|
||||
(return)))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/partial/now-playing-inline")
|
||||
(then (lambda (response)
|
||||
(let ((content-type (ps:chain response headers (get "content-type"))))
|
||||
(unless (ps:chain content-type (includes "text/plain"))
|
||||
(ps:chain console (error "Unexpected content type:" content-type))
|
||||
(return))
|
||||
(ps:chain response (text)))))
|
||||
(then (lambda (now-playing-text)
|
||||
(let ((now-playing-el (ps:chain document (get-element-by-id "live-now-playing"))))
|
||||
(when now-playing-el
|
||||
(setf (ps:@ now-playing-el text-content) now-playing-text)))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Could not fetch stream info:" error))
|
||||
(let ((now-playing-el (ps:chain document (get-element-by-id "live-now-playing"))))
|
||||
(when now-playing-el
|
||||
(setf (ps:@ now-playing-el text-content) "Error loading stream info")))))))
|
||||
|
||||
;; ========================================
|
||||
;; Stream Queue Management
|
||||
;; ========================================
|
||||
|
||||
;; Load current stream queue
|
||||
(defun load-stream-queue ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/queue")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when (= (ps:@ data status) "success")
|
||||
(setf *stream-queue* (or (ps:@ data queue) (array)))
|
||||
(display-stream-queue)))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading stream queue:" error))
|
||||
(let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
|
||||
(when container
|
||||
(setf (ps:@ container inner-h-t-m-l)
|
||||
"<div class=\"error\">Error loading queue</div>")))))))
|
||||
|
||||
;; Display stream queue
|
||||
(defun display-stream-queue ()
|
||||
(let ((container (ps:chain document (get-element-by-id "stream-queue-container"))))
|
||||
(when container
|
||||
(if (= (ps:@ *stream-queue* length) 0)
|
||||
(setf (ps:@ container inner-h-t-m-l)
|
||||
"<div class=\"empty-state\">Queue is empty. Add tracks below.</div>")
|
||||
(let ((html "<div class=\"queue-items\">"))
|
||||
(ps:chain *stream-queue*
|
||||
(for-each (lambda (item index)
|
||||
(when item
|
||||
(let ((is-first (= index 0))
|
||||
(is-last (= index (- (ps:@ *stream-queue* length) 1))))
|
||||
(setf html
|
||||
(+ html
|
||||
"<div class=\"queue-item\" data-track-id=\"" (ps:@ item id) "\" data-index=\"" index "\">"
|
||||
"<span class=\"queue-position\">" (+ index 1) "</span>"
|
||||
"<div class=\"queue-track-info\">"
|
||||
"<div class=\"track-title\">" (or (ps:@ item title) "Unknown") "</div>"
|
||||
"<div class=\"track-artist\">" (or (ps:@ item artist) "Unknown Artist") "</div>"
|
||||
"</div>"
|
||||
"<div class=\"queue-actions\">"
|
||||
"<button class=\"btn btn-sm btn-secondary\" onclick=\"moveTrackUp(" index ")\" " (if is-first "disabled" "") ">⬆️</button>"
|
||||
"<button class=\"btn btn-sm btn-secondary\" onclick=\"moveTrackDown(" index ")\" " (if is-last "disabled" "") ">⬇️</button>"
|
||||
"<button class=\"btn btn-sm btn-danger\" onclick=\"removeFromQueue(" (ps:@ item id) ")\">Remove</button>"
|
||||
"</div>"
|
||||
"</div>")))))))
|
||||
(setf html (+ html "</div>"))
|
||||
(setf (ps:@ container inner-h-t-m-l) html))))))
|
||||
|
||||
;; Clear stream queue
|
||||
(defun clear-stream-queue ()
|
||||
(unless (confirm "Clear the entire stream queue? This will stop playback until new tracks are added.")
|
||||
(return))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/queue/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
|
||||
(alert "Queue cleared successfully")
|
||||
(load-stream-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")))))
|
||||
|
||||
;; Load queue from M3U file
|
||||
(defun load-queue-from-m3u ()
|
||||
(unless (confirm "Load queue from stream-queue.m3u file? This will replace the current queue.")
|
||||
(return))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/queue/load-m3u" (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
|
||||
(alert (+ "Successfully loaded " (ps:@ data count) " tracks from M3U file!"))
|
||||
(load-stream-queue))
|
||||
(alert (+ "Error loading from M3U: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading from M3U:" error))
|
||||
(alert (+ "Error loading from M3U: " (ps:@ error message)))))))
|
||||
|
||||
;; Move track up in queue
|
||||
(defun move-track-up (index)
|
||||
(when (= index 0) (return))
|
||||
|
||||
;; Swap with previous track
|
||||
(let ((new-queue (ps:chain *stream-queue* (slice))))
|
||||
(let ((temp (ps:getprop new-queue (- index 1))))
|
||||
(setf (ps:getprop new-queue (- index 1)) (ps:getprop new-queue index))
|
||||
(setf (ps:getprop new-queue index) temp))
|
||||
(reorder-queue new-queue)))
|
||||
|
||||
;; Move track down in queue
|
||||
(defun move-track-down (index)
|
||||
(when (= index (- (ps:@ *stream-queue* length) 1)) (return))
|
||||
|
||||
;; Swap with next track
|
||||
(let ((new-queue (ps:chain *stream-queue* (slice))))
|
||||
(let ((temp (ps:getprop new-queue index)))
|
||||
(setf (ps:getprop new-queue index) (ps:getprop new-queue (+ index 1)))
|
||||
(setf (ps:getprop new-queue (+ index 1)) temp))
|
||||
(reorder-queue new-queue)))
|
||||
|
||||
;; Reorder the queue
|
||||
(defun reorder-queue (new-queue)
|
||||
(let ((track-ids (ps:chain new-queue
|
||||
(map (lambda (track) (ps:@ track id)))
|
||||
(join ","))))
|
||||
(ps:chain
|
||||
(fetch (+ "/api/asteroid/stream/queue/reorder?track-ids=" track-ids)
|
||||
(ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(load-stream-queue)
|
||||
(alert (+ "Error reordering queue: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error reordering queue:" error))
|
||||
(alert "Error reordering queue"))))))
|
||||
|
||||
;; Remove track from queue
|
||||
(defun remove-from-queue (track-id)
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/queue/remove"
|
||||
(ps:create :method "POST"
|
||||
:headers (ps:create "Content-Type" "application/x-www-form-urlencoded")
|
||||
:body (+ "track-id=" track-id)))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(load-stream-queue)
|
||||
(alert (+ "Error removing track: " (or (ps:@ data message) "Unknown error")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error removing track:" error))
|
||||
(alert "Error removing track")))))
|
||||
|
||||
;; Add track to queue
|
||||
(defun add-to-queue (track-id &optional (position "end") (show-notification t))
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/stream/queue/add"
|
||||
(ps:create :method "POST"
|
||||
:headers (ps:create "Content-Type" "application/x-www-form-urlencoded")
|
||||
:body (+ "track-id=" track-id "&position=" position)))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
;; Only reload queue if we're in the queue management section
|
||||
(let ((queue-container (ps:chain document (get-element-by-id "stream-queue-container"))))
|
||||
(when (and queue-container (not (= (ps:@ queue-container offset-parent) nil)))
|
||||
(load-stream-queue)))
|
||||
|
||||
;; Show brief success notification
|
||||
(when show-notification
|
||||
(show-toast "✓ Added to queue"))
|
||||
t)
|
||||
(progn
|
||||
(alert (+ "Error adding track: " (or (ps:@ data message) "Unknown error")))
|
||||
nil)))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error adding track:" error))
|
||||
(alert "Error adding track")
|
||||
nil))))
|
||||
|
||||
;; Simple toast notification
|
||||
(defun show-toast (message)
|
||||
(let ((toast (ps:chain document (create-element "div"))))
|
||||
(setf (ps:@ toast text-content) message)
|
||||
(setf (ps:@ toast style css-text)
|
||||
"position: fixed; bottom: 20px; right: 20px; background: #00ff00; color: #000; padding: 12px 20px; border-radius: 4px; font-weight: bold; z-index: 10000; animation: slideIn 0.3s ease-out;")
|
||||
(ps:chain document body (append-child toast))
|
||||
|
||||
(set-timeout (lambda ()
|
||||
(setf (ps:@ toast style opacity) "0")
|
||||
(setf (ps:@ toast style transition) "opacity 0.3s")
|
||||
(set-timeout (lambda () (ps:chain toast (remove))) 300))
|
||||
2000)))
|
||||
|
||||
;; Add random tracks to queue
|
||||
(defun add-random-tracks ()
|
||||
(when (= (ps:@ *tracks* length) 0)
|
||||
(alert "No tracks available. Please scan the library first.")
|
||||
(return))
|
||||
|
||||
(let* ((count 10)
|
||||
(shuffled (ps:chain *tracks* (slice) (sort (lambda () (- (ps:chain -math (random)) 0.5)))))
|
||||
(selected (ps:chain shuffled (slice 0 (ps:chain -math (min count (ps:@ *tracks* length)))))))
|
||||
|
||||
(ps:chain selected
|
||||
(for-each (lambda (track)
|
||||
(add-to-queue (ps:@ track id) "end" nil))))
|
||||
|
||||
(show-toast (+ "✓ Added " (ps:@ selected length) " random tracks to queue"))))
|
||||
|
||||
;; Search tracks for adding to queue
|
||||
(defun search-tracks-for-queue (event)
|
||||
(clear-timeout *queue-search-timeout*)
|
||||
(let ((query (ps:chain (ps:@ event target value) (to-lower-case))))
|
||||
|
||||
(when (< (ps:@ query length) 2)
|
||||
(let ((results-container (ps:chain document (get-element-by-id "queue-track-results"))))
|
||||
(when results-container
|
||||
(setf (ps:@ results-container inner-h-t-m-l) "")))
|
||||
(return))
|
||||
|
||||
(setf *queue-search-timeout*
|
||||
(set-timeout (lambda ()
|
||||
(let ((results (ps:chain *tracks*
|
||||
(filter (lambda (track)
|
||||
(or (and (ps:@ track title)
|
||||
(ps:chain (ps:@ track title) (to-lower-case) (includes query)))
|
||||
(and (ps:@ track artist)
|
||||
(ps:chain (ps:@ track artist) (to-lower-case) (includes query)))
|
||||
(and (ps:@ track album)
|
||||
(ps:chain (ps:@ track album) (to-lower-case) (includes query))))))
|
||||
(slice 0 20))))
|
||||
(display-queue-search-results results)))
|
||||
300))))
|
||||
|
||||
;; Display search results for queue
|
||||
(defun display-queue-search-results (results)
|
||||
(let ((container (ps:chain document (get-element-by-id "queue-track-results"))))
|
||||
(when container
|
||||
(if (= (ps:@ results length) 0)
|
||||
(setf (ps:@ container inner-h-t-m-l)
|
||||
"<div class=\"empty-state\">No tracks found</div>")
|
||||
(let ((html "<div class=\"search-results\">"))
|
||||
(ps:chain results
|
||||
(for-each (lambda (track)
|
||||
(setf html
|
||||
(+ html
|
||||
"<div class=\"search-result-item\">"
|
||||
"<div class=\"track-info\">"
|
||||
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown") "</div>"
|
||||
"<div class=\"track-artist\">" (or (ps:@ track artist) "Unknown") " - " (or (ps:@ track album) "Unknown Album") "</div>"
|
||||
"</div>"
|
||||
"<div class=\"track-actions\">"
|
||||
"<button class=\"btn btn-sm btn-primary\" onclick=\"addToQueue(" (ps:@ track id) ", 'end')\">Add to End</button>"
|
||||
"<button class=\"btn btn-sm btn-success\" onclick=\"addToQueue(" (ps:@ track id) ", 'next')\">Play Next</button>"
|
||||
"</div>"
|
||||
"</div>")))))
|
||||
(setf html (+ html "</div>"))
|
||||
(setf (ps:@ container inner-h-t-m-l) html))))))
|
||||
|
||||
;; 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*)
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
;;;; auth-ui.lisp - ParenScript version of auth-ui.js
|
||||
;;;; Handle authentication UI state across all pages
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *auth-ui-js*
|
||||
(ps:ps*
|
||||
'(progn
|
||||
|
||||
;; Check if user is logged in by calling the API
|
||||
(defun check-auth-status ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/auth-status")
|
||||
(then (lambda (response)
|
||||
(ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; api-output wraps response in {status, message, data}
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
data)))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error checking auth status:" error))
|
||||
(ps:create :logged-in false
|
||||
:is-admin false)))))
|
||||
|
||||
;; Update UI based on authentication status
|
||||
(defun update-auth-ui (auth-status)
|
||||
;; Show/hide elements based on login status
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-logged-in]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status logged-in)
|
||||
"inline-block"
|
||||
"none")))))
|
||||
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-logged-out]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status logged-in)
|
||||
"none"
|
||||
"inline-block")))))
|
||||
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-admin]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status is-admin)
|
||||
"inline-block"
|
||||
"none"))))))
|
||||
|
||||
;; Initialize auth UI on page load
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
(lambda ()
|
||||
(ps:chain console (log "Auth UI initializing..."))
|
||||
(ps:chain (check-auth-status)
|
||||
(then (lambda (auth-status)
|
||||
(ps:chain console (log "Auth status:" auth-status))
|
||||
(update-auth-ui auth-status)
|
||||
(ps:chain console (log "Auth UI updated")))))))))
|
||||
"Compiled JavaScript for auth UI - generated at load time"))
|
||||
|
||||
(defun generate-auth-ui-js ()
|
||||
"Return the pre-compiled JavaScript for authentication UI"
|
||||
*auth-ui-js*)
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
;;;; front-page.lisp - ParenScript version of front-page.js
|
||||
;;;; Stream quality, now playing, pop-out player, frameset mode
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *front-page-js*
|
||||
(ps:ps*
|
||||
'(progn
|
||||
|
||||
;; Stream quality configuration
|
||||
(defun get-stream-config (stream-base-url encoding)
|
||||
(let ((config (ps:create
|
||||
:aac (ps:create
|
||||
:url (+ stream-base-url "/asteroid.aac")
|
||||
:format "AAC 96kbps Stereo"
|
||||
:type "audio/aac"
|
||||
:mount "asteroid.aac")
|
||||
:mp3 (ps:create
|
||||
:url (+ stream-base-url "/asteroid.mp3")
|
||||
:format "MP3 128kbps Stereo"
|
||||
:type "audio/mpeg"
|
||||
:mount "asteroid.mp3")
|
||||
:low (ps:create
|
||||
:url (+ stream-base-url "/asteroid-low.mp3")
|
||||
:format "MP3 64kbps Stereo"
|
||||
:type "audio/mpeg"
|
||||
:mount "asteroid-low.mp3"))))
|
||||
(ps:getprop config encoding)))
|
||||
|
||||
;; Change stream quality
|
||||
(defun change-stream-quality ()
|
||||
(let* ((selector (ps:chain document (get-element-by-id "stream-quality")))
|
||||
(stream-base-url (ps:chain document (get-element-by-id "stream-base-url")))
|
||||
(config (get-stream-config (ps:@ stream-base-url value) (ps:@ selector value)))
|
||||
(audio-element (ps:chain document (get-element-by-id "live-audio")))
|
||||
(source-element (ps:chain document (get-element-by-id "audio-source")))
|
||||
(was-playing (not (ps:@ audio-element paused)))
|
||||
(current-time (ps:@ audio-element current-time)))
|
||||
|
||||
;; Save preference
|
||||
(ps:chain local-storage (set-item "stream-quality" (ps:@ selector value)))
|
||||
|
||||
;; Update stream information
|
||||
(update-stream-information)
|
||||
|
||||
;; Update audio player
|
||||
(setf (ps:@ source-element src) (ps:@ config url))
|
||||
(setf (ps:@ source-element type) (ps:@ config type))
|
||||
(ps:chain audio-element (load))
|
||||
|
||||
;; Resume playback if it was playing
|
||||
(when was-playing
|
||||
(ps:chain (ps:chain audio-element (play))
|
||||
(catch (lambda (e)
|
||||
(ps:chain console (log "Autoplay prevented:" e))))))))
|
||||
|
||||
;; Update now playing info from API
|
||||
(defun update-now-playing ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/partial/now-playing")
|
||||
(then (lambda (response)
|
||||
(let ((content-type (ps:chain response headers (get "content-type"))))
|
||||
(if (ps:chain content-type (includes "text/html"))
|
||||
(ps:chain response (text))
|
||||
(throw (ps:new (-error "Error connecting to stream")))))))
|
||||
(then (lambda (data)
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "now-playing")) inner-h-t-m-l)
|
||||
data)))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (log "Could not fetch stream status:" error))))))
|
||||
|
||||
;; Update stream information
|
||||
(defun update-stream-information ()
|
||||
(let* ((selector (ps:chain document (get-element-by-id "stream-quality")))
|
||||
(stream-base-url (ps:chain document (get-element-by-id "stream-base-url")))
|
||||
(stream-quality (or (ps:chain local-storage (get-item "stream-quality")) "aac")))
|
||||
|
||||
;; Update selector if needed
|
||||
(when (and selector (not (= (ps:@ selector value) stream-quality)))
|
||||
(setf (ps:@ selector value) stream-quality)
|
||||
(ps:chain selector (dispatch-event (ps:new (-event "change")))))
|
||||
|
||||
;; Update stream info display
|
||||
(when stream-base-url
|
||||
(let ((config (get-stream-config (ps:@ stream-base-url value) stream-quality)))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "stream-url")) text-content)
|
||||
(ps:@ config url))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "stream-format")) text-content)
|
||||
(ps:@ config format))
|
||||
(let ((status-quality (ps:chain document (query-selector "[data-text=\"stream-quality\"]"))))
|
||||
(when status-quality
|
||||
(setf (ps:@ status-quality text-content) (ps:@ config format))))))))
|
||||
|
||||
;; Pop-out player functionality
|
||||
(defvar *popout-window* nil)
|
||||
|
||||
(defun open-popout-player ()
|
||||
;; Check if popout is already open
|
||||
(when (and *popout-window* (not (ps:@ *popout-window* closed)))
|
||||
(ps:chain *popout-window* (focus))
|
||||
(return))
|
||||
|
||||
;; Calculate centered position
|
||||
(let* ((width 420)
|
||||
(height 300)
|
||||
(left (/ (- (ps:@ screen width) width) 2))
|
||||
(top (/ (- (ps:@ screen height) height) 2))
|
||||
(features (+ "width=" width ",height=" height ",left=" left ",top=" top
|
||||
",resizable=yes,scrollbars=no,status=no,menubar=no,toolbar=no,location=no")))
|
||||
|
||||
;; Open popout window
|
||||
(setf *popout-window*
|
||||
(ps:chain window (open "/asteroid/popout-player" "AsteroidPlayer" features)))
|
||||
|
||||
;; Update button state
|
||||
(update-popout-button t)))
|
||||
|
||||
(defun update-popout-button (is-open)
|
||||
(let ((btn (ps:chain document (get-element-by-id "popout-btn"))))
|
||||
(when btn
|
||||
(if is-open
|
||||
(progn
|
||||
(setf (ps:@ btn text-content) "✓ Player Open")
|
||||
(ps:chain btn class-list (remove "btn-info"))
|
||||
(ps:chain btn class-list (add "btn-success")))
|
||||
(progn
|
||||
(setf (ps:@ btn text-content) "🗗 Pop Out Player")
|
||||
(ps:chain btn class-list (remove "btn-success"))
|
||||
(ps:chain btn class-list (add "btn-info")))))))
|
||||
|
||||
;; Frameset mode functionality
|
||||
(defun enable-frameset-mode ()
|
||||
(ps:chain local-storage (set-item "useFrameset" "true"))
|
||||
(setf (ps:@ window location href) "/asteroid/frameset"))
|
||||
|
||||
(defun disable-frameset-mode ()
|
||||
(ps:chain local-storage (remove-item "useFrameset"))
|
||||
(setf (ps:@ window location href) "/asteroid/"))
|
||||
|
||||
(defun redirect-when-frame ()
|
||||
(let* ((path (ps:@ window location pathname))
|
||||
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self))))
|
||||
(is-content-frame (ps:chain path (includes "asteroid/content"))))
|
||||
|
||||
(when (and is-frameset-page (not is-content-frame))
|
||||
(setf (ps:@ window location href) "/asteroid/content"))
|
||||
|
||||
(when (and (not is-frameset-page) is-content-frame)
|
||||
(setf (ps:@ window location href) "/asteroid"))))
|
||||
|
||||
;; Initialize on page load
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
(lambda ()
|
||||
;; Update stream information
|
||||
(update-stream-information)
|
||||
|
||||
;; Periodically update stream info if in frameset
|
||||
(let ((is-frameset-page (not (= (ps:@ window parent) (ps:@ window self)))))
|
||||
(when is-frameset-page
|
||||
(set-interval update-stream-information 1000)))
|
||||
|
||||
;; Update now playing
|
||||
(update-now-playing)
|
||||
|
||||
;; Auto-reconnect on stream errors
|
||||
(let ((audio-element (ps:chain document (get-element-by-id "live-audio"))))
|
||||
(when audio-element
|
||||
(ps:chain audio-element
|
||||
(add-event-listener
|
||||
"error"
|
||||
(lambda (err)
|
||||
(ps:chain console (log "Stream error, attempting reconnect in 3 seconds..." err))
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain audio-element (load))
|
||||
(ps:chain (ps:chain audio-element (play))
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Reconnect failed:" err))))))
|
||||
3000))))
|
||||
|
||||
(ps:chain audio-element
|
||||
(add-event-listener
|
||||
"stalled"
|
||||
(lambda ()
|
||||
(ps:chain console (log "Stream stalled, reloading..."))
|
||||
(ps:chain audio-element (load))
|
||||
(ps:chain (ps:chain audio-element (play))
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Reload failed:" err))))))))
|
||||
|
||||
(let ((pause-timestamp nil)
|
||||
(is-reconnecting false)
|
||||
(needs-reconnect false)
|
||||
(pause-reconnect-threshold 10000))
|
||||
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "pause"
|
||||
(lambda ()
|
||||
(setf pause-timestamp (ps:chain |Date| (now)))
|
||||
(ps:chain console (log "Stream paused at:" pause-timestamp)))))
|
||||
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "play"
|
||||
(lambda ()
|
||||
(when (and (not is-reconnecting)
|
||||
pause-timestamp
|
||||
(> (- (ps:chain |Date| (now)) pause-timestamp) pause-reconnect-threshold))
|
||||
(setf needs-reconnect true)
|
||||
(ps:chain console (log "Long pause detected, will reconnect when playing starts...")))
|
||||
(setf pause-timestamp nil))))
|
||||
|
||||
(ps:chain audio-element
|
||||
(add-event-listener "playing"
|
||||
(lambda ()
|
||||
(when (and needs-reconnect (not is-reconnecting))
|
||||
(setf is-reconnecting true)
|
||||
(setf needs-reconnect false)
|
||||
(ps:chain console (log "Reconnecting stream after long pause to clear stale buffers..."))
|
||||
|
||||
(ps:chain audio-element (pause))
|
||||
|
||||
(when (ps:@ window |resetSpectrumAnalyzer|)
|
||||
(ps:chain window (reset-spectrum-analyzer)))
|
||||
|
||||
(ps:chain audio-element (load))
|
||||
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain audio-element (play)
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Reconnect play failed:" err)))))
|
||||
|
||||
(when (ps:@ window |initSpectrumAnalyzer|)
|
||||
(ps:chain window (init-spectrum-analyzer))
|
||||
(ps:chain console (log "Spectrum analyzer reinitialized after reconnect")))
|
||||
|
||||
(setf is-reconnecting false))
|
||||
200))))))))
|
||||
|
||||
;; Check frameset preference
|
||||
(let ((path (ps:@ window location pathname))
|
||||
(is-frameset-page (not (= (ps:@ window parent) (ps:@ window self)))))
|
||||
(when (and (= (ps:chain local-storage (get-item "useFrameset")) "true")
|
||||
(not is-frameset-page)
|
||||
(ps:chain path (includes "/asteroid")))
|
||||
(setf (ps:@ window location href) "/asteroid/frameset"))
|
||||
|
||||
(redirect-when-frame)))))
|
||||
|
||||
;; Update now playing every 10 seconds
|
||||
(set-interval update-now-playing 10000)
|
||||
|
||||
;; Listen for messages from popout window
|
||||
(ps:chain window
|
||||
(add-event-listener
|
||||
"message"
|
||||
(lambda (event)
|
||||
(cond
|
||||
((= (ps:@ event data type) "popout-opened")
|
||||
(update-popout-button t))
|
||||
((= (ps:@ event data type) "popout-closed")
|
||||
(update-popout-button nil)
|
||||
(setf *popout-window* nil))))))
|
||||
|
||||
;; Check if popout is still open periodically
|
||||
(set-interval
|
||||
(lambda ()
|
||||
(when (and *popout-window* (ps:@ *popout-window* closed))
|
||||
(update-popout-button nil)
|
||||
(setf *popout-window* nil)))
|
||||
1000)))
|
||||
"Compiled JavaScript for front-page - generated at load time")
|
||||
|
||||
(defun generate-front-page-js ()
|
||||
"Return the pre-compiled JavaScript for front page"
|
||||
*front-page-js*)
|
||||
|
|
@ -0,0 +1,667 @@
|
|||
;;;; player.lisp - ParenScript version of player.js
|
||||
;;;; Web Player functionality including audio playback, playlists, queue management, and live streaming
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *player-js*
|
||||
(ps:ps*
|
||||
'(progn
|
||||
|
||||
;; Global variables
|
||||
(defvar *tracks* (array))
|
||||
(defvar *current-track* nil)
|
||||
(defvar *current-track-index* -1)
|
||||
(defvar *play-queue* (array))
|
||||
(defvar *is-shuffled* nil)
|
||||
(defvar *is-repeating* nil)
|
||||
(defvar *audio-player* nil)
|
||||
|
||||
;; Pagination variables for track library
|
||||
(defvar *library-current-page* 1)
|
||||
(defvar *library-tracks-per-page* 20)
|
||||
(defvar *filtered-library-tracks* (array))
|
||||
|
||||
;; Initialize player on page load
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
(lambda ()
|
||||
(setf *audio-player* (ps:chain document (get-element-by-id "audio-player")))
|
||||
(redirect-when-frame)
|
||||
(load-tracks)
|
||||
(load-playlists)
|
||||
(setup-event-listeners)
|
||||
(update-player-display)
|
||||
(update-volume)
|
||||
|
||||
;; Setup live stream with reduced buffering and reconnect logic
|
||||
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
|
||||
(when live-audio
|
||||
;; Reduce buffer to minimize delay
|
||||
(setf (ps:@ live-audio preload) "none")
|
||||
|
||||
;; Add reconnect logic for long pauses
|
||||
(let ((pause-timestamp nil)
|
||||
(is-reconnecting false)
|
||||
(needs-reconnect false)
|
||||
(pause-reconnect-threshold 10000))
|
||||
|
||||
(ps:chain live-audio
|
||||
(add-event-listener "pause"
|
||||
(lambda ()
|
||||
(setf pause-timestamp (ps:chain |Date| (now)))
|
||||
(ps:chain console (log "Live stream paused at:" pause-timestamp)))))
|
||||
|
||||
(ps:chain live-audio
|
||||
(add-event-listener "play"
|
||||
(lambda ()
|
||||
(when (and (not is-reconnecting)
|
||||
pause-timestamp
|
||||
(> (- (ps:chain |Date| (now)) pause-timestamp) pause-reconnect-threshold))
|
||||
(setf needs-reconnect true)
|
||||
(ps:chain console (log "Long pause detected, will reconnect when playing starts...")))
|
||||
(setf pause-timestamp nil))))
|
||||
|
||||
(ps:chain live-audio
|
||||
(add-event-listener "playing"
|
||||
(lambda ()
|
||||
(when (and needs-reconnect (not is-reconnecting))
|
||||
(setf is-reconnecting true)
|
||||
(setf needs-reconnect false)
|
||||
(ps:chain console (log "Reconnecting live stream after long pause to clear stale buffers..."))
|
||||
|
||||
(ps:chain live-audio (pause))
|
||||
|
||||
(when (ps:@ window |resetSpectrumAnalyzer|)
|
||||
(ps:chain window (reset-spectrum-analyzer)))
|
||||
|
||||
(ps:chain live-audio (load))
|
||||
|
||||
(set-timeout
|
||||
(lambda ()
|
||||
(ps:chain live-audio (play)
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Reconnect play failed:" err)))))
|
||||
|
||||
(when (ps:@ window |initSpectrumAnalyzer|)
|
||||
(ps:chain window (init-spectrum-analyzer))
|
||||
(ps:chain console (log "Spectrum analyzer reinitialized after reconnect")))
|
||||
|
||||
(setf is-reconnecting false))
|
||||
200)))))
|
||||
)))
|
||||
|
||||
;; Restore user quality preference
|
||||
(let ((selector (ps:chain document (get-element-by-id "live-stream-quality")))
|
||||
(stream-quality (ps:chain (ps:@ local-storage (get-item "stream-quality")) "aac")))
|
||||
(when (and selector (not (== (ps:@ selector value) stream-quality)))
|
||||
(setf (ps:@ selector value) stream-quality)
|
||||
(ps:chain selector (dispatch-event (new "Event" "change"))))))))
|
||||
|
||||
;; Frame redirection logic
|
||||
(defun redirect-when-frame ()
|
||||
(let ((path (ps:@ window location pathname))
|
||||
(is-frameset-page (not (== (ps:@ window parent) (ps:@ window self))))
|
||||
(is-content-frame (ps:chain path (includes "player-content"))))
|
||||
|
||||
(when (and is-frameset-page (not is-content-frame))
|
||||
(setf (ps:@ window location href) "/asteroid/player-content"))
|
||||
|
||||
(when (and (not is-frameset-page) is-content-frame)
|
||||
(setf (ps:@ window location href) "/asteroid/player"))))
|
||||
|
||||
;; Setup all event listeners
|
||||
(defun setup-event-listeners ()
|
||||
;; Search
|
||||
(ps:chain (ps:chain document (get-element-by-id "search-tracks"))
|
||||
(add-event-listener "input" filter-tracks))
|
||||
|
||||
;; Player controls
|
||||
(ps:chain (ps:chain document (get-element-by-id "play-pause-btn"))
|
||||
(add-event-listener "click" toggle-play-pause))
|
||||
(ps:chain (ps:chain document (get-element-by-id "prev-btn"))
|
||||
(add-event-listener "click" play-previous))
|
||||
(ps:chain (ps:chain document (get-element-by-id "next-btn"))
|
||||
(add-event-listener "click" play-next))
|
||||
(ps:chain (ps:chain document (get-element-by-id "shuffle-btn"))
|
||||
(add-event-listener "click" toggle-shuffle))
|
||||
(ps:chain (ps:chain document (get-element-by-id "repeat-btn"))
|
||||
(add-event-listener "click" toggle-repeat))
|
||||
|
||||
;; Volume control
|
||||
(ps:chain (ps:chain document (get-element-by-id "volume-slider"))
|
||||
(add-event-listener "input" update-volume))
|
||||
|
||||
;; Audio player events
|
||||
(when *audio-player*
|
||||
(ps:chain *audio-player*
|
||||
(add-event-listener "loadedmetadata" update-time-display)
|
||||
(add-event-listener "timeupdate" update-time-display)
|
||||
(add-event-listener "ended" handle-track-end)
|
||||
(add-event-listener "play" (lambda () (update-play-button "⏸️ Pause")))
|
||||
(add-event-listener "pause" (lambda () (update-play-button "▶️ Play")))))
|
||||
|
||||
;; Playlist controls
|
||||
(ps:chain (ps:chain document (get-element-by-id "create-playlist"))
|
||||
(add-event-listener "click" create-playlist))
|
||||
(ps:chain (ps:chain document (get-element-by-id "clear-queue"))
|
||||
(add-event-listener "click" clear-queue))
|
||||
(ps:chain (ps:chain document (get-element-by-id "save-queue"))
|
||||
(add-event-listener "click" save-queue-as-playlist)))
|
||||
|
||||
;; Load tracks from API
|
||||
(defun load-tracks ()
|
||||
(ps:chain
|
||||
(ps:chain (fetch "/api/asteroid/tracks"))
|
||||
(then (lambda (response)
|
||||
(if (ps:@ response ok)
|
||||
(ps:chain response (json))
|
||||
(progn
|
||||
(ps:chain console (error (+ "HTTP " (ps:@ response status))))
|
||||
(ps:create :status "error" :tracks (array))))))
|
||||
(then (lambda (result)
|
||||
;; Handle RADIANCE API wrapper format
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (== (ps:@ data status) "success")
|
||||
(progn
|
||||
(setf *tracks* (or (ps:@ data tracks) (array)))
|
||||
(display-tracks *tracks*))
|
||||
(progn
|
||||
(ps:chain console (error "Error loading tracks:" (ps:@ data error)))
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-html)
|
||||
"<div class=\"error\">Error loading tracks</div>"))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading tracks:" error))
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "track-list")) inner-html)
|
||||
"<div class=\"error\">Error loading tracks</div>")))))
|
||||
|
||||
;; Display tracks in library
|
||||
(defun display-tracks (track-list)
|
||||
(setf *filtered-library-tracks* track-list)
|
||||
(setf *library-current-page* 1)
|
||||
(render-library-page))
|
||||
|
||||
;; Render current library page
|
||||
(defun render-library-page ()
|
||||
(let ((container (ps:chain document (get-element-by-id "track-list")))
|
||||
(pagination-controls (ps:chain document (get-element-by-id "library-pagination-controls"))))
|
||||
|
||||
(if (== (ps:@ *filtered-library-tracks* length) 0)
|
||||
(progn
|
||||
(setf (ps:@ container inner-html) "<div class=\"no-tracks\">No tracks found</div>")
|
||||
(setf (ps:@ pagination-controls style display) "none")
|
||||
(return)))
|
||||
|
||||
;; Calculate pagination
|
||||
(let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*)))
|
||||
(start-index (* (* *library-current-page* -1) *library-tracks-per-page* *library-tracks-per-page*))
|
||||
(end-index (+ start-index *library-tracks-per-page*))
|
||||
(tracks-to-show (ps:chain *filtered-library-tracks* (slice start-index end-index))))
|
||||
|
||||
;; Render tracks for current page
|
||||
(let ((tracks-html (ps:chain tracks-to-show
|
||||
(map (lambda (track page-index)
|
||||
;; Find the actual index in the full tracks array
|
||||
(let ((actual-index (ps:chain *tracks*
|
||||
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ track id)))))))
|
||||
(+ "<div class=\"track-item\" data-track-id=\"" (ps:@ track id) "\" data-index=\"" actual-index "\">"
|
||||
"<div class=\"track-info\">"
|
||||
"<div class=\"track-title\">" (or (ps:@ track title 0) "Unknown Title") "</div>"
|
||||
"<div class=\"track-meta\">" (or (ps:@ track artist 0) "Unknown Artist") " • " (or (ps:@ track album 0) "Unknown Album") "</div>"
|
||||
"</div>"
|
||||
"<div class=\"track-actions\">"
|
||||
"<button onclick=\"playTrack(" actual-index ")\" class=\"btn btn-sm btn-success\">▶️</button>"
|
||||
"<button onclick=\"addToQueue(" actual-index ")\" class=\"btn btn-sm btn-info\">➕</button>"
|
||||
"</div>"
|
||||
"</div>"))))
|
||||
(join ""))))
|
||||
|
||||
(setf (ps:@ container inner-html) tracks-html)
|
||||
|
||||
;; Update pagination controls
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "library-page-info")) text-content)
|
||||
(+ "Page " *library-current-page* " of " total-pages " (" (ps:@ *filtered-library-tracks* length) " tracks)"))
|
||||
(setf (ps:@ pagination-controls style display)
|
||||
(if (> total-pages 1) "block" "none"))))))
|
||||
|
||||
;; Library pagination functions
|
||||
(defun library-go-to-page (page)
|
||||
(let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*))))
|
||||
(when (and (>= page 1) (<= page total-pages))
|
||||
(setf *library-current-page* page)
|
||||
(render-library-page))))
|
||||
|
||||
(defun library-previous-page ()
|
||||
(when (> *library-current-page* 1)
|
||||
(setf *library-current-page* (- *library-current-page* 1))
|
||||
(render-library-page)))
|
||||
|
||||
(defun library-next-page ()
|
||||
(let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*))))
|
||||
(when (< *library-current-page* total-pages)
|
||||
(setf *library-current-page* (+ *library-current-page* 1))
|
||||
(render-library-page))))
|
||||
|
||||
(defun library-go-to-last-page ()
|
||||
(let ((total-pages (ceiling (/ (ps:@ *filtered-library-tracks* length) *library-tracks-per-page*))))
|
||||
(setf *library-current-page* total-pages)
|
||||
(render-library-page)))
|
||||
|
||||
(defun change-library-tracks-per-page ()
|
||||
(setf *library-tracks-per-page*
|
||||
(parseInt (ps:chain (ps:chain document (get-element-by-id "library-tracks-per-page")) value)))
|
||||
(setf *library-current-page* 1)
|
||||
(render-library-page))
|
||||
|
||||
;; Filter tracks based on search query
|
||||
(defun filter-tracks ()
|
||||
(let ((query (ps:chain (ps:chain document (get-element-by-id "search-tracks")) value (to-lower-case))))
|
||||
(let ((filtered (ps:chain *tracks*
|
||||
(filter (lambda (track)
|
||||
(or (ps:chain (or (ps:@ track title 0) "") (to-lower-case) (includes query))
|
||||
(ps:chain (or (ps:@ track artist 0) "") (to-lower-case) (includes query))
|
||||
(ps:chain (or (ps:@ track album 0) "") (to-lower-case) (includes query))))))))
|
||||
(display-tracks filtered))))
|
||||
|
||||
;; Play a specific track by index
|
||||
(defun play-track (index)
|
||||
(when (and (>= index 0) (< index (ps:@ *tracks* length)))
|
||||
(setf *current-track* (aref *tracks* index))
|
||||
(setf *current-track-index* index)
|
||||
|
||||
;; Load track into audio player
|
||||
(setf (ps:@ *audio-player* src) (+ "/asteroid/tracks/" (ps:@ *current-track* id) "/stream"))
|
||||
(ps:chain *audio-player* (load))
|
||||
(ps:chain *audio-player*
|
||||
(play)
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Playback error:" error))
|
||||
(alert "Error playing track. The track may not be available."))))
|
||||
|
||||
(update-player-display)
|
||||
|
||||
;; Update server-side player state
|
||||
(ps:chain (fetch (+ "/api/asteroid/player/play?track-id=" (ps:@ *current-track* id))
|
||||
(ps:create :method "POST"))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "API update error:" error)))))))
|
||||
|
||||
;; Toggle play/pause
|
||||
(defun toggle-play-pause ()
|
||||
(if *current-track*
|
||||
(if (ps:@ *audio-player* paused)
|
||||
(ps:chain *audio-player* (play))
|
||||
(ps:chain *audio-player* (pause)))
|
||||
(alert "Please select a track to play")))
|
||||
|
||||
;; Play previous track
|
||||
(defun play-previous ()
|
||||
(if (> (ps:@ *play-queue* length) 0)
|
||||
;; Play from queue
|
||||
(let ((prev-index (max 0 (- *current-track-index* 1))))
|
||||
(play-track prev-index))
|
||||
;; Play previous track in library
|
||||
(let ((prev-index (if (> *current-track-index* 0)
|
||||
(- *current-track-index* 1)
|
||||
(- (ps:@ *tracks* length) 1))))
|
||||
(play-track prev-index))))
|
||||
|
||||
;; Play next track
|
||||
(defun play-next ()
|
||||
(if (> (ps:@ *play-queue* length) 0)
|
||||
;; Play from queue
|
||||
(let ((next-track (ps:chain *play-queue* (shift))))
|
||||
(play-track (ps:chain *tracks*
|
||||
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ next-track id))))))
|
||||
(update-queue-display))
|
||||
;; Play next track in library
|
||||
(let ((next-index (if *is-shuffled*
|
||||
(floor (* (random) (ps:@ *tracks* length))))
|
||||
(mod (+ *current-track-index* 1) (ps:@ *tracks* length)))))
|
||||
(play-track next-index))))
|
||||
|
||||
;; Handle track end
|
||||
(defun handle-track-end ()
|
||||
(if *is-repeating*
|
||||
(progn
|
||||
(setf (ps:@ *audio-player* current-time) 0)
|
||||
(ps:chain *audio-player* (play)))
|
||||
(play-next)))
|
||||
|
||||
;; Toggle shuffle mode
|
||||
(defun toggle-shuffle ()
|
||||
(setf *is-shuffled* (not *is-shuffled*))
|
||||
(let ((btn (ps:chain document (get-element-by-id "shuffle-btn"))))
|
||||
(setf (ps:@ btn text-content) (if *is-shuffled* "🔀 Shuffle ON" "🔀 Shuffle"))
|
||||
(ps:chain btn (class-list toggle "active" *is-shuffled*))))
|
||||
|
||||
;; Toggle repeat mode
|
||||
(defun toggle-repeat ()
|
||||
(setf *is-repeating* (not *is-repeating*))
|
||||
(let ((btn (ps:chain document (get-element-by-id "repeat-btn"))))
|
||||
(setf (ps:@ btn text-content) (if *is-repeating* "🔁 Repeat ON" "🔁 Repeat"))
|
||||
(ps:chain btn (class-list toggle "active" *is-repeating*))))
|
||||
|
||||
;; Update volume
|
||||
(defun update-volume ()
|
||||
(let ((volume (/ (parseInt (ps:chain (ps:chain document (get-element-by-id "volume-slider")) value)) 100)))
|
||||
(when *audio-player*
|
||||
(setf (ps:@ *audio-player* volume) volume))))
|
||||
|
||||
;; Update time display
|
||||
(defun update-time-display ()
|
||||
(let ((current (format-time (ps:@ *audio-player* current-time)))
|
||||
(total (format-time (ps:@ *audio-player* duration))))
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "current-time")) text-content) current)
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "total-time")) text-content) total)))
|
||||
|
||||
;; Format time helper
|
||||
(defun format-time (seconds)
|
||||
(if (isNaN seconds)
|
||||
"0:00"
|
||||
(let ((mins (floor (/ seconds 60)))
|
||||
(secs (floor (mod seconds 60))))
|
||||
(+ mins ":" (ps:chain secs (to-string) (pad-start 2 "0"))))))
|
||||
|
||||
;; Update play button text
|
||||
(defun update-play-button (text)
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "play-pause-btn")) text-content) text))
|
||||
|
||||
;; Update player display with current track info
|
||||
(defun update-player-display ()
|
||||
(when *current-track*
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "current-title")) text-content)
|
||||
(or (ps:@ *current-track* title) "Unknown Title"))
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "current-artist")) text-content)
|
||||
(or (ps:@ *current-track* artist) "Unknown Artist"))
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "current-album")) text-content)
|
||||
(or (ps:@ *current-track* album) "Unknown Album"))))
|
||||
|
||||
;; Add track to queue
|
||||
(defun add-to-queue (index)
|
||||
(when (and (>= index 0) (< index (ps:@ *tracks* length)))
|
||||
(setf (aref *play-queue* (ps:@ *play-queue* length)) (aref *tracks* index))
|
||||
(update-queue-display)))
|
||||
|
||||
;; Update queue display
|
||||
(defun update-queue-display ()
|
||||
(let ((container (ps:chain document (get-element-by-id "play-queue"))))
|
||||
(if (== (ps:@ *play-queue* length) 0)
|
||||
(setf (ps:@ container inner-html) "<div class=\"empty-queue\">Queue is empty</div>")
|
||||
(let ((queue-html (ps:chain *play-queue*
|
||||
(map (lambda (track index)
|
||||
(+ "<div class=\"queue-item\">"
|
||||
"<div class=\"track-info\">"
|
||||
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>"
|
||||
"<div class=\"track-meta\">" (or (ps:@ track artist) "Unknown Artist") "</div>"
|
||||
"</div>"
|
||||
"<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>"
|
||||
"</div>")))
|
||||
(join ""))))
|
||||
(setf (ps:@ container inner-html) queue-html))))
|
||||
|
||||
;; Remove track from queue
|
||||
(defun remove-from-queue (index)
|
||||
(ps:chain *play-queue* (splice index 1))
|
||||
(update-queue-display))
|
||||
|
||||
;; Clear queue
|
||||
(defun clear-queue ()
|
||||
(setf *play-queue* (array))
|
||||
(update-queue-display))
|
||||
|
||||
;; Create playlist
|
||||
(defun create-playlist ()
|
||||
(let ((name (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value (trim))))
|
||||
(when (not (== name ""))
|
||||
(let ((form-data (new "FormData")))
|
||||
(ps:chain form-data (append "name" name))
|
||||
(ps:chain form-data (append "description" ""))
|
||||
|
||||
(ps:chain (fetch "/api/asteroid/playlists/create"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Handle RADIANCE API wrapper format
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (== (ps:@ data status) "success")
|
||||
(progn
|
||||
(alert (+ "Playlist \"" name "\" created successfully!"))
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value) "")
|
||||
|
||||
;; Wait a moment then reload playlists
|
||||
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
|
||||
(then (lambda () (load-playlists)))))
|
||||
(alert (+ "Error creating playlist: " (ps:@ data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error creating playlist:" error))
|
||||
(alert (+ "Error creating playlist: " (ps:@ error message))))))))))
|
||||
|
||||
;; Save queue as playlist
|
||||
(defun save-queue-as-playlist ()
|
||||
(if (> (ps:@ *play-queue* length) 0)
|
||||
(let ((name (prompt "Enter playlist name:")))
|
||||
(when name
|
||||
;; Create the playlist
|
||||
(let ((form-data (new "FormData")))
|
||||
(ps:chain form-data (append "name" name))
|
||||
(ps:chain form-data (append "description" (+ "Created from queue with " (ps:@ *play-queue* length) " tracks")))
|
||||
|
||||
(ps:chain (fetch "/api/asteroid/playlists/create"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (create-result)
|
||||
;; Handle RADIANCE API wrapper format
|
||||
(let ((create-data (or (ps:@ create-result data) create-result)))
|
||||
(if (== (ps:@ create-data status) "success")
|
||||
(progn
|
||||
;; Wait a moment for database to update
|
||||
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
|
||||
(then (lambda ()
|
||||
;; Get the new playlist ID by fetching playlists
|
||||
(ps:chain (fetch "/api/asteroid/playlists")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (playlists-result)
|
||||
;; Handle RADIANCE API wrapper format
|
||||
(let ((playlist-result-data (or (ps:@ playlists-result data) playlists-result)))
|
||||
(if (and (== (ps:@ playlist-result-data status) "success")
|
||||
(> (ps:@ playlist-result-data playlists length) 0))
|
||||
(progn
|
||||
;; Find the playlist with matching name (most recent)
|
||||
(let ((new-playlist (or (ps:chain (ps:@ playlist-result-data playlists)
|
||||
(find (lambda (p) (== (ps:@ p name) name))))
|
||||
(aref (ps:@ playlist-result-data playlists)
|
||||
(- (ps:@ playlist-result-data playlists length) 1)))))
|
||||
|
||||
;; Add all tracks from queue to playlist
|
||||
(let ((added-count 0))
|
||||
(ps:chain *play-queue*
|
||||
(for-each (lambda (track)
|
||||
(let ((track-id (ps:@ track id)))
|
||||
(when track-id
|
||||
(let ((add-form-data (new "FormData")))
|
||||
(ps:chain add-form-data (append "playlist-id" (ps:@ new-playlist id)))
|
||||
(ps:chain add-form-data (append "track-id" track-id))
|
||||
|
||||
(ps:chain (fetch "/api/asteroid/playlists/add-track"
|
||||
(ps:create :method "POST" :body add-form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (add-result)
|
||||
(when (== (ps:@ add-result data status) "success")
|
||||
(setf added-count (+ added-count 1)))))
|
||||
(catch (lambda (err)
|
||||
(ps:chain console (log "Error adding track:" err)))))))))))
|
||||
|
||||
(alert (+ "Playlist \"" name "\" created with " added-count " tracks!"))
|
||||
(load-playlists))))
|
||||
(progn
|
||||
(alert (+ "Playlist created but could not add tracks. Error: "
|
||||
(or (ps:@ playlist-result-data message) "Unknown")))
|
||||
(load-playlists))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error fetching playlists:" error))
|
||||
(alert "Playlist created but could not add tracks"))))))))
|
||||
(alert (+ "Error creating playlist: " (ps:@ create-data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error saving queue as playlist:" error))
|
||||
(alert (+ "Error saving queue as playlist: " (ps:@ error message)))))))))
|
||||
(alert "Queue is empty")))
|
||||
|
||||
;; Load playlists from API
|
||||
(defun load-playlists ()
|
||||
(ps:chain
|
||||
(ps:chain (fetch "/api/asteroid/playlists"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((playlists (cond
|
||||
((and (ps:@ result data) (== (ps:@ result data status) "success"))
|
||||
(or (ps:@ result data playlists) (array)))
|
||||
((== (ps:@ result status) "success")
|
||||
(or (ps:@ result playlists) (array)))
|
||||
(t
|
||||
(array)))))
|
||||
(display-playlists playlists))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading playlists:" error))
|
||||
(display-playlists (array))))))
|
||||
|
||||
;; Display playlists
|
||||
(defun display-playlists (playlists)
|
||||
(let ((container (ps:chain document (get-element-by-id "playlists-container"))))
|
||||
|
||||
(if (or (not playlists) (== (ps:@ playlists length) 0))
|
||||
(setf (ps:@ container inner-html) "<div class=\"no-playlists\">No playlists created yet.</div>")
|
||||
(let ((playlists-html (ps:chain playlists
|
||||
(map (lambda (playlist)
|
||||
(+ "<div class=\"playlist-item\">"
|
||||
"<div class=\"playlist-info\">"
|
||||
"<div class=\"playlist-name\">" (ps:@ playlist name) "</div>"
|
||||
"<div class=\"playlist-meta\">" (ps:@ playlist "track-count") " tracks</div>"
|
||||
"</div>"
|
||||
"<div class=\"playlist-actions\">"
|
||||
"<button onclick=\"loadPlaylist(" (ps:@ playlist id) ")\" class=\"btn btn-sm btn-info\">📂 Load</button>"
|
||||
"</div>"
|
||||
"</div>"))
|
||||
(join "")))))
|
||||
|
||||
(setf (ps:@ container inner-html) playlists-html)))))
|
||||
|
||||
;; Load playlist into queue
|
||||
(defun load-playlist (playlist-id)
|
||||
(ps:chain
|
||||
(ps:chain (fetch (+ "/api/asteroid/playlists/get?playlist-id=" playlist-id)))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Handle RADIANCE API wrapper format
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (and (== (ps:@ data status) "success") (ps:@ data playlist))
|
||||
(let ((playlist (ps:@ data playlist)))
|
||||
|
||||
;; Clear current queue
|
||||
(setf *play-queue* (array))
|
||||
|
||||
;; Add all playlist tracks to queue
|
||||
(when (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
|
||||
(ps:chain (ps:@ playlist tracks)
|
||||
(for-each (lambda (track)
|
||||
;; Find the full track object from our tracks array
|
||||
(let ((full-track (ps:chain *tracks*
|
||||
(find (lambda (trk) (== (ps:@ trk id) (ps:@ track id)))))))
|
||||
(when full-track
|
||||
(setf (aref *play-queue* (ps:@ *play-queue* length)) full-track)))))
|
||||
|
||||
(update-queue-display)
|
||||
(alert (+ "Loaded " (ps:@ *play-queue* length) " tracks from \"" (ps:@ playlist name) "\" into queue!"))
|
||||
|
||||
;; Optionally start playing the first track
|
||||
(when (> (ps:@ *play-queue* length) 0)
|
||||
(let ((first-track (ps:chain *play-queue* (shift)))
|
||||
(track-index (ps:chain *tracks*
|
||||
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ first-track id))))))
|
||||
)
|
||||
(when (>= track-index 0)
|
||||
(play-track track-index))))))
|
||||
(when (or (not (ps:@ playlist tracks)) (== (ps:@ playlist tracks length) 0))
|
||||
(alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
|
||||
(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: " (ps:@ error message)))))))
|
||||
|
||||
;; Stream quality configuration
|
||||
(defun get-live-stream-config (stream-base-url quality)
|
||||
(let ((config (ps:create
|
||||
:aac (ps:create
|
||||
:url (+ stream-base-url "/asteroid.aac")
|
||||
:type "audio/aac"
|
||||
:mount "asteroid.aac")
|
||||
:mp3 (ps:create
|
||||
:url (+ stream-base-url "/asteroid.mp3")
|
||||
:type "audio/mpeg"
|
||||
:mount "asteroid.mp3")
|
||||
:low (ps:create
|
||||
:url (+ stream-base-url "/asteroid-low.mp3")
|
||||
:type "audio/mpeg"
|
||||
:mount "asteroid-low.mp3"))))
|
||||
(aref config quality)))
|
||||
|
||||
;; Change live stream quality
|
||||
(defun change-live-stream-quality ()
|
||||
(let ((stream-base-url (ps:chain (ps:chain document (get-element-by-id "stream-base-url")) value))
|
||||
(selector (ps:chain document (get-element-by-id "live-stream-quality")))
|
||||
(config (get-live-stream-config
|
||||
(ps:chain (ps:chain document (get-element-by-id "stream-base-url")) value)
|
||||
(ps:chain (ps:chain document (get-element-by-id "live-stream-quality")) value))))
|
||||
|
||||
;; Update audio player
|
||||
(let ((audio-element (ps:chain document (get-element-by-id "live-stream-audio")))
|
||||
(source-element (ps:chain document (get-element-by-id "live-stream-source")))
|
||||
(was-playing (not (ps:chain (ps:chain document (get-element-by-id "live-stream-audio")) paused))))
|
||||
|
||||
(setf (ps:@ source-element src) (ps:@ config url))
|
||||
(setf (ps:@ source-element type) (ps:@ config type))
|
||||
(ps:chain audio-element (load))
|
||||
|
||||
;; Resume playback if it was playing
|
||||
(when was-playing
|
||||
(ps:chain audio-element
|
||||
(play)
|
||||
(catch (lambda (e) (ps:chain console (log "Autoplay prevented:" e)))))))))
|
||||
|
||||
;; Update now playing information
|
||||
(defun update-now-playing ()
|
||||
(ps:chain
|
||||
(ps:chain (fetch "/api/asteroid/partial/now-playing"))
|
||||
(then (lambda (response)
|
||||
(let ((content-type (ps:chain response (headers) (get "content-type"))))
|
||||
(if (ps:chain content-type (includes "text/html"))
|
||||
(ps:chain response (text))
|
||||
(progn
|
||||
(ps:chain console (log "Error connecting to stream"))
|
||||
"")))))
|
||||
(then (lambda (data)
|
||||
(setf (ps:chain (ps:chain document (get-element-by-id "now-playing")) inner-html) data)))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (log "Could not fetch stream status:" error))))))
|
||||
|
||||
;; Initial update after 1 second
|
||||
(ps:chain (setTimeout update-now-playing 1000))
|
||||
;; Update live stream info every 10 seconds
|
||||
(ps:chain (set-interval update-now-playing 10000))
|
||||
|
||||
;; Make functions globally accessible for onclick handlers
|
||||
(defvar window (ps:@ window))
|
||||
(setf (ps:@ window play-track) play-track)
|
||||
(setf (ps:@ window add-to-queue) add-to-queue)
|
||||
(setf (ps:@ window remove-from-queue) remove-from-queue)
|
||||
(setf (ps:@ window library-go-to-page) library-go-to-page)
|
||||
(setf (ps:@ window library-previous-page) library-previous-page)
|
||||
(setf (ps:@ window library-next-page) library-next-page)
|
||||
(setf (ps:@ window library-go-to-last-page) library-go-to-last-page)
|
||||
(setf (ps:@ window change-library-tracks-per-page) change-library-tracks-per-page)
|
||||
(setf (ps:@ window load-playlist) load-playlist)))
|
||||
"Compiled JavaScript for web player - generated at load time")
|
||||
|
||||
(defun generate-player-js ()
|
||||
"Generate JavaScript code for the web player"
|
||||
*player-js*)
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
;;;; profile.lisp - ParenScript version of profile.js
|
||||
;;;; User profile page with listening stats and history
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *profile-js*
|
||||
(ps:ps*
|
||||
'(progn
|
||||
|
||||
;; Global state
|
||||
(defvar *current-user* nil)
|
||||
(defvar *listening-data* nil)
|
||||
|
||||
;; Utility functions
|
||||
(defun update-element (data-text value)
|
||||
(let ((element (ps:chain document (query-selector (+ "[data-text=\"" data-text "\"]")))))
|
||||
(when (and element (not (= value undefined)) (not (= value null)))
|
||||
(setf (ps:@ element text-content) value))))
|
||||
|
||||
(defun format-role (role)
|
||||
(let ((role-map (ps:create
|
||||
"admin" "👑 Admin"
|
||||
"dj" "🎧 DJ"
|
||||
"listener" "🎵 Listener")))
|
||||
(or (ps:getprop role-map role) role)))
|
||||
|
||||
(defun format-date (date-string)
|
||||
(let ((date (ps:new (-date date-string))))
|
||||
(ps:chain date (to-locale-date-string "en-US"
|
||||
(ps:create :year "numeric"
|
||||
:month "long"
|
||||
:day "numeric")))))
|
||||
|
||||
(defun format-relative-time (date-string)
|
||||
(let* ((date (ps:new (-date date-string)))
|
||||
(now (ps:new (-date)))
|
||||
(diff-ms (- now date))
|
||||
(diff-days (ps:chain -math (floor (/ diff-ms (* 1000 60 60 24)))))
|
||||
(diff-hours (ps:chain -math (floor (/ diff-ms (* 1000 60 60)))))
|
||||
(diff-minutes (ps:chain -math (floor (/ diff-ms (* 1000 60))))))
|
||||
(cond
|
||||
((> diff-days 0)
|
||||
(+ diff-days " day" (if (> diff-days 1) "s" "") " ago"))
|
||||
((> diff-hours 0)
|
||||
(+ diff-hours " hour" (if (> diff-hours 1) "s" "") " ago"))
|
||||
((> diff-minutes 0)
|
||||
(+ diff-minutes " minute" (if (> diff-minutes 1) "s" "") " ago"))
|
||||
(t "Just now"))))
|
||||
|
||||
(defun format-duration (seconds)
|
||||
(let ((hours (ps:chain -math (floor (/ seconds 3600))))
|
||||
(minutes (ps:chain -math (floor (/ (rem seconds 3600) 60)))))
|
||||
(if (> hours 0)
|
||||
(+ hours "h " minutes "m")
|
||||
(+ minutes "m"))))
|
||||
|
||||
(defun show-message (message &optional (type "info"))
|
||||
(let ((toast (ps:chain document (create-element "div")))
|
||||
(colors (ps:create
|
||||
"info" "#007bff"
|
||||
"success" "#28a745"
|
||||
"error" "#dc3545"
|
||||
"warning" "#ffc107")))
|
||||
(setf (ps:@ toast class-name) (+ "toast toast-" type))
|
||||
(setf (ps:@ toast text-content) message)
|
||||
(setf (ps:@ toast style css-text)
|
||||
"position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 4px; color: white; font-weight: bold; z-index: 1000; opacity: 0; transition: opacity 0.3s ease;")
|
||||
(setf (ps:@ toast style background-color) (or (ps:getprop colors type) (ps:getprop colors "info")))
|
||||
|
||||
(ps:chain document body (append-child toast))
|
||||
|
||||
(set-timeout (lambda () (setf (ps:@ toast style opacity) "1")) 100)
|
||||
(set-timeout (lambda ()
|
||||
(setf (ps:@ toast style opacity) "0")
|
||||
(set-timeout (lambda () (ps:chain document body (remove-child toast))) 300))
|
||||
3000)))
|
||||
|
||||
(defun show-error (message)
|
||||
(show-message message "error"))
|
||||
|
||||
;; Profile data loading
|
||||
(defun update-profile-display (user)
|
||||
(update-element "username" (or (ps:@ user username) "Unknown User"))
|
||||
(update-element "user-role" (format-role (or (ps:@ user role) "listener")))
|
||||
(update-element "join-date" (format-date (or (ps:@ user created_at) (ps:new (-date)))))
|
||||
(update-element "last-active" (format-relative-time (or (ps:@ user last_active) (ps:new (-date)))))
|
||||
|
||||
(let ((admin-link (ps:chain document (query-selector "[data-show-if-admin]"))))
|
||||
(when admin-link
|
||||
(setf (ps:@ admin-link style display)
|
||||
(if (= (ps:@ user role) "admin") "inline" "none")))))
|
||||
|
||||
(defun load-listening-stats ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/listening-stats")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when (= (ps:@ data status) "success")
|
||||
(let ((stats (ps:@ data stats)))
|
||||
(update-element "total-listen-time" (format-duration (or (ps:@ stats total_listen_time) 0)))
|
||||
(update-element "tracks-played" (or (ps:@ stats tracks_played) 0))
|
||||
(update-element "session-count" (or (ps:@ stats session_count) 0))
|
||||
(update-element "favorite-genre" (or (ps:@ stats favorite_genre) "Unknown")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading listening stats:" error))
|
||||
(update-element "total-listen-time" "0h 0m")
|
||||
(update-element "tracks-played" "0")
|
||||
(update-element "session-count" "0")
|
||||
(update-element "favorite-genre" "Unknown")))))
|
||||
|
||||
(defun load-recent-tracks ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/recent-tracks?limit=3")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (and (= (ps:@ data status) "success")
|
||||
(ps:@ data tracks)
|
||||
(> (ps:@ data tracks length) 0))
|
||||
(ps:chain data tracks
|
||||
(for-each (lambda (track index)
|
||||
(let ((track-num (+ index 1)))
|
||||
(update-element (+ "recent-track-" track-num "-title")
|
||||
(or (ps:@ track title) "Unknown Track"))
|
||||
(update-element (+ "recent-track-" track-num "-artist")
|
||||
(or (ps:@ track artist) "Unknown Artist"))
|
||||
(update-element (+ "recent-track-" track-num "-duration")
|
||||
(format-duration (or (ps:@ track duration) 0)))
|
||||
(update-element (+ "recent-track-" track-num "-played-at")
|
||||
(format-relative-time (ps:@ track played_at)))))))
|
||||
(loop for i from 1 to 3
|
||||
do (let* ((track-item-selector (+ "[data-text=\"recent-track-" i "-title\"]"))
|
||||
(track-item-el (ps:chain document (query-selector track-item-selector)))
|
||||
(track-item (when track-item-el (ps:chain track-item-el (closest ".track-item")))))
|
||||
(when (and track-item
|
||||
(or (not (ps:@ data tracks))
|
||||
(not (ps:getprop (ps:@ data tracks) (- i 1)))))
|
||||
(setf (ps:@ track-item style display) "none"))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading recent tracks:" error))))))
|
||||
|
||||
(defun load-top-artists ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/top-artists?limit=5")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (and (= (ps:@ data status) "success")
|
||||
(ps:@ data artists)
|
||||
(> (ps:@ data artists length) 0))
|
||||
(ps:chain data artists
|
||||
(for-each (lambda (artist index)
|
||||
(let ((artist-num (+ index 1)))
|
||||
(update-element (+ "top-artist-" artist-num)
|
||||
(or (ps:@ artist name) "Unknown Artist"))
|
||||
(update-element (+ "top-artist-" artist-num "-plays")
|
||||
(+ (or (ps:@ artist play_count) 0) " plays"))))))
|
||||
(loop for i from 1 to 5
|
||||
do (let* ((artist-item-selector (+ "[data-text=\"top-artist-" i "\"]"))
|
||||
(artist-item-el (ps:chain document (query-selector artist-item-selector)))
|
||||
(artist-item (when artist-item-el (ps:chain artist-item-el (closest ".artist-item")))))
|
||||
(when (and artist-item
|
||||
(or (not (ps:@ data artists))
|
||||
(not (ps:getprop (ps:@ data artists) (- i 1)))))
|
||||
(setf (ps:@ artist-item style display) "none"))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading top artists:" error))))))
|
||||
|
||||
(defun load-profile-data ()
|
||||
(ps:chain console (log "Loading profile data..."))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/profile")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(setf *current-user* (ps:@ data user))
|
||||
(update-profile-display (ps:@ data user)))
|
||||
(progn
|
||||
(ps:chain console (error "Failed to load profile:" (ps:@ data message)))
|
||||
(show-error "Failed to load profile data"))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading profile:" error))
|
||||
(show-error "Error loading profile data"))))
|
||||
|
||||
(load-listening-stats)
|
||||
(load-recent-tracks)
|
||||
(load-top-artists))
|
||||
|
||||
;; Action functions
|
||||
(defun load-more-recent-tracks ()
|
||||
(ps:chain console (log "Loading more recent tracks..."))
|
||||
(show-message "Loading more tracks..." "info"))
|
||||
|
||||
(defun edit-profile ()
|
||||
(ps:chain console (log "Edit profile clicked"))
|
||||
(show-message "Profile editing coming soon!" "info"))
|
||||
|
||||
(defun export-listening-data ()
|
||||
(ps:chain console (log "Exporting listening data..."))
|
||||
(show-message "Preparing data export..." "info")
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/export-data" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (blob))))
|
||||
(then (lambda (blob)
|
||||
(let* ((url (ps:chain window -u-r-l (create-object-u-r-l blob)))
|
||||
(a (ps:chain document (create-element "a"))))
|
||||
(setf (ps:@ a style display) "none")
|
||||
(setf (ps:@ a href) url)
|
||||
(setf (ps:@ a download) (+ "asteroid-listening-data-"
|
||||
(or (ps:@ *current-user* username) "user")
|
||||
".json"))
|
||||
(ps:chain document body (append-child a))
|
||||
(ps:chain a (click))
|
||||
(ps:chain window -u-r-l (revoke-object-u-r-l url))
|
||||
(show-message "Data exported successfully!" "success"))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error exporting data:" error))
|
||||
(show-message "Failed to export data" "error")))))
|
||||
|
||||
(defun clear-listening-history ()
|
||||
(when (not (confirm "Are you sure you want to clear your listening history? This action cannot be undone."))
|
||||
(return))
|
||||
|
||||
(ps:chain console (log "Clearing listening history..."))
|
||||
(show-message "Clearing listening history..." "info")
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/clear-history" (ps:create :method "POST"))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (data)
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(show-message "Listening history cleared successfully!" "success")
|
||||
(set-timeout (lambda () (ps:chain location (reload))) 1500))
|
||||
(show-message (+ "Failed to clear history: " (ps:@ data message)) "error"))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error clearing history:" error))
|
||||
(show-message "Failed to clear history" "error")))))
|
||||
|
||||
;; Password change
|
||||
(defun change-password (event)
|
||||
(ps:chain event (prevent-default))
|
||||
|
||||
(let ((current-password (ps:@ (ps:chain document (get-element-by-id "current-password")) value))
|
||||
(new-password (ps:@ (ps:chain document (get-element-by-id "new-password")) value))
|
||||
(confirm-password (ps:@ (ps:chain document (get-element-by-id "confirm-password")) value))
|
||||
(message-div (ps:chain document (get-element-by-id "password-message"))))
|
||||
|
||||
;; Client-side validation
|
||||
(cond
|
||||
((< (ps:@ new-password length) 8)
|
||||
(setf (ps:@ message-div text-content) "New password must be at least 8 characters")
|
||||
(setf (ps:@ message-div class-name) "message error")
|
||||
(return false))
|
||||
((not (= new-password confirm-password))
|
||||
(setf (ps:@ message-div text-content) "New passwords do not match")
|
||||
(setf (ps:@ message-div class-name) "message error")
|
||||
(return false)))
|
||||
|
||||
;; Send request to API
|
||||
(let ((form-data (ps:new (-form-data))))
|
||||
(ps:chain form-data (append "current-password" current-password))
|
||||
(ps:chain form-data (append "new-password" new-password))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/change-password"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (data)
|
||||
(if (or (= (ps:@ data status) "success")
|
||||
(and (ps:@ data data) (= (ps:@ data data status) "success")))
|
||||
(progn
|
||||
(setf (ps:@ message-div text-content) "Password changed successfully!")
|
||||
(setf (ps:@ message-div class-name) "message success")
|
||||
(ps:chain (ps:chain document (get-element-by-id "change-password-form")) (reset)))
|
||||
(progn
|
||||
(setf (ps:@ message-div text-content)
|
||||
(or (ps:@ data message)
|
||||
(ps:@ data data message)
|
||||
"Failed to change password"))
|
||||
(setf (ps:@ message-div class-name) "message error")))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error changing password:" error))
|
||||
(setf (ps:@ message-div text-content) "Error changing password")
|
||||
(setf (ps:@ message-div class-name) "message error")))))
|
||||
|
||||
false))
|
||||
|
||||
;; Initialize on page load
|
||||
(ps:chain window
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
load-profile-data))))
|
||||
"Compiled JavaScript for profile page - generated at load time")
|
||||
|
||||
(defun generate-profile-js ()
|
||||
"Return the pre-compiled JavaScript for profile page"
|
||||
*profile-js*)
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
;;;; recently-played.lisp - ParenScript version of recently-played.js
|
||||
;;;; Recently Played Tracks functionality
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *recently-played-js*
|
||||
(ps:ps
|
||||
(progn
|
||||
|
||||
;; Update recently played tracks display
|
||||
(defun update-recently-played ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/recently-played")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Radiance wraps API responses in a data envelope
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (and (equal (ps:@ data status) "success")
|
||||
(ps:@ data tracks)
|
||||
(> (ps:@ data tracks length) 0))
|
||||
(let ((list-el (ps:chain document (get-element-by-id "recently-played-list"))))
|
||||
(when list-el
|
||||
;; Build HTML for tracks
|
||||
(let ((html "<ul class=\"track-list\">"))
|
||||
(ps:chain (ps:@ data tracks)
|
||||
(for-each (lambda (track index)
|
||||
(let ((time-ago (format-time-ago (ps:@ track timestamp))))
|
||||
(setf html
|
||||
(+ html
|
||||
"<li class=\"track-item\">"
|
||||
"<div class=\"track-info\">"
|
||||
"<div class=\"track-title\">"
|
||||
"<a href=\"" (ps:@ track search_url) "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"track-link\">"
|
||||
(escape-html (ps:@ track song))
|
||||
"<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" class=\"external-icon\">"
|
||||
"<path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"></path>"
|
||||
"<polyline points=\"15 3 21 3 21 9\"></polyline>"
|
||||
"<line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\"></line>"
|
||||
"</svg>"
|
||||
"</a>"
|
||||
"</div>"
|
||||
"<div class=\"track-artist\">" (escape-html (ps:@ track artist)) "</div>"
|
||||
"<span class=\"track-time\">" time-ago "</span>"
|
||||
"</div>"
|
||||
"</li>"))))))
|
||||
(setf html (+ html "</ul>"))
|
||||
(setf (aref list-el "innerHTML") html))))
|
||||
(let ((list-el (ps:chain document (get-element-by-id "recently-played-list"))))
|
||||
(when list-el
|
||||
(setf (aref list-el "innerHTML") "<p class=\"no-tracks\">No tracks played yet</p>")))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error fetching recently played:" error))
|
||||
(let ((list-el (ps:chain document (get-element-by-id "recently-played-list"))))
|
||||
(when list-el
|
||||
(setf (aref list-el "innerHTML") "<p class=\"error\">Error loading recently played tracks</p>"))))))))
|
||||
|
||||
;; Format timestamp as relative time
|
||||
(defun format-time-ago (timestamp)
|
||||
(let* ((now (floor (/ (ps:chain *date (now)) 1000)))
|
||||
(diff (- now timestamp)))
|
||||
(cond
|
||||
((< diff 60) "Just now")
|
||||
((< diff 3600) (+ (floor (/ diff 60)) "m ago"))
|
||||
((< diff 86400) (+ (floor (/ diff 3600)) "h ago"))
|
||||
(t (+ (floor (/ diff 86400)) "d ago")))))
|
||||
|
||||
;; Escape HTML to prevent XSS
|
||||
(defun escape-html (text)
|
||||
(when (ps:@ window document)
|
||||
(let ((div (ps:chain document (create-element "div"))))
|
||||
(setf (ps:@ div text-content) text)
|
||||
(aref div "innerHTML"))))
|
||||
|
||||
;; Initialize on page load
|
||||
(when (ps:@ window document)
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
(lambda ()
|
||||
(let ((panel (ps:chain document (get-element-by-id "recently-played-panel"))))
|
||||
(if panel
|
||||
(progn
|
||||
(update-recently-played)
|
||||
;; Update every 30 seconds
|
||||
(set-interval update-recently-played 30000))
|
||||
(let ((list (ps:chain document (get-element-by-id "recently-played-list"))))
|
||||
(when list
|
||||
(update-recently-played)
|
||||
(set-interval update-recently-played 30000))))))))))
|
||||
"Compiled JavaScript for recently played tracks - generated at load time"
|
||||
)
|
||||
|
||||
(defun generate-recently-played-js ()
|
||||
"Generate JavaScript code for recently played tracks"
|
||||
*recently-played-js*)
|
||||
|
|
@ -89,7 +89,8 @@
|
|||
(height (ps:@ *canvas* height))
|
||||
(bar-width (/ width buffer-length))
|
||||
(bar-height 0)
|
||||
(x 0))
|
||||
(x 0)
|
||||
(is-muted (and *current-audio-element* (ps:@ *current-audio-element* muted))))
|
||||
|
||||
(ps:chain *analyser* (get-byte-frequency-data data-array))
|
||||
|
||||
|
|
@ -111,7 +112,15 @@
|
|||
(setf (ps:@ *canvas-ctx* |fillStyle|) gradient)
|
||||
(ps:chain *canvas-ctx* (fill-rect x (- height bar-height) bar-width bar-height))
|
||||
|
||||
(incf x bar-width)))))
|
||||
(incf x bar-width)))
|
||||
|
||||
;; Draw MUTED indicator if audio is muted
|
||||
(when is-muted
|
||||
(setf (ps:@ *canvas-ctx* |fillStyle|) "rgba(255, 0, 0, 0.8)")
|
||||
(setf (ps:@ *canvas-ctx* font) "bold 20px monospace")
|
||||
(setf (ps:@ *canvas-ctx* |textAlign|) "right")
|
||||
(setf (ps:@ *canvas-ctx* |textBaseline|) "top")
|
||||
(ps:chain *canvas-ctx* (fill-text "MUTED" (- width 10) 10)))))
|
||||
|
||||
(defun stop-spectrum-analyzer ()
|
||||
"Stop the spectrum analyzer"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
;;;; users.lisp - ParenScript version of users.js
|
||||
;;;; User management page for admins
|
||||
|
||||
(in-package #:asteroid)
|
||||
|
||||
(defparameter *users-js*
|
||||
(ps:ps*
|
||||
'(progn
|
||||
|
||||
;; Load user stats
|
||||
(defun load-user-stats ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user-stats")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when (and (= (ps:@ data status) "success") (ps:@ data stats))
|
||||
(let ((stats (ps:@ data stats)))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "total-users")) text-content)
|
||||
(or (ps:getprop stats "total-users") 0))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "active-users")) text-content)
|
||||
(or (ps:getprop stats "active-users") 0))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "admin-users")) text-content)
|
||||
(or (ps:getprop stats "admins") 0))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "dj-users")) text-content)
|
||||
(or (ps:getprop stats "djs") 0)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading user stats:" error))))))
|
||||
|
||||
;; Load users list
|
||||
(defun load-users ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/users")
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(when (= (ps:@ data status) "success")
|
||||
(show-users-table (ps:@ data users))
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "users-list-section")) style display) "block")))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error loading users:" error))
|
||||
(alert "Error loading users. Please try again.")))))
|
||||
|
||||
;; Show users table
|
||||
(defun show-users-table (users)
|
||||
(let ((container (ps:chain document (get-element-by-id "users-container")))
|
||||
(users-html (ps:chain users
|
||||
(map (lambda (user)
|
||||
(+ "<tr>"
|
||||
"<td>" (ps:@ user username) "</td>"
|
||||
"<td>" (ps:@ user email) "</td>"
|
||||
"<td>"
|
||||
"<select onchange=\"updateUserRole('" (ps:@ user id) "', this.value)\">"
|
||||
"<option value=\"listener\" " (if (= (ps:@ user role) "listener") "selected" "") ">Listener</option>"
|
||||
"<option value=\"dj\" " (if (= (ps:@ user role) "dj") "selected" "") ">DJ</option>"
|
||||
"<option value=\"admin\" " (if (= (ps:@ user role) "admin") "selected" "") ">Admin</option>"
|
||||
"</select>"
|
||||
"</td>"
|
||||
"<td>" (if (ps:@ user active) "✅ Active" "❌ Inactive") "</td>"
|
||||
"<td>" (if (ps:getprop user "last-login")
|
||||
(ps:chain (ps:new (-date (* (ps:getprop user "last-login") 1000))) (to-locale-string))
|
||||
"Never") "</td>"
|
||||
"<td class=\"user-actions\">"
|
||||
(if (ps:@ user active)
|
||||
(+ "<button class=\"btn btn-danger\" onclick=\"deactivateUser('" (ps:@ user id) "')\">Deactivate</button>")
|
||||
(+ "<button class=\"btn btn-success\" onclick=\"activateUser('" (ps:@ user id) "')\">Activate</button>"))
|
||||
"</td>"
|
||||
"</tr>")))
|
||||
(join ""))))
|
||||
(setf (ps:@ container inner-h-t-m-l)
|
||||
(+ "<table class=\"users-table\">"
|
||||
"<thead>"
|
||||
"<tr>"
|
||||
"<th>Username</th>"
|
||||
"<th>Email</th>"
|
||||
"<th>Role</th>"
|
||||
"<th>Status</th>"
|
||||
"<th>Last Login</th>"
|
||||
"<th>Actions</th>"
|
||||
"</tr>"
|
||||
"</thead>"
|
||||
"<tbody>"
|
||||
users-html
|
||||
"</tbody>"
|
||||
"</table>"
|
||||
"<button class=\"btn btn-secondary\" onclick=\"hideUsersTable()\">Close</button>"))))
|
||||
|
||||
(defun hide-users-table ()
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "users-list-section")) style display) "none"))
|
||||
|
||||
;; Update user role
|
||||
(defun update-user-role (user-id new-role)
|
||||
(let ((form-data (ps:new (-form-data))))
|
||||
(ps:chain form-data (append "user-id" user-id))
|
||||
(ps:chain form-data (append "role" new-role))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/role"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Handle Radiance API data wrapping
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(load-user-stats)
|
||||
(alert (ps:@ data message)))
|
||||
(alert (+ "Error updating user role: " (ps:@ data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error updating user role:" error))
|
||||
(alert "Error updating user role. Please try again."))))))
|
||||
|
||||
;; Deactivate user
|
||||
(defun deactivate-user (user-id)
|
||||
(when (not (confirm "Are you sure you want to deactivate this user?"))
|
||||
(return))
|
||||
|
||||
(let ((form-data (ps:new (-form-data))))
|
||||
(ps:chain form-data (append "user-id" user-id))
|
||||
(ps:chain form-data (append "active" 0))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/activate"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Handle Radiance API data wrapping
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(load-users)
|
||||
(load-user-stats)
|
||||
(alert (ps:@ data message)))
|
||||
(alert (+ "Error deactivating user: " (ps:@ data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error deactivating user:" error))
|
||||
(alert "Error deactivating user. Please try again."))))))
|
||||
|
||||
;; Activate user
|
||||
(defun activate-user (user-id)
|
||||
(when (not (confirm "Are you sure you want to activate this user?"))
|
||||
(return))
|
||||
|
||||
(let ((form-data (ps:new (-form-data))))
|
||||
(ps:chain form-data (append "user-id" user-id))
|
||||
(ps:chain form-data (append "active" 1))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/user/activate"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; Handle Radiance API data wrapping
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(load-users)
|
||||
(load-user-stats)
|
||||
(alert (ps:@ data message)))
|
||||
(alert (+ "Error activating user: " (ps:@ data message)))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error activating user:" error))
|
||||
(alert "Error activating user. Please try again."))))))
|
||||
|
||||
;; Toggle create user form
|
||||
(defun toggle-create-user-form ()
|
||||
(let ((form (ps:chain document (get-element-by-id "create-user-form"))))
|
||||
(if (= (ps:@ form style display) "none")
|
||||
(progn
|
||||
(setf (ps:@ form style display) "block")
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "new-username")) value) "")
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "new-email")) value) "")
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "new-password")) value) "")
|
||||
(setf (ps:@ (ps:chain document (get-element-by-id "new-role")) value) "listener"))
|
||||
(setf (ps:@ form style display) "none"))))
|
||||
|
||||
;; Create new user
|
||||
(defun create-new-user (event)
|
||||
(ps:chain event (prevent-default))
|
||||
|
||||
(let ((username (ps:@ (ps:chain document (get-element-by-id "new-username")) value))
|
||||
(email (ps:@ (ps:chain document (get-element-by-id "new-email")) value))
|
||||
(password (ps:@ (ps:chain document (get-element-by-id "new-password")) value))
|
||||
(role (ps:@ (ps:chain document (get-element-by-id "new-role")) value))
|
||||
(form-data (ps:new (-form-data))))
|
||||
|
||||
(ps:chain form-data (append "username" username))
|
||||
(ps:chain form-data (append "email" email))
|
||||
(ps:chain form-data (append "password" password))
|
||||
(ps:chain form-data (append "role" role))
|
||||
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/users/create"
|
||||
(ps:create :method "POST" :body form-data))
|
||||
(then (lambda (response) (ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
(if (= (ps:@ data status) "success")
|
||||
(progn
|
||||
(alert (+ "User \"" username "\" created successfully!"))
|
||||
(toggle-create-user-form)
|
||||
(load-user-stats)
|
||||
(load-users))
|
||||
(alert (+ "Error creating user: " (or (ps:@ data message) (ps:@ result message))))))))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error creating user:" error))
|
||||
(alert "Error creating user. Please try again."))))))
|
||||
|
||||
;; Initialize on page load
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
load-user-stats))
|
||||
|
||||
;; Update user stats every 30 seconds
|
||||
(set-interval load-user-stats 30000)))
|
||||
"Compiled JavaScript for users management - generated at load time")
|
||||
|
||||
(defun generate-users-js ()
|
||||
"Return the pre-compiled JavaScript for users page"
|
||||
*users-js*)
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
#EXTM3U
|
||||
#EXTINF:370,Vector Lovers - City Lights From a Train
|
||||
/app/music/Vector Lovers/City Lights From a Train.flac
|
||||
#EXTINF:400,The Black Dog - Psil-Cosyin
|
||||
/app/music/The Black Dog/Psil-Cosyin.flac
|
||||
#EXTINF:320,Plaid - Eyen
|
||||
/app/music/Plaid/Eyen.flac
|
||||
#EXTINF:330,ISAN - Birds Over Barges
|
||||
/app/music/ISAN/Birds Over Barges.flac
|
||||
#EXTINF:360,Ochre - Bluebottle Farm
|
||||
/app/music/Ochre/Bluebottle Farm.flac
|
||||
#EXTINF:390,Arovane - Theme
|
||||
/app/music/Arovane/Theme.flac
|
||||
#EXTINF:380,Proem - Deep Like Airline Failure
|
||||
/app/music/Proem/Deep Like Airline Failure.flac
|
||||
#EXTINF:310,Solvent - My Radio (Remix)
|
||||
/app/music/Solvent/My Radio (Remix).flac
|
||||
#EXTINF:350,Bochum Welt - Marylebone (7th)
|
||||
/app/music/Bochum Welt/Marylebone (7th).flac
|
||||
#EXTINF:290,Mrs Jynx - Shibuya Lullaby
|
||||
/app/music/Mrs Jynx/Shibuya Lullaby.flac
|
||||
#EXTINF:340,Kettel - Whisper Me Wishes
|
||||
/app/music/Kettel/Whisper Me Wishes.flac
|
||||
#EXTINF:360,Christ. - Perlandine Friday
|
||||
/app/music/Christ./Perlandine Friday.flac
|
||||
#EXTINF:330,Cepia - Ithaca
|
||||
/app/music/Cepia/Ithaca.flac
|
||||
#EXTINF:340,Datassette - Vacuform
|
||||
/app/music/Datassette/Vacuform.flac
|
||||
#EXTINF:390,Plant43 - Dreams of the Sentient City
|
||||
/app/music/Plant43/Dreams of the Sentient City.flac
|
||||
#EXTINF:410,Claro Intelecto - Peace of Mind (Electrosoul)
|
||||
/app/music/Claro Intelecto/Peace of Mind (Electrosoul).flac
|
||||
#EXTINF:430,E.R.P. - Evoked
|
||||
/app/music/E.R.P./Evoked.flac
|
||||
#EXTINF:310,Der Zyklus - Formenverwandler
|
||||
/app/music/Der Zyklus/Formenverwandler.flac
|
||||
#EXTINF:330,Dopplereffekt - Infophysix
|
||||
/app/music/Dopplereffekt/Infophysix.flac
|
||||
#EXTINF:350,Drexciya - Wavejumper
|
||||
/app/music/Drexciya/Wavejumper.flac
|
||||
#EXTINF:375,The Other People Place - Sorrow & A Cup of Joe
|
||||
/app/music/The Other People Place/Sorrow & A Cup of Joe.flac
|
||||
#EXTINF:340,Arpanet - Wireless Internet
|
||||
/app/music/Arpanet/Wireless Internet.flac
|
||||
#EXTINF:380,Legowelt - Sturmvogel
|
||||
/app/music/Legowelt/Sturmvogel.flac
|
||||
#EXTINF:310,DMX Krew - Space Paranoia
|
||||
/app/music/DMX Krew/Space Paranoia.flac
|
||||
#EXTINF:360,Skywave Theory - Nova Drift
|
||||
/app/music/Skywave Theory/Nova Drift.flac
|
||||
#EXTINF:460,Pye Corner Audio - Transmission Four
|
||||
/app/music/Pye Corner Audio/Transmission Four.flac
|
||||
#EXTINF:390,B12 - Heaven Sent
|
||||
/app/music/B12/Heaven Sent.flac
|
||||
#EXTINF:450,Higher Intelligence Agency - Tortoise
|
||||
/app/music/Higher Intelligence Agency/Tortoise.flac
|
||||
#EXTINF:420,Biosphere - Kobresia
|
||||
/app/music/Biosphere/Kobresia.flac
|
||||
#EXTINF:870,Global Communication - 14:31
|
||||
/app/music/Global Communication/14:31.flac
|
||||
#EXTINF:500,Monolake - Cyan
|
||||
/app/music/Monolake/Cyan.flac
|
||||
#EXTINF:660,Deepchord - Electromagnetic
|
||||
/app/music/Deepchord/Electromagnetic.flac
|
||||
#EXTINF:1020,GAS - Pop 4
|
||||
/app/music/GAS/Pop 4.flac
|
||||
#EXTINF:600,Yagya - Rigning Nýju
|
||||
/app/music/Yagya/Rigning Nýju.flac
|
||||
#EXTINF:990,Voices From The Lake - Velo di Maya
|
||||
/app/music/Voices From The Lake/Velo di Maya.flac
|
||||
#EXTINF:3720,ASC - Time Heals All
|
||||
/app/music/ASC/Time Heals All.flac
|
||||
#EXTINF:540,36 - Room 237
|
||||
/app/music/36/Room 237.flac
|
||||
#EXTINF:900,Loscil - Endless Falls
|
||||
/app/music/Loscil/Endless Falls.flac
|
||||
#EXTINF:450,Kiasmos - Looped
|
||||
/app/music/Kiasmos/Looped.flac
|
||||
#EXTINF:590,Underworld - Rez
|
||||
/app/music/Underworld/Rez.flac
|
||||
#EXTINF:570,Orbital - Halcyon + On + On
|
||||
/app/music/Orbital/Halcyon + On + On.flac
|
||||
#EXTINF:1080,The Orb - A Huge Ever Growing Pulsating Brain
|
||||
/app/music/The Orb/A Huge Ever Growing Pulsating Brain.flac
|
||||
#EXTINF:360,Autechre - Slip
|
||||
/app/music/Autechre/Slip.flac
|
||||
#EXTINF:400,Labradford - S (Mi Media Naranja)
|
||||
/app/music/Labradford/S (Mi Media Naranja).flac
|
||||
#EXTINF:350,Vector Lovers - Rusting Cars and Wildflowers
|
||||
/app/music/Vector Lovers/Rusting Cars and Wildflowers.flac
|
||||
#EXTINF:390,The Black Dog - Raxmus
|
||||
/app/music/The Black Dog/Raxmus.flac
|
||||
#EXTINF:315,Plaid - Hawkmoth
|
||||
/app/music/Plaid/Hawkmoth.flac
|
||||
#EXTINF:320,ISAN - What This Button Did
|
||||
/app/music/ISAN/What This Button Did.flac
|
||||
#EXTINF:370,Ochre - Circadies
|
||||
/app/music/Ochre/Circadies.flac
|
||||
#EXTINF:420,Arovane - Tides
|
||||
/app/music/Arovane/Tides.flac
|
||||
#EXTINF:370,Proem - Nothing is as It Seems
|
||||
/app/music/Proem/Nothing is as It Seems.flac
|
||||
#EXTINF:300,Solvent - Loss For Words
|
||||
/app/music/Solvent/Loss For Words.flac
|
||||
#EXTINF:340,Bochum Welt - Saint (77sunset)
|
||||
/app/music/Bochum Welt/Saint (77sunset).flac
|
||||
#EXTINF:280,Mrs Jynx - Stay Home
|
||||
/app/music/Mrs Jynx/Stay Home.flac
|
||||
#EXTINF:330,Kettel - Church
|
||||
/app/music/Kettel/Church.flac
|
||||
#EXTINF:370,Christ. - Cordate
|
||||
/app/music/Christ./Cordate.flac
|
||||
#EXTINF:350,Datassette - Computers Elevate
|
||||
/app/music/Datassette/Computers Elevate.flac
|
||||
#EXTINF:420,Plant43 - The Cold Surveyor
|
||||
/app/music/Plant43/The Cold Surveyor.flac
|
||||
#EXTINF:380,Claro Intelecto - Section
|
||||
/app/music/Claro Intelecto/Section.flac
|
||||
#EXTINF:440,E.R.P. - Vox Automaton
|
||||
/app/music/E.R.P./Vox Automaton.flac
|
||||
#EXTINF:300,Dopplereffekt - Z-Boson
|
||||
/app/music/Dopplereffekt/Z-Boson.flac
|
||||
#EXTINF:380,Drexciya - Digital Tsunami
|
||||
/app/music/Drexciya/Digital Tsunami.flac
|
||||
#EXTINF:350,The Other People Place - You Said You Want Me
|
||||
/app/music/The Other People Place/You Said You Want Me.flac
|
||||
#EXTINF:370,Legowelt - Star Gazing
|
||||
/app/music/Legowelt/Star Gazing.flac
|
||||
#EXTINF:440,Pye Corner Audio - Electronic Rhythm Number 3
|
||||
/app/music/Pye Corner Audio/Electronic Rhythm Number 3.flac
|
||||
#EXTINF:460,B12 - Infinite Lites (Classic Mix)
|
||||
/app/music/B12/Infinite Lites (Classic Mix).flac
|
||||
#EXTINF:390,Biosphere - The Things I Tell You
|
||||
/app/music/Biosphere/The Things I Tell You.flac
|
||||
#EXTINF:580,Global Communication - 9:39
|
||||
/app/music/Global Communication/9:39.flac
|
||||
#EXTINF:460,Monolake - T-Channel
|
||||
/app/music/Monolake/T-Channel.flac
|
||||
#EXTINF:690,Deepchord - Vantage Isle (Variant)
|
||||
/app/music/Deepchord/Vantage Isle (Variant).flac
|
||||
#EXTINF:840,GAS - Königsforst 5
|
||||
/app/music/GAS/Königsforst 5.flac
|
||||
#EXTINF:520,Yagya - The Salt on Her Cheeks
|
||||
/app/music/Yagya/The Salt on Her Cheeks.flac
|
||||
#EXTINF:720,Voices From The Lake - Dream State
|
||||
/app/music/Voices From The Lake/Dream State.flac
|
||||
#EXTINF:510,36 - Night Rain
|
||||
/app/music/36/Night Rain.flac
|
||||
#EXTINF:470,Loscil - First Narrows
|
||||
/app/music/Loscil/First Narrows.flac
|
||||
#EXTINF:400,Kiasmos - Burnt
|
||||
/app/music/Kiasmos/Burnt.flac
|
||||
#EXTINF:570,Underworld - Jumbo (Extended)
|
||||
/app/music/Underworld/Jumbo (Extended).flac
|
||||
#EXTINF:480,Orbital - Belfast
|
||||
/app/music/Orbital/Belfast.flac
|
||||
#EXTINF:540,The Orb - Little Fluffy Clouds (Ambient Mix)
|
||||
/app/music/The Orb/Little Fluffy Clouds (Ambient Mix).flac
|
||||
#EXTINF:390,Autechre - Nine
|
||||
/app/music/Autechre/Nine.flac
|
||||
#EXTINF:380,Labradford - G (Mi Media Naranja)
|
||||
/app/music/Labradford/G (Mi Media Naranja).flac
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
#EXTM3U
|
||||
#EXTINF:370,Vector Lovers - City Lights From a Train
|
||||
Vector Lovers/City Lights From a Train.flac
|
||||
#EXTINF:400,The Black Dog - Psil-Cosyin
|
||||
The Black Dog/Psil-Cosyin.flac
|
||||
#EXTINF:320,Plaid - Eyen
|
||||
Plaid/Eyen.flac
|
||||
#EXTINF:330,ISAN - Birds Over Barges
|
||||
ISAN/Birds Over Barges.flac
|
||||
#EXTINF:360,Ochre - Bluebottle Farm
|
||||
Ochre/Bluebottle Farm.flac
|
||||
#EXTINF:390,Arovane - Theme
|
||||
Arovane/Theme.flac
|
||||
#EXTINF:380,Proem - Deep Like Airline Failure
|
||||
Proem/Deep Like Airline Failure.flac
|
||||
#EXTINF:310,Solvent - My Radio (Remix)
|
||||
Solvent/My Radio (Remix).flac
|
||||
#EXTINF:350,Bochum Welt - Marylebone (7th)
|
||||
Bochum Welt/Marylebone (7th).flac
|
||||
#EXTINF:290,Mrs Jynx - Shibuya Lullaby
|
||||
Mrs Jynx/Shibuya Lullaby.flac
|
||||
#EXTINF:340,Kettel - Whisper Me Wishes
|
||||
Kettel/Whisper Me Wishes.flac
|
||||
#EXTINF:360,Christ. - Perlandine Friday
|
||||
Christ./Perlandine Friday.flac
|
||||
#EXTINF:330,Cepia - Ithaca
|
||||
Cepia/Ithaca.flac
|
||||
#EXTINF:340,Datassette - Vacuform
|
||||
Datassette/Vacuform.flac
|
||||
#EXTINF:390,Plant43 - Dreams of the Sentient City
|
||||
Plant43/Dreams of the Sentient City.flac
|
||||
#EXTINF:410,Claro Intelecto - Peace of Mind (Electrosoul)
|
||||
Claro Intelecto/Peace of Mind (Electrosoul).flac
|
||||
#EXTINF:430,E.R.P. - Evoked
|
||||
E.R.P./Evoked.flac
|
||||
#EXTINF:310,Der Zyklus - Formenverwandler
|
||||
Der Zyklus/Formenverwandler.flac
|
||||
#EXTINF:330,Dopplereffekt - Infophysix
|
||||
Dopplereffekt/Infophysix.flac
|
||||
#EXTINF:350,Drexciya - Wavejumper
|
||||
Drexciya/Wavejumper.flac
|
||||
#EXTINF:375,The Other People Place - Sorrow & A Cup of Joe
|
||||
The Other People Place/Sorrow & A Cup of Joe.flac
|
||||
#EXTINF:340,Arpanet - Wireless Internet
|
||||
Arpanet/Wireless Internet.flac
|
||||
#EXTINF:380,Legowelt - Sturmvogel
|
||||
Legowelt/Sturmvogel.flac
|
||||
#EXTINF:310,DMX Krew - Space Paranoia
|
||||
DMX Krew/Space Paranoia.flac
|
||||
#EXTINF:360,Skywave Theory - Nova Drift
|
||||
Skywave Theory/Nova Drift.flac
|
||||
#EXTINF:460,Pye Corner Audio - Transmission Four
|
||||
Pye Corner Audio/Transmission Four.flac
|
||||
#EXTINF:390,B12 - Heaven Sent
|
||||
B12/Heaven Sent.flac
|
||||
#EXTINF:450,Higher Intelligence Agency - Tortoise
|
||||
Higher Intelligence Agency/Tortoise.flac
|
||||
#EXTINF:420,Biosphere - Kobresia
|
||||
Biosphere/Kobresia.flac
|
||||
#EXTINF:870,Global Communication - 14:31
|
||||
Global Communication/14:31.flac
|
||||
#EXTINF:500,Monolake - Cyan
|
||||
Monolake/Cyan.flac
|
||||
#EXTINF:660,Deepchord - Electromagnetic
|
||||
Deepchord/Electromagnetic.flac
|
||||
#EXTINF:1020,GAS - Pop 4
|
||||
GAS/Pop 4.flac
|
||||
#EXTINF:600,Yagya - Rigning Nýju
|
||||
Yagya/Rigning Nýju.flac
|
||||
#EXTINF:990,Voices From The Lake - Velo di Maya
|
||||
Voices From The Lake/Velo di Maya.flac
|
||||
#EXTINF:3720,ASC - Time Heals All
|
||||
ASC/Time Heals All.flac
|
||||
#EXTINF:540,36 - Room 237
|
||||
36/Room 237.flac
|
||||
#EXTINF:900,Loscil - Endless Falls
|
||||
Loscil/Endless Falls.flac
|
||||
#EXTINF:450,Kiasmos - Looped
|
||||
Kiasmos/Looped.flac
|
||||
#EXTINF:590,Underworld - Rez
|
||||
Underworld/Rez.flac
|
||||
#EXTINF:570,Orbital - Halcyon + On + On
|
||||
Orbital/Halcyon + On + On.flac
|
||||
#EXTINF:1080,The Orb - A Huge Ever Growing Pulsating Brain
|
||||
The Orb/A Huge Ever Growing Pulsating Brain.flac
|
||||
#EXTINF:360,Autechre - Slip
|
||||
Autechre/Slip.flac
|
||||
#EXTINF:400,Labradford - S (Mi Media Naranja)
|
||||
Labradford/S (Mi Media Naranja).flac
|
||||
#EXTINF:350,Vector Lovers - Rusting Cars and Wildflowers
|
||||
Vector Lovers/Rusting Cars and Wildflowers.flac
|
||||
#EXTINF:390,The Black Dog - Raxmus
|
||||
The Black Dog/Raxmus.flac
|
||||
#EXTINF:315,Plaid - Hawkmoth
|
||||
Plaid/Hawkmoth.flac
|
||||
#EXTINF:320,ISAN - What This Button Did
|
||||
ISAN/What This Button Did.flac
|
||||
#EXTINF:370,Ochre - Circadies
|
||||
Ochre/Circadies.flac
|
||||
#EXTINF:420,Arovane - Tides
|
||||
Arovane/Tides.flac
|
||||
#EXTINF:370,Proem - Nothing is as It Seems
|
||||
Proem/Nothing is as It Seems.flac
|
||||
#EXTINF:300,Solvent - Loss For Words
|
||||
Solvent/Loss For Words.flac
|
||||
#EXTINF:340,Bochum Welt - Saint (77sunset)
|
||||
Bochum Welt/Saint (77sunset).flac
|
||||
#EXTINF:280,Mrs Jynx - Stay Home
|
||||
Mrs Jynx/Stay Home.flac
|
||||
#EXTINF:330,Kettel - Church
|
||||
Kettel/Church.flac
|
||||
#EXTINF:370,Christ. - Cordate
|
||||
Christ./Cordate.flac
|
||||
#EXTINF:350,Datassette - Computers Elevate
|
||||
Datassette/Computers Elevate.flac
|
||||
#EXTINF:420,Plant43 - The Cold Surveyor
|
||||
Plant43/The Cold Surveyor.flac
|
||||
#EXTINF:380,Claro Intelecto - Section
|
||||
Claro Intelecto/Section.flac
|
||||
#EXTINF:440,E.R.P. - Vox Automaton
|
||||
E.R.P./Vox Automaton.flac
|
||||
#EXTINF:300,Dopplereffekt - Z-Boson
|
||||
Dopplereffekt/Z-Boson.flac
|
||||
#EXTINF:380,Drexciya - Digital Tsunami
|
||||
Drexciya/Digital Tsunami.flac
|
||||
#EXTINF:350,The Other People Place - You Said You Want Me
|
||||
The Other People Place/You Said You Want Me.flac
|
||||
#EXTINF:370,Legowelt - Star Gazing
|
||||
Legowelt/Star Gazing.flac
|
||||
#EXTINF:440,Pye Corner Audio - Electronic Rhythm Number 3
|
||||
Pye Corner Audio/Electronic Rhythm Number 3.flac
|
||||
#EXTINF:460,B12 - Infinite Lites (Classic Mix)
|
||||
B12/Infinite Lites (Classic Mix).flac
|
||||
#EXTINF:390,Biosphere - The Things I Tell You
|
||||
Biosphere/The Things I Tell You.flac
|
||||
#EXTINF:580,Global Communication - 9:39
|
||||
Global Communication/9:39.flac
|
||||
#EXTINF:460,Monolake - T-Channel
|
||||
Monolake/T-Channel.flac
|
||||
#EXTINF:690,Deepchord - Vantage Isle (Variant)
|
||||
Deepchord/Vantage Isle (Variant).flac
|
||||
#EXTINF:840,GAS - Königsforst 5
|
||||
GAS/Königsforst 5.flac
|
||||
#EXTINF:520,Yagya - The Salt on Her Cheeks
|
||||
Yagya/The Salt on Her Cheeks.flac
|
||||
#EXTINF:720,Voices From The Lake - Dream State
|
||||
Voices From The Lake/Dream State.flac
|
||||
#EXTINF:510,36 - Night Rain
|
||||
36/Night Rain.flac
|
||||
#EXTINF:470,Loscil - First Narrows
|
||||
Loscil/First Narrows.flac
|
||||
#EXTINF:400,Kiasmos - Burnt
|
||||
Kiasmos/Burnt.flac
|
||||
#EXTINF:570,Underworld - Jumbo (Extended)
|
||||
Underworld/Jumbo (Extended).flac
|
||||
#EXTINF:480,Orbital - Belfast
|
||||
Orbital/Belfast.flac
|
||||
#EXTINF:540,The Orb - Little Fluffy Clouds (Ambient Mix)
|
||||
The Orb/Little Fluffy Clouds (Ambient Mix).flac
|
||||
#EXTINF:390,Autechre - Nine
|
||||
Autechre/Nine.flac
|
||||
#EXTINF:380,Labradford - G (Mi Media Naranja)
|
||||
Labradford/G (Mi Media Naranja).flac
|
||||
Binary file not shown.
|
|
@ -0,0 +1,80 @@
|
|||
#+TITLE: Asteroid Low Orbit Playlist
|
||||
#+AUTHOR: Glenn
|
||||
#+DATE: 2025-11-06
|
||||
|
||||
* Files
|
||||
|
||||
- *Asteroid-Low-Orbit.m3u* - Original playlist with relative paths
|
||||
- *Asteroid-Low-Orbit-DOCKER.m3u* - Ready for VPS deployment (Docker container paths)
|
||||
|
||||
* For VPS Deployment
|
||||
|
||||
The =Asteroid-Low-Orbit-DOCKER.m3u= file is ready to use on the VPS (b612).
|
||||
|
||||
** Installation Steps
|
||||
|
||||
1. *Copy the file to the VPS:*
|
||||
|
||||
#+begin_src bash
|
||||
scp scripts/Asteroid-Low-Orbit-DOCKER.m3u glenneth@b612:~/asteroid/stream-queue.m3u
|
||||
#+end_src
|
||||
|
||||
2. *Ensure music files exist on VPS:*
|
||||
- Music should be in =/home/glenneth/Music/=
|
||||
- The directory structure should match the paths in the playlist
|
||||
- Example: =/home/glenneth/Music/Vector Lovers/City Lights From a Train.flac=
|
||||
|
||||
3. *Restart Liquidsoap container:*
|
||||
|
||||
#+begin_src bash
|
||||
cd ~/asteroid/docker
|
||||
docker-compose restart liquidsoap
|
||||
#+end_src
|
||||
|
||||
** How It Works
|
||||
|
||||
- *Host path*: =/home/glenneth/Music/= (on VPS)
|
||||
- *Container path*: =/app/music/= (inside Liquidsoap Docker container)
|
||||
- *Playlist paths*: Use =/app/music/...= because Liquidsoap reads from inside the container
|
||||
|
||||
The docker-compose.yml mounts the music directory:
|
||||
|
||||
#+begin_src yaml
|
||||
volumes:
|
||||
- ${MUSIC_LIBRARY:-../music/library}:/app/music:ro
|
||||
#+end_src
|
||||
|
||||
* Playlist Contents
|
||||
|
||||
This playlist contains ~50 tracks of ambient/IDM music curated for Asteroid Radio's "Low Orbit" programming block.
|
||||
|
||||
** Artists Featured
|
||||
|
||||
- Vector Lovers
|
||||
- The Black Dog
|
||||
- Plaid
|
||||
- ISAN
|
||||
- Ochre
|
||||
- Arovane
|
||||
- Proem
|
||||
- Solvent
|
||||
- Bochum Welt
|
||||
- Mrs Jynx
|
||||
- Kettel
|
||||
- Christ.
|
||||
- Cepia
|
||||
- Datassette
|
||||
- Plant43
|
||||
- Claro Intelecto
|
||||
- E.R.P.
|
||||
- Der Zyklus
|
||||
- Dopplereffekt
|
||||
- And more...
|
||||
|
||||
* Notes for Fade & easilok
|
||||
|
||||
- This playlist is ready to deploy to b612
|
||||
- All paths are formatted for the Docker container setup
|
||||
- Music files need to be present in =/home/glenneth/Music/= on the VPS
|
||||
- The playlist can be manually copied to replace =stream-queue.m3u= when ready
|
||||
- No changes to the main project repository required
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fix M3U file paths for VPS or Docker deployment
|
||||
Usage: python3 fix-m3u-paths.py input.m3u output.m3u [--docker|--vps]
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def fix_m3u_paths(input_file, output_file, mode='vps'):
|
||||
"""Convert relative paths to absolute paths for VPS or Docker"""
|
||||
|
||||
if mode == 'docker':
|
||||
base_path = '/app/music'
|
||||
else: # vps
|
||||
base_path = '/home/glenneth/Music'
|
||||
|
||||
with open(input_file, 'r', encoding='utf-8') as f_in:
|
||||
with open(output_file, 'w', encoding='utf-8') as f_out:
|
||||
for line in f_in:
|
||||
line = line.rstrip('\n')
|
||||
|
||||
# Keep #EXTM3U and #EXTINF lines as-is
|
||||
if line.startswith('#'):
|
||||
f_out.write(line + '\n')
|
||||
# Convert file paths
|
||||
elif line.strip():
|
||||
# Remove leading/trailing whitespace
|
||||
path = line.strip()
|
||||
# If it's already an absolute path, keep it
|
||||
if path.startswith('/'):
|
||||
f_out.write(path + '\n')
|
||||
else:
|
||||
# Make it absolute
|
||||
full_path = f"{base_path}/{path}"
|
||||
f_out.write(full_path + '\n')
|
||||
else:
|
||||
f_out.write('\n')
|
||||
|
||||
print(f"Converted {input_file} -> {output_file}")
|
||||
print(f"Mode: {mode}")
|
||||
print(f"Base path: {base_path}")
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python3 fix-m3u-paths.py input.m3u output.m3u [--docker|--vps]")
|
||||
print(" --docker: Use /app/music/ prefix (for Docker container)")
|
||||
print(" --vps: Use /home/glenneth/Music/ prefix (default)")
|
||||
sys.exit(1)
|
||||
|
||||
input_file = sys.argv[1]
|
||||
output_file = sys.argv[2]
|
||||
mode = 'vps'
|
||||
|
||||
if len(sys.argv) > 3:
|
||||
if sys.argv[3] == '--docker':
|
||||
mode = 'docker'
|
||||
elif sys.argv[3] == '--vps':
|
||||
mode = 'vps'
|
||||
|
||||
if not Path(input_file).exists():
|
||||
print(f"Error: Input file '{input_file}' not found")
|
||||
sys.exit(1)
|
||||
|
||||
fix_m3u_paths(input_file, output_file, mode)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
#!/bin/bash
|
||||
# Basic music library tree generator (no external tools required)
|
||||
# Shows file structure with sizes only
|
||||
# Usage: ./music-library-tree-basic.sh [music-directory] [output-file]
|
||||
|
||||
MUSIC_DIR="${1:-/home/glenneth/Music}"
|
||||
OUTPUT_FILE="${2:-music-library-tree.txt}"
|
||||
|
||||
# Check if music directory exists
|
||||
if [ ! -d "$MUSIC_DIR" ]; then
|
||||
echo "Error: Music directory '$MUSIC_DIR' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to format file size
|
||||
format_size() {
|
||||
local size=$1
|
||||
if [ $size -ge 1073741824 ]; then
|
||||
awk "BEGIN {printf \"%.1fG\", $size/1073741824}"
|
||||
elif [ $size -ge 1048576 ]; then
|
||||
awk "BEGIN {printf \"%.1fM\", $size/1048576}"
|
||||
elif [ $size -ge 1024 ]; then
|
||||
awk "BEGIN {printf \"%.0fK\", $size/1024}"
|
||||
else
|
||||
printf "%dB" $size
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to recursively build tree
|
||||
build_tree() {
|
||||
local dir="$1"
|
||||
local prefix="$2"
|
||||
|
||||
# Get all entries sorted
|
||||
local entries=()
|
||||
while IFS= read -r -d $'\0' entry; do
|
||||
entries+=("$entry")
|
||||
done < <(find "$dir" -maxdepth 1 -mindepth 1 -print0 2>/dev/null | sort -z)
|
||||
|
||||
# Separate directories and files
|
||||
local dirs=()
|
||||
local files=()
|
||||
|
||||
for entry in "${entries[@]}"; do
|
||||
if [ -d "$entry" ]; then
|
||||
dirs+=("$entry")
|
||||
else
|
||||
files+=("$entry")
|
||||
fi
|
||||
done
|
||||
|
||||
# Combine: directories first, then files
|
||||
local all_entries=("${dirs[@]}" "${files[@]}")
|
||||
local count=${#all_entries[@]}
|
||||
local index=0
|
||||
|
||||
for entry in "${all_entries[@]}"; do
|
||||
index=$((index + 1))
|
||||
local basename=$(basename "$entry")
|
||||
local is_last=false
|
||||
[ $index -eq $count ] && is_last=true
|
||||
|
||||
if [ -d "$entry" ]; then
|
||||
# Directory - count files inside
|
||||
local file_count=$(find "$entry" -type f 2>/dev/null | wc -l)
|
||||
if $is_last; then
|
||||
echo "${prefix}└── $basename/ ($file_count files)" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix} "
|
||||
else
|
||||
echo "${prefix}├── $basename/ ($file_count files)" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix}│ "
|
||||
fi
|
||||
else
|
||||
# File
|
||||
local ext="${basename##*.}"
|
||||
ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
|
||||
local size=$(stat -c%s "$entry" 2>/dev/null || stat -f%z "$entry" 2>/dev/null || echo "0")
|
||||
local size_fmt=$(format_size $size)
|
||||
|
||||
if [[ "$ext" =~ ^(mp3|flac|ogg|m4a|wav|aac|opus|wma)$ ]]; then
|
||||
if $is_last; then
|
||||
echo "${prefix}└── ♪ $basename ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── ♪ $basename ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
else
|
||||
if $is_last; then
|
||||
echo "${prefix}└── $basename ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── $basename ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
echo "Generating music library tree (basic mode - no duration info)..."
|
||||
|
||||
# Start generating the tree
|
||||
{
|
||||
echo "Music Library Tree"
|
||||
echo "=================="
|
||||
echo "Generated: $(date)"
|
||||
echo "Directory: $MUSIC_DIR"
|
||||
echo "Note: Duration info not available (requires mediainfo/ffprobe)"
|
||||
echo ""
|
||||
|
||||
# Count total files
|
||||
total_audio=$(find "$MUSIC_DIR" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.ogg" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.aac" -o -iname "*.opus" -o -iname "*.wma" \) 2>/dev/null | wc -l)
|
||||
total_dirs=$(find "$MUSIC_DIR" -type d 2>/dev/null | wc -l)
|
||||
total_size=$(du -sh "$MUSIC_DIR" 2>/dev/null | cut -f1)
|
||||
|
||||
echo "Total audio files: $total_audio"
|
||||
echo "Total directories: $total_dirs"
|
||||
echo "Total size: $total_size"
|
||||
echo ""
|
||||
|
||||
# Build the tree
|
||||
echo "$(basename "$MUSIC_DIR")/"
|
||||
} > "$OUTPUT_FILE"
|
||||
|
||||
build_tree "$MUSIC_DIR" ""
|
||||
|
||||
echo ""
|
||||
echo "Tree generated successfully!"
|
||||
echo "Output saved to: $OUTPUT_FILE"
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
#!/bin/bash
|
||||
# Simple music library tree generator using 'tree' command
|
||||
# Usage: ./music-library-tree-simple.sh [music-directory] [output-file]
|
||||
|
||||
MUSIC_DIR="${1:-/home/glenn/Projects/Code/asteroid/music}"
|
||||
OUTPUT_FILE="${2:-music-library-tree.txt}"
|
||||
|
||||
# Check if music directory exists
|
||||
if [ ! -d "$MUSIC_DIR" ]; then
|
||||
echo "Error: Music directory '$MUSIC_DIR' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if tree command is available
|
||||
if ! command -v tree &> /dev/null; then
|
||||
echo "Error: 'tree' command not found. Please install it:"
|
||||
echo " Ubuntu/Debian: sudo apt-get install tree"
|
||||
echo " CentOS/RHEL: sudo yum install tree"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Generating music library tree..."
|
||||
|
||||
# Generate header
|
||||
{
|
||||
echo "Music Library Tree"
|
||||
echo "=================="
|
||||
echo "Generated: $(date)"
|
||||
echo "Directory: $MUSIC_DIR"
|
||||
echo ""
|
||||
|
||||
# Count audio files
|
||||
total_audio=$(find "$MUSIC_DIR" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.ogg" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.aac" -o -iname "*.opus" -o -iname "*.wma" \) 2>/dev/null | wc -l)
|
||||
echo "Total audio files: $total_audio"
|
||||
echo ""
|
||||
|
||||
# Generate tree with file sizes
|
||||
tree -h -F --dirsfirst "$MUSIC_DIR"
|
||||
|
||||
} > "$OUTPUT_FILE"
|
||||
|
||||
echo ""
|
||||
echo "Tree generated successfully!"
|
||||
echo "Output saved to: $OUTPUT_FILE"
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
#!/bin/bash
|
||||
# Music library tree generator for VPS (no ffprobe required)
|
||||
# Usage: ./music-library-tree-vps.sh [music-directory] [output-file]
|
||||
|
||||
MUSIC_DIR="${1:-/home/glenneth/Music}"
|
||||
OUTPUT_FILE="${2:-music-library-tree.txt}"
|
||||
|
||||
# Check if music directory exists
|
||||
if [ ! -d "$MUSIC_DIR" ]; then
|
||||
echo "Error: Music directory '$MUSIC_DIR' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to get duration using available tools
|
||||
get_duration() {
|
||||
local file="$1"
|
||||
|
||||
# Try mediainfo first
|
||||
if command -v mediainfo &> /dev/null; then
|
||||
duration=$(mediainfo --Inform="General;%Duration%" "$file" 2>/dev/null)
|
||||
if [ -n "$duration" ] && [ "$duration" != "" ]; then
|
||||
# Convert milliseconds to minutes:seconds
|
||||
duration_sec=$((duration / 1000))
|
||||
printf "%02d:%02d" $((duration_sec/60)) $((duration_sec%60))
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try mp3info for MP3 files
|
||||
if [[ "$file" == *.mp3 ]] && command -v mp3info &> /dev/null; then
|
||||
duration=$(mp3info -p "%m:%02s" "$file" 2>/dev/null)
|
||||
if [ -n "$duration" ]; then
|
||||
echo "$duration"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try soxi (from sox package)
|
||||
if command -v soxi &> /dev/null; then
|
||||
duration=$(soxi -D "$file" 2>/dev/null)
|
||||
if [ -n "$duration" ]; then
|
||||
duration_sec=${duration%.*}
|
||||
printf "%02d:%02d" $((duration_sec/60)) $((duration_sec%60))
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# No duration available
|
||||
echo "--:--"
|
||||
}
|
||||
|
||||
# Function to format file size
|
||||
format_size() {
|
||||
local size=$1
|
||||
if [ $size -ge 1073741824 ]; then
|
||||
printf "%.1fG" $(awk "BEGIN {printf \"%.1f\", $size/1073741824}")
|
||||
elif [ $size -ge 1048576 ]; then
|
||||
printf "%.1fM" $(awk "BEGIN {printf \"%.1f\", $size/1048576}")
|
||||
elif [ $size -ge 1024 ]; then
|
||||
printf "%.0fK" $(awk "BEGIN {printf \"%.0f\", $size/1024}")
|
||||
else
|
||||
printf "%dB" $size
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to recursively build tree
|
||||
build_tree() {
|
||||
local dir="$1"
|
||||
local prefix="$2"
|
||||
|
||||
# Get all entries sorted (directories first, then files)
|
||||
local entries=($(find "$dir" -maxdepth 1 -mindepth 1 | sort))
|
||||
local dirs=()
|
||||
local files=()
|
||||
|
||||
# Separate directories and files
|
||||
for entry in "${entries[@]}"; do
|
||||
if [ -d "$entry" ]; then
|
||||
dirs+=("$entry")
|
||||
else
|
||||
files+=("$entry")
|
||||
fi
|
||||
done
|
||||
|
||||
# Combine: directories first, then files
|
||||
local all_entries=("${dirs[@]}" "${files[@]}")
|
||||
local count=${#all_entries[@]}
|
||||
local index=0
|
||||
|
||||
for entry in "${all_entries[@]}"; do
|
||||
index=$((index + 1))
|
||||
local basename=$(basename "$entry")
|
||||
local is_last=false
|
||||
[ $index -eq $count ] && is_last=true
|
||||
|
||||
if [ -d "$entry" ]; then
|
||||
# Directory
|
||||
if $is_last; then
|
||||
echo "${prefix}└── $basename/" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix} "
|
||||
else
|
||||
echo "${prefix}├── $basename/" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix}│ "
|
||||
fi
|
||||
else
|
||||
# File - check if it's an audio file
|
||||
local ext="${basename##*.}"
|
||||
ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
if [[ "$ext" =~ ^(mp3|flac|ogg|m4a|wav|aac|opus|wma)$ ]]; then
|
||||
local duration=$(get_duration "$entry")
|
||||
local size=$(stat -c%s "$entry" 2>/dev/null || stat -f%z "$entry" 2>/dev/null)
|
||||
local size_fmt=$(format_size $size)
|
||||
|
||||
if $is_last; then
|
||||
echo "${prefix}└── $basename [$duration] ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── $basename [$duration] ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
else
|
||||
# Non-audio file
|
||||
if $is_last; then
|
||||
echo "${prefix}└── $basename" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── $basename" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Detect available tools
|
||||
echo "Checking for available metadata tools..."
|
||||
TOOLS_AVAILABLE=""
|
||||
command -v mediainfo &> /dev/null && TOOLS_AVAILABLE="$TOOLS_AVAILABLE mediainfo"
|
||||
command -v mp3info &> /dev/null && TOOLS_AVAILABLE="$TOOLS_AVAILABLE mp3info"
|
||||
command -v soxi &> /dev/null && TOOLS_AVAILABLE="$TOOLS_AVAILABLE soxi"
|
||||
|
||||
if [ -z "$TOOLS_AVAILABLE" ]; then
|
||||
echo "Warning: No metadata tools found (mediainfo, mp3info, soxi)"
|
||||
echo "Duration information will not be available"
|
||||
else
|
||||
echo "Found tools:$TOOLS_AVAILABLE"
|
||||
fi
|
||||
|
||||
echo "Generating music library tree..."
|
||||
|
||||
# Start generating the tree
|
||||
{
|
||||
echo "Music Library Tree"
|
||||
echo "=================="
|
||||
echo "Generated: $(date)"
|
||||
echo "Directory: $MUSIC_DIR"
|
||||
echo "Tools available:$TOOLS_AVAILABLE"
|
||||
echo ""
|
||||
|
||||
# Count total files
|
||||
total_audio=$(find "$MUSIC_DIR" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.ogg" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.aac" -o -iname "*.opus" -o -iname "*.wma" \) 2>/dev/null | wc -l)
|
||||
echo "Total audio files: $total_audio"
|
||||
echo ""
|
||||
|
||||
# Build the tree
|
||||
echo "$(basename "$MUSIC_DIR")/"
|
||||
} > "$OUTPUT_FILE"
|
||||
|
||||
build_tree "$MUSIC_DIR" ""
|
||||
|
||||
echo ""
|
||||
echo "Tree generated successfully!"
|
||||
echo "Output saved to: $OUTPUT_FILE"
|
||||
echo "Total audio files: $(find "$MUSIC_DIR" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.ogg" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.aac" -o -iname "*.opus" -o -iname "*.wma" \) 2>/dev/null | wc -l)"
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a tree view of the music library with track durations
|
||||
Usage: python3 music-library-tree.py [music-directory] [output-file]
|
||||
|
||||
Requires: mutagen (install with: pip3 install mutagen)
|
||||
If mutagen not available, falls back to showing file info without duration
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Try to import mutagen for audio metadata
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
MUTAGEN_AVAILABLE = True
|
||||
except ImportError:
|
||||
MUTAGEN_AVAILABLE = False
|
||||
print("Warning: mutagen not installed. Duration info will not be available.")
|
||||
print("Install with: pip3 install mutagen")
|
||||
|
||||
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.m4a', '.wav', '.aac', '.opus', '.wma'}
|
||||
|
||||
def get_duration(file_path):
|
||||
"""Get duration of audio file using mutagen"""
|
||||
if not MUTAGEN_AVAILABLE:
|
||||
return "--:--"
|
||||
|
||||
try:
|
||||
audio = MutagenFile(str(file_path))
|
||||
if audio is not None and hasattr(audio.info, 'length'):
|
||||
duration_sec = int(audio.info.length)
|
||||
minutes = duration_sec // 60
|
||||
seconds = duration_sec % 60
|
||||
return f"{minutes:02d}:{seconds:02d}"
|
||||
except Exception:
|
||||
pass
|
||||
return "--:--"
|
||||
|
||||
def format_size(size):
|
||||
"""Format file size in human-readable format"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size < 1024.0:
|
||||
return f"{size:.2f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.2f} TB"
|
||||
|
||||
def build_tree(directory, output_file, prefix="", is_last=True):
|
||||
"""Recursively build tree structure"""
|
||||
try:
|
||||
entries = sorted(Path(directory).iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
||||
except PermissionError:
|
||||
return
|
||||
|
||||
for i, entry in enumerate(entries):
|
||||
is_last_entry = (i == len(entries) - 1)
|
||||
connector = "└── " if is_last_entry else "├── "
|
||||
|
||||
if entry.is_dir():
|
||||
output_file.write(f"{prefix}{connector}📁 {entry.name}/\n")
|
||||
extension = " " if is_last_entry else "│ "
|
||||
build_tree(entry, output_file, prefix + extension, is_last_entry)
|
||||
else:
|
||||
ext = entry.suffix.lower()
|
||||
if ext in AUDIO_EXTENSIONS:
|
||||
duration = get_duration(entry)
|
||||
size = entry.stat().st_size
|
||||
size_fmt = format_size(size)
|
||||
output_file.write(f"{prefix}{connector}🎵 {entry.name} [{duration}] ({size_fmt})\n")
|
||||
else:
|
||||
output_file.write(f"{prefix}{connector}📄 {entry.name}\n")
|
||||
|
||||
def main():
|
||||
music_dir = sys.argv[1] if len(sys.argv) > 1 else "/home/glenneth/Music"
|
||||
output_path = sys.argv[2] if len(sys.argv) > 2 else "music-library-tree.txt"
|
||||
|
||||
music_path = Path(music_dir)
|
||||
if not music_path.exists():
|
||||
print(f"Error: Music directory '{music_dir}' does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
print("Generating music library tree...")
|
||||
|
||||
# Count audio files
|
||||
audio_files = []
|
||||
for ext in AUDIO_EXTENSIONS:
|
||||
audio_files.extend(music_path.rglob(f"*{ext}"))
|
||||
total_audio = len(audio_files)
|
||||
|
||||
# Generate tree
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write("Music Library Tree\n")
|
||||
f.write("==================\n")
|
||||
f.write(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
f.write(f"Directory: {music_dir}\n")
|
||||
f.write(f"Mutagen available: {'Yes' if MUTAGEN_AVAILABLE else 'No (install with: pip3 install mutagen)'}\n")
|
||||
f.write(f"\nTotal audio files: {total_audio}\n\n")
|
||||
f.write(f"📁 {music_path.name}/\n")
|
||||
build_tree(music_path, f, "", True)
|
||||
|
||||
print(f"\nTree generated successfully!")
|
||||
print(f"Output saved to: {output_path}")
|
||||
print(f"Total audio files: {total_audio}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
#!/bin/bash
|
||||
# Generate a tree view of the music library with track durations
|
||||
# Usage: ./music-library-tree.sh [music-directory] [output-file]
|
||||
|
||||
MUSIC_DIR="${1:-/home/glenn/Projects/Code/asteroid/music}"
|
||||
OUTPUT_FILE="${2:-music-library-tree.txt}"
|
||||
|
||||
# Check if music directory exists
|
||||
if [ ! -d "$MUSIC_DIR" ]; then
|
||||
echo "Error: Music directory '$MUSIC_DIR' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to get duration using ffprobe
|
||||
get_duration() {
|
||||
local file="$1"
|
||||
if command -v ffprobe &> /dev/null; then
|
||||
duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file" 2>/dev/null)
|
||||
if [ -n "$duration" ]; then
|
||||
# Convert to minutes:seconds
|
||||
printf "%02d:%02d" $((${duration%.*}/60)) $((${duration%.*}%60))
|
||||
else
|
||||
echo "??:??"
|
||||
fi
|
||||
else
|
||||
echo "??:??"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to format file size
|
||||
format_size() {
|
||||
local size=$1
|
||||
if [ $size -ge 1073741824 ]; then
|
||||
printf "%.2f GB" $(echo "scale=2; $size/1073741824" | bc)
|
||||
elif [ $size -ge 1048576 ]; then
|
||||
printf "%.2f MB" $(echo "scale=2; $size/1048576" | bc)
|
||||
elif [ $size -ge 1024 ]; then
|
||||
printf "%.2f KB" $(echo "scale=2; $size/1024" | bc)
|
||||
else
|
||||
printf "%d B" $size
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to recursively build tree
|
||||
build_tree() {
|
||||
local dir="$1"
|
||||
local prefix="$2"
|
||||
local is_last="$3"
|
||||
|
||||
local entries=()
|
||||
while IFS= read -r -d '' entry; do
|
||||
entries+=("$entry")
|
||||
done < <(find "$dir" -maxdepth 1 -mindepth 1 -print0 | sort -z)
|
||||
|
||||
local count=${#entries[@]}
|
||||
local index=0
|
||||
|
||||
for entry in "${entries[@]}"; do
|
||||
index=$((index + 1))
|
||||
local basename=$(basename "$entry")
|
||||
local is_last_entry=false
|
||||
[ $index -eq $count ] && is_last_entry=true
|
||||
|
||||
if [ -d "$entry" ]; then
|
||||
# Directory
|
||||
if $is_last_entry; then
|
||||
echo "${prefix}└── 📁 $basename/" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix} " true
|
||||
else
|
||||
echo "${prefix}├── 📁 $basename/" >> "$OUTPUT_FILE"
|
||||
build_tree "$entry" "${prefix}│ " false
|
||||
fi
|
||||
else
|
||||
# File - check if it's an audio file
|
||||
local ext="${basename##*.}"
|
||||
ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
if [[ "$ext" =~ ^(mp3|flac|ogg|m4a|wav|aac|opus|wma)$ ]]; then
|
||||
local duration=$(get_duration "$entry")
|
||||
local size=$(stat -f%z "$entry" 2>/dev/null || stat -c%s "$entry" 2>/dev/null)
|
||||
local size_fmt=$(format_size $size)
|
||||
|
||||
if $is_last_entry; then
|
||||
echo "${prefix}└── 🎵 $basename [$duration] ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── 🎵 $basename [$duration] ($size_fmt)" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
else
|
||||
# Non-audio file
|
||||
if $is_last_entry; then
|
||||
echo "${prefix}└── 📄 $basename" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo "${prefix}├── 📄 $basename" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Start generating the tree
|
||||
echo "Generating music library tree..."
|
||||
echo "Music Library Tree" > "$OUTPUT_FILE"
|
||||
echo "==================" >> "$OUTPUT_FILE"
|
||||
echo "Generated: $(date)" >> "$OUTPUT_FILE"
|
||||
echo "Directory: $MUSIC_DIR" >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
|
||||
# Count total files
|
||||
total_audio=$(find "$MUSIC_DIR" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.ogg" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.aac" -o -iname "*.opus" -o -iname "*.wma" \) | wc -l)
|
||||
echo "Total audio files: $total_audio" >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
|
||||
# Build the tree
|
||||
echo "📁 $(basename "$MUSIC_DIR")/" >> "$OUTPUT_FILE"
|
||||
build_tree "$MUSIC_DIR" "" true
|
||||
|
||||
echo ""
|
||||
echo "Tree generated successfully!"
|
||||
echo "Output saved to: $OUTPUT_FILE"
|
||||
echo "Total audio files: $total_audio"
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a tree view of the music library with track durations
|
||||
No external dependencies required - reads file headers directly
|
||||
Usage: python3 music-library-tree-standalone.py [music-directory] [output-file]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.m4a', '.wav', '.aac', '.opus', '.wma'}
|
||||
|
||||
def get_mp3_duration(file_path):
|
||||
"""Get MP3 duration by reading frame headers"""
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
# Skip ID3v2 tag if present
|
||||
header = f.read(10)
|
||||
if header[:3] == b'ID3':
|
||||
size = struct.unpack('>I', b'\x00' + header[6:9])[0]
|
||||
f.seek(size + 10)
|
||||
else:
|
||||
f.seek(0)
|
||||
|
||||
# Read first frame to get bitrate and sample rate
|
||||
frame_header = f.read(4)
|
||||
if len(frame_header) < 4:
|
||||
return None
|
||||
|
||||
# Parse MP3 frame header
|
||||
if frame_header[0] != 0xFF or (frame_header[1] & 0xE0) != 0xE0:
|
||||
return None
|
||||
|
||||
# Bitrate table (MPEG1 Layer III)
|
||||
bitrates = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0]
|
||||
bitrate_index = (frame_header[2] >> 4) & 0x0F
|
||||
bitrate = bitrates[bitrate_index] * 1000
|
||||
|
||||
if bitrate == 0:
|
||||
return None
|
||||
|
||||
# Get file size
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
|
||||
# Estimate duration
|
||||
duration = (file_size * 8) / bitrate
|
||||
return int(duration)
|
||||
except:
|
||||
return None
|
||||
|
||||
def get_flac_duration(file_path):
|
||||
"""Get FLAC duration by reading metadata block"""
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
# Check FLAC signature
|
||||
if f.read(4) != b'fLaC':
|
||||
return None
|
||||
|
||||
# Read metadata blocks
|
||||
while True:
|
||||
block_header = f.read(4)
|
||||
if len(block_header) < 4:
|
||||
return None
|
||||
|
||||
is_last = (block_header[0] & 0x80) != 0
|
||||
block_type = block_header[0] & 0x7F
|
||||
block_size = struct.unpack('>I', b'\x00' + block_header[1:4])[0]
|
||||
|
||||
if block_type == 0: # STREAMINFO
|
||||
streaminfo = f.read(block_size)
|
||||
# Sample rate is at bytes 10-13 (20 bits)
|
||||
sample_rate = (struct.unpack('>I', streaminfo[10:14])[0] >> 12) & 0xFFFFF
|
||||
# Total samples is at bytes 13-17 (36 bits)
|
||||
total_samples = struct.unpack('>Q', b'\x00\x00\x00' + streaminfo[13:18])[0] & 0xFFFFFFFFF
|
||||
|
||||
if sample_rate > 0:
|
||||
duration = total_samples / sample_rate
|
||||
return int(duration)
|
||||
return None
|
||||
|
||||
if is_last:
|
||||
break
|
||||
f.seek(block_size, 1)
|
||||
except:
|
||||
return None
|
||||
|
||||
def get_wav_duration(file_path):
|
||||
"""Get WAV duration by reading RIFF header"""
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
# Check RIFF header
|
||||
if f.read(4) != b'RIFF':
|
||||
return None
|
||||
f.read(4) # File size
|
||||
if f.read(4) != b'WAVE':
|
||||
return None
|
||||
|
||||
# Find fmt chunk
|
||||
while True:
|
||||
chunk_id = f.read(4)
|
||||
if len(chunk_id) < 4:
|
||||
return None
|
||||
chunk_size = struct.unpack('<I', f.read(4))[0]
|
||||
|
||||
if chunk_id == b'fmt ':
|
||||
fmt_data = f.read(chunk_size)
|
||||
sample_rate = struct.unpack('<I', fmt_data[4:8])[0]
|
||||
byte_rate = struct.unpack('<I', fmt_data[8:12])[0]
|
||||
break
|
||||
else:
|
||||
f.seek(chunk_size, 1)
|
||||
|
||||
# Find data chunk
|
||||
while True:
|
||||
chunk_id = f.read(4)
|
||||
if len(chunk_id) < 4:
|
||||
return None
|
||||
chunk_size = struct.unpack('<I', f.read(4))[0]
|
||||
|
||||
if chunk_id == b'data':
|
||||
if byte_rate > 0:
|
||||
duration = chunk_size / byte_rate
|
||||
return int(duration)
|
||||
return None
|
||||
else:
|
||||
f.seek(chunk_size, 1)
|
||||
except:
|
||||
return None
|
||||
|
||||
def get_duration(file_path):
|
||||
"""Get duration of audio file by reading file headers"""
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
if ext == '.mp3':
|
||||
duration_sec = get_mp3_duration(file_path)
|
||||
elif ext == '.flac':
|
||||
duration_sec = get_flac_duration(file_path)
|
||||
elif ext == '.wav':
|
||||
duration_sec = get_wav_duration(file_path)
|
||||
else:
|
||||
# For other formats, we can't easily read without libraries
|
||||
return "--:--"
|
||||
|
||||
if duration_sec is not None:
|
||||
minutes = duration_sec // 60
|
||||
seconds = duration_sec % 60
|
||||
return f"{minutes:02d}:{seconds:02d}"
|
||||
|
||||
return "--:--"
|
||||
|
||||
def format_size(size):
|
||||
"""Format file size in human-readable format"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||
if size < 1024.0:
|
||||
return f"{size:.2f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.2f} TB"
|
||||
|
||||
def build_tree(directory, output_file, prefix="", is_last=True):
|
||||
"""Recursively build tree structure"""
|
||||
try:
|
||||
entries = sorted(Path(directory).iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
||||
except PermissionError:
|
||||
return
|
||||
|
||||
for i, entry in enumerate(entries):
|
||||
is_last_entry = (i == len(entries) - 1)
|
||||
connector = "└── " if is_last_entry else "├── "
|
||||
|
||||
if entry.is_dir():
|
||||
output_file.write(f"{prefix}{connector}📁 {entry.name}/\n")
|
||||
extension = " " if is_last_entry else "│ "
|
||||
build_tree(entry, output_file, prefix + extension, is_last_entry)
|
||||
else:
|
||||
ext = entry.suffix.lower()
|
||||
if ext in AUDIO_EXTENSIONS:
|
||||
duration = get_duration(entry)
|
||||
size = entry.stat().st_size
|
||||
size_fmt = format_size(size)
|
||||
output_file.write(f"{prefix}{connector}🎵 {entry.name} [{duration}] ({size_fmt})\n")
|
||||
else:
|
||||
output_file.write(f"{prefix}{connector}📄 {entry.name}\n")
|
||||
|
||||
def main():
|
||||
music_dir = sys.argv[1] if len(sys.argv) > 1 else "/home/glenneth/Music"
|
||||
output_path = sys.argv[2] if len(sys.argv) > 2 else "music-library-tree.txt"
|
||||
|
||||
music_path = Path(music_dir)
|
||||
if not music_path.exists():
|
||||
print(f"Error: Music directory '{music_dir}' does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
print("Generating music library tree...")
|
||||
print("Reading durations from file headers (MP3, FLAC, WAV supported)")
|
||||
|
||||
# Count audio files
|
||||
audio_files = []
|
||||
for ext in AUDIO_EXTENSIONS:
|
||||
audio_files.extend(music_path.rglob(f"*{ext}"))
|
||||
total_audio = len(audio_files)
|
||||
|
||||
# Generate tree
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write("Music Library Tree\n")
|
||||
f.write("==================\n")
|
||||
f.write(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
f.write(f"Directory: {music_dir}\n")
|
||||
f.write(f"Duration support: MP3, FLAC, WAV (no external libraries needed)\n")
|
||||
f.write(f"\nTotal audio files: {total_audio}\n\n")
|
||||
f.write(f"📁 {music_path.name}/\n")
|
||||
build_tree(music_path, f, "", True)
|
||||
|
||||
print(f"\nTree generated successfully!")
|
||||
print(f"Output saved to: {output_path}")
|
||||
print(f"Total audio files: {total_audio}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1161,10 +1161,12 @@
|
|||
:opacity 1
|
||||
:background-color #(color-accented-black)))
|
||||
|
||||
;; Live stream blink animation
|
||||
;; Live stream pulse animation (like old MacBook sleep indicator)
|
||||
(.live-stream-indicator
|
||||
:animation "asteroid-blink 1s steps(5, start) infinite")
|
||||
:animation "asteroid-pulse 2s ease-in-out infinite")
|
||||
|
||||
(:keyframes asteroid-blink
|
||||
(to :visibility "hidden"))
|
||||
(:keyframes asteroid-pulse
|
||||
(0% :opacity 1)
|
||||
(50% :opacity 0.3)
|
||||
(100% :opacity 1))
|
||||
) ;; End of let block
|
||||
|
|
|
|||
|
|
@ -635,12 +635,6 @@ function displayQueueSearchResults(results) {
|
|||
|
||||
// Live stream info update
|
||||
async function updateLiveStreamInfo() {
|
||||
// Don't update if stream is paused
|
||||
const audioElement = document.getElementById('live-stream-audio');
|
||||
if (audioElement && audioElement.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing-inline');
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
|
@ -55,12 +55,6 @@ function changeStreamQuality() {
|
|||
|
||||
// Update now playing info from Icecast
|
||||
async function updateNowPlaying() {
|
||||
// Don't update if stream is paused
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
if (audioElement && audioElement.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing')
|
||||
const contentType = response.headers.get("content-type")
|
||||
|
|
@ -108,60 +102,9 @@ window.addEventListener('DOMContentLoaded', function() {
|
|||
// Update playing information right after load
|
||||
updateNowPlaying();
|
||||
|
||||
// Auto-reconnect on stream errors and after long pauses
|
||||
// Auto-reconnect on stream errors
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
if (audioElement) {
|
||||
// Track pause timestamp to detect long pauses and reconnect
|
||||
let pauseTimestamp = null;
|
||||
let isReconnecting = false;
|
||||
let needsReconnect = false;
|
||||
const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds
|
||||
|
||||
audioElement.addEventListener('pause', function() {
|
||||
pauseTimestamp = Date.now();
|
||||
console.log('Stream paused at:', pauseTimestamp);
|
||||
});
|
||||
|
||||
audioElement.addEventListener('play', function() {
|
||||
// Check if we need to reconnect after long pause
|
||||
if (!isReconnecting && pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) {
|
||||
needsReconnect = true;
|
||||
console.log('Long pause detected, will reconnect when playing starts...');
|
||||
}
|
||||
pauseTimestamp = null;
|
||||
});
|
||||
|
||||
// Intercept the playing event to stop stale audio
|
||||
audioElement.addEventListener('playing', function() {
|
||||
if (needsReconnect && !isReconnecting) {
|
||||
isReconnecting = true;
|
||||
needsReconnect = false;
|
||||
console.log('Reconnecting stream after long pause to clear stale buffers...');
|
||||
|
||||
// Stop the stale audio immediately
|
||||
audioElement.pause();
|
||||
|
||||
// Reset spectrum analyzer before reconnect
|
||||
if (typeof resetSpectrumAnalyzer === 'function') {
|
||||
resetSpectrumAnalyzer();
|
||||
}
|
||||
|
||||
audioElement.load(); // Force reconnect to clear accumulated buffer
|
||||
|
||||
// Start playing the fresh stream and reinitialize spectrum analyzer
|
||||
setTimeout(function() {
|
||||
audioElement.play().catch(err => console.log('Reconnect play failed:', err));
|
||||
|
||||
if (typeof initSpectrumAnalyzer === 'function') {
|
||||
initSpectrumAnalyzer();
|
||||
console.log('Spectrum analyzer reinitialized after reconnect');
|
||||
}
|
||||
|
||||
isReconnecting = false;
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('error', function(e) {
|
||||
console.log('Stream error, attempting reconnect in 3 seconds...');
|
||||
setTimeout(function() {
|
||||
|
|
@ -26,57 +26,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (liveAudio) {
|
||||
// Reduce buffer to minimize delay
|
||||
liveAudio.preload = 'none';
|
||||
|
||||
// Track pause timestamp to detect long pauses and reconnect
|
||||
let pauseTimestamp = null;
|
||||
let isReconnecting = false;
|
||||
let needsReconnect = false;
|
||||
const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds
|
||||
|
||||
liveAudio.addEventListener('pause', function() {
|
||||
pauseTimestamp = Date.now();
|
||||
console.log('Live stream paused at:', pauseTimestamp);
|
||||
});
|
||||
|
||||
liveAudio.addEventListener('play', function() {
|
||||
// Check if we need to reconnect after long pause
|
||||
if (!isReconnecting && pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) {
|
||||
needsReconnect = true;
|
||||
console.log('Long pause detected, will reconnect when playing starts...');
|
||||
}
|
||||
pauseTimestamp = null;
|
||||
});
|
||||
|
||||
// Intercept the playing event to stop stale audio
|
||||
liveAudio.addEventListener('playing', function() {
|
||||
if (needsReconnect && !isReconnecting) {
|
||||
isReconnecting = true;
|
||||
needsReconnect = false;
|
||||
console.log('Reconnecting live stream after long pause to clear stale buffers...');
|
||||
|
||||
// Stop the stale audio immediately
|
||||
liveAudio.pause();
|
||||
|
||||
// Reset spectrum analyzer before reconnect
|
||||
if (typeof resetSpectrumAnalyzer === 'function') {
|
||||
resetSpectrumAnalyzer();
|
||||
}
|
||||
|
||||
liveAudio.load(); // Force reconnect to clear accumulated buffer
|
||||
|
||||
// Start playing the fresh stream and reinitialize spectrum analyzer
|
||||
setTimeout(function() {
|
||||
liveAudio.play().catch(err => console.log('Reconnect play failed:', err));
|
||||
|
||||
if (typeof initSpectrumAnalyzer === 'function') {
|
||||
initSpectrumAnalyzer();
|
||||
console.log('Spectrum analyzer reinitialized after reconnect');
|
||||
}
|
||||
|
||||
isReconnecting = false;
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Restore user quality preference
|
||||
const selector = document.getElementById('live-stream-quality');
|
||||
|
|
@ -181,8 +130,8 @@ function renderLibraryPage() {
|
|||
return `
|
||||
<div class="track-item" data-track-id="${track.id}" data-index="${actualIndex}">
|
||||
<div class="track-info">
|
||||
<div class="track-title">${track.title || 'Unknown Title'}</div>
|
||||
<div class="track-meta">${track.artist || 'Unknown Artist'} • ${track.album || 'Unknown Album'}</div>
|
||||
<div class="track-title">${track.title[0] || 'Unknown Title'}</div>
|
||||
<div class="track-meta">${track.artist[0] || 'Unknown Artist'} • ${track.album[0] || 'Unknown Album'}</div>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button onclick="playTrack(${actualIndex})" class="btn btn-sm btn-success">▶️</button>
|
||||
|
|
@ -237,9 +186,9 @@ function changeLibraryTracksPerPage() {
|
|||
function filterTracks() {
|
||||
const query = document.getElementById('search-tracks').value.toLowerCase();
|
||||
const filtered = tracks.filter(track =>
|
||||
(track.title || '').toLowerCase().includes(query) ||
|
||||
(track.artist || '').toLowerCase().includes(query) ||
|
||||
(track.album || '').toLowerCase().includes(query)
|
||||
(track.title[0] || '').toLowerCase().includes(query) ||
|
||||
(track.artist[0] || '').toLowerCase().includes(query) ||
|
||||
(track.album[0] || '').toLowerCase().includes(query)
|
||||
);
|
||||
displayTracks(filtered);
|
||||
}
|
||||
|
|
@ -355,9 +304,9 @@ function updatePlayButton(text) {
|
|||
|
||||
function updatePlayerDisplay() {
|
||||
if (currentTrack) {
|
||||
document.getElementById('current-title').textContent = currentTrack.title || 'Unknown Title';
|
||||
document.getElementById('current-artist').textContent = currentTrack.artist || 'Unknown Artist';
|
||||
document.getElementById('current-album').textContent = currentTrack.album || 'Unknown Album';
|
||||
document.getElementById('current-title').textContent = currentTrack.title[0] || 'Unknown Title';
|
||||
document.getElementById('current-artist').textContent = currentTrack.artist[0] || 'Unknown Artist';
|
||||
document.getElementById('current-album').textContent = currentTrack.album[0] || 'Unknown Album';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,8 +328,8 @@ function updateQueueDisplay() {
|
|||
const queueHtml = playQueue.map((track, index) => `
|
||||
<div class="queue-item">
|
||||
<div class="track-info">
|
||||
<div class="track-title">${track.title || 'Unknown Title'}</div>
|
||||
<div class="track-meta">${track.artist || 'Unknown Artist'}</div>
|
||||
<div class="track-title">${track.title[0] || 'Unknown Title'}</div>
|
||||
<div class="track-meta">${track.artist[0] || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<button onclick="removeFromQueue(${index})" class="btn btn-sm btn-danger">✖️</button>
|
||||
</div>
|
||||
|
|
@ -417,10 +366,8 @@ async function createPlaylist() {
|
|||
});
|
||||
|
||||
const result = await response.json();
|
||||
// Handle RADIANCE API wrapper format
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success') {
|
||||
if (result.status === 'success') {
|
||||
alert(`Playlist "${name}" created successfully!`);
|
||||
document.getElementById('new-playlist-name').value = '';
|
||||
|
||||
|
|
@ -428,7 +375,7 @@ async function createPlaylist() {
|
|||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
loadPlaylists();
|
||||
} else {
|
||||
alert('Error creating playlist: ' + data.message);
|
||||
alert('Error creating playlist: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating playlist:', error);
|
||||
|
|
@ -457,28 +404,24 @@ async function saveQueueAsPlaylist() {
|
|||
});
|
||||
|
||||
const createResult = await createResponse.json();
|
||||
// Handle RADIANCE API wrapper format
|
||||
const createData = createResult.data || createResult;
|
||||
|
||||
if (createData.status === 'success') {
|
||||
if (createResult.status === 'success') {
|
||||
// Wait a moment for database to update
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Get the new playlist ID by fetching playlists
|
||||
const playlistsResponse = await fetch('/api/asteroid/playlists');
|
||||
const playlistsResult = await playlistsResponse.json();
|
||||
// Handle RADIANCE API wrapper format
|
||||
const playlistResultData = playlistsResult.data || playlistsResult;
|
||||
|
||||
if (playlistResultData.status === 'success' && playlistResultData.playlists.length > 0) {
|
||||
if (playlistsResult.status === 'success' && playlistsResult.playlists.length > 0) {
|
||||
// Find the playlist with matching name (most recent)
|
||||
const newPlaylist = playlistResultData.playlists.find(p => p.name === name) ||
|
||||
playlistResultData.playlists[playlistResultData.playlists.length - 1];
|
||||
const newPlaylist = playlistsResult.playlists.find(p => p.name === name) ||
|
||||
playlistsResult.playlists[playlistsResult.playlists.length - 1];
|
||||
|
||||
// Add all tracks from queue to playlist
|
||||
let addedCount = 0;
|
||||
for (const track of playQueue) {
|
||||
const trackId = track.id;
|
||||
const trackId = track.id || (Array.isArray(track.id) ? track.id[0] : null);
|
||||
|
||||
if (trackId) {
|
||||
const addFormData = new FormData();
|
||||
|
|
@ -492,7 +435,7 @@ async function saveQueueAsPlaylist() {
|
|||
|
||||
const addResult = await addResponse.json();
|
||||
|
||||
if (addResult.data?.status === 'success') {
|
||||
if (addResult.status === 'success') {
|
||||
addedCount++;
|
||||
}
|
||||
} else {
|
||||
|
|
@ -503,11 +446,10 @@ async function saveQueueAsPlaylist() {
|
|||
alert(`Playlist "${name}" created with ${addedCount} tracks!`);
|
||||
loadPlaylists();
|
||||
} else {
|
||||
alert('Playlist created but could not add tracks. Error: ' + (playlistResultData.message || 'Unknown'));
|
||||
loadPlaylists();
|
||||
alert('Playlist created but could not add tracks. Error: ' + (playlistsResult.message || 'Unknown'));
|
||||
}
|
||||
} else {
|
||||
alert('Error creating playlist: ' + createData.message);
|
||||
alert('Error creating playlist: ' + createResult.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving queue as playlist:', error);
|
||||
|
|
@ -519,11 +461,11 @@ async function loadPlaylists() {
|
|||
try {
|
||||
const response = await fetch('/api/asteroid/playlists');
|
||||
const result = await response.json();
|
||||
// Handle RADIANCE API wrapper format
|
||||
const data = result.data || result;
|
||||
|
||||
if (data && data.status === 'success') {
|
||||
displayPlaylists(data.playlists || []);
|
||||
if (result.data && result.data.status === 'success') {
|
||||
displayPlaylists(result.data.playlists || []);
|
||||
} else if (result.status === 'success') {
|
||||
displayPlaylists(result.playlists || []);
|
||||
} else {
|
||||
displayPlaylists([]);
|
||||
}
|
||||
|
|
@ -560,11 +502,9 @@ async function loadPlaylist(playlistId) {
|
|||
try {
|
||||
const response = await fetch(`/api/asteroid/playlists/get?playlist-id=${playlistId}`);
|
||||
const result = await response.json();
|
||||
// Handle RADIANCE API wrapper format
|
||||
const data = result.data || result;
|
||||
|
||||
if (data.status === 'success' && data.playlist) {
|
||||
const playlist = data.playlist;
|
||||
if (result.status === 'success' && result.playlist) {
|
||||
const playlist = result.playlist;
|
||||
|
||||
// Clear current queue
|
||||
playQueue = [];
|
||||
|
|
@ -594,7 +534,7 @@ async function loadPlaylist(playlistId) {
|
|||
alert(`Playlist "${playlist.name}" is empty`);
|
||||
}
|
||||
} else {
|
||||
alert('Error loading playlist: ' + (data.message || 'Unknown error'));
|
||||
alert('Error loading playlist: ' + (result.message || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading playlist:', error);
|
||||
|
|
@ -649,12 +589,6 @@ function changeLiveStreamQuality() {
|
|||
|
||||
// Live stream informatio update
|
||||
async function updateNowPlaying() {
|
||||
// Don't update if stream is paused
|
||||
const liveAudio = document.getElementById('live-stream-audio');
|
||||
if (liveAudio && liveAudio.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing')
|
||||
const contentType = response.headers.get("content-type")
|
||||
|
|
@ -47,64 +47,15 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Track pause timestamp to detect long pauses and reconnect
|
||||
let pauseTimestamp = null;
|
||||
let isReconnecting = false;
|
||||
let needsReconnect = false;
|
||||
const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds
|
||||
|
||||
audioElement.addEventListener('pause', function() {
|
||||
pauseTimestamp = Date.now();
|
||||
console.log('Frame player stream paused at:', pauseTimestamp);
|
||||
});
|
||||
|
||||
audioElement.addEventListener('play', function() {
|
||||
// Check if we need to reconnect after long pause
|
||||
if (!isReconnecting && pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) {
|
||||
needsReconnect = true;
|
||||
console.log('Long pause detected, will reconnect when playing starts...');
|
||||
}
|
||||
pauseTimestamp = null;
|
||||
});
|
||||
|
||||
// Intercept the playing event to stop stale audio
|
||||
audioElement.addEventListener('playing', function() {
|
||||
if (needsReconnect && !isReconnecting) {
|
||||
isReconnecting = true;
|
||||
needsReconnect = false;
|
||||
console.log('Reconnecting frame player stream after long pause to clear stale buffers...');
|
||||
|
||||
// Stop the stale audio immediately
|
||||
audioElement.pause();
|
||||
|
||||
// Reset spectrum analyzer before reconnect
|
||||
if (typeof resetSpectrumAnalyzer === 'function') {
|
||||
resetSpectrumAnalyzer();
|
||||
}
|
||||
|
||||
audioElement.load(); // Force reconnect to clear accumulated buffer
|
||||
|
||||
// Start playing the fresh stream and reinitialize spectrum analyzer
|
||||
setTimeout(function() {
|
||||
audioElement.play().catch(err => console.log('Reconnect play failed:', err));
|
||||
|
||||
if (typeof initSpectrumAnalyzer === 'function') {
|
||||
initSpectrumAnalyzer();
|
||||
console.log('Spectrum analyzer reinitialized after reconnect');
|
||||
}
|
||||
|
||||
isReconnecting = false;
|
||||
}, 200);
|
||||
} else {
|
||||
console.log('Audio playing');
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listeners for debugging
|
||||
audioElement.addEventListener('waiting', function() {
|
||||
console.log('Audio buffering...');
|
||||
});
|
||||
|
||||
audioElement.addEventListener('playing', function() {
|
||||
console.log('Audio playing');
|
||||
});
|
||||
|
||||
audioElement.addEventListener('error', function(e) {
|
||||
console.error('Audio error:', e);
|
||||
});
|
||||
|
|
@ -161,12 +112,6 @@
|
|||
|
||||
// Update mini now playing display
|
||||
async function updateMiniNowPlaying() {
|
||||
// Don't update if stream is paused
|
||||
const audioElement = document.getElementById('persistent-audio');
|
||||
if (audioElement && audioElement.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing-inline');
|
||||
if (response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title lquery="(text title)">🎵 ASTEROID RADIO 🎵</title>
|
||||
<title lquery="(text title)">ASTEROID RADIO</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">🎵 ASTEROID RADIO 🎵</title>
|
||||
<title data-text="title">ASTEROID RADIO</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
<div id="now-playing" class="now-playing"></div>
|
||||
|
||||
<!-- Recently Played Tracks -->
|
||||
<div class="recently-played-panel">
|
||||
<div id="recently-played-panel" class="recently-played-panel">
|
||||
<h3>Recently Played</h3>
|
||||
<div id="recently-played-list" class="recently-played-list">
|
||||
<p class="loading">Loading...</p>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title data-text="title">🎵 ASTEROID RADIO 🎵</title>
|
||||
<title data-text="title">ASTEROID RADIO</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
|
||||
|
|
@ -79,7 +79,7 @@
|
|||
<div id="now-playing" class="now-playing"></div>
|
||||
|
||||
<!-- Recently Played Tracks -->
|
||||
<div class="recently-played-panel">
|
||||
<div id="recently-played-panel" class="recently-played-panel">
|
||||
<h3>Recently Played</h3>
|
||||
<div id="recently-played-list" class="recently-played-list">
|
||||
<p class="loading">Loading...</p>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,18 @@
|
|||
<title data-text="title">Asteroid Radio - Login</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/asteroid/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
|
||||
<link rel="stylesheet" type="text/css" href="/static/asteroid.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🎵 ASTEROID RADIO - LOGIN</h1>
|
||||
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
<span>ASTEROID RADIO - LOGIN</span>
|
||||
</h1>
|
||||
<nav class="nav">
|
||||
<a href="/asteroid">Home</a>
|
||||
<a href="/asteroid/player">Player</a>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@
|
|||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎵 WEB PLAYER</h1>
|
||||
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
<span>WEB PLAYER</span>
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
</h1>
|
||||
|
||||
<!-- Spectrum Analyzer Canvas -->
|
||||
<div style="text-align: center; margin: 15px 0;">
|
||||
|
|
|
|||
|
|
@ -4,13 +4,20 @@
|
|||
<title data-text="title">Asteroid Radio - Web Player</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/asteroid/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
<script src="/asteroid/static/js/auth-ui.js"></script>
|
||||
<script src="/asteroid/static/js/player.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎵 WEB PLAYER</h1>
|
||||
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
<span>WEB PLAYER</span>
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
</h1>
|
||||
<div class="nav">
|
||||
<a href="/asteroid">Home</a>
|
||||
<a href="/asteroid/profile">Profile</a>
|
||||
|
|
|
|||
|
|
@ -97,12 +97,6 @@
|
|||
|
||||
// Update now playing info for popout
|
||||
async function updatePopoutNowPlaying() {
|
||||
// Don't update if stream is paused
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
if (audioElement && audioElement.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/asteroid/partial/now-playing-inline');
|
||||
const html = await response.text();
|
||||
|
|
@ -131,60 +125,8 @@
|
|||
// Initial update
|
||||
updatePopoutNowPlaying();
|
||||
|
||||
// Auto-reconnect on stream errors and after long pauses
|
||||
// Auto-reconnect on stream errors
|
||||
const audioElement = document.getElementById('live-audio');
|
||||
|
||||
// Track pause timestamp to detect long pauses and reconnect
|
||||
let pauseTimestamp = null;
|
||||
let isReconnecting = false;
|
||||
let needsReconnect = false;
|
||||
const PAUSE_RECONNECT_THRESHOLD = 10000; // 10 seconds
|
||||
|
||||
audioElement.addEventListener('pause', function() {
|
||||
pauseTimestamp = Date.now();
|
||||
console.log('Popout stream paused at:', pauseTimestamp);
|
||||
});
|
||||
|
||||
audioElement.addEventListener('play', function() {
|
||||
// Check if we need to reconnect after long pause
|
||||
if (!isReconnecting && pauseTimestamp && (Date.now() - pauseTimestamp) > PAUSE_RECONNECT_THRESHOLD) {
|
||||
needsReconnect = true;
|
||||
console.log('Long pause detected, will reconnect when playing starts...');
|
||||
}
|
||||
pauseTimestamp = null;
|
||||
});
|
||||
|
||||
// Intercept the playing event to stop stale audio
|
||||
audioElement.addEventListener('playing', function() {
|
||||
if (needsReconnect && !isReconnecting) {
|
||||
isReconnecting = true;
|
||||
needsReconnect = false;
|
||||
console.log('Reconnecting popout stream after long pause to clear stale buffers...');
|
||||
|
||||
// Stop the stale audio immediately
|
||||
audioElement.pause();
|
||||
|
||||
// Reset spectrum analyzer before reconnect
|
||||
if (typeof resetSpectrumAnalyzer === 'function') {
|
||||
resetSpectrumAnalyzer();
|
||||
}
|
||||
|
||||
audioElement.load(); // Force reconnect to clear accumulated buffer
|
||||
|
||||
// Start playing the fresh stream and reinitialize spectrum analyzer
|
||||
setTimeout(function() {
|
||||
audioElement.play().catch(err => console.log('Reconnect play failed:', err));
|
||||
|
||||
if (typeof initSpectrumAnalyzer === 'function') {
|
||||
initSpectrumAnalyzer();
|
||||
console.log('Spectrum analyzer reinitialized after reconnect');
|
||||
}
|
||||
|
||||
isReconnecting = false;
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
audioElement.addEventListener('error', function(e) {
|
||||
console.log('Stream error, attempting reconnect in 3 seconds...');
|
||||
setTimeout(function() {
|
||||
|
|
|
|||
|
|
@ -4,12 +4,18 @@
|
|||
<title data-text="title">Asteroid Radio - Register</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="/asteroid/static/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/asteroid/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/asteroid/static/favicon-16x16.png">
|
||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🎵 ASTEROID RADIO - REGISTER</h1>
|
||||
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
|
||||
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 50px; width: auto;">
|
||||
<span>ASTEROID RADIO - REGISTER</span>
|
||||
</h1>
|
||||
<nav class="nav">
|
||||
<a href="/asteroid">Home</a>
|
||||
<a href="/asteroid/player">Player</a>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
;;;; test-parenscript.lisp - Test ParenScript compilation
|
||||
|
||||
(ql:quickload :parenscript)
|
||||
|
||||
(defun test-auth-ui-compilation ()
|
||||
"Test compiling the auth-ui ParenScript to JavaScript"
|
||||
(let ((js-code
|
||||
(ps:ps
|
||||
;; Check if user is logged in by calling the API
|
||||
(defun check-auth-status ()
|
||||
(ps:chain
|
||||
(fetch "/api/asteroid/auth-status")
|
||||
(then (lambda (response)
|
||||
(ps:chain response (json))))
|
||||
(then (lambda (result)
|
||||
;; api-output wraps response in {status, message, data}
|
||||
(let ((data (or (ps:@ result data) result)))
|
||||
data)))
|
||||
(catch (lambda (error)
|
||||
(ps:chain console (error "Error checking auth status:" error))
|
||||
(ps:create :logged-in false
|
||||
:is-admin false)))))
|
||||
|
||||
;; Update UI based on authentication status
|
||||
(defun update-auth-ui (auth-status)
|
||||
;; Show/hide elements based on login status
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-logged-in]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status logged-in)
|
||||
"inline-block"
|
||||
"none")))))
|
||||
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-logged-out]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status logged-in)
|
||||
"none"
|
||||
"inline-block")))))
|
||||
|
||||
(ps:chain document
|
||||
(query-selector-all "[data-show-if-admin]")
|
||||
(for-each (lambda (el)
|
||||
(setf (ps:@ el style display)
|
||||
(if (ps:@ auth-status is-admin)
|
||||
"inline-block"
|
||||
"none"))))))
|
||||
|
||||
;; Initialize auth UI on page load
|
||||
(ps:chain document
|
||||
(add-event-listener
|
||||
"DOMContentLoaded"
|
||||
(async lambda ()
|
||||
(ps:chain console (log "Auth UI initializing..."))
|
||||
(let ((auth-status (await (check-auth-status))))
|
||||
(ps:chain console (log "Auth status:" auth-status))
|
||||
(update-auth-ui auth-status)
|
||||
(ps:chain console (log "Auth UI updated")))))))))
|
||||
|
||||
(format t "~%Generated JavaScript:~%~%")
|
||||
(format t "~a~%" js-code)
|
||||
(format t "~%~%")
|
||||
|
||||
;; Write to file for comparison
|
||||
(with-open-file (out "/home/glenn/Projects/Code/asteroid/static/js/auth-ui-generated.js"
|
||||
:direction :output
|
||||
:if-exists :supersede)
|
||||
(write-string js-code out))
|
||||
|
||||
(format t "Wrote generated JavaScript to: static/js/auth-ui-generated.js~%")))
|
||||
|
||||
;; Run the test
|
||||
(test-auth-ui-compilation)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
;;;; test-ps-compile.lisp - Test ParenScript compilation for auth-ui
|
||||
|
||||
(load "~/quicklisp/setup.lisp")
|
||||
(ql:quickload '(:parenscript) :silent t)
|
||||
|
||||
(format t "~%Testing ParenScript compilation for auth-ui...~%~%")
|
||||
|
||||
;; Load the auth-ui parenscript file
|
||||
(load "parenscript/auth-ui.lisp")
|
||||
|
||||
;; Test compilation
|
||||
(format t "Compiling ParenScript to JavaScript...~%~%")
|
||||
(let ((js-output (asteroid::generate-auth-ui-js)))
|
||||
(format t "Generated JavaScript (~a characters):~%~%" (length js-output))
|
||||
(format t "~a~%~%" js-output)
|
||||
|
||||
;; Write to file
|
||||
(with-open-file (out "static/js/auth-ui-parenscript-output.js"
|
||||
:direction :output
|
||||
:if-exists :supersede)
|
||||
(write-string js-output out))
|
||||
|
||||
(format t "~%✓ JavaScript written to: static/js/auth-ui-parenscript-output.js~%")
|
||||
(format t "✓ Compilation successful!~%~%"))
|
||||
|
||||
(format t "Compare with original:~%")
|
||||
(format t " Original: static/js/auth-ui.js.original~%")
|
||||
(format t " Generated: static/js/auth-ui-parenscript-output.js~%~%")
|
||||
|
|
@ -173,7 +173,7 @@
|
|||
;; Page request - redirect to login (redirect doesn't return)
|
||||
(progn
|
||||
(format t "Authentication failed - redirecting to login~%")
|
||||
(radiance:redirect "/asteroid/login"))))))
|
||||
(radiance:redirect "/login"))))))
|
||||
|
||||
(defun require-role (role &key (api nil))
|
||||
"Require user to have a specific role.
|
||||
|
|
@ -284,7 +284,11 @@
|
|||
(defun initialize-user-system ()
|
||||
"Initialize the user management system"
|
||||
(format t "Initializing user management system...~%")
|
||||
;; Skip database check at startup - database queries hang with current setup
|
||||
(format t "Skipping admin creation check - database already initialized~%")
|
||||
(format t "User management initialization complete.~%")
|
||||
;; Try immediate initialization first
|
||||
#+nil
|
||||
(handler-case
|
||||
(progn
|
||||
(format t "Setting up user management...~%")
|
||||
|
|
|
|||
Loading…
Reference in New Issue