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:
Broque Thomas 2026-02-03 15:20:04 -08:00
parent 5d779e7c81
commit cee5590718
8 changed files with 3257 additions and 1067 deletions

285
core/musicbrainz_client.py Normal file
View 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
View 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
View 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()

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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