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/*.wma
music/library
# music/library/*/*.mp3
# music/library/*/*.flac
# music/library/*/*.ogg
# music/library/*/*.wav
# music/library/*/*.m4a
# music/library/*/*.aac
# music/library/*/*.wma
# Docker build artifacts
docker/.env
docker/.dockerignore

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@ -45,8 +45,6 @@
(let ((now-playing-stats (icecast-now-playing *stream-base-url*)))
(if now-playing-stats
(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")
(clip:process-to-string
(load-template "partial/now-playing")
@ -56,14 +54,7 @@
(clip:process-to-string
(load-template "partial/now-playing")
:connection-error t
: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))))
:stats nil))))))
(define-api asteroid/partial/now-playing-inline () ()
"Get inline text with now playing info (for admin dashboard and widgets)"

View File

@ -320,11 +320,11 @@
(defun update-player-display ()
(when *current-track*
(setf (ps:chain (ps:chain document (get-element-by-id "current-title")) text-content)
(or (ps:@ *current-track* title) "Unknown Title"))
(or (ps:@ *current-track* title 0) "Unknown Title"))
(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)
(or (ps:@ *current-track* album) "Unknown Album"))))
(or (ps:@ *current-track* album 0) "Unknown Album"))))
;; Add track to queue
(defun add-to-queue (index)
@ -341,8 +341,8 @@
(map (lambda (track index)
(+ "<div class=\"queue-item\">"
"<div class=\"track-info\">"
"<div class=\"track-title\">" (or (ps:@ track title) "Unknown Title") "</div>"
"<div class=\"track-meta\">" (or (ps:@ track artist) "Unknown Artist") "</div>"
"<div class=\"track-title\">" (or (ps:@ track title 0) "Unknown Title") "</div>"
"<div class=\"track-meta\">" (or (ps:@ track artist 0) "Unknown Artist") "</div>"
"</div>"
"<button onclick=\"removeFromQueue(" index ")\" class=\"btn btn-sm btn-danger\">✖️</button>"
"</div>")))
@ -371,9 +371,7 @@
(ps:create :method "POST" :body form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
;; Handle RADIANCE API wrapper format
(let ((data (or (ps:@ result data) result)))
(if (== (ps:@ data status) "success")
(if (== (ps:@ result status) "success")
(progn
(alert (+ "Playlist \"" name "\" created successfully!"))
(setf (ps:chain (ps:chain document (get-element-by-id "new-playlist-name")) value) "")
@ -381,7 +379,7 @@
;; Wait a moment then reload playlists
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
(then (lambda () (load-playlists)))))
(alert (+ "Error creating playlist: " (ps:@ data message)))))))
(alert (+ "Error creating playlist: " (ps:@ result message))))))
(catch (lambda (error)
(ps:chain console (error "Error creating playlist:" error))
(alert (+ "Error creating playlist: " (ps:@ error message))))))))))
@ -400,57 +398,11 @@
(ps:create :method "POST" :body form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (create-result)
;; Handle RADIANCE API wrapper format
(let ((create-data (or (ps:@ create-result data) create-result)))
(if (== (ps:@ create-data status) "success")
(if (== (ps:@ create-result status) "success")
(progn
;; Wait a moment for database to update
(ps:chain (new "Promise" (lambda (resolve) (setTimeout resolve 500)))
(then (lambda ()
;; Get the new playlist ID by fetching playlists
(ps:chain (fetch "/api/asteroid/playlists")
(then (lambda (response) (ps:chain response (json))))
(then (lambda (playlists-result)
;; Handle RADIANCE API wrapper format
(let ((playlist-result-data (or (ps:@ playlists-result data) playlists-result)))
(if (and (== (ps:@ playlist-result-data status) "success")
(> (ps:@ playlist-result-data playlists length) 0))
(progn
;; Find the playlist with matching name (most recent)
(let ((new-playlist (or (ps:chain (ps:@ playlist-result-data playlists)
(find (lambda (p) (== (ps:@ p name) name))))
(aref (ps:@ playlist-result-data playlists)
(- (ps:@ playlist-result-data playlists length) 1)))))
;; Add all tracks from queue to playlist
(let ((added-count 0))
(ps:chain *play-queue*
(for-each (lambda (track)
(let ((track-id (ps:@ track id)))
(when track-id
(let ((add-form-data (new "FormData")))
(ps:chain add-form-data (append "playlist-id" (ps:@ new-playlist id)))
(ps:chain add-form-data (append "track-id" track-id))
(ps:chain (fetch "/api/asteroid/playlists/add-track"
(ps:create :method "POST" :body add-form-data))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (add-result)
(when (== (ps:@ add-result data status) "success")
(setf added-count (+ added-count 1)))))
(catch (lambda (err)
(ps:chain console (log "Error adding track:" err)))))))))))
(alert (+ "Playlist \"" name "\" created with " added-count " tracks!"))
(load-playlists))))
(progn
(alert (+ "Playlist created but could not add tracks. Error: "
(or (ps:@ playlist-result-data message) "Unknown")))
(load-playlists))))))
(catch (lambda (error)
(ps:chain console (error "Error fetching playlists:" error))
(alert "Playlist created but could not add tracks"))))))))
(alert (+ "Error creating playlist: " (ps:@ create-data message)))))))
(alert (+ "Playlist \"" name "\" created successfully!"))
(load-playlists))
(alert (+ "Error creating playlist: " (ps:@ create-result message))))))
(catch (lambda (error)
(ps:chain console (error "Error saving queue as playlist:" error))
(alert (+ "Error saving queue as playlist: " (ps:@ error message)))))))))
@ -501,10 +453,8 @@
(ps:chain (fetch (+ "/api/asteroid/playlists/get?playlist-id=" playlist-id)))
(then (lambda (response) (ps:chain response (json))))
(then (lambda (result)
;; Handle RADIANCE API wrapper format
(let ((data (or (ps:@ result data) result)))
(if (and (== (ps:@ data status) "success") (ps:@ data playlist))
(let ((playlist (ps:@ data playlist)))
(if (and (== (ps:@ result status) "success") (ps:@ result playlist))
(let ((playlist (ps:@ result playlist)))
;; Clear current queue
(setf *play-queue* (array))
@ -532,7 +482,7 @@
(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")))))))
(alert (+ "Error loading playlist: " (or (ps:@ result message) "Unknown error"))))))
(catch (lambda (error)
(ps:chain console (error "Error loading playlist:" error))
(alert (+ "Error loading playlist: " (ps:@ error message)))))))
@ -608,7 +558,8 @@
(setf (ps:@ window library-next-page) library-next-page)
(setf (ps:@ window library-go-to-last-page) library-go-to-last-page)
(setf (ps:@ window change-library-tracks-per-page) change-library-tracks-per-page)
(setf (ps:@ window load-playlist) load-playlist)))
(setf (ps:@ window load-playlist) load-playlist)
))
"Compiled JavaScript for web player - generated at load time")
(defun generate-player-js ()

View File

@ -10,45 +10,62 @@
(unless (db:collection-exists-p "playlists")
(error "Playlists collection does not exist in database"))
(let ((playlist (dm:hull "playlists")))
(setf (dm:field playlist "user-id") user-id)
(setf (dm:field playlist "name") name)
(setf (dm:field playlist "description") (or description ""))
(setf (dm:field playlist "track-ids") "") ; Empty string for text field
(setf (dm:field playlist "created-date") (local-time:timestamp-to-unix (local-time:now)))
(let ((playlist-data `(("user-id" ,user-id)
("name" ,name)
("description" ,(or description ""))
("track-ids" "") ; Empty string for text field
("created-date" ,(local-time:timestamp-to-unix (local-time:now))))))
(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))
(dm:insert playlist)
(format t "Playlist data: ~a~%" playlist-data)
(db:insert "playlists" playlist-data)
t))
(defun get-user-playlists (user-id)
"Get all playlists for a user"
(format t "Querying playlists with user-id: ~a (type: ~a)~%" user-id (type-of user-id))
(let ((all-playlists (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))
(when (> (length all-playlists) 0)
(let* ((first-playlist (first all-playlists))
(first-playlist-user (dm:field first-playlist "user-id")))
(let ((first-playlist (first all-playlists)))
(format t "First playlist user-id: ~a (type: ~a)~%"
first-playlist-user
(type-of first-playlist-user))))
(gethash "user-id" first-playlist)
(type-of (gethash "user-id" first-playlist)))))
;; Filter manually since DB stores user-id as a list (2) instead of 2
(remove-if-not (lambda (playlist)
(let ((stored-user-id (dm:field playlist "user-id")))
(equal stored-user-id user-id)))
(let ((stored-user-id (gethash "user-id" playlist)))
(or (equal stored-user-id user-id)
(and (listp stored-user-id)
(equal (first stored-user-id) user-id)))))
all-playlists)))
(defun get-playlist-by-id (playlist-id)
"Get a specific playlist by ID"
(format t "get-playlist-by-id called with: ~a (type: ~a)~%" playlist-id (type-of playlist-id))
(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)
"Add a track to a playlist"
(db:with-transaction ()
(let ((playlist (get-playlist-by-id playlist-id)))
(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
(tracks-list (if (and current-track-ids
(stringp current-track-ids)
@ -66,16 +83,21 @@
(format t "New tracks: ~a~%" new-tracks)
(format t "Track IDs string: ~a~%" track-ids-str)
;; Update using track-ids field (defined in schema)
(setf (dm:field playlist "track-ids") track-ids-str)
(data-model-save playlist)
(db:update "playlists"
(db:query (:= "_id" playlist-id))
`(("track-ids" ,track-ids-str)))
(format t "Update complete~%")
t)))))
t))))
(defun remove-track-from-playlist (playlist-id track-id)
"Remove a track from a playlist"
(let ((playlist (get-playlist-by-id playlist-id)))
(when playlist
(let* ((current-track-ids (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
(tracks-list (if (and current-track-ids
(stringp current-track-ids)
@ -86,11 +108,28 @@
(new-tracks (remove track-id tracks-list :test #'equal))
;; Convert back to comma-separated string
(track-ids-str (format nil "~{~a~^,~}" new-tracks)))
(setf (dm:field playlist "track-ids") track-ids-str)
(data-model-save playlist)
(db:update "playlists"
(db:query (:= "_id" playlist-id))
`(("track-ids" ,track-ids-str)))
t))))
(defun delete-playlist (playlist-id)
"Delete a playlist"
(dm:delete "playlists" (db:query (:= '_id playlist-id)))
(db:remove "playlists" (db:query (:= "_id" playlist-id)))
t)
(defun ensure-playlists-collection ()
"Ensure playlists collection exists in database"
(unless (db:collection-exists-p "playlists")
(format t "Creating playlists collection...~%")
(db:create "playlists"))
;; Debug: Print the actual structure
(format t "~%=== PLAYLISTS COLLECTION STRUCTURE ===~%")
(format t "Structure: ~a~%~%" (db:structure "playlists"))
;; Debug: Check existing playlists
(let ((playlists (db:select "playlists" (db:query :all))))
(when playlists
(format t "Sample playlist fields: ~{~a~^, ~}~%~%"
(alexandria:hash-table-keys (first playlists))))))

View File

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

View File

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

View File

@ -21,7 +21,7 @@
<div class="nav">
<a href="/asteroid">Home</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/register" data-show-if-logged-out>Register</a>
<a href="/asteroid/logout" data-show-if-logged-in class="btn-logout">Logout</a>
@ -89,7 +89,7 @@
</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.
</audio>

View File

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

View File

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