diff --git a/asteroid.asd b/asteroid.asd index e2ea1b2..4430530 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -43,7 +43,8 @@ (:file "template-utils") (:file "parenscript-utils") (:module :parenscript - :components ((:file "auth-ui") + :components ((:file "recently-played") + (:file "auth-ui") (:file "front-page") (:file "profile") (:file "users") diff --git a/asteroid.lisp b/asteroid.lisp index c96bdab..9ad5700 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -24,6 +24,13 @@ (defparameter *supported-formats* '("mp3" "flac" "ogg" "wav")) (defparameter *stream-base-url* "http://localhost:8000") +;; Recently played tracks storage (in-memory) +(defparameter *recently-played* nil + "List of recently played tracks (max 3), newest first") +(defparameter *recently-played-lock* (bt:make-lock "recently-played-lock")) +(defparameter *last-known-track* nil + "Last known track title to detect changes") + ;; Configure JSON as the default API format (define-api-format json (data) "JSON API format for Radiance" @@ -33,6 +40,40 @@ ;; Set JSON as the default API format (setf *default-api-format* "json") +;; Recently played tracks management +(defun add-recently-played (track-info) + "Add a track to the recently played list (max 3 tracks)" + (bt:with-lock-held (*recently-played-lock*) + (push track-info *recently-played*) + (when (> (length *recently-played*) 3) + (setf *recently-played* (subseq *recently-played* 0 3))))) + +(defun universal-time-to-unix (universal-time) + "Convert Common Lisp universal time to Unix timestamp" + ;; Universal time is seconds since 1900-01-01, Unix is since 1970-01-01 + ;; Difference is 2208988800 seconds (70 years) + (- universal-time 2208988800)) + +(defun get-recently-played () + "Get the list of recently played tracks" + (bt:with-lock-held (*recently-played-lock*) + (copy-list *recently-played*))) + +(defun parse-track-title (title) + "Parse track title into artist and song name. Expected format: 'Artist - Song'" + (let ((pos (search " - " title))) + (if pos + (list :artist (string-trim " " (subseq title 0 pos)) + :song (string-trim " " (subseq title (+ pos 3)))) + (list :artist "Unknown" :song title)))) + +(defun generate-music-search-url (artist song) + "Generate MusicBrainz search URL for artist and song" + ;; Simple search without field prefixes works better with URL encoding + (let ((query (format nil "~a ~a" artist song))) + (format nil "https://musicbrainz.org/search?query=~a&type=recording" + (drakma:url-encode query :utf-8)))) + ;; API Routes using Radiance's define-api ;; API endpoints are accessed at /api/ automatically ;; They use lambda-lists for parameters and api-output for responses @@ -46,6 +87,26 @@ ("message" . "Library scan completed") ("tracks-added" . ,tracks-added)))))) +(define-api asteroid/recently-played () () + "Get the last 3 played tracks with MusicBrainz links" + (with-error-handling + (let ((tracks (get-recently-played))) + (api-output `(("status" . "success") + ("tracks" . ,(mapcar (lambda (track) + (let* ((title (getf track :title)) + (timestamp (getf track :timestamp)) + (unix-timestamp (universal-time-to-unix timestamp)) + (parsed (parse-track-title title)) + (artist (getf parsed :artist)) + (song (getf parsed :song)) + (search-url (generate-music-search-url artist song))) + `(("title" . ,title) + ("artist" . ,artist) + ("song" . ,song) + ("timestamp" . ,unix-timestamp) + ("search_url" . ,search-url)))) + tracks))))))) + (define-api asteroid/admin/tracks () () "API endpoint to view all tracks in database" (require-authentication) @@ -547,6 +608,18 @@ (format t "ERROR generating player.js: ~a~%" e) (format nil "// Error generating JavaScript: ~a~%" e)))) + ;; Serve ParenScript-compiled recently-played.js + ((string= path "js/recently-played.js") + (format t "~%=== SERVING PARENSCRIPT recently-played.js ===~%") + (setf (content-type *response*) "application/javascript") + (handler-case + (let ((js (generate-recently-played-js))) + (format t "DEBUG: Generated JS length: ~a~%" (if js (length js) "NIL")) + (if js js "// Error: No JavaScript generated")) + (error (e) + (format t "ERROR generating recently-played.js: ~a~%" e) + (format nil "// Error generating JavaScript: ~a~%" e)))) + ;; Serve regular static file (t (serve-file (merge-pathnames (format nil "static/~a" path) diff --git a/frontend-partials.lisp b/frontend-partials.lisp index 1e89d03..047627a 100644 --- a/frontend-partials.lisp +++ b/frontend-partials.lisp @@ -35,6 +35,15 @@ "Unknown"))) "Unknown"))) (format t "DEBUG: Parsed title=~a, total-listeners=~a~%" title total-listeners) + + ;; Track recently played if title changed + (when (and title + (not (string= title "Unknown")) + (not (equal title *last-known-track*))) + (setf *last-known-track* title) + (add-recently-played (list :title title + :timestamp (get-universal-time)))) + `((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*)) (:title . ,title) (:listeners . ,total-listeners))))))) @@ -56,14 +65,7 @@ (clip:process-to-string (load-template "partial/now-playing") :connection-error t - :stats nil)))) - (error () - (format t "Error in now-playing endpoint~%") - (setf (header "Content-Type") "text/html") - (clip:process-to-string - (load-template "partial/now-playing") - :connection-error t - :stats nil)))) + :stats nil)))))) (define-api asteroid/partial/now-playing-inline () () "Get inline text with now playing info (for admin dashboard and widgets)" diff --git a/parenscript/recently-played.lisp b/parenscript/recently-played.lisp new file mode 100644 index 0000000..b0b5a70 --- /dev/null +++ b/parenscript/recently-played.lisp @@ -0,0 +1,96 @@ +;;;; recently-played.lisp - ParenScript version of recently-played.js +;;;; Recently Played Tracks functionality + +(in-package #:asteroid) + +(defparameter *recently-played-js* + (ps:ps + (progn + + ;; Update recently played tracks display + (defun update-recently-played () + (ps:chain + (fetch "/api/asteroid/recently-played") + (then (lambda (response) (ps:chain response (json)))) + (then (lambda (result) + ;; Radiance wraps API responses in a data envelope + (let ((data (or (ps:@ result data) result))) + (if (and (equal (ps:@ data status) "success") + (ps:@ data tracks) + (> (ps:@ data tracks length) 0)) + (let ((list-el (ps:chain document (get-element-by-id "recently-played-list")))) + (when list-el + ;; Build HTML for tracks + (let ((html "")) + (setf (aref list-el "innerHTML") html)))) + (let ((list-el (ps:chain document (get-element-by-id "recently-played-list")))) + (when list-el + (setf (aref list-el "innerHTML") "

No tracks played yet

"))))))) + (catch (lambda (error) + (ps:chain console (error "Error fetching recently played:" error)) + (let ((list-el (ps:chain document (get-element-by-id "recently-played-list")))) + (when list-el + (setf (aref list-el "innerHTML") "

Error loading recently played tracks

")))))))) + + ;; Format timestamp as relative time + (defun format-time-ago (timestamp) + (let* ((now (floor (/ (ps:chain *date (now)) 1000))) + (diff (- now timestamp))) + (cond + ((< diff 60) "Just now") + ((< diff 3600) (+ (floor (/ diff 60)) "m ago")) + ((< diff 86400) (+ (floor (/ diff 3600)) "h ago")) + (t (+ (floor (/ diff 86400)) "d ago"))))) + + ;; Escape HTML to prevent XSS + (defun escape-html (text) + (when (ps:@ window document) + (let ((div (ps:chain document (create-element "div")))) + (setf (ps:@ div text-content) text) + (aref div "innerHTML")))) + + ;; Initialize on page load + (when (ps:@ window document) + (ps:chain document + (add-event-listener + "DOMContentLoaded" + (lambda () + (let ((panel (ps:chain document (get-element-by-id "recently-played-panel")))) + (if panel + (progn + (update-recently-played) + ;; Update every 30 seconds + (set-interval update-recently-played 30000)) + (let ((list (ps:chain document (get-element-by-id "recently-played-list")))) + (when list + (update-recently-played) + (set-interval update-recently-played 30000)))))))))) + "Compiled JavaScript for recently played tracks - generated at load time" +) + +(defun generate-recently-played-js () + "Generate JavaScript code for recently played tracks" + *recently-played-js*) diff --git a/static/asteroid.lass b/static/asteroid.lass index f828d0f..e07209a 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -123,6 +123,98 @@ :color "#4488ff" :overflow auto) + ;; Recently Played Tracks Styles + (.recently-played-panel + :background "#1a2332" + :border "1px solid #2a3441" + :border-radius "8px" + :padding "20px" + :margin "20px 0" + + (h3 :margin "0 0 15px 0" + :color "#00ff00" + :font-size "1.2em" + :font-weight 600) + + (.recently-played-list + :min-height "100px" + + ((:or .loading .no-tracks .error) + :text-align center + :color "#888" + :padding "20px" + :font-style italic) + + (.error :color "#ff4444")) + + ((:and .recently-played-list .track-list) + :list-style none + :padding 0 + :margin 0 + :border none + :max-height none + :overflow-y visible) + + ((:and .recently-played-list .track-item) + :padding "6px 12px" + :border-bottom "1px solid #2a3441" + :transition "background-color 0.2s" + + ((:and :last-child) :border-bottom none) + ((:and :hover) :background-color "#2a3441")) + + ((:and .recently-played-list .track-info) + :display grid + :grid-template-columns "1fr auto" + :grid-template-rows "auto auto" + :gap "2px 20px" + :align-items center) + + ((:and .recently-played-list .track-title) + :color "#4488ff" + :font-weight 500 + :font-size "1em" + :grid-column 1 + :grid-row 1) + + ((:and .recently-played-list .track-artist) + :color "#4488ff" + :font-size "0.9em" + :grid-column 1 + :grid-row 2) + + ((:and .recently-played-list .track-time) + :color "#888" + :font-size "0.85em" + :grid-column 2 + :grid-row 1 + :text-align right) + + ((:and .recently-played-list .track-meta) + :grid-column 2 + :grid-row 2 + :text-align right)) + + (.recently-played-list + (.allmusic-link + :display inline-flex + :align-items center + :gap "4px" + :color "#4488ff" + :text-decoration none + :font-size "0.85em" + :letter-spacing "0.08rem" + :transition "all 0.2s" + :white-space nowrap + + ((:and :hover) + :color "#00ff00" + :text-decoration underline + :text-underline-offset "5px") + + (svg :width "14px" + :height "14px"))) + (.back :color "#00ffff" :text-decoration none diff --git a/template/front-page-content.ctml b/template/front-page-content.ctml index fa6a804..46dbf67 100644 --- a/template/front-page-content.ctml +++ b/template/front-page-content.ctml @@ -10,6 +10,7 @@ +
@@ -42,6 +43,14 @@
+ + +
+

Recently Played

+
+

Loading...

+
+
diff --git a/template/front-page.ctml b/template/front-page.ctml index e5e3b09..48d10d0 100644 --- a/template/front-page.ctml +++ b/template/front-page.ctml @@ -10,6 +10,7 @@ +
@@ -68,6 +69,14 @@
+ + +
+

Recently Played

+
+

Loading...

+
+