Compare commits

..

25 Commits

Author SHA1 Message Date
Glenn Thompson ba4b0d9088 chore: Add favicon images and clean up patch file 2025-11-15 18:48:55 +03:00
Glenn Thompson 1c149df62d Merge branch 'main' into glenneth/parenscript-conversion 2025-11-15 18:46:26 +03:00
Glenn Thompson 679d8f68b5 Merge branch 'main' into experiment/parenscript-conversion 2025-11-14 09:11:51 +03:00
Glenn Thompson 781b5afb28 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-14 09:11:44 +03:00
Glenn Thompson e31789704d Merge main into experiment/parenscript-conversion
- Resolved conflicts in asteroid.asd (kept parenscript dependency)
- Resolved conflicts in build-asteroid.lisp (took new bootstrap structure)
- Includes all upstream changes: uri-path fix, profile redirect fix, curated playlist, build improvements
2025-11-12 08:19:06 +03:00
Glenn Thompson 2e585e16ed 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-10 13:04:28 +03:00
Glenn Thompson d0f10a3fff Merge main into experiment/parenscript-conversion
- Resolved conflict in frontend-partials.lisp
- Kept cleaner upstream XML parsing logic from main
- Preserved debug logging from experiment branch
2025-11-10 12:44:14 +03:00
Glenn Thompson af2afefbfc 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-07 16:12:04 +03:00
Glenn Thompson db1fdbe69f 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-07 15:52:53 +03:00
Glenn Thompson 17bdf49363 Merge upstream changes into ParenScript experiment branch
- Merged latest upstream fixes including logging improvements
- Database schema bug fixes
- UI improvements (navigation and stream info)
- All conflicts resolved, ParenScript functionality preserved
2025-11-07 09:20:28 +03:00
Glenn Thompson f6c377df8d 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-07 09:17:18 +03:00
glenneth 85f3cef33d 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-06 11:51:35 +03:00
glenneth fb23ae3bf7 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-06 11:50:06 +03:00
glenneth df688fd705 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-06 11:43:53 +03:00
glenneth 3d3ef1818f 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-06 11:29:21 +03:00
glenneth 9123d40a7d 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-06 11:27:50 +03:00
glenneth b3186d60f3 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-06 11:00:24 +03:00
glenneth 82cfa9b293 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-06 10:53:02 +03:00
glenneth fd842a64af 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-06 10:29:41 +03:00
glenneth 74394f8388 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-06 10:24:20 +03:00
glenneth 7b392ec2df 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-06 10:17:14 +03:00
glenneth 484144541d 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-06 10:15:30 +03:00
glenneth 303a834ef9 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-06 10:11:04 +03:00
glenneth 5e92dab33b fix: Replace l:info with format t for logging
- l:info was called with wrong number of arguments
- Use format t for consistency with rest of codebase
2025-11-06 06:18:51 +03:00
glenneth 7830dbe334 fix: Correct database schema bugs from upstream merge
- Fix typo: :ingeger -> :integer in playlist_tracks.position
- Fix unsupported type: :timestamp -> :integer in playlist_tracks.added_date
- LambdaLite only supports :text, :integer, and :boolean types
2025-11-06 06:16:57 +03:00
15 changed files with 459 additions and 448 deletions

10
.gitignore vendored
View File

@ -28,16 +28,6 @@ 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,15 +11,11 @@
: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
@ -28,7 +24,12 @@
: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,11 +16,8 @@
;; 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*
(or (uiop:getenv "MUSIC_LIBRARY_PATH") (merge-pathnames "music/library/"
;; Default to /app/music/ for production Docker, but check if music/library/ exists for local dev (asdf:system-source-directory :asteroid)))
(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")
@ -51,16 +48,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"
(dm:get "tracks" (db:query :all))))) (db:select "tracks" (db:query :all)))))
(api-output `(("status" . "success") (api-output `(("status" . "success")
("tracks" . ,(mapcar (lambda (track) ("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(dm:id track)) `(("id" . ,(gethash "_id" track))
("title" . ,(dm:field track "title")) ("title" . ,(first (gethash "title" track)))
("artist" . ,(dm:field track "artist")) ("artist" . ,(first (gethash "artist" track)))
("album" . ,(dm:field track "album")) ("album" . ,(first (gethash "album" track)))
("duration" . ,(dm:field track "duration")) ("duration" . ,(first (gethash "duration" track)))
("format" . ,(dm:field track "format")) ("format" . ,(first (gethash "format" track)))
("bitrate" . ,(dm:field track "bitrate")))) ("bitrate" . ,(first (gethash "bitrate" track)))))
tracks))))))) tracks)))))))
;; Playlist API endpoints ;; Playlist API endpoints
@ -69,23 +66,31 @@
(require-authentication) (require-authentication)
(with-error-handling (with-error-handling
(let* ((user (get-current-user)) (let* ((user (get-current-user))
(user-id (dm:id user)) (user-id-raw (gethash "_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* ((track-ids (dm:field playlist "track-ids")) (let ((name-val (gethash "name" playlist))
;; Calculate track count from comma-separated string (desc-val (gethash "description" playlist))
;; Handle nil, empty string, or list containing empty string (track-ids-val (gethash "track-ids" playlist))
(track-count (if (and track-ids (created-val (gethash "created-date" playlist))
(stringp track-ids) (id-val (gethash "_id" playlist)))
(not (string= track-ids ""))) ;; Calculate track count from comma-separated string
(length (cl-ppcre:split "," track-ids)) ;; Handle nil, empty string, or list containing empty string
0))) (let* ((track-ids-str (if (listp track-ids-val)
`(("id" . ,(dm:id playlist)) (first track-ids-val)
("name" . ,(dm:field playlist "name")) track-ids-val))
("description" . ,(dm:field playlist "description")) (track-count (if (and track-ids-str
("track-count" . ,track-count) (stringp track-ids-str)
("created-date" . ,(dm:field playlist "created-date"))))) (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))))))
playlists))))))) playlists)))))))
(define-api asteroid/playlists/create (name &optional description) () (define-api asteroid/playlists/create (name &optional description) ()
@ -93,7 +98,8 @@
(require-authentication) (require-authentication)
(with-error-handling (with-error-handling
(let* ((user (get-current-user)) (let* ((user (get-current-user))
(user-id (dm:id user))) (user-id-raw (gethash "_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/")
@ -117,19 +123,23 @@
(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 (dm:field playlist "tracks")) (let* ((track-ids-raw (gethash "tracks" playlist))
(track-ids (if (listp track-ids-raw) track-ids-raw (list track-ids-raw)))
(tracks (mapcar (lambda (track-id) (tracks (mapcar (lambda (track-id)
(dm:get-one "tracks" (db:query (:= '_id track-id)))) (let ((track-list (db:select "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" . ,(dm:field playlist "name")) ("name" . ,(let ((n (gethash "name" playlist)))
(if (listp n) (first n) n)))
("tracks" . ,(mapcar (lambda (track) ("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(dm:id track)) `(("id" . ,(gethash "_id" track))
("title" . ,(dm:field track "title")) ("title" . ,(gethash "title" track))
("artist" . ,(dm:field track "artist")) ("artist" . ,(gethash "artist" track))
("album" . ,(dm:field track "album")))) ("album" . ,(gethash "album" track))))
valid-tracks))))))) valid-tracks)))))))
(api-output `(("status" . "error") (api-output `(("status" . "error")
("message" . "Playlist not found")) ("message" . "Playlist not found"))
@ -141,15 +151,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"
(dm:get "tracks" (db:query :all))))) (db:select "tracks" (db:query :all)))))
(api-output `(("status" . "success") (api-output `(("status" . "success")
("tracks" . ,(mapcar (lambda (track) ("tracks" . ,(mapcar (lambda (track)
`(("id" . ,(dm:id track)) `(("id" . ,(gethash "_id" track))
("title" . ,(dm:field track "title")) ("title" . ,(gethash "title" track))
("artist" . ,(dm:field track "artist")) ("artist" . ,(gethash "artist" track))
("album" . ,(dm:field track "album")) ("album" . ,(gethash "album" track))
("duration" . ,(dm:field track "duration")) ("duration" . ,(gethash "duration" track))
("format" . ,(dm:field track "format")))) ("format" . ,(gethash "format" track))))
tracks))))))) tracks)))))))
;; Stream Control API Endpoints ;; Stream Control API Endpoints
@ -162,9 +172,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" . ,(dm:field track "title")) ("title" . ,(gethash "title" track))
("artist" . ,(dm:field track "artist")) ("artist" . ,(gethash "artist" track))
("album" . ,(dm:field track "album"))))) ("album" . ,(gethash "album" track)))))
queue))))))) queue)))))))
(define-api asteroid/stream/queue/add (track-id &optional (position "end")) () (define-api asteroid/stream/queue/add (track-id &optional (position "end")) ()
@ -224,7 +234,17 @@
(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"
(dm:get-one "tracks" (db:query (:= '_id track-id)))) ;; 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)))))
(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"
@ -242,8 +262,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 (dm:field track "file-path")) (let* ((file-path (first (gethash "file-path" track)))
(format (dm:field track "format")) (format (first (gethash "format" track)))
(file (probe-file file-path))) (file (probe-file file-path)))
(unless file (unless file
(error 'not-found-error (error 'not-found-error
@ -255,8 +275,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
(setf (dm:field track "play-count") (1+ (dm:field track "play-count"))) (db:update "tracks" (db:query (:= '_id id))
(data-model-save track) `(("play-count" ,(1+ (first (gethash "play-count" track))))))
;; Return file contents ;; Return file contents
(alexandria:read-file-into-byte-vector file))))) (alexandria:read-file-into-byte-vector file)))))
@ -312,8 +332,8 @@
(api-output `(("status" . "success") (api-output `(("status" . "success")
("message" . "Playback started") ("message" . "Playback started")
("track" . (("id" . ,id) ("track" . (("id" . ,id)
("title" . ,(dm:field track "title")) ("title" . ,(first (gethash "title" track)))
("artist" . ,(dm:field track "artist")))) ("artist" . ,(first (gethash "artist" track)))))
("player" . ,(get-player-status))))))) ("player" . ,(get-player-status)))))))
(define-api asteroid/player/pause () () (define-api asteroid/player/pause () ()
@ -577,23 +597,34 @@
;; Admin page (requires authentication) ;; Admin page (requires authentication)
(define-page admin #@"/admin" () (define-page admin #@"/admin" ()
"Admin dashboard" "Admin dashboard"
(require-authentication) (format t "~%=== ADMIN PAGE CALLED ===~%")
(let ((track-count (handler-case (handler-case
(length (dm:get "tracks" (db:query :all))) (progn
(error () 0)))) (require-authentication)
(clip:process-to-string (format t "~%=== AUTHENTICATION PASSED ===~%"))
(load-template "admin") (error (e)
:title "🎵 ASTEROID RADIO - Admin Dashboard" (format t "~%ERROR IN require-authentication: ~a~%" e)
:server-status "🟢 Running" (error e)))
:database-status (handler-case (handler-case
(if (db:connected-p) "🟢 Connected" "🔴 Disconnected") (let ((track-count (handler-case
(error () "🔴 No Database Backend")) (length (db:select "tracks" (db:query :all)))
:liquidsoap-status (check-liquidsoap-status) (error () 0))))
:icecast-status (check-icecast-status) (clip:process-to-string
:track-count (format nil "~d" track-count) (load-template "admin")
:library-path "/home/glenn/Projects/Code/asteroid/music/library/" :title "ASTEROID RADIO - Admin Dashboard"
:stream-base-url *stream-base-url* :server-status "🟢 Running"
:default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)))) :database-status (handler-case
(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" ()
@ -722,7 +753,8 @@
(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
(dm:field user "username") (let ((username (gethash "username" user)))
(if (listp username) (first username) username))
nil))))))) nil)))))))
;; User profile API endpoints ;; User profile API endpoints
@ -734,11 +766,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" . ,(dm:field user "username")) ("user" . (("username" . ,(first (gethash "username" user)))
("email" . ,(dm:field user "email")) ("email" . ,(first (gethash "email" user)))
("role" . ,(dm:field user "role")) ("role" . ,(first (gethash "role" user)))
("created_at" . ,(dm:field user "created-at")) ("created_at" . ,(first (gethash "created-date" user)))
("last_active" . ,(dm:field user "last-active")))))) ("last_active" . ,(first (gethash "last-login" user)))))))
(signal-not-found "user" user-id))))) (signal-not-found "user" user-id)))))
(define-api asteroid/user/listening-stats () () (define-api asteroid/user/listening-stats () ()
@ -801,8 +833,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 (dm:id user))) (let ((user-id (gethash "_id" user)))
(setf (session:field "user-id") user-id)))) (setf (session:field "user-id") (if (listp user-id) (first 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,11 +14,12 @@
(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~%" (dm:field user "username")) (format t "Login successful for user: ~a~%" (gethash "username" user))
(handler-case (handler-case
(progn (progn
(let* ((user-id (dm:id user)) (let* ((user-id (gethash "_id" user))
(user-role (dm:field user "role")) (user-role-raw (gethash "role" user))
(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")
@ -26,8 +27,7 @@
(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") user-id) (setf (session:field "user-id") (if (listp user-id) (first 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,13 +61,15 @@
(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" . ,(dm:id user)) `(("id" . ,(if (listp (gethash "_id" user))
("username" . ,(dm:field user "username")) (first (gethash "_id" user))
("email" . ,(dm:field user "email")) (gethash "_id" user)))
("role" . ,(dm:field user "role")) ("username" . ,(first (gethash "username" user)))
("active" . ,(= (dm:field user "active") 1)) ("email" . ,(first (gethash "email" user)))
("created-date" . ,(dm:field user "created-date")) ("role" . ,(first (gethash "role" user)))
("last-login" . ,(dm:field user "last-login")))) ("active" . ,(= (first (gethash "active" user)) 1))
("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")
@ -118,16 +120,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 (dm:field user "username")))))) (when user (gethash "username" user))))))
(unless username (unless username
(error 'authentication-error :message "Not authenticated")) (error 'authentication-error :message "Not authenticated"))
;; Verify current password ;; Verify current password
(unless (authenticate-user username current-password) (unless (verify-user-credentials 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,8 +1,5 @@
;; -*-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,40 +116,33 @@
(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)
@ -159,8 +152,7 @@
(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.
@ -168,7 +160,7 @@
Usage: Usage:
(with-db-error-handling \"select\" (with-db-error-handling \"select\"
(dm:get 'tracks (db:query :all)))" (db:select 'tracks (db:query :all)))"
`(handler-case `(handler-case
(progn ,@body) (progn ,@body)
(error (e) (error (e)

View File

@ -20,7 +20,6 @@
(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")
@ -48,26 +47,3 @@
(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,8 +45,6 @@
(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")
@ -56,14 +54,7 @@
(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) "Unknown Title")) (or (ps:@ *current-track* title 0) "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) "Unknown Artist")) (or (ps:@ *current-track* artist 0) "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) "Unknown Album")))) (or (ps:@ *current-track* album 0) "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) "Unknown Title") "</div>" "<div class=\"track-title\">" (or (ps:@ track title 0) "Unknown Title") "</div>"
"<div class=\"track-meta\">" (or (ps:@ track artist) "Unknown Artist") "</div>" "<div class=\"track-meta\">" (or (ps:@ track artist 0) "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,17 +371,15 @@
(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)
;; Handle RADIANCE API wrapper format (if (== (ps:@ result status) "success")
(let ((data (or (ps:@ result data) result))) (progn
(if (== (ps:@ data status) "success") (alert (+ "Playlist \"" name "\" created successfully!"))
(progn (setf (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value) "")
(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)))
;; Wait a moment then reload playlists (then (lambda () (load-playlists)))))
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500))) (alert (+ "Error creating playlist: " (ps:@ result message))))))
(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))))))))))
@ -400,57 +398,11 @@
(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)
;; Handle RADIANCE API wrapper format (if (== (ps:@ create-result status) "success")
(let ((create-data (or (ps:@ create-result data) create-result))) (progn
(if (== (ps:@ create-data status) "success") (alert (+ "Playlist \"" name "\" created successfully!"))
(progn (load-playlists))
;; Wait a moment for database to update (alert (+ "Error creating playlist: " (ps:@ create-result message))))))
(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)))))))))
@ -501,38 +453,36 @@
(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)
;; Handle RADIANCE API wrapper format (if (and (== (ps:@ result status) "success") (ps:@ result playlist))
(let ((data (or (ps:@ result data) result))) (let ((playlist (ps:@ result playlist)))
(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)))))
;; Clear current queue (update-queue-display)
(setf *play-queue* (array)) (alert (+ "Loaded " (ps:@ *play-queue* length) " tracks from \"" (ps:@ playlist name) "\" into queue!"))
;; Add all playlist tracks to queue ;; Optionally start playing the first track
(when (and (ps:@ playlist tracks) (> (ps:@ playlist tracks length) 0)) (when (> (ps:@ *play-queue* length) 0)
(ps:chain (ps:@ playlist tracks) (let ((first-track (ps:chain *play-queue* (shift)))
(for-each (lambda (track) (track-index (ps:chain *tracks*
;; Find the full track object from our tracks array (find-index (lambda (trk) (== (ps:@ trk id) (ps:@ first-track id))))))
(let ((full-track (ps:chain *tracks* )
(find (lambda (trk) (== (ps:@ trk id) (ps:@ track id))))))) (when (>= track-index 0)
(when full-track (play-track track-index))))))
(setf (aref *play-queue* (ps:@ *play-queue* length)) full-track))))) (when (or (not (ps:@ playlist tracks)) (== (ps:@ playlist tracks length) 0))
(alert (+ "Playlist \"" (ps:@ playlist name) "\" is empty"))))
(update-queue-display) (alert (+ "Error loading playlist: " (or (ps:@ result message) "Unknown error"))))))
(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)))))))
@ -608,7 +558,8 @@
(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,72 +10,94 @@
(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 (dm:hull "playlists"))) (let ((playlist-data `(("user-id" ,user-id)
(setf (dm:field playlist "user-id") user-id) ("name" ,name)
(setf (dm:field playlist "name") name) ("description" ,(or description ""))
(setf (dm:field playlist "description") (or description "")) ("track-ids" "") ; Empty string for text field
(setf (dm:field playlist "track-ids") "") ; Empty string for text field ("created-date" ,(local-time:timestamp-to-unix (local-time:now))))))
(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~%" (data-model-as-alist playlist)) (format t "Playlist data: ~a~%" playlist-data)
(dm:insert playlist) (db:insert "playlists" playlist-data)
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 (dm:get "playlists" (db:query :all)))) (let ((all-playlists (db:select "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)~%"
first-playlist-user (gethash "user-id" first-playlist)
(type-of first-playlist-user)))) (type-of (gethash "user-id" first-playlist)))))
;; 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 (dm:field playlist "user-id"))) (let ((stored-user-id (gethash "user-id" playlist)))
(equal stored-user-id user-id))) (or (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))
(dm:get-one "playlists" (db:query (:= '_id 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)))))
(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"
(db:with-transaction () (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
;; Parse comma-separated string into list (current-track-ids (if (listp current-track-ids-raw)
(tracks-list (if (and current-track-ids (first current-track-ids-raw)
(stringp current-track-ids) current-track-ids-raw))
(not (string= current-track-ids ""))) ;; Parse comma-separated string into list
(mapcar #'parse-integer (tracks-list (if (and current-track-ids
(cl-ppcre:split "," current-track-ids)) (stringp current-track-ids)
nil)) (not (string= current-track-ids "")))
(new-tracks (append tracks-list (list track-id))) (mapcar #'parse-integer
;; Convert back to comma-separated string (cl-ppcre:split "," current-track-ids))
(track-ids-str (format nil "~{~a~^,~}" new-tracks))) nil))
(format t "Adding track ~a to playlist ~a~%" track-id playlist-id) (new-tracks (append tracks-list (list track-id)))
(format t "Current track-ids raw: ~a (type: ~a)~%" current-track-ids-raw (type-of current-track-ids-raw)) ;; Convert back to comma-separated string
(format t "Current track-ids: ~a~%" current-track-ids) (track-ids-str (format nil "~{~a~^,~}" new-tracks)))
(format t "Tracks list: ~a~%" tracks-list) (format t "Adding track ~a to playlist ~a~%" track-id playlist-id)
(format t "New tracks: ~a~%" new-tracks) (format t "Current track-ids raw: ~a (type: ~a)~%" current-track-ids-raw (type-of current-track-ids-raw))
(format t "Track IDs string: ~a~%" track-ids-str) (format t "Current track-ids: ~a~%" current-track-ids)
;; Update using track-ids field (defined in schema) (format t "Tracks list: ~a~%" tracks-list)
(setf (dm:field playlist "track-ids") track-ids-str) (format t "New tracks: ~a~%" new-tracks)
(data-model-save playlist) (format t "Track IDs string: ~a~%" track-ids-str)
(format t "Update complete~%") ;; Update using track-ids field (defined in schema)
t))))) (db:update "playlists"
(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 (dm:field playlist "track-ids")) (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 ;; 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)
@ -86,11 +108,28 @@
(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)))
(setf (dm:field playlist "track-ids") track-ids-str) (db:update "playlists"
(data-model-save playlist) (db:query (:= "_id" playlist-id))
`(("track-ids" ,track-ids-str)))
t)))) t))))
(defun delete-playlist (playlist-id) (defun delete-playlist (playlist-id)
"Delete a playlist" "Delete a playlist"
(dm:delete "playlists" (db:query (:= '_id playlist-id))) (db:remove "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,8 +45,11 @@
"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-str (dm:field playlist "track-ids")) (let* ((track-ids-raw (gethash "track-ids" playlist))
(track-ids (if (and track-ids-str (track-ids-str (if (listp track-ids-raw)
(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
@ -62,7 +65,10 @@
"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
(dm:field track "file-path")))) (let ((file-path (gethash "file-path" track)))
(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"
@ -95,10 +101,11 @@
(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 (dm:get "tracks" (db:query :all)))) (let ((all-tracks (db:select "tracks" (db:query :all))))
(generate-m3u-playlist (generate-m3u-playlist
(mapcar (lambda (track) (mapcar (lambda (track)
(dm:id track)) (let ((id (gethash "_id" track)))
(if (listp id) (first id) id)))
all-tracks) all-tracks)
playlist-path)) playlist-path))
;; Generate from queue ;; Generate from queue
@ -108,8 +115,11 @@
"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-str (dm:field playlist "track-ids")) (let* ((track-ids-raw (gethash "track-ids" playlist))
(track-ids (if (and track-ids-str (track-ids-str (if (listp track-ids-raw)
(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
@ -118,6 +128,7 @@
(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*)
@ -134,11 +145,12 @@
(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 (dm:get "tracks" (db:query :all)))) (let ((tracks (db:select "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)
(dm:id track)) (let ((id (gethash "_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
@ -148,16 +160,18 @@
(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 (dm:get "tracks" (db:query :all)))) (let ((tracks (db:select "tracks" (db:query :all))))
(let ((matching-tracks (let ((matching-tracks
(remove-if-not (remove-if-not
(lambda (track) (lambda (track)
(let ((artist (dm:field track "artist"))) (let ((artist (gethash "artist" track)))
(when artist (when artist
(search artist-name artist :test #'char-equal)))) (let ((artist-str (if (listp artist) (first artist) artist)))
(search artist-name artist-str :test #'char-equal)))))
tracks))) tracks)))
(let ((track-ids (mapcar (lambda (track) (let ((track-ids (mapcar (lambda (track)
(dm:id track)) (let ((id (gethash "_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)
@ -178,7 +192,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 (dm:get "tracks" (db:query :all)))) (all-tracks (db:select "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)
@ -192,12 +206,14 @@
;; Find track by file path ;; Find track by file path
(let ((track (find-if (let ((track (find-if
(lambda (trk) (lambda (trk)
(let ((file-path (dm:field trk "file-path"))) (let ((fp (gethash "file-path" trk)))
(string= file-path host-path))) (let ((file-path (if (listp fp) (first fp) fp)))
(string= file-path host-path))))
all-tracks))) all-tracks)))
(when track (when track
(push (dm:id track) track-ids)))))))) (let ((id (gethash "_id" track)))
(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,17 +62,16 @@
(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 (dm:get "tracks" (db:query (:= "file-path" file-path))))) (let ((existing (db:select "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 (dm:get "tracks" (db:query :all)))) (let ((all-tracks (db:select "tracks" (db:query :all))))
(some (lambda (track) (some (lambda (track)
(let ((stored-path (dm:field track "file-path"))) (let ((stored-path (gethash "file-path" track)))
(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"
@ -84,17 +83,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
(let ((track (dm:hull "tracks"))) (progn
(setf (dm:field track "title") (getf metadata :title)) (db:insert "tracks"
(setf (dm:field track "artist") (getf metadata :artist)) (list (list "title" (getf metadata :title))
(setf (dm:field track "album") (getf metadata :album)) (list "artist" (getf metadata :artist))
(setf (dm:field track "duration") (getf metadata :duration)) (list "album" (getf metadata :album))
(setf (dm:field track "file-path") file-path) (list "duration" (getf metadata :duration))
(setf (dm:field track "format") (getf metadata :format)) (list "file-path" file-path)
(setf (dm:field track "bitrate") (getf metadata :bitrate)) (list "format" (getf metadata :format))
(setf (dm:field track "added-date") (local-time:timestamp-to-unix (local-time:now))) (list "bitrate" (getf metadata :bitrate))
(setf (dm:field track "play-count") 0) (list "added-date" (local-time:timestamp-to-unix (local-time:now)))
(dm:insert track) (list "play-count" 0)))
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" data-show-if-admin>Admin</a> <a href="/asteroid/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; display: none;"> <audio id="audio-player" controls preload="none" style="width: 100%; margin: 20px 0;">
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" data-show-if-admin>Admin</a> <a href="/asteroid/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,19 +9,18 @@
;; 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 ((user (dm:hull "USERS")) (let* ((password-hash (hash-password password))
(password-hash (hash-password password))) (user-data `(("username" ,username)
(setf (dm:field user "username") username ("email" ,email)
(dm:field user "email") email ("password-hash" ,password-hash)
(dm:field user "password-hash") password-hash ("role" ,(string-downcase (symbol-name role)))
(dm:field user "role") (string-downcase (symbol-name role)) ("active" ,(if active 1 0))
(dm:field user "active") (if active 1 0) ("created-date" ,(local-time:timestamp-to-unix (local-time:now)))
(dm:field user "created-date") (local-time:timestamp-to-unix (local-time:now)) ("last-login" nil))))
(dm:field user "last-login") nil)
(handler-case (handler-case
(db:with-transaction () (db:with-transaction ()
(format t "Inserting user data: ~a~%" user) (format t "Inserting user data: ~a~%" user-data)
(let ((result (dm:insert user))) (let ((result (db:insert "USERS" user-data)))
(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))
@ -32,21 +31,38 @@
(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)
(let ((user (dm:get-one "USERS" (db:query (:= 'username username))))) (format t "Available collections: ~a~%" (db:collections))
(when user (format t "Trying to select from USERS collection...~%")
(format t "Found user '~a' with id #~a~%" username (dm:id user)) (let ((all-users-test (db:select "USERS" (db:query :all))))
user))) (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))))
(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))
(let ((user (dm:get-one "USERS" (db:query (:= '_id user-id))))) ;; Handle both integer and BIT types by iterating through all users
(when user (let ((all-users (db:select "USERS" (db:query :all)))
(format t "Found user '~a' with id #~a~%" (target-id (if (numberp user-id) user-id (parse-integer (format nil "~a" user-id)))))
(dm:field user "username") (format t "Searching through ~a users for ID ~a~%" (length all-users) target-id)
(dm:id user)) (dolist (user all-users)
user))) (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))))))
(defun authenticate-user (username password) (defun authenticate-user (username password)
"Authenticate a user with username and password" "Authenticate a user with username and password"
@ -54,23 +70,22 @@
(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
(let ((user-active (dm:field user "active")) (handler-case
(user-password (dm:field user "password-hash"))) (progn
(handler-case (format t "User active: ~a~%" (gethash "active" user))
(progn (format t "Password hash from DB: ~a~%" (gethash "password-hash" user))
(format t "User active: ~a~%" user-active) (format t "Password verification: ~a~%"
(format t "Password hash from DB: ~a~%" user-password) (verify-password password (first (gethash "password-hash" user)))))
(format t "Password verification: ~a~%" (error (e)
(verify-password password user-password))) (format t "Error during user data access: ~a~%" e))))
(error (e) (when (and user
(format t "Error during user data access: ~a~%" e))) (= (first (gethash "active" user)) 1)
(when (and (= 1 user-active) (verify-password password (first (gethash "password-hash" user))))
(verify-password password user-password)) ;; Update last login
;; Update last login (db:update "USERS"
(setf (dm:field user "last-login") (local-time:timestamp-to-unix (local-time:now))) (db:query (:= "_id" (gethash "_id" user)))
;; (dm:save user) `(("last-login" ,(local-time:timestamp-to-unix (local-time:now)))))
(data-model-save user) user)))
user)))))
(defun hash-password (password) (defun hash-password (password)
"Hash a password using ironclad" "Hash a password using ironclad"
@ -92,22 +107,30 @@
(if user (if user
(handler-case (handler-case
(let ((new-hash (hash-password new-password)) (let ((new-hash (hash-password new-password))
(user-id (dm:id user))) (user-id (gethash "_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
(setf (dm:field user "password-hash") new-hash) (format t "Attempting direct update with uppercase field name...~%")
;; (dm:save user) (db:update "USERS"
(data-model-save user) (db:query (:= "_id" user-id))
`(("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 (dm:field updated-user "password-hash"))) (let ((updated-hash (gethash "PASSWORD-HASH" updated-user)))
(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 (string= updated-hash new-hash))) (let ((match (if (listp updated-hash)
(format t "Password update match: ~a~%" match) (string= (first updated-hash) new-hash)
(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)
@ -125,8 +148,9 @@
(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-value (dm:field user "role")) (let* ((role-field (gethash "role" user))
(user-role (intern (string-upcase role-value) :keyword))) (role-string (if (listp role-field) (first role-field) role-field))
(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)))
@ -201,14 +225,12 @@
(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
(let ((user (find-user-by-id user-id))) (progn
(if user (db:update "USERS"
(progn (db:query (:= "_id" user-id))
(setf (dm:field user "role") (string-downcase (symbol-name new-role))) `(("role" ,(string-downcase (symbol-name new-role)))))
;; (dm:save user) (format t "Updated user ~a role to ~a~%" user-id new-role)
(data-model-save user) t)
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)))
@ -216,10 +238,10 @@
(defun deactivate-user (user-id) (defun deactivate-user (user-id)
"Deactivate a user account" "Deactivate a user account"
(handler-case (handler-case
(let ((user (find-user-by-id user-id))) (progn
(setf (dm:field user "active") 0) (db:update "USERS"
;; (dm:save user) (db:query (:= "_id" user-id))
(data-model-save user) `(("active" 0)))
(format t "Deactivated user ~a~%" user-id) (format t "Deactivated user ~a~%" user-id)
t) t)
(error (e) (error (e)
@ -229,10 +251,10 @@
(defun activate-user (user-id) (defun activate-user (user-id)
"Activate a user account" "Activate a user account"
(handler-case (handler-case
(let ((user (find-user-by-id user-id))) (progn
(setf (dm:field user "active") 1) (db:update "USERS"
;; (dm:save user) (db:query (:= "_id" user-id))
(data-model-save user) `(("active" 1)))
(format t "Activated user ~a~%" user-id) (format t "Activated user ~a~%" user-id)
t) t)
(error (e) (error (e)
@ -242,41 +264,44 @@
(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 (dm:get "USERS" (db:query :all)))) (let ((users (db:select "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~%" (dm:field user "username")) (format t "User: ~a~%" user)
(format t "User _id field: ~a (type: ~a)~%" (dm:id user) (type-of (dm:id user)))) (format t "User _id field: ~a (type: ~a)~%" (gethash "_id" user) (type-of (gethash "_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) (= 1 (dm:field user "active"))) all-users)) ("active-users" . ,(count-if (lambda (user) (gethash "active" user)) all-users))
("listeners" . ,(count-if (lambda (user) ("listeners" . ,(count-if (lambda (user)
(let ((role (dm:field user "role"))) (let ((role (gethash "role" user)))
(string= role "listener"))) all-users)) (string= (if (listp role) (first role) role) "listener"))) all-users))
("djs" . ,(count-if (lambda (user) ("djs" . ,(count-if (lambda (user)
(let ((role (dm:field user "role"))) (let ((role (gethash "role" user)))
(string= role "dj"))) all-users)) (string= (if (listp role) (first role) role) "dj"))) all-users))
("admins" . ,(count-if (lambda (user) ("admins" . ,(count-if (lambda (user)
(let ((role (dm:field user "role"))) (let ((role (gethash "role" user)))
(string= role "admin"))) all-users))))) (string= (if (listp role) (first role) 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"
(let ((existing-admins (remove-if-not (handler-case
(lambda (user) (let ((existing-admins (remove-if-not
(let ((role (dm:field user "role"))) (lambda (user)
(string= role "admin"))) (let ((role (gethash "role" user)))
(get-all-users)))) (string= (if (listp role) (first role) role) "admin")))
(unless existing-admins (get-all-users))))
(format t "~%Creating default admin user...~%") (unless existing-admins
(format t "Username: admin~%") (format t "~%Creating default admin user...~%")
(format t "Password: asteroid123~%") (format t "Username: admin~%")
(format t "Please change this password after first login!~%~%") (format t "Password: asteroid123~%")
(create-user "admin" "admin@asteroid.radio" "asteroid123" :role :admin :active t)))) (format t "Please change this password after first login!~%~%")
(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"