Compare commits

...

7 Commits

Author SHA1 Message Date
Glenn Thompson 06c5904ecd Refine recently played styling and MusicBrainz search
- Use 2-column grid layout: track/artist left, time/link right
- Match color scheme with now-playing section (blue text)
- Tighter row spacing (6px padding)
- Simplified MusicBrainz search query (no field prefixes)
- Fix CSS selector for proper link styling
- Right-align time and MusicBrainz link
2025-11-18 07:16:07 +03:00
Glenn Thompson 4fd35db924 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.
2025-11-18 07:16:07 +03:00
Glenn Thompson c198775083 Merge remote-tracking branch 'upstream/main' 2025-11-18 07:15:32 +03:00
Luis Pereira 559187df2e fix: with-error-handling using inner message
This fix some issues where, on the client, `response.message` was `Ok.`
for error responses and real error message needed to be extracted from
`response.data.message`, which made a weird API.
2025-11-17 18:08:12 -05:00
Luis Pereira 59076e67b8 fix: profile password change using non existing function 2025-11-17 18:08:12 -05:00
Glenn Thompson 2a505e482d Fix scan-library path to work in both development and production
- Auto-detect music library path based on environment
- Check for music/library/ directory for local development
- Default to /app/music/ for production Docker deployment
- Allow MUSIC_LIBRARY_PATH environment variable override
- Fixes scan-library function failing on production server
2025-11-17 18:06:14 -05:00
Glenn Thompson 19b9deccf5 fix: Use /app/music/ as default music library path for production
- Changed hardcoded music/library/ path to /app/music/ (production path)
- Added MUSIC_LIBRARY_PATH environment variable for local dev override
- Fixes scan library function on production server
- Aligns with path structure used in M3U playlists and liquidsoap config
2025-11-17 18:06:14 -05:00
9 changed files with 405 additions and 4 deletions

View File

@ -16,11 +16,21 @@
;; configuration logic. Probably using 'ubiquity
(defparameter *server-port* 8080)
(defparameter *music-library-path*
(merge-pathnames "music/library/"
(asdf:system-source-directory :asteroid)))
(or (uiop:getenv "MUSIC_LIBRARY_PATH")
;; Default to /app/music/ for production Docker, but check if music/library/ exists for local dev
(if (probe-file (merge-pathnames "music/library/" (asdf:system-source-directory :asteroid)))
(merge-pathnames "music/library/" (asdf:system-source-directory :asteroid))
"/app/music/")))
(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"
@ -30,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
@ -143,6 +187,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

@ -127,7 +127,7 @@
(error 'authentication-error :message "Not authenticated"))
;; Verify current password
(unless (verify-user-credentials username current-password)
(unless (authenticate-user username current-password)
(error 'authentication-error :message "Current password is incorrect"))
;; Update password

View File

@ -109,39 +109,47 @@
(not-found-error (e)
(api-output `(("status" . "error")
("message" . ,(error-message e)))
:message (error-message e)
:status 404))
(authentication-error (e)
(api-output `(("status" . "error")
("message" . ,(error-message e)))
:message (error-message e)
:status 401))
(authorization-error (e)
(api-output `(("status" . "error")
("message" . ,(error-message e)))
:message (error-message e)
:status 403))
(validation-error (e)
(api-output `(("status" . "error")
("message" . ,(error-message e)))
:message (error-message e)
:status 400))
(database-error (e)
(format t "Database error: ~a~%" e)
(api-output `(("status" . "error")
("message" . "Database operation failed"))
:message "Database operation failed"
:status 500))
(asteroid-stream-error (e)
(format t "Stream error: ~a~%" e)
(api-output `(("status" . "error")
("message" . "Stream operation failed"))
:message "Stream operation failed"
:status 500))
(asteroid-error (e)
(format t "Asteroid error: ~a~%" e)
(api-output `(("status" . "error")
("message" . ,(error-message e)))
:message (error-message e)
:status 500))
(error (e)
(format t "Unexpected error: ~a~%" e)
(api-output `(("status" . "error")
("message" . "An unexpected error occurred"))
:status 500))))
:status 500
:message "An unexpected error occurred"))))
(defmacro with-db-error-handling (operation &body body)
"Wrap database operations with error handling.

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,131 @@ 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: 6px 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: #2a3441;
}
body .recently-played-panel .recently-played-list.track-info{
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
gap: 2px 20px;
align-items: center;
}
body .recently-played-panel .recently-played-list.track-title{
color: #4488ff;
font-weight: 500;
font-size: 1em;
grid-column: 1;
grid-row: 1;
}
body .recently-played-panel .recently-played-list.track-artist{
color: #4488ff;
font-size: 0.9em;
grid-column: 1;
grid-row: 2;
}
body .recently-played-panel .recently-played-list.track-time{
color: #888;
font-size: 0.85em;
grid-column: 2;
grid-row: 1;
text-align: right;
}
body .recently-played-panel .recently-played-list.track-meta{
grid-column: 2;
grid-row: 2;
text-align: right;
}
body .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;
-moz-transition: all 0.2s;
-o-transition: all 0.2s;
-webkit-transition: all 0.2s;
-ms-transition: all 0.2s;
transition: all 0.2s;
white-space: nowrap;
}
body .recently-played-list .allmusic-link HOVER{
color: #00ff00;
text-decoration: underline;
text-underline-offset: 5px;
}
body .recently-played-list .allmusic-link svg{
width: 14px;
height: 14px;
}
body .back{
color: #00ffff;
text-decoration: none;

View File

@ -123,6 +123,98 @@
: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 "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

@ -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>
<span class="track-time">${timeAgo}</span>
<div class="track-meta">
<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>