diff --git a/core/musicbrainz_client.py b/core/musicbrainz_client.py new file mode 100644 index 00000000..c8b9e800 --- /dev/null +++ b/core/musicbrainz_client.py @@ -0,0 +1,285 @@ +import requests +import time +import threading +from typing import Dict, List, Optional, Any +from functools import wraps +from utils.logging_config import get_logger + +logger = get_logger("musicbrainz_client") + +# Global rate limiting variables +_last_api_call_time = 0 +_api_call_lock = threading.Lock() +MIN_API_INTERVAL = 1.0 # 1 second between API calls (MusicBrainz requirement) + +def rate_limited(func): + """Decorator to enforce rate limiting on MusicBrainz 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: + # Implement exponential backoff for API errors + if "rate limit" in str(e).lower() or "503" in str(e): + logger.warning(f"MusicBrainz rate limit hit, implementing backoff: {e}") + time.sleep(2.0) # Wait 2 seconds before retrying + raise e + return wrapper + +class MusicBrainzClient: + """Client for interacting with MusicBrainz API""" + + BASE_URL = "https://musicbrainz.org/ws/2" + + def __init__(self, app_name: str = "SoulSync", app_version: str = "1.0", contact_email: str = ""): + """ + Initialize MusicBrainz client + + Args: + app_name: Name of the application + app_version: Version of the application + contact_email: Contact email (optional but recommended) + """ + self.user_agent = f"{app_name}/{app_version}" + if contact_email: + self.user_agent += f" ( {contact_email} )" + + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': self.user_agent, + 'Accept': 'application/json' + }) + + logger.info(f"MusicBrainz client initialized with user agent: {self.user_agent}") + + @rate_limited + def search_artist(self, artist_name: str, limit: int = 10) -> List[Dict[str, Any]]: + """ + Search for artists by name + + Args: + artist_name: Name of the artist to search for + limit: Maximum number of results to return + + Returns: + List of artist results with id, name, score, etc. + """ + try: + # Escape quotes and backslashes for Lucene query + safe_name = artist_name.replace('\\', '\\\\').replace('"', '\\"') + + params = { + 'query': f'artist:"{safe_name}"', + 'fmt': 'json', + 'limit': limit + } + + response = self.session.get( + f"{self.BASE_URL}/artist", + params=params, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + artists = data.get('artists', []) + + logger.debug(f"Found {len(artists)} artists for query: {artist_name}") + return artists + + except Exception as e: + logger.error(f"Error searching for artist '{artist_name}': {e}") + return [] + + @rate_limited + def search_release(self, album_name: str, artist_name: Optional[str] = None, limit: int = 10) -> List[Dict[str, Any]]: + """ + Search for releases (albums) by name + + Args: + album_name: Name of the album to search for + artist_name: Optional artist name to narrow search + limit: Maximum number of results to return + + Returns: + List of release results + """ + try: + # Escape quotes and backslashes for Lucene query + safe_album = album_name.replace('\\', '\\\\').replace('"', '\\"') + query = f'release:"{safe_album}"' + + if artist_name: + safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"') + query += f' AND artist:"{safe_artist}"' + + params = { + 'query': query, + 'fmt': 'json', + 'limit': limit + } + + response = self.session.get( + f"{self.BASE_URL}/release", + params=params, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + releases = data.get('releases', []) + + logger.debug(f"Found {len(releases)} releases for query: {album_name}") + return releases + + except Exception as e: + logger.error(f"Error searching for release '{album_name}': {e}") + return [] + + @rate_limited + def search_recording(self, track_name: str, artist_name: Optional[str] = None, limit: int = 10) -> List[Dict[str, Any]]: + """ + Search for recordings (tracks) by name + + Args: + track_name: Name of the track to search for + artist_name: Optional artist name to narrow search + limit: Maximum number of results to return + + Returns: + List of recording results + """ + try: + # Escape quotes and backslashes for Lucene query + safe_track = track_name.replace('\\', '\\\\').replace('"', '\\"') + query = f'recording:"{safe_track}"' + + if artist_name: + safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"') + query += f' AND artist:"{safe_artist}"' + + params = { + 'query': query, + 'fmt': 'json', + 'limit': limit + } + + response = self.session.get( + f"{self.BASE_URL}/recording", + params=params, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + recordings = data.get('recordings', []) + + logger.debug(f"Found {len(recordings)} recordings for query: {track_name}") + return recordings + + except Exception as e: + logger.error(f"Error searching for recording '{track_name}': {e}") + return [] + + @rate_limited + def get_artist(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]: + """ + Get full artist details by MusicBrainz ID + + Args: + mbid: MusicBrainz ID of the artist + includes: Optional list of additional data to include (e.g., 'url-rels', 'genres') + + Returns: + Artist data or None if not found + """ + try: + params = {'fmt': 'json'} + if includes: + params['inc'] = '+'.join(includes) + + response = self.session.get( + f"{self.BASE_URL}/artist/{mbid}", + params=params, + timeout=10 + ) + response.raise_for_status() + + return response.json() + + except Exception as e: + logger.error(f"Error fetching artist {mbid}: {e}") + return None + + @rate_limited + def get_release(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]: + """ + Get full release details by MusicBrainz ID + + Args: + mbid: MusicBrainz ID of the release + includes: Optional list of additional data to include + + Returns: + Release data or None if not found + """ + try: + params = {'fmt': 'json'} + if includes: + params['inc'] = '+'.join(includes) + + response = self.session.get( + f"{self.BASE_URL}/release/{mbid}", + params=params, + timeout=10 + ) + response.raise_for_status() + + return response.json() + + except Exception as e: + logger.error(f"Error fetching release {mbid}: {e}") + return None + + @rate_limited + def get_recording(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]: + """ + Get full recording details by MusicBrainz ID + + Args: + mbid: MusicBrainz ID of the recording + includes: Optional list of additional data to include + + Returns: + Recording data or None if not found + """ + try: + params = {'fmt': 'json'} + if includes: + params['inc'] = '+'.join(includes) + + response = self.session.get( + f"{self.BASE_URL}/recording/{mbid}", + params=params, + timeout=10 + ) + response.raise_for_status() + + return response.json() + + except Exception as e: + logger.error(f"Error fetching recording {mbid}: {e}") + return None diff --git a/core/musicbrainz_service.py b/core/musicbrainz_service.py new file mode 100644 index 00000000..d299f36d --- /dev/null +++ b/core/musicbrainz_service.py @@ -0,0 +1,432 @@ +from typing import Optional, Dict, Any, List +import json +from datetime import datetime, timedelta +from difflib import SequenceMatcher +from utils.logging_config import get_logger +from core.musicbrainz_client import MusicBrainzClient +from database.music_database import MusicDatabase + +logger = get_logger("musicbrainz_service") + +class MusicBrainzService: + """Service layer for MusicBrainz integration with caching and matching logic""" + + def __init__(self, database: MusicDatabase, app_name: str = "SoulSync", app_version: str = "1.0", contact_email: str = ""): + self.db = database + self.mb_client = MusicBrainzClient(app_name, app_version, contact_email) + self.retry_days = 30 # Retry 'not_found' items after 30 days + + def _calculate_similarity(self, str1: str, str2: str) -> float: + """Calculate string similarity score (0.0 to 1.0)""" + if not str1 or not str2: + return 0.0 + + # Normalize for comparison + s1 = str1.lower().strip() + s2 = str2.lower().strip() + + if s1 == s2: + return 1.0 + + return SequenceMatcher(None, s1, s2).ratio() + + def _check_cache(self, entity_type: str, entity_name: str, artist_name: Optional[str] = None) -> Optional[Dict[str, Any]]: + """Check if we have a cached MusicBrainz result""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + # Fix: Match exact artist_name (not OR artist_name IS NULL) + # This prevents getting wrong cached results + if artist_name is not None: + cursor.execute(""" + SELECT musicbrainz_id, metadata_json, match_confidence, last_updated + FROM musicbrainz_cache + WHERE entity_type = ? AND entity_name = ? AND artist_name = ? + ORDER BY last_updated DESC + LIMIT 1 + """, (entity_type, entity_name, artist_name)) + else: + cursor.execute(""" + SELECT musicbrainz_id, metadata_json, match_confidence, last_updated + FROM musicbrainz_cache + WHERE entity_type = ? AND entity_name = ? AND artist_name IS NULL + ORDER BY last_updated DESC + LIMIT 1 + """, (entity_type, entity_name)) + + row = cursor.fetchone() + + if row: + # Don't use cache if it's older than 90 days + last_updated = datetime.fromisoformat(row[3]) if row[3] else None + if last_updated and (datetime.now() - last_updated).days > 90: + logger.debug(f"Cache entry for {entity_type} '{entity_name}' is stale (> 90 days)") + return None + + # Parse JSON with error handling + try: + metadata = json.loads(row[1]) if row[1] else None + except json.JSONDecodeError: + logger.warning(f"Invalid JSON in cache for {entity_type} '{entity_name}', ignoring") + metadata = None + + return { + 'musicbrainz_id': row[0], + 'metadata': metadata, + 'confidence': row[2] + } + + return None + + except Exception as e: + logger.error(f"Error checking cache: {e}") + return None + finally: + if conn: + conn.close() + + def _save_to_cache(self, entity_type: str, entity_name: str, artist_name: Optional[str], + musicbrainz_id: Optional[str], metadata: Optional[Dict], confidence: int): + """Save MusicBrainz result to cache""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + metadata_json = json.dumps(metadata) if metadata else None + + cursor.execute(""" + INSERT OR REPLACE INTO musicbrainz_cache + (entity_type, entity_name, artist_name, musicbrainz_id, metadata_json, match_confidence, last_updated) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (entity_type, entity_name, artist_name, musicbrainz_id, metadata_json, confidence, datetime.now())) + + conn.commit() + + logger.debug(f"Cached {entity_type} '{entity_name}' (MBID: {musicbrainz_id}, confidence: {confidence})") + + except Exception as e: + logger.error(f"Error saving to cache: {e}") + if conn: + conn.rollback() + finally: + if conn: + conn.close() + + def match_artist(self, artist_name: str) -> Optional[Dict[str, Any]]: + """ + Match an artist by name to MusicBrainz + + Returns: + Dict with 'mbid', 'name', 'confidence' or None if no good match + """ + # Check cache first + cached = self._check_cache('artist', artist_name) + if cached: + logger.debug(f"Cache hit for artist '{artist_name}'") + return { + 'mbid': cached['musicbrainz_id'], + 'name': artist_name, + 'confidence': cached['confidence'], + 'cached': True + } + + # Search MusicBrainz + try: + results = self.mb_client.search_artist(artist_name, limit=5) + + if not results: + logger.info(f"No MusicBrainz results for artist '{artist_name}'") + self._save_to_cache('artist', artist_name, None, None, None, 0) + return None + + # Find best match + best_match = None + best_confidence = 0 + + for result in results: + mb_name = result.get('name', '') + mb_score = result.get('score', 0) # MusicBrainz search score + + # Calculate our own similarity + similarity = self._calculate_similarity(artist_name, mb_name) + + # Combine MusicBrainz score with our similarity (weighted) + # Cap at 100 to prevent edge cases where MB score > 100 + confidence = min(100, int((similarity * 60) + (mb_score / 100 * 40))) + + if confidence > best_confidence: + best_confidence = confidence + best_match = result + + # Only return matches with confidence >= 70% + if best_match and best_confidence >= 70: + mbid = best_match.get('id') + mb_name = best_match.get('name') + + # Save to cache + self._save_to_cache('artist', artist_name, None, mbid, best_match, best_confidence) + + logger.info(f"Matched artist '{artist_name}' → '{mb_name}' (MBID: {mbid}, confidence: {best_confidence})") + + return { + 'mbid': mbid, + 'name': mb_name, + 'confidence': best_confidence, + 'cached': False + } + else: + logger.info(f"Low confidence match for artist '{artist_name}' (best: {best_confidence})") + self._save_to_cache('artist', artist_name, None, None, None, best_confidence) + return None + + except Exception as e: + logger.error(f"Error matching artist '{artist_name}': {e}") + return None + + def match_release(self, album_name: str, artist_name: Optional[str] = None) -> Optional[Dict[str, Any]]: + """ + Match a release (album) by name to MusicBrainz + + Returns: + Dict with 'mbid', 'title', 'confidence' or None if no good match + """ + # Check cache first + cached = self._check_cache('release', album_name, artist_name) + if cached: + logger.debug(f"Cache hit for release '{album_name}'") + return { + 'mbid': cached['musicbrainz_id'], + 'title': album_name, + 'confidence': cached['confidence'], + 'cached': True + } + + # Search MusicBrainz + try: + results = self.mb_client.search_release(album_name, artist_name, limit=5) + + if not results: + logger.info(f"No MusicBrainz results for release '{album_name}'") + self._save_to_cache('release', album_name, artist_name, None, None, 0) + return None + + # Find best match + best_match = None + best_confidence = 0 + + for result in results: + mb_title = result.get('title', '') + mb_score = result.get('score', 0) + + # Calculate title similarity + title_similarity = self._calculate_similarity(album_name, mb_title) + + # If we have artist info, check artist match too + artist_bonus = 0 + if artist_name and 'artist-credit' in result: + artist_credits = result['artist-credit'] + for credit in artist_credits: + if isinstance(credit, dict) and 'artist' in credit: + mb_artist = credit['artist'].get('name', '') + artist_similarity = self._calculate_similarity(artist_name, mb_artist) + if artist_similarity > 0.7: + artist_bonus = 20 + break + + # Combine scores - cap at 100 + confidence = min(100, int((title_similarity * 50) + (mb_score / 100 * 30) + artist_bonus)) + + if confidence > best_confidence: + best_confidence = confidence + best_match = result + + # Only return matches with confidence >= 70% + if best_match and best_confidence >= 70: + mbid = best_match.get('id') + mb_title = best_match.get('title') + + # Save to cache + self._save_to_cache('release', album_name, artist_name, mbid, best_match, best_confidence) + + logger.info(f"Matched release '{album_name}' → '{mb_title}' (MBID: {mbid}, confidence: {best_confidence})") + + return { + 'mbid': mbid, + 'title': mb_title, + 'confidence': best_confidence, + 'cached': False + } + else: + logger.info(f"Low confidence match for release '{album_name}' (best: {best_confidence})") + self._save_to_cache('release', album_name, artist_name, None, None, best_confidence) + return None + + except Exception as e: + logger.error(f"Error matching release '{album_name}': {e}") + return None + + def match_recording(self, track_name: str, artist_name: Optional[str] = None) -> Optional[Dict[str, Any]]: + """ + Match a recording (track) by name to MusicBrainz + + Returns: + Dict with 'mbid', 'title', 'confidence' or None if no good match + """ + # Check cache first + cached = self._check_cache('recording', track_name, artist_name) + if cached: + logger.debug(f"Cache hit for recording '{track_name}'") + return { + 'mbid': cached['musicbrainz_id'], + 'title': track_name, + 'confidence': cached['confidence'], + 'cached': True + } + + # Search MusicBrainz + try: + results = self.mb_client.search_recording(track_name, artist_name, limit=5) + + if not results: + logger.info(f"No MusicBrainz results for recording '{track_name}'") + self._save_to_cache('recording', track_name, artist_name, None, None, 0) + return None + + # Find best match + best_match = None + best_confidence = 0 + + for result in results: + mb_title = result.get('title', '') + mb_score = result.get('score', 0) + + # Calculate title similarity + title_similarity = self._calculate_similarity(track_name, mb_title) + + # If we have artist info, check artist match too + artist_bonus = 0 + if artist_name and 'artist-credit' in result: + artist_credits = result['artist-credit'] + for credit in artist_credits: + if isinstance(credit, dict) and 'artist' in credit: + mb_artist = credit['artist'].get('name', '') + artist_similarity = self._calculate_similarity(artist_name, mb_artist) + if artist_similarity > 0.7: + artist_bonus = 20 + break + + # Combine scores - cap at 100 + confidence = min(100, int((title_similarity * 50) + (mb_score / 100 * 30) + artist_bonus)) + + if confidence > best_confidence: + best_confidence = confidence + best_match = result + + # Only return matches with confidence >= 70% + if best_match and best_confidence >= 70: + mbid = best_match.get('id') + mb_title = best_match.get('title') + + # Save to cache + self._save_to_cache('recording', track_name, artist_name, mbid, best_match, best_confidence) + + logger.info(f"Matched recording '{track_name}' → '{mb_title}' (MBID: {mbid}, confidence: {best_confidence})") + + return { + 'mbid': mbid, + 'title': mb_title, + 'confidence': best_confidence, + 'cached': False + } + else: + logger.info(f"Low confidence match for recording '{track_name}' (best: {best_confidence})") + self._save_to_cache('recording', track_name, artist_name, None, None, best_confidence) + return None + + except Exception as e: + logger.error(f"Error matching recording '{track_name}': {e}") + return None + + def update_artist_mbid(self, artist_id: int, mbid: Optional[str], status: str): + """Update artist with MusicBrainz ID""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + UPDATE artists + SET musicbrainz_id = ?, + musicbrainz_last_attempted = ?, + musicbrainz_match_status = ? + WHERE id = ? + """, (mbid, datetime.now(), status, artist_id)) + + conn.commit() + + logger.debug(f"Updated artist {artist_id} with MBID: {mbid}, status: {status}") + + except Exception as e: + logger.error(f"Error updating artist {artist_id}: {e}") + if conn: + conn.rollback() + finally: + if conn: + conn.close() + + def update_album_mbid(self, album_id: int, mbid: Optional[str], status: str): + """Update album with MusicBrainz release ID""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + UPDATE albums + SET musicbrainz_release_id = ?, + musicbrainz_last_attempted = ?, + musicbrainz_match_status = ? + WHERE id = ? + """, (mbid, datetime.now(), status, album_id)) + + conn.commit() + + logger.debug(f"Updated album {album_id} with MBID: {mbid}, status: {status}") + + except Exception as e: + logger.error(f"Error updating album {album_id}: {e}") + if conn: + conn.rollback() + finally: + if conn: + conn.close() + + def update_track_mbid(self, track_id: int, mbid: Optional[str], status: str): + """Update track with MusicBrainz recording ID""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + UPDATE tracks + SET musicbrainz_recording_id = ?, + musicbrainz_last_attempted = ?, + musicbrainz_match_status = ? + WHERE id = ? + """, (mbid, datetime.now(), status, track_id)) + + conn.commit() + + logger.debug(f"Updated track {track_id} with MBID: {mbid}, status: {status}") + + except Exception as e: + logger.error(f"Error updating track {track_id}: {e}") + if conn: + conn.rollback() + finally: + if conn: + conn.close() diff --git a/core/musicbrainz_worker.py b/core/musicbrainz_worker.py new file mode 100644 index 00000000..7bfc8566 --- /dev/null +++ b/core/musicbrainz_worker.py @@ -0,0 +1,390 @@ +import threading +import time +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.musicbrainz_service import MusicBrainzService + +logger = get_logger("musicbrainz_worker") + +class MusicBrainzWorker: + """Background worker for enriching library with MusicBrainz IDs""" + + def __init__(self, database: MusicDatabase, app_name: str = "SoulSync", app_version: str = "1.0", contact_email: str = ""): + self.db = database + self.mb_service = MusicBrainzService(database, app_name, app_version, contact_email) + + # 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 # Retry 'not_found' items after 30 days + + logger.info("MusicBrainz 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("MusicBrainz background worker started") + + def stop(self): + """Stop the background worker""" + if not self.running: + return + + logger.info("Stopping MusicBrainz worker...") + self.should_stop = True + self.running = False + + if self.thread: + self.thread.join(timeout=5) + + logger.info("Music Brainz 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("MusicBrainz 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("MusicBrainz worker resumed") + + def get_stats(self) -> Dict[str, Any]: + """Get current statistics""" + # Update pending count + self.stats['pending'] = self._count_pending_items() + + # Get progress breakdown by entity type + progress = self._get_progress_breakdown() + + # Check if thread is actually alive (in case it crashed) + 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("MusicBrainz worker thread started") + + while not self.should_stop: + try: + # Check if paused + if self.paused: + time.sleep(1) + continue + + # Get next item to process + item = self._get_next_item() + + if not item: + # No more items - sleep for a bit + self.current_item = None + logger.debug("No pending items, sleeping...") + time.sleep(10) + continue + + # Set current item for UI tracking + self.current_item = item + + # Process the item + self._process_item(item) + + # Clear current item after processing + self.current_item = None + + # Rate limit: 1 request per second + time.sleep(1) + + except Exception as e: + logger.error(f"Error in worker loop: {e}") + time.sleep(5) # Back off on errors + + logger.info("MusicBrainz worker thread finished") + + def _get_next_item(self) -> Optional[Dict[str, Any]]: + """Get next item to process from priority queue""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + # Priority 1: Unattempted artists + cursor.execute(""" + SELECT id, name + FROM artists + WHERE musicbrainz_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.musicbrainz_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.musicbrainz_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 musicbrainz_match_status = 'not_found' + AND musicbrainz_last_attempted < ? + ORDER BY musicbrainz_last_attempted ASC + LIMIT 1 + """, (cutoff_date,)) + row = cursor.fetchone() + if row: + logger.info(f"Retrying artist '{row[1]}' (last attempted: {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.musicbrainz_match_status = 'not_found' + AND a.musicbrainz_last_attempted < ? + ORDER BY a.musicbrainz_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.musicbrainz_match_status = 'not_found' + AND t.musicbrainz_last_attempted < ? + ORDER BY t.musicbrainz_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 _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.mb_service.match_artist(item_name) + if result and result.get('mbid'): + self.mb_service.update_artist_mbid(item_id, result['mbid'], 'matched') + self.stats['matched'] += 1 + logger.info(f"✅ Matched artist '{item_name}' → MBID: {result['mbid']}") + else: + self.mb_service.update_artist_mbid(item_id, None, '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.mb_service.match_release(item_name, artist_name) + if result and result.get('mbid'): + self.mb_service.update_album_mbid(item_id, result['mbid'], 'matched') + self.stats['matched'] += 1 + logger.info(f"✅ Matched album '{item_name}' → MBID: {result['mbid']}") + else: + self.mb_service.update_album_mbid(item_id, None, '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.mb_service.match_recording(item_name, artist_name) + if result and result.get('mbid'): + self.mb_service.update_track_mbid(item_id, result['mbid'], 'matched') + self.stats['matched'] += 1 + logger.info(f"✅ Matched track '{item_name}' → MBID: {result['mbid']}") + else: + self.mb_service.update_track_mbid(item_id, None, '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 + + # Mark as error in database + try: + if item['type'] == 'artist': + self.mb_service.update_artist_mbid(item['id'], None, 'error') + elif item['type'] == 'album': + self.mb_service.update_album_mbid(item['id'], None, 'error') + elif item['type'] == 'track': + self.mb_service.update_track_mbid(item['id'], None, 'error') + except Exception as e2: + logger.error(f"Error updating item status: {e2}") + + def _count_pending_items(self) -> int: + """Count how many items still need processing""" + conn = None + try: + conn = self.db._get_connection() + cursor = conn.cursor() + + # Count unattempted items + cursor.execute(""" + SELECT + (SELECT COUNT(*) FROM artists WHERE musicbrainz_match_status IS NULL) + + (SELECT COUNT(*) FROM albums WHERE musicbrainz_match_status IS NULL) + + (SELECT COUNT(*) FROM tracks WHERE musicbrainz_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 musicbrainz_match_status = 'matched' THEN 1 ELSE 0 END) AS matched + FROM artists + """) + row = cursor.fetchone() + if row: + total, matched = row[0], row[1] or 0 + progress['artists'] = { + 'matched': matched, + 'total': total, + 'percent': int((matched / total * 100) if total > 0 else 0) + } + + # Albums progress + cursor.execute(""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN musicbrainz_match_status = 'matched' THEN 1 ELSE 0 END) AS matched + FROM albums + """) + row = cursor.fetchone() + if row: + total, matched = row[0], row[1] or 0 + progress['albums'] = { + 'matched': matched, + 'total': total, + 'percent': int((matched / total * 100) if total > 0 else 0) + } + + # Tracks progress + cursor.execute(""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN musicbrainz_match_status = 'matched' THEN 1 ELSE 0 END) AS matched + FROM tracks + """) + row = cursor.fetchone() + if row: + total, matched = row[0], row[1] or 0 + progress['tracks'] = { + 'matched': matched, + 'total': total, + 'percent': int((matched / 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 1665ff77..254747c3 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -294,6 +294,9 @@ class MusicDatabase: # Make spotify_artist_id nullable for iTunes-only artists (migration) self._fix_watchlist_spotify_id_nullable(cursor) + # Add MusicBrainz columns to library tables (migration) + self._add_musicbrainz_columns(cursor) + conn.commit() logger.info("Database initialized successfully") @@ -885,6 +888,70 @@ class MusicDatabase: logger.error(f"Error making spotify_artist_id nullable in watchlist_artists: {e}") # Don't raise - this is a migration, database can still function + def _add_musicbrainz_columns(self, cursor): + """Add MusicBrainz tracking columns to library tables for metadata enrichment""" + try: + # Check if musicbrainz_id column exists in artists table + cursor.execute("PRAGMA table_info(artists)") + artists_columns = [column[1] for column in cursor.fetchall()] + + if 'musicbrainz_id' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN musicbrainz_id TEXT") + cursor.execute("ALTER TABLE artists ADD COLUMN musicbrainz_last_attempted TIMESTAMP") + cursor.execute("ALTER TABLE artists ADD COLUMN musicbrainz_match_status TEXT") + logger.info("Added MusicBrainz columns to artists table") + + # Check if musicbrainz_release_id column exists in albums table + cursor.execute("PRAGMA table_info(albums)") + albums_columns = [column[1] for column in cursor.fetchall()] + + if 'musicbrainz_release_id' not in albums_columns: + cursor.execute("ALTER TABLE albums ADD COLUMN musicbrainz_release_id TEXT") + cursor.execute("ALTER TABLE albums ADD COLUMN musicbrainz_last_attempted TIMESTAMP") + cursor.execute("ALTER TABLE albums ADD COLUMN musicbrainz_match_status TEXT") + logger.info("Added MusicBrainz columns to albums table") + + # Check if musicbrainz_recording_id column exists in tracks table + cursor.execute("PRAGMA table_info(tracks)") + tracks_columns = [column[1] for column in cursor.fetchall()] + + if 'musicbrainz_recording_id' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN musicbrainz_recording_id TEXT") + cursor.execute("ALTER TABLE tracks ADD COLUMN musicbrainz_last_attempted TIMESTAMP") + cursor.execute("ALTER TABLE tracks ADD COLUMN musicbrainz_match_status TEXT") + logger.info("Added MusicBrainz columns to tracks table") + + # Create MusicBrainz cache table for storing API results + cursor.execute(""" + CREATE TABLE IF NOT EXISTS musicbrainz_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, + entity_name TEXT NOT NULL, + artist_name TEXT, + musicbrainz_id TEXT, + spotify_id TEXT, + itunes_id TEXT, + metadata_json TEXT, + match_confidence INTEGER, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(entity_type, entity_name, artist_name) + ) + """) + + # Create indexes for MusicBrainz columns for performance + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists (musicbrainz_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_mb_status ON artists (musicbrainz_match_status)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_mbid ON albums (musicbrainz_release_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_mb_status ON albums (musicbrainz_match_status)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_mbid ON tracks (musicbrainz_recording_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_mb_status ON tracks (musicbrainz_match_status)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_mb_cache_entity ON musicbrainz_cache (entity_type, entity_name)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_mb_cache_mbid ON musicbrainz_cache (musicbrainz_id)") + + except Exception as e: + logger.error(f"Error adding MusicBrainz 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 00344c11..75dc5cf2 100644 --- a/web_server.py +++ b/web_server.py @@ -67,6 +67,7 @@ from datetime import datetime import yt_dlp from core.matching_engine import MusicMatchingEngine from beatport_unified_scraper import BeatportUnifiedScraper +from core.musicbrainz_worker import MusicBrainzWorker # --- Flask App Setup --- base_dir = os.path.abspath(os.path.dirname(__file__)) @@ -24298,6 +24299,83 @@ def merge_discography_data(owned_releases, spotify_discography): 'singles': [] } +# ================================================================================================ +# MUSICBRAINZ ENRICHMENT - PHASE 5 WEB UI INTEGRATION +# ================================================================================================ + +# --- MusicBrainz Worker Initialization --- +mb_worker = None +try: + from database.music_database import MusicDatabase + mb_db = MusicDatabase() + mb_worker = MusicBrainzWorker( + database=mb_db, + app_name="SoulSync", + app_version="1.0", + contact_email="" + ) + # Start worker automatically (can be paused via UI) + mb_worker.start() + print("✅ MusicBrainz enrichment worker initialized and started") +except Exception as e: + print(f"⚠️ MusicBrainz worker initialization failed: {e}") + mb_worker = None + +# --- MusicBrainz API Endpoints --- + +@app.route('/api/musicbrainz/status', methods=['GET']) +def musicbrainz_status(): + """Get MusicBrainz enrichment status for UI polling""" + try: + if mb_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 = mb_worker.get_stats() + return jsonify(status), 200 + except Exception as e: + logger.error(f"Error getting MusicBrainz status: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/musicbrainz/pause', methods=['POST']) +def musicbrainz_pause(): + """Pause MusicBrainz enrichment worker (finishes current match first)""" + try: + if mb_worker is None: + return jsonify({'error': 'MusicBrainz worker not initialized'}), 400 + + mb_worker.pause() + logger.info("MusicBrainz worker paused via UI") + return jsonify({'status': 'paused'}), 200 + except Exception as e: + logger.error(f"Error pausing MusicBrainz worker: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/musicbrainz/resume', methods=['POST']) +def musicbrainz_resume(): + """Resume MusicBrainz enrichment worker""" + try: + if mb_worker is None: + return jsonify({'error': 'MusicBrainz worker not initialized'}), 400 + + mb_worker.resume() + logger.info("MusicBrainz worker resumed via UI") + return jsonify({'status': 'running'}), 200 + except Exception as e: + logger.error(f"Error resuming MusicBrainz worker: {e}") + return jsonify({'error': str(e)}), 500 + +# ================================================================================================ +# END MUSICBRAINZ INTEGRATION +# ================================================================================================ + + if __name__ == '__main__': # Initialize logging for web server from utils.logging_config import setup_logging diff --git a/webui/index.html b/webui/index.html index af24cd67..09d89062 100644 --- a/webui/index.html +++ b/webui/index.html @@ -164,6 +164,27 @@