From 1a4395cc9549e277b4d98f7fff236b65bc9393d8 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Wed, 18 Feb 2026 14:08:01 -0800 Subject: [PATCH] Add AudioDB enrichment for artists, albums, and tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated TheAudioDB as a metadata enrichment source. A background worker scans the library in priority order (artists → albums → tracks), matching entities via fuzzy name comparison and storing style, mood, and AudioDB IDs. Includes rate limiting, 30-day retry for not-found items, and a UI tooltip showing phase-based scan progress. --- core/audiodb_client.py | 154 ++++++++++ core/audiodb_worker.py | 570 +++++++++++++++++++++++++++++++++++++ database/music_database.py | 69 +++++ web_server.py | 72 +++++ webui/index.html | 62 +++- webui/static/mobile.css | 11 + webui/static/script.js | 129 +++++++++ webui/static/style.css | 224 +++++++++++++++ 8 files changed, 1277 insertions(+), 14 deletions(-) create mode 100644 core/audiodb_client.py create mode 100644 core/audiodb_worker.py diff --git a/core/audiodb_client.py b/core/audiodb_client.py new file mode 100644 index 00000000..7e31fa6c --- /dev/null +++ b/core/audiodb_client.py @@ -0,0 +1,154 @@ +import requests +import time +import threading +from typing import Dict, Optional, Any +from functools import wraps +from utils.logging_config import get_logger + +logger = get_logger("audiodb_client") + +# Global rate limiting variables +_last_api_call_time = 0 +_api_call_lock = threading.Lock() +MIN_API_INTERVAL = 2.0 # 2 seconds between API calls (30 req/min free tier) + +def rate_limited(func): + """Decorator to enforce rate limiting on AudioDB API calls""" + @wraps(func) + def wrapper(*args, **kwargs): + global _last_api_call_time + + with _api_call_lock: + current_time = time.time() + time_since_last_call = current_time - _last_api_call_time + + if time_since_last_call < MIN_API_INTERVAL: + sleep_time = MIN_API_INTERVAL - time_since_last_call + time.sleep(sleep_time) + + _last_api_call_time = time.time() + + try: + result = func(*args, **kwargs) + return result + except Exception as e: + if "rate limit" in str(e).lower() or "429" in str(e): + logger.warning(f"AudioDB rate limit hit, implementing backoff: {e}") + time.sleep(4.0) + raise e + return wrapper + + +class AudioDBClient: + """Client for interacting with TheAudioDB API""" + + BASE_URL = "https://www.theaudiodb.com/api/v1/json/2" + + def __init__(self): + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': 'SoulSync/1.0', + 'Accept': 'application/json' + }) + logger.info("AudioDB client initialized") + + @rate_limited + def search_artist(self, artist_name: str) -> Optional[Dict[str, Any]]: + """ + Search for an artist by name. + + Args: + artist_name: Name of the artist to search for + + Returns: + Artist dict from AudioDB or None if not found + """ + try: + response = self.session.get( + f"{self.BASE_URL}/search.php", + params={'s': artist_name}, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + artists = data.get('artists') + + if artists and len(artists) > 0: + logger.debug(f"Found artist for query: {artist_name}") + return artists[0] + + logger.debug(f"No artist found for query: {artist_name}") + return None + + except Exception as e: + logger.error(f"Error searching for artist '{artist_name}': {e}") + return None + + @rate_limited + def search_album(self, artist_name: str, album_title: str) -> Optional[Dict[str, Any]]: + """ + Search for an album by artist name and album title. + + Args: + artist_name: Name of the artist + album_title: Title of the album + + Returns: + Album dict from AudioDB or None if not found + """ + try: + response = self.session.get( + f"{self.BASE_URL}/searchalbum.php", + params={'s': artist_name, 'a': album_title}, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + albums = data.get('album') + + if albums and len(albums) > 0: + logger.debug(f"Found album for query: {artist_name} - {album_title}") + return albums[0] + + logger.debug(f"No album found for query: {artist_name} - {album_title}") + return None + + except Exception as e: + logger.error(f"Error searching for album '{artist_name} - {album_title}': {e}") + return None + + @rate_limited + def search_track(self, artist_name: str, track_title: str) -> Optional[Dict[str, Any]]: + """ + Search for a track by artist name and track title. + + Args: + artist_name: Name of the artist + track_title: Title of the track + + Returns: + Track dict from AudioDB or None if not found + """ + try: + response = self.session.get( + f"{self.BASE_URL}/searchtrack.php", + params={'s': artist_name, 't': track_title}, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + tracks = data.get('track') + + if tracks and len(tracks) > 0: + logger.debug(f"Found track for query: {artist_name} - {track_title}") + return tracks[0] + + logger.debug(f"No track found for query: {artist_name} - {track_title}") + return None + + except Exception as e: + logger.error(f"Error searching for track '{artist_name} - {track_title}': {e}") + return None diff --git a/core/audiodb_worker.py b/core/audiodb_worker.py new file mode 100644 index 00000000..a2236717 --- /dev/null +++ b/core/audiodb_worker.py @@ -0,0 +1,570 @@ +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.audiodb_client import AudioDBClient + +logger = get_logger("audiodb_worker") + + +class AudioDBWorker: + """Background worker for enriching library artists, albums, and tracks with AudioDB metadata""" + + def __init__(self, database: MusicDatabase): + self.db = database + self.client = AudioDBClient() + + # 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 + + # Name matching threshold + self.name_similarity_threshold = 0.80 + + logger.info("AudioDB background worker initialized") + + 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("AudioDB background worker started") + + def stop(self): + """Stop the background worker""" + if not self.running: + return + + logger.info("Stopping AudioDB worker...") + self.should_stop = True + self.running = False + + if self.thread: + self.thread.join(timeout=5) + + logger.info("AudioDB 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("AudioDB 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("AudioDB 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()) + + return { + 'enabled': True, + 'running': is_actually_running and not self.paused, + 'paused': self.paused, + 'current_item': self.current_item, + 'stats': self.stats.copy(), + 'progress': progress + } + + def _run(self): + """Main worker loop""" + logger.info("AudioDB worker thread started") + + while not self.should_stop: + try: + if self.paused: + time.sleep(1) + 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) + + time.sleep(2) + + except Exception as e: + logger.error(f"Error in worker loop: {e}") + time.sleep(5) + + logger.info("AudioDB 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 audiodb_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.audiodb_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.audiodb_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' artists after retry_days + cutoff_date = datetime.now() - timedelta(days=self.retry_days) + cursor.execute(""" + SELECT id, name + FROM artists + WHERE audiodb_match_status = 'not_found' + AND audiodb_last_attempted < ? + ORDER BY audiodb_last_attempted ASC + LIMIT 1 + """, (cutoff_date,)) + row = cursor.fetchone() + if row: + logger.info(f"Retrying artist '{row[1]}' (last attempted before {cutoff_date})") + return {'type': 'artist', 'id': row[0], 'name': row[1]} + + # Priority 5: Retry 'not_found' 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.audiodb_match_status = 'not_found' + AND a.audiodb_last_attempted < ? + ORDER BY a.audiodb_last_attempted ASC + LIMIT 1 + """, (cutoff_date,)) + row = cursor.fetchone() + if row: + return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2]} + + # Priority 6: Retry 'not_found' 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.audiodb_match_status = 'not_found' + AND t.audiodb_last_attempted < ? + ORDER BY t.audiodb_last_attempted ASC + LIMIT 1 + """, (cutoff_date,)) + 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 artist 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 AudioDB 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': + result = self.client.search_artist(item_name) + if result: + result_name = result.get('strArtist', '') + if self._name_matches(item_name, result_name): + self._update_artist(item_id, result) + self.stats['matched'] += 1 + logger.info(f"Matched artist '{item_name}' -> AudioDB ID: {result.get('idArtist')}") + else: + self._mark_status('artist', item_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"Name mismatch for artist '{item_name}' (got '{result_name}')") + else: + self._mark_status('artist', item_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"No match for artist '{item_name}'") + + elif item_type == 'album': + artist_name = item.get('artist', '') + result = self.client.search_album(artist_name, item_name) + if result: + result_name = result.get('strAlbum', '') + if self._name_matches(item_name, result_name): + self._update_album(item_id, result) + self.stats['matched'] += 1 + logger.info(f"Matched album '{item_name}' -> AudioDB ID: {result.get('idAlbum')}") + else: + self._mark_status('album', item_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"Name mismatch for album '{item_name}' (got '{result_name}')") + else: + self._mark_status('album', item_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"No match for album '{item_name}'") + + elif item_type == 'track': + artist_name = item.get('artist', '') + result = self.client.search_track(artist_name, item_name) + if result: + result_name = result.get('strTrack', '') + if self._name_matches(item_name, result_name): + self._update_track(item_id, result) + self.stats['matched'] += 1 + logger.info(f"Matched track '{item_name}' -> AudioDB ID: {result.get('idTrack')}") + else: + self._mark_status('track', item_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"Name mismatch for track '{item_name}' (got '{result_name}')") + else: + self._mark_status('track', item_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"No match for track '{item_name}'") + + 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 _update_artist(self, artist_id: int, data: Dict[str, Any]): + """Store AudioDB metadata for an artist using generic column names""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + # Update AudioDB tracking + generic metadata columns + cursor.execute(""" + UPDATE artists SET + audiodb_id = ?, + audiodb_match_status = 'matched', + audiodb_last_attempted = CURRENT_TIMESTAMP, + style = ?, + mood = ?, + label = ?, + banner_url = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, ( + data.get('idArtist'), + data.get('strStyle'), + data.get('strMood'), + data.get('strLabel'), + data.get('strArtistBanner'), + artist_id + )) + + # Backfill thumb_url if artist has no image + thumb_url = data.get('strArtistThumb') + 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 genres if artist has none + genre = data.get('strGenre') + if genre: + cursor.execute(""" + UPDATE artists SET genres = ? + WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') + """, (json.dumps([genre]), artist_id)) + + conn.commit() + + except Exception as e: + logger.error(f"Error updating artist #{artist_id} with AudioDB data: {e}") + raise + finally: + if conn: + conn.close() + + def _update_album(self, album_id: int, data: Dict[str, Any]): + """Store AudioDB metadata for an album using generic column names""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + UPDATE albums SET + audiodb_id = ?, + audiodb_match_status = 'matched', + audiodb_last_attempted = CURRENT_TIMESTAMP, + style = ?, + mood = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, ( + data.get('idAlbum'), + data.get('strStyle'), + data.get('strMood'), + album_id + )) + + # Backfill thumb_url if album has no image + thumb_url = data.get('strAlbumThumb') + 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 if album has none + genre = data.get('strGenre') + if genre: + cursor.execute(""" + UPDATE albums SET genres = ? + WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') + """, (json.dumps([genre]), album_id)) + + conn.commit() + + except Exception as e: + logger.error(f"Error updating album #{album_id} with AudioDB data: {e}") + raise + finally: + if conn: + conn.close() + + def _update_track(self, track_id: int, data: Dict[str, Any]): + """Store AudioDB metadata for a track using generic column names""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + UPDATE tracks SET + audiodb_id = ?, + audiodb_match_status = 'matched', + audiodb_last_attempted = CURRENT_TIMESTAMP, + style = ?, + mood = ? + WHERE id = ? + """, ( + data.get('idTrack'), + data.get('strStyle'), + data.get('strMood'), + track_id + )) + + conn.commit() + + except Exception as e: + logger.error(f"Error updating track #{track_id} with AudioDB data: {e}") + raise + finally: + if conn: + conn.close() + + def _mark_status(self, entity_type: str, entity_id: int, status: str): + """Mark an entity (artist, album, or track) 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 + audiodb_match_status = ?, + audiodb_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 across all entity types""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT + (SELECT COUNT(*) FROM artists WHERE audiodb_match_status IS NULL) + + (SELECT COUNT(*) FROM albums WHERE audiodb_match_status IS NULL) + + (SELECT COUNT(*) FROM tracks WHERE audiodb_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 = {} + + # Artists progress + cursor.execute(""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN audiodb_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed + FROM artists + """) + row = cursor.fetchone() + if row: + total, processed = row[0], row[1] or 0 + progress['artists'] = { + 'matched': processed, + 'total': total, + 'percent': int((processed / total * 100) if total > 0 else 0) + } + + # Albums progress + cursor.execute(""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN audiodb_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed + FROM albums + """) + row = cursor.fetchone() + if row: + total, processed = row[0], row[1] or 0 + progress['albums'] = { + 'matched': processed, + 'total': total, + 'percent': int((processed / total * 100) if total > 0 else 0) + } + + # Tracks progress + cursor.execute(""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN audiodb_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed + FROM tracks + """) + row = cursor.fetchone() + if row: + total, processed = row[0], row[1] or 0 + progress['tracks'] = { + '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 31e15f88..28de7a6e 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -306,6 +306,9 @@ class MusicDatabase: # Add external ID columns (Spotify/iTunes) to library tables (migration) self._add_external_id_columns(cursor) + # Add AudioDB columns to artists table (migration) + self._add_audiodb_columns(cursor) + # Bubble snapshots table for persisting UI state across page refreshes cursor.execute(""" CREATE TABLE IF NOT EXISTS bubble_snapshots ( @@ -1067,6 +1070,72 @@ class MusicDatabase: logger.error(f"Error adding external ID columns: {e}") # Don't raise - this is a migration, database can still function + def _add_audiodb_columns(self, cursor): + """Add AudioDB tracking + generic metadata columns for enrichment (artists, albums, tracks)""" + try: + # --- Artists --- + cursor.execute("PRAGMA table_info(artists)") + artists_columns = [column[1] for column in cursor.fetchall()] + + if 'audiodb_id' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN audiodb_id TEXT") + cursor.execute("ALTER TABLE artists ADD COLUMN audiodb_match_status TEXT") + cursor.execute("ALTER TABLE artists ADD COLUMN audiodb_last_attempted TIMESTAMP") + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_audiodb_id ON artists (audiodb_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_audiodb_status ON artists (audiodb_match_status)") + + logger.info("Added AudioDB tracking columns to artists table") + + if 'style' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN style TEXT") + cursor.execute("ALTER TABLE artists ADD COLUMN mood TEXT") + cursor.execute("ALTER TABLE artists ADD COLUMN label TEXT") + cursor.execute("ALTER TABLE artists ADD COLUMN banner_url TEXT") + logger.info("Added generic artist metadata columns (style, mood, label, banner_url)") + + # --- Albums --- + cursor.execute("PRAGMA table_info(albums)") + albums_columns = [column[1] for column in cursor.fetchall()] + + if 'audiodb_id' not in albums_columns: + cursor.execute("ALTER TABLE albums ADD COLUMN audiodb_id TEXT") + cursor.execute("ALTER TABLE albums ADD COLUMN audiodb_match_status TEXT") + cursor.execute("ALTER TABLE albums ADD COLUMN audiodb_last_attempted TIMESTAMP") + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_audiodb_id ON albums (audiodb_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_audiodb_status ON albums (audiodb_match_status)") + + logger.info("Added AudioDB tracking columns to albums table") + + if 'style' not in albums_columns: + cursor.execute("ALTER TABLE albums ADD COLUMN style TEXT") + cursor.execute("ALTER TABLE albums ADD COLUMN mood TEXT") + logger.info("Added generic album metadata columns (style, mood)") + + # --- Tracks --- + cursor.execute("PRAGMA table_info(tracks)") + tracks_columns = [column[1] for column in cursor.fetchall()] + + if 'audiodb_id' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN audiodb_id TEXT") + cursor.execute("ALTER TABLE tracks ADD COLUMN audiodb_match_status TEXT") + cursor.execute("ALTER TABLE tracks ADD COLUMN audiodb_last_attempted TIMESTAMP") + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_audiodb_id ON tracks (audiodb_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_audiodb_status ON tracks (audiodb_match_status)") + + logger.info("Added AudioDB tracking columns to tracks table") + + if 'style' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN style TEXT") + cursor.execute("ALTER TABLE tracks ADD COLUMN mood TEXT") + logger.info("Added generic track metadata columns (style, mood)") + + except Exception as e: + logger.error(f"Error adding AudioDB columns: {e}") + # Don't raise - this is a migration, database can still function + def close(self): """Close database connection (no-op since we create connections per operation)""" # Each operation creates and closes its own connection, so nothing to do here diff --git a/web_server.py b/web_server.py index ef2196eb..f2cf35e1 100644 --- a/web_server.py +++ b/web_server.py @@ -69,6 +69,7 @@ import yt_dlp from core.matching_engine import MusicMatchingEngine from beatport_unified_scraper import BeatportUnifiedScraper from core.musicbrainz_worker import MusicBrainzWorker +from core.audiodb_worker import AudioDBWorker # --- Flask App Setup --- base_dir = os.path.abspath(os.path.dirname(__file__)) @@ -25496,6 +25497,77 @@ def musicbrainz_resume(): # ================================================================================================ +# ================================================================================================ +# AUDIODB ENRICHMENT - ARTIST METADATA & IMAGES +# ================================================================================================ + +# --- AudioDB Worker Initialization --- +audiodb_worker = None +try: + from database.music_database import MusicDatabase + audiodb_db = MusicDatabase() + audiodb_worker = AudioDBWorker(database=audiodb_db) + audiodb_worker.start() + print("✅ AudioDB enrichment worker initialized and started") +except Exception as e: + print(f"⚠️ AudioDB worker initialization failed: {e}") + audiodb_worker = None + +# --- AudioDB API Endpoints --- + +@app.route('/api/audiodb/status', methods=['GET']) +def audiodb_status(): + """Get AudioDB enrichment status for UI polling""" + try: + if audiodb_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 = audiodb_worker.get_stats() + return jsonify(status), 200 + except Exception as e: + logger.error(f"Error getting AudioDB status: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/audiodb/pause', methods=['POST']) +def audiodb_pause(): + """Pause AudioDB enrichment worker""" + try: + if audiodb_worker is None: + return jsonify({'error': 'AudioDB worker not initialized'}), 400 + + audiodb_worker.pause() + logger.info("AudioDB worker paused via UI") + return jsonify({'status': 'paused'}), 200 + except Exception as e: + logger.error(f"Error pausing AudioDB worker: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/audiodb/resume', methods=['POST']) +def audiodb_resume(): + """Resume AudioDB enrichment worker""" + try: + if audiodb_worker is None: + return jsonify({'error': 'AudioDB worker not initialized'}), 400 + + audiodb_worker.resume() + logger.info("AudioDB worker resumed via UI") + return jsonify({'status': 'running'}), 200 + except Exception as e: + logger.error(f"Error resuming AudioDB worker: {e}") + return jsonify({'error': str(e)}), 500 + +# ================================================================================================ +# END AUDIODB INTEGRATION +# ================================================================================================ + + # ================================================================================================ # IMPORT / STAGING SYSTEM # ================================================================================================ diff --git a/webui/index.html b/webui/index.html index 535b277f..51fb21b4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -196,8 +196,33 @@ - + +
+
+
🎶 AudioDB Enrichment
+
+
Status: Idle +
+
No active matches +
+
Progress: 0 / 0 +
+
+
+
+ + @@ -1435,7 +1460,8 @@ @@ -2660,8 +2686,10 @@

AcoustID Verification

-
@@ -3352,7 +3383,8 @@
@@ -3364,7 +3396,8 @@
@@ -3380,7 +3413,8 @@
- +
diff --git a/webui/static/mobile.css b/webui/static/mobile.css index 346958b2..077e8089 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -202,6 +202,17 @@ transform: translateY(0); } + /* AudioDB tooltip - reposition for mobile */ + .audiodb-tooltip { + left: 0; + right: auto; + transform: translateY(-5px); + } + + .audiodb-button:hover+.audiodb-tooltip { + transform: translateY(0); + } + .tooltip-content { min-width: auto; width: calc(100vw - 60px); diff --git a/webui/static/script.js b/webui/static/script.js index 6e01be7e..cbd92646 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -35255,6 +35255,135 @@ if (document.readyState === 'loading') { } } +// ============================================================================ +// AUDIODB ENRICHMENT UI +// ============================================================================ + +/** + * Poll AudioDB status every 2 seconds and update UI + */ +async function updateAudioDBStatus() { + try { + const response = await fetch('/api/audiodb/status'); + if (!response.ok) { + console.warn('AudioDB status endpoint unavailable'); + return; + } + + const data = await response.json(); + const button = document.getElementById('audiodb-button'); + if (!button) return; + + // Update button state classes + button.classList.remove('active', 'paused'); + if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + // Update tooltip content + const tooltipStatus = document.getElementById('audiodb-tooltip-status'); + const tooltipCurrent = document.getElementById('audiodb-tooltip-current'); + const tooltipProgress = document.getElementById('audiodb-tooltip-progress'); + + if (tooltipStatus) { + if (data.running && !data.paused) { + tooltipStatus.textContent = 'Running'; + } else if (data.paused) { + tooltipStatus.textContent = 'Paused'; + } else { + tooltipStatus.textContent = 'Idle'; + } + } + + if (tooltipCurrent) { + if (data.current_item && data.current_item.name) { + const type = data.current_item.type || 'item'; + const name = data.current_item.name; + tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; + } else { + tooltipCurrent.textContent = 'No active matches'; + } + } + + if (tooltipProgress && data.progress) { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist' || (!artistsComplete && !currentType)) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } else { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } + + } catch (error) { + console.error('Error updating AudioDB status:', error); + } +} + +/** + * Toggle AudioDB enrichment pause/resume + */ +async function toggleAudioDBEnrichment() { + try { + const button = document.getElementById('audiodb-button'); + if (!button) return; + + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/audiodb/pause' : '/api/audiodb/resume'; + + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} AudioDB enrichment`); + } + + // Immediately update UI + await updateAudioDBStatus(); + + console.log(`✅ AudioDB enrichment ${isRunning ? 'paused' : 'resumed'}`); + + } catch (error) { + console.error('Error toggling AudioDB enrichment:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +// Initialize AudioDB UI on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('audiodb-button'); + if (button) { + button.addEventListener('click', toggleAudioDBEnrichment); + updateAudioDBStatus(); + setInterval(updateAudioDBStatus, 2000); + console.log('✅ AudioDB UI initialized'); + } + }); +} else { + const button = document.getElementById('audiodb-button'); + if (button) { + button.addEventListener('click', toggleAudioDBEnrichment); + updateAudioDBStatus(); + setInterval(updateAudioDBStatus, 2000); + console.log('✅ AudioDB UI initialized'); + } +} + // =================================================================== // IMPORT / STAGING SYSTEM // =================================================================== diff --git a/webui/static/style.css b/webui/static/style.css index 7b23afe7..9ca00995 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -22034,6 +22034,230 @@ body { border-bottom: 8px solid rgba(30, 30, 30, 0.98); } +/* ============================================================================ + AUDIODB ENRICHMENT STATUS BUTTON + ============================================================================ */ + +/* Container for button + tooltip positioning */ +.audiodb-button-container { + position: relative; + display: inline-block; + margin-right: 12px; +} + +/* AudioDB Button */ +.audiodb-button { + position: relative; + width: 44px; + height: 44px; + background: linear-gradient(135deg, + rgba(0, 188, 212, 0.12) 0%, + rgba(0, 150, 170, 0.18) 100%); + backdrop-filter: blur(20px) saturate(1.4); + -webkit-backdrop-filter: blur(20px) saturate(1.4); + border: 1.5px solid rgba(0, 188, 212, 0.25); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 4px 16px rgba(0, 188, 212, 0.2), + 0 2px 8px rgba(0, 0, 0, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.audiodb-button:hover { + background: linear-gradient(135deg, + rgba(0, 188, 212, 0.18) 0%, + rgba(0, 150, 170, 0.25) 100%); + border-color: rgba(0, 188, 212, 0.4); + transform: scale(1.05); + box-shadow: + 0 6px 20px rgba(0, 188, 212, 0.3), + 0 3px 12px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} + +.audiodb-button:active { + transform: scale(0.95); + box-shadow: + 0 2px 8px rgba(0, 188, 212, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +/* AudioDB Logo */ +.audiodb-logo { + width: 24px; + height: 24px; + object-fit: contain; + opacity: 0.85; + transition: opacity 0.3s ease; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); +} + +.audiodb-button:hover .audiodb-logo { + opacity: 1; +} + +/* Loading Spinner - Animated Ring */ +.audiodb-spinner { + position: absolute; + width: 44px; + height: 44px; + border: 3px solid transparent; + border-top-color: rgba(0, 188, 212, 0.8); + border-right-color: rgba(0, 188, 212, 0.5); + border-radius: 50%; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +/* Active State - Spinner visible and rotating */ +.audiodb-button.active .audiodb-spinner { + opacity: 1; + animation: audiodb-spin 1.2s linear infinite; +} + +.audiodb-button.active { + background: linear-gradient(135deg, + rgba(0, 188, 212, 0.22) 0%, + rgba(0, 150, 170, 0.28) 100%); + border-color: rgba(0, 188, 212, 0.5); + box-shadow: + 0 6px 24px rgba(0, 188, 212, 0.4), + 0 3px 12px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.15); +} + +.audiodb-button.active .audiodb-logo { + opacity: 1; + filter: drop-shadow(0 0 8px rgba(0, 188, 212, 0.6)); +} + +/* Paused State */ +.audiodb-button.paused { + background: linear-gradient(135deg, + rgba(255, 193, 7, 0.12) 0%, + rgba(255, 152, 0, 0.18) 100%); + border-color: rgba(255, 193, 7, 0.35); + box-shadow: + 0 4px 16px rgba(255, 193, 7, 0.2), + 0 2px 8px rgba(0, 0, 0, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.audiodb-button.paused:hover { + border-color: rgba(255, 193, 7, 0.5); + box-shadow: + 0 6px 20px rgba(255, 193, 7, 0.3), + 0 3px 12px rgba(0, 0, 0, 0.2); +} + +/* Spinner Rotation Animation */ +@keyframes audiodb-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* ============================================================================ + AUDIODB HOVER TOOLTIP + ============================================================================ */ + +.audiodb-tooltip { + position: absolute; + left: 50%; + top: calc(100% + 12px); + transform: translateX(-50%) translateY(-5px); + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +.audiodb-button:hover+.audiodb-tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + +.audiodb-tooltip-content { + min-width: 260px; + background: linear-gradient(135deg, + rgba(30, 30, 30, 0.98) 0%, + rgba(20, 20, 20, 0.99) 100%); + backdrop-filter: blur(40px) saturate(1.6); + -webkit-backdrop-filter: blur(40px) saturate(1.6); + border: 1px solid rgba(0, 188, 212, 0.3); + border-radius: 16px; + padding: 16px 18px; + box-shadow: + 0 12px 40px rgba(0, 0, 0, 0.5), + 0 6px 20px rgba(0, 188, 212, 0.25), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.audiodb-tooltip-header { + font-family: 'SF Pro Display', -apple-system, sans-serif; + font-size: 13px; + font-weight: 600; + color: rgba(0, 188, 212, 0.95); + letter-spacing: -0.2px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.audiodb-tooltip-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +#audiodb-tooltip-status { + color: #1ed760; + font-weight: 600; +} + +.audiodb-button.paused+.audiodb-tooltip #audiodb-tooltip-status { + color: #ffc107; +} + +/* AudioDB Tooltip Arrow */ +.audiodb-tooltip-content::before { + content: ''; + position: absolute; + left: 50%; + top: -8px; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 8px solid rgba(0, 188, 212, 0.3); +} + +.audiodb-tooltip-content::after { + content: ''; + position: absolute; + left: 50%; + top: -7px; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 8px solid rgba(30, 30, 30, 0.98); +} + /* MusicBrainz Integration */ .release-card { position: relative !important;