Add recently played tracks feature with MusicBrainz integration
- Display last 3 played tracks on front page - Auto-updates every 30 seconds - Shows track title, artist, and time ago - Links to MusicBrainz search for each track - Thread-safe in-memory storage - Works in both normal and frameset modes - Hacker-themed green styling Implements feature request from fade to show recently played tracks with linkage to track info at music database.
This commit is contained in:
parent
a1cfaf468c
commit
0a7d5c3de5
|
|
@ -24,6 +24,13 @@
|
||||||
(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")
|
||||||
|
|
||||||
|
;; 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
|
;; Configure JSON as the default API format
|
||||||
(define-api-format json (data)
|
(define-api-format json (data)
|
||||||
"JSON API format for Radiance"
|
"JSON API format for Radiance"
|
||||||
|
|
@ -33,6 +40,40 @@
|
||||||
;; Set JSON as the default API format
|
;; Set JSON as the default API format
|
||||||
(setf *default-api-format* "json")
|
(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"
|
||||||
|
;; MusicBrainz uses 'artist:' and 'recording:' prefixes for better search
|
||||||
|
(let ((query (format nil "artist:\"~a\" recording:\"~a\"" artist song)))
|
||||||
|
(format nil "https://musicbrainz.org/search?query=~a&type=recording&method=indexed"
|
||||||
|
(drakma:url-encode query :utf-8))))
|
||||||
|
|
||||||
;; API Routes using Radiance's define-api
|
;; API Routes using Radiance's define-api
|
||||||
;; API endpoints are accessed at /api/<name> automatically
|
;; API endpoints are accessed at /api/<name> automatically
|
||||||
;; They use lambda-lists for parameters and api-output for responses
|
;; They use lambda-lists for parameters and api-output for responses
|
||||||
|
|
@ -135,6 +176,27 @@
|
||||||
("message" . "Playlist not found"))
|
("message" . "Playlist not found"))
|
||||||
:status 404)))))
|
:status 404)))))
|
||||||
|
|
||||||
|
;; Recently played tracks API endpoint
|
||||||
|
(define-api asteroid/recently-played () ()
|
||||||
|
"Get the last 3 played tracks with AllMusic 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)))))))
|
||||||
|
|
||||||
;; API endpoint to get all tracks (for web player)
|
;; API endpoint to get all tracks (for web player)
|
||||||
(define-api asteroid/tracks () ()
|
(define-api asteroid/tracks () ()
|
||||||
"Get all tracks for web player"
|
"Get all tracks for web player"
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,13 @@
|
||||||
(aref groups 0)
|
(aref groups 0)
|
||||||
"Unknown")))
|
"Unknown")))
|
||||||
"Unknown")))
|
"Unknown")))
|
||||||
|
;; 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*))
|
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||||
(:title . ,title)
|
(:title . ,title)
|
||||||
(:listeners . ,total-listeners)))))))
|
(:listeners . ,total-listeners)))))))
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,118 @@ body .now-playing{
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel{
|
||||||
|
background: #1a2332;
|
||||||
|
border: 1px solid #2a3441;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel h3{
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #00ff00;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list{
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list .loading,
|
||||||
|
body .recently-played-panel .recently-played-list .no-tracks,
|
||||||
|
body .recently-played-panel .recently-played-list .error{
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
padding: 20px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list .error{
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-list{
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
max-height: none;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-item{
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #2a3441;
|
||||||
|
-moz-transition: background-color 0.2s;
|
||||||
|
-o-transition: background-color 0.2s;
|
||||||
|
-webkit-transition: background-color 0.2s;
|
||||||
|
-ms-transition: background-color 0.2s;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-item LAST-CHILD{
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-item HOVER{
|
||||||
|
background-color: #252525;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-info{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-title{
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-artist{
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-meta{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.track-time{
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.allmusic-link{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85em;
|
||||||
|
-moz-transition: color 0.2s;
|
||||||
|
-o-transition: color 0.2s;
|
||||||
|
-webkit-transition: color 0.2s;
|
||||||
|
-ms-transition: color 0.2s;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.allmusic-link HOVER{
|
||||||
|
color: #00cc00;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .recently-played-panel .recently-played-list.allmusic-link svg{
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
body .back{
|
body .back{
|
||||||
color: #00ffff;
|
color: #00ffff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,86 @@
|
||||||
:color "#4488ff"
|
:color "#4488ff"
|
||||||
:overflow auto)
|
:overflow auto)
|
||||||
|
|
||||||
|
;; Recently Played Panel
|
||||||
|
(.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 "12px"
|
||||||
|
:border-bottom "1px solid #2a3441"
|
||||||
|
:transition "background-color 0.2s"
|
||||||
|
|
||||||
|
((:and :last-child) :border-bottom none)
|
||||||
|
((:and :hover) :background-color "#252525"))
|
||||||
|
|
||||||
|
((:and .recently-played-list .track-info)
|
||||||
|
:display flex
|
||||||
|
:flex-direction column
|
||||||
|
:gap "4px")
|
||||||
|
|
||||||
|
((:and .recently-played-list .track-title)
|
||||||
|
:color "#fff"
|
||||||
|
:font-weight 500
|
||||||
|
:font-size "1em")
|
||||||
|
|
||||||
|
((:and .recently-played-list .track-artist)
|
||||||
|
:color "#aaa"
|
||||||
|
:font-size "0.9em")
|
||||||
|
|
||||||
|
((:and .recently-played-list .track-meta)
|
||||||
|
:display flex
|
||||||
|
:align-items center
|
||||||
|
:gap "12px"
|
||||||
|
:margin-top "4px")
|
||||||
|
|
||||||
|
((:and .recently-played-list .track-time)
|
||||||
|
:color "#666"
|
||||||
|
:font-size "0.85em")
|
||||||
|
|
||||||
|
((:and .recently-played-list .allmusic-link)
|
||||||
|
:display inline-flex
|
||||||
|
:align-items center
|
||||||
|
:gap "4px"
|
||||||
|
:color "#00ff00"
|
||||||
|
:text-decoration none
|
||||||
|
:font-size "0.85em"
|
||||||
|
:transition "color 0.2s"
|
||||||
|
|
||||||
|
((:and :hover)
|
||||||
|
:color "#00cc00"
|
||||||
|
:text-decoration underline)
|
||||||
|
|
||||||
|
(svg :width "14px"
|
||||||
|
:height "14px")))
|
||||||
|
|
||||||
(.back
|
(.back
|
||||||
:color "#00ffff"
|
:color "#00ffff"
|
||||||
:text-decoration none
|
:text-decoration none
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
// Recently Played Tracks functionality
|
||||||
|
|
||||||
|
async function updateRecentlyPlayed() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/asteroid/recently-played');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Radiance wraps API responses in a data envelope
|
||||||
|
const data = result.data || result;
|
||||||
|
|
||||||
|
if (data.status === 'success' && data.tracks && data.tracks.length > 0) {
|
||||||
|
const listEl = document.getElementById('recently-played-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
// Build HTML for tracks
|
||||||
|
let html = '<ul class="track-list">';
|
||||||
|
data.tracks.forEach((track, index) => {
|
||||||
|
const timeAgo = formatTimeAgo(track.timestamp);
|
||||||
|
html += `
|
||||||
|
<li class="track-item">
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-title">${escapeHtml(track.song)}</div>
|
||||||
|
<div class="track-artist">${escapeHtml(track.artist)}</div>
|
||||||
|
<div class="track-meta">
|
||||||
|
<span class="track-time">${timeAgo}</span>
|
||||||
|
<a href="${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>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
const listEl = document.getElementById('recently-played-list');
|
||||||
|
if (listEl) {
|
||||||
|
listEl.innerHTML = '<p class="no-tracks">No tracks played yet</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recently played:', error);
|
||||||
|
const listEl = document.getElementById('recently-played-list');
|
||||||
|
if (listEl) {
|
||||||
|
listEl.innerHTML = '<p class="error">Error loading recently played tracks</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(timestamp) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
if (diff < 60) return 'Just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const panel = document.getElementById('recently-played-panel');
|
||||||
|
if (panel) {
|
||||||
|
updateRecentlyPlayed();
|
||||||
|
// Update every 30 seconds
|
||||||
|
setInterval(updateRecentlyPlayed, 30000);
|
||||||
|
} else {
|
||||||
|
const list = document.getElementById('recently-played-list');
|
||||||
|
if (list) {
|
||||||
|
updateRecentlyPlayed();
|
||||||
|
setInterval(updateRecentlyPlayed, 30000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
<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/auth-ui.js"></script>
|
||||||
<script src="/asteroid/static/js/front-page.js"></script>
|
<script src="/asteroid/static/js/front-page.js"></script>
|
||||||
|
<script src="/asteroid/static/js/recently-played.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
@ -35,6 +36,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="now-playing" class="now-playing"></div>
|
<div id="now-playing" class="now-playing"></div>
|
||||||
|
|
||||||
|
<!-- Recently Played Tracks -->
|
||||||
|
<div 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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
|
<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/auth-ui.js"></script>
|
||||||
<script src="/asteroid/static/js/front-page.js"></script>
|
<script src="/asteroid/static/js/front-page.js"></script>
|
||||||
|
<script src="/asteroid/static/js/recently-played.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
@ -61,6 +62,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="now-playing" class="now-playing"></div>
|
<div id="now-playing" class="now-playing"></div>
|
||||||
|
|
||||||
|
<!-- Recently Played Tracks -->
|
||||||
|
<div 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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue