Add Last.fm Track Radio to Discover page

Adds a new Last.fm Radio section to the Discover page that lets users
search a track on Last.fm, generate a similar-tracks playlist, and run
it through the existing discovery/download/sync pipeline. Also generates
playlists automatically from top listening history during watchlist scans
(max once per week).

- core/lastfm_client.py: Add get_similar_tracks() using track.getsimilar
- core/listenbrainz_manager.py: Add save_lastfm_radio_playlist() with
  deterministic MBID (MD5 seed), cleanup limit of 5 for lastfm_radio type
- web_server.py: Add /api/lastfm/configured, /api/lastfm/search/tracks,
  /api/lastfm/radio/generate, /api/discover/listenbrainz/lastfm-radio;
  fix playlist['name'] KeyError in discovery worker that was resetting
  phase back to 'fresh' after completion
- core/watchlist_scanner.py: Add _generate_lastfm_radio_playlists() with
  weekly throttle, called at end of scan_all_watchlist_artists()
- webui/index.html: Add #lastfm-radio-section above ListenBrainz section,
  hidden unless Last.fm API key is configured
- webui/static/script.js: Search/generation/card-load functions; fix
  discovery modal labels (Last.fm Radio vs ListenBrainz), description
  update on completion, belt-and-suspenders completion handling inside
  updateYouTubeDiscoveryModal; fix album/duration display for tracks
  without metadata; music note SVG placeholder for missing art
- webui/static/style.css: Styles for search bar, dropdown, result rows
This commit is contained in:
Broque Thomas 2026-04-14 23:41:36 -07:00
parent 1475e3882c
commit 251c27e006
7 changed files with 730 additions and 25 deletions

View file

@ -518,6 +518,38 @@ class LastFMClient:
return None
@rate_limited
def get_similar_tracks(self, artist_name: str, track_title: str, limit: int = 20) -> List[Dict[str, Any]]:
"""
Get tracks similar to the given track.
Returns:
List of track dicts with: name, artist, match (0.01.0), mbid
"""
data = self._make_request('track.getsimilar', {
'artist': artist_name,
'track': track_title,
'autocorrect': 1,
'limit': limit
})
if not data:
return []
tracks = data.get('similartracks', {}).get('track', [])
if not isinstance(tracks, list):
tracks = [tracks] if tracks else []
result = []
for t in tracks:
artist = t.get('artist', {})
result.append({
'name': t.get('name', ''),
'artist': artist.get('name', '') if isinstance(artist, dict) else str(artist),
'match': float(t.get('match', 0)),
'mbid': t.get('mbid', ''),
})
return result
# ── Utility Methods ──
def get_best_image(self, images: List) -> Optional[str]:

View file

@ -331,18 +331,24 @@ class ListenBrainzManager:
conn = self._get_db_connection()
cursor = conn.cursor()
# For each playlist type, keep only the 25 most recent
playlist_types = ['created_for', 'user', 'collaborative']
# For each playlist type, keep only the N most recent
# lastfm_radio keeps fewer since they're auto-regenerated weekly
playlist_type_limits = {
'created_for': 25,
'user': 25,
'collaborative': 25,
'lastfm_radio': 5,
}
for playlist_type in playlist_types:
for playlist_type, keep_count in playlist_type_limits.items():
try:
# Get IDs of playlists to delete (all except 25 most recent)
# Get IDs of playlists to delete (all except keep_count most recent)
cursor.execute("""
SELECT id FROM listenbrainz_playlists
WHERE playlist_type = ? AND profile_id = ?
ORDER BY last_updated DESC
LIMIT -1 OFFSET 25
""", (playlist_type, self.profile_id))
LIMIT -1 OFFSET ?
""", (playlist_type, self.profile_id, keep_count))
old_playlist_ids = [row[0] for row in cursor.fetchall()]
@ -362,6 +368,85 @@ class ListenBrainzManager:
conn.commit()
conn.close()
def save_lastfm_radio_playlist(self, seed_track: str, seed_artist: str, similar_tracks: List[Dict]) -> str:
"""
Persist a Last.fm similar-tracks playlist to the DB under playlist_type='lastfm_radio'.
Uses a deterministic playlist_mbid derived from the seed so re-generating the same
seed upserts (refreshes) rather than creating duplicates.
Args:
seed_track: The seed track title.
seed_artist: The seed artist name.
similar_tracks: List of dicts with {name, artist, match, mbid} from LastFMClient.get_similar_tracks().
Returns:
The playlist_mbid string.
"""
import hashlib
mbid_hash = hashlib.md5(f"{seed_artist.lower()}:{seed_track.lower()}".encode()).hexdigest()[:12]
playlist_mbid = f"lastfm_radio_{mbid_hash}"
title = f"Last.fm Radio: {seed_track} by {seed_artist}"
track_count = len(similar_tracks)
conn = self._get_db_connection()
cursor = conn.cursor()
try:
cursor.execute("""
SELECT id FROM listenbrainz_playlists
WHERE playlist_mbid = ? AND profile_id = ?
""", (playlist_mbid, self.profile_id))
existing = cursor.fetchone()
if existing:
playlist_id = existing[0]
# Delete old tracks so we can re-insert fresh ones
cursor.execute("DELETE FROM listenbrainz_tracks WHERE playlist_id = ?", (playlist_id,))
cursor.execute("""
UPDATE listenbrainz_playlists
SET title = ?, track_count = ?, last_updated = CURRENT_TIMESTAMP
WHERE id = ?
""", (title, track_count, playlist_id))
logger.info(f"Updated Last.fm radio playlist '{title}' ({track_count} tracks)")
else:
cursor.execute("""
INSERT INTO listenbrainz_playlists
(playlist_mbid, title, creator, playlist_type, track_count, annotation_data, profile_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (playlist_mbid, title, 'Last.fm', 'lastfm_radio', track_count, '{}', self.profile_id))
playlist_id = cursor.lastrowid
logger.info(f"Saved new Last.fm radio playlist '{title}' ({track_count} tracks)")
# Insert tracks
for idx, t in enumerate(similar_tracks):
cursor.execute("""
INSERT OR REPLACE INTO listenbrainz_tracks
(playlist_id, position, track_name, artist_name, album_name,
duration_ms, recording_mbid, release_mbid, album_cover_url, additional_metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
playlist_id, idx,
t.get('name', ''),
t.get('artist', ''),
'', # album unknown at this stage
0, # duration unknown
t.get('mbid', '') or '',
'',
None,
'{}',
))
conn.commit()
except Exception as e:
conn.rollback()
logger.error(f"Error saving Last.fm radio playlist: {e}")
raise
finally:
conn.close()
return playlist_mbid
def has_cached_playlists(self) -> bool:
"""Check if there are any cached playlists in the database"""
conn = self._get_db_connection()

View file

@ -695,6 +695,9 @@ class WatchlistScanner:
logger.info("Updating seasonal content...")
self._populate_seasonal_content()
# Generate Last.fm Radio playlists for top tracks (max once per week)
self._generate_lastfm_radio_playlists()
# Sync Spotify library cache (runs after main scan)
try:
if self.spotify_client and self.spotify_client.is_rate_limited():
@ -3352,6 +3355,82 @@ class WatchlistScanner:
import traceback
traceback.print_exc()
def _generate_lastfm_radio_playlists(self):
"""Generate Last.fm Radio playlists from the user's top 3 most-played tracks.
Runs at most once per week (throttled via config key 'lastfm_radio.last_generated').
Requires a Last.fm API key to be configured.
Stores playlists in DB under playlist_type='lastfm_radio' via ListenBrainzManager.
"""
try:
from datetime import datetime, timedelta
from config.settings import config_manager
from database.music_database import get_database
# Weekly throttle
last_generated_str = config_manager.get('lastfm_radio.last_generated', '')
if last_generated_str:
try:
last_generated = datetime.fromisoformat(last_generated_str)
if datetime.now() - last_generated < timedelta(days=7):
logger.info("Last.fm radio: skipping — generated within the last 7 days")
return
except ValueError:
pass # Malformed timestamp — proceed
# Require Last.fm API key
api_key = config_manager.get('lastfm.api_key', '')
if not api_key:
logger.info("Last.fm radio: skipping — no API key configured")
return
# Get top 3 most-played tracks over the last 30 days
db = get_database()
top_tracks = db.get_top_tracks(time_range='30d', limit=3)
if not top_tracks:
logger.info("Last.fm radio: skipping — no listening history found")
return
logger.info(f"Last.fm radio: generating playlists for {len(top_tracks)} top tracks")
from core.lastfm_client import LastFMClient
from core.listenbrainz_manager import ListenBrainzManager
client = LastFMClient(api_key=api_key)
# Use profile_id=1 as a sensible default; the scanner runs globally
lb_manager = ListenBrainzManager(str(db.database_path), profile_id=1)
generated = 0
for track in top_tracks:
track_name = track.get('name', '')
artist_name = track.get('artist', '')
if not track_name or not artist_name:
continue
try:
similar = client.get_similar_tracks(artist_name, track_name, limit=25)
if not similar:
logger.info(f"Last.fm radio: no similar tracks for '{artist_name} - {track_name}'")
continue
playlist_mbid = lb_manager.save_lastfm_radio_playlist(track_name, artist_name, similar)
logger.info(
f"Last.fm radio: saved '{track_name}' by '{artist_name}' "
f"{playlist_mbid} ({len(similar)} tracks)"
)
generated += 1
except Exception as track_err:
logger.warning(f"Last.fm radio: error processing '{track_name}': {track_err}")
if generated > 0:
config_manager.set('lastfm_radio.last_generated', datetime.now().isoformat())
logger.info(f"Last.fm radio: generated {generated} playlists, throttle updated")
except Exception as e:
logger.error(f"Error in _generate_lastfm_radio_playlists: {e}")
import traceback
traceback.print_exc()
# Singleton instance
_watchlist_scanner_instance = None

View file

@ -37127,7 +37127,7 @@ def _run_listenbrainz_discovery_worker(state_key):
state['status'] = 'complete'
state['discovery_progress'] = 100
playlist_name = playlist['name']
playlist_name = playlist.get('name') or playlist.get('title') or 'Unknown Playlist'
source_label = discovery_source.upper()
add_activity_item("", f"ListenBrainz Discovery Complete ({source_label})", f"'{playlist_name}' - {state['spotify_matches']}/{len(tracks)} tracks found", "Now")
@ -45100,6 +45100,185 @@ def refresh_listenbrainz():
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
# ========================================
# LAST.FM TRACK RADIO
# ========================================
@app.route('/api/lastfm/configured', methods=['GET'])
def lastfm_configured():
"""Return whether a Last.fm API key is configured (used to gate the Radio section)."""
lf = lastfm_worker.client if lastfm_worker else None
return jsonify({"configured": bool(lf and lf.api_key)})
@app.route('/api/lastfm/search/tracks', methods=['GET'])
def lastfm_search_tracks():
"""Search Last.fm for tracks matching a query string.
Query params:
q: search query (track name, artist name, or both)
Returns:
JSON list of {name, artist, mbid, listeners}
"""
try:
q = request.args.get('q', '').strip()
if not q or len(q) < 2:
return jsonify({"success": False, "error": "Query too short", "results": []}), 400
lf = lastfm_worker.client if lastfm_worker else None
if not lf or not lf.api_key:
return jsonify({"success": False, "error": "Last.fm not configured", "results": []}), 400
# Use raw API call to get multiple results (search_track only returns best match)
data = lf._make_request('track.search', {'track': q, 'limit': 8})
if not data:
return jsonify({"success": True, "results": []})
raw = data.get('results', {}).get('trackmatches', {}).get('track', [])
if not isinstance(raw, list):
raw = [raw] if raw else []
results = []
for t in raw:
# Last.fm image array: [{#text: url, size: small/medium/large/extralarge}]
image_url = lf.get_best_image(t.get('image', []))
results.append({
'name': t.get('name', ''),
'artist': t.get('artist', ''),
'mbid': t.get('mbid', ''),
'listeners': int(t.get('listeners', 0)),
'image_url': image_url or '',
})
return jsonify({"success": True, "results": results})
except Exception as e:
print(f"Error searching Last.fm tracks: {e}")
return jsonify({"success": False, "error": str(e), "results": []}), 500
@app.route('/api/lastfm/radio/generate', methods=['POST'])
def lastfm_radio_generate():
"""Generate a Last.fm Radio playlist from a seed track.
Body JSON:
track_name: seed track title
artist_name: seed artist name
Creates/updates a 'lastfm_radio' playlist in the DB and adds it to
listenbrainz_playlist_states in 'fresh' phase, ready for discovery.
Returns:
{success, playlist_mbid, title, track_count}
"""
try:
data = request.get_json() or {}
track_name = (data.get('track_name') or '').strip()
artist_name = (data.get('artist_name') or '').strip()
if not track_name or not artist_name:
return jsonify({"success": False, "error": "track_name and artist_name are required"}), 400
lf = lastfm_worker.client if lastfm_worker else None
if not lf or not lf.api_key:
return jsonify({"success": False, "error": "Last.fm not configured"}), 400
# Fetch similar tracks from Last.fm
similar = lf.get_similar_tracks(artist_name, track_name, limit=25)
if not similar:
return jsonify({"success": False, "error": "No similar tracks found on Last.fm"}), 404
# Persist to DB via manager
lb_manager, _username, _source = _get_profile_lb_manager()
playlist_mbid = lb_manager.save_lastfm_radio_playlist(track_name, artist_name, similar)
title = f"Last.fm Radio: {track_name} by {artist_name}"
# Build playlist dict that mirrors the LB playlist format expected by the discovery pipeline
playlist_data = {
'identifier': f"lastfm_radio/{playlist_mbid}",
'name': title,
'title': title,
'creator': 'Last.fm',
'tracks': [
{
'track_name': t['name'],
'artist_name': t['artist'],
'album_name': '',
'duration_ms': 0,
}
for t in similar
],
}
# Upsert into in-memory state (fresh phase — not yet discovered)
state_key = _lb_state_key(playlist_mbid)
if state_key not in listenbrainz_playlist_states:
listenbrainz_playlist_states[state_key] = {
'playlist_mbid': playlist_mbid,
'playlist': playlist_data,
'phase': 'fresh',
'status': 'fresh',
'discovery_progress': 0,
'spotify_matches': 0,
'spotify_total': len(similar),
'discovery_results': [],
'created_at': time.time(),
'last_accessed': time.time(),
}
else:
# Refresh existing state (new seed data) but preserve phase if already discovered
state = listenbrainz_playlist_states[state_key]
if state['phase'] not in ('discovering',):
state['playlist'] = playlist_data
state['spotify_total'] = len(similar)
state['last_accessed'] = time.time()
print(f"Last.fm Radio generated: '{title}' ({len(similar)} tracks) → {playlist_mbid}")
return jsonify({
"success": True,
"playlist_mbid": playlist_mbid,
"title": title,
"track_count": len(similar),
})
except Exception as e:
print(f"Error generating Last.fm radio: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/listenbrainz/lastfm-radio', methods=['GET'])
def get_listenbrainz_lastfm_radio():
"""Get cached Last.fm Radio playlists (from DB cache).
Does NOT require ListenBrainz authentication Last.fm Radio playlists are
generated independently of the LB account.
"""
try:
lb_manager, username, source = _get_profile_lb_manager()
playlists = lb_manager.get_cached_playlists('lastfm_radio')
formatted = [
{
"playlist": {
"identifier": f"https://listenbrainz.org/playlist/{p['playlist_mbid']}",
"title": p['title'],
"creator": p['creator'],
"annotation": p.get('annotation', {}),
"track": [],
}
}
for p in playlists
]
return jsonify({"success": True, "playlists": formatted, "count": len(formatted), "username": username, "source": source})
except Exception as e:
print(f"Error getting Last.fm radio playlists: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
# ========================================
# LISTENBRAINZ PLAYLIST MANAGEMENT (Discovery System)
# ========================================

View file

@ -3823,6 +3823,35 @@
</div>
</div>
<!-- Last.fm Track Radio (hidden until Last.fm API key configured) -->
<div class="discover-section" id="lastfm-radio-section" style="display:none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">📻 Last.fm Radio</h2>
<p class="discover-section-subtitle">Search a track to generate a similar-tracks playlist</p>
</div>
</div>
<!-- Search bar -->
<div class="lastfm-radio-search" id="lastfm-radio-search-section">
<div class="lastfm-radio-search-row">
<div class="lastfm-radio-input-wrap">
<input type="text" id="lastfm-radio-input"
placeholder="Search a track to generate a radio..."
autocomplete="off"
oninput="debouncedLastfmTrackSearch(this.value)"
onkeydown="if(event.key==='Escape')clearLastfmRadioSelection()">
<div id="lastfm-radio-dropdown" class="lastfm-radio-dropdown" style="display:none;"></div>
</div>
</div>
</div>
<!-- Generated radio playlist cards -->
<div id="lastfm-radio-playlists">
<!-- Populated dynamically -->
</div>
</div>
<!-- ListenBrainz Playlists (Tabbed) -->
<div class="discover-section">
<div class="discover-section-header">

View file

@ -32744,20 +32744,23 @@ function openYouTubeDiscoveryModal(urlHash) {
const isBeatport = state.is_beatport_playlist;
const isListenBrainz = state.is_listenbrainz_playlist;
const isMirrored = state.is_mirrored_playlist;
const isLastfmRadio = typeof urlHash === 'string' && urlHash.startsWith('lastfm_radio_');
const modalTitle = isMirrored ? '🎵 Mirrored Playlist Discovery' :
isSpotifyPublic ? '🎵 Spotify Playlist Discovery' :
isDeezer ? '🎵 Deezer Playlist Discovery' :
isTidal ? '🎵 Tidal Playlist Discovery' :
isBeatport ? '🎵 Beatport Chart Discovery' :
isListenBrainz ? '🎵 ListenBrainz Playlist Discovery' :
'🎵 YouTube Playlist Discovery';
isLastfmRadio ? '📻 Last.fm Radio Discovery' :
isListenBrainz ? '🎵 ListenBrainz Playlist Discovery' :
'🎵 YouTube Playlist Discovery';
const sourceLabel = isMirrored ? (state.mirrored_source ? state.mirrored_source.charAt(0).toUpperCase() + state.mirrored_source.slice(1) : 'Source') :
isSpotifyPublic ? 'Spotify' :
isDeezer ? 'Deezer' :
isTidal ? 'Tidal' :
isBeatport ? 'Beatport' :
isListenBrainz ? 'LB' :
'YT';
isLastfmRadio ? 'Last.fm' :
isListenBrainz ? 'LB' :
'YT';
const modalHtml = `
<div class="modal-overlay" id="youtube-discovery-modal-${urlHash}">
@ -32765,7 +32768,7 @@ function openYouTubeDiscoveryModal(urlHash) {
<div class="modal-header">
<h2>${modalTitle}</h2>
<div class="modal-subtitle">${state.playlist.name} (${state.playlist.tracks.length} tracks)</div>
<div class="modal-description">${getModalDescription(state.phase, isTidal, isBeatport, isListenBrainz, isMirrored, isDeezer, isSpotifyPublic)}</div>
<div class="modal-description">${getModalDescription(state.phase, isTidal, isBeatport, isListenBrainz, isMirrored, isDeezer, isSpotifyPublic, isLastfmRadio)}</div>
<button class="modal-close-btn" onclick="closeYouTubeDiscoveryModal('${urlHash}')"></button>
</div>
@ -33186,8 +33189,8 @@ function getModalActionButtons(urlHash, phase, state = null) {
}
}
function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false) {
const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isDeezer ? 'Deezer' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')))));
function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false, isLastfmRadio = false) {
const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isDeezer ? 'Deezer' : (isLastfmRadio ? 'Last.fm Radio' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube'))))));
switch (phase) {
case 'fresh':
return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`;
@ -33378,15 +33381,26 @@ function updateYouTubeDiscoveryModal(urlHash, status) {
}
});
// Update action buttons if discovery is complete (progress = 100%)
if (status.progress >= 100) {
// Update action buttons and description when discovery is complete.
// status.complete is explicitly set by LB/WS polling callers; only act when transitioning
// from 'discovering' to avoid interfering with download/sync phases of other playlist types.
if (status.complete) {
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
if (state && state.phase === 'discovered') {
if (state && state.phase === 'discovering') {
state.phase = 'discovered';
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
if (actionButtonsContainer) {
actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state);
console.log(`✨ Updated action buttons for completed discovery: ${urlHash}`);
}
const descEl = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-description`);
if (descEl) descEl.textContent = 'Discovery complete! View the results below.';
} else if (state && state.phase === 'discovered') {
// Already discovered — ensure buttons are correct (e.g. after rehydration)
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
if (actionButtonsContainer && actionButtonsContainer.querySelector('.modal-info')) {
actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state);
}
}
}
}
@ -34076,6 +34090,8 @@ function startListenBrainzDiscoveryPolling(playlistMbid) {
socket.emit('discovery:unsubscribe', { ids: [playlistMbid] }); delete _discoveryProgressCallbacks[playlistMbid];
if (listenbrainzPlaylistStates[playlistMbid]) listenbrainzPlaylistStates[playlistMbid].phase = 'discovered';
updateYouTubeModalButtons(playlistMbid, 'discovered');
const _descElWs = document.querySelector(`#youtube-discovery-modal-${playlistMbid} .modal-description`);
if (_descElWs) _descElWs.textContent = 'Discovery complete! View the results below.';
const playlistIdEl = `discover-lb-playlist-${playlistMbid}`;
const syncBtn = document.getElementById(`${playlistIdEl}-sync-btn`);
if (syncBtn) syncBtn.style.display = 'inline-block';
@ -34158,6 +34174,10 @@ function startListenBrainzDiscoveryPolling(playlistMbid) {
// Update modal buttons to show sync and download buttons
updateYouTubeModalButtons(playlistMbid, 'discovered');
// Update modal description to "Discovery complete!"
const descEl = document.querySelector(`#youtube-discovery-modal-${playlistMbid} .modal-description`);
if (descEl) descEl.textContent = 'Discovery complete! View the results below.';
// Show sync button in playlist listing (hidden by default until discovered)
const playlistId = `discover-lb-playlist-${playlistMbid}`;
const syncBtn = document.getElementById(`${playlistId}-sync-btn`);
@ -52456,6 +52476,7 @@ async function loadDiscoverPage() {
loadCacheLabelExplorer(), // From metadata cache
loadCacheDeepCuts(), // From metadata cache
loadCacheGenreExplorer(), // From metadata cache
initializeLastfmRadioSection(), // Last.fm Radio section (gated on API key)
initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed)
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre)
@ -55236,27 +55257,189 @@ let listenbrainzTracksCache = {}; // Store tracks for each playlist
let activeListenBrainzTab = 'recommendations'; // Track active tab
let activeListenBrainzSubTab = null; // Track active sub-tab within recommendations
// ── Last.fm Track Radio ──────────────────────────────────────────────────────
let _lastfmRadioDebounceTimer = null;
let _lastfmRadioSelected = null; // {name, artist}
function debouncedLastfmTrackSearch(query) {
clearTimeout(_lastfmRadioDebounceTimer);
const q = (query || '').trim();
if (!q) {
document.getElementById('lastfm-radio-dropdown').style.display = 'none';
return;
}
_lastfmRadioDebounceTimer = setTimeout(() => _runLastfmTrackSearch(q), 400);
}
async function _runLastfmTrackSearch(q) {
if (q.length < 2) return;
const dropdown = document.getElementById('lastfm-radio-dropdown');
// Show a mini spinner while fetching
dropdown.innerHTML = '<div class="lastfm-radio-searching"><div class="server-search-spinner" style="width:14px;height:14px;margin:0 auto;"></div></div>';
dropdown.style.display = 'block';
try {
const res = await fetch(`/api/lastfm/search/tracks?q=${encodeURIComponent(q)}`);
if (!res.ok) { dropdown.style.display = 'none'; return; }
const data = await res.json();
if (!data.results || data.results.length === 0) {
dropdown.style.display = 'none';
return;
}
dropdown.innerHTML = data.results.map(t => {
const imgHtml = t.image_url
? `<img src="${t.image_url}" alt="" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\'lastfm-radio-art-empty\\'></div>'">`
: '<div class="lastfm-radio-art-empty"></div>';
const listeners = t.listeners > 0
? `<span class="lastfm-radio-result-listeners">${(t.listeners / 1000).toFixed(0)}k listeners</span>`
: '';
return `
<div class="lastfm-radio-result" onclick="selectLastfmRadioTrack(decodeURIComponent('${encodeURIComponent(t.name)}'), decodeURIComponent('${encodeURIComponent(t.artist)}'))">
<div class="lastfm-radio-result-art">${imgHtml}</div>
<div class="lastfm-radio-result-meta">
<span class="lastfm-radio-result-track">${t.name}</span>
<span class="lastfm-radio-result-artist">${t.artist}${listeners ? ' · ' + t.listeners.toLocaleString() + ' listeners' : ''}</span>
</div>
</div>`;
}).join('');
dropdown.style.display = 'block';
} catch (e) {
console.error('Last.fm search error:', e);
dropdown.style.display = 'none';
}
}
function selectLastfmRadioTrack(name, artist) {
// Close dropdown and update input to show selection
document.getElementById('lastfm-radio-dropdown').style.display = 'none';
document.getElementById('lastfm-radio-input').value = `${name}${artist}`;
document.getElementById('lastfm-radio-input').blur();
// Immediately kick off generation
_generateLastfmRadioFor(name, artist);
}
function clearLastfmRadioSelection() {
document.getElementById('lastfm-radio-input').value = '';
document.getElementById('lastfm-radio-dropdown').style.display = 'none';
}
// Keep generateLastfmRadio as public alias (called by nothing now but harmless)
async function generateLastfmRadio() {
const input = (document.getElementById('lastfm-radio-input').value || '').trim();
if (!input) return;
// Parse "Track — Artist" format if present
const parts = input.split(' — ');
if (parts.length >= 2) {
await _generateLastfmRadioFor(parts[0].trim(), parts[1].trim());
}
}
async function _generateLastfmRadioFor(name, artist) {
const container = document.getElementById('lastfm-radio-playlists');
const input = document.getElementById('lastfm-radio-input');
// Show loading state in the playlists area
if (container) {
container.innerHTML = `
<div class="lastfm-radio-generating">
<div class="server-search-spinner"></div>
<p>Building radio for <strong>${name}</strong> by <strong>${artist}</strong></p>
</div>`;
}
if (input) input.disabled = true;
try {
const res = await fetch('/api/lastfm/radio/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_name: name, artist_name: artist }),
});
const data = await res.json();
if (!data.success) {
if (container) container.innerHTML = '';
showToast(data.error || 'Failed to generate radio', 'error');
return;
}
// Reload all radio playlist cards
await _loadLastfmRadioPlaylists();
} catch (e) {
if (container) container.innerHTML = '';
showToast('Error generating Last.fm radio', 'error');
console.error(e);
} finally {
if (input) input.disabled = false;
}
}
async function initializeLastfmRadioSection() {
try {
const cfgRes = await fetch('/api/lastfm/configured');
if (!cfgRes.ok) return;
const { configured } = await cfgRes.json();
const section = document.getElementById('lastfm-radio-section');
if (!section) return;
if (!configured) {
section.style.display = 'none';
return;
}
section.style.display = '';
await _loadLastfmRadioPlaylists();
} catch (e) {
console.error('Error initializing Last.fm Radio section:', e);
}
}
async function _loadLastfmRadioPlaylists() {
const container = document.getElementById('lastfm-radio-playlists');
if (!container) return;
try {
const res = await fetch('/api/discover/listenbrainz/lastfm-radio');
if (!res.ok) return;
const data = await res.json();
if (!data.success || !data.playlists || data.playlists.length === 0) {
container.innerHTML = '';
return;
}
// Reuse the same LB playlist card builder — cards are identical
container.innerHTML = buildListenBrainzPlaylistsHtml(data.playlists, 'lastfm_radio');
loadTracksForPlaylists(data.playlists);
} catch (e) {
console.error('Error loading Last.fm radio playlists:', e);
}
}
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
const section = document.getElementById('lastfm-radio-search-section');
if (section && !section.contains(e.target)) {
const dd = document.getElementById('lastfm-radio-dropdown');
if (dd) dd.style.display = 'none';
}
});
// ────────────────────────────────────────────────────────────────────────────
async function initializeListenBrainzTabs() {
try {
console.log('🧠 Initializing ListenBrainz tabs...');
// Fetch all playlists types
// Fetch all playlist types
const [createdForRes, userPlaylistsRes, collaborativeRes] = await Promise.all([
fetch('/api/discover/listenbrainz/created-for'),
fetch('/api/discover/listenbrainz/user-playlists'),
fetch('/api/discover/listenbrainz/collaborative')
fetch('/api/discover/listenbrainz/collaborative'),
]);
console.log('📡 API Responses:', {
createdFor: createdForRes.status,
userPlaylists: userPlaylistsRes.status,
collaborative: collaborativeRes.status
collaborative: collaborativeRes.status,
});
const tabs = [
{ id: 'recommendations', label: '🎁 Recommendations', hasData: false },
{ id: 'user', label: '📚 Your Playlists', hasData: false },
{ id: 'collaborative', label: '🤝 Collaborative', hasData: false }
{ id: 'collaborative', label: '🤝 Collaborative', hasData: false },
];
// Track LB username for header display
@ -55699,17 +55882,17 @@ function displayListenBrainzTracks(tracks, playlistId) {
metaElement.textContent = `by ${creator}${tracks.length} track${tracks.length !== 1 ? 's' : ''}`;
}
// Simple SVG placeholder for missing album art
const placeholderImage = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiMzMzMiLz48cGF0aCBkPSJNMjAgMTBDMTguMzQzMSAxMCAxNyAxMS4zNDMxIDE3IDEzQzE3IDE0LjY1NjkgMTguMzQzMSAxNiAyMCAxNkMyMS42NTY5IDE2IDIzIDE0LjY1NjkgMjMgMTNDMjMgMTEuMzQzMSAyMS42NTY5IDEwIDIwIDEwWk0yNSAyMEgyNUMyNSAxOC44OTU0IDI0LjEwNDYgMTggMjMgMThIMTdDMTUuODk1NCAxOCAxNSAxOC44OTU0IDE1IDIwVjI4QzE1IDI5LjEwNDYgMTUuODk1NCAzMCAxNyAzMEgyM0MyNC4xMDQ2IDMwIDI1IDI5LjEwNDYgMjUgMjhWMjBaIiBmaWxsPSIjNjY2Ii8+PC9zdmc+';
// Simple SVG placeholder for missing album art (music note icon)
const placeholderImage = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiMyYTJhMmEiLz48cGF0aCBkPSJNMjQgMTJ2MTIuNUEzLjUgMy41IDAgMSAxIDIwLjUgMjFWMTZsLTUgMXY5YTMuNSAzLjUgMCAxIDEtMy41LTMuNVYxM2wxMi0zeiIgZmlsbD0iIzU1NSIvPjwvc3ZnPg==';
let html = '<div class="discover-playlist-tracks-compact">';
tracks.forEach((track, index) => {
const coverUrl = track.album_cover_url || placeholderImage;
const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
const duration = track.duration_ms > 0 ? `${durationMin}:${durationSec.toString().padStart(2, '0')}` : '';
const albumName = escapeHtml(track.album_name || 'Unknown Album');
const albumName = track.album_name ? escapeHtml(track.album_name) : '';
html += `
<div class="discover-playlist-track-compact" data-track-index="${index}">

View file

@ -40896,6 +40896,124 @@ body.downloads-disabled [onclick*="DownloadMissing"]:not([onclick*="close"]) {
text-decoration: underline;
}
/* Last.fm Track Radio — search bar & dropdown */
.lastfm-radio-search {
margin-bottom: 16px;
}
.lastfm-radio-search-row {
position: relative;
}
.lastfm-radio-input-wrap {
position: relative;
}
.lastfm-radio-input-wrap input {
width: 100%;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
color: #fff;
font-size: 13px;
padding: 9px 14px;
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}
.lastfm-radio-input-wrap input:focus {
border-color: rgba(255,255,255,0.28);
}
.lastfm-radio-input-wrap input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.lastfm-radio-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: #1a1a2a;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
z-index: 200;
overflow: hidden;
box-shadow: 0 12px 32px rgba(0,0,0,0.5);
padding: 4px;
}
.lastfm-radio-searching {
padding: 12px;
display: flex;
justify-content: center;
}
.lastfm-radio-result {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s;
}
.lastfm-radio-result:hover {
background: rgba(255,255,255,0.07);
}
.lastfm-radio-result-art {
width: 40px;
height: 40px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background: rgba(255,255,255,0.05);
}
.lastfm-radio-result-art img {
width: 100%;
height: 100%;
object-fit: cover;
}
.lastfm-radio-art-empty {
width: 100%;
height: 100%;
background: rgba(255,255,255,0.05);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: rgba(255,255,255,0.2);
}
.lastfm-radio-art-empty::after { content: '♪'; }
.lastfm-radio-result-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.lastfm-radio-result-track {
font-size: 13px;
color: #fff;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lastfm-radio-result-artist {
font-size: 11px;
color: rgba(255,255,255,0.45);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Loading state shown in the playlists container while generating */
.lastfm-radio-generating {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 32px 16px;
color: rgba(255,255,255,0.5);
font-size: 13px;
}
.lastfm-radio-generating .server-search-spinner {
margin: 0;
}
/* ===========================
AUTOMATIONS PAGE
=========================== */