diff --git a/core/lastfm_client.py b/core/lastfm_client.py index 7cb9c4f7..07ad33c6 100644 --- a/core/lastfm_client.py +++ b/core/lastfm_client.py @@ -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]: diff --git a/core/listenbrainz_manager.py b/core/listenbrainz_manager.py index e36ee65b..ad964646 100644 --- a/core/listenbrainz_manager.py +++ b/core/listenbrainz_manager.py @@ -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() diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index f21c5654..61d900ee 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -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 diff --git a/web_server.py b/web_server.py index 741869c8..e5cb61cd 100644 --- a/web_server.py +++ b/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) # ======================================== diff --git a/webui/index.html b/webui/index.html index 53ef0c3d..6ec2b572 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3823,6 +3823,35 @@ + +
+