From dc7140c45967931e78d3ec115bab30fe3a049ff5 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:12:36 -0700 Subject: [PATCH] Add Last.fm and Genius to on-demand enrichment, settings reload, and enrich dropdown parity --- core/genius_worker.py | 496 ++++++++++++++++++++++++++++++ core/lastfm_client.py | 23 +- core/lastfm_worker.py | 607 +++++++++++++++++++++++++++++++++++++ database/music_database.py | 106 +++++++ web_server.py | 219 ++++++++++++- webui/index.html | 44 +++ webui/static/script.js | 247 ++++++++++++++- webui/static/style.css | 386 +++++++++++++++++++++++ 8 files changed, 2117 insertions(+), 11 deletions(-) create mode 100644 core/genius_worker.py create mode 100644 core/lastfm_worker.py diff --git a/core/genius_worker.py b/core/genius_worker.py new file mode 100644 index 00000000..d4c6b73d --- /dev/null +++ b/core/genius_worker.py @@ -0,0 +1,496 @@ +import json +import re +import threading +import time +from difflib import SequenceMatcher +from typing import Optional, Dict, Any +from datetime import datetime, timedelta +from utils.logging_config import get_logger +from database.music_database import MusicDatabase +from core.genius_client import GeniusClient +from config.settings import config_manager + +logger = get_logger("genius_worker") + + +class GeniusWorker: + """Background worker for enriching library artists and tracks with Genius metadata. + + Enriches: + - Artists: Genius ID, description, alternate names, image + - Tracks: Genius ID, lyrics, description, song art URL + Note: Genius is song/artist-focused — album enrichment is minimal (ID only from song data). + """ + + def __init__(self, database: MusicDatabase): + self.db = database + self._init_client() + + # Worker state + self.running = False + self.paused = False + self.should_stop = False + self.thread = None + + # Current item being processed (for UI tooltip) + self.current_item = None + + # Statistics + self.stats = { + 'matched': 0, + 'not_found': 0, + 'pending': 0, + 'errors': 0 + } + + # Retry configuration + self.retry_days = 30 + self.error_retry_days = 7 + + # Name matching threshold + self.name_similarity_threshold = 0.75 # Slightly lower — Genius titles often include featured artists + + logger.info("Genius background worker initialized") + + def _init_client(self): + """Initialize or reinitialize the Genius client from config""" + access_token = config_manager.get('genius.access_token', '') + self.client = GeniusClient(access_token=access_token) + + def start(self): + """Start the background worker""" + if self.running: + logger.warning("Worker already running") + return + + self.running = True + self.should_stop = False + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + logger.info("Genius background worker started") + + def stop(self): + """Stop the background worker""" + if not self.running: + return + + logger.info("Stopping Genius worker...") + self.should_stop = True + self.running = False + + if self.thread: + self.thread.join(timeout=5) + + logger.info("Genius worker stopped") + + def pause(self): + """Pause the worker""" + if not self.running: + logger.warning("Worker not running, cannot pause") + return + self.paused = True + logger.info("Genius worker paused") + + def resume(self): + """Resume the worker""" + if not self.running: + logger.warning("Worker not running, start it first") + return + self.paused = False + logger.info("Genius worker resumed") + + def get_stats(self) -> Dict[str, Any]: + """Get current statistics""" + self.stats['pending'] = self._count_pending_items() + progress = self._get_progress_breakdown() + is_actually_running = self.running and (self.thread is not None and self.thread.is_alive()) + is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None + + return { + 'enabled': True, + 'running': is_actually_running and not self.paused, + 'paused': self.paused, + 'idle': is_idle, + 'current_item': self.current_item, + 'stats': self.stats.copy(), + 'progress': progress + } + + def _run(self): + """Main worker loop""" + logger.info("Genius worker thread started") + + while not self.should_stop: + try: + if self.paused: + time.sleep(1) + continue + + # Check if access token is configured + if not self.client.access_token: + self._init_client() + if not self.client.access_token: + time.sleep(30) + continue + + self.current_item = None + item = self._get_next_item() + + if not item: + logger.debug("No pending items, sleeping...") + time.sleep(10) + continue + + self.current_item = item + self._process_item(item) + + # Genius rate limiting is conservative (500ms per call) + lyrics scraping + time.sleep(1) + + except Exception as e: + logger.error(f"Error in worker loop: {e}") + time.sleep(5) + + logger.info("Genius worker thread finished") + + def _get_next_item(self) -> Optional[Dict[str, Any]]: + """Get next item to process from priority queue. + Genius is artist+track focused — we skip album-level processing + since Genius doesn't have direct album endpoints.""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + # Priority 1: Unattempted artists + cursor.execute(""" + SELECT id, name + FROM artists + WHERE genius_match_status IS NULL + ORDER BY id ASC + LIMIT 1 + """) + row = cursor.fetchone() + if row: + return {'type': 'artist', 'id': row[0], 'name': row[1]} + + # Priority 2: Unattempted tracks (skip albums — Genius is song-centric) + cursor.execute(""" + SELECT t.id, t.title, ar.name AS artist_name + FROM tracks t + JOIN artists ar ON t.artist_id = ar.id + WHERE t.genius_match_status IS NULL + ORDER BY t.id ASC + LIMIT 1 + """) + row = cursor.fetchone() + if row: + return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2]} + + # Priority 3: Retry artists + not_found_cutoff = datetime.now() - timedelta(days=self.retry_days) + error_cutoff = datetime.now() - timedelta(days=self.error_retry_days) + cursor.execute(""" + SELECT id, name + FROM artists + WHERE (genius_match_status = 'not_found' AND genius_last_attempted < ?) + OR (genius_match_status = 'error' AND genius_last_attempted < ?) + ORDER BY genius_last_attempted ASC + LIMIT 1 + """, (not_found_cutoff, error_cutoff)) + row = cursor.fetchone() + if row: + logger.info(f"Retrying artist '{row[1]}' (last attempted before cutoff)") + return {'type': 'artist', 'id': row[0], 'name': row[1]} + + # Priority 4: Retry tracks + cursor.execute(""" + SELECT t.id, t.title, ar.name AS artist_name + FROM tracks t + JOIN artists ar ON t.artist_id = ar.id + WHERE (t.genius_match_status = 'not_found' AND t.genius_last_attempted < ?) + OR (t.genius_match_status = 'error' AND t.genius_last_attempted < ?) + ORDER BY t.genius_last_attempted ASC + LIMIT 1 + """, (not_found_cutoff, error_cutoff)) + row = cursor.fetchone() + if row: + return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2]} + + return None + + except Exception as e: + logger.error(f"Error getting next item: {e}") + return None + finally: + if conn: + conn.close() + + def _normalize_name(self, name: str) -> str: + """Normalize name for comparison""" + name = name.lower().strip() + name = re.sub(r'\s*\(.*?\)\s*', ' ', name) + name = re.sub(r'\s*\[.*?\]\s*', ' ', name) # Also strip brackets (Genius uses these) + name = re.sub(r'\s*feat\.?\s+.*$', '', name) # Strip featuring + name = re.sub(r'[^\w\s]', '', name) + name = re.sub(r'\s+', ' ', name).strip() + return name + + def _name_matches(self, query_name: str, result_name: str) -> bool: + """Check if result name matches our query with fuzzy matching""" + norm_query = self._normalize_name(query_name) + norm_result = self._normalize_name(result_name) + similarity = SequenceMatcher(None, norm_query, norm_result).ratio() + logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}") + return similarity >= self.name_similarity_threshold + + def _process_item(self, item: Dict[str, Any]): + """Process a single item (artist or track)""" + try: + item_type = item['type'] + item_id = item['id'] + item_name = item['name'] + + logger.debug(f"Processing {item_type} #{item_id}: {item_name}") + + if item_type == 'artist': + self._process_artist(item_id, item_name) + elif item_type == 'track': + self._process_track(item_id, item_name, item.get('artist', '')) + + except Exception as e: + logger.error(f"Error processing {item['type']} #{item['id']}: {e}") + self.stats['errors'] += 1 + try: + self._mark_status(item['type'], item['id'], 'error') + except Exception as e2: + logger.error(f"Error updating item status: {e2}") + + def _process_artist(self, artist_id: int, artist_name: str): + """Process an artist: search Genius, get full artist details""" + result = self.client.search_artist(artist_name) + if result: + result_name = result.get('name', '') + if self._name_matches(artist_name, result_name): + genius_id = result.get('id') + # Fetch full artist details + full_artist = None + if genius_id: + try: + full_artist = self.client.get_artist(genius_id) + except Exception as e: + logger.warning(f"Failed to fetch full artist details for '{artist_name}': {e}") + + if full_artist is None: + self._mark_status('artist', artist_id, 'error') + self.stats['errors'] += 1 + logger.warning(f"Artist '{artist_name}' matched but full details unavailable, will retry") + return + + self._update_artist(artist_id, result, full_artist) + self.stats['matched'] += 1 + logger.info(f"Matched artist '{artist_name}' -> Genius ID: {genius_id}") + else: + self._mark_status('artist', artist_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')") + else: + self._mark_status('artist', artist_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"No match for artist '{artist_name}'") + + def _process_track(self, track_id: int, track_name: str, artist_name: str): + """Process a track: search Genius, get full song details + lyrics""" + result = self.client.search_song(artist_name, track_name) + if result: + result_title = result.get('title', '') + if self._name_matches(track_name, result_title): + genius_id = result.get('id') + # Fetch full song details + full_song = None + if genius_id: + try: + full_song = self.client.get_song(genius_id) + except Exception as e: + logger.warning(f"Failed to fetch full song details for '{track_name}': {e}") + + if full_song is None: + self._mark_status('track', track_id, 'error') + self.stats['errors'] += 1 + logger.warning(f"Track '{track_name}' matched but full details unavailable, will retry") + return + + # Scrape lyrics + lyrics = None + song_url = result.get('url') or full_song.get('url') + if song_url: + try: + lyrics = self.client.get_lyrics(song_url) + except Exception as e: + logger.debug(f"Lyrics scraping failed for '{track_name}': {e}") + + self._update_track(track_id, result, full_song, lyrics) + self.stats['matched'] += 1 + logger.info(f"Matched track '{track_name}' -> Genius ID: {genius_id}") + else: + self._mark_status('track', track_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"Name mismatch for track '{track_name}' (got '{result_title}')") + else: + self._mark_status('track', track_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"No match for track '{track_name}'") + + def _update_artist(self, artist_id: int, search_data: Dict[str, Any], full_data: Dict[str, Any]): + """Store Genius metadata for an artist""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + genius_id = str(full_data.get('id', search_data.get('id', ''))) + description = self.client.extract_description(full_data.get('description')) + image_url = full_data.get('image_url') or search_data.get('image_url') + + # Alternate names + alt_names = full_data.get('alternate_names', []) + alt_names_json = json.dumps(alt_names) if alt_names else None + + cursor.execute(""" + UPDATE artists SET + genius_id = ?, + genius_match_status = 'matched', + genius_last_attempted = CURRENT_TIMESTAMP, + genius_description = ?, + genius_alt_names = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (genius_id, description, alt_names_json, artist_id)) + + # Backfill thumb_url + if image_url: + cursor.execute(""" + UPDATE artists SET thumb_url = ? + WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '') + """, (image_url, artist_id)) + + conn.commit() + + except Exception as e: + logger.error(f"Error updating artist #{artist_id} with Genius data: {e}") + raise + finally: + if conn: + conn.close() + + def _update_track(self, track_id: int, search_data: Dict[str, Any], full_data: Dict[str, Any], lyrics: Optional[str]): + """Store Genius metadata for a track""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + genius_id = str(full_data.get('id', search_data.get('id', ''))) + description = self.client.extract_description(full_data.get('description')) + genius_url = full_data.get('url') or search_data.get('url') + + cursor.execute(""" + UPDATE tracks SET + genius_id = ?, + genius_match_status = 'matched', + genius_last_attempted = CURRENT_TIMESTAMP, + genius_lyrics = ?, + genius_description = ?, + genius_url = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (genius_id, lyrics, description, genius_url, track_id)) + + conn.commit() + + except Exception as e: + logger.error(f"Error updating track #{track_id} with Genius data: {e}") + raise + finally: + if conn: + conn.close() + + def _mark_status(self, entity_type: str, entity_id: int, status: str): + """Mark an entity with a match status""" + table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'} + table = table_map.get(entity_type) + if not table: + logger.error(f"Unknown entity type: {entity_type}") + return + + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(f""" + UPDATE {table} SET + genius_match_status = ?, + genius_last_attempted = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (status, entity_id)) + conn.commit() + except Exception as e: + logger.error(f"Error marking {entity_type} #{entity_id} status: {e}") + finally: + if conn: + conn.close() + + def _count_pending_items(self) -> int: + """Count how many items still need processing (artists + tracks only)""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT + (SELECT COUNT(*) FROM artists WHERE genius_match_status IS NULL) + + (SELECT COUNT(*) FROM tracks WHERE genius_match_status IS NULL) + AS pending + """) + row = cursor.fetchone() + return row[0] if row else 0 + except Exception as e: + logger.error(f"Error counting pending items: {e}") + return 0 + finally: + if conn: + conn.close() + + def _get_progress_breakdown(self) -> Dict[str, Dict[str, int]]: + """Get progress breakdown by entity type""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + progress = {} + + for entity, table in [('artists', 'artists'), ('tracks', 'tracks')]: + cursor.execute(f""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN genius_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed + FROM {table} + """) + row = cursor.fetchone() + if row: + total, processed = row[0], row[1] or 0 + progress[entity] = { + 'matched': processed, + 'total': total, + 'percent': int((processed / total * 100) if total > 0 else 0) + } + + return progress + + except Exception as e: + logger.error(f"Error getting progress breakdown: {e}") + return {} + finally: + if conn: + conn.close() diff --git a/core/lastfm_client.py b/core/lastfm_client.py index 5132bed3..7041d13d 100644 --- a/core/lastfm_client.py +++ b/core/lastfm_client.py @@ -54,8 +54,14 @@ class LastFMClient: }) logger.info("Last.fm client initialized") - def _make_request(self, method: str, params: Dict = None, timeout: int = 10) -> Optional[Dict]: - """Make a request to the Last.fm API""" + def _make_request(self, method: str, params: Dict = None, timeout: int = 10, raise_on_transient: bool = False) -> Optional[Dict]: + """Make a request to the Last.fm API. + + Args: + raise_on_transient: If True, raise exceptions on transient errors (timeouts, HTTP errors) + instead of returning None. Used by get_*_info methods so the worker can distinguish + 'not found' (mark not_found, retry in 30 days) from 'API failed' (mark error, retry in 7 days). + """ if not self.api_key: logger.warning("Last.fm API key not configured") return None @@ -85,6 +91,9 @@ class LastFMClient: # Error 6 = "Artist/Album/Track not found" — not a real error if error_code == 6: return None + # Transient errors: 11=Service Offline, 16=Temporarily Unavailable, 29=Rate Limit + if raise_on_transient and error_code in (11, 16, 29): + raise Exception(f"Last.fm transient error ({error_code}): {error_msg}") logger.error(f"Last.fm API error ({error_code}): {error_msg}") return None @@ -92,9 +101,13 @@ class LastFMClient: except requests.exceptions.Timeout: logger.warning(f"Last.fm API timeout for method: {method}") + if raise_on_transient: + raise return None except Exception as e: logger.error(f"Last.fm API request error ({method}): {e}") + if raise_on_transient: + raise return None # ── Artist Methods ── @@ -134,7 +147,7 @@ class LastFMClient: data = self._make_request('artist.getinfo', { 'artist': artist_name, 'autocorrect': 1 - }) + }, raise_on_transient=True) if not data: return None @@ -220,7 +233,7 @@ class LastFMClient: 'artist': artist_name, 'album': album_title, 'autocorrect': 1 - }) + }, raise_on_transient=True) if not data: return None @@ -270,7 +283,7 @@ class LastFMClient: 'artist': artist_name, 'track': track_title, 'autocorrect': 1 - }) + }, raise_on_transient=True) if not data: return None diff --git a/core/lastfm_worker.py b/core/lastfm_worker.py new file mode 100644 index 00000000..17e17c11 --- /dev/null +++ b/core/lastfm_worker.py @@ -0,0 +1,607 @@ +import json +import re +import threading +import time +from difflib import SequenceMatcher +from typing import Optional, Dict, Any +from datetime import datetime, timedelta +from utils.logging_config import get_logger +from database.music_database import MusicDatabase +from core.lastfm_client import LastFMClient +from config.settings import config_manager + +logger = get_logger("lastfm_worker") + + +class LastFMWorker: + """Background worker for enriching library artists, albums, and tracks with Last.fm metadata. + + Enriches: + - Artists: listeners, playcount, bio/summary, tags, similar artists, images + - Albums: listeners, playcount, tags, wiki/summary, images + - Tracks: listeners, playcount, tags, duration + """ + + def __init__(self, database: MusicDatabase): + self.db = database + self._init_client() + + # Worker state + self.running = False + self.paused = False + self.should_stop = False + self.thread = None + + # Current item being processed (for UI tooltip) + self.current_item = None + + # Statistics + self.stats = { + 'matched': 0, + 'not_found': 0, + 'pending': 0, + 'errors': 0 + } + + # Retry configuration + self.retry_days = 30 + self.error_retry_days = 7 + + # Name matching threshold + self.name_similarity_threshold = 0.80 + + logger.info("Last.fm background worker initialized") + + def _init_client(self): + """Initialize or reinitialize the Last.fm client from config""" + api_key = config_manager.get('lastfm.api_key', '') + self.client = LastFMClient(api_key=api_key) + + def start(self): + """Start the background worker""" + if self.running: + logger.warning("Worker already running") + return + + self.running = True + self.should_stop = False + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + logger.info("Last.fm background worker started") + + def stop(self): + """Stop the background worker""" + if not self.running: + return + + logger.info("Stopping Last.fm worker...") + self.should_stop = True + self.running = False + + if self.thread: + self.thread.join(timeout=5) + + logger.info("Last.fm worker stopped") + + def pause(self): + """Pause the worker""" + if not self.running: + logger.warning("Worker not running, cannot pause") + return + self.paused = True + logger.info("Last.fm worker paused") + + def resume(self): + """Resume the worker""" + if not self.running: + logger.warning("Worker not running, start it first") + return + self.paused = False + logger.info("Last.fm worker resumed") + + def get_stats(self) -> Dict[str, Any]: + """Get current statistics""" + self.stats['pending'] = self._count_pending_items() + progress = self._get_progress_breakdown() + is_actually_running = self.running and (self.thread is not None and self.thread.is_alive()) + is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None + + return { + 'enabled': True, + 'running': is_actually_running and not self.paused, + 'paused': self.paused, + 'idle': is_idle, + 'current_item': self.current_item, + 'stats': self.stats.copy(), + 'progress': progress + } + + def _run(self): + """Main worker loop""" + logger.info("Last.fm worker thread started") + + while not self.should_stop: + try: + if self.paused: + time.sleep(1) + continue + + # Check if API key is configured + if not self.client.api_key: + self._init_client() + if not self.client.api_key: + time.sleep(30) + continue + + self.current_item = None + item = self._get_next_item() + + if not item: + logger.debug("No pending items, sleeping...") + time.sleep(10) + continue + + self.current_item = item + self._process_item(item) + + # Last.fm allows 5 req/sec but we use multiple calls per item + time.sleep(1) + + except Exception as e: + logger.error(f"Error in worker loop: {e}") + time.sleep(5) + + logger.info("Last.fm worker thread finished") + + def _get_next_item(self) -> Optional[Dict[str, Any]]: + """Get next item to process from priority queue (artists -> albums -> tracks)""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + # Priority 1: Unattempted artists + cursor.execute(""" + SELECT id, name + FROM artists + WHERE lastfm_match_status IS NULL + ORDER BY id ASC + LIMIT 1 + """) + row = cursor.fetchone() + if row: + return {'type': 'artist', 'id': row[0], 'name': row[1]} + + # Priority 2: Unattempted albums + cursor.execute(""" + SELECT a.id, a.title, ar.name AS artist_name + FROM albums a + JOIN artists ar ON a.artist_id = ar.id + WHERE a.lastfm_match_status IS NULL + ORDER BY a.id ASC + LIMIT 1 + """) + row = cursor.fetchone() + if row: + return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2]} + + # Priority 3: Unattempted tracks + cursor.execute(""" + SELECT t.id, t.title, ar.name AS artist_name + FROM tracks t + JOIN artists ar ON t.artist_id = ar.id + WHERE t.lastfm_match_status IS NULL + ORDER BY t.id ASC + LIMIT 1 + """) + row = cursor.fetchone() + if row: + return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2]} + + # Priority 4: Retry 'not_found' or 'error' artists + not_found_cutoff = datetime.now() - timedelta(days=self.retry_days) + error_cutoff = datetime.now() - timedelta(days=self.error_retry_days) + cursor.execute(""" + SELECT id, name + FROM artists + WHERE (lastfm_match_status = 'not_found' AND lastfm_last_attempted < ?) + OR (lastfm_match_status = 'error' AND lastfm_last_attempted < ?) + ORDER BY lastfm_last_attempted ASC + LIMIT 1 + """, (not_found_cutoff, error_cutoff)) + row = cursor.fetchone() + if row: + logger.info(f"Retrying artist '{row[1]}' (last attempted before cutoff)") + return {'type': 'artist', 'id': row[0], 'name': row[1]} + + # Priority 5: Retry albums + cursor.execute(""" + SELECT a.id, a.title, ar.name AS artist_name + FROM albums a + JOIN artists ar ON a.artist_id = ar.id + WHERE (a.lastfm_match_status = 'not_found' AND a.lastfm_last_attempted < ?) + OR (a.lastfm_match_status = 'error' AND a.lastfm_last_attempted < ?) + ORDER BY a.lastfm_last_attempted ASC + LIMIT 1 + """, (not_found_cutoff, error_cutoff)) + row = cursor.fetchone() + if row: + return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2]} + + # Priority 6: Retry tracks + cursor.execute(""" + SELECT t.id, t.title, ar.name AS artist_name + FROM tracks t + JOIN artists ar ON t.artist_id = ar.id + WHERE (t.lastfm_match_status = 'not_found' AND t.lastfm_last_attempted < ?) + OR (t.lastfm_match_status = 'error' AND t.lastfm_last_attempted < ?) + ORDER BY t.lastfm_last_attempted ASC + LIMIT 1 + """, (not_found_cutoff, error_cutoff)) + row = cursor.fetchone() + if row: + return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2]} + + return None + + except Exception as e: + logger.error(f"Error getting next item: {e}") + return None + finally: + if conn: + conn.close() + + def _normalize_name(self, name: str) -> str: + """Normalize name for comparison""" + name = name.lower().strip() + name = re.sub(r'\s*\(.*?\)\s*', ' ', name) + name = re.sub(r'[^\w\s]', '', name) + name = re.sub(r'\s+', ' ', name).strip() + return name + + def _name_matches(self, query_name: str, result_name: str) -> bool: + """Check if result name matches our query with fuzzy matching""" + norm_query = self._normalize_name(query_name) + norm_result = self._normalize_name(result_name) + similarity = SequenceMatcher(None, norm_query, norm_result).ratio() + logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}") + return similarity >= self.name_similarity_threshold + + def _process_item(self, item: Dict[str, Any]): + """Process a single item (artist, album, or track)""" + try: + item_type = item['type'] + item_id = item['id'] + item_name = item['name'] + + logger.debug(f"Processing {item_type} #{item_id}: {item_name}") + + if item_type == 'artist': + self._process_artist(item_id, item_name) + elif item_type == 'album': + self._process_album(item_id, item_name, item.get('artist', '')) + elif item_type == 'track': + self._process_track(item_id, item_name, item.get('artist', '')) + + except Exception as e: + logger.error(f"Error processing {item['type']} #{item['id']}: {e}") + self.stats['errors'] += 1 + try: + self._mark_status(item['type'], item['id'], 'error') + except Exception as e2: + logger.error(f"Error updating item status: {e2}") + + def _process_artist(self, artist_id: int, artist_name: str): + """Process an artist: get full info from Last.fm""" + # Use get_artist_info for detailed data (includes stats, bio, tags, similar) + result = self.client.get_artist_info(artist_name) + if result: + result_name = result.get('name', '') + if self._name_matches(artist_name, result_name): + self._update_artist(artist_id, result) + self.stats['matched'] += 1 + logger.info(f"Matched artist '{artist_name}' on Last.fm") + else: + self._mark_status('artist', artist_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')") + else: + self._mark_status('artist', artist_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"No match for artist '{artist_name}'") + + def _process_album(self, album_id: int, album_name: str, artist_name: str): + """Process an album: get full info from Last.fm""" + result = self.client.get_album_info(artist_name, album_name) + if result: + result_name = result.get('name', '') + if self._name_matches(album_name, result_name): + self._update_album(album_id, result) + self.stats['matched'] += 1 + logger.info(f"Matched album '{album_name}' on Last.fm") + else: + self._mark_status('album', album_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"Name mismatch for album '{album_name}' (got '{result_name}')") + else: + self._mark_status('album', album_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"No match for album '{album_name}'") + + def _process_track(self, track_id: int, track_name: str, artist_name: str): + """Process a track: get full info from Last.fm""" + result = self.client.get_track_info(artist_name, track_name) + if result: + result_name = result.get('name', '') + if self._name_matches(track_name, result_name): + self._update_track(track_id, result) + self.stats['matched'] += 1 + logger.info(f"Matched track '{track_name}' on Last.fm") + else: + self._mark_status('track', track_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"Name mismatch for track '{track_name}' (got '{result_name}')") + else: + self._mark_status('track', track_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"No match for track '{track_name}'") + + def _update_artist(self, artist_id: int, data: Dict[str, Any]): + """Store Last.fm metadata for an artist""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + # Extract stats + stats = data.get('stats', {}) + listeners = int(stats.get('listeners', 0)) if stats.get('listeners') else None + playcount = int(stats.get('playcount', 0)) if stats.get('playcount') else None + + # Extract bio summary + bio = data.get('bio', {}) + summary = bio.get('summary', '') if bio else '' + # Clean Last.fm's HTML link from summary + if summary: + summary = re.sub(r'.*?\.?', '', summary).strip() + + # Extract tags + tags = self.client.extract_tags(data.get('tags')) + tags_json = json.dumps(tags) if tags else None + + # Extract similar artists (Last.fm returns a single dict instead of list when only 1 result) + similar_data = data.get('similar') + similar_raw = similar_data.get('artist', []) if isinstance(similar_data, dict) else [] + if similar_raw and not isinstance(similar_raw, list): + similar_raw = [similar_raw] + if similar_raw: + similar = [{'name': s.get('name', ''), 'match': s.get('match', '')} for s in similar_raw[:10] if isinstance(s, dict)] + similar_json = json.dumps(similar) if similar else None + else: + similar_json = None + + # Get best image + thumb_url = self.client.get_best_image(data.get('image', [])) + + # Last.fm URL (serves as unique identifier) + lastfm_url = data.get('url') + + # Update core lastfm fields + cursor.execute(""" + UPDATE artists SET + lastfm_match_status = 'matched', + lastfm_last_attempted = CURRENT_TIMESTAMP, + lastfm_listeners = ?, + lastfm_playcount = ?, + lastfm_tags = ?, + lastfm_similar = ?, + lastfm_bio = ?, + lastfm_url = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (listeners, playcount, tags_json, similar_json, summary or None, lastfm_url, artist_id)) + + # Backfill thumb_url if missing + if thumb_url: + cursor.execute(""" + UPDATE artists SET thumb_url = ? + WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '') + """, (thumb_url, artist_id)) + + # Backfill style from tags if missing + if tags: + cursor.execute(""" + UPDATE artists SET style = ? + WHERE id = ? AND (style IS NULL OR style = '') + """, (', '.join(tags[:5]), artist_id)) + + conn.commit() + + except Exception as e: + logger.error(f"Error updating artist #{artist_id} with Last.fm data: {e}") + raise + finally: + if conn: + conn.close() + + def _update_album(self, album_id: int, data: Dict[str, Any]): + """Store Last.fm metadata for an album""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + listeners = int(data.get('listeners', 0)) if data.get('listeners') else None + playcount = int(data.get('playcount', 0)) if data.get('playcount') else None + + # Extract tags + tags = self.client.extract_tags(data.get('tags')) + tags_json = json.dumps(tags) if tags else None + + # Extract wiki summary + wiki = data.get('wiki', {}) + summary = wiki.get('summary', '') if wiki else '' + if summary: + summary = re.sub(r'.*?\.?', '', summary).strip() + + # Get best image + thumb_url = self.client.get_best_image(data.get('image', [])) + + # Last.fm URL + lastfm_url = data.get('url') + + cursor.execute(""" + UPDATE albums SET + lastfm_match_status = 'matched', + lastfm_last_attempted = CURRENT_TIMESTAMP, + lastfm_listeners = ?, + lastfm_playcount = ?, + lastfm_tags = ?, + lastfm_wiki = ?, + lastfm_url = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (listeners, playcount, tags_json, summary or None, lastfm_url, album_id)) + + # Backfill thumb_url + if thumb_url: + cursor.execute(""" + UPDATE albums SET thumb_url = ? + WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '') + """, (thumb_url, album_id)) + + # Backfill genres from tags + if tags: + cursor.execute(""" + UPDATE albums SET genres = ? + WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') + """, (json.dumps(tags[:10]), album_id)) + + conn.commit() + + except Exception as e: + logger.error(f"Error updating album #{album_id} with Last.fm data: {e}") + raise + finally: + if conn: + conn.close() + + def _update_track(self, track_id: int, data: Dict[str, Any]): + """Store Last.fm metadata for a track""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + listeners = int(data.get('listeners', 0)) if data.get('listeners') else None + playcount = int(data.get('playcount', 0)) if data.get('playcount') else None + + # Extract tags + tags_data = data.get('toptags', {}) + tags = self.client.extract_tags(tags_data) + tags_json = json.dumps(tags) if tags else None + + # Last.fm URL + lastfm_url = data.get('url') + + cursor.execute(""" + UPDATE tracks SET + lastfm_match_status = 'matched', + lastfm_last_attempted = CURRENT_TIMESTAMP, + lastfm_listeners = ?, + lastfm_playcount = ?, + lastfm_tags = ?, + lastfm_url = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (listeners, playcount, tags_json, lastfm_url, track_id)) + + conn.commit() + + except Exception as e: + logger.error(f"Error updating track #{track_id} with Last.fm data: {e}") + raise + finally: + if conn: + conn.close() + + def _mark_status(self, entity_type: str, entity_id: int, status: str): + """Mark an entity with a match status""" + table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'} + table = table_map.get(entity_type) + if not table: + logger.error(f"Unknown entity type: {entity_type}") + return + + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(f""" + UPDATE {table} SET + lastfm_match_status = ?, + lastfm_last_attempted = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (status, entity_id)) + conn.commit() + except Exception as e: + logger.error(f"Error marking {entity_type} #{entity_id} status: {e}") + finally: + if conn: + conn.close() + + def _count_pending_items(self) -> int: + """Count how many items still need processing""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT + (SELECT COUNT(*) FROM artists WHERE lastfm_match_status IS NULL) + + (SELECT COUNT(*) FROM albums WHERE lastfm_match_status IS NULL) + + (SELECT COUNT(*) FROM tracks WHERE lastfm_match_status IS NULL) + AS pending + """) + row = cursor.fetchone() + return row[0] if row else 0 + except Exception as e: + logger.error(f"Error counting pending items: {e}") + return 0 + finally: + if conn: + conn.close() + + def _get_progress_breakdown(self) -> Dict[str, Dict[str, int]]: + """Get progress breakdown by entity type""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + progress = {} + + for entity, table in [('artists', 'artists'), ('albums', 'albums'), ('tracks', 'tracks')]: + cursor.execute(f""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN lastfm_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed + FROM {table} + """) + row = cursor.fetchone() + if row: + total, processed = row[0], row[1] or 0 + progress[entity] = { + 'matched': processed, + 'total': total, + 'percent': int((processed / total * 100) if total > 0 else 0) + } + + return progress + + except Exception as e: + logger.error(f"Error getting progress breakdown: {e}") + return {} + finally: + if conn: + conn.close() diff --git a/database/music_database.py b/database/music_database.py index 05c8fbfb..5ec5b951 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -316,6 +316,9 @@ class MusicDatabase: # Add Spotify/iTunes enrichment tracking columns (migration) self._add_spotify_itunes_enrichment_columns(cursor) + # Add Last.fm and Genius enrichment columns (migration) + self._add_lastfm_genius_columns(cursor) + # Bubble snapshots table for persisting UI state across page refreshes cursor.execute(""" CREATE TABLE IF NOT EXISTS bubble_snapshots ( @@ -1439,6 +1442,109 @@ class MusicDatabase: logger.error(f"Error adding Spotify/iTunes enrichment columns: {e}") # Don't raise - this is a migration, database can still function + def _add_lastfm_genius_columns(self, cursor): + """Add Last.fm and Genius enrichment tracking + metadata columns to artists, albums, tracks""" + try: + # --- Artists --- + cursor.execute("PRAGMA table_info(artists)") + artists_columns = [column[1] for column in cursor.fetchall()] + + # Last.fm columns + if 'lastfm_match_status' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_match_status TEXT") + if 'lastfm_last_attempted' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_last_attempted TIMESTAMP") + if 'lastfm_listeners' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_listeners INTEGER") + if 'lastfm_playcount' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_playcount INTEGER") + if 'lastfm_tags' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_tags TEXT") + if 'lastfm_similar' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_similar TEXT") + if 'lastfm_bio' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_bio TEXT") + if 'lastfm_url' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_url TEXT") + + # Genius columns + if 'genius_id' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN genius_id TEXT") + if 'genius_match_status' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN genius_match_status TEXT") + if 'genius_last_attempted' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN genius_last_attempted TIMESTAMP") + if 'genius_description' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN genius_description TEXT") + if 'genius_alt_names' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN genius_alt_names TEXT") + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_lastfm_status ON artists (lastfm_match_status)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_genius_id ON artists (genius_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_genius_status ON artists (genius_match_status)") + + # --- Albums --- + cursor.execute("PRAGMA table_info(albums)") + albums_columns = [column[1] for column in cursor.fetchall()] + + # Last.fm columns + if 'lastfm_match_status' not in albums_columns: + cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_match_status TEXT") + if 'lastfm_last_attempted' not in albums_columns: + cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_last_attempted TIMESTAMP") + if 'lastfm_listeners' not in albums_columns: + cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_listeners INTEGER") + if 'lastfm_playcount' not in albums_columns: + cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_playcount INTEGER") + if 'lastfm_tags' not in albums_columns: + cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_tags TEXT") + if 'lastfm_wiki' not in albums_columns: + cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_wiki TEXT") + if 'lastfm_url' not in albums_columns: + cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_url TEXT") + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_lastfm_status ON albums (lastfm_match_status)") + + # --- Tracks --- + cursor.execute("PRAGMA table_info(tracks)") + tracks_columns = [column[1] for column in cursor.fetchall()] + + # Last.fm columns + if 'lastfm_match_status' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_match_status TEXT") + if 'lastfm_last_attempted' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_last_attempted TIMESTAMP") + if 'lastfm_listeners' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_listeners INTEGER") + if 'lastfm_playcount' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_playcount INTEGER") + if 'lastfm_tags' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_tags TEXT") + if 'lastfm_url' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_url TEXT") + + # Genius columns + if 'genius_id' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN genius_id TEXT") + if 'genius_match_status' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN genius_match_status TEXT") + if 'genius_last_attempted' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN genius_last_attempted TIMESTAMP") + if 'genius_lyrics' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN genius_lyrics TEXT") + if 'genius_description' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN genius_description TEXT") + if 'genius_url' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN genius_url TEXT") + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_lastfm_status ON tracks (lastfm_match_status)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_genius_id ON tracks (genius_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_genius_status ON tracks (genius_match_status)") + + except Exception as e: + logger.error(f"Error adding Last.fm/Genius enrichment columns: {e}") + # Don't raise - this is a migration, database can still function + def _add_retag_tables(self, cursor): """Add retag tool tables for tracking processed downloads""" try: diff --git a/web_server.py b/web_server.py index 14f9d563..6d619493 100644 --- a/web_server.py +++ b/web_server.py @@ -93,6 +93,8 @@ from core.audiodb_worker import AudioDBWorker from core.deezer_worker import DeezerWorker from core.spotify_worker import SpotifyWorker from core.itunes_worker import iTunesWorker +from core.lastfm_worker import LastFMWorker +from core.genius_worker import GeniusWorker from core.hydrabase_worker import HydrabaseWorker from core.hydrabase_client import HydrabaseClient from core.automation_engine import AutomationEngine @@ -3956,6 +3958,11 @@ def handle_settings(): soulseek_client.youtube.reload_settings() # FIX: Re-instantiate the global tidal_client to pick up new settings tidal_client = TidalClient() + # Reload enrichment worker clients for key-based services + if lastfm_worker: + lastfm_worker._init_client() + if genius_worker: + genius_worker._init_client() print("✅ Service clients re-initialized with new settings.") return jsonify({"success": True, "message": "Settings saved successfully."}) except Exception as e: @@ -9553,14 +9560,14 @@ def library_play_track(): print(f"❌ Error playing library track: {e}") return jsonify({"success": False, "error": str(e)}), 500 -_enrichment_locks = {svc: threading.Lock() for svc in ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes')} +_enrichment_locks = {svc: threading.Lock() for svc in ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius')} @app.route('/api/library/enrich', methods=['POST']) def library_enrich_entity(): """Trigger enrichment of a specific entity from a single service. Body: { entity_type: 'artist'|'album'|'track', entity_id: str, service: str, name: str, artist_name: str? } - service: 'audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes' + service: 'audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius' """ try: data = request.get_json() @@ -9579,7 +9586,7 @@ def library_enrich_entity(): if entity_type not in ('artist', 'album', 'track'): return jsonify({"success": False, "error": "entity_type must be artist, album, or track"}), 400 - valid_services = ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes') + valid_services = ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius') if service not in valid_services: return jsonify({"success": False, "error": f"service must be one of: {', '.join(valid_services)}"}), 400 @@ -9701,6 +9708,26 @@ def _run_single_enrichment(service, entity_type, entity_id, name, artist_name): itunes_enrichment_worker._process_track_individual({'type': 'track_individual', 'id': entity_id, 'name': name, 'artist': artist_name}) return {"success": True, "message": f"iTunes lookup complete for {entity_type}"} + elif service == 'lastfm': + if not lastfm_worker: + return {"success": False, "error": "Last.fm worker not initialized"} + item = {'type': entity_type, 'id': entity_id, 'name': name} + if entity_type in ('album', 'track'): + item['artist'] = artist_name + lastfm_worker._process_item(item) + return {"success": True, "message": f"Last.fm lookup complete for {entity_type}"} + + elif service == 'genius': + if not genius_worker: + return {"success": False, "error": "Genius worker not initialized"} + item = {'type': entity_type, 'id': entity_id, 'name': name} + if entity_type == 'track': + item['artist'] = artist_name + elif entity_type == 'album': + return {"success": False, "error": "Genius does not support album enrichment"} + genius_worker._process_item(item) + return {"success": True, "message": f"Genius lookup complete for {entity_type}"} + else: return {"success": False, "error": f"Unknown service: {service}"} @@ -9817,6 +9844,48 @@ def _search_service(service, entity_type, query): 'extra': f"{artist_name} · {album_name}"}) return results + elif service == 'lastfm': + if not lastfm_worker or not lastfm_worker.client: + raise ValueError("Last.fm worker not initialized") + client = lastfm_worker.client + if entity_type == 'artist': + result = client.search_artist(query) + if result: + image = client.get_best_image(result.get('image', [])) + return [{'id': result.get('url', ''), 'name': result.get('name', ''), + 'image': image, 'extra': f"{result.get('listeners', '0')} listeners"}] + elif entity_type == 'album': + result = client.search_album(query, '') + if result: + image = client.get_best_image(result.get('image', [])) + return [{'id': result.get('url', ''), 'name': result.get('name', ''), + 'image': image, 'extra': result.get('artist', '')}] + elif entity_type == 'track': + # search_track takes separate artist/track params + parts = query.split(' - ', 1) if ' - ' in query else ['', query] + result = client.search_track(parts[0], parts[1]) + if result: + artist_name = result.get('artist', '') + return [{'id': result.get('url', ''), 'name': result.get('name', ''), + 'image': None, 'extra': f"{artist_name} · {result.get('listeners', '0')} listeners"}] + return [] + + elif service == 'genius': + if not genius_worker or not genius_worker.client: + raise ValueError("Genius worker not initialized") + client = genius_worker.client + if entity_type == 'artist': + result = client.search_artist(query) + if result: + return [{'id': str(result.get('id', '')), 'name': result.get('name', ''), + 'image': result.get('image_url'), 'extra': ''}] + elif entity_type == 'track': + result = client.search_song(query, '') + if result: + return [{'id': str(result.get('id', '')), 'name': result.get('title', ''), + 'image': result.get('song_art_image_url'), 'extra': result.get('artist_names', '')}] + return [] + elif service == 'audiodb': if not audiodb_worker or not audiodb_worker.client: raise ValueError("AudioDB worker not initialized") @@ -9853,6 +9922,8 @@ _SERVICE_ID_COLUMNS = { 'deezer': {'artist': 'deezer_id', 'album': 'deezer_id', 'track': 'deezer_id'}, 'audiodb': {'artist': 'audiodb_id', 'album': 'audiodb_id', 'track': 'audiodb_id'}, 'itunes': {'artist': 'itunes_artist_id', 'album': 'itunes_album_id', 'track': 'itunes_track_id'}, + 'lastfm': {'artist': 'lastfm_url', 'album': 'lastfm_url', 'track': 'lastfm_url'}, + 'genius': {'artist': 'genius_id', 'track': 'genius_id'}, } @app.route('/api/library/manual-match', methods=['PUT']) @@ -33391,6 +33462,146 @@ def itunes_enrichment_resume(): # ================================================================================================ +# ================================================================================================ +# LAST.FM ENRICHMENT WORKER +# ================================================================================================ + +lastfm_worker = None +try: + from database.music_database import MusicDatabase + lastfm_db = MusicDatabase() + lastfm_worker = LastFMWorker(database=lastfm_db) + lastfm_worker.start() + print("✅ Last.fm enrichment worker initialized and started") +except Exception as e: + print(f"⚠️ Last.fm worker initialization failed: {e}") + lastfm_worker = None + +# --- Last.fm API Endpoints --- + +@app.route('/api/lastfm-enrichment/status', methods=['GET']) +def lastfm_enrichment_status(): + """Get Last.fm enrichment status for UI polling""" + try: + if lastfm_worker is None: + return jsonify({ + 'enabled': False, + 'running': False, + 'paused': False, + 'current_item': None, + 'stats': {'matched': 0, 'not_found': 0, 'pending': 0, 'errors': 0}, + 'progress': {} + }), 200 + + status = lastfm_worker.get_stats() + return jsonify(status), 200 + except Exception as e: + logger.error(f"Error getting Last.fm enrichment status: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/lastfm-enrichment/pause', methods=['POST']) +def lastfm_enrichment_pause(): + """Pause Last.fm enrichment worker""" + try: + if lastfm_worker is None: + return jsonify({'error': 'Last.fm worker not initialized'}), 400 + + lastfm_worker.pause() + logger.info("Last.fm worker paused via UI") + return jsonify({'status': 'paused'}), 200 + except Exception as e: + logger.error(f"Error pausing Last.fm worker: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/lastfm-enrichment/resume', methods=['POST']) +def lastfm_enrichment_resume(): + """Resume Last.fm enrichment worker""" + try: + if lastfm_worker is None: + return jsonify({'error': 'Last.fm worker not initialized'}), 400 + + lastfm_worker.resume() + logger.info("Last.fm worker resumed via UI") + return jsonify({'status': 'running'}), 200 + except Exception as e: + logger.error(f"Error resuming Last.fm worker: {e}") + return jsonify({'error': str(e)}), 500 + +# ================================================================================================ +# END LAST.FM ENRICHMENT INTEGRATION +# ================================================================================================ + + +# ================================================================================================ +# GENIUS ENRICHMENT WORKER +# ================================================================================================ + +genius_worker = None +try: + from database.music_database import MusicDatabase + genius_db = MusicDatabase() + genius_worker = GeniusWorker(database=genius_db) + genius_worker.start() + print("✅ Genius enrichment worker initialized and started") +except Exception as e: + print(f"⚠️ Genius worker initialization failed: {e}") + genius_worker = None + +# --- Genius API Endpoints --- + +@app.route('/api/genius-enrichment/status', methods=['GET']) +def genius_enrichment_status(): + """Get Genius enrichment status for UI polling""" + try: + if genius_worker is None: + return jsonify({ + 'enabled': False, + 'running': False, + 'paused': False, + 'current_item': None, + 'stats': {'matched': 0, 'not_found': 0, 'pending': 0, 'errors': 0}, + 'progress': {} + }), 200 + + status = genius_worker.get_stats() + return jsonify(status), 200 + except Exception as e: + logger.error(f"Error getting Genius enrichment status: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/genius-enrichment/pause', methods=['POST']) +def genius_enrichment_pause(): + """Pause Genius enrichment worker""" + try: + if genius_worker is None: + return jsonify({'error': 'Genius worker not initialized'}), 400 + + genius_worker.pause() + logger.info("Genius worker paused via UI") + return jsonify({'status': 'paused'}), 200 + except Exception as e: + logger.error(f"Error pausing Genius worker: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/genius-enrichment/resume', methods=['POST']) +def genius_enrichment_resume(): + """Resume Genius enrichment worker""" + try: + if genius_worker is None: + return jsonify({'error': 'Genius worker not initialized'}), 400 + + genius_worker.resume() + logger.info("Genius worker resumed via UI") + return jsonify({'status': 'running'}), 200 + except Exception as e: + logger.error(f"Error resuming Genius worker: {e}") + return jsonify({'error': str(e)}), 500 + +# ================================================================================================ +# END GENIUS ENRICHMENT INTEGRATION +# ================================================================================================ + + # ================================================================================================ # HYDRABASE P2P MIRROR WORKER # ================================================================================================ @@ -34626,6 +34837,8 @@ def _emit_enrichment_status_loop(): 'deezer': lambda: deezer_worker, 'spotify-enrichment': lambda: spotify_enrichment_worker, 'itunes-enrichment': lambda: itunes_enrichment_worker, + 'lastfm-enrichment': lambda: lastfm_worker, + 'genius-enrichment': lambda: genius_worker, 'hydrabase': lambda: hydrabase_worker, 'repair': lambda: repair_worker, } diff --git a/webui/index.html b/webui/index.html index f65bacdd..cf7d797d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -356,6 +356,50 @@ + +
+ +
+
+
Last.fm Enrichment
+
+
Status: Idle +
+
No active matches +
+
Progress: 0 / 0 +
+
+
+
+
+ +
+ +
+
+
Genius Enrichment
+
+
Status: Idle +
+
No active matches +
+
Progress: 0 / 0 +
+
+
+
+