diff --git a/asteroid.asd b/asteroid.asd
index 3c2ec23..a4140ae 100644
--- a/asteroid.asd
+++ b/asteroid.asd
@@ -33,6 +33,7 @@
:pathname "./"
:components ((:file "app-utils")
(:file "module")
+ (:file "conditions")
(:file "database")
(:file "template-utils")
(:file "stream-media")
diff --git a/asteroid.lisp b/asteroid.lisp
index 211ad63..2ba7a0b 100644
--- a/asteroid.lisp
+++ b/asteroid.lisp
@@ -37,258 +37,200 @@
(define-api asteroid/admin/scan-library () ()
"API endpoint to scan music library"
(require-role :admin)
- (handler-case
- (let ((tracks-added (scan-music-library)))
- (api-output `(("status" . "success")
- ("message" . "Library scan completed")
- ("tracks-added" . ,tracks-added))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Scan failed: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let ((tracks-added (scan-music-library)))
+ (api-output `(("status" . "success")
+ ("message" . "Library scan completed")
+ ("tracks-added" . ,tracks-added))))))
(define-api asteroid/admin/tracks () ()
"API endpoint to view all tracks in database"
(require-authentication)
- (handler-case
- (let ((tracks (db:select "tracks" (db:query :all))))
- (api-output `(("status" . "success")
- ("tracks" . ,(mapcar (lambda (track)
- `(("id" . ,(gethash "_id" track))
- ("title" . ,(first (gethash "title" track)))
- ("artist" . ,(first (gethash "artist" track)))
- ("album" . ,(first (gethash "album" track)))
- ("duration" . ,(first (gethash "duration" track)))
- ("format" . ,(first (gethash "format" track)))
- ("bitrate" . ,(first (gethash "bitrate" track)))))
- tracks)))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error retrieving tracks: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let ((tracks (with-db-error-handling "select"
+ (db:select "tracks" (db:query :all)))))
+ (api-output `(("status" . "success")
+ ("tracks" . ,(mapcar (lambda (track)
+ `(("id" . ,(gethash "_id" track))
+ ("title" . ,(first (gethash "title" track)))
+ ("artist" . ,(first (gethash "artist" track)))
+ ("album" . ,(first (gethash "album" track)))
+ ("duration" . ,(first (gethash "duration" track)))
+ ("format" . ,(first (gethash "format" track)))
+ ("bitrate" . ,(first (gethash "bitrate" track)))))
+ tracks)))))))
;; Playlist API endpoints
(define-api asteroid/playlists () ()
"Get all playlists for current user"
(require-authentication)
- (handler-case
- (let* ((user (get-current-user))
- (user-id-raw (gethash "_id" user))
- (user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw))
- (playlists (get-user-playlists user-id)))
- (api-output `(("status" . "success")
- ("playlists" . ,(mapcar (lambda (playlist)
- (let ((name-val (gethash "name" playlist))
- (desc-val (gethash "description" playlist))
- (track-ids-val (gethash "track-ids" playlist))
- (created-val (gethash "created-date" playlist))
- (id-val (gethash "_id" playlist)))
- ;; Calculate track count from comma-separated string
- ;; Handle nil, empty string, or list containing empty string
- (let* ((track-ids-str (if (listp track-ids-val)
- (first track-ids-val)
- track-ids-val))
- (track-count (if (and track-ids-str
- (stringp track-ids-str)
- (not (string= track-ids-str "")))
- (length (cl-ppcre:split "," track-ids-str))
- 0)))
- `(("id" . ,(if (listp id-val) (first id-val) id-val))
- ("name" . ,(if (listp name-val) (first name-val) name-val))
- ("description" . ,(if (listp desc-val) (first desc-val) desc-val))
- ("track-count" . ,track-count)
- ("created-date" . ,(if (listp created-val) (first created-val) created-val))))))
- playlists)))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error retrieving playlists: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let* ((user (get-current-user))
+ (user-id-raw (gethash "_id" user))
+ (user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw))
+ (playlists (get-user-playlists user-id)))
+ (api-output `(("status" . "success")
+ ("playlists" . ,(mapcar (lambda (playlist)
+ (let ((name-val (gethash "name" playlist))
+ (desc-val (gethash "description" playlist))
+ (track-ids-val (gethash "track-ids" playlist))
+ (created-val (gethash "created-date" playlist))
+ (id-val (gethash "_id" playlist)))
+ ;; Calculate track count from comma-separated string
+ ;; Handle nil, empty string, or list containing empty string
+ (let* ((track-ids-str (if (listp track-ids-val)
+ (first track-ids-val)
+ track-ids-val))
+ (track-count (if (and track-ids-str
+ (stringp track-ids-str)
+ (not (string= track-ids-str "")))
+ (length (cl-ppcre:split "," track-ids-str))
+ 0)))
+ `(("id" . ,(if (listp id-val) (first id-val) id-val))
+ ("name" . ,(if (listp name-val) (first name-val) name-val))
+ ("description" . ,(if (listp desc-val) (first desc-val) desc-val))
+ ("track-count" . ,track-count)
+ ("created-date" . ,(if (listp created-val) (first created-val) created-val))))))
+ playlists)))))))
(define-api asteroid/playlists/create (name &optional description) ()
"Create a new playlist"
(require-authentication)
- (handler-case
- (let* ((user (get-current-user))
- (user-id-raw (gethash "_id" user))
- (user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw)))
- (create-playlist user-id name description)
- (if (string= "true" (post/get "browser"))
- (redirect "/asteroid/")
- (api-output `(("status" . "success")
- ("message" . "Playlist created successfully")))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error creating playlist: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let* ((user (get-current-user))
+ (user-id-raw (gethash "_id" user))
+ (user-id (if (listp user-id-raw) (first user-id-raw) user-id-raw)))
+ (create-playlist user-id name description)
+ (if (string= "true" (post/get "browser"))
+ (redirect "/asteroid/")
+ (api-output `(("status" . "success")
+ ("message" . "Playlist created successfully")))))))
(define-api asteroid/playlists/add-track (playlist-id track-id) ()
"Add a track to a playlist"
(require-authentication)
- (handler-case
- (let ((pl-id (parse-integer playlist-id :junk-allowed t))
- (tr-id (parse-integer track-id :junk-allowed t)))
- (add-track-to-playlist pl-id tr-id)
- (if (string= "true" (post/get "browser"))
- (redirect "/asteroid/")
- (api-output `(("status" . "success")
- ("message" . "Track added to playlist")))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error adding track: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let ((pl-id (parse-integer playlist-id :junk-allowed t))
+ (tr-id (parse-integer track-id :junk-allowed t)))
+ (add-track-to-playlist pl-id tr-id)
+ (api-output `(("status" . "success")
+ ("message" . "Track added to playlist"))))))
(define-api asteroid/playlists/get (playlist-id) ()
"Get playlist details with tracks"
(require-authentication)
- (handler-case
- (let* ((id (parse-integer playlist-id :junk-allowed t))
- (playlist (get-playlist-by-id id)))
- (if playlist
- (let* ((track-ids-raw (gethash "tracks" playlist))
- (track-ids (if (listp track-ids-raw) track-ids-raw (list track-ids-raw)))
- (tracks (mapcar (lambda (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" . ,(let ((n (gethash "name" playlist)))
- (if (listp n) (first n) n)))
- ("tracks" . ,(mapcar (lambda (track)
- `(("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"))
- :status 404)))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error retrieving playlist: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let* ((id (parse-integer playlist-id :junk-allowed t))
+ (playlist (get-playlist-by-id id)))
+ (if playlist
+ (let* ((track-ids-raw (gethash "tracks" playlist))
+ (track-ids (if (listp track-ids-raw) track-ids-raw (list track-ids-raw)))
+ (tracks (mapcar (lambda (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" . ,(let ((n (gethash "name" playlist)))
+ (if (listp n) (first n) n)))
+ ("tracks" . ,(mapcar (lambda (track)
+ `(("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"))
+ :status 404)))))
;; API endpoint to get all tracks (for web player)
(define-api asteroid/tracks () ()
"Get all tracks for web player"
(require-authentication)
- (handler-case
- (let ((tracks (db:select "tracks" (db:query :all))))
- (api-output `(("status" . "success")
- ("tracks" . ,(mapcar (lambda (track)
- `(("id" . ,(gethash "_id" track))
- ("title" . ,(gethash "title" track))
- ("artist" . ,(gethash "artist" track))
- ("album" . ,(gethash "album" track))
- ("duration" . ,(gethash "duration" track))
- ("format" . ,(gethash "format" track))))
- tracks)))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error retrieving tracks: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let ((tracks (with-db-error-handling "select"
+ (db:select "tracks" (db:query :all)))))
+ (api-output `(("status" . "success")
+ ("tracks" . ,(mapcar (lambda (track)
+ `(("id" . ,(gethash "_id" track))
+ ("title" . ,(gethash "title" track))
+ ("artist" . ,(gethash "artist" track))
+ ("album" . ,(gethash "album" track))
+ ("duration" . ,(gethash "duration" track))
+ ("format" . ,(gethash "format" track))))
+ tracks)))))))
;; Stream Control API Endpoints
(define-api asteroid/stream/queue () ()
"Get the current stream queue"
(require-role :admin)
- (handler-case
- (let ((queue (get-stream-queue)))
- (api-output `(("status" . "success")
- ("queue" . ,(mapcar (lambda (track-id)
- (let ((track (get-track-by-id track-id)))
- (when track
- `(("id" . ,track-id)
- ("title" . ,(gethash "title" track))
- ("artist" . ,(gethash "artist" track))
- ("album" . ,(gethash "album" track))))))
- queue)))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error getting queue: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let ((queue (get-stream-queue)))
+ (api-output `(("status" . "success")
+ ("queue" . ,(mapcar (lambda (track-id)
+ (let ((track (get-track-by-id track-id)))
+ `(("id" . ,track-id)
+ ("title" . ,(gethash "title" track))
+ ("artist" . ,(gethash "artist" track))
+ ("album" . ,(gethash "album" track)))))
+ queue)))))))
(define-api asteroid/stream/queue/add (track-id &optional (position "end")) ()
"Add a track to the stream queue"
(require-role :admin)
- (handler-case
- (let ((tr-id (parse-integer track-id :junk-allowed t))
- (pos (if (string= position "next") :next :end)))
- (add-to-stream-queue tr-id pos)
- (api-output `(("status" . "success")
- ("message" . "Track added to stream queue"))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error adding to queue: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let ((tr-id (parse-integer track-id :junk-allowed t))
+ (pos (if (string= position "next") :next :end)))
+ (add-to-stream-queue tr-id pos)
+ (api-output `(("status" . "success")
+ ("message" . "Track added to stream queue"))))))
(define-api asteroid/stream/queue/remove (track-id) ()
"Remove a track from the stream queue"
(require-role :admin)
- (handler-case
- (let ((tr-id (parse-integer track-id :junk-allowed t)))
- (remove-from-stream-queue tr-id)
- (api-output `(("status" . "success")
- ("message" . "Track removed from stream queue"))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error removing from queue: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let ((tr-id (parse-integer track-id :junk-allowed t)))
+ (remove-from-stream-queue tr-id)
+ (api-output `(("status" . "success")
+ ("message" . "Track removed from stream queue"))))))
(define-api asteroid/stream/queue/clear () ()
"Clear the entire stream queue"
(require-role :admin)
- (handler-case
- (progn
- (clear-stream-queue)
- (api-output `(("status" . "success")
- ("message" . "Stream queue cleared"))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error clearing queue: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (clear-stream-queue)
+ (api-output `(("status" . "success")
+ ("message" . "Stream queue cleared")))))
(define-api asteroid/stream/queue/add-playlist (playlist-id) ()
"Add all tracks from a playlist to the stream queue"
(require-role :admin)
- (handler-case
- (let ((pl-id (parse-integer playlist-id :junk-allowed t)))
- (add-playlist-to-stream-queue pl-id)
- (api-output `(("status" . "success")
- ("message" . "Playlist added to stream queue"))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error adding playlist to queue: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let ((pl-id (parse-integer playlist-id :junk-allowed t)))
+ (add-playlist-to-stream-queue pl-id)
+ (api-output `(("status" . "success")
+ ("message" . "Playlist added to stream queue"))))))
(define-api asteroid/stream/queue/reorder (track-ids) ()
"Reorder the stream queue (expects comma-separated track IDs)"
(require-role :admin)
- (handler-case
- (let ((ids (mapcar (lambda (id-str) (parse-integer id-str :junk-allowed t))
- (cl-ppcre:split "," track-ids))))
- (reorder-stream-queue ids)
- (api-output `(("status" . "success")
- ("message" . "Stream queue reordered"))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error reordering queue: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let ((ids (mapcar (lambda (id-str) (parse-integer id-str :junk-allowed t))
+ (cl-ppcre:split "," track-ids))))
+ (reorder-stream-queue ids)
+ (api-output `(("status" . "success")
+ ("message" . "Stream queue reordered"))))))
(define-api asteroid/stream/queue/load-m3u () ()
"Load stream queue from stream-queue.m3u file"
(require-role :admin)
- (handler-case
- (let ((count (load-queue-from-m3u-file)))
- (api-output `(("status" . "success")
- ("message" . "Queue loaded from M3U file")
- ("count" . ,count))))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error loading from M3U: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let ((count (load-queue-from-m3u-file)))
+ (api-output `(("status" . "success")
+ ("message" . "Queue loaded from M3U file")
+ ("count" . ,count))))))
(defun get-track-by-id (track-id)
"Get a track by its ID - handles type mismatches"
@@ -315,39 +257,28 @@
(define-page stream-track #@"/tracks/(.*)/stream" (:uri-groups (track-id))
"Stream audio file by track ID"
- (handler-case
- (let* ((id (parse-integer track-id))
- (track (get-track-by-id id)))
- (if track
- (let* ((file-path (first (gethash "file-path" track)))
- (format (first (gethash "format" track)))
- (file (probe-file file-path)))
- (if file
- (progn
- ;; Set appropriate headers for audio streaming
- (setf (radiance:header "Content-Type") (get-mime-type-for-format format))
- (setf (radiance:header "Accept-Ranges") "bytes")
- (setf (radiance:header "Cache-Control") "public, max-age=3600")
- ;; Increment play count
- (db:update "tracks" (db:query (:= '_id id))
- `(("play-count" ,(1+ (first (gethash "play-count" track))))))
- ;; Return file contents
- (alexandria:read-file-into-byte-vector file))
- (progn
- (setf (radiance:header "Content-Type") "application/json")
- (cl-json:encode-json-to-string
- `(("status" . "error")
- ("message" . "Audio file not found on disk"))))))
- (progn
- (setf (radiance:header "Content-Type") "application/json")
- (cl-json:encode-json-to-string
- `(("status" . "error")
- ("message" . "Track not found"))))))
- (error (e)
- (setf (radiance:header "Content-Type") "application/json")
- (cl-json:encode-json-to-string
- `(("status" . "error")
- ("message" . ,(format nil "Streaming error: ~a" e)))))))
+ (with-error-handling
+ (let* ((id (parse-integer track-id))
+ (track (get-track-by-id id)))
+ (unless track
+ (signal-not-found "track" id))
+ (let* ((file-path (first (gethash "file-path" track)))
+ (format (first (gethash "format" track)))
+ (file (probe-file file-path)))
+ (unless file
+ (error 'not-found-error
+ :message "Audio file not found on disk"
+ :resource-type "file"
+ :resource-id file-path))
+ ;; Set appropriate headers for audio streaming
+ (setf (radiance:header "Content-Type") (get-mime-type-for-format format))
+ (setf (radiance:header "Accept-Ranges") "bytes")
+ (setf (radiance:header "Cache-Control") "public, max-age=3600")
+ ;; Increment play count
+ (db:update "tracks" (db:query (:= '_id id))
+ `(("play-count" ,(1+ (first (gethash "play-count" track))))))
+ ;; Return file contents
+ (alexandria:read-file-into-byte-vector file)))))
;; Player state management
(defvar *current-track* nil "Currently playing track")
@@ -390,27 +321,20 @@
;; Player control API endpoints
(define-api asteroid/player/play (track-id) ()
"Start playing a track by ID"
- (handler-case
- (let* ((id (parse-integer track-id))
- (track (get-track-by-id id)))
- (if track
- (progn
- (setf *current-track* id)
- (setf *player-state* :playing)
- (setf *current-position* 0)
- (api-output `(("status" . "success")
- ("message" . "Playback started")
- ("track" . (("id" . ,id)
- ("title" . ,(first (gethash "title" track)))
- ("artist" . ,(first (gethash "artist" track)))))
- ("player" . ,(get-player-status)))))
- (api-output `(("status" . "error")
- ("message" . "Track not found"))
- :status 404)))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Play error: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let* ((id (parse-integer track-id))
+ (track (get-track-by-id id)))
+ (unless track
+ (signal-not-found "track" id))
+ (setf *current-track* id)
+ (setf *player-state* :playing)
+ (setf *current-position* 0)
+ (api-output `(("status" . "success")
+ ("message" . "Playback started")
+ ("track" . (("id" . ,id)
+ ("title" . ,(first (gethash "title" track)))
+ ("artist" . ,(first (gethash "artist" track)))))
+ ("player" . ,(get-player-status)))))))
(define-api asteroid/player/pause () ()
"Pause current playback"
@@ -519,72 +443,64 @@
;; Front page - regular view by default
(define-page front-page #@"/" ()
"Main front page"
- (let ((template-path (merge-pathnames "template/front-page.ctml"
- (asdf:system-source-directory :asteroid))))
- (clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
- :title "🎵 ASTEROID RADIO 🎵"
- :station-name "🎵 ASTEROID RADIO 🎵"
- :status-message "🟢 LIVE - Broadcasting asteroid music for hackers"
- :listeners "0"
- :stream-quality "128kbps MP3"
- :stream-base-url *stream-base-url*
- :default-stream-url (concatenate 'string *stream-base-url* "/asteroid.aac")
- :default-stream-encoding "audio/aac"
- :default-stream-encoding-desc "AAC 96kbps Stereo"
- :now-playing-artist "The Void"
- :now-playing-track "Silence"
- :now-playing-album "Startup Sounds"
- :now-playing-duration "∞")))
+ (clip:process-to-string
+ (load-template "front-page")
+ :title "🎵 ASTEROID RADIO 🎵"
+ :station-name "🎵 ASTEROID RADIO 🎵"
+ :status-message "🟢 LIVE - Broadcasting asteroid music for hackers"
+ :listeners "0"
+ :stream-quality "128kbps MP3"
+ :stream-base-url *stream-base-url*
+ :default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
+ :default-stream-encoding "audio/aac"
+ :default-stream-encoding-desc "AAC 96kbps Stereo"
+ :now-playing-artist "The Void"
+ :now-playing-track "Silence"
+ :now-playing-album "Startup Sounds"
+ :now-playing-duration "∞"))
;; Frameset wrapper for persistent player mode
(define-page frameset-wrapper #@"/frameset" ()
"Frameset wrapper with persistent audio player"
- (let ((template-path (merge-pathnames "template/frameset-wrapper.ctml"
- (asdf:system-source-directory :asteroid))))
- (clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
- :title "🎵 ASTEROID RADIO 🎵")))
+ (clip:process-to-string
+ (load-template "frameset-wrapper")
+ :title "🎵 ASTEROID RADIO 🎵"))
;; Content frame - front page content without player
(define-page front-page-content #@"/content" ()
"Front page content (displayed in content frame)"
- (let ((template-path (merge-pathnames "template/front-page-content.ctml"
- (asdf:system-source-directory :asteroid))))
- (clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
- :title "🎵 ASTEROID RADIO 🎵"
- :station-name "🎵 ASTEROID RADIO 🎵"
- :status-message "🟢 LIVE - Broadcasting asteroid music for hackers"
- :listeners "0"
- :stream-quality "128kbps MP3"
- :stream-base-url *stream-base-url*
- :now-playing-artist "The Void"
- :now-playing-track "Silence"
- :now-playing-album "Startup Sounds"
- :now-playing-duration "∞")))
+ (clip:process-to-string
+ (load-template "front-page-content")
+ :title "🎵 ASTEROID RADIO 🎵"
+ :station-name "🎵 ASTEROID RADIO 🎵"
+ :status-message "🟢 LIVE - Broadcasting asteroid music for hackers"
+ :listeners "0"
+ :stream-quality "128kbps MP3"
+ :stream-base-url *stream-base-url*
+ :now-playing-artist "The Void"
+ :now-playing-track "Silence"
+ :now-playing-album "Startup Sounds"
+ :now-playing-duration "∞"))
;; Persistent audio player frame (bottom frame)
(define-page audio-player-frame #@"/audio-player-frame" ()
"Persistent audio player frame (bottom of page)"
- (let ((template-path (merge-pathnames "template/audio-player-frame.ctml"
- (asdf:system-source-directory :asteroid))))
- (clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
- :stream-base-url *stream-base-url*
- :default-stream-url (concatenate 'string *stream-base-url* "/asteroid.aac")
- :default-stream-encoding "audio/aac")))
+ (clip:process-to-string
+ (load-template "audio-player-frame")
+ :stream-base-url *stream-base-url*
+ :default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
+ :default-stream-encoding "audio/aac"))
;; Configure static file serving for other files
(define-page static #@"/static/(.*)" (:uri-groups (path))
- (serve-file (merge-pathnames (concatenate 'string "static/" path)
+ (serve-file (merge-pathnames (format nil "static/~a" path)
(asdf:system-source-directory :asteroid))))
;; Status check functions
(defun check-icecast-status ()
"Check if Icecast server is running and accessible"
(handler-case
- (let ((response (drakma:http-request (concatenate 'string *stream-base-url* "/status-json.xsl")
+ (let ((response (drakma:http-request (format nil "~a/status-json.xsl" *stream-base-url*)
:want-stream nil
:connection-timeout 2)))
(if response "🟢 Running" "🔴 Not Running"))
@@ -606,13 +522,11 @@
(define-page admin #@"/admin" ()
"Admin dashboard"
(require-authentication)
- (let ((template-path (merge-pathnames "template/admin.ctml"
- (asdf:system-source-directory :asteroid)))
- (track-count (handler-case
+ (let ((track-count (handler-case
(length (db:select "tracks" (db:query :all)))
(error () 0))))
(clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
+ (load-template "admin")
:title "🎵 ASTEROID RADIO - Admin Dashboard"
:server-status "🟢 Running"
:database-status (handler-case
@@ -623,57 +537,53 @@
: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 (concatenate 'string *stream-base-url* "/asteroid.aac"))))
+ :default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*))))
;; User Management page (requires authentication)
(define-page users-management #@"/admin/user" ()
"User Management dashboard"
(require-authentication)
- (let ((template-path (merge-pathnames "template/users.ctml"
- (asdf:system-source-directory :asteroid))))
- (clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
- :title "🎵 ASTEROID RADIO - User Management")))
+ (clip:process-to-string
+ (load-template "users")
+ :title "🎵 ASTEROID RADIO - User Management"))
;; User Profile page (requires authentication)
(define-page user-profile #@"/profile" ()
"User profile page"
(require-authentication)
- (let ((template-path (merge-pathnames "template/profile.ctml"
- (asdf:system-source-directory :asteroid))))
- (clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
- :title "🎧 admin - Profile | Asteroid Radio"
- :username "admin"
- :user-role "admin"
- :join-date "Unknown"
- :last-active "Unknown"
- :total-listen-time "0h 0m"
- :tracks-played "0"
- :session-count "0"
- :favorite-genre "Unknown"
- :recent-track-1-title ""
- :recent-track-1-artist ""
- :recent-track-1-duration ""
- :recent-track-1-played-at ""
- :recent-track-2-title ""
- :recent-track-2-artist ""
- :recent-track-2-duration ""
- :recent-track-2-played-at ""
- :recent-track-3-title ""
- :recent-track-3-artist ""
- :recent-track-3-duration ""
- :recent-track-3-played-at ""
- :top-artist-1 ""
- :top-artist-1-plays ""
- :top-artist-2 ""
- :top-artist-2-plays ""
- :top-artist-3 ""
- :top-artist-3-plays ""
- :top-artist-4 ""
- :top-artist-4-plays ""
- :top-artist-5 ""
- :top-artist-5-plays "")))
+ (clip:process-to-string
+ (load-template "profile")
+ :title "🎧 admin - Profile | Asteroid Radio"
+ :username "admin"
+ :user-role "admin"
+ :join-date "Unknown"
+ :last-active "Unknown"
+ :total-listen-time "0h 0m"
+ :tracks-played "0"
+ :session-count "0"
+ :favorite-genre "Unknown"
+ :recent-track-1-title ""
+ :recent-track-1-artist ""
+ :recent-track-1-duration ""
+ :recent-track-1-played-at ""
+ :recent-track-2-title ""
+ :recent-track-2-artist ""
+ :recent-track-2-duration ""
+ :recent-track-2-played-at ""
+ :recent-track-3-title ""
+ :recent-track-3-artist ""
+ :recent-track-3-duration ""
+ :recent-track-3-played-at ""
+ :top-artist-1 ""
+ :top-artist-1-plays ""
+ :top-artist-2 ""
+ :top-artist-2-plays ""
+ :top-artist-3 ""
+ :top-artist-3-plays ""
+ :top-artist-4 ""
+ :top-artist-4-plays ""
+ :top-artist-5 ""
+ :top-artist-5-plays ""))
;; Helper functions for profile page - TEMPORARILY COMMENTED OUT
#|
@@ -750,42 +660,31 @@
;; Auth status API endpoint
(define-api asteroid/auth-status () ()
"Check if user is logged in and their role"
- (handler-case
- (let* ((user-id (session:field "user-id"))
- (user (when user-id (find-user-by-id user-id))))
- (api-output `(("loggedIn" . ,(if user t nil))
- ("isAdmin" . ,(if (and user (user-has-role-p user :admin)) t nil))
- ("username" . ,(if user
- (let ((username (gethash "username" user)))
- (if (listp username) (first username) username))
- nil)))))
- (error (e)
- (api-output `(("loggedIn" . nil)
- ("isAdmin" . nil)
- ("error" . ,(format nil "~a" e)))
- :status 500))))
+ (with-error-handling
+ (let* ((user-id (session:field "user-id"))
+ (user (when user-id (find-user-by-id user-id))))
+ (api-output `(("loggedIn" . ,(if user t nil))
+ ("isAdmin" . ,(if (and user (user-has-role-p user :admin)) t nil))
+ ("username" . ,(if user
+ (let ((username (gethash "username" user)))
+ (if (listp username) (first username) username))
+ nil)))))))
;; User profile API endpoints
(define-api asteroid/user/profile () ()
"Get current user profile information"
(require-authentication)
- (handler-case
- (let* ((user-id (session:field "user-id"))
- (user (find-user-by-id user-id)))
- (if user
- (api-output `(("status" . "success")
- ("user" . (("username" . ,(first (gethash "username" user)))
- ("email" . ,(first (gethash "email" user)))
- ("role" . ,(first (gethash "role" user)))
- ("created_at" . ,(first (gethash "created-date" user)))
- ("last_active" . ,(first (gethash "last-login" user)))))))
- (api-output `(("status" . "error")
- ("message" . "User not found"))
- :status 404)))
- (error (e)
- (api-output `(("status" . "error")
- ("message" . ,(format nil "Error loading profile: ~a" e)))
- :status 500))))
+ (with-error-handling
+ (let* ((user-id (session:field "user-id"))
+ (user (find-user-by-id user-id)))
+ (if user
+ (api-output `(("status" . "success")
+ ("user" . (("username" . ,(first (gethash "username" user)))
+ ("email" . ,(first (gethash "email" user)))
+ ("role" . ,(first (gethash "role" user)))
+ ("created_at" . ,(first (gethash "created-date" user)))
+ ("last_active" . ,(first (gethash "last-login" user)))))))
+ (signal-not-found "user" user-id)))))
(define-api asteroid/user/listening-stats () ()
"Get user listening statistics"
@@ -862,40 +761,34 @@
:success-message ""))))
(define-page player #@"/player" ()
- (let ((template-path (merge-pathnames "template/player.ctml"
- (asdf:system-source-directory :asteroid))))
- (clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
- :title "Asteroid Radio - Web Player"
- :stream-base-url *stream-base-url*
- :default-stream-url (concatenate 'string *stream-base-url* "/asteroid.aac")
- :bitrate "128kbps MP3"
- :now-playing-artist "The Void"
- :now-playing-track "Silence"
- :now-playing-album "Startup Sounds"
- :player-status "Stopped")))
+ (clip:process-to-string
+ (load-template "player")
+ :title "Asteroid Radio - Web Player"
+ :stream-base-url *stream-base-url*
+ :default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
+ :bitrate "128kbps MP3"
+ :now-playing-artist "The Void"
+ :now-playing-track "Silence"
+ :now-playing-album "Startup Sounds"
+ :player-status "Stopped"))
;; Player content frame (for frameset mode)
(define-page player-content #@"/player-content" ()
"Player page content (displayed in content frame)"
- (let ((template-path (merge-pathnames "template/player-content.ctml"
- (asdf:system-source-directory :asteroid))))
- (clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
- :title "Asteroid Radio - Web Player"
- :stream-base-url *stream-base-url*
- :default-stream-url (concatenate 'string *stream-base-url* "/asteroid.aac")
- :default-stream-encoding "audio/aac")))
+ (clip:process-to-string
+ (load-template "player-content")
+ :title "Asteroid Radio - Web Player"
+ :stream-base-url *stream-base-url*
+ :default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
+ :default-stream-encoding "audio/aac"))
(define-page popout-player #@"/popout-player" ()
"Pop-out player window"
- (let ((template-path (merge-pathnames "template/popout-player.ctml"
- (asdf:system-source-directory :asteroid))))
- (clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
- :stream-base-url *stream-base-url*
- :default-stream-url (concatenate 'string *stream-base-url* "/asteroid.aac")
- :default-stream-encoding "audio/aac")))
+ (clip:process-to-string
+ (load-template "popout-player")
+ :stream-base-url *stream-base-url*
+ :default-stream-url (format nil "~a/asteroid.aac" *stream-base-url*)
+ :default-stream-encoding "audio/aac"))
(define-api asteroid/status () ()
"Get server status"
@@ -907,14 +800,14 @@
("artist" . "The Void")
("album" . "Startup Sounds")))
("listeners" . 0)
- ("stream-url" . ,(concatenate 'string *stream-base-url* "/asteroid.mp3"))
+ ("stream-url" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
("stream-status" . "live"))))
;; Live stream status from Icecast
(define-api asteroid/icecast-status () ()
"Get live status from Icecast server"
- (handler-case
- (let* ((icecast-url (concatenate 'string *stream-base-url* "/admin/stats.xml"))
+ (with-error-handling
+ (let* ((icecast-url (format nil "~a/admin/stats.xml" *stream-base-url*))
(response (drakma:http-request icecast-url
:want-stream nil
:basic-authorization '("admin" "asteroid_admin_2024"))))
@@ -936,7 +829,7 @@
(listeners (if listenersp (cl-ppcre:regex-replace-all ".*(.*?).*" source-section "\\1") "0")))
;; Return JSON in format expected by frontend
(api-output
- `(("icestats" . (("source" . (("listenurl" . ,(concatenate 'string *stream-base-url* "/asteroid.mp3"))
+ `(("icestats" . (("source" . (("listenurl" . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
("title" . ,title)
("listeners" . ,(parse-integer listeners :junk-allowed t)))))))))
;; No source found, return empty
@@ -944,11 +837,7 @@
`(("icestats" . (("source" . nil))))))))
(api-output
`(("error" . "Could not connect to Icecast server"))
- :status 503)))
- (error (e)
- (api-output
- `(("error" . ,(format nil "Icecast connection failed: ~a" e)))
- :status 500))))
+ :status 503)))))
;; RADIANCE server management functions
diff --git a/conditions.lisp b/conditions.lisp
new file mode 100644
index 0000000..f16464a
--- /dev/null
+++ b/conditions.lisp
@@ -0,0 +1,185 @@
+;;;; conditions.lisp - Custom error conditions for Asteroid Radio
+;;;; Provides a hierarchy of error conditions for better error handling and debugging
+
+(in-package :asteroid)
+
+;;; Base Condition Hierarchy
+
+(define-condition asteroid-error (error)
+ ((message
+ :initarg :message
+ :reader error-message
+ :documentation "Human-readable error message"))
+ (:documentation "Base condition for all Asteroid-specific errors")
+ (:report (lambda (condition stream)
+ (format stream "Asteroid Error: ~a" (error-message condition)))))
+
+;;; Specific Error Types
+
+(define-condition database-error (asteroid-error)
+ ((operation
+ :initarg :operation
+ :reader error-operation
+ :initform nil
+ :documentation "Database operation that failed (e.g., 'select', 'insert')"))
+ (:documentation "Signaled when a database operation fails")
+ (:report (lambda (condition stream)
+ (format stream "Database Error~@[ during ~a~]: ~a"
+ (error-operation condition)
+ (error-message condition)))))
+
+(define-condition authentication-error (asteroid-error)
+ ((user
+ :initarg :user
+ :reader error-user
+ :initform nil
+ :documentation "Username or user ID that failed authentication"))
+ (:documentation "Signaled when authentication fails")
+ (:report (lambda (condition stream)
+ (format stream "Authentication Error~@[ for user ~a~]: ~a"
+ (error-user condition)
+ (error-message condition)))))
+
+(define-condition authorization-error (asteroid-error)
+ ((required-role
+ :initarg :required-role
+ :reader error-required-role
+ :initform nil
+ :documentation "Role required for the operation"))
+ (:documentation "Signaled when user lacks required permissions")
+ (:report (lambda (condition stream)
+ (format stream "Authorization Error~@[ (requires ~a)~]: ~a"
+ (error-required-role condition)
+ (error-message condition)))))
+
+(define-condition not-found-error (asteroid-error)
+ ((resource-type
+ :initarg :resource-type
+ :reader error-resource-type
+ :initform nil
+ :documentation "Type of resource that wasn't found (e.g., 'track', 'user')")
+ (resource-id
+ :initarg :resource-id
+ :reader error-resource-id
+ :initform nil
+ :documentation "ID of the resource that wasn't found"))
+ (:documentation "Signaled when a requested resource doesn't exist")
+ (:report (lambda (condition stream)
+ (format stream "Not Found~@[ (~a~@[ ~a~])~]: ~a"
+ (error-resource-type condition)
+ (error-resource-id condition)
+ (error-message condition)))))
+
+(define-condition validation-error (asteroid-error)
+ ((field
+ :initarg :field
+ :reader error-field
+ :initform nil
+ :documentation "Field that failed validation"))
+ (:documentation "Signaled when input validation fails")
+ (:report (lambda (condition stream)
+ (format stream "Validation Error~@[ in field ~a~]: ~a"
+ (error-field condition)
+ (error-message condition)))))
+
+(define-condition asteroid-stream-error (asteroid-error)
+ ((stream-type
+ :initarg :stream-type
+ :reader error-stream-type
+ :initform nil
+ :documentation "Type of stream (e.g., 'icecast', 'liquidsoap')"))
+ (:documentation "Signaled when stream operations fail")
+ (:report (lambda (condition stream)
+ (format stream "Stream Error~@[ (~a)~]: ~a"
+ (error-stream-type condition)
+ (error-message condition)))))
+
+;;; Error Handling Macros
+
+(defmacro with-error-handling (&body body)
+ "Wrap API endpoint code with standard error handling.
+ Catches specific Asteroid errors and returns appropriate HTTP status codes.
+
+ Usage:
+ (define-api my-endpoint () ()
+ (with-error-handling
+ (do-something-that-might-fail)))"
+ `(handler-case
+ (progn ,@body)
+ (not-found-error (e)
+ (api-output `(("status" . "error")
+ ("message" . ,(error-message e)))
+ :status 404))
+ (authentication-error (e)
+ (api-output `(("status" . "error")
+ ("message" . ,(error-message e)))
+ :status 401))
+ (authorization-error (e)
+ (api-output `(("status" . "error")
+ ("message" . ,(error-message e)))
+ :status 403))
+ (validation-error (e)
+ (api-output `(("status" . "error")
+ ("message" . ,(error-message e)))
+ :status 400))
+ (database-error (e)
+ (format t "Database error: ~a~%" e)
+ (api-output `(("status" . "error")
+ ("message" . "Database operation failed"))
+ :status 500))
+ (asteroid-stream-error (e)
+ (format t "Stream error: ~a~%" e)
+ (api-output `(("status" . "error")
+ ("message" . "Stream operation failed"))
+ :status 500))
+ (asteroid-error (e)
+ (format t "Asteroid error: ~a~%" e)
+ (api-output `(("status" . "error")
+ ("message" . ,(error-message e)))
+ :status 500))
+ (error (e)
+ (format t "Unexpected error: ~a~%" e)
+ (api-output `(("status" . "error")
+ ("message" . "An unexpected error occurred"))
+ :status 500))))
+
+(defmacro with-db-error-handling (operation &body body)
+ "Wrap database operations with error handling.
+ Automatically converts database errors to database-error conditions.
+
+ Usage:
+ (with-db-error-handling \"select\"
+ (db:select 'tracks (db:query :all)))"
+ `(handler-case
+ (progn ,@body)
+ (error (e)
+ (error 'database-error
+ :message (format nil "~a" e)
+ :operation ,operation))))
+
+;;; Helper Functions
+
+(defun signal-not-found (resource-type resource-id)
+ "Signal a not-found-error with the given resource information."
+ (error 'not-found-error
+ :message (format nil "~a not found" resource-type)
+ :resource-type resource-type
+ :resource-id resource-id))
+
+(defun signal-validation-error (field message)
+ "Signal a validation-error for the given field."
+ (error 'validation-error
+ :message message
+ :field field))
+
+(defun signal-auth-error (user message)
+ "Signal an authentication-error for the given user."
+ (error 'authentication-error
+ :message message
+ :user user))
+
+(defun signal-authz-error (required-role message)
+ "Signal an authorization-error with the required role."
+ (error 'authorization-error
+ :message message
+ :required-role required-role))
diff --git a/frontend-partials.lisp b/frontend-partials.lisp
index 4b060b2..5d5e82b 100644
--- a/frontend-partials.lisp
+++ b/frontend-partials.lisp
@@ -1,7 +1,7 @@
(in-package :asteroid)
(defun icecast-now-playing (icecast-base-url)
- (let* ((icecast-url (concatenate 'string icecast-base-url "/admin/stats.xml"))
+ (let* ((icecast-url (format nil "~a/admin/stats.xml" icecast-base-url))
(response (drakma:http-request icecast-url
:want-stream nil
:basic-authorization '("admin" "asteroid_admin_2024"))))
@@ -22,31 +22,29 @@
(listenersp (cl-ppcre:all-matches "" source-section))
(title (if titlep (cl-ppcre:regex-replace-all ".*(.*?).*" source-section "\\1") "Unknown"))
(listeners (if listenersp (cl-ppcre:regex-replace-all ".*(.*?).*" source-section "\\1") "0")))
- `((:listenurl . ,(concatenate 'string *stream-base-url* "/asteroid.mp3"))
+ `((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
(:title . ,title)
(:listeners . ,(parse-integer listeners :junk-allowed t))))
- `((:listenurl . ,(concatenate 'string *stream-base-url* "/asteroid.mp3"))
+ `((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
(:title . "Unknown")
(:listeners . "Unknown"))))))))
(define-api asteroid/partial/now-playing () ()
"Get Partial HTML with live status from Icecast server"
(handler-case
- (let ((now-playing-stats (icecast-now-playing *stream-base-url*))
- (template-path (merge-pathnames "template/partial/now-playing.ctml"
- (asdf:system-source-directory :asteroid))))
+ (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 :format "html"))
(setf (header "Content-Type") "text/html")
(clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
+ (load-template "partial/now-playing")
:stats now-playing-stats))
(progn
(setf (header "Content-Type") "text/html")
(clip:process-to-string
- (plump:parse (alexandria:read-file-into-string template-path))
+ (load-template "partial/now-playing")
:connection-error t
:stats nil))))
(error (e)
diff --git a/template-utils.lisp b/template-utils.lisp
index 1dc1564..448ea92 100644
--- a/template-utils.lisp
+++ b/template-utils.lisp
@@ -3,17 +3,32 @@
(in-package :asteroid)
+;; Template directory configuration
+(defparameter *template-directory*
+ (merge-pathnames "template/" (asdf:system-source-directory :asteroid))
+ "Base directory for all CLIP templates")
+
;; Template cache for parsed templates
(defvar *template-cache* (make-hash-table :test 'equal)
"Cache for parsed template DOMs")
+(defun template-path (name)
+ "Build full path to template file.
+ NAME can be either:
+ - Simple name: 'front-page' -> 'template/front-page.ctml'
+ - Path with subdirs: 'partial/now-playing' -> 'template/partial/now-playing.ctml'"
+ (merge-pathnames (format nil "~a.ctml" name) *template-directory*))
+
+(defun load-template (name)
+ "Load and parse a template by name without caching.
+ Use this for templates that change frequently during development."
+ (plump:parse (alexandria:read-file-into-string (template-path name))))
+
(defun get-template (template-name)
- "Load and cache a template file"
+ "Load and cache a template file.
+ Use this for production - templates are cached after first load."
(or (gethash template-name *template-cache*)
- (let* ((template-path (merge-pathnames
- (format nil "template/~a.ctml" template-name)
- (asdf:system-source-directory :asteroid)))
- (parsed (plump:parse (alexandria:read-file-into-string template-path))))
+ (let ((parsed (load-template template-name)))
(setf (gethash template-name *template-cache*) parsed)
parsed)))