feat: Add recently-played tracks feature with ParenScript

- Convert recently-played.js to ParenScript in parenscript/recently-played.lisp
- Add API endpoint /api/asteroid/recently-played
- Add track monitoring in icecast-now-playing to populate recently-played list
- Add recently-played panel to front-page.ctml and front-page-content.ctml
- Add LASS styling for recently-played section
- Fix ParenScript issues: use ps:ps instead of ps:ps* with quote, use aref for innerHTML
- Display last 3 tracks with time ago formatting and MusicBrainz search links
This commit is contained in:
Glenn Thompson 2025-11-20 11:26:26 +03:00
parent 6bbc3d0b6a
commit 578306f06f
7 changed files with 291 additions and 9 deletions

View File

@ -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")

View File

@ -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/<name> 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)

View File

@ -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)"

View File

@ -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 "<ul class=\"track-list\">"))
(ps:chain (ps:@ data tracks)
(for-each (lambda (track index)
(let ((time-ago (format-time-ago (ps:@ track timestamp))))
(setf html
(+ html
"<li class=\"track-item\">"
"<div class=\"track-info\">"
"<div class=\"track-title\">" (escape-html (ps:@ track song)) "</div>"
"<div class=\"track-artist\">" (escape-html (ps:@ track artist)) "</div>"
"<span class=\"track-time\">" time-ago "</span>"
"<div class=\"track-meta\">"
"<a href=\"" (ps:@ track search_url) "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"allmusic-link\">"
"<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">"
"<path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"></path>"
"<polyline points=\"15 3 21 3 21 9\"></polyline>"
"<line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\"></line>"
"</svg>"
"MusicBrainz"
"</a>"
"</div>"
"</div>"
"</li>"))))))
(setf html (+ html "</ul>"))
(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") "<p class=\"no-tracks\">No tracks played yet</p>")))))))
(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") "<p class=\"error\">Error loading recently played tracks</p>"))))))))
;; 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*)

View File

@ -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

View File

@ -10,6 +10,7 @@
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
<script src="/asteroid/static/js/front-page.js"></script>
<script src="/asteroid/static/js/recently-played.js"></script>
</head>
<body>
<div class="container">
@ -42,6 +43,14 @@
</div>
<div id="now-playing" class="now-playing"></div>
<!-- Recently Played Tracks -->
<div id="recently-played-panel" class="recently-played-panel">
<h3>Recently Played</h3>
<div id="recently-played-list" class="recently-played-list">
<p class="loading">Loading...</p>
</div>
</div>
</main>
</div>
</body>

View File

@ -10,6 +10,7 @@
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
<script src="/asteroid/static/js/auth-ui.js"></script>
<script src="/asteroid/static/js/front-page.js"></script>
<script src="/asteroid/static/js/recently-played.js"></script>
</head>
<body>
<div class="container">
@ -68,6 +69,14 @@
</div>
<div id="now-playing" class="now-playing"></div>
<!-- Recently Played Tracks -->
<div id="recently-played-panel" class="recently-played-panel">
<h3>Recently Played</h3>
<div id="recently-played-list" class="recently-played-list">
<p class="loading">Loading...</p>
</div>
</div>
</main>
</div>
</body>