Compare commits
25 Commits
6bbc3d0b6a
...
ba4b0d9088
| Author | SHA1 | Date |
|---|---|---|
|
|
ba4b0d9088 | |
|
|
1c149df62d | |
|
|
679d8f68b5 | |
|
|
781b5afb28 | |
|
|
e31789704d | |
|
|
2e585e16ed | |
|
|
d0f10a3fff | |
|
|
af2afefbfc | |
|
|
db1fdbe69f | |
|
|
17bdf49363 | |
|
|
f6c377df8d | |
|
|
85f3cef33d | |
|
|
fb23ae3bf7 | |
|
|
df688fd705 | |
|
|
3d3ef1818f | |
|
|
9123d40a7d | |
|
|
b3186d60f3 | |
|
|
82cfa9b293 | |
|
|
fd842a64af | |
|
|
74394f8388 | |
|
|
7b392ec2df | |
|
|
484144541d | |
|
|
303a834ef9 | |
|
|
5e92dab33b | |
|
|
7830dbe334 |
|
|
@ -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
|
||||||
|
|
|
||||||
13
asteroid.asd
13
asteroid.asd
|
|
@ -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)
|
||||||
|
|
|
||||||
186
asteroid.lisp
186
asteroid.lisp
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))))
|
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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 ()
|
||||||
|
|
|
||||||
|
|
@ -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))))))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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*))
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue