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
15 changed files with 448 additions and 459 deletions

10
.gitignore vendored
View File

@ -28,6 +28,16 @@ docker/music/*.m4a
docker/music/*.aac docker/music/*.aac
docker/music/*.wma 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 build artifacts
docker/.env docker/.env
docker/.dockerignore docker/.dockerignore

View File

@ -11,11 +11,15 @@
:class "radiance:virtual-module" :class "radiance:virtual-module"
:depends-on (:slynk :depends-on (:slynk
:lparallel :lparallel
:alexandria
:cl-json
:radiance :radiance
:i-log4cl
:r-clip
:r-simple-rate
:r-simple-profile
:lass :lass
:parenscript :parenscript
:cl-json
:alexandria
:local-time :local-time
:taglib :taglib
:ironclad :ironclad
@ -24,12 +28,7 @@
:bordeaux-threads :bordeaux-threads
:drakma :drakma
;; radiance interfaces ;; radiance interfaces
:i-log4cl
;; :i-postmodern
:r-clip
:r-data-model :r-data-model
:r-simple-profile
:r-simple-rate
(:interface :auth) (:interface :auth)
(:interface :database) (:interface :database)
(:interface :relational-database) (:interface :relational-database)

View File

@ -16,8 +16,11 @@
;; configuration logic. Probably using 'ubiquity ;; configuration logic. Probably using 'ubiquity
(defparameter *server-port* 8080) (defparameter *server-port* 8080)
(defparameter *music-library-path* (defparameter *music-library-path*
(merge-pathnames "music/library/" (or (uiop:getenv "MUSIC_LIBRARY_PATH")
(asdf:system-source-directory :asteroid))) ;; 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 *supported-formats* '("mp3" "flac" "ogg" "wav"))
(defparameter *stream-base-url* "http://localhost:8000") (defparameter *stream-base-url* "http://localhost:8000")
@ -48,16 +51,16 @@
(require-authentication) (require-authentication)
(with-error-handling (with-error-handling
(let ((tracks (with-db-error-handling "select" (let ((tracks (with-db-error-handling "select"
(db:select "tracks" (db:query :all))))) (dm:get "tracks" (db:query :all)))))
(api-output `(("status" . "success") (api-output `(("status" . "success")
("tracks" . ,(mapcar (lambda (track) ("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(gethash "_id" track)) `(("id" . ,(dm:id track))
("title" . ,(first (gethash "title" track))) ("title" . ,(dm:field track "title"))
("artist" . ,(first (gethash "artist" track))) ("artist" . ,(dm:field track "artist"))
("album" . ,(first (gethash "album" track))) ("album" . ,(dm:field track "album"))
("duration" . ,(first (gethash "duration" track))) ("duration" . ,(dm:field track "duration"))
("format" . ,(first (gethash "format" track))) ("format" . ,(dm:field track "format"))
("bitrate" . ,(first (gethash "bitrate" track))))) ("bitrate" . ,(dm:field track "bitrate"))))
tracks))))))) tracks)))))))
;; Playlist API endpoints ;; Playlist API endpoints
@ -66,31 +69,23 @@
(require-authentication) (require-authentication)
(with-error-handling (with-error-handling
(let* ((user (get-current-user)) (let* ((user (get-current-user))
(user-id-raw (gethash "_id" user)) (user-id (dm:id user))
(user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw))
(playlists (get-user-playlists user-id))) (playlists (get-user-playlists user-id)))
(api-output `(("status" . "success") (api-output `(("status" . "success")
("playlists" . ,(mapcar (lambda (playlist) ("playlists" . ,(mapcar (lambda (playlist)
(let ((name-val (gethash "name" playlist)) (let* ((track-ids (dm:field playlist "track-ids"))
(desc-val (gethash "description" playlist)) ;; Calculate track count from comma-separated string
(track-ids-val (gethash "track-ids" playlist)) ;; Handle nil, empty string, or list containing empty string
(created-val (gethash "created-date" playlist)) (track-count (if (and track-ids
(id-val (gethash "_id" playlist))) (stringp track-ids)
;; Calculate track count from comma-separated string (not (string= track-ids "")))
;; Handle nil, empty string, or list containing empty string (length (cl-ppcre:split "," track-ids))
(let* ((track-ids-str (if (listp track-ids-val) 0)))
(first track-ids-val) `(("id" . ,(dm:id playlist))
track-ids-val)) ("name" . ,(dm:field playlist "name"))
(track-count (if (and track-ids-str ("description" . ,(dm:field playlist "description"))
(stringp track-ids-str) ("track-count" . ,track-count)
(not (string= track-ids-str ""))) ("created-date" . ,(dm:field playlist "created-date")))))
(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))))))
playlists))))))) playlists)))))))
(define-api asteroid/playlists/create (name &optional description) () (define-api asteroid/playlists/create (name &optional description) ()
@ -98,8 +93,7 @@
(require-authentication) (require-authentication)
(with-error-handling (with-error-handling
(let* ((user (get-current-user)) (let* ((user (get-current-user))
(user-id-raw (gethash "_id" user)) (user-id (dm:id user)))
(user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw)))
(create-playlist user-id name description) (create-playlist user-id name description)
(if (string= "true" (post/get "browser")) (if (string= "true" (post/get "browser"))
(redirect "/asteroid/") (redirect "/asteroid/")
@ -123,23 +117,19 @@
(let* ((id (parse-integer playlist-id :junk-allowed t)) (let* ((id (parse-integer playlist-id :junk-allowed t))
(playlist (get-playlist-by-id id))) (playlist (get-playlist-by-id id)))
(if playlist (if playlist
(let* ((track-ids-raw (gethash "tracks" playlist)) (let* ((track-ids (dm:field playlist "tracks"))
(track-ids (if (listp track-ids-raw) track-ids-raw (list track-ids-raw)))
(tracks (mapcar (lambda (track-id) (tracks (mapcar (lambda (track-id)
(let ((track-list (db:select "tracks" (db:query (:= "_id" track-id))))) (dm:get-one "tracks" (db:query (:= '_id track-id))))
(when (> (length track-list) 0)
(first track-list))))
track-ids)) track-ids))
(valid-tracks (remove nil tracks))) (valid-tracks (remove nil tracks)))
(api-output `(("status" . "success") (api-output `(("status" . "success")
("playlist" . (("id" . ,id) ("playlist" . (("id" . ,id)
("name" . ,(let ((n (gethash "name" playlist))) ("name" . ,(dm:field playlist "name"))
(if (listp n) (first n) n)))
("tracks" . ,(mapcar (lambda (track) ("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(gethash "_id" track)) `(("id" . ,(dm:id track))
("title" . ,(gethash "title" track)) ("title" . ,(dm:field track "title"))
("artist" . ,(gethash "artist" track)) ("artist" . ,(dm:field track "artist"))
("album" . ,(gethash "album" track)))) ("album" . ,(dm:field track "album"))))
valid-tracks))))))) valid-tracks)))))))
(api-output `(("status" . "error") (api-output `(("status" . "error")
("message" . "Playlist not found")) ("message" . "Playlist not found"))
@ -151,15 +141,15 @@
(require-authentication) (require-authentication)
(with-error-handling (with-error-handling
(let ((tracks (with-db-error-handling "select" (let ((tracks (with-db-error-handling "select"
(db:select "tracks" (db:query :all))))) (dm:get "tracks" (db:query :all)))))
(api-output `(("status" . "success") (api-output `(("status" . "success")
("tracks" . ,(mapcar (lambda (track) ("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(gethash "_id" track)) `(("id" . ,(dm:id track))
("title" . ,(gethash "title" track)) ("title" . ,(dm:field track "title"))
("artist" . ,(gethash "artist" track)) ("artist" . ,(dm:field track "artist"))
("album" . ,(gethash "album" track)) ("album" . ,(dm:field track "album"))
("duration" . ,(gethash "duration" track)) ("duration" . ,(dm:field track "duration"))
("format" . ,(gethash "format" track)))) ("format" . ,(dm:field track "format"))))
tracks))))))) tracks)))))))
;; Stream Control API Endpoints ;; Stream Control API Endpoints
@ -172,9 +162,9 @@
("queue" . ,(mapcar (lambda (track-id) ("queue" . ,(mapcar (lambda (track-id)
(let ((track (get-track-by-id track-id))) (let ((track (get-track-by-id track-id)))
`(("id" . ,track-id) `(("id" . ,track-id)
("title" . ,(gethash "title" track)) ("title" . ,(dm:field track "title"))
("artist" . ,(gethash "artist" track)) ("artist" . ,(dm:field track "artist"))
("album" . ,(gethash "album" track))))) ("album" . ,(dm:field track "album")))))
queue))))))) queue)))))))
(define-api asteroid/stream/queue/add (track-id &optional (position "end")) () (define-api asteroid/stream/queue/add (track-id &optional (position "end")) ()
@ -234,17 +224,7 @@
(defun get-track-by-id (track-id) (defun get-track-by-id (track-id)
"Get a track by its ID - handles type mismatches" "Get a track by its ID - handles type mismatches"
;; Try direct query first (dm:get-one "tracks" (db:query (:= '_id track-id))))
(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)))))
(defun get-mime-type-for-format (format) (defun get-mime-type-for-format (format)
"Get MIME type for audio format" "Get MIME type for audio format"
@ -262,8 +242,8 @@
(track (get-track-by-id id))) (track (get-track-by-id id)))
(unless track (unless track
(signal-not-found "track" id)) (signal-not-found "track" id))
(let* ((file-path (first (gethash "file-path" track))) (let* ((file-path (dm:field track "file-path"))
(format (first (gethash "format" track))) (format (dm:field track "format"))
(file (probe-file file-path))) (file (probe-file file-path)))
(unless file (unless file
(error 'not-found-error (error 'not-found-error
@ -275,8 +255,8 @@
(setf (radiance:header "Accept-Ranges") "bytes") (setf (radiance:header "Accept-Ranges") "bytes")
(setf (radiance:header "Cache-Control") "public, max-age=3600") (setf (radiance:header "Cache-Control") "public, max-age=3600")
;; Increment play count ;; Increment play count
(db:update "tracks" (db:query (:= '_id id)) (setf (dm:field track "play-count") (1+ (dm:field track "play-count")))
`(("play-count" ,(1+ (first (gethash "play-count" track)))))) (data-model-save track)
;; Return file contents ;; Return file contents
(alexandria:read-file-into-byte-vector file))))) (alexandria:read-file-into-byte-vector file)))))
@ -332,8 +312,8 @@
(api-output `(("status" . "success") (api-output `(("status" . "success")
("message" . "Playback started") ("message" . "Playback started")
("track" . (("id" . ,id) ("track" . (("id" . ,id)
("title" . ,(first (gethash "title" track))) ("title" . ,(dm:field track "title"))
("artist" . ,(first (gethash "artist" track))))) ("artist" . ,(dm:field track "artist"))))
("player" . ,(get-player-status))))))) ("player" . ,(get-player-status)))))))
(define-api asteroid/player/pause () () (define-api asteroid/player/pause () ()
@ -597,34 +577,23 @@
;; Admin page (requires authentication) ;; Admin page (requires authentication)
(define-page admin #@"/admin" () (define-page admin #@"/admin" ()
"Admin dashboard" "Admin dashboard"
(format t "~%=== ADMIN PAGE CALLED ===~%") (require-authentication)
(handler-case (let ((track-count (handler-case
(progn (length (dm:get "tracks" (db:query :all)))
(require-authentication) (error () 0))))
(format t "~%=== AUTHENTICATION PASSED ===~%")) (clip:process-to-string
(error (e) (load-template "admin")
(format t "~%ERROR IN require-authentication: ~a~%" e) :title "🎵 ASTEROID RADIO - Admin Dashboard"
(error e))) :server-status "🟢 Running"
(handler-case :database-status (handler-case
(let ((track-count (handler-case (if (db:connected-p) "🟢 Connected" "🔴 Disconnected")
(length (db:select "tracks" (db:query :all))) (error () "🔴 No Database Backend"))
(error () 0)))) :liquidsoap-status (check-liquidsoap-status)
(clip:process-to-string :icecast-status (check-icecast-status)
(load-template "admin") :track-count (format nil "~d" track-count)
:title "ASTEROID RADIO - Admin Dashboard" :library-path "/home/glenn/Projects/Code/asteroid/music/library/"
:server-status "🟢 Running" :stream-base-url *stream-base-url*
:database-status (handler-case :default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*))))
(if (db:connected-p) "🟢 Connected" "🔴 Disconnected")
(error () "🔴 No Database Backend"))
:liquidsoap-status (check-liquidsoap-status)
:icecast-status (check-icecast-status)
:track-count (format nil "~d" track-count)
:library-path "/home/glenn/Projects/Code/asteroid/music/library/"
:stream-base-url *stream-base-url*
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)))
(error (e)
(format t "~%ERROR IN ADMIN PAGE: ~a~%" e)
(error e))))
;; User Management page (requires authentication) ;; User Management page (requires authentication)
(define-page users-management #@"/admin/user" () (define-page users-management #@"/admin/user" ()
@ -753,8 +722,7 @@
(api-output `(("loggedIn" . ,(if user t nil)) (api-output `(("loggedIn" . ,(if user t nil))
("isAdmin" . ,(if (and user (user-has-role-p user :admin)) t nil)) ("isAdmin" . ,(if (and user (user-has-role-p user :admin)) t nil))
("username" . ,(if user ("username" . ,(if user
(let ((username (gethash "username" user))) (dm:field user "username")
(if (listp username) (first username) username))
nil))))))) nil)))))))
;; User profile API endpoints ;; User profile API endpoints
@ -766,11 +734,11 @@
(user (find-user-by-id user-id))) (user (find-user-by-id user-id)))
(if user (if user
(api-output `(("status" . "success") (api-output `(("status" . "success")
("user" . (("username" . ,(first (gethash "username" user))) ("user" . (("username" . ,(dm:field user "username"))
("email" . ,(first (gethash "email" user))) ("email" . ,(dm:field user "email"))
("role" . ,(first (gethash "role" user))) ("role" . ,(dm:field user "role"))
("created_at" . ,(first (gethash "created-date" user))) ("created_at" . ,(dm:field user "created-at"))
("last_active" . ,(first (gethash "last-login" user))))))) ("last_active" . ,(dm:field user "last-active"))))))
(signal-not-found "user" user-id))))) (signal-not-found "user" user-id)))))
(define-api asteroid/user/listening-stats () () (define-api asteroid/user/listening-stats () ()
@ -833,8 +801,8 @@
;; Auto-login after successful registration ;; Auto-login after successful registration
(let ((user (find-user-by-username username))) (let ((user (find-user-by-username username)))
(when user (when user
(let ((user-id (gethash "_id" user))) (let ((user-id (dm:id user)))
(setf (session:field "user-id") (if (listp user-id) (first user-id) user-id))))) (setf (session:field "user-id") user-id))))
;; Redirect new users to their profile page ;; Redirect new users to their profile page
(radiance:redirect "/asteroid/profile")) (radiance:redirect "/asteroid/profile"))
(clip:process-to-string (clip:process-to-string

View File

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

View File

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

View File

@ -116,33 +116,40 @@
(not-found-error (e) (not-found-error (e)
(api-output `(("status" . "error") (api-output `(("status" . "error")
("message" . ,(error-message e))) ("message" . ,(error-message e)))
:message (error-message e)
:status 404)) :status 404))
(authentication-error (e) (authentication-error (e)
(api-output `(("status" . "error") (api-output `(("status" . "error")
("message" . ,(error-message e))) ("message" . ,(error-message e)))
:message (error-message e)
:status 401)) :status 401))
(authorization-error (e) (authorization-error (e)
(api-output `(("status" . "error") (api-output `(("status" . "error")
("message" . ,(error-message e))) ("message" . ,(error-message e)))
:message (error-message e)
:status 403)) :status 403))
(validation-error (e) (validation-error (e)
(api-output `(("status" . "error") (api-output `(("status" . "error")
("message" . ,(error-message e))) ("message" . ,(error-message e)))
:message (error-message e)
:status 400)) :status 400))
(database-error (e) (database-error (e)
(format t "Database error: ~a~%" e) (format t "Database error: ~a~%" e)
(api-output `(("status" . "error") (api-output `(("status" . "error")
("message" . "Database operation failed")) ("message" . "Database operation failed"))
:message "Database operation failed"
:status 500)) :status 500))
(asteroid-stream-error (e) (asteroid-stream-error (e)
(format t "Stream error: ~a~%" e) (format t "Stream error: ~a~%" e)
(api-output `(("status" . "error") (api-output `(("status" . "error")
("message" . "Stream operation failed")) ("message" . "Stream operation failed"))
:message "Stream operation failed"
:status 500)) :status 500))
(asteroid-error (e) (asteroid-error (e)
(format t "Asteroid error: ~a~%" e) (format t "Asteroid error: ~a~%" e)
(api-output `(("status" . "error") (api-output `(("status" . "error")
("message" . ,(error-message e))) ("message" . ,(error-message e)))
:message (error-message e)
:status 500)) :status 500))
(stream-connectivity-error (e) (stream-connectivity-error (e)
;; For endpoints that need plain text responses (like now-playing-inline) ;; For endpoints that need plain text responses (like now-playing-inline)
@ -152,7 +159,8 @@
(format t "Unexpected error: ~a~%" e) (format t "Unexpected error: ~a~%" e)
(api-output `(("status" . "error") (api-output `(("status" . "error")
("message" . "An unexpected error occurred")) ("message" . "An unexpected error occurred"))
:status 500)))) :status 500
:message "An unexpected error occurred"))))
(defmacro with-db-error-handling (operation &body body) (defmacro with-db-error-handling (operation &body body)
"Wrap database operations with error handling. "Wrap database operations with error handling.
@ -160,7 +168,7 @@
Usage: Usage:
(with-db-error-handling \"select\" (with-db-error-handling \"select\"
(db:select 'tracks (db:query :all)))" (dm:get 'tracks (db:query :all)))"
`(handler-case `(handler-case
(progn ,@body) (progn ,@body)
(error (e) (error (e)

View File

@ -20,6 +20,7 @@
(db:create "playlists" '((name :text) (db:create "playlists" '((name :text)
(description :text) (description :text)
(created-date :integer) (created-date :integer)
(user-id :integer)
(track-ids :text)))) (track-ids :text))))
(unless (db:collection-exists-p "USERS") (unless (db:collection-exists-p "USERS")
@ -47,3 +48,26 @@
(format t "~2&Database collections initialized~%")) (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

@ -12,32 +12,32 @@
:basic-authorization '("admin" "asteroid_admin_2024")))) :basic-authorization '("admin" "asteroid_admin_2024"))))
(format t "DEBUG: Fetching Icecast stats from ~a~%" icecast-url) (format t "DEBUG: Fetching Icecast stats from ~a~%" icecast-url)
(when response (when response
(let ((xml-string (if (stringp response) (let ((xml-string (if (stringp response)
response response
(babel:octets-to-string response :encoding :utf-8)))) (babel:octets-to-string response :encoding :utf-8))))
;; Extract total listener count from root <listeners> tag (sums all mount points) ;; Extract total listener count from root <listeners> tag (sums all mount points)
;; Extract title from asteroid.mp3 mount point ;; Extract title from asteroid.mp3 mount point
(let* ((total-listeners (multiple-value-bind (match groups) (let* ((total-listeners (multiple-value-bind (match groups)
(cl-ppcre:scan-to-strings "<listeners>(\\d+)</listeners>" xml-string) (cl-ppcre:scan-to-strings "<listeners>(\\d+)</listeners>" xml-string)
(if (and match groups) (if (and match groups)
(parse-integer (aref groups 0) :junk-allowed t) (parse-integer (aref groups 0) :junk-allowed t)
0))) 0)))
;; Get title from asteroid.mp3 mount point ;; Get title from asteroid.mp3 mount point
(mount-start (cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string)) (mount-start (cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string))
(title (if mount-start (title (if mount-start
(let* ((source-section (subseq xml-string mount-start (let* ((source-section (subseq xml-string mount-start
(or (cl-ppcre:scan "</source>" xml-string :start mount-start) (or (cl-ppcre:scan "</source>" xml-string :start mount-start)
(length xml-string))))) (length xml-string)))))
(multiple-value-bind (match groups) (multiple-value-bind (match groups)
(cl-ppcre:scan-to-strings "<title>(.*?)</title>" source-section) (cl-ppcre:scan-to-strings "<title>(.*?)</title>" source-section)
(if (and match groups) (if (and match groups)
(aref groups 0) (aref groups 0)
"Unknown"))) "Unknown")))
"Unknown"))) "Unknown")))
(format t "DEBUG: Parsed title=~a, total-listeners=~a~%" title total-listeners) (format t "DEBUG: Parsed title=~a, total-listeners=~a~%" title total-listeners)
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*)) `((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
(:title . ,title) (:title . ,title)
(:listeners . ,total-listeners))))))) (:listeners . ,total-listeners)))))))
(define-api asteroid/partial/now-playing () () (define-api asteroid/partial/now-playing () ()
"Get Partial HTML with live status from Icecast server" "Get Partial HTML with live status from Icecast server"
@ -45,6 +45,8 @@
(let ((now-playing-stats (icecast-now-playing *stream-base-url*))) (let ((now-playing-stats (icecast-now-playing *stream-base-url*)))
(if now-playing-stats (if now-playing-stats
(progn (progn
;; TODO: it should be able to define a custom api-output for this
;; (api-output <clip-parser> :format "html"))
(setf (header "Content-Type") "text/html") (setf (header "Content-Type") "text/html")
(clip:process-to-string (clip:process-to-string
(load-template "partial/now-playing") (load-template "partial/now-playing")
@ -54,7 +56,14 @@
(clip:process-to-string (clip:process-to-string
(load-template "partial/now-playing") (load-template "partial/now-playing")
:connection-error t :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 () () (define-api asteroid/partial/now-playing-inline () ()
"Get inline text with now playing info (for admin dashboard and widgets)" "Get inline text with now playing info (for admin dashboard and widgets)"

View File

@ -320,11 +320,11 @@
(defun update-player-display () (defun update-player-display ()
(when *current-track* (when *current-track*
(setf (ps:chain (ps:chain document (get-element-by-id "current-title")) text-content) (setf (ps:chain (ps:chain document (get-element-by-id "current-title")) text-content)
(or (ps:@ *current-track* title 0) "Unknown Title")) (or (ps:@ *current-track* title) "Unknown Title"))
(setf (ps:chain (ps:chain document (get-element-by-id "current-artist")) text-content) (setf (ps:chain (ps:chain document (get-element-by-id "current-artist")) text-content)
(or (ps:@ *current-track* artist 0) "Unknown Artist")) (or (ps:@ *current-track* artist) "Unknown Artist"))
(setf (ps:chain (ps:chain document (get-element-by-id "current-album")) text-content) (setf (ps:chain (ps:chain document (get-element-by-id "current-album")) text-content)
(or (ps:@ *current-track* album 0) "Unknown Album")))) (or (ps:@ *current-track* album) "Unknown Album"))))
;; Add track to queue ;; Add track to queue
(defun add-to-queue (index) (defun add-to-queue (index)
@ -341,8 +341,8 @@
(map (lambda (track index) (map (lambda (track index)
(+ "<div class=\"queue-item\">" (+ "<div class=\"queue-item\">"
"<div class=\"track-info\">" "<div class=\"track-info\">"
"<div class=\"track-title\">" (or (ps:@ track title 0) "Unknown Title") "</div>" "<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>"
"<div class=\"track-meta\">" (or (ps:@ track artist 0) "Unknown Artist") "</div>" "<div class=\"track-meta\">" (or (ps:@ track artist) "Unknown Artist") "</div>"
"</div>" "</div>"
"<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>" "<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>"
"</div>"))) "</div>")))
@ -371,15 +371,17 @@
(ps:create :method "POST" :body form-data)) (ps:create :method "POST" :body form-data))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
(then (lambda (result) (then (lambda (result)
(if (== (ps:@ result status) "success") ;; Handle RADIANCE API wrapper format
(progn (let ((data (or (ps:@ result data) result)))
(alert (+ "Playlist \"" name "\" created successfully!")) (if (== (ps:@ data status) "success")
(setf (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value) "") (progn
(alert (+ "Playlist \"" name "\" created successfully!"))
;; Wait a moment then reload playlists (setf (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value) "")
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
(then (lambda () (load-playlists))))) ;; Wait a moment then reload playlists
(alert (+ "Error creating playlist: " (ps:@ result message)))))) (ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
(then (lambda () (load-playlists)))))
(alert (+ "Error creating playlist: " (ps:@ data message)))))))
(catch (lambda (error) (catch (lambda (error)
(ps:chain console (error "Error creating playlist:" error)) (ps:chain console (error "Error creating playlist:" error))
(alert (+ "Error creating playlist: " (ps:@ error message)))))))))) (alert (+ "Error creating playlist: " (ps:@ error message))))))))))
@ -398,11 +400,57 @@
(ps:create :method "POST" :body form-data)) (ps:create :method "POST" :body form-data))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
(then (lambda (create-result) (then (lambda (create-result)
(if (== (ps:@ create-result status) "success") ;; Handle RADIANCE API wrapper format
(progn (let ((create-data (or (ps:@ create-result data) create-result)))
(alert (+ "Playlist \"" name "\" created successfully!")) (if (== (ps:@ create-data status) "success")
(load-playlists)) (progn
(alert (+ "Error creating playlist: " (ps:@ create-result message)))))) ;; 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) (catch (lambda (error)
(ps:chain console (error "Error saving queue as playlist:" error)) (ps:chain console (error "Error saving queue as playlist:" error))
(alert (+ "Error saving queue as playlist: " (ps:@ error message))))))))) (alert (+ "Error saving queue as playlist: " (ps:@ error message)))))))))
@ -453,36 +501,38 @@
(ps:chain (fetch (+ "/api/asteroid/playlists/get?playlist-id=" playlist-id))) (ps:chain (fetch (+ "/api/asteroid/playlists/get?playlist-id=" playlist-id)))
(then (lambda (response) (ps:chain response (json)))) (then (lambda (response) (ps:chain response (json))))
(then (lambda (result) (then (lambda (result)
(if (and (== (ps:@ result status) "success") (ps:@ result playlist)) ;; Handle RADIANCE API wrapper format
(let ((playlist (ps:@ result playlist))) (let ((data (or (ps:@ result data) result)))
(if (and (== (ps:@ data status) "success") (ps:@ data playlist))
;; Clear current queue (let ((playlist (ps:@ data playlist)))
(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) ;; Clear current queue
(alert (+ "Loaded " (ps:@ *play-queue* length) " tracks from \"" (ps:@ playlist name) "\" into queue!")) (setf *play-queue* (array))
;; Optionally start playing the first track ;; Add all playlist tracks to queue
(when (> (ps:@ *play-queue* length) 0) (when (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0))
(let ((first-track (ps:chain *play-queue* (shift))) (ps:chain (ps:@ playlist tracks)
(track-index (ps:chain *tracks* (for-each (lambda (track)
(find-index (lambda (trk) (== (ps:@ trk id) (ps:@ first-track id)))))) ;; Find the full track object from our tracks array
) (let ((full-track (ps:chain *tracks*
(when (>= track-index 0) (find (lambda (trk) (== (ps:@ trk id) (ps:@ track id)))))))
(play-track track-index)))))) (when full-track
(when (or (not (ps:@ playlist tracks)) (== (ps:@ playlist tracks length) 0)) (setf (aref *play-queue* (ps:@ *play-queue* length)) full-track)))))
(alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
(alert (+ "Error loading playlist: " (or (ps:@ result message) "Unknown error")))))) (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) (catch (lambda (error)
(ps:chain console (error "Error loading playlist:" error)) (ps:chain console (error "Error loading playlist:" error))
(alert (+ "Error loading playlist: " (ps:@ error message))))))) (alert (+ "Error loading playlist: " (ps:@ error message)))))))
@ -558,8 +608,7 @@
(setf (ps:@ window library-next-page) library-next-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 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 change-library-tracks-per-page) change-library-tracks-per-page)
(setf (ps:@ window load-playlist) load-playlist) (setf (ps:@ window load-playlist) load-playlist)))
))
"Compiled JavaScript for web player - generated at load time") "Compiled JavaScript for web player - generated at load time")
(defun generate-player-js () (defun generate-player-js ()

View File

@ -10,94 +10,72 @@
(unless (db:collection-exists-p "playlists") (unless (db:collection-exists-p "playlists")
(error "Playlists collection does not exist in database")) (error "Playlists collection does not exist in database"))
(let ((playlist-data `(("user-id" ,user-id) (let ((playlist (dm:hull "playlists")))
("name" ,name) (setf (dm:field playlist "user-id") user-id)
("description" ,(or description "")) (setf (dm:field playlist "name") name)
("track-ids" "") ; Empty string for text field (setf (dm:field playlist "description") (or description ""))
("created-date" ,(local-time:timestamp-to-unix (local-time:now)))))) (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 "Creating playlist with user-id: ~a (type: ~a)~%" user-id (type-of user-id))
(format t "Playlist data: ~a~%" playlist-data) (format t "Playlist data: ~a~%" (data-model-as-alist playlist))
(db:insert "playlists" playlist-data) (dm:insert playlist)
t)) t))
(defun get-user-playlists (user-id) (defun get-user-playlists (user-id)
"Get all playlists for a user" "Get all playlists for a user"
(format t "Querying playlists with user-id: ~a (type: ~a)~%" user-id (type-of user-id)) (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)) (format t "Total playlists in database: ~a~%" (length all-playlists))
(when (> (length all-playlists) 0) (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)~%" (format t "First playlist user-id: ~a (type: ~a)~%"
(gethash "user-id" first-playlist) first-playlist-user
(type-of (gethash "user-id" first-playlist))))) (type-of first-playlist-user))))
;; Filter manually since DB stores user-id as a list (2) instead of 2 ;; Filter manually since DB stores user-id as a list (2) instead of 2
(remove-if-not (lambda (playlist) (remove-if-not (lambda (playlist)
(let ((stored-user-id (gethash "user-id" playlist))) (let ((stored-user-id (dm:field playlist "user-id")))
(or (equal stored-user-id user-id) (equal stored-user-id user-id)))
(and (listp stored-user-id)
(equal (first stored-user-id) user-id)))))
all-playlists))) all-playlists)))
(defun get-playlist-by-id (playlist-id) (defun get-playlist-by-id (playlist-id)
"Get a specific playlist by ID" "Get a specific playlist by ID"
(format t "get-playlist-by-id called with: ~a (type: ~a)~%" playlist-id (type-of playlist-id)) (format t "get-playlist-by-id called with: ~a (type: ~a)~%" playlist-id (type-of playlist-id))
;; Try direct query first (dm:get-one "playlists" (db:query (:= '_id playlist-id))))
(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)))))
(defun add-track-to-playlist (playlist-id track-id) (defun add-track-to-playlist (playlist-id track-id)
"Add a track to a playlist" "Add a track to a playlist"
(let ((playlist (get-playlist-by-id playlist-id))) (db:with-transaction ()
(when playlist (let ((playlist (get-playlist-by-id playlist-id)))
(let* ((current-track-ids-raw (gethash "track-ids" playlist)) (when playlist
;; Handle database storing as list - extract string (let* ((current-track-ids (dm:field playlist "track-ids"))
(current-track-ids (if (listp current-track-ids-raw) ;; Parse comma-separated string into list
(first current-track-ids-raw) (tracks-list (if (and current-track-ids
current-track-ids-raw)) (stringp current-track-ids)
;; Parse comma-separated string into list (not (string= current-track-ids "")))
(tracks-list (if (and current-track-ids (mapcar #'parse-integer
(stringp current-track-ids) (cl-ppcre:split "," current-track-ids))
(not (string= current-track-ids ""))) nil))
(mapcar #'parse-integer (new-tracks (append tracks-list (list track-id)))
(cl-ppcre:split "," current-track-ids)) ;; Convert back to comma-separated string
nil)) (track-ids-str (format nil "~{~a~^,~}" new-tracks)))
(new-tracks (append tracks-list (list track-id))) (format t "Adding track ~a to playlist ~a~%" track-id playlist-id)
;; Convert back to comma-separated string (format t "Current track-ids raw: ~a (type: ~a)~%" current-track-ids-raw (type-of current-track-ids-raw))
(track-ids-str (format nil "~{~a~^,~}" new-tracks))) (format t "Current track-ids: ~a~%" current-track-ids)
(format t "Adding track ~a to playlist ~a~%" track-id playlist-id) (format t "Tracks list: ~a~%" tracks-list)
(format t "Current track-ids raw: ~a (type: ~a)~%" current-track-ids-raw (type-of current-track-ids-raw)) (format t "New tracks: ~a~%" new-tracks)
(format t "Current track-ids: ~a~%" current-track-ids) (format t "Track IDs string: ~a~%" track-ids-str)
(format t "Tracks list: ~a~%" tracks-list) ;; Update using track-ids field (defined in schema)
(format t "New tracks: ~a~%" new-tracks) (setf (dm:field playlist "track-ids") track-ids-str)
(format t "Track IDs string: ~a~%" track-ids-str) (data-model-save playlist)
;; Update using track-ids field (defined in schema) (format t "Update complete~%")
(db:update "playlists" t)))))
(db:query (:= "_id" playlist-id))
`(("track-ids" ,track-ids-str)))
(format t "Update complete~%")
t))))
(defun remove-track-from-playlist (playlist-id track-id) (defun remove-track-from-playlist (playlist-id track-id)
"Remove a track from a playlist" "Remove a track from a playlist"
(let ((playlist (get-playlist-by-id playlist-id))) (let ((playlist (get-playlist-by-id playlist-id)))
(when playlist (when playlist
(let* ((current-track-ids-raw (gethash "track-ids" playlist)) (let* ((current-track-ids (dm:field playlist "track-ids"))
;; 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 ;; Parse comma-separated string into list
(tracks-list (if (and current-track-ids (tracks-list (if (and current-track-ids
(stringp current-track-ids) (stringp current-track-ids)
@ -108,28 +86,11 @@
(new-tracks (remove track-id tracks-list :test #'equal)) (new-tracks (remove track-id tracks-list :test #'equal))
;; Convert back to comma-separated string ;; Convert back to comma-separated string
(track-ids-str (format nil "~{~a~^,~}" new-tracks))) (track-ids-str (format nil "~{~a~^,~}" new-tracks)))
(db:update "playlists" (setf (dm:field playlist "track-ids") track-ids-str)
(db:query (:= "_id" playlist-id)) (data-model-save playlist)
`(("track-ids" ,track-ids-str)))
t)))) t))))
(defun delete-playlist (playlist-id) (defun delete-playlist (playlist-id)
"Delete a playlist" "Delete a playlist"
(db:remove "playlists" (db:query (:= "_id" playlist-id))) (dm:delete "playlists" (db:query (:= '_id playlist-id)))
t) 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

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

View File

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

View File

@ -21,7 +21,7 @@
<div class="nav"> <div class="nav">
<a href="/asteroid">Home</a> <a href="/asteroid">Home</a>
<a href="/asteroid/profile">Profile</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/login" data-show-if-logged-out>Login</a>
<a href="/asteroid/register" data-show-if-logged-out>Register</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> <a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
@ -89,7 +89,7 @@
</div> </div>
</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. Your browser does not support the audio element.
</audio> </audio>

View File

@ -14,7 +14,7 @@
<div class="nav"> <div class="nav">
<a href="/asteroid">Home</a> <a href="/asteroid">Home</a>
<a href="/asteroid/player">Player</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> <a href="/asteroid/logout" class="btn-logout">Logout</a>
</div> </div>

View File

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