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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -164,6 +164,27 @@
|
|||
</div>
|
||||
<div class="header-spacer"></div>
|
||||
<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
|
||||
(0)</button>
|
||||
<button class="header-button wishlist-button" id="wishlist-button">🎵 Wishlist (0)</button>
|
||||
|
|
|
|||
|
|
@ -34160,3 +34160,118 @@ if (document.readyState === 'loading') {
|
|||
} else {
|
||||
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