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:
parent
1475e3882c
commit
251c27e006
7 changed files with 730 additions and 25 deletions
|
|
@ -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.0–1.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]:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
181
web_server.py
181
web_server.py
|
|
@ -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)
|
||||
# ========================================
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
=========================== */
|
||||
|
|
|
|||
Loading…
Reference in a new issue