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:
Glenn Thompson 2025-11-17 14:16:06 +03:00 committed by Brian O'Reilly
parent a1cfaf468c
commit 0a7d5c3de5
7 changed files with 365 additions and 0 deletions

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"
;; 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 endpoints are accessed at /api/<name> automatically
;; They use lambda-lists for parameters and api-output for responses
@ -135,6 +176,27 @@
("message" . "Playlist not found"))
: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)
(define-api asteroid/tracks () ()
"Get all tracks for web player"

View File

@ -33,6 +33,13 @@
(aref groups 0)
"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*))
(:title . ,title)
(:listeners . ,total-listeners)))))))

View File

@ -143,6 +143,118 @@ body .now-playing{
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{
color: #00ffff;
text-decoration: none;

View File

@ -123,6 +123,86 @@
:color "#4488ff"
: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
:color "#00ffff"
:text-decoration none

View File

@ -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);
}
}
});

View File

@ -7,6 +7,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">
@ -35,6 +36,14 @@
</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>
</div>
</body>

View File

@ -7,6 +7,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">
@ -61,6 +62,14 @@
</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>
</div>
</body>