Compare commits

...

34 Commits

Author SHA1 Message Date
Glenn Thompson 6bbc3d0b6a fix: Correct parenthesis mismatches in player.lisp and frontend-partials.lisp
- Fixed missing closing paren in save-queue-as-playlist function
- Fixed extra closing paren in icecast-now-playing function
- Updated player.lisp with upstream changes from player.js:
  * Removed array indexing for track properties
  * Added RADIANCE API wrapper handling
  * Complete save-queue-as-playlist implementation
- Build and server startup now working correctly
2025-11-20 07:36:10 +03:00
Glenn Thompson a08e42f752 chore: Add favicon images and clean up patch file 2025-11-20 07:16:11 +03:00
Glenn Thompson 24e6859aa0 fix: Admin login and authentication issues
- Fix undefined uri-path function - use radiance:path instead
- Fix redirect paths for subdomain routing (remove /asteroid prefix)
- Add error handling and debug logging to admin page
- Fix login redirect to use correct paths for asteroid.localhost
- Add debug output to track authentication flow
2025-11-20 07:16:03 +03:00
Glenn Thompson d540c87cfc fix: Use uri-path instead of :external representation for API detection
- Replace uri-to-url with :representation :external with uri-path
- Fixes issue where full URLs like http://asteroid.radio.localhost were generated
- .localhost domains resolve to 127.0.0.1 which breaks on remote servers
- Path-only approach works for both local and remote deployments
- Follows Radiance best practices: :external is only for redirect URLs
2025-11-20 07:14:23 +03:00
Glenn Thompson 16d81e8ccc Document frontend-partials.lisp changes in ParenScript experiment
- Added details about listener count aggregation across all mount points
- Documented stray ^ character fix
- Documented error handler additions
- Documented debug logging additions
- Cross-referenced error variable removal to Challenge 3
2025-11-20 07:13:34 +03:00
Glenn Thompson 5f77b4cd4f Complete ParenScript migration: player.js and admin.js converted
- Converted player.js to parenscript/player.lisp
- Converted admin.js to parenscript/admin.lisp
- Fixed ParenScript compilation errors (push macro, != operator, error handlers)
- Fixed now-playing display with proper Icecast stats parsing
- Aggregated listener counts across all three stream mount points (mp3, aac, low)
- Updated documentation with all lessons learned and ParenScript patterns
- All JavaScript files now successfully converted to ParenScript
- Application maintains 100% original functionality
2025-11-20 07:13:32 +03:00
Glenn Thompson d0e40cccad fix: Comment out Quicklisp check in build script and update ParenScript docs
- Allow building when Quicklisp is already loaded
- Update ParenScript resources with correct GitLab repository URL
2025-11-20 07:07:57 +03:00
glenneth 263dc8a800 docs: Update ParenScript experiment with profile.js and users.js lessons
Added documentation for the two latest conversions:
- Marked profile.js and users.js as complete
- Documented modulo operator issue (use 'rem' not '%')
- Documented property access with hyphens (use ps:getprop)
- Documented HTML generation patterns
- Documented conditional attributes in templates
- Added comprehensive summary of 10 key ParenScript patterns

Status: 4 of 6 JavaScript files successfully converted
Remaining: admin.js, player.js (complex, 610 lines)
2025-11-20 07:07:27 +03:00
glenneth 022b1d8b96 feat: Convert users.js to ParenScript
Successfully converted users.js with all functionality:
- User stats display (total, active, admin, DJ counts)
- Load users list with table display
- Change user role (UI working, backend may need fixes)
- Activate/deactivate users
- Create new user form
- Auto-refresh stats every 30 seconds

Generated JavaScript working correctly.

Files:
- parenscript/users.lisp - ParenScript source
- asteroid.asd - Added users to parenscript module
- asteroid.lisp - Added users.js to static route interception
- static/js/users.js - Removed from git (backed up as .original)

Four files successfully converted to ParenScript!
Remaining: admin.js, player.js
2025-11-20 07:07:27 +03:00
glenneth cc79ba7330 feat: Convert profile.js to ParenScript
Successfully converted profile.js with all functionality:
- Profile data loading (username, role, join date, last active)
- Listening statistics display
- Recent tracks display
- Top artists display
- Password change form
- Export listening data
- Clear listening history
- Toast notifications

Generated JavaScript working correctly after fixing modulo operator.

Key learning: Use 'rem' instead of '%' for modulo in ParenScript.

Files:
- parenscript/profile.lisp - ParenScript source
- asteroid.asd - Added profile to parenscript module
- asteroid.lisp - Added profile.js to static route interception
- static/js/profile.js - Removed from git (backed up as .original)
- static/js/player.js - Restored (skipped for now, too complex)

Three files successfully converted to ParenScript\!
2025-11-20 07:07:27 +03:00
glenneth 3d7b08119a docs: Update ParenScript experiment documentation
Moved PARENSCRIPT-EXPERIMENT.org to docs/ directory.

Updates:
- Marked front-page.js as complete in Phase 2
- Removed duplicate front-page.js from Phase 3
- Added conversion progress section with both files
- Documented front-page.js specific patterns:
  * Global variables with defvar
  * String concatenation with +
  * Conditional logic with cond
  * Object property access with ps:getprop
- Listed all tested features for front-page.js

Status: 2 of 6 JavaScript files converted successfully
2025-11-20 07:07:27 +03:00
glenneth c35ae5a1f0 feat: Convert front-page.js to ParenScript
Successfully converted front-page.js with all functionality:
- Stream quality configuration and switching
- Now playing updates (every 10 seconds)
- Pop-out player functionality
- Frameset mode toggle
- Auto-reconnect on stream errors

Generated JavaScript: 6900 characters
No browser errors, all features working

Files:
- parenscript/front-page.lisp - ParenScript source
- asteroid.asd - Added front-page to parenscript module
- asteroid.lisp - Added front-page.js to static route interception
- static/js/front-page.js - Removed from git (backed up as .original)

Two files successfully converted to ParenScript!
2025-11-20 07:07:27 +03:00
glenneth 0d50f01a07 fix: Replace async/await with promise chains in ParenScript
ParenScript doesn't support async/await syntax properly. Changed to use
promise chains with .then() which compiles correctly.

Result:
- No JavaScript errors
- Auth UI working correctly
- Generated JS: 1386 characters
- First successful ParenScript replacement complete\!

Next: Can convert more JS files (profile.js, users.js, etc.)
2025-11-20 07:07:27 +03:00
glenneth 3c2ddf84c0 fix: ParenScript compilation working - intercept static route
The issue was route ordering. Since Radiance matches routes in load order,
we couldn't override the static file route. Solution: intercept the static
route and check if path is 'js/auth-ui.js', then serve ParenScript-compiled
JavaScript instead.

Changes:
- Compile ParenScript to string at load time (stored in *auth-ui-js*)
- Intercept static route to serve ParenScript for auth-ui.js
- JavaScript successfully generated (1290 chars)
- Ready for browser testing
2025-11-20 07:07:27 +03:00
glenneth b12e366d2c fix: Move ParenScript route before static file route
The ParenScript route must come before the catch-all static route
to properly override /static/js/auth-ui.js with dynamically compiled
JavaScript. Routes are matched in order, so specific routes must
precede general patterns.
2025-11-20 07:07:27 +03:00
glenneth 3c76436e81 fix: Correct parenthesis count in auth-ui ParenScript
Removed extra closing parenthesis that was causing compilation error.
Build now succeeds with ParenScript version of auth-ui.js
2025-11-20 07:07:27 +03:00
glenneth 8b839daf0a experiment: Replace auth-ui.js with ParenScript version
- Removed static/js/auth-ui.js (backed up as .original)
- Templates still reference /asteroid/static/js/auth-ui.js
- Route now serves dynamically compiled ParenScript
- Ready to test ParenScript replacement
2025-11-20 07:07:27 +03:00
glenneth ec00843a90 experiment: Convert auth-ui.js to ParenScript
- Created parenscript/auth-ui.lisp with ParenScript version
- Added route to serve compiled JavaScript at /static/js/auth-ui.js
- Updated asteroid.asd to include parenscript module
- First conversion: auth-ui.js (authentication UI state management)

The ParenScript code compiles to equivalent JavaScript and is served
dynamically. This allows us to write client-side code in Lisp.
2025-11-20 07:07:27 +03:00
glenneth aa4ed06d7f experiment: Add ParenScript setup and utilities
- Add parenscript dependency to asteroid.asd
- Create parenscript-utils.lisp with helper functions and macros
- Add PARENSCRIPT-EXPERIMENT.org documenting the conversion plan
- Goal: Replace all JavaScript with ParenScript for full-stack Lisp
2025-11-20 07:07:24 +03:00
Glenn Thompson cec3763403 Merge remote-tracking branch 'upstream/main' 2025-11-20 07:04:29 +03:00
Luis Pereira a1cfaf468c fix: playlist creation on frontend 2025-11-19 18:00:02 -05:00
Luis Pereira 9c3d4bcec4 fix: hide duplicated browser audio in player page 2025-11-19 18:00:02 -05:00
Luis Pereira a1fa5b0b51 fix: tracks and playlist db interation through data-model 2025-11-19 18:00:02 -05:00
Glenn Thompson c198775083 Merge remote-tracking branch 'upstream/main' 2025-11-18 07:15:32 +03:00
Luis Pereira 559187df2e fix: with-error-handling using inner message
This fix some issues where, on the client, `response.message` was `Ok.`
for error responses and real error message needed to be extracted from
`response.data.message`, which made a weird API.
2025-11-17 18:08:12 -05:00
Luis Pereira 59076e67b8 fix: profile password change using non existing function 2025-11-17 18:08:12 -05:00
Glenn Thompson 2a505e482d Fix scan-library path to work in both development and production
- Auto-detect music library path based on environment
- Check for music/library/ directory for local development
- Default to /app/music/ for production Docker deployment
- Allow MUSIC_LIBRARY_PATH environment variable override
- Fixes scan-library function failing on production server
2025-11-17 18:06:14 -05:00
Glenn Thompson 19b9deccf5 fix: Use /app/music/ as default music library path for production
- Changed hardcoded music/library/ path to /app/music/ (production path)
- Added MUSIC_LIBRARY_PATH environment variable for local dev override
- Fixes scan library function on production server
- Aligns with path structure used in M3U playlists and liquidsoap config
2025-11-17 18:06:14 -05:00
Glenn Thompson d187a01641 Merge remote-tracking branch 'upstream/main' 2025-11-17 06:34:49 +03:00
Brian O'Reilly 6043e3f9a4 lets just ignore the library directory, permanently. :(( 2025-11-16 11:16:01 -05:00
Luis Pereira 8245917b28 fix: add data-model-save wrapper
This tries to bypass a weird error where native "dm:save" fails with lambdalite.
2025-11-16 09:38:04 -05:00
Luis Pereira 74088ca47b fix: admin button shown only for admin accounts 2025-11-16 09:38:04 -05:00
Luis Pereira c5804641b8 fix: move user database methods to data-model 2025-11-16 09:38:04 -05:00
Luis Pereira 92ccee7cf6 feat: data module to alist converter 2025-11-16 09:38:04 -05:00
50 changed files with 4326 additions and 389 deletions

10
.gitignore vendored
View File

@ -28,6 +28,16 @@ docker/music/*.m4a
docker/music/*.aac
docker/music/*.wma
music/library
# music/library/*/*.mp3
# music/library/*/*.flac
# music/library/*/*.ogg
# music/library/*/*.wav
# music/library/*/*.m4a
# music/library/*/*.aac
# music/library/*/*.wma
# Docker build artifacts
docker/.env
docker/.dockerignore

View File

@ -11,10 +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
@ -23,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 :relational-database)
@ -41,6 +41,14 @@
(:file "conditions")
(:file "database")
(:file "template-utils")
(:file "parenscript-utils")
(:module :parenscript
:components ((:file "auth-ui")
(:file "front-page")
(:file "profile")
(:file "users")
(:file "admin")
(:file "player")))
(:file "stream-media")
(:file "user-management")
(:file "playlist-management")

View File

@ -16,8 +16,11 @@
;; configuration logic. Probably using 'ubiquity
(defparameter *server-port* 8080)
(defparameter *music-library-path*
(merge-pathnames "music/library/"
(asdf:system-source-directory :asteroid)))
(or (uiop:getenv "MUSIC_LIBRARY_PATH")
;; Default to /app/music/ for production Docker, but check if music/library/ exists for local dev
(if (probe-file (merge-pathnames "music/library/" (asdf:system-source-directory :asteroid)))
(merge-pathnames "music/library/" (asdf:system-source-directory :asteroid))
"/app/music/")))
(defparameter *supported-formats* '("mp3" "flac" "ogg" "wav"))
(defparameter *stream-base-url* "http://localhost:8000")
@ -48,16 +51,16 @@
(require-authentication)
(with-error-handling
(let ((tracks (with-db-error-handling "select"
(db:select "tracks" (db:query :all)))))
(dm:get "tracks" (db:query :all)))))
(api-output `(("status" . "success")
("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(gethash "_id" track))
("title" . ,(first (gethash "title" track)))
("artist" . ,(first (gethash "artist" track)))
("album" . ,(first (gethash "album" track)))
("duration" . ,(first (gethash "duration" track)))
("format" . ,(first (gethash "format" track)))
("bitrate" . ,(first (gethash "bitrate" track)))))
`(("id" . ,(dm:id track))
("title" . ,(dm:field track "title"))
("artist" . ,(dm:field track "artist"))
("album" . ,(dm:field track "album"))
("duration" . ,(dm:field track "duration"))
("format" . ,(dm:field track "format"))
("bitrate" . ,(dm:field track "bitrate"))))
tracks)))))))
;; Playlist API endpoints
@ -66,31 +69,23 @@
(require-authentication)
(with-error-handling
(let* ((user (get-current-user))
(user-id-raw (gethash "_id" user))
(user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw))
(user-id (dm:id user))
(playlists (get-user-playlists user-id)))
(api-output `(("status" . "success")
("playlists" . ,(mapcar (lambda (playlist)
(let ((name-val (gethash "name" playlist))
(desc-val (gethash "description" playlist))
(track-ids-val (gethash "track-ids" playlist))
(created-val (gethash "created-date" playlist))
(id-val (gethash "_id" playlist)))
;; Calculate track count from comma-separated string
;; Handle nil, empty string, or list containing empty string
(let* ((track-ids-str (if (listp track-ids-val)
(first track-ids-val)
track-ids-val))
(track-count (if (and track-ids-str
(stringp track-ids-str)
(not (string= track-ids-str "")))
(length (cl-ppcre:split "," track-ids-str))
0)))
`(("id" . ,(if (listp id-val) (first id-val) id-val))
("name" . ,(if (listp name-val) (first name-val) name-val))
("description" . ,(if (listp desc-val) (first desc-val) desc-val))
("track-count" . ,track-count)
("created-date" . ,(if (listp created-val) (first created-val) created-val))))))
(let* ((track-ids (dm:field playlist "track-ids"))
;; Calculate track count from comma-separated string
;; Handle nil, empty string, or list containing empty string
(track-count (if (and track-ids
(stringp track-ids)
(not (string= track-ids "")))
(length (cl-ppcre:split "," track-ids))
0)))
`(("id" . ,(dm:id playlist))
("name" . ,(dm:field playlist "name"))
("description" . ,(dm:field playlist "description"))
("track-count" . ,track-count)
("created-date" . ,(dm:field playlist "created-date")))))
playlists)))))))
(define-api asteroid/playlists/create (name &optional description) ()
@ -98,8 +93,7 @@
(require-authentication)
(with-error-handling
(let* ((user (get-current-user))
(user-id-raw (gethash "_id" user))
(user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw)))
(user-id (dm:id user)))
(create-playlist user-id name description)
(if (string= "true" (post/get "browser"))
(redirect "/asteroid/")
@ -123,23 +117,19 @@
(let* ((id (parse-integer playlist-id :junk-allowed t))
(playlist (get-playlist-by-id id)))
(if playlist
(let* ((track-ids-raw (gethash "tracks" playlist))
(track-ids (if (listp track-ids-raw) track-ids-raw (list track-ids-raw)))
(let* ((track-ids (dm:field playlist "tracks"))
(tracks (mapcar (lambda (track-id)
(let ((track-list (db:select "tracks" (db:query (:= "_id" track-id)))))
(when (> (length track-list) 0)
(first track-list))))
(dm:get-one "tracks" (db:query (:= '_id track-id))))
track-ids))
(valid-tracks (remove nil tracks)))
(api-output `(("status" . "success")
("playlist" . (("id" . ,id)
("name" . ,(let ((n (gethash "name" playlist)))
(if (listp n) (first n) n)))
("name" . ,(dm:field playlist "name"))
("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(gethash "_id" track))
("title" . ,(gethash "title" track))
("artist" . ,(gethash "artist" track))
("album" . ,(gethash "album" track))))
`(("id" . ,(dm:id track))
("title" . ,(dm:field track "title"))
("artist" . ,(dm:field track "artist"))
("album" . ,(dm:field track "album"))))
valid-tracks)))))))
(api-output `(("status" . "error")
("message" . "Playlist not found"))
@ -151,15 +141,15 @@
(require-authentication)
(with-error-handling
(let ((tracks (with-db-error-handling "select"
(db:select "tracks" (db:query :all)))))
(dm:get "tracks" (db:query :all)))))
(api-output `(("status" . "success")
("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(gethash "_id" track))
("title" . ,(gethash "title" track))
("artist" . ,(gethash "artist" track))
("album" . ,(gethash "album" track))
("duration" . ,(gethash "duration" track))
("format" . ,(gethash "format" track))))
`(("id" . ,(dm:id track))
("title" . ,(dm:field track "title"))
("artist" . ,(dm:field track "artist"))
("album" . ,(dm:field track "album"))
("duration" . ,(dm:field track "duration"))
("format" . ,(dm:field track "format"))))
tracks)))))))
;; Stream Control API Endpoints
@ -172,9 +162,9 @@
("queue" . ,(mapcar (lambda (track-id)
(let ((track (get-track-by-id track-id)))
`(("id" . ,track-id)
("title" . ,(gethash "title" track))
("artist" . ,(gethash "artist" track))
("album" . ,(gethash "album" track)))))
("title" . ,(dm:field track "title"))
("artist" . ,(dm:field track "artist"))
("album" . ,(dm:field track "album")))))
queue)))))))
(define-api asteroid/stream/queue/add (track-id &optional (position "end")) ()
@ -234,17 +224,7 @@
(defun get-track-by-id (track-id)
"Get a track by its ID - handles type mismatches"
;; Try direct query first
(let ((tracks (db:select "tracks" (db:query (:= "_id" track-id)))))
(if (> (length tracks) 0)
(first tracks)
;; If not found, search manually (ID might be stored as list)
(let ((all-tracks (db:select "tracks" (db:query :all))))
(find-if (lambda (track)
(let ((stored-id (gethash "_id" track)))
(or (equal stored-id track-id)
(and (listp stored-id) (equal (first stored-id) track-id)))))
all-tracks)))))
(dm:get-one "tracks" (db:query (:= '_id track-id))))
(defun get-mime-type-for-format (format)
"Get MIME type for audio format"
@ -262,8 +242,8 @@
(track (get-track-by-id id)))
(unless track
(signal-not-found "track" id))
(let* ((file-path (first (gethash "file-path" track)))
(format (first (gethash "format" track)))
(let* ((file-path (dm:field track "file-path"))
(format (dm:field track "format"))
(file (probe-file file-path)))
(unless file
(error 'not-found-error
@ -275,8 +255,8 @@
(setf (radiance:header "Accept-Ranges") "bytes")
(setf (radiance:header "Cache-Control") "public, max-age=3600")
;; Increment play count
(db:update "tracks" (db:query (:= '_id id))
`(("play-count" ,(1+ (first (gethash "play-count" track))))))
(setf (dm:field track "play-count") (1+ (dm:field track "play-count")))
(data-model-save track)
;; Return file contents
(alexandria:read-file-into-byte-vector file)))))
@ -332,8 +312,8 @@
(api-output `(("status" . "success")
("message" . "Playback started")
("track" . (("id" . ,id)
("title" . ,(first (gethash "title" track)))
("artist" . ,(first (gethash "artist" track)))))
("title" . ,(dm:field track "title"))
("artist" . ,(dm:field track "artist"))))
("player" . ,(get-player-status)))))))
(define-api asteroid/player/pause () ()
@ -445,8 +425,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"
@ -464,15 +444,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"
@ -492,9 +472,85 @@
: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 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 ()
@ -523,7 +579,7 @@
"Admin dashboard"
(require-authentication)
(let ((track-count (handler-case
(length (db:select "tracks" (db:query :all)))
(length (dm:get "tracks" (db:query :all)))
(error () 0))))
(clip:process-to-string
(load-template "admin")
@ -545,7 +601,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" ()
@ -666,8 +722,7 @@
(api-output `(("loggedIn" . ,(if user t nil))
("isAdmin" . ,(if (and user (user-has-role-p user :admin)) t nil))
("username" . ,(if user
(let ((username (gethash "username" user)))
(if (listp username) (first username) username))
(dm:field user "username")
nil)))))))
;; User profile API endpoints
@ -679,11 +734,11 @@
(user (find-user-by-id user-id)))
(if user
(api-output `(("status" . "success")
("user" . (("username" . ,(first (gethash "username" user)))
("email" . ,(first (gethash "email" user)))
("role" . ,(first (gethash "role" user)))
("created_at" . ,(first (gethash "created-date" user)))
("last_active" . ,(first (gethash "last-login" user)))))))
("user" . (("username" . ,(dm:field user "username"))
("email" . ,(dm:field user "email"))
("role" . ,(dm:field user "role"))
("created_at" . ,(dm:field user "created-at"))
("last_active" . ,(dm:field user "last-active"))))))
(signal-not-found "user" user-id)))))
(define-api asteroid/user/listening-stats () ()
@ -746,8 +801,8 @@
;; Auto-login after successful registration
(let ((user (find-user-by-username username)))
(when user
(let ((user-id (gethash "_id" user)))
(setf (session:field "user-id") (if (listp user-id) (first user-id) user-id)))))
(let ((user-id (dm:id user)))
(setf (session:field "user-id") user-id))))
;; Redirect new users to their profile page
(radiance:redirect "/asteroid/profile"))
(clip:process-to-string

View File

@ -14,12 +14,11 @@
(if user
(progn
;; Login successful - store user ID in session
(format t "Login successful for user: ~a~%" (gethash "username" user))
(format t "Login successful for user: ~a~%" (dm:field user "username"))
(handler-case
(progn
(let* ((user-id (gethash "_id" user))
(user-role-raw (gethash "role" user))
(user-role (if (listp user-role-raw) (first user-role-raw) user-role-raw))
(let* ((user-id (dm:id user))
(user-role (dm:field user "role"))
(redirect-path (cond
;; Admin users go to admin dashboard
((string-equal user-role "admin") "/admin")
@ -27,7 +26,8 @@
(t "/profile"))))
(format t "User ID from DB: ~a~%" user-id)
(format t "User role: ~a, redirecting to: ~a~%" user-role redirect-path)
(setf (session:field "user-id") (if (listp user-id) (first user-id) user-id))
(setf (session:field "user-id") user-id)
(format t "User ID #~a persisted in session.~%" (session:field "user-id"))
(radiance:redirect redirect-path)))
(error (e)
(format t "Session error: ~a~%" e)
@ -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 () ()
@ -61,15 +61,13 @@
(let ((users (get-all-users)))
(api-output `(("status" . "success")
("users" . ,(mapcar (lambda (user)
`(("id" . ,(if (listp (gethash "_id" user))
(first (gethash "_id" user))
(gethash "_id" user)))
("username" . ,(first (gethash "username" user)))
("email" . ,(first (gethash "email" user)))
("role" . ,(first (gethash "role" user)))
("active" . ,(= (first (gethash "active" user)) 1))
("created-date" . ,(first (gethash "created-date" user)))
("last-login" . ,(first (gethash "last-login" user)))))
`(("id" . ,(dm:id user))
("username" . ,(dm:field user "username"))
("email" . ,(dm:field user "email"))
("role" . ,(dm:field user "role"))
("active" . ,(= (dm:field user "active") 1))
("created-date" . ,(dm:field user "created-date"))
("last-login" . ,(dm:field user "last-login"))))
users)))))
(error (e)
(api-output `(("status" . "error")
@ -120,16 +118,16 @@
(unless (>= (length new-password) 8)
(error 'validation-error :message "New password must be at least 8 characters"))
(let* ((user-id (session-field 'user-id))
(let* ((user-id (session:field "user-id"))
(username (when user-id
(let ((user (find-user-by-id user-id)))
(when user (gethash "username" user))))))
(when user (dm:field user "username"))))))
(unless username
(error 'authentication-error :message "Not authenticated"))
;; Verify current password
(unless (verify-user-credentials username current-password)
(unless (authenticate-user username current-password)
(error 'authentication-error :message "Current password is incorrect"))
;; Update password

View File

@ -1,5 +1,8 @@
;; -*-lisp-*-
(unless *load-pathname*
(error "Please LOAD this file."))
(defpackage #:asteroid-bootstrap
(:nicknames #:ab)
(:use #:cl)

View File

@ -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)
@ -109,39 +116,51 @@
(not-found-error (e)
(api-output `(("status" . "error")
("message" . ,(error-message e)))
:message (error-message e)
:status 404))
(authentication-error (e)
(api-output `(("status" . "error")
("message" . ,(error-message e)))
:message (error-message e)
:status 401))
(authorization-error (e)
(api-output `(("status" . "error")
("message" . ,(error-message e)))
:message (error-message e)
:status 403))
(validation-error (e)
(api-output `(("status" . "error")
("message" . ,(error-message e)))
:message (error-message e)
:status 400))
(database-error (e)
(format t "Database error: ~a~%" e)
(api-output `(("status" . "error")
("message" . "Database operation failed"))
:message "Database operation failed"
:status 500))
(asteroid-stream-error (e)
(format t "Stream error: ~a~%" e)
(api-output `(("status" . "error")
("message" . "Stream operation failed"))
:message "Stream operation failed"
:status 500))
(asteroid-error (e)
(format t "Asteroid error: ~a~%" e)
(api-output `(("status" . "error")
("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")
("message" . "An unexpected error occurred"))
:status 500))))
:status 500
:message "An unexpected error occurred"))))
(defmacro with-db-error-handling (operation &body body)
"Wrap database operations with error handling.
@ -149,7 +168,7 @@
Usage:
(with-db-error-handling \"select\"
(db:select 'tracks (db:query :all)))"
(dm:get 'tracks (db:query :all)))"
`(handler-case
(progn ,@body)
(error (e)

View File

@ -20,6 +20,7 @@
(db:create "playlists" '((name :text)
(description :text)
(created-date :integer)
(user-id :integer)
(track-ids :text))))
(unless (db:collection-exists-p "USERS")
@ -47,3 +48,26 @@
(format t "~2&Database collections initialized~%"))
(defun data-model-as-alist (model)
"Converts a radiance data-model instance into a alist"
(unless (dm:hull-p model)
(loop for field in (dm:fields model)
collect (cons field (dm:field model field)))))
(defun lambdalite-db-p ()
"Checks if application is using lambdalite as database backend"
(string= (string-upcase (package-name (db:implementation)))
"I-LAMBDALITE"))
(defun data-model-save (data-model)
"Wrapper on data-model save method to bypass error using dm:save on lambdalite.
It uses the same approach as dm:save under the hood through db:save."
(if (lambdalite-db-p)
(progn
(format t "Updating lambdalite collection '~a'~%" (dm:collection data-model))
(db:update (dm:collection data-model)
(db:query (:= '_id (dm:id data-model)))
(dm:field-table data-model)))
(progn
(format t "Updating database table '~a'~%" (dm:collection data-model))
(dm:save data-model))))

View File

@ -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.

View File

@ -10,32 +10,34 @@
(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")))
`((: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)
`((: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"
@ -54,7 +56,14 @@
(clip:process-to-string
(load-template "partial/now-playing")
:connection-error t
:stats nil))))))
:stats nil))))
(error ()
(format t "Error in now-playing endpoint~%")
(setf (header "Content-Type") "text/html")
(clip:process-to-string
(load-template "partial/now-playing")
:connection-error t
:stats nil))))
(define-api asteroid/partial/now-playing-inline () ()
"Get inline text with now playing info (for admin dashboard and widgets)"

33
parenscript-utils.lisp Normal file
View File

@ -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)))

660
parenscript/admin.lisp Normal file
View File

@ -0,0 +1,660 @@
;;;; 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 ()
(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*)

67
parenscript/auth-ui.lisp Normal file
View File

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

229
parenscript/front-page.lisp Normal file
View File

@ -0,0 +1,229 @@
;;;; 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))))))))))
;; 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*)

616
parenscript/player.lisp Normal file
View File

@ -0,0 +1,616 @@
;;;; 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
(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")))
;; 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*)

303
parenscript/profile.lisp Normal file
View File

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

203
parenscript/users.lisp Normal file
View File

@ -0,0 +1,203 @@
;;;; 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 "role" new-role))
(ps:chain
(fetch (+ "/api/asteroid/users/" user-id "/role")
(ps:create :method "POST" :body form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(if (= (ps:@ result status) "success")
(progn
(load-user-stats)
(alert "User role updated successfully"))
(alert (+ "Error updating user role: " (ps:@ result 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))
(ps:chain
(fetch (+ "/api/asteroid/users/" user-id "/deactivate")
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(if (= (ps:@ result status) "success")
(progn
(load-users)
(load-user-stats)
(alert "User deactivated successfully"))
(alert (+ "Error deactivating user: " (ps:@ result 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)
(ps:chain
(fetch (+ "/api/asteroid/users/" user-id "/activate")
(ps:create :method "POST"))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
(if (= (ps:@ result status) "success")
(progn
(load-users)
(load-user-stats)
(alert "User activated successfully"))
(alert (+ "Error activating user: " (ps:@ result 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*)

View File

@ -10,94 +10,72 @@
(unless (db:collection-exists-p "playlists")
(error "Playlists collection does not exist in database"))
(let ((playlist-data `(("user-id" ,user-id)
("name" ,name)
("description" ,(or description ""))
("track-ids" "") ; Empty string for text field
("created-date" ,(local-time:timestamp-to-unix (local-time:now))))))
(let ((playlist (dm:hull "playlists")))
(setf (dm:field playlist "user-id") user-id)
(setf (dm:field playlist "name") name)
(setf (dm:field playlist "description") (or description ""))
(setf (dm:field playlist "track-ids") "") ; Empty string for text field
(setf (dm:field playlist "created-date") (local-time:timestamp-to-unix (local-time:now)))
(format t "Creating playlist with user-id: ~a (type: ~a)~%" user-id (type-of user-id))
(format t "Playlist data: ~a~%" playlist-data)
(db:insert "playlists" playlist-data)
(format t "Playlist data: ~a~%" (data-model-as-alist playlist))
(dm:insert playlist)
t))
(defun get-user-playlists (user-id)
"Get all playlists for a user"
(format t "Querying playlists with user-id: ~a (type: ~a)~%" user-id (type-of user-id))
(let ((all-playlists (db:select "playlists" (db:query :all))))
(let ((all-playlists (dm:get "playlists" (db:query :all))))
(format t "Total playlists in database: ~a~%" (length all-playlists))
(when (> (length all-playlists) 0)
(let ((first-playlist (first all-playlists)))
(let* ((first-playlist (first all-playlists))
(first-playlist-user (dm:field first-playlist "user-id")))
(format t "First playlist user-id: ~a (type: ~a)~%"
(gethash "user-id" first-playlist)
(type-of (gethash "user-id" first-playlist)))))
first-playlist-user
(type-of first-playlist-user))))
;; Filter manually since DB stores user-id as a list (2) instead of 2
(remove-if-not (lambda (playlist)
(let ((stored-user-id (gethash "user-id" playlist)))
(or (equal stored-user-id user-id)
(and (listp stored-user-id)
(equal (first stored-user-id) user-id)))))
(let ((stored-user-id (dm:field playlist "user-id")))
(equal stored-user-id user-id)))
all-playlists)))
(defun get-playlist-by-id (playlist-id)
"Get a specific playlist by ID"
(format t "get-playlist-by-id called with: ~a (type: ~a)~%" playlist-id (type-of playlist-id))
;; Try direct query first
(let ((playlists (db:select "playlists" (db:query (:= "_id" playlist-id)))))
(if (> (length playlists) 0)
(progn
(format t "Found via direct query~%")
(first playlists))
;; If not found, search manually (ID might be stored as list)
(let ((all-playlists (db:select "playlists" (db:query :all))))
(format t "Searching through ~a playlists manually~%" (length all-playlists))
(find-if (lambda (playlist)
(let ((stored-id (gethash "_id" playlist)))
(format t "Checking playlist _id: ~a (type: ~a)~%" stored-id (type-of stored-id))
(or (equal stored-id playlist-id)
(and (listp stored-id) (equal (first stored-id) playlist-id)))))
all-playlists)))))
(dm:get-one "playlists" (db:query (:= '_id playlist-id))))
(defun add-track-to-playlist (playlist-id track-id)
"Add a track to a playlist"
(let ((playlist (get-playlist-by-id playlist-id)))
(when playlist
(let* ((current-track-ids-raw (gethash "track-ids" playlist))
;; Handle database storing as list - extract string
(current-track-ids (if (listp current-track-ids-raw)
(first current-track-ids-raw)
current-track-ids-raw))
;; Parse comma-separated string into list
(tracks-list (if (and current-track-ids
(stringp current-track-ids)
(not (string= current-track-ids "")))
(mapcar #'parse-integer
(cl-ppcre:split "," current-track-ids))
nil))
(new-tracks (append tracks-list (list track-id)))
;; Convert back to comma-separated string
(track-ids-str (format nil "~{~a~^,~}" new-tracks)))
(format t "Adding track ~a to playlist ~a~%" track-id playlist-id)
(format t "Current track-ids raw: ~a (type: ~a)~%" current-track-ids-raw (type-of current-track-ids-raw))
(format t "Current track-ids: ~a~%" current-track-ids)
(format t "Tracks list: ~a~%" tracks-list)
(format t "New tracks: ~a~%" new-tracks)
(format t "Track IDs string: ~a~%" track-ids-str)
;; Update using track-ids field (defined in schema)
(db:update "playlists"
(db:query (:= "_id" playlist-id))
`(("track-ids" ,track-ids-str)))
(format t "Update complete~%")
t))))
(db:with-transaction ()
(let ((playlist (get-playlist-by-id playlist-id)))
(when playlist
(let* ((current-track-ids (dm:field playlist "track-ids"))
;; Parse comma-separated string into list
(tracks-list (if (and current-track-ids
(stringp current-track-ids)
(not (string= current-track-ids "")))
(mapcar #'parse-integer
(cl-ppcre:split "," current-track-ids))
nil))
(new-tracks (append tracks-list (list track-id)))
;; Convert back to comma-separated string
(track-ids-str (format nil "~{~a~^,~}" new-tracks)))
(format t "Adding track ~a to playlist ~a~%" track-id playlist-id)
(format t "Current track-ids raw: ~a (type: ~a)~%" current-track-ids-raw (type-of current-track-ids-raw))
(format t "Current track-ids: ~a~%" current-track-ids)
(format t "Tracks list: ~a~%" tracks-list)
(format t "New tracks: ~a~%" new-tracks)
(format t "Track IDs string: ~a~%" track-ids-str)
;; Update using track-ids field (defined in schema)
(setf (dm:field playlist "track-ids") track-ids-str)
(data-model-save playlist)
(format t "Update complete~%")
t)))))
(defun remove-track-from-playlist (playlist-id track-id)
"Remove a track from a playlist"
(let ((playlist (get-playlist-by-id playlist-id)))
(when playlist
(let* ((current-track-ids-raw (gethash "track-ids" playlist))
;; Handle database storing as list - extract string
(current-track-ids (if (listp current-track-ids-raw)
(first current-track-ids-raw)
current-track-ids-raw))
(let* ((current-track-ids (dm:field playlist "track-ids"))
;; Parse comma-separated string into list
(tracks-list (if (and current-track-ids
(stringp current-track-ids)
@ -108,28 +86,11 @@
(new-tracks (remove track-id tracks-list :test #'equal))
;; Convert back to comma-separated string
(track-ids-str (format nil "~{~a~^,~}" new-tracks)))
(db:update "playlists"
(db:query (:= "_id" playlist-id))
`(("track-ids" ,track-ids-str)))
(setf (dm:field playlist "track-ids") track-ids-str)
(data-model-save playlist)
t))))
(defun delete-playlist (playlist-id)
"Delete a playlist"
(db:remove "playlists" (db:query (:= "_id" playlist-id)))
(dm:delete "playlists" (db:query (:= '_id playlist-id)))
t)
(defun ensure-playlists-collection ()
"Ensure playlists collection exists in database"
(unless (db:collection-exists-p "playlists")
(format t "Creating playlists collection...~%")
(db:create "playlists"))
;; Debug: Print the actual structure
(format t "~%=== PLAYLISTS COLLECTION STRUCTURE ===~%")
(format t "Structure: ~a~%~%" (db:structure "playlists"))
;; Debug: Check existing playlists
(let ((playlists (db:select "playlists" (db:query :all))))
(when playlists
(format t "Sample playlist fields: ~{~a~^, ~}~%~%"
(alexandria:hash-table-keys (first playlists))))))

View File

@ -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

View File

@ -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.

View File

@ -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

68
scripts/fix-m3u-paths.py Normal file
View File

@ -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()

View File

@ -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"

View 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"

View 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)"

View File

@ -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()

View File

@ -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"

222
scripts/scan.py Normal file
View File

@ -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()

BIN
static/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
static/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -45,11 +45,8 @@
"Add all tracks from a playlist to the stream queue"
(let ((playlist (get-playlist-by-id playlist-id)))
(when playlist
(let* ((track-ids-raw (gethash "track-ids" playlist))
(track-ids-str (if (listp track-ids-raw)
(first track-ids-raw)
track-ids-raw))
(track-ids (if (and track-ids-str
(let* ((track-ids-str (dm:field playlist "track-ids"))
(track-ids (if (and track-ids-str
(stringp track-ids-str)
(not (string= track-ids-str "")))
(mapcar #'parse-integer
@ -65,10 +62,7 @@
"Get the file path for a track by ID"
(let ((track (get-track-by-id track-id)))
(when track
(let ((file-path (gethash "file-path" track)))
(if (listp file-path)
(first file-path)
file-path)))))
(dm:field track "file-path"))))
(defun convert-to-docker-path (host-path)
"Convert host file path to Docker container path"
@ -101,11 +95,10 @@
(asdf:system-source-directory :asteroid))))
(if (null *stream-queue*)
;; If queue is empty, generate from all tracks (fallback)
(let ((all-tracks (db:select "tracks" (db:query :all))))
(let ((all-tracks (dm:get "tracks" (db:query :all))))
(generate-m3u-playlist
(mapcar (lambda (track)
(let ((id (gethash "_id" track)))
(if (listp id) (first id) id)))
(mapcar (lambda (track)
(dm:id track))
all-tracks)
playlist-path))
;; Generate from queue
@ -115,11 +108,8 @@
"Export a user playlist to an M3U file"
(let ((playlist (get-playlist-by-id playlist-id)))
(when playlist
(let* ((track-ids-raw (gethash "track-ids" playlist))
(track-ids-str (if (listp track-ids-raw)
(first track-ids-raw)
track-ids-raw))
(track-ids (if (and track-ids-str
(let* ((track-ids-str (dm:field playlist "track-ids"))
(track-ids (if (and track-ids-str
(stringp track-ids-str)
(not (string= track-ids-str "")))
(mapcar #'parse-integer
@ -128,7 +118,6 @@
(generate-m3u-playlist track-ids output-path)))))
;;; Stream History Management
(defun add-to-stream-history (track-id)
"Add a track to the stream history"
(push track-id *stream-history*)
@ -145,12 +134,11 @@
(defun build-smart-queue (genre &optional (count 20))
"Build a smart queue based on genre"
(let ((tracks (db:select "tracks" (db:query :all))))
(let ((tracks (dm:get "tracks" (db:query :all))))
;; For now, just add random tracks
;; TODO: Implement genre filtering when we have genre metadata
(let ((track-ids (mapcar (lambda (track)
(let ((id (gethash "_id" track)))
(if (listp id) (first id) id)))
(dm:id track))
tracks)))
(setf *stream-queue* (subseq (alexandria:shuffle track-ids)
0
@ -160,18 +148,16 @@
(defun build-queue-from-artist (artist-name &optional (count 20))
"Build a queue from tracks by a specific artist"
(let ((tracks (db:select "tracks" (db:query :all))))
(let ((tracks (dm:get "tracks" (db:query :all))))
(let ((matching-tracks
(remove-if-not
(lambda (track)
(let ((artist (gethash "artist" track)))
(let ((artist (dm:field track "artist")))
(when artist
(let ((artist-str (if (listp artist) (first artist) artist)))
(search artist-name artist-str :test #'char-equal)))))
(search artist-name artist :test #'char-equal))))
tracks)))
(let ((track-ids (mapcar (lambda (track)
(let ((id (gethash "_id" track)))
(if (listp id) (first id) id)))
(dm:id track))
matching-tracks)))
(setf *stream-queue* (subseq track-ids 0 (min count (length track-ids))))
(regenerate-stream-playlist)
@ -192,7 +178,7 @@
(let* ((m3u-path (merge-pathnames "stream-queue.m3u"
(asdf:system-source-directory :asteroid)))
(track-ids '())
(all-tracks (db:select "tracks" (db:query :all))))
(all-tracks (dm:get "tracks" (db:query :all))))
(when (probe-file m3u-path)
(with-open-file (stream m3u-path :direction :input)
@ -206,14 +192,12 @@
;; Find track by file path
(let ((track (find-if
(lambda (trk)
(let ((fp (gethash "file-path" trk)))
(let ((file-path (if (listp fp) (first fp) fp)))
(string= file-path host-path))))
(let ((file-path (dm:field trk "file-path")))
(string= file-path host-path)))
all-tracks)))
(when track
(let ((id (gethash "_id" track)))
(push (if (listp id) (first id) id) track-ids)))))))))
(push (dm:id track) track-ids))))))))
;; Reverse to maintain order from file
(setf track-ids (nreverse track-ids))
(setf *stream-queue* track-ids)

View File

@ -62,16 +62,17 @@
(defun track-exists-p (file-path)
"Check if a track with the given file path already exists in the database"
;; Try direct query first
(let ((existing (db:select "tracks" (db:query (:= "file-path" file-path)))))
(let ((existing (dm:get "tracks" (db:query (:= "file-path" file-path)))))
(if (> (length existing) 0)
t
;; If not found, search manually (file-path might be stored as list)
(let ((all-tracks (db:select "tracks" (db:query :all))))
(let ((all-tracks (dm:get "tracks" (db:query :all))))
(some (lambda (track)
(let ((stored-path (gethash "file-path" track)))
(let ((stored-path (dm:field track "file-path")))
(or (equal stored-path file-path)
(and (listp stored-path) (equal (first stored-path) file-path)))))
all-tracks)))))
all-tracks)
))))
(defun insert-track-to-database (metadata)
"Insert track metadata into database if it doesn't already exist"
@ -83,17 +84,17 @@
(let ((file-path (getf metadata :file-path)))
(if (track-exists-p file-path)
nil
(progn
(db:insert "tracks"
(list (list "title" (getf metadata :title))
(list "artist" (getf metadata :artist))
(list "album" (getf metadata :album))
(list "duration" (getf metadata :duration))
(list "file-path" file-path)
(list "format" (getf metadata :format))
(list "bitrate" (getf metadata :bitrate))
(list "added-date" (local-time:timestamp-to-unix (local-time:now)))
(list "play-count" 0)))
(let ((track (dm:hull "tracks")))
(setf (dm:field track "title") (getf metadata :title))
(setf (dm:field track "artist") (getf metadata :artist))
(setf (dm:field track "album") (getf metadata :album))
(setf (dm:field track "duration") (getf metadata :duration))
(setf (dm:field track "file-path") file-path)
(setf (dm:field track "format") (getf metadata :format))
(setf (dm:field track "bitrate") (getf metadata :bitrate))
(setf (dm:field track "added-date") (local-time:timestamp-to-unix (local-time:now)))
(setf (dm:field track "play-count") 0)
(dm:insert track)
t))))
(defun scan-music-library (&optional (directory *music-library-path*))

View File

@ -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>

View File

@ -1,9 +1,12 @@
<!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">
<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/front-page.js"></script>
@ -11,7 +14,11 @@
<body>
<div class="container">
<header>
<h1 data-text="station-name">🎵 ASTEROID RADIO 🎵</h1>
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 60px; width: auto;">
<span data-text="station-name">ASTEROID RADIO</span>
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 60px; width: auto;">
</h1>
<nav class="nav">
<a href="/asteroid/content" target="content-frame">Home</a>
<a href="/asteroid/player-content" target="content-frame">Player</a>

View File

@ -1,9 +1,12 @@
<!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">
<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/front-page.js"></script>
@ -11,7 +14,11 @@
<body>
<div class="container">
<header>
<h1 data-text="station-name">🎵 ASTEROID RADIO 🎵</h1>
<h1 style="display: flex; align-items: center; justify-content: center; gap: 15px;">
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 60px; width: auto;">
<span data-text="station-name">ASTEROID RADIO</span>
<img src="/asteroid/static/asteroid.png" alt="Asteroid" style="height: 60px; width: auto;">
</h1>
<nav class="nav">
<a href="/asteroid/">Home</a>
<a href="/asteroid/player">Player</a>

View File

@ -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>

View File

@ -10,7 +10,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>
<div class="nav">
<a href="/asteroid/content" target="content-frame">Home</a>
<a href="/asteroid/profile" target="content-frame" data-show-if-logged-in>Profile</a>

View File

@ -4,17 +4,24 @@
<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>
<a href="/asteroid/admin">Admin</a>
<a href="/asteroid/admin" data-show-if-admin>Admin</a>
<a href="/asteroid/login" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
@ -82,7 +89,7 @@
</div>
</div>
<audio id="audio-player" controls preload="none" style="width: 100%; margin: 20px 0;">
<audio id="audio-player" controls preload="none" style="width: 100%; margin: 20px 0; display: none;">
Your browser does not support the audio element.
</audio>

View File

@ -14,7 +14,7 @@
<div class="nav">
<a href="/asteroid">Home</a>
<a href="/asteroid/player">Player</a>
<a href="/asteroid/admin">Admin</a>
<a href="/asteroid/admin" data-show-if-admin>Admin</a>
<a href="/asteroid/logout" class="btn-logout">Logout</a>
</div>

View File

@ -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>

75
test-parenscript.lisp Normal file
View File

@ -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)

28
test-ps-compile.lisp Normal file
View File

@ -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~%~%")

View File

@ -9,18 +9,19 @@
;; User management functions
(defun create-user (username email password &key (role :listener) (active t))
"Create a new user account"
(let* ((password-hash (hash-password password))
(user-data `(("username" ,username)
("email" ,email)
("password-hash" ,password-hash)
("role" ,(string-downcase (symbol-name role)))
("active" ,(if active 1 0))
("created-date" ,(local-time:timestamp-to-unix (local-time:now)))
("last-login" nil))))
(let ((user (dm:hull "USERS"))
(password-hash (hash-password password)))
(setf (dm:field user "username") username
(dm:field user "email") email
(dm:field user "password-hash") password-hash
(dm:field user "role") (string-downcase (symbol-name role))
(dm:field user "active") (if active 1 0)
(dm:field user "created-date") (local-time:timestamp-to-unix (local-time:now))
(dm:field user "last-login") nil)
(handler-case
(db:with-transaction ()
(format t "Inserting user data: ~a~%" user-data)
(let ((result (db:insert "USERS" user-data)))
(format t "Inserting user data: ~a~%" user)
(let ((result (dm:insert user)))
(format t "Insert result: ~a~%" result)
(format t "User created: ~a (~a)~%" username role)
t))
@ -31,38 +32,21 @@
(defun find-user-by-username (username)
"Find a user by username"
(format t "Searching for user: ~a~%" username)
(format t "Available collections: ~a~%" (db:collections))
(format t "Trying to select from USERS collection...~%")
(let ((all-users-test (db:select "USERS" (db:query :all))))
(format t "Total users in USERS collection: ~a~%" (length all-users-test))
(dolist (user all-users-test)
(format t "User data: ~a~%" user)
(format t "Username field: ~a~%" (gethash "username" user))))
(let ((all-users (db:select "USERS" (db:query :all)))
(users nil))
(dolist (user all-users)
(format t "Comparing ~a with ~a~%" (gethash "username" user) username)
(let ((stored-username (gethash "username" user)))
(when (equal (if (listp stored-username) (first stored-username) stored-username) username)
(push user users))))
(format t "Query returned ~a users~%" (length users))
(when users
(format t "First user: ~a~%" (first users))
(first users))))
(let ((user (dm:get-one "USERS" (db:query (:= 'username username)))))
(when user
(format t "Found user '~a' with id #~a~%" username (dm:id user))
user)))
(defun find-user-by-id (user-id)
"Find a user by ID"
(format t "Looking for user with ID: ~a (type: ~a)~%" user-id (type-of user-id))
;; Handle both integer and BIT types by iterating through all users
(let ((all-users (db:select "USERS" (db:query :all)))
(target-id (if (numberp user-id) user-id (parse-integer (format nil "~a" user-id)))))
(format t "Searching through ~a users for ID ~a~%" (length all-users) target-id)
(dolist (user all-users)
(let ((db-id (gethash "_id" user)))
(format t "Checking user with _id: ~a (type: ~a)~%" db-id (type-of db-id))
(when (equal db-id target-id)
(format t "Found matching user!~%")
(return user))))))
(let ((user (dm:get-one "USERS" (db:query (:= '_id user-id)))))
(when user
(format t "Found user '~a' with id #~a~%"
(dm:field user "username")
(dm:id user))
user)))
(defun authenticate-user (username password)
"Authenticate a user with username and password"
@ -70,22 +54,23 @@
(let ((user (find-user-by-username username)))
(format t "User found: ~a~%" (if user "YES" "NO"))
(when user
(handler-case
(progn
(format t "User active: ~a~%" (gethash "active" user))
(format t "Password hash from DB: ~a~%" (gethash "password-hash" user))
(format t "Password verification: ~a~%"
(verify-password password (first (gethash "password-hash" user)))))
(error (e)
(format t "Error during user data access: ~a~%" e))))
(when (and user
(= (first (gethash "active" user)) 1)
(verify-password password (first (gethash "password-hash" user))))
;; Update last login
(db:update "USERS"
(db:query (:= "_id" (gethash "_id" user)))
`(("last-login" ,(local-time:timestamp-to-unix (local-time:now)))))
user)))
(let ((user-active (dm:field user "active"))
(user-password (dm:field user "password-hash")))
(handler-case
(progn
(format t "User active: ~a~%" user-active)
(format t "Password hash from DB: ~a~%" user-password)
(format t "Password verification: ~a~%"
(verify-password password user-password)))
(error (e)
(format t "Error during user data access: ~a~%" e)))
(when (and (= 1 user-active)
(verify-password password user-password))
;; Update last login
(setf (dm:field user "last-login") (local-time:timestamp-to-unix (local-time:now)))
;; (dm:save user)
(data-model-save user)
user)))))
(defun hash-password (password)
"Hash a password using ironclad"
@ -107,30 +92,22 @@
(if user
(handler-case
(let ((new-hash (hash-password new-password))
(user-id (gethash "_id" user)))
(user-id (dm:id user)))
(format t "Resetting password for user: ~a (ID: ~a, type: ~a)~%" username user-id (type-of user-id))
(format t "Old hash: ~a~%" (dm:field user "password-hash"))
(format t "New hash: ~a~%" new-hash)
(format t "User hash table keys: ")
(maphash (lambda (k v) (format t "~a " k)) user)
(format t "~%")
(format t "Query: ~a~%" (db:query (:= "_id" user-id)))
(format t "Update data: ~a~%" `(("password-hash" ,new-hash)))
;; Try direct update with uppercase field name to match stored case
(format t "Attempting direct update with uppercase field name...~%")
(db:update "USERS"
(db:query (:= "_id" user-id))
`(("PASSWORD-HASH" ,new-hash)))
(format t "Update complete, verifying...~%")
(setf (dm:field user "password-hash") new-hash)
;; (dm:save user)
(data-model-save user)
;; Verify the update worked
(let ((updated-user (find-user-by-username username)))
(format t "Verification - fetching user again...~%")
(let ((updated-hash (gethash "PASSWORD-HASH" updated-user)))
(let ((updated-hash (dm:field updated-user "password-hash")))
(format t "Updated password hash in DB: ~a~%" updated-hash)
(format t "Expected hash: ~a~%" new-hash)
(let ((match (if (listp updated-hash)
(string= (first updated-hash) new-hash)
(string= updated-hash new-hash))))
(format t "Match: ~a~%" match)
(let ((match (string= updated-hash new-hash)))
(format t "Password update match: ~a~%" match)
(if match
(progn
(format t "Password reset successful for user: ~a~%" username)
@ -148,9 +125,8 @@
(defun user-has-role-p (user role)
"Check if user has the specified role"
(when user
(let* ((role-field (gethash "role" user))
(role-string (if (listp role-field) (first role-field) role-field))
(user-role (intern (string-upcase role-string) :keyword)))
(let* ((role-value (dm:field user "role"))
(user-role (intern (string-upcase role-value) :keyword)))
(format t "User role: ~a, checking against: ~a~%" user-role role)
(or (eq user-role role)
(and (eq role :listener) (member user-role '(:dj :admin)))
@ -194,7 +170,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.
@ -225,12 +201,14 @@
(defun update-user-role (user-id new-role)
"Update a user's role"
(handler-case
(progn
(db:update "USERS"
(db:query (:= "_id" user-id))
`(("role" ,(string-downcase (symbol-name new-role)))))
(format t "Updated user ~a role to ~a~%" user-id new-role)
t)
(let ((user (find-user-by-id user-id)))
(if user
(progn
(setf (dm:field user "role") (string-downcase (symbol-name new-role)))
;; (dm:save user)
(data-model-save user)
t)
(format t "Could not find user with id #~a~%" user-id)))
(error (e)
(format t "Error updating user role: ~a~%" e)
nil)))
@ -238,10 +216,10 @@
(defun deactivate-user (user-id)
"Deactivate a user account"
(handler-case
(progn
(db:update "USERS"
(db:query (:= "_id" user-id))
`(("active" 0)))
(let ((user (find-user-by-id user-id)))
(setf (dm:field user "active") 0)
;; (dm:save user)
(data-model-save user)
(format t "Deactivated user ~a~%" user-id)
t)
(error (e)
@ -251,10 +229,10 @@
(defun activate-user (user-id)
"Activate a user account"
(handler-case
(progn
(db:update "USERS"
(db:query (:= "_id" user-id))
`(("active" 1)))
(let ((user (find-user-by-id user-id)))
(setf (dm:field user "active") 1)
;; (dm:save user)
(data-model-save user)
(format t "Activated user ~a~%" user-id)
t)
(error (e)
@ -264,34 +242,34 @@
(defun get-all-users ()
"Get all users from database"
(format t "Getting all users from database...~%")
(let ((users (db:select "USERS" (db:query :all))))
(let ((users (dm:get "USERS" (db:query :all))))
(format t "Total users in database: ~a~%" (length users))
(dolist (user users)
(format t "User: ~a~%" user)
(format t "User _id field: ~a (type: ~a)~%" (gethash "_id" user) (type-of (gethash "_id" user))))
(format t "User: ~a~%" (dm:field user "username"))
(format t "User _id field: ~a (type: ~a)~%" (dm:id user) (type-of (dm:id user))))
users))
(defun get-user-stats ()
"Get user statistics"
(let ((all-users (get-all-users)))
`(("total-users" . ,(length all-users))
("active-users" . ,(count-if (lambda (user) (gethash "active" user)) all-users))
("active-users" . ,(count-if (lambda (user) (= 1 (dm:field user "active"))) all-users))
("listeners" . ,(count-if (lambda (user)
(let ((role (gethash "role" user)))
(string= (if (listp role) (first role) role) "listener"))) all-users))
(let ((role (dm:field user "role")))
(string= role "listener"))) all-users))
("djs" . ,(count-if (lambda (user)
(let ((role (gethash "role" user)))
(string= (if (listp role) (first role) role) "dj"))) all-users))
(let ((role (dm:field user "role")))
(string= role "dj"))) all-users))
("admins" . ,(count-if (lambda (user)
(let ((role (gethash "role" user)))
(string= (if (listp role) (first role) role) "admin"))) all-users)))))
(let ((role (dm:field user "role")))
(string= role "admin"))) all-users)))))
(defun create-default-admin ()
"Create default admin user if no admin exists"
(let ((existing-admins (remove-if-not
(lambda (user)
(let ((role (gethash "role" user)))
(string= (if (listp role) (first role) role) "admin")))
(let ((role (dm:field user "role")))
(string= role "admin")))
(get-all-users))))
(unless existing-admins
(format t "~%Creating default admin user...~%")
@ -303,7 +281,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...~%")