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 *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"
|
||||
|
|
|
|||
|
|
@ -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)))))))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue