feat(ui): add MusicBrainz enrichment status UI with real-time monitoring
MusicBrainz library enrichment with real-time status monitoring and manual control. Features: - Status icon button in dashboard header with glassmorphic design - Animated loading spinner during active enrichment - Hover tooltip showing: - Worker status (Running/Paused/Idle) - Currently processing item - Artist matching progress with percentage - Click-to-toggle pause/resume functionality - Auto-polling every 2 seconds for live updates Backend Changes: - Added GET /api/musicbrainz/status endpoint - Added POST /api/musicbrainz/pause endpoint - Added POST /api/musicbrainz/resume endpoint - Worker tracks current_item for UI display - get_stats() returns enhanced status data Frontend Changes: - New MusicBrainz button component with tooltip - Premium CSS styling with animations - JavaScript polling and state management - Positioned tooltip below button with centered arrow Files Modified: - web_server.py: API endpoints and worker initialization - core/musicbrainz_worker.py: current_item tracking - webui/index.html: Button and tooltip structure - webui/static/style.css: Complete styling (240 lines) - webui/static/script.js: Polling and interaction logic (115 lines)
This commit is contained in:
parent
5d779e7c81
commit
cee5590718
8 changed files with 3257 additions and 1067 deletions
285
core/musicbrainz_client.py
Normal file
285
core/musicbrainz_client.py
Normal file
|
|
@ -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
|
||||||
432
core/musicbrainz_service.py
Normal file
432
core/musicbrainz_service.py
Normal file
|
|
@ -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()
|
||||||
390
core/musicbrainz_worker.py
Normal file
390
core/musicbrainz_worker.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -294,6 +294,9 @@ class MusicDatabase:
|
||||||
# Make spotify_artist_id nullable for iTunes-only artists (migration)
|
# Make spotify_artist_id nullable for iTunes-only artists (migration)
|
||||||
self._fix_watchlist_spotify_id_nullable(cursor)
|
self._fix_watchlist_spotify_id_nullable(cursor)
|
||||||
|
|
||||||
|
# Add MusicBrainz columns to library tables (migration)
|
||||||
|
self._add_musicbrainz_columns(cursor)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
|
@ -885,6 +888,70 @@ class MusicDatabase:
|
||||||
logger.error(f"Error making spotify_artist_id nullable in watchlist_artists: {e}")
|
logger.error(f"Error making spotify_artist_id nullable in watchlist_artists: {e}")
|
||||||
# Don't raise - this is a migration, database can still function
|
# 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):
|
def close(self):
|
||||||
"""Close database connection (no-op since we create connections per operation)"""
|
"""Close database connection (no-op since we create connections per operation)"""
|
||||||
# Each operation creates and closes its own connection, so nothing to do here
|
# Each operation creates and closes its own connection, so nothing to do here
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ from datetime import datetime
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from core.matching_engine import MusicMatchingEngine
|
from core.matching_engine import MusicMatchingEngine
|
||||||
from beatport_unified_scraper import BeatportUnifiedScraper
|
from beatport_unified_scraper import BeatportUnifiedScraper
|
||||||
|
from core.musicbrainz_worker import MusicBrainzWorker
|
||||||
|
|
||||||
# --- Flask App Setup ---
|
# --- Flask App Setup ---
|
||||||
base_dir = os.path.abspath(os.path.dirname(__file__))
|
base_dir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
@ -24298,6 +24299,83 @@ def merge_discography_data(owned_releases, spotify_discography):
|
||||||
'singles': []
|
'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__':
|
if __name__ == '__main__':
|
||||||
# Initialize logging for web server
|
# Initialize logging for web server
|
||||||
from utils.logging_config import setup_logging
|
from utils.logging_config import setup_logging
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,27 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="header-spacer"></div>
|
<div class="header-spacer"></div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<!-- MusicBrainz Enrichment Status Icon -->
|
||||||
|
<div class="mb-button-container">
|
||||||
|
<button class="musicbrainz-button" id="musicbrainz-button"
|
||||||
|
title="MusicBrainz Library Enrichment">
|
||||||
|
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png"
|
||||||
|
alt="MusicBrainz" class="mb-logo">
|
||||||
|
<div class="mb-spinner"></div>
|
||||||
|
</button>
|
||||||
|
<!-- MusicBrainz Hover Tooltip -->
|
||||||
|
<div class="musicbrainz-tooltip" id="musicbrainz-tooltip">
|
||||||
|
<div class="tooltip-content">
|
||||||
|
<div class="tooltip-header">🎵 MusicBrainz Enrichment</div>
|
||||||
|
<div class="tooltip-body" id="mb-tooltip-body">
|
||||||
|
<div class="tooltip-status">Status: <span id="mb-tooltip-status">Idle</span>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-current" id="mb-tooltip-current">No active matches</div>
|
||||||
|
<div class="tooltip-progress" id="mb-tooltip-progress">Progress: 0 / 0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="header-button watchlist-button" id="watchlist-button">👁️ Watchlist
|
<button class="header-button watchlist-button" id="watchlist-button">👁️ Watchlist
|
||||||
(0)</button>
|
(0)</button>
|
||||||
<button class="header-button wishlist-button" id="wishlist-button">🎵 Wishlist (0)</button>
|
<button class="header-button wishlist-button" id="wishlist-button">🎵 Wishlist (0)</button>
|
||||||
|
|
|
||||||
|
|
@ -34160,3 +34160,118 @@ if (document.readyState === 'loading') {
|
||||||
} else {
|
} else {
|
||||||
initializeDiscoverDownloadBar();
|
initializeDiscoverDownloadBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MUSICBRAINZ ENRICHMENT UI - PHASE 5 WEB UI
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll MusicBrainz status every 2 seconds and update UI
|
||||||
|
*/
|
||||||
|
async function updateMusicBrainzStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/musicbrainz/status');
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('MusicBrainz status endpoint unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const button = document.getElementById('musicbrainz-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('mb-tooltip-status');
|
||||||
|
const tooltipCurrent = document.getElementById('mb-tooltip-current');
|
||||||
|
const tooltipProgress = document.getElementById('mb-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 matched = artists.matched || 0;
|
||||||
|
const total = artists.total || 0;
|
||||||
|
const percent = total > 0 ? Math.round((matched / total) * 100) : 0;
|
||||||
|
tooltipProgress.textContent = `Progress: ${matched} / ${total} artists (${percent}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating MusicBrainz status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle MusicBrainz enrichment pause/resume
|
||||||
|
*/
|
||||||
|
async function toggleMusicBrainzEnrichment() {
|
||||||
|
try {
|
||||||
|
const button = document.getElementById('musicbrainz-button');
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
const isRunning = button.classList.contains('active');
|
||||||
|
const endpoint = isRunning ? '/api/musicbrainz/pause' : '/api/musicbrainz/resume';
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} MusicBrainz enrichment`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediately update UI
|
||||||
|
await updateMusicBrainzStatus();
|
||||||
|
|
||||||
|
console.log(`✅ MusicBrainz enrichment ${isRunning ? 'paused' : 'resumed'}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling MusicBrainz enrichment:', error);
|
||||||
|
showToast(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize MusicBrainz UI on page load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const button = document.getElementById('musicbrainz-button');
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', toggleMusicBrainzEnrichment);
|
||||||
|
// Start polling
|
||||||
|
updateMusicBrainzStatus();
|
||||||
|
setInterval(updateMusicBrainzStatus, 2000); // Poll every 2 seconds
|
||||||
|
console.log('✅ MusicBrainz UI initialized');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const button = document.getElementById('musicbrainz-button');
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', toggleMusicBrainzEnrichment);
|
||||||
|
// Start polling
|
||||||
|
updateMusicBrainzStatus();
|
||||||
|
setInterval(updateMusicBrainzStatus, 2000); // Poll every 2 seconds
|
||||||
|
console.log('✅ MusicBrainz UI initialized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue