Add AudioDB enrichment for artists, albums, and tracks

Integrated TheAudioDB as a metadata enrichment source. A background worker scans the library in priority order (artists → albums → tracks), matching entities via fuzzy name comparison and storing style, mood, and AudioDB IDs. Includes rate limiting, 30-day retry for not-found items, and a UI tooltip showing phase-based scan progress.
pull/153/head
Broque Thomas 2 months ago
parent d54f433277
commit 1a4395cc95

@ -0,0 +1,154 @@
import requests
import time
import threading
from typing import Dict, Optional, Any
from functools import wraps
from utils.logging_config import get_logger
logger = get_logger("audiodb_client")
# Global rate limiting variables
_last_api_call_time = 0
_api_call_lock = threading.Lock()
MIN_API_INTERVAL = 2.0 # 2 seconds between API calls (30 req/min free tier)
def rate_limited(func):
"""Decorator to enforce rate limiting on AudioDB API calls"""
@wraps(func)
def wrapper(*args, **kwargs):
global _last_api_call_time
with _api_call_lock:
current_time = time.time()
time_since_last_call = current_time - _last_api_call_time
if time_since_last_call < MIN_API_INTERVAL:
sleep_time = MIN_API_INTERVAL - time_since_last_call
time.sleep(sleep_time)
_last_api_call_time = time.time()
try:
result = func(*args, **kwargs)
return result
except Exception as e:
if "rate limit" in str(e).lower() or "429" in str(e):
logger.warning(f"AudioDB rate limit hit, implementing backoff: {e}")
time.sleep(4.0)
raise e
return wrapper
class AudioDBClient:
"""Client for interacting with TheAudioDB API"""
BASE_URL = "https://www.theaudiodb.com/api/v1/json/2"
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'SoulSync/1.0',
'Accept': 'application/json'
})
logger.info("AudioDB client initialized")
@rate_limited
def search_artist(self, artist_name: str) -> Optional[Dict[str, Any]]:
"""
Search for an artist by name.
Args:
artist_name: Name of the artist to search for
Returns:
Artist dict from AudioDB or None if not found
"""
try:
response = self.session.get(
f"{self.BASE_URL}/search.php",
params={'s': artist_name},
timeout=10
)
response.raise_for_status()
data = response.json()
artists = data.get('artists')
if artists and len(artists) > 0:
logger.debug(f"Found artist for query: {artist_name}")
return artists[0]
logger.debug(f"No artist found for query: {artist_name}")
return None
except Exception as e:
logger.error(f"Error searching for artist '{artist_name}': {e}")
return None
@rate_limited
def search_album(self, artist_name: str, album_title: str) -> Optional[Dict[str, Any]]:
"""
Search for an album by artist name and album title.
Args:
artist_name: Name of the artist
album_title: Title of the album
Returns:
Album dict from AudioDB or None if not found
"""
try:
response = self.session.get(
f"{self.BASE_URL}/searchalbum.php",
params={'s': artist_name, 'a': album_title},
timeout=10
)
response.raise_for_status()
data = response.json()
albums = data.get('album')
if albums and len(albums) > 0:
logger.debug(f"Found album for query: {artist_name} - {album_title}")
return albums[0]
logger.debug(f"No album found for query: {artist_name} - {album_title}")
return None
except Exception as e:
logger.error(f"Error searching for album '{artist_name} - {album_title}': {e}")
return None
@rate_limited
def search_track(self, artist_name: str, track_title: str) -> Optional[Dict[str, Any]]:
"""
Search for a track by artist name and track title.
Args:
artist_name: Name of the artist
track_title: Title of the track
Returns:
Track dict from AudioDB or None if not found
"""
try:
response = self.session.get(
f"{self.BASE_URL}/searchtrack.php",
params={'s': artist_name, 't': track_title},
timeout=10
)
response.raise_for_status()
data = response.json()
tracks = data.get('track')
if tracks and len(tracks) > 0:
logger.debug(f"Found track for query: {artist_name} - {track_title}")
return tracks[0]
logger.debug(f"No track found for query: {artist_name} - {track_title}")
return None
except Exception as e:
logger.error(f"Error searching for track '{artist_name} - {track_title}': {e}")
return None

@ -0,0 +1,570 @@
import json
import re
import threading
import time
from difflib import SequenceMatcher
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.audiodb_client import AudioDBClient
logger = get_logger("audiodb_worker")
class AudioDBWorker:
"""Background worker for enriching library artists, albums, and tracks with AudioDB metadata"""
def __init__(self, database: MusicDatabase):
self.db = database
self.client = AudioDBClient()
# Worker state
self.running = False
self.paused = False
self.should_stop = False
self.thread = None
# Current item being processed (for UI tooltip)
self.current_item = None
# Statistics
self.stats = {
'matched': 0,
'not_found': 0,
'pending': 0,
'errors': 0
}
# Retry configuration
self.retry_days = 30
# Name matching threshold
self.name_similarity_threshold = 0.80
logger.info("AudioDB background worker initialized")
def start(self):
"""Start the background worker"""
if self.running:
logger.warning("Worker already running")
return
self.running = True
self.should_stop = False
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
logger.info("AudioDB background worker started")
def stop(self):
"""Stop the background worker"""
if not self.running:
return
logger.info("Stopping AudioDB worker...")
self.should_stop = True
self.running = False
if self.thread:
self.thread.join(timeout=5)
logger.info("AudioDB worker stopped")
def pause(self):
"""Pause the worker"""
if not self.running:
logger.warning("Worker not running, cannot pause")
return
self.paused = True
logger.info("AudioDB worker paused")
def resume(self):
"""Resume the worker"""
if not self.running:
logger.warning("Worker not running, start it first")
return
self.paused = False
logger.info("AudioDB worker resumed")
def get_stats(self) -> Dict[str, Any]:
"""Get current statistics"""
self.stats['pending'] = self._count_pending_items()
progress = self._get_progress_breakdown()
is_actually_running = self.running and (self.thread is not None and self.thread.is_alive())
return {
'enabled': True,
'running': is_actually_running and not self.paused,
'paused': self.paused,
'current_item': self.current_item,
'stats': self.stats.copy(),
'progress': progress
}
def _run(self):
"""Main worker loop"""
logger.info("AudioDB worker thread started")
while not self.should_stop:
try:
if self.paused:
time.sleep(1)
continue
self.current_item = None
item = self._get_next_item()
if not item:
logger.debug("No pending items, sleeping...")
time.sleep(10)
continue
self.current_item = item
self._process_item(item)
time.sleep(2)
except Exception as e:
logger.error(f"Error in worker loop: {e}")
time.sleep(5)
logger.info("AudioDB worker thread finished")
def _get_next_item(self) -> Optional[Dict[str, Any]]:
"""Get next item to process from priority queue (artists → albums → tracks)"""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name
FROM artists
WHERE audiodb_match_status IS NULL
ORDER BY id ASC
LIMIT 1
""")
row = cursor.fetchone()
if row:
return {'type': 'artist', 'id': row[0], 'name': row[1]}
# Priority 2: Unattempted albums
cursor.execute("""
SELECT a.id, a.title, ar.name AS artist_name
FROM albums a
JOIN artists ar ON a.artist_id = ar.id
WHERE a.audiodb_match_status IS NULL
ORDER BY a.id ASC
LIMIT 1
""")
row = cursor.fetchone()
if row:
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2]}
# Priority 3: Unattempted tracks
cursor.execute("""
SELECT t.id, t.title, ar.name AS artist_name
FROM tracks t
JOIN artists ar ON t.artist_id = ar.id
WHERE t.audiodb_match_status IS NULL
ORDER BY t.id ASC
LIMIT 1
""")
row = cursor.fetchone()
if row:
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2]}
# Priority 4: Retry 'not_found' artists after retry_days
cutoff_date = datetime.now() - timedelta(days=self.retry_days)
cursor.execute("""
SELECT id, name
FROM artists
WHERE audiodb_match_status = 'not_found'
AND audiodb_last_attempted < ?
ORDER BY audiodb_last_attempted ASC
LIMIT 1
""", (cutoff_date,))
row = cursor.fetchone()
if row:
logger.info(f"Retrying artist '{row[1]}' (last attempted before {cutoff_date})")
return {'type': 'artist', 'id': row[0], 'name': row[1]}
# Priority 5: Retry 'not_found' albums
cursor.execute("""
SELECT a.id, a.title, ar.name AS artist_name
FROM albums a
JOIN artists ar ON a.artist_id = ar.id
WHERE a.audiodb_match_status = 'not_found'
AND a.audiodb_last_attempted < ?
ORDER BY a.audiodb_last_attempted ASC
LIMIT 1
""", (cutoff_date,))
row = cursor.fetchone()
if row:
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2]}
# Priority 6: Retry 'not_found' tracks
cursor.execute("""
SELECT t.id, t.title, ar.name AS artist_name
FROM tracks t
JOIN artists ar ON t.artist_id = ar.id
WHERE t.audiodb_match_status = 'not_found'
AND t.audiodb_last_attempted < ?
ORDER BY t.audiodb_last_attempted ASC
LIMIT 1
""", (cutoff_date,))
row = cursor.fetchone()
if row:
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2]}
return None
except Exception as e:
logger.error(f"Error getting next item: {e}")
return None
finally:
if conn:
conn.close()
def _normalize_name(self, name: str) -> str:
"""Normalize artist name for comparison"""
name = name.lower().strip()
name = re.sub(r'\s*\(.*?\)\s*', ' ', name)
name = re.sub(r'[^\w\s]', '', name)
name = re.sub(r'\s+', ' ', name).strip()
return name
def _name_matches(self, query_name: str, result_name: str) -> bool:
"""Check if AudioDB result name matches our query with fuzzy matching"""
norm_query = self._normalize_name(query_name)
norm_result = self._normalize_name(result_name)
similarity = SequenceMatcher(None, norm_query, norm_result).ratio()
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
return similarity >= self.name_similarity_threshold
def _process_item(self, item: Dict[str, Any]):
"""Process a single item (artist, album, or track)"""
try:
item_type = item['type']
item_id = item['id']
item_name = item['name']
logger.debug(f"Processing {item_type} #{item_id}: {item_name}")
if item_type == 'artist':
result = self.client.search_artist(item_name)
if result:
result_name = result.get('strArtist', '')
if self._name_matches(item_name, result_name):
self._update_artist(item_id, result)
self.stats['matched'] += 1
logger.info(f"Matched artist '{item_name}' -> AudioDB ID: {result.get('idArtist')}")
else:
self._mark_status('artist', item_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"Name mismatch for artist '{item_name}' (got '{result_name}')")
else:
self._mark_status('artist', item_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"No match for artist '{item_name}'")
elif item_type == 'album':
artist_name = item.get('artist', '')
result = self.client.search_album(artist_name, item_name)
if result:
result_name = result.get('strAlbum', '')
if self._name_matches(item_name, result_name):
self._update_album(item_id, result)
self.stats['matched'] += 1
logger.info(f"Matched album '{item_name}' -> AudioDB ID: {result.get('idAlbum')}")
else:
self._mark_status('album', item_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"Name mismatch for album '{item_name}' (got '{result_name}')")
else:
self._mark_status('album', item_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"No match for album '{item_name}'")
elif item_type == 'track':
artist_name = item.get('artist', '')
result = self.client.search_track(artist_name, item_name)
if result:
result_name = result.get('strTrack', '')
if self._name_matches(item_name, result_name):
self._update_track(item_id, result)
self.stats['matched'] += 1
logger.info(f"Matched track '{item_name}' -> AudioDB ID: {result.get('idTrack')}")
else:
self._mark_status('track', item_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"Name mismatch for track '{item_name}' (got '{result_name}')")
else:
self._mark_status('track', item_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"No match for track '{item_name}'")
except Exception as e:
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
self.stats['errors'] += 1
try:
self._mark_status(item['type'], item['id'], 'error')
except Exception as e2:
logger.error(f"Error updating item status: {e2}")
def _update_artist(self, artist_id: int, data: Dict[str, Any]):
"""Store AudioDB metadata for an artist using generic column names"""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
# Update AudioDB tracking + generic metadata columns
cursor.execute("""
UPDATE artists SET
audiodb_id = ?,
audiodb_match_status = 'matched',
audiodb_last_attempted = CURRENT_TIMESTAMP,
style = ?,
mood = ?,
label = ?,
banner_url = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (
data.get('idArtist'),
data.get('strStyle'),
data.get('strMood'),
data.get('strLabel'),
data.get('strArtistBanner'),
artist_id
))
# Backfill thumb_url if artist has no image
thumb_url = data.get('strArtistThumb')
if thumb_url:
cursor.execute("""
UPDATE artists SET thumb_url = ?
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
""", (thumb_url, artist_id))
# Backfill genres if artist has none
genre = data.get('strGenre')
if genre:
cursor.execute("""
UPDATE artists SET genres = ?
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
""", (json.dumps([genre]), artist_id))
conn.commit()
except Exception as e:
logger.error(f"Error updating artist #{artist_id} with AudioDB data: {e}")
raise
finally:
if conn:
conn.close()
def _update_album(self, album_id: int, data: Dict[str, Any]):
"""Store AudioDB metadata for an album using generic column names"""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
UPDATE albums SET
audiodb_id = ?,
audiodb_match_status = 'matched',
audiodb_last_attempted = CURRENT_TIMESTAMP,
style = ?,
mood = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (
data.get('idAlbum'),
data.get('strStyle'),
data.get('strMood'),
album_id
))
# Backfill thumb_url if album has no image
thumb_url = data.get('strAlbumThumb')
if thumb_url:
cursor.execute("""
UPDATE albums SET thumb_url = ?
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
""", (thumb_url, album_id))
# Backfill genres if album has none
genre = data.get('strGenre')
if genre:
cursor.execute("""
UPDATE albums SET genres = ?
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
""", (json.dumps([genre]), album_id))
conn.commit()
except Exception as e:
logger.error(f"Error updating album #{album_id} with AudioDB data: {e}")
raise
finally:
if conn:
conn.close()
def _update_track(self, track_id: int, data: Dict[str, Any]):
"""Store AudioDB metadata for a track using generic column names"""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
UPDATE tracks SET
audiodb_id = ?,
audiodb_match_status = 'matched',
audiodb_last_attempted = CURRENT_TIMESTAMP,
style = ?,
mood = ?
WHERE id = ?
""", (
data.get('idTrack'),
data.get('strStyle'),
data.get('strMood'),
track_id
))
conn.commit()
except Exception as e:
logger.error(f"Error updating track #{track_id} with AudioDB data: {e}")
raise
finally:
if conn:
conn.close()
def _mark_status(self, entity_type: str, entity_id: int, status: str):
"""Mark an entity (artist, album, or track) with a match status"""
table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}
table = table_map.get(entity_type)
if not table:
logger.error(f"Unknown entity type: {entity_type}")
return
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute(f"""
UPDATE {table} SET
audiodb_match_status = ?,
audiodb_last_attempted = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (status, entity_id))
conn.commit()
except Exception as e:
logger.error(f"Error marking {entity_type} #{entity_id} status: {e}")
finally:
if conn:
conn.close()
def _count_pending_items(self) -> int:
"""Count how many items still need processing across all entity types"""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT
(SELECT COUNT(*) FROM artists WHERE audiodb_match_status IS NULL) +
(SELECT COUNT(*) FROM albums WHERE audiodb_match_status IS NULL) +
(SELECT COUNT(*) FROM tracks WHERE audiodb_match_status IS NULL)
AS pending
""")
row = cursor.fetchone()
return row[0] if row else 0
except Exception as e:
logger.error(f"Error counting pending items: {e}")
return 0
finally:
if conn:
conn.close()
def _get_progress_breakdown(self) -> Dict[str, Dict[str, int]]:
"""Get progress breakdown by entity type"""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
progress = {}
# Artists progress
cursor.execute("""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN audiodb_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed
FROM artists
""")
row = cursor.fetchone()
if row:
total, processed = row[0], row[1] or 0
progress['artists'] = {
'matched': processed,
'total': total,
'percent': int((processed / total * 100) if total > 0 else 0)
}
# Albums progress
cursor.execute("""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN audiodb_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed
FROM albums
""")
row = cursor.fetchone()
if row:
total, processed = row[0], row[1] or 0
progress['albums'] = {
'matched': processed,
'total': total,
'percent': int((processed / total * 100) if total > 0 else 0)
}
# Tracks progress
cursor.execute("""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN audiodb_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed
FROM tracks
""")
row = cursor.fetchone()
if row:
total, processed = row[0], row[1] or 0
progress['tracks'] = {
'matched': processed,
'total': total,
'percent': int((processed / total * 100) if total > 0 else 0)
}
return progress
except Exception as e:
logger.error(f"Error getting progress breakdown: {e}")
return {}
finally:
if conn:
conn.close()

@ -306,6 +306,9 @@ class MusicDatabase:
# Add external ID columns (Spotify/iTunes) to library tables (migration)
self._add_external_id_columns(cursor)
# Add AudioDB columns to artists table (migration)
self._add_audiodb_columns(cursor)
# Bubble snapshots table for persisting UI state across page refreshes
cursor.execute("""
CREATE TABLE IF NOT EXISTS bubble_snapshots (
@ -1067,6 +1070,72 @@ class MusicDatabase:
logger.error(f"Error adding external ID columns: {e}")
# Don't raise - this is a migration, database can still function
def _add_audiodb_columns(self, cursor):
"""Add AudioDB tracking + generic metadata columns for enrichment (artists, albums, tracks)"""
try:
# --- Artists ---
cursor.execute("PRAGMA table_info(artists)")
artists_columns = [column[1] for column in cursor.fetchall()]
if 'audiodb_id' not in artists_columns:
cursor.execute("ALTER TABLE artists ADD COLUMN audiodb_id TEXT")
cursor.execute("ALTER TABLE artists ADD COLUMN audiodb_match_status TEXT")
cursor.execute("ALTER TABLE artists ADD COLUMN audiodb_last_attempted TIMESTAMP")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_audiodb_id ON artists (audiodb_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_audiodb_status ON artists (audiodb_match_status)")
logger.info("Added AudioDB tracking columns to artists table")
if 'style' not in artists_columns:
cursor.execute("ALTER TABLE artists ADD COLUMN style TEXT")
cursor.execute("ALTER TABLE artists ADD COLUMN mood TEXT")
cursor.execute("ALTER TABLE artists ADD COLUMN label TEXT")
cursor.execute("ALTER TABLE artists ADD COLUMN banner_url TEXT")
logger.info("Added generic artist metadata columns (style, mood, label, banner_url)")
# --- Albums ---
cursor.execute("PRAGMA table_info(albums)")
albums_columns = [column[1] for column in cursor.fetchall()]
if 'audiodb_id' not in albums_columns:
cursor.execute("ALTER TABLE albums ADD COLUMN audiodb_id TEXT")
cursor.execute("ALTER TABLE albums ADD COLUMN audiodb_match_status TEXT")
cursor.execute("ALTER TABLE albums ADD COLUMN audiodb_last_attempted TIMESTAMP")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_audiodb_id ON albums (audiodb_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_audiodb_status ON albums (audiodb_match_status)")
logger.info("Added AudioDB tracking columns to albums table")
if 'style' not in albums_columns:
cursor.execute("ALTER TABLE albums ADD COLUMN style TEXT")
cursor.execute("ALTER TABLE albums ADD COLUMN mood TEXT")
logger.info("Added generic album metadata columns (style, mood)")
# --- Tracks ---
cursor.execute("PRAGMA table_info(tracks)")
tracks_columns = [column[1] for column in cursor.fetchall()]
if 'audiodb_id' not in tracks_columns:
cursor.execute("ALTER TABLE tracks ADD COLUMN audiodb_id TEXT")
cursor.execute("ALTER TABLE tracks ADD COLUMN audiodb_match_status TEXT")
cursor.execute("ALTER TABLE tracks ADD COLUMN audiodb_last_attempted TIMESTAMP")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_audiodb_id ON tracks (audiodb_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_audiodb_status ON tracks (audiodb_match_status)")
logger.info("Added AudioDB tracking columns to tracks table")
if 'style' not in tracks_columns:
cursor.execute("ALTER TABLE tracks ADD COLUMN style TEXT")
cursor.execute("ALTER TABLE tracks ADD COLUMN mood TEXT")
logger.info("Added generic track metadata columns (style, mood)")
except Exception as e:
logger.error(f"Error adding AudioDB columns: {e}")
# Don't raise - this is a migration, database can still function
def close(self):
"""Close database connection (no-op since we create connections per operation)"""
# Each operation creates and closes its own connection, so nothing to do here

@ -69,6 +69,7 @@ import yt_dlp
from core.matching_engine import MusicMatchingEngine
from beatport_unified_scraper import BeatportUnifiedScraper
from core.musicbrainz_worker import MusicBrainzWorker
from core.audiodb_worker import AudioDBWorker
# --- Flask App Setup ---
base_dir = os.path.abspath(os.path.dirname(__file__))
@ -25496,6 +25497,77 @@ def musicbrainz_resume():
# ================================================================================================
# ================================================================================================
# AUDIODB ENRICHMENT - ARTIST METADATA & IMAGES
# ================================================================================================
# --- AudioDB Worker Initialization ---
audiodb_worker = None
try:
from database.music_database import MusicDatabase
audiodb_db = MusicDatabase()
audiodb_worker = AudioDBWorker(database=audiodb_db)
audiodb_worker.start()
print("✅ AudioDB enrichment worker initialized and started")
except Exception as e:
print(f"⚠️ AudioDB worker initialization failed: {e}")
audiodb_worker = None
# --- AudioDB API Endpoints ---
@app.route('/api/audiodb/status', methods=['GET'])
def audiodb_status():
"""Get AudioDB enrichment status for UI polling"""
try:
if audiodb_worker is None:
return jsonify({
'enabled': False,
'running': False,
'paused': False,
'current_item': None,
'stats': {'matched': 0, 'not_found': 0, 'pending': 0, 'errors': 0},
'progress': {}
}), 200
status = audiodb_worker.get_stats()
return jsonify(status), 200
except Exception as e:
logger.error(f"Error getting AudioDB status: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/audiodb/pause', methods=['POST'])
def audiodb_pause():
"""Pause AudioDB enrichment worker"""
try:
if audiodb_worker is None:
return jsonify({'error': 'AudioDB worker not initialized'}), 400
audiodb_worker.pause()
logger.info("AudioDB worker paused via UI")
return jsonify({'status': 'paused'}), 200
except Exception as e:
logger.error(f"Error pausing AudioDB worker: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/audiodb/resume', methods=['POST'])
def audiodb_resume():
"""Resume AudioDB enrichment worker"""
try:
if audiodb_worker is None:
return jsonify({'error': 'AudioDB worker not initialized'}), 400
audiodb_worker.resume()
logger.info("AudioDB worker resumed via UI")
return jsonify({'status': 'running'}), 200
except Exception as e:
logger.error(f"Error resuming AudioDB worker: {e}")
return jsonify({'error': str(e)}), 500
# ================================================================================================
# END AUDIODB INTEGRATION
# ================================================================================================
# ================================================================================================
# IMPORT / STAGING SYSTEM
# ================================================================================================

File diff suppressed because one or more lines are too long

@ -202,6 +202,17 @@
transform: translateY(0);
}
/* AudioDB tooltip - reposition for mobile */
.audiodb-tooltip {
left: 0;
right: auto;
transform: translateY(-5px);
}
.audiodb-button:hover+.audiodb-tooltip {
transform: translateY(0);
}
.tooltip-content {
min-width: auto;
width: calc(100vw - 60px);

@ -35255,6 +35255,135 @@ if (document.readyState === 'loading') {
}
}
// ============================================================================
// AUDIODB ENRICHMENT UI
// ============================================================================
/**
* Poll AudioDB status every 2 seconds and update UI
*/
async function updateAudioDBStatus() {
try {
const response = await fetch('/api/audiodb/status');
if (!response.ok) {
console.warn('AudioDB status endpoint unavailable');
return;
}
const data = await response.json();
const button = document.getElementById('audiodb-button');
if (!button) return;
// Update button state classes
button.classList.remove('active', 'paused');
if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
// Update tooltip content
const tooltipStatus = document.getElementById('audiodb-tooltip-status');
const tooltipCurrent = document.getElementById('audiodb-tooltip-current');
const tooltipProgress = document.getElementById('audiodb-tooltip-progress');
if (tooltipStatus) {
if (data.running && !data.paused) {
tooltipStatus.textContent = 'Running';
} else if (data.paused) {
tooltipStatus.textContent = 'Paused';
} else {
tooltipStatus.textContent = 'Idle';
}
}
if (tooltipCurrent) {
if (data.current_item && data.current_item.name) {
const type = data.current_item.type || 'item';
const name = data.current_item.name;
tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`;
} else {
tooltipCurrent.textContent = 'No active matches';
}
}
if (tooltipProgress && data.progress) {
const artists = data.progress.artists || {};
const albums = data.progress.albums || {};
const tracks = data.progress.tracks || {};
const currentType = data.current_item?.type;
let progressText = '';
const artistsComplete = artists.matched >= artists.total;
const albumsComplete = albums.matched >= albums.total;
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
} else if (currentType === 'album' || (artistsComplete && !albumsComplete)) {
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
} else if (currentType === 'track' || (artistsComplete && albumsComplete)) {
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
} else {
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
}
tooltipProgress.textContent = progressText;
}
} catch (error) {
console.error('Error updating AudioDB status:', error);
}
}
/**
* Toggle AudioDB enrichment pause/resume
*/
async function toggleAudioDBEnrichment() {
try {
const button = document.getElementById('audiodb-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/audiodb/pause' : '/api/audiodb/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} AudioDB enrichment`);
}
// Immediately update UI
await updateAudioDBStatus();
console.log(`✅ AudioDB enrichment ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling AudioDB enrichment:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// Initialize AudioDB UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('audiodb-button');
if (button) {
button.addEventListener('click', toggleAudioDBEnrichment);
updateAudioDBStatus();
setInterval(updateAudioDBStatus, 2000);
console.log('✅ AudioDB UI initialized');
}
});
} else {
const button = document.getElementById('audiodb-button');
if (button) {
button.addEventListener('click', toggleAudioDBEnrichment);
updateAudioDBStatus();
setInterval(updateAudioDBStatus, 2000);
console.log('✅ AudioDB UI initialized');
}
}
// ===================================================================
// IMPORT / STAGING SYSTEM
// ===================================================================

@ -22034,6 +22034,230 @@ body {
border-bottom: 8px solid rgba(30, 30, 30, 0.98);
}
/* ============================================================================
AUDIODB ENRICHMENT STATUS BUTTON
============================================================================ */
/* Container for button + tooltip positioning */
.audiodb-button-container {
position: relative;
display: inline-block;
margin-right: 12px;
}
/* AudioDB Button */
.audiodb-button {
position: relative;
width: 44px;
height: 44px;
background: linear-gradient(135deg,
rgba(0, 188, 212, 0.12) 0%,
rgba(0, 150, 170, 0.18) 100%);
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
border: 1.5px solid rgba(0, 188, 212, 0.25);
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 4px 16px rgba(0, 188, 212, 0.2),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.audiodb-button:hover {
background: linear-gradient(135deg,
rgba(0, 188, 212, 0.18) 0%,
rgba(0, 150, 170, 0.25) 100%);
border-color: rgba(0, 188, 212, 0.4);
transform: scale(1.05);
box-shadow:
0 6px 20px rgba(0, 188, 212, 0.3),
0 3px 12px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.audiodb-button:active {
transform: scale(0.95);
box-shadow:
0 2px 8px rgba(0, 188, 212, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
/* AudioDB Logo */
.audiodb-logo {
width: 24px;
height: 24px;
object-fit: contain;
opacity: 0.85;
transition: opacity 0.3s ease;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
.audiodb-button:hover .audiodb-logo {
opacity: 1;
}
/* Loading Spinner - Animated Ring */
.audiodb-spinner {
position: absolute;
width: 44px;
height: 44px;
border: 3px solid transparent;
border-top-color: rgba(0, 188, 212, 0.8);
border-right-color: rgba(0, 188, 212, 0.5);
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
/* Active State - Spinner visible and rotating */
.audiodb-button.active .audiodb-spinner {
opacity: 1;
animation: audiodb-spin 1.2s linear infinite;
}
.audiodb-button.active {
background: linear-gradient(135deg,
rgba(0, 188, 212, 0.22) 0%,
rgba(0, 150, 170, 0.28) 100%);
border-color: rgba(0, 188, 212, 0.5);
box-shadow:
0 6px 24px rgba(0, 188, 212, 0.4),
0 3px 12px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
.audiodb-button.active .audiodb-logo {
opacity: 1;
filter: drop-shadow(0 0 8px rgba(0, 188, 212, 0.6));
}
/* Paused State */
.audiodb-button.paused {
background: linear-gradient(135deg,
rgba(255, 193, 7, 0.12) 0%,
rgba(255, 152, 0, 0.18) 100%);
border-color: rgba(255, 193, 7, 0.35);
box-shadow:
0 4px 16px rgba(255, 193, 7, 0.2),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.audiodb-button.paused:hover {
border-color: rgba(255, 193, 7, 0.5);
box-shadow:
0 6px 20px rgba(255, 193, 7, 0.3),
0 3px 12px rgba(0, 0, 0, 0.2);
}
/* Spinner Rotation Animation */
@keyframes audiodb-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* ============================================================================
AUDIODB HOVER TOOLTIP
============================================================================ */
.audiodb-tooltip {
position: absolute;
left: 50%;
top: calc(100% + 12px);
transform: translateX(-50%) translateY(-5px);
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.audiodb-button:hover+.audiodb-tooltip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.audiodb-tooltip-content {
min-width: 260px;
background: linear-gradient(135deg,
rgba(30, 30, 30, 0.98) 0%,
rgba(20, 20, 20, 0.99) 100%);
backdrop-filter: blur(40px) saturate(1.6);
-webkit-backdrop-filter: blur(40px) saturate(1.6);
border: 1px solid rgba(0, 188, 212, 0.3);
border-radius: 16px;
padding: 16px 18px;
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.5),
0 6px 20px rgba(0, 188, 212, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.audiodb-tooltip-header {
font-family: 'SF Pro Display', -apple-system, sans-serif;
font-size: 13px;
font-weight: 600;
color: rgba(0, 188, 212, 0.95);
letter-spacing: -0.2px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.audiodb-tooltip-body {
display: flex;
flex-direction: column;
gap: 8px;
}
#audiodb-tooltip-status {
color: #1ed760;
font-weight: 600;
}
.audiodb-button.paused+.audiodb-tooltip #audiodb-tooltip-status {
color: #ffc107;
}
/* AudioDB Tooltip Arrow */
.audiodb-tooltip-content::before {
content: '';
position: absolute;
left: 50%;
top: -8px;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 8px solid rgba(0, 188, 212, 0.3);
}
.audiodb-tooltip-content::after {
content: '';
position: absolute;
left: 50%;
top: -7px;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 8px solid rgba(30, 30, 30, 0.98);
}
/* MusicBrainz Integration */
.release-card {
position: relative !important;

Loading…
Cancel
Save