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:
parent
6bbc3d0b6a
commit
578306f06f
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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*)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue