Tidal & Qobuz Background Enrichment Workers
This commit is contained in:
parent
589d5bed79
commit
ac2c710a1e
9 changed files with 3123 additions and 46 deletions
|
|
@ -34,6 +34,40 @@ logger = get_logger("qobuz_client")
|
|||
|
||||
QOBUZ_API_BASE = "https://www.qobuz.com/api.json/0.2/"
|
||||
|
||||
# ── Module-level rate limiting (shared across ALL QobuzClient instances) ──
|
||||
_qobuz_api_lock = threading.Lock()
|
||||
_qobuz_last_api_call = 0.0
|
||||
_QOBUZ_MIN_INTERVAL = 1.0 # 1 request/sec (60/min, matches streamrip default)
|
||||
|
||||
# Global rate limit ban state (like Spotify's pattern)
|
||||
_qobuz_rate_limit_until = 0.0
|
||||
_qobuz_rate_limit_lock = threading.Lock()
|
||||
|
||||
|
||||
def _qobuz_throttle():
|
||||
"""Enforce minimum interval between Qobuz API calls across all instances."""
|
||||
global _qobuz_last_api_call
|
||||
with _qobuz_api_lock:
|
||||
now = time.time()
|
||||
elapsed = now - _qobuz_last_api_call
|
||||
if elapsed < _QOBUZ_MIN_INTERVAL:
|
||||
time.sleep(_QOBUZ_MIN_INTERVAL - elapsed)
|
||||
_qobuz_last_api_call = time.time()
|
||||
|
||||
|
||||
def _qobuz_set_rate_limit(retry_after: float = 60.0):
|
||||
"""Set a global rate limit ban for all Qobuz instances."""
|
||||
global _qobuz_rate_limit_until
|
||||
with _qobuz_rate_limit_lock:
|
||||
_qobuz_rate_limit_until = time.time() + retry_after
|
||||
logger.warning(f"Qobuz global rate limit set for {retry_after}s")
|
||||
|
||||
|
||||
def _qobuz_is_rate_limited() -> bool:
|
||||
"""Check if Qobuz is currently rate limited."""
|
||||
with _qobuz_rate_limit_lock:
|
||||
return time.time() < _qobuz_rate_limit_until
|
||||
|
||||
# Quality tier definitions (format_id values)
|
||||
QOBUZ_QUALITY_MAP = {
|
||||
'mp3': {
|
||||
|
|
@ -450,6 +484,12 @@ class QobuzClient:
|
|||
logger.warning("Qobuz not authenticated")
|
||||
return None
|
||||
|
||||
if _qobuz_is_rate_limited():
|
||||
logger.debug(f"Qobuz rate limited, skipping {endpoint}")
|
||||
return None
|
||||
|
||||
_qobuz_throttle()
|
||||
|
||||
try:
|
||||
resp = self.session.get(
|
||||
QOBUZ_API_BASE + endpoint,
|
||||
|
|
@ -461,6 +501,10 @@ class QobuzClient:
|
|||
logger.warning("Qobuz auth token expired")
|
||||
self.user_auth_token = None
|
||||
return None
|
||||
elif resp.status_code == 429:
|
||||
retry_after = float(resp.headers.get('Retry-After', 60))
|
||||
_qobuz_set_rate_limit(retry_after)
|
||||
return None
|
||||
elif resp.status_code != 200:
|
||||
logger.warning(f"Qobuz API error: {endpoint} returned HTTP {resp.status_code}")
|
||||
return None
|
||||
|
|
@ -471,6 +515,93 @@ class QobuzClient:
|
|||
logger.error(f"Qobuz API request failed ({endpoint}): {e}")
|
||||
return None
|
||||
|
||||
# ── Enrichment API Methods ──
|
||||
|
||||
def search_artist(self, name: str):
|
||||
"""Search for an artist by name. Returns first result as raw dict or None."""
|
||||
try:
|
||||
data = self._api_request('artist/search', {
|
||||
'query': name,
|
||||
'limit': 1,
|
||||
})
|
||||
if data and 'artists' in data:
|
||||
items = data['artists'].get('items', [])
|
||||
if items:
|
||||
return items[0]
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching Qobuz artist: {e}")
|
||||
return None
|
||||
|
||||
def search_album(self, artist: str, title: str):
|
||||
"""Search for an album by artist + title. Returns first result as raw dict or None."""
|
||||
try:
|
||||
query = f"{artist} {title}" if artist else title
|
||||
data = self._api_request('album/search', {
|
||||
'query': query,
|
||||
'limit': 1,
|
||||
})
|
||||
if data and 'albums' in data:
|
||||
items = data['albums'].get('items', [])
|
||||
if items:
|
||||
return items[0]
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching Qobuz album: {e}")
|
||||
return None
|
||||
|
||||
def search_track(self, artist: str, title: str):
|
||||
"""Search for a track by artist + title. Returns first result as raw dict or None."""
|
||||
try:
|
||||
query = f"{artist} {title}" if artist else title
|
||||
data = self._api_request('track/search', {
|
||||
'query': query,
|
||||
'limit': 1,
|
||||
})
|
||||
if data and 'tracks' in data:
|
||||
items = data['tracks'].get('items', [])
|
||||
if items:
|
||||
return items[0]
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching Qobuz track: {e}")
|
||||
return None
|
||||
|
||||
def get_artist(self, artist_id):
|
||||
"""Get full artist details by Qobuz ID."""
|
||||
try:
|
||||
data = self._api_request('artist/get', {
|
||||
'artist_id': artist_id,
|
||||
'extra': 'albums',
|
||||
})
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Qobuz artist {artist_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_album(self, album_id):
|
||||
"""Get full album details by Qobuz ID."""
|
||||
try:
|
||||
data = self._api_request('album/get', {
|
||||
'album_id': album_id,
|
||||
'extra': 'tracks',
|
||||
})
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Qobuz album {album_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_track(self, track_id):
|
||||
"""Get full track details by Qobuz ID."""
|
||||
try:
|
||||
data = self._api_request('track/get', {
|
||||
'track_id': track_id,
|
||||
})
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Qobuz track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||||
"""
|
||||
Search Qobuz for tracks (async, Soulseek-compatible interface).
|
||||
|
|
@ -603,6 +734,12 @@ class QobuzClient:
|
|||
logger.error("No app_secret available for stream URL signing")
|
||||
return None
|
||||
|
||||
if _qobuz_is_rate_limited():
|
||||
logger.debug("Qobuz rate limited, skipping stream URL request")
|
||||
return None
|
||||
|
||||
_qobuz_throttle()
|
||||
|
||||
ts = str(int(time.time()))
|
||||
sig_raw = f"trackgetFileUrlformat_id{format_id}intentstreamtrack_id{track_id}{ts}{self.app_secret}"
|
||||
sig = hashlib.md5(sig_raw.encode()).hexdigest()
|
||||
|
|
@ -623,6 +760,10 @@ class QobuzClient:
|
|||
if resp.status_code == 401:
|
||||
logger.warning("Qobuz stream URL auth failed — token may be expired")
|
||||
return None
|
||||
elif resp.status_code == 429:
|
||||
retry_after = float(resp.headers.get('Retry-After', 60))
|
||||
_qobuz_set_rate_limit(retry_after)
|
||||
return None
|
||||
elif resp.status_code == 400:
|
||||
data = resp.json() if resp.text else {}
|
||||
logger.warning(f"Qobuz stream URL rejected: {data.get('message', 'unknown error')}")
|
||||
|
|
|
|||
811
core/qobuz_worker.py
Normal file
811
core/qobuz_worker.py
Normal file
|
|
@ -0,0 +1,811 @@
|
|||
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.qobuz_client import _qobuz_is_rate_limited
|
||||
|
||||
logger = get_logger("qobuz_worker")
|
||||
|
||||
|
||||
class QobuzWorker:
|
||||
"""Background worker for enriching library artists, albums, and tracks with Qobuz metadata"""
|
||||
|
||||
def __init__(self, database: MusicDatabase, client=None):
|
||||
self.db = database
|
||||
self.client = client # Set externally or created during init in web_server
|
||||
|
||||
# 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
|
||||
self.error_retry_days = 7
|
||||
|
||||
# Name matching threshold
|
||||
self.name_similarity_threshold = 0.80
|
||||
|
||||
logger.info("Qobuz 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("Qobuz background worker started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the background worker"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
logger.info("Stopping Qobuz worker...")
|
||||
self.should_stop = True
|
||||
self.running = False
|
||||
|
||||
if self.thread:
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
logger.info("Qobuz 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("Qobuz 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("Qobuz 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())
|
||||
is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None
|
||||
|
||||
authenticated = False
|
||||
try:
|
||||
if self.client:
|
||||
authenticated = self.client.is_authenticated()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'enabled': True,
|
||||
'running': is_actually_running and not self.paused,
|
||||
'paused': self.paused,
|
||||
'idle': is_idle,
|
||||
'authenticated': authenticated,
|
||||
'current_item': self.current_item,
|
||||
'stats': self.stats.copy(),
|
||||
'progress': progress
|
||||
}
|
||||
|
||||
def _run(self):
|
||||
"""Main worker loop"""
|
||||
logger.info("Qobuz worker thread started")
|
||||
|
||||
while not self.should_stop:
|
||||
try:
|
||||
if self.paused:
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# Auth guard: sleep if not authenticated
|
||||
try:
|
||||
if not self.client or not self.client.is_authenticated():
|
||||
self.current_item = None
|
||||
time.sleep(30)
|
||||
continue
|
||||
except Exception:
|
||||
time.sleep(30)
|
||||
continue
|
||||
|
||||
# Rate limit guard: back off if globally rate limited
|
||||
if _qobuz_is_rate_limited():
|
||||
self.current_item = None
|
||||
logger.debug("Qobuz rate limited, backing off...")
|
||||
time.sleep(10)
|
||||
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)
|
||||
|
||||
# Throttle between API calls
|
||||
time.sleep(2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in worker loop: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
logger.info("Qobuz 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 qobuz_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, ar.qobuz_id AS artist_qobuz_id
|
||||
FROM albums a
|
||||
JOIN artists ar ON a.artist_id = ar.id
|
||||
WHERE a.qobuz_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], 'artist_qobuz_id': row[3]}
|
||||
|
||||
# Priority 3: Unattempted tracks
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name, ar.qobuz_id AS artist_qobuz_id
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE t.qobuz_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], 'artist_qobuz_id': row[3]}
|
||||
|
||||
# Priority 4: Retry 'not_found' or 'error' artists
|
||||
not_found_cutoff = datetime.now() - timedelta(days=self.retry_days)
|
||||
error_cutoff = datetime.now() - timedelta(days=self.error_retry_days)
|
||||
cursor.execute("""
|
||||
SELECT id, name
|
||||
FROM artists
|
||||
WHERE (qobuz_match_status = 'not_found' AND qobuz_last_attempted < ?)
|
||||
OR (qobuz_match_status = 'error' AND qobuz_last_attempted < ?)
|
||||
ORDER BY qobuz_last_attempted ASC
|
||||
LIMIT 1
|
||||
""", (not_found_cutoff, error_cutoff))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
logger.info(f"Retrying artist '{row[1]}' (last attempted before cutoff)")
|
||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
||||
|
||||
# Priority 5: Retry 'not_found' or 'error' albums
|
||||
cursor.execute("""
|
||||
SELECT a.id, a.title, ar.name AS artist_name, ar.qobuz_id AS artist_qobuz_id
|
||||
FROM albums a
|
||||
JOIN artists ar ON a.artist_id = ar.id
|
||||
WHERE (a.qobuz_match_status = 'not_found' AND a.qobuz_last_attempted < ?)
|
||||
OR (a.qobuz_match_status = 'error' AND a.qobuz_last_attempted < ?)
|
||||
ORDER BY a.qobuz_last_attempted ASC
|
||||
LIMIT 1
|
||||
""", (not_found_cutoff, error_cutoff))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_qobuz_id': row[3]}
|
||||
|
||||
# Priority 6: Retry 'not_found' or 'error' tracks
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name, ar.qobuz_id AS artist_qobuz_id
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE (t.qobuz_match_status = 'not_found' AND t.qobuz_last_attempted < ?)
|
||||
OR (t.qobuz_match_status = 'error' AND t.qobuz_last_attempted < ?)
|
||||
ORDER BY t.qobuz_last_attempted ASC
|
||||
LIMIT 1
|
||||
""", (not_found_cutoff, error_cutoff))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_qobuz_id': row[3]}
|
||||
|
||||
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 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 Qobuz 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 _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool:
|
||||
"""Verify/correct parent artist's Qobuz ID based on album/track match"""
|
||||
parent_qobuz_id = item.get('artist_qobuz_id')
|
||||
if not parent_qobuz_id or not result_artist_id:
|
||||
return True
|
||||
|
||||
if str(result_artist_id) != str(parent_qobuz_id):
|
||||
logger.info(
|
||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||
f"updating parent artist Qobuz ID from {parent_qobuz_id} to {result_artist_id}"
|
||||
)
|
||||
self._correct_artist_qobuz_id(item, str(result_artist_id))
|
||||
|
||||
return True
|
||||
|
||||
def _correct_artist_qobuz_id(self, item: Dict[str, Any], correct_qobuz_id: str):
|
||||
"""Correct the parent artist's qobuz_id based on a more specific album/track match"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
table = 'albums' if item['type'] == 'album' else 'tracks'
|
||||
cursor.execute(f"SELECT artist_id FROM {table} WHERE id = ?", (item['id'],))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return
|
||||
|
||||
artist_id = row[0]
|
||||
cursor.execute("""
|
||||
UPDATE artists SET
|
||||
qobuz_id = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (correct_qobuz_id, artist_id))
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"Corrected artist #{artist_id} Qobuz ID to {correct_qobuz_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error correcting artist Qobuz ID: {e}")
|
||||
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':
|
||||
self._process_artist(item_id, item_name)
|
||||
elif item_type == 'album':
|
||||
self._process_album(item_id, item_name, item.get('artist', ''), item)
|
||||
elif item_type == 'track':
|
||||
self._process_track(item_id, item_name, item.get('artist', ''), item)
|
||||
|
||||
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 _process_artist(self, artist_id: int, artist_name: str):
|
||||
"""Process an artist: search Qobuz, verify, store metadata"""
|
||||
result = self.client.search_artist(artist_name)
|
||||
|
||||
if result:
|
||||
result_name = result.get('name', '')
|
||||
if self._name_matches(artist_name, result_name):
|
||||
qobuz_artist_id = result.get('id')
|
||||
if not qobuz_artist_id:
|
||||
self._mark_status('artist', artist_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Qobuz search result for '{artist_name}' has no ID")
|
||||
return
|
||||
|
||||
# Fetch full artist details
|
||||
full_artist = None
|
||||
try:
|
||||
full_artist = self.client.get_artist(qobuz_artist_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full artist details for '{artist_name}': {e}")
|
||||
|
||||
self._update_artist(artist_id, result, full_artist)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' -> Qobuz ID: {qobuz_artist_id}")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')")
|
||||
else:
|
||||
if _qobuz_is_rate_limited():
|
||||
logger.warning(f"Rate limited while searching artist '{artist_name}', will retry")
|
||||
return
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for artist '{artist_name}'")
|
||||
|
||||
def _process_album(self, album_id: int, album_name: str, artist_name: str, item: Dict[str, Any]):
|
||||
"""Process an album: search Qobuz, verify, fetch full details, store metadata"""
|
||||
result = self.client.search_album(artist_name, album_name)
|
||||
|
||||
if result:
|
||||
result_name = result.get('title', '')
|
||||
if self._name_matches(album_name, result_name):
|
||||
# Verify artist ID
|
||||
result_artist = result.get('artist', {})
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
|
||||
# Fetch full album details
|
||||
qobuz_album_id = result.get('id')
|
||||
if not qobuz_album_id:
|
||||
self._mark_status('album', album_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Qobuz search result for album '{album_name}' has no ID")
|
||||
return
|
||||
|
||||
full_album = None
|
||||
try:
|
||||
full_album = self.client.get_album(qobuz_album_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full album details for '{album_name}': {e}")
|
||||
|
||||
if full_album is None:
|
||||
if _qobuz_is_rate_limited():
|
||||
logger.warning(f"Rate limited while fetching album '{album_name}', will retry")
|
||||
return
|
||||
self._mark_status('album', album_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Album '{album_name}' matched but full details unavailable, will retry")
|
||||
return
|
||||
|
||||
self._update_album(album_id, result, full_album)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched album '{album_name}' -> Qobuz ID: {qobuz_album_id}")
|
||||
else:
|
||||
self._mark_status('album', album_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for album '{album_name}' (got '{result_name}')")
|
||||
else:
|
||||
if _qobuz_is_rate_limited():
|
||||
logger.warning(f"Rate limited while searching album '{album_name}', will retry")
|
||||
return
|
||||
self._mark_status('album', album_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for album '{album_name}'")
|
||||
|
||||
def _process_track(self, track_id: int, track_name: str, artist_name: str, item: Dict[str, Any]):
|
||||
"""Process a track: search Qobuz, verify, fetch full details, store metadata"""
|
||||
result = self.client.search_track(artist_name, track_name)
|
||||
|
||||
if result:
|
||||
result_name = result.get('title', '')
|
||||
if self._name_matches(track_name, result_name):
|
||||
# Verify artist ID
|
||||
result_artist = result.get('artist', result.get('performer', {}))
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
|
||||
# Fetch full track details
|
||||
qobuz_track_id = result.get('id')
|
||||
if not qobuz_track_id:
|
||||
self._mark_status('track', track_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Qobuz search result for track '{track_name}' has no ID")
|
||||
return
|
||||
|
||||
full_track = None
|
||||
try:
|
||||
full_track = self.client.get_track(qobuz_track_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full track details for '{track_name}': {e}")
|
||||
|
||||
if full_track is None:
|
||||
if _qobuz_is_rate_limited():
|
||||
logger.warning(f"Rate limited while fetching track '{track_name}', will retry")
|
||||
return
|
||||
self._mark_status('track', track_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Track '{track_name}' matched but full details unavailable, will retry")
|
||||
return
|
||||
|
||||
self._update_track(track_id, result, full_track)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched track '{track_name}' -> Qobuz ID: {qobuz_track_id}")
|
||||
else:
|
||||
self._mark_status('track', track_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for track '{track_name}' (got '{result_name}')")
|
||||
else:
|
||||
if _qobuz_is_rate_limited():
|
||||
logger.warning(f"Rate limited while searching track '{track_name}', will retry")
|
||||
return
|
||||
self._mark_status('track', track_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for track '{track_name}'")
|
||||
|
||||
def _update_artist(self, artist_id: int, data: Dict[str, Any], full_data: Optional[Dict[str, Any]] = None):
|
||||
"""Store Qobuz metadata for an artist"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE artists SET
|
||||
qobuz_id = ?,
|
||||
qobuz_match_status = 'matched',
|
||||
qobuz_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (
|
||||
str(data.get('id')),
|
||||
artist_id
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
# Backfill optional metadata (failures here won't lose the match)
|
||||
try:
|
||||
src = full_data or data
|
||||
thumb_url = None
|
||||
image = src.get('image', {})
|
||||
if isinstance(image, dict):
|
||||
thumb_url = image.get('large', image.get('medium', image.get('small', image.get('thumbnail', ''))))
|
||||
elif isinstance(image, str):
|
||||
thumb_url = image
|
||||
# Also check picture field
|
||||
if not thumb_url:
|
||||
thumb_url = src.get('picture', '')
|
||||
|
||||
if thumb_url:
|
||||
cursor.execute("""
|
||||
UPDATE artists SET thumb_url = ?
|
||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
||||
""", (thumb_url, artist_id))
|
||||
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Backfill failed for artist #{artist_id} (match preserved): {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating artist #{artist_id} with Qobuz data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _update_album(self, album_id: int, search_data: Dict[str, Any], full_data: Optional[Dict[str, Any]]):
|
||||
"""Store Qobuz metadata for an album"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
data = full_data or search_data
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE albums SET
|
||||
qobuz_id = ?,
|
||||
qobuz_match_status = 'matched',
|
||||
qobuz_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (
|
||||
str(search_data.get('id')),
|
||||
album_id
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
# Backfill optional metadata (failures here won't lose the match)
|
||||
try:
|
||||
label = data.get('label', {})
|
||||
label_name = label.get('name', '') if isinstance(label, dict) else str(label) if label else ''
|
||||
if label_name:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET label = ?
|
||||
WHERE id = ? AND (label IS NULL OR label = '')
|
||||
""", (label_name, album_id))
|
||||
|
||||
parental = data.get('parental_warning')
|
||||
if parental is not None:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET explicit = ?
|
||||
WHERE id = ? AND explicit IS NULL
|
||||
""", (1 if parental else 0, album_id))
|
||||
|
||||
genre = data.get('genre', {})
|
||||
genre_name = genre.get('name', '') if isinstance(genre, dict) else str(genre) if genre else ''
|
||||
if genre_name:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET genres = ?
|
||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
||||
""", (json.dumps([genre_name]), album_id))
|
||||
|
||||
upc = data.get('upc')
|
||||
if upc:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET upc = ?
|
||||
WHERE id = ? AND (upc IS NULL OR upc = '')
|
||||
""", (str(upc), album_id))
|
||||
|
||||
tracks_count = data.get('tracks_count')
|
||||
if tracks_count and isinstance(tracks_count, int) and tracks_count > 0:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET track_count = ?
|
||||
WHERE id = ? AND track_count IS NULL
|
||||
""", (tracks_count, album_id))
|
||||
|
||||
duration = data.get('duration')
|
||||
if duration and isinstance(duration, (int, float)) and duration > 0:
|
||||
duration_ms = int(duration * 1000)
|
||||
cursor.execute("""
|
||||
UPDATE albums SET duration = ?
|
||||
WHERE id = ? AND duration IS NULL
|
||||
""", (duration_ms, album_id))
|
||||
|
||||
copyright_text = data.get('copyright')
|
||||
if copyright_text:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET copyright = ?
|
||||
WHERE id = ? AND (copyright IS NULL OR copyright = '')
|
||||
""", (copyright_text, album_id))
|
||||
|
||||
thumb_url = None
|
||||
image = data.get('image', {})
|
||||
if isinstance(image, dict):
|
||||
thumb_url = image.get('large', image.get('medium', image.get('small', image.get('thumbnail', ''))))
|
||||
elif isinstance(image, str):
|
||||
thumb_url = image
|
||||
if thumb_url:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET thumb_url = ?
|
||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
||||
""", (thumb_url, album_id))
|
||||
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Backfill failed for album #{album_id} (match preserved): {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating album #{album_id} with Qobuz data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _update_track(self, track_id: int, search_data: Dict[str, Any], full_data: Optional[Dict[str, Any]]):
|
||||
"""Store Qobuz metadata for a track"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
data = full_data or search_data
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET
|
||||
qobuz_id = ?,
|
||||
qobuz_match_status = 'matched',
|
||||
qobuz_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (
|
||||
str(search_data.get('id')),
|
||||
track_id
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
# Backfill optional metadata (failures here won't lose the match)
|
||||
try:
|
||||
parental = data.get('parental_warning')
|
||||
if parental is not None:
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET explicit = ?
|
||||
WHERE id = ? AND explicit IS NULL
|
||||
""", (1 if parental else 0, track_id))
|
||||
|
||||
isrc = data.get('isrc')
|
||||
if isrc:
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET isrc = ?
|
||||
WHERE id = ? AND (isrc IS NULL OR isrc = '')
|
||||
""", (isrc, track_id))
|
||||
|
||||
duration = data.get('duration')
|
||||
if duration and isinstance(duration, (int, float)) and duration > 0:
|
||||
duration_ms = int(duration * 1000)
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET duration = ?
|
||||
WHERE id = ? AND duration IS NULL
|
||||
""", (duration_ms, track_id))
|
||||
|
||||
copyright_text = data.get('copyright')
|
||||
if copyright_text:
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET copyright = ?
|
||||
WHERE id = ? AND (copyright IS NULL OR copyright = '')
|
||||
""", (copyright_text, track_id))
|
||||
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Backfill failed for track #{track_id} (match preserved): {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating track #{track_id} with Qobuz data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _mark_status(self, entity_type: str, entity_id: int, status: str):
|
||||
"""Mark an entity 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
|
||||
qobuz_match_status = ?,
|
||||
qobuz_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"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM artists WHERE qobuz_match_status IS NULL) +
|
||||
(SELECT COUNT(*) FROM albums WHERE qobuz_match_status IS NULL) +
|
||||
(SELECT COUNT(*) FROM tracks WHERE qobuz_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 = {}
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN qobuz_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)
|
||||
}
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN qobuz_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)
|
||||
}
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN qobuz_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()
|
||||
|
|
@ -714,7 +714,234 @@ class TidalClient:
|
|||
except Exception as e:
|
||||
logger.error(f"Error searching Tidal tracks: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ── Enrichment API Methods ──
|
||||
|
||||
@rate_limited
|
||||
def search_artist(self, name: str) -> Optional[Dict]:
|
||||
"""Search for an artist by name. Returns first result as raw dict or None."""
|
||||
try:
|
||||
if not self._ensure_valid_token():
|
||||
return None
|
||||
|
||||
params = {
|
||||
'query': name,
|
||||
'type': 'artists',
|
||||
'limit': 1,
|
||||
'countryCode': 'US'
|
||||
}
|
||||
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/searchresults",
|
||||
params=params,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 429:
|
||||
raise Exception(f"Rate limited (429) on search_artist")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
items = []
|
||||
if 'artists' in data and 'items' in data['artists']:
|
||||
items = data['artists']['items']
|
||||
elif 'artists' in data and isinstance(data['artists'], list):
|
||||
items = data['artists']
|
||||
if items:
|
||||
return items[0]
|
||||
else:
|
||||
logger.debug(f"Tidal artist search failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
if "429" in str(e):
|
||||
raise # Let rate_limited decorator handle retry
|
||||
logger.error(f"Error searching Tidal artist: {e}")
|
||||
return None
|
||||
|
||||
@rate_limited
|
||||
def search_album(self, artist: str, title: str) -> Optional[Dict]:
|
||||
"""Search for an album by artist + title. Returns first result as raw dict or None."""
|
||||
try:
|
||||
if not self._ensure_valid_token():
|
||||
return None
|
||||
|
||||
query = f"{artist} {title}" if artist else title
|
||||
params = {
|
||||
'query': query,
|
||||
'type': 'albums',
|
||||
'limit': 1,
|
||||
'countryCode': 'US'
|
||||
}
|
||||
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/searchresults",
|
||||
params=params,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 429:
|
||||
raise Exception(f"Rate limited (429) on search_album")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
items = []
|
||||
if 'albums' in data and 'items' in data['albums']:
|
||||
items = data['albums']['items']
|
||||
elif 'albums' in data and isinstance(data['albums'], list):
|
||||
items = data['albums']
|
||||
if items:
|
||||
return items[0]
|
||||
else:
|
||||
logger.debug(f"Tidal album search failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
if "429" in str(e):
|
||||
raise # Let rate_limited decorator handle retry
|
||||
logger.error(f"Error searching Tidal album: {e}")
|
||||
return None
|
||||
|
||||
@rate_limited
|
||||
def search_track(self, artist: str, title: str) -> Optional[Dict]:
|
||||
"""Search for a track by artist + title. Returns first result as raw dict or None."""
|
||||
try:
|
||||
if not self._ensure_valid_token():
|
||||
return None
|
||||
|
||||
query = f"{artist} {title}" if artist else title
|
||||
params = {
|
||||
'query': query,
|
||||
'type': 'tracks',
|
||||
'limit': 1,
|
||||
'countryCode': 'US'
|
||||
}
|
||||
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/searchresults",
|
||||
params=params,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 429:
|
||||
raise Exception(f"Rate limited (429) on search_track")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
items = []
|
||||
if 'tracks' in data and 'items' in data['tracks']:
|
||||
items = data['tracks']['items']
|
||||
elif 'tracks' in data and isinstance(data['tracks'], list):
|
||||
items = data['tracks']
|
||||
if items:
|
||||
return items[0]
|
||||
else:
|
||||
logger.debug(f"Tidal track search failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
if "429" in str(e):
|
||||
raise # Let rate_limited decorator handle retry
|
||||
logger.error(f"Error searching Tidal track: {e}")
|
||||
return None
|
||||
|
||||
@rate_limited
|
||||
def get_artist(self, artist_id: str) -> Optional[Dict]:
|
||||
"""Get full artist details by Tidal ID."""
|
||||
try:
|
||||
if not self._ensure_valid_token():
|
||||
return None
|
||||
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/artists/{artist_id}",
|
||||
params={'countryCode': 'US'},
|
||||
headers={'accept': 'application/vnd.api+json'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 429:
|
||||
raise Exception(f"Rate limited (429) on get_artist")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# Handle JSON:API format
|
||||
if 'data' in data and 'attributes' in data.get('data', {}):
|
||||
result = dict(data['data'].get('attributes', {}))
|
||||
result['id'] = data['data'].get('id', artist_id)
|
||||
return result
|
||||
return data
|
||||
else:
|
||||
logger.debug(f"Tidal get_artist failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
if "429" in str(e):
|
||||
raise # Let rate_limited decorator handle retry
|
||||
logger.error(f"Error getting Tidal artist {artist_id}: {e}")
|
||||
return None
|
||||
|
||||
@rate_limited
|
||||
def get_album(self, album_id: str) -> Optional[Dict]:
|
||||
"""Get full album details by Tidal ID."""
|
||||
try:
|
||||
if not self._ensure_valid_token():
|
||||
return None
|
||||
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/albums/{album_id}",
|
||||
params={'countryCode': 'US'},
|
||||
headers={'accept': 'application/vnd.api+json'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 429:
|
||||
raise Exception(f"Rate limited (429) on get_album")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'data' in data and 'attributes' in data.get('data', {}):
|
||||
result = dict(data['data'].get('attributes', {}))
|
||||
result['id'] = data['data'].get('id', album_id)
|
||||
return result
|
||||
return data
|
||||
else:
|
||||
logger.debug(f"Tidal get_album failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
if "429" in str(e):
|
||||
raise # Let rate_limited decorator handle retry
|
||||
logger.error(f"Error getting Tidal album {album_id}: {e}")
|
||||
return None
|
||||
|
||||
@rate_limited
|
||||
def get_track(self, track_id: str) -> Optional[Dict]:
|
||||
"""Get full track details by Tidal ID."""
|
||||
try:
|
||||
if not self._ensure_valid_token():
|
||||
return None
|
||||
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/tracks/{track_id}",
|
||||
params={'countryCode': 'US'},
|
||||
headers={'accept': 'application/vnd.api+json'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 429:
|
||||
raise Exception(f"Rate limited (429) on get_track")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'data' in data and 'attributes' in data.get('data', {}):
|
||||
result = dict(data['data'].get('attributes', {}))
|
||||
result['id'] = data['data'].get('id', track_id)
|
||||
return result
|
||||
return data
|
||||
else:
|
||||
logger.debug(f"Tidal get_track failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
if "429" in str(e):
|
||||
raise # Let rate_limited decorator handle retry
|
||||
logger.error(f"Error getting Tidal track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
@rate_limited
|
||||
def get_playlist(self, playlist_id: str) -> Optional[Playlist]:
|
||||
"""Get playlist details including tracks using JSON:API format"""
|
||||
|
|
|
|||
818
core/tidal_worker.py
Normal file
818
core/tidal_worker.py
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
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.tidal_client import TidalClient
|
||||
|
||||
logger = get_logger("tidal_worker")
|
||||
|
||||
|
||||
class TidalWorker:
|
||||
"""Background worker for enriching library artists, albums, and tracks with Tidal metadata"""
|
||||
|
||||
def __init__(self, database: MusicDatabase, client: TidalClient = None):
|
||||
self.db = database
|
||||
self.client = client or TidalClient()
|
||||
|
||||
# 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
|
||||
self.error_retry_days = 7
|
||||
|
||||
# Name matching threshold
|
||||
self.name_similarity_threshold = 0.80
|
||||
|
||||
logger.info("Tidal 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("Tidal background worker started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the background worker"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
logger.info("Stopping Tidal worker...")
|
||||
self.should_stop = True
|
||||
self.running = False
|
||||
|
||||
if self.thread:
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
logger.info("Tidal 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("Tidal 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("Tidal 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())
|
||||
is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None
|
||||
|
||||
authenticated = False
|
||||
try:
|
||||
authenticated = self.client.is_authenticated()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'enabled': True,
|
||||
'running': is_actually_running and not self.paused,
|
||||
'paused': self.paused,
|
||||
'idle': is_idle,
|
||||
'authenticated': authenticated,
|
||||
'current_item': self.current_item,
|
||||
'stats': self.stats.copy(),
|
||||
'progress': progress
|
||||
}
|
||||
|
||||
def _run(self):
|
||||
"""Main worker loop"""
|
||||
logger.info("Tidal worker thread started")
|
||||
|
||||
while not self.should_stop:
|
||||
try:
|
||||
if self.paused:
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# Auth guard: sleep if not authenticated
|
||||
try:
|
||||
if not self.client.is_authenticated():
|
||||
self.current_item = None
|
||||
time.sleep(30)
|
||||
continue
|
||||
except Exception:
|
||||
time.sleep(30)
|
||||
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("Tidal 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 tidal_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, ar.tidal_id AS artist_tidal_id
|
||||
FROM albums a
|
||||
JOIN artists ar ON a.artist_id = ar.id
|
||||
WHERE a.tidal_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], 'artist_tidal_id': row[3]}
|
||||
|
||||
# Priority 3: Unattempted tracks
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name, ar.tidal_id AS artist_tidal_id
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE t.tidal_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], 'artist_tidal_id': row[3]}
|
||||
|
||||
# Priority 4: Retry 'not_found' or 'error' artists
|
||||
not_found_cutoff = datetime.now() - timedelta(days=self.retry_days)
|
||||
error_cutoff = datetime.now() - timedelta(days=self.error_retry_days)
|
||||
cursor.execute("""
|
||||
SELECT id, name
|
||||
FROM artists
|
||||
WHERE (tidal_match_status = 'not_found' AND tidal_last_attempted < ?)
|
||||
OR (tidal_match_status = 'error' AND tidal_last_attempted < ?)
|
||||
ORDER BY tidal_last_attempted ASC
|
||||
LIMIT 1
|
||||
""", (not_found_cutoff, error_cutoff))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
logger.info(f"Retrying artist '{row[1]}' (last attempted before cutoff)")
|
||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
||||
|
||||
# Priority 5: Retry 'not_found' or 'error' albums
|
||||
cursor.execute("""
|
||||
SELECT a.id, a.title, ar.name AS artist_name, ar.tidal_id AS artist_tidal_id
|
||||
FROM albums a
|
||||
JOIN artists ar ON a.artist_id = ar.id
|
||||
WHERE (a.tidal_match_status = 'not_found' AND a.tidal_last_attempted < ?)
|
||||
OR (a.tidal_match_status = 'error' AND a.tidal_last_attempted < ?)
|
||||
ORDER BY a.tidal_last_attempted ASC
|
||||
LIMIT 1
|
||||
""", (not_found_cutoff, error_cutoff))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_tidal_id': row[3]}
|
||||
|
||||
# Priority 6: Retry 'not_found' or 'error' tracks
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name, ar.tidal_id AS artist_tidal_id
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE (t.tidal_match_status = 'not_found' AND t.tidal_last_attempted < ?)
|
||||
OR (t.tidal_match_status = 'error' AND t.tidal_last_attempted < ?)
|
||||
ORDER BY t.tidal_last_attempted ASC
|
||||
LIMIT 1
|
||||
""", (not_found_cutoff, error_cutoff))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_tidal_id': row[3]}
|
||||
|
||||
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 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 Tidal 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 _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool:
|
||||
"""Verify/correct parent artist's Tidal ID based on album/track match"""
|
||||
parent_tidal_id = item.get('artist_tidal_id')
|
||||
if not parent_tidal_id or not result_artist_id:
|
||||
return True
|
||||
|
||||
if str(result_artist_id) != str(parent_tidal_id):
|
||||
logger.info(
|
||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||
f"updating parent artist Tidal ID from {parent_tidal_id} to {result_artist_id}"
|
||||
)
|
||||
self._correct_artist_tidal_id(item, str(result_artist_id))
|
||||
|
||||
return True
|
||||
|
||||
def _correct_artist_tidal_id(self, item: Dict[str, Any], correct_tidal_id: str):
|
||||
"""Correct the parent artist's tidal_id based on a more specific album/track match"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
table = 'albums' if item['type'] == 'album' else 'tracks'
|
||||
cursor.execute(f"SELECT artist_id FROM {table} WHERE id = ?", (item['id'],))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return
|
||||
|
||||
artist_id = row[0]
|
||||
cursor.execute("""
|
||||
UPDATE artists SET
|
||||
tidal_id = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (correct_tidal_id, artist_id))
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"Corrected artist #{artist_id} Tidal ID to {correct_tidal_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error correcting artist Tidal ID: {e}")
|
||||
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':
|
||||
self._process_artist(item_id, item_name)
|
||||
elif item_type == 'album':
|
||||
self._process_album(item_id, item_name, item.get('artist', ''), item)
|
||||
elif item_type == 'track':
|
||||
self._process_track(item_id, item_name, item.get('artist', ''), item)
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
if '429' in error_str or 'rate limit' in error_str:
|
||||
# Rate limit — don't mark as error, leave for retry on next loop
|
||||
logger.warning(f"Rate limited while processing {item['type']} #{item['id']}, will retry")
|
||||
return
|
||||
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 _process_artist(self, artist_id: int, artist_name: str):
|
||||
"""Process an artist: search Tidal, verify, store metadata"""
|
||||
result = self.client.search_artist(artist_name)
|
||||
if result:
|
||||
result_name = result.get('name', '')
|
||||
if self._name_matches(artist_name, result_name):
|
||||
tidal_artist_id = result.get('id')
|
||||
if not tidal_artist_id:
|
||||
self._mark_status('artist', artist_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Tidal search result for '{artist_name}' has no ID")
|
||||
return
|
||||
|
||||
# Fetch full artist details for image
|
||||
full_artist = None
|
||||
try:
|
||||
full_artist = self.client.get_artist(tidal_artist_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full artist details for '{artist_name}': {e}")
|
||||
|
||||
self._update_artist(artist_id, result, full_artist)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' -> Tidal ID: {tidal_artist_id}")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for artist '{artist_name}'")
|
||||
|
||||
def _process_album(self, album_id: int, album_name: str, artist_name: str, item: Dict[str, Any]):
|
||||
"""Process an album: search Tidal, verify, fetch full details, store metadata"""
|
||||
result = self.client.search_album(artist_name, album_name)
|
||||
if result:
|
||||
result_name = result.get('title', '')
|
||||
if self._name_matches(album_name, result_name):
|
||||
# Verify artist ID
|
||||
result_artist = result.get('artist', {})
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
|
||||
# Fetch full album details
|
||||
tidal_album_id = result.get('id')
|
||||
if not tidal_album_id:
|
||||
self._mark_status('album', album_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Tidal search result for album '{album_name}' has no ID")
|
||||
return
|
||||
|
||||
full_album = None
|
||||
try:
|
||||
full_album = self.client.get_album(tidal_album_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full album details for '{album_name}': {e}")
|
||||
|
||||
if full_album is None:
|
||||
self._mark_status('album', album_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Album '{album_name}' matched but full details unavailable, will retry")
|
||||
return
|
||||
|
||||
self._update_album(album_id, result, full_album)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched album '{album_name}' -> Tidal ID: {tidal_album_id}")
|
||||
else:
|
||||
self._mark_status('album', album_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for album '{album_name}' (got '{result_name}')")
|
||||
else:
|
||||
self._mark_status('album', album_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for album '{album_name}'")
|
||||
|
||||
def _process_track(self, track_id: int, track_name: str, artist_name: str, item: Dict[str, Any]):
|
||||
"""Process a track: search Tidal, verify, fetch full details, store metadata"""
|
||||
result = self.client.search_track(artist_name, track_name)
|
||||
if result:
|
||||
result_name = result.get('title', '')
|
||||
if self._name_matches(track_name, result_name):
|
||||
# Verify artist ID
|
||||
result_artist = result.get('artist', {})
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
|
||||
# Fetch full track details
|
||||
tidal_track_id = result.get('id')
|
||||
if not tidal_track_id:
|
||||
self._mark_status('track', track_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Tidal search result for track '{track_name}' has no ID")
|
||||
return
|
||||
|
||||
full_track = None
|
||||
try:
|
||||
full_track = self.client.get_track(tidal_track_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full track details for '{track_name}': {e}")
|
||||
|
||||
if full_track is None:
|
||||
self._mark_status('track', track_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Track '{track_name}' matched but full details unavailable, will retry")
|
||||
return
|
||||
|
||||
self._update_track(track_id, result, full_track)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched track '{track_name}' -> Tidal ID: {tidal_track_id}")
|
||||
else:
|
||||
self._mark_status('track', track_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for track '{track_name}' (got '{result_name}')")
|
||||
else:
|
||||
self._mark_status('track', track_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for track '{track_name}'")
|
||||
|
||||
def _update_artist(self, artist_id: int, data: Dict[str, Any], full_data: Optional[Dict[str, Any]] = None):
|
||||
"""Store Tidal metadata for an artist"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE artists SET
|
||||
tidal_id = ?,
|
||||
tidal_match_status = 'matched',
|
||||
tidal_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (
|
||||
str(data.get('id')),
|
||||
artist_id
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
# Backfill optional metadata (failures here won't lose the match)
|
||||
try:
|
||||
thumb_url = None
|
||||
if full_data:
|
||||
# V2 detail may have picture array or picture URL
|
||||
pictures = full_data.get('picture', [])
|
||||
if isinstance(pictures, list) and pictures:
|
||||
# Pick largest available
|
||||
for size in ['1080x1080', '750x750', '480x480', '320x320']:
|
||||
for pic in pictures:
|
||||
if isinstance(pic, dict) and size in pic.get('url', ''):
|
||||
thumb_url = pic['url']
|
||||
break
|
||||
if thumb_url:
|
||||
break
|
||||
if not thumb_url and isinstance(pictures[0], dict):
|
||||
thumb_url = pictures[0].get('url')
|
||||
elif not thumb_url and isinstance(pictures[0], str):
|
||||
thumb_url = pictures[0]
|
||||
elif isinstance(pictures, str):
|
||||
thumb_url = pictures
|
||||
# Also check imageLinks (JSON:API attributes are flattened to top level)
|
||||
if not thumb_url:
|
||||
pic_links = full_data.get('imageLinks', [])
|
||||
if pic_links:
|
||||
for pl in pic_links:
|
||||
if isinstance(pl, dict):
|
||||
thumb_url = pl.get('href', '')
|
||||
break
|
||||
|
||||
if not thumb_url:
|
||||
thumb_url = data.get('picture', data.get('image', ''))
|
||||
if isinstance(thumb_url, list) and thumb_url:
|
||||
thumb_url = thumb_url[0].get('url', '') if isinstance(thumb_url[0], dict) else str(thumb_url[0])
|
||||
|
||||
if thumb_url:
|
||||
cursor.execute("""
|
||||
UPDATE artists SET thumb_url = ?
|
||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
||||
""", (thumb_url, artist_id))
|
||||
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Backfill failed for artist #{artist_id} (match preserved): {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating artist #{artist_id} with Tidal data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _update_album(self, album_id: int, search_data: Dict[str, Any], full_data: Optional[Dict[str, Any]]):
|
||||
"""Store Tidal metadata for an album"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
data = full_data or search_data
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE albums SET
|
||||
tidal_id = ?,
|
||||
tidal_match_status = 'matched',
|
||||
tidal_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (
|
||||
str(search_data.get('id')),
|
||||
album_id
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
# Backfill optional metadata (failures here won't lose the match)
|
||||
try:
|
||||
# Backfill label (can be string or dict with 'name' key in JSON:API)
|
||||
label = data.get('label')
|
||||
if isinstance(label, dict):
|
||||
label = label.get('name', '')
|
||||
if label:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET label = ?
|
||||
WHERE id = ? AND (label IS NULL OR label = '')
|
||||
""", (str(label), album_id))
|
||||
|
||||
# Backfill explicit flag
|
||||
explicit = data.get('explicit')
|
||||
if explicit is not None:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET explicit = ?
|
||||
WHERE id = ? AND explicit IS NULL
|
||||
""", (1 if explicit else 0, album_id))
|
||||
|
||||
# Backfill UPC
|
||||
upc = data.get('upc', data.get('barcodeId', ''))
|
||||
if upc:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET upc = ?
|
||||
WHERE id = ? AND (upc IS NULL OR upc = '')
|
||||
""", (str(upc), album_id))
|
||||
|
||||
# Backfill track_count
|
||||
num_tracks = data.get('numberOfTracks', data.get('numberOfItems'))
|
||||
if num_tracks and isinstance(num_tracks, int) and num_tracks > 0:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET track_count = ?
|
||||
WHERE id = ? AND track_count IS NULL
|
||||
""", (num_tracks, album_id))
|
||||
|
||||
# Backfill duration (Tidal returns seconds, DB stores milliseconds)
|
||||
duration = data.get('duration')
|
||||
if duration and isinstance(duration, (int, float)) and duration > 0:
|
||||
duration_ms = int(duration * 1000)
|
||||
cursor.execute("""
|
||||
UPDATE albums SET duration = ?
|
||||
WHERE id = ? AND duration IS NULL
|
||||
""", (duration_ms, album_id))
|
||||
|
||||
# Backfill copyright
|
||||
copyright_text = data.get('copyright')
|
||||
if copyright_text:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET copyright = ?
|
||||
WHERE id = ? AND (copyright IS NULL OR copyright = '')
|
||||
""", (copyright_text, album_id))
|
||||
|
||||
# Backfill thumb_url
|
||||
thumb_url = None
|
||||
cover = data.get('cover', data.get('image', ''))
|
||||
if isinstance(cover, list) and cover:
|
||||
thumb_url = cover[0].get('url', '') if isinstance(cover[0], dict) else str(cover[0])
|
||||
elif isinstance(cover, str) and cover:
|
||||
thumb_url = cover
|
||||
# JSON:API imageLinks (attributes are flattened to top level)
|
||||
if not thumb_url:
|
||||
img_links = data.get('imageLinks', [])
|
||||
if img_links:
|
||||
for il in img_links:
|
||||
if isinstance(il, dict):
|
||||
thumb_url = il.get('href', '')
|
||||
break
|
||||
if thumb_url:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET thumb_url = ?
|
||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
||||
""", (thumb_url, album_id))
|
||||
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Backfill failed for album #{album_id} (match preserved): {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating album #{album_id} with Tidal data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _update_track(self, track_id: int, search_data: Dict[str, Any], full_data: Optional[Dict[str, Any]]):
|
||||
"""Store Tidal metadata for a track"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
data = full_data or search_data
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET
|
||||
tidal_id = ?,
|
||||
tidal_match_status = 'matched',
|
||||
tidal_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (
|
||||
str(search_data.get('id')),
|
||||
track_id
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
# Backfill optional metadata (failures here won't lose the match)
|
||||
try:
|
||||
explicit = data.get('explicit')
|
||||
if explicit is not None:
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET explicit = ?
|
||||
WHERE id = ? AND explicit IS NULL
|
||||
""", (1 if explicit else 0, track_id))
|
||||
|
||||
isrc = data.get('isrc')
|
||||
if isrc:
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET isrc = ?
|
||||
WHERE id = ? AND (isrc IS NULL OR isrc = '')
|
||||
""", (isrc, track_id))
|
||||
|
||||
duration = data.get('duration')
|
||||
if duration and isinstance(duration, (int, float)) and duration > 0:
|
||||
duration_ms = int(duration * 1000)
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET duration = ?
|
||||
WHERE id = ? AND duration IS NULL
|
||||
""", (duration_ms, track_id))
|
||||
|
||||
copyright_text = data.get('copyright')
|
||||
if copyright_text:
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET copyright = ?
|
||||
WHERE id = ? AND (copyright IS NULL OR copyright = '')
|
||||
""", (copyright_text, track_id))
|
||||
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Backfill failed for track #{track_id} (match preserved): {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating track #{track_id} with Tidal data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _mark_status(self, entity_type: str, entity_id: int, status: str):
|
||||
"""Mark an entity 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
|
||||
tidal_match_status = ?,
|
||||
tidal_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"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM artists WHERE tidal_match_status IS NULL) +
|
||||
(SELECT COUNT(*) FROM albums WHERE tidal_match_status IS NULL) +
|
||||
(SELECT COUNT(*) FROM tracks WHERE tidal_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 = {}
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN tidal_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)
|
||||
}
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN tidal_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)
|
||||
}
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN tidal_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()
|
||||
|
|
@ -319,6 +319,9 @@ class MusicDatabase:
|
|||
# Add Last.fm and Genius enrichment columns (migration)
|
||||
self._add_lastfm_genius_columns(cursor)
|
||||
|
||||
# Add Tidal and Qobuz enrichment columns (migration)
|
||||
self._add_tidal_qobuz_enrichment_columns(cursor)
|
||||
|
||||
# Bubble snapshots table for persisting UI state across page refreshes
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS bubble_snapshots (
|
||||
|
|
@ -1571,6 +1574,90 @@ class MusicDatabase:
|
|||
logger.error(f"Error adding Last.fm/Genius enrichment columns: {e}")
|
||||
# Don't raise - this is a migration, database can still function
|
||||
|
||||
def _add_tidal_qobuz_enrichment_columns(self, cursor):
|
||||
"""Add Tidal and Qobuz enrichment tracking columns to artists, albums, tracks"""
|
||||
try:
|
||||
# --- Artists ---
|
||||
cursor.execute("PRAGMA table_info(artists)")
|
||||
artists_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'tidal_id' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN tidal_id TEXT")
|
||||
if 'tidal_match_status' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN tidal_match_status TEXT")
|
||||
if 'tidal_last_attempted' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN tidal_last_attempted TIMESTAMP")
|
||||
if 'qobuz_id' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN qobuz_id TEXT")
|
||||
if 'qobuz_match_status' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN qobuz_match_status TEXT")
|
||||
if 'qobuz_last_attempted' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN qobuz_last_attempted TIMESTAMP")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_tidal_id ON artists (tidal_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_tidal_status ON artists (tidal_match_status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_qobuz_id ON artists (qobuz_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_qobuz_status ON artists (qobuz_match_status)")
|
||||
|
||||
# --- Albums ---
|
||||
cursor.execute("PRAGMA table_info(albums)")
|
||||
albums_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'tidal_id' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN tidal_id TEXT")
|
||||
if 'tidal_match_status' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN tidal_match_status TEXT")
|
||||
if 'tidal_last_attempted' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN tidal_last_attempted TIMESTAMP")
|
||||
if 'qobuz_id' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN qobuz_id TEXT")
|
||||
if 'qobuz_match_status' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN qobuz_match_status TEXT")
|
||||
if 'qobuz_last_attempted' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN qobuz_last_attempted TIMESTAMP")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_tidal_id ON albums (tidal_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_tidal_status ON albums (tidal_match_status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_qobuz_id ON albums (qobuz_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_qobuz_status ON albums (qobuz_match_status)")
|
||||
|
||||
# --- Albums (extra metadata columns) ---
|
||||
if 'upc' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN upc TEXT")
|
||||
if 'copyright' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN copyright TEXT")
|
||||
|
||||
# --- Tracks ---
|
||||
cursor.execute("PRAGMA table_info(tracks)")
|
||||
tracks_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'tidal_id' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN tidal_id TEXT")
|
||||
if 'tidal_match_status' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN tidal_match_status TEXT")
|
||||
if 'tidal_last_attempted' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN tidal_last_attempted TIMESTAMP")
|
||||
if 'qobuz_id' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN qobuz_id TEXT")
|
||||
if 'qobuz_match_status' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN qobuz_match_status TEXT")
|
||||
if 'qobuz_last_attempted' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN qobuz_last_attempted TIMESTAMP")
|
||||
if 'isrc' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN isrc TEXT")
|
||||
if 'copyright' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN copyright TEXT")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_tidal_id ON tracks (tidal_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_tidal_status ON tracks (tidal_match_status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_qobuz_id ON tracks (qobuz_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_qobuz_status ON tracks (qobuz_match_status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_isrc ON tracks (isrc)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding Tidal/Qobuz enrichment columns: {e}")
|
||||
# Don't raise - this is a migration, database can still function
|
||||
|
||||
def _add_retag_tables(self, cursor):
|
||||
"""Add retag tool tables for tracking processed downloads"""
|
||||
try:
|
||||
|
|
|
|||
311
web_server.py
311
web_server.py
|
|
@ -95,6 +95,8 @@ from core.spotify_worker import SpotifyWorker
|
|||
from core.itunes_worker import iTunesWorker
|
||||
from core.lastfm_worker import LastFMWorker
|
||||
from core.genius_worker import GeniusWorker
|
||||
from core.tidal_worker import TidalWorker
|
||||
from core.qobuz_worker import QobuzWorker
|
||||
from core.hydrabase_worker import HydrabaseWorker
|
||||
from core.hydrabase_client import HydrabaseClient
|
||||
from core.automation_engine import AutomationEngine
|
||||
|
|
@ -4038,6 +4040,8 @@ def handle_settings():
|
|||
lastfm_worker._init_client()
|
||||
if genius_worker:
|
||||
genius_worker._init_client()
|
||||
if tidal_enrichment_worker:
|
||||
tidal_enrichment_worker.client = tidal_client
|
||||
print("✅ Service clients re-initialized with new settings.")
|
||||
return jsonify({"success": True, "message": "Settings saved successfully."})
|
||||
except Exception as e:
|
||||
|
|
@ -5626,6 +5630,8 @@ def tidal_callback():
|
|||
if success:
|
||||
# Re-initialize the main global tidal_client instance with the new token
|
||||
tidal_client = TidalClient()
|
||||
if tidal_enrichment_worker:
|
||||
tidal_enrichment_worker.client = tidal_client
|
||||
return "<h1>✅ Tidal Authentication Successful!</h1><p>You can now close this window and return to the SoulSync application.</p>"
|
||||
else:
|
||||
return "<h1>❌ Tidal Authentication Failed</h1><p>Could not exchange authorization code for a token. Please try again.</p>", 400
|
||||
|
|
@ -9641,14 +9647,14 @@ def library_play_track():
|
|||
print(f"❌ Error playing library track: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
_enrichment_locks = {svc: threading.Lock() for svc in ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius')}
|
||||
_enrichment_locks = {svc: threading.Lock() for svc in ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius', 'tidal', 'qobuz')}
|
||||
|
||||
@app.route('/api/library/enrich', methods=['POST'])
|
||||
def library_enrich_entity():
|
||||
"""Trigger enrichment of a specific entity from a single service.
|
||||
Body: { entity_type: 'artist'|'album'|'track', entity_id: str, service: str,
|
||||
name: str, artist_name: str? }
|
||||
service: 'audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius'
|
||||
service: 'audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius', 'tidal', 'qobuz'
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
|
@ -9667,7 +9673,7 @@ def library_enrich_entity():
|
|||
if entity_type not in ('artist', 'album', 'track'):
|
||||
return jsonify({"success": False, "error": "entity_type must be artist, album, or track"}), 400
|
||||
|
||||
valid_services = ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius')
|
||||
valid_services = ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius', 'tidal', 'qobuz')
|
||||
if service not in valid_services:
|
||||
return jsonify({"success": False, "error": f"service must be one of: {', '.join(valid_services)}"}), 400
|
||||
|
||||
|
|
@ -9809,6 +9815,30 @@ def _run_single_enrichment(service, entity_type, entity_id, name, artist_name):
|
|||
genius_worker._process_item(item)
|
||||
return {"success": True, "message": f"Genius lookup complete for {entity_type}"}
|
||||
|
||||
elif service == 'tidal':
|
||||
if not tidal_enrichment_worker:
|
||||
return {"success": False, "error": "Tidal worker not initialized"}
|
||||
item = {'type': entity_type, 'id': entity_id, 'name': name, 'artist': artist_name}
|
||||
if entity_type == 'artist':
|
||||
tidal_enrichment_worker._process_artist(entity_id, name)
|
||||
elif entity_type == 'album':
|
||||
tidal_enrichment_worker._process_album(entity_id, name, artist_name, item)
|
||||
elif entity_type == 'track':
|
||||
tidal_enrichment_worker._process_track(entity_id, name, artist_name, item)
|
||||
return {"success": True, "message": f"Tidal lookup complete for {entity_type}"}
|
||||
|
||||
elif service == 'qobuz':
|
||||
if not qobuz_enrichment_worker:
|
||||
return {"success": False, "error": "Qobuz worker not initialized"}
|
||||
item = {'type': entity_type, 'id': entity_id, 'name': name, 'artist': artist_name}
|
||||
if entity_type == 'artist':
|
||||
qobuz_enrichment_worker._process_artist(entity_id, name)
|
||||
elif entity_type == 'album':
|
||||
qobuz_enrichment_worker._process_album(entity_id, name, artist_name, item)
|
||||
elif entity_type == 'track':
|
||||
qobuz_enrichment_worker._process_track(entity_id, name, artist_name, item)
|
||||
return {"success": True, "message": f"Qobuz lookup complete for {entity_type}"}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown service: {service}"}
|
||||
|
||||
|
|
@ -9967,6 +9997,60 @@ def _search_service(service, entity_type, query):
|
|||
'image': result.get('song_art_image_url'), 'extra': result.get('artist_names', '')}]
|
||||
return []
|
||||
|
||||
elif service == 'tidal':
|
||||
if not tidal_enrichment_worker or not tidal_enrichment_worker.client:
|
||||
raise ValueError("Tidal worker not initialized")
|
||||
client = tidal_enrichment_worker.client
|
||||
if entity_type == 'artist':
|
||||
result = client.search_artist(query)
|
||||
if result:
|
||||
thumb = result.get('picture', '')
|
||||
if isinstance(thumb, list) and thumb:
|
||||
thumb = thumb[0].get('url', '') if isinstance(thumb[0], dict) else str(thumb[0])
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('name', ''),
|
||||
'image': thumb if isinstance(thumb, str) else None, 'extra': ''}]
|
||||
elif entity_type == 'album':
|
||||
result = client.search_album('', query)
|
||||
if result:
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('title', ''),
|
||||
'image': None, 'extra': result.get('artist', {}).get('name', '') if isinstance(result.get('artist'), dict) else ''}]
|
||||
elif entity_type == 'track':
|
||||
result = client.search_track('', query)
|
||||
if result:
|
||||
artist_name = result.get('artist', {}).get('name', '') if isinstance(result.get('artist'), dict) else ''
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('title', ''),
|
||||
'image': None, 'extra': artist_name}]
|
||||
return []
|
||||
|
||||
elif service == 'qobuz':
|
||||
if not qobuz_enrichment_worker or not qobuz_enrichment_worker.client:
|
||||
raise ValueError("Qobuz worker not initialized")
|
||||
client = qobuz_enrichment_worker.client
|
||||
if entity_type == 'artist':
|
||||
result = client.search_artist(query)
|
||||
if result:
|
||||
image = result.get('image', {})
|
||||
thumb = image.get('large', image.get('medium', '')) if isinstance(image, dict) else ''
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('name', ''),
|
||||
'image': thumb, 'extra': ''}]
|
||||
elif entity_type == 'album':
|
||||
result = client.search_album('', query)
|
||||
if result:
|
||||
artist_name = result.get('artist', {}).get('name', '') if isinstance(result.get('artist'), dict) else ''
|
||||
image = result.get('image', {})
|
||||
thumb = image.get('large', image.get('medium', '')) if isinstance(image, dict) else ''
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('title', ''),
|
||||
'image': thumb, 'extra': artist_name}]
|
||||
elif entity_type == 'track':
|
||||
result = client.search_track('', query)
|
||||
if result:
|
||||
artist_name = result.get('performer', {}).get('name', '') if isinstance(result.get('performer'), dict) else ''
|
||||
if not artist_name:
|
||||
artist_name = result.get('artist', {}).get('name', '') if isinstance(result.get('artist'), dict) else ''
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('title', ''),
|
||||
'image': None, 'extra': artist_name}]
|
||||
return []
|
||||
|
||||
elif service == 'audiodb':
|
||||
if not audiodb_worker or not audiodb_worker.client:
|
||||
raise ValueError("AudioDB worker not initialized")
|
||||
|
|
@ -10005,6 +10089,8 @@ _SERVICE_ID_COLUMNS = {
|
|||
'itunes': {'artist': 'itunes_artist_id', 'album': 'itunes_album_id', 'track': 'itunes_track_id'},
|
||||
'lastfm': {'artist': 'lastfm_url', 'album': 'lastfm_url', 'track': 'lastfm_url'},
|
||||
'genius': {'artist': 'genius_id', 'track': 'genius_id'},
|
||||
'tidal': {'artist': 'tidal_id', 'album': 'tidal_id', 'track': 'tidal_id'},
|
||||
'qobuz': {'artist': 'qobuz_id', 'album': 'qobuz_id', 'track': 'qobuz_id'},
|
||||
}
|
||||
|
||||
@app.route('/api/library/manual-match', methods=['PUT'])
|
||||
|
|
@ -22606,47 +22692,46 @@ _playlist_discovery_cancelled = set() # Set of automation_ids that have been ca
|
|||
|
||||
def _pause_enrichment_workers(label='discovery'):
|
||||
"""Pause enrichment workers during discovery to reduce API contention.
|
||||
Returns tuple of (spotify_was_running, itunes_was_running) for resume."""
|
||||
s_was = False
|
||||
i_was = False
|
||||
try:
|
||||
if spotify_enrichment_worker and not spotify_enrichment_worker.paused:
|
||||
spotify_enrichment_worker.pause()
|
||||
s_was = True
|
||||
print(f"⏸️ Paused Spotify enrichment worker during {label}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if itunes_enrichment_worker and not itunes_enrichment_worker.paused:
|
||||
itunes_enrichment_worker.pause()
|
||||
i_was = True
|
||||
print(f"⏸️ Paused iTunes enrichment worker during {label}")
|
||||
except Exception:
|
||||
pass
|
||||
return s_was, i_was
|
||||
Returns dict of {name: was_running} for resume."""
|
||||
was_running = {}
|
||||
workers = {
|
||||
'Spotify': spotify_enrichment_worker,
|
||||
'iTunes': itunes_enrichment_worker,
|
||||
'Tidal': tidal_enrichment_worker if 'tidal_enrichment_worker' in globals() else None,
|
||||
'Qobuz': qobuz_enrichment_worker if 'qobuz_enrichment_worker' in globals() else None,
|
||||
}
|
||||
for name, worker in workers.items():
|
||||
try:
|
||||
if worker and not worker.paused:
|
||||
worker.pause()
|
||||
was_running[name] = True
|
||||
print(f"⏸️ Paused {name} enrichment worker during {label}")
|
||||
except Exception:
|
||||
pass
|
||||
return was_running
|
||||
|
||||
|
||||
def _resume_enrichment_workers(was_running, label='discovery'):
|
||||
"""Resume enrichment workers that were paused by _pause_enrichment_workers."""
|
||||
s_was, i_was = was_running
|
||||
try:
|
||||
if s_was and spotify_enrichment_worker:
|
||||
spotify_enrichment_worker.resume()
|
||||
print(f"▶️ Resumed Spotify enrichment worker after {label}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if i_was and itunes_enrichment_worker:
|
||||
itunes_enrichment_worker.resume()
|
||||
print(f"▶️ Resumed iTunes enrichment worker after {label}")
|
||||
except Exception:
|
||||
pass
|
||||
workers = {
|
||||
'Spotify': spotify_enrichment_worker,
|
||||
'iTunes': itunes_enrichment_worker,
|
||||
'Tidal': tidal_enrichment_worker if 'tidal_enrichment_worker' in globals() else None,
|
||||
'Qobuz': qobuz_enrichment_worker if 'qobuz_enrichment_worker' in globals() else None,
|
||||
}
|
||||
for name, worker in workers.items():
|
||||
try:
|
||||
if was_running.get(name) and worker:
|
||||
worker.resume()
|
||||
print(f"▶️ Resumed {name} enrichment worker after {label}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run_playlist_discovery_worker(playlists, automation_id=None):
|
||||
"""Background worker that discovers Spotify/iTunes metadata for undiscovered
|
||||
mirrored playlist tracks. Stores results in extra_data for use by sync."""
|
||||
_ew_state = (False, False)
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = _pause_enrichment_workers('mirrored playlist discovery')
|
||||
use_spotify = spotify_client and spotify_client.is_spotify_authenticated()
|
||||
|
|
@ -23054,7 +23139,7 @@ def _discovery_score_candidates(source_title, source_artist, source_duration_ms,
|
|||
|
||||
def _run_tidal_discovery_worker(playlist_id):
|
||||
"""Background worker for Tidal discovery process (Spotify preferred, iTunes fallback)"""
|
||||
_ew_state = (False, False)
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = _pause_enrichment_workers('Tidal discovery')
|
||||
state = tidal_discovery_states[playlist_id]
|
||||
|
|
@ -23783,7 +23868,7 @@ def update_youtube_discovery_match():
|
|||
|
||||
def _run_youtube_discovery_worker(url_hash):
|
||||
"""Background worker for YouTube music discovery process (Spotify preferred, iTunes fallback)"""
|
||||
_ew_state = (False, False)
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = _pause_enrichment_workers('YouTube discovery')
|
||||
state = youtube_playlist_states[url_hash]
|
||||
|
|
@ -24094,7 +24179,7 @@ def _run_youtube_discovery_worker(url_hash):
|
|||
|
||||
def _run_listenbrainz_discovery_worker(playlist_mbid):
|
||||
"""Background worker for ListenBrainz music discovery process (Spotify preferred, iTunes fallback)"""
|
||||
_ew_state = (False, False)
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = _pause_enrichment_workers('ListenBrainz discovery')
|
||||
state = listenbrainz_playlist_states[playlist_mbid]
|
||||
|
|
@ -26312,7 +26397,7 @@ def start_watchlist_scan():
|
|||
# Start the scan in a background thread
|
||||
scan_profile_id = get_current_profile_id()
|
||||
def run_scan():
|
||||
_ew_state = (False, False)
|
||||
_ew_state = {}
|
||||
try:
|
||||
global watchlist_scan_state, watchlist_auto_scanning, watchlist_auto_scanning_timestamp
|
||||
from core.watchlist_scanner import WatchlistScanner
|
||||
|
|
@ -27143,7 +27228,7 @@ def _process_watchlist_scan_automatically(automation_id=None):
|
|||
|
||||
print("🤖 [Auto-Watchlist] Timer triggered - starting automatic watchlist scan...")
|
||||
|
||||
_ew_state = (False, False)
|
||||
_ew_state = {}
|
||||
|
||||
try:
|
||||
# CRITICAL FIX: Use smart stuck detection BEFORE acquiring lock
|
||||
|
|
@ -31271,7 +31356,7 @@ def clean_beatport_text(text):
|
|||
|
||||
def _run_beatport_discovery_worker(url_hash):
|
||||
"""Background worker for Beatport discovery process (Spotify preferred, iTunes fallback)"""
|
||||
_ew_state = (False, False)
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = _pause_enrichment_workers('Beatport discovery')
|
||||
state = beatport_chart_states[url_hash]
|
||||
|
|
@ -33142,7 +33227,9 @@ def start_oauth_callback_servers():
|
|||
# Reinitialize the global tidal client with new tokens
|
||||
global tidal_client
|
||||
tidal_client = TidalClient()
|
||||
|
||||
if tidal_enrichment_worker:
|
||||
tidal_enrichment_worker.client = tidal_client
|
||||
|
||||
add_activity_item("✅", "Tidal Auth Complete", "Successfully authenticated with Tidal", "Now")
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'text/html')
|
||||
|
|
@ -33871,6 +33958,144 @@ def genius_enrichment_resume():
|
|||
# END GENIUS ENRICHMENT INTEGRATION
|
||||
# ================================================================================================
|
||||
|
||||
# ================================================================================================
|
||||
# TIDAL ENRICHMENT WORKER
|
||||
# ================================================================================================
|
||||
|
||||
tidal_enrichment_worker = None
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
tidal_enrich_db = MusicDatabase()
|
||||
tidal_enrichment_worker = TidalWorker(database=tidal_enrich_db, client=tidal_client)
|
||||
tidal_enrichment_worker.start()
|
||||
print("✅ Tidal enrichment worker initialized and started")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Tidal worker initialization failed: {e}")
|
||||
tidal_enrichment_worker = None
|
||||
|
||||
# --- Tidal Enrichment API Endpoints ---
|
||||
|
||||
@app.route('/api/tidal-enrichment/status', methods=['GET'])
|
||||
def tidal_enrichment_status():
|
||||
"""Get Tidal enrichment status for UI polling"""
|
||||
try:
|
||||
if tidal_enrichment_worker is None:
|
||||
return jsonify({
|
||||
'enabled': False,
|
||||
'running': False,
|
||||
'paused': False,
|
||||
'authenticated': False,
|
||||
'current_item': None,
|
||||
'stats': {'matched': 0, 'not_found': 0, 'pending': 0, 'errors': 0},
|
||||
'progress': {}
|
||||
}), 200
|
||||
|
||||
status = tidal_enrichment_worker.get_stats()
|
||||
return jsonify(status), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Tidal enrichment status: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/tidal-enrichment/pause', methods=['POST'])
|
||||
def tidal_enrichment_pause():
|
||||
"""Pause Tidal enrichment worker"""
|
||||
try:
|
||||
if tidal_enrichment_worker is None:
|
||||
return jsonify({'error': 'Tidal worker not initialized'}), 400
|
||||
|
||||
tidal_enrichment_worker.pause()
|
||||
logger.info("Tidal worker paused via UI")
|
||||
return jsonify({'status': 'paused'}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error pausing Tidal worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/tidal-enrichment/resume', methods=['POST'])
|
||||
def tidal_enrichment_resume():
|
||||
"""Resume Tidal enrichment worker"""
|
||||
try:
|
||||
if tidal_enrichment_worker is None:
|
||||
return jsonify({'error': 'Tidal worker not initialized'}), 400
|
||||
|
||||
tidal_enrichment_worker.resume()
|
||||
logger.info("Tidal worker resumed via UI")
|
||||
return jsonify({'status': 'running'}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error resuming Tidal worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# ================================================================================================
|
||||
# QOBUZ ENRICHMENT WORKER
|
||||
# ================================================================================================
|
||||
|
||||
qobuz_enrichment_worker = None
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
from core.qobuz_client import QobuzClient
|
||||
qobuz_enrich_db = MusicDatabase()
|
||||
qobuz_enrich_client = QobuzClient() # Separate client instance for thread safety
|
||||
qobuz_enrichment_worker = QobuzWorker(database=qobuz_enrich_db, client=qobuz_enrich_client)
|
||||
qobuz_enrichment_worker.start()
|
||||
print("✅ Qobuz enrichment worker initialized and started")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Qobuz worker initialization failed: {e}")
|
||||
qobuz_enrichment_worker = None
|
||||
|
||||
# --- Qobuz Enrichment API Endpoints ---
|
||||
|
||||
@app.route('/api/qobuz-enrichment/status', methods=['GET'])
|
||||
def qobuz_enrichment_status():
|
||||
"""Get Qobuz enrichment status for UI polling"""
|
||||
try:
|
||||
if qobuz_enrichment_worker is None:
|
||||
return jsonify({
|
||||
'enabled': False,
|
||||
'running': False,
|
||||
'paused': False,
|
||||
'authenticated': False,
|
||||
'current_item': None,
|
||||
'stats': {'matched': 0, 'not_found': 0, 'pending': 0, 'errors': 0},
|
||||
'progress': {}
|
||||
}), 200
|
||||
|
||||
status = qobuz_enrichment_worker.get_stats()
|
||||
return jsonify(status), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Qobuz enrichment status: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/qobuz-enrichment/pause', methods=['POST'])
|
||||
def qobuz_enrichment_pause():
|
||||
"""Pause Qobuz enrichment worker"""
|
||||
try:
|
||||
if qobuz_enrichment_worker is None:
|
||||
return jsonify({'error': 'Qobuz worker not initialized'}), 400
|
||||
|
||||
qobuz_enrichment_worker.pause()
|
||||
logger.info("Qobuz worker paused via UI")
|
||||
return jsonify({'status': 'paused'}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error pausing Qobuz worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/qobuz-enrichment/resume', methods=['POST'])
|
||||
def qobuz_enrichment_resume():
|
||||
"""Resume Qobuz enrichment worker"""
|
||||
try:
|
||||
if qobuz_enrichment_worker is None:
|
||||
return jsonify({'error': 'Qobuz worker not initialized'}), 400
|
||||
|
||||
qobuz_enrichment_worker.resume()
|
||||
logger.info("Qobuz worker resumed via UI")
|
||||
return jsonify({'status': 'running'}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error resuming Qobuz worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# ================================================================================================
|
||||
# END TIDAL/QOBUZ ENRICHMENT INTEGRATION
|
||||
# ================================================================================================
|
||||
|
||||
|
||||
# ================================================================================================
|
||||
# HYDRABASE P2P MIRROR WORKER
|
||||
|
|
@ -35114,6 +35339,8 @@ def _emit_enrichment_status_loop():
|
|||
'itunes-enrichment': lambda: itunes_enrichment_worker,
|
||||
'lastfm-enrichment': lambda: lastfm_worker,
|
||||
'genius-enrichment': lambda: genius_worker,
|
||||
'tidal-enrichment': lambda: tidal_enrichment_worker,
|
||||
'qobuz-enrichment': lambda: qobuz_enrichment_worker,
|
||||
'hydrabase': lambda: hydrabase_worker,
|
||||
'repair': lambda: repair_worker,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -439,6 +439,50 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tidal Enrichment Status Icon -->
|
||||
<div class="tidal-enrich-button-container">
|
||||
<button class="tidal-enrich-button" id="tidal-enrich-button" title="Tidal Library Enrichment">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/7/74/Tidal_%28service%29_logo.svg/2048px-Tidal_%28service%29_logo.svg.png"
|
||||
alt="Tidal" class="tidal-enrich-logo">
|
||||
<div class="tidal-enrich-spinner"></div>
|
||||
</button>
|
||||
<div class="tidal-enrich-tooltip" id="tidal-enrich-tooltip">
|
||||
<div class="tidal-enrich-tooltip-content">
|
||||
<div class="tidal-enrich-tooltip-header">Tidal Enrichment</div>
|
||||
<div class="tidal-enrich-tooltip-body" id="tidal-enrich-tooltip-body">
|
||||
<div class="tooltip-status">Status: <span
|
||||
id="tidal-enrich-tooltip-status">Idle</span>
|
||||
</div>
|
||||
<div class="tooltip-current" id="tidal-enrich-tooltip-current">No active matches
|
||||
</div>
|
||||
<div class="tooltip-progress" id="tidal-enrich-tooltip-progress">Progress: 0 / 0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Qobuz Enrichment Status Icon -->
|
||||
<div class="qobuz-enrich-button-container">
|
||||
<button class="qobuz-enrich-button" id="qobuz-enrich-button" title="Qobuz Library Enrichment">
|
||||
<img src="https://play-lh.googleusercontent.com/TBY_DSvCIF7xIBFCIlxNQf-kJmFjLM1K5p-vGmBLM7XxcREadERFKmyoWz4hHvadpg"
|
||||
alt="Qobuz" class="qobuz-enrich-logo">
|
||||
<div class="qobuz-enrich-spinner"></div>
|
||||
</button>
|
||||
<div class="qobuz-enrich-tooltip" id="qobuz-enrich-tooltip">
|
||||
<div class="qobuz-enrich-tooltip-content">
|
||||
<div class="qobuz-enrich-tooltip-header">Qobuz Enrichment</div>
|
||||
<div class="qobuz-enrich-tooltip-body" id="qobuz-enrich-tooltip-body">
|
||||
<div class="tooltip-status">Status: <span
|
||||
id="qobuz-enrich-tooltip-status">Idle</span>
|
||||
</div>
|
||||
<div class="tooltip-current" id="qobuz-enrich-tooltip-current">No active matches
|
||||
</div>
|
||||
<div class="tooltip-progress" id="qobuz-enrich-tooltip-progress">Progress: 0 / 0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hydrabase P2P Mirror Status Icon -->
|
||||
<div class="hydrabase-button-container" id="hydrabase-button-container" style="display: none;">
|
||||
<button class="hydrabase-button" id="hydrabase-button" title="Hydrabase P2P Mirror">
|
||||
|
|
|
|||
|
|
@ -251,6 +251,8 @@ function initializeWebSocket() {
|
|||
socket.on('enrichment:itunes-enrichment', (data) => updateiTunesEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:lastfm-enrichment', (data) => updateLastFMEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:genius-enrichment', (data) => updateGeniusEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:tidal-enrichment', (data) => updateTidalEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:qobuz-enrichment', (data) => updateQobuzEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:hydrabase', (data) => updateHydrabaseStatusFromData(data));
|
||||
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
|
||||
|
||||
|
|
@ -405,6 +407,8 @@ const SPOTIFY_LOGO_URL = 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/0
|
|||
const ITUNES_LOGO_URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png';
|
||||
const LASTFM_LOGO_URL = 'https://www.last.fm/static/images/lastfm_avatar_twitter.52a5d69a85ac.png';
|
||||
const GENIUS_LOGO_URL = 'https://images.genius.com/8ed669cadd956443e29c70361ec4f372.1000x1000x1.png';
|
||||
const TIDAL_LOGO_URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/74/Tidal_%28service%29_logo.svg/2048px-Tidal_%28service%29_logo.svg.png';
|
||||
const QOBUZ_LOGO_URL = 'https://play-lh.googleusercontent.com/TBY_DSvCIF7xIBFCIlxNQf-kJmFjLM1K5p-vGmBLM7XxcREadERFKmyoWz4hHvadpg';
|
||||
function getAudioDBLogoURL() { const el = document.querySelector('img.audiodb-logo'); return el ? el.src : null; }
|
||||
|
||||
// --- Wishlist Modal Persistence State Management ---
|
||||
|
|
@ -25325,7 +25329,7 @@ function closeYouTubeDiscoveryModal(urlHash) {
|
|||
|
||||
if (isTidal) {
|
||||
// Tidal: Extract playlist ID and reset Tidal state
|
||||
const tidalPlaylistId = state.beatport_chart_hash ? state.beatport_chart_hash.replace('tidal_', '') : null;
|
||||
const tidalPlaylistId = state.tidal_playlist_id || null;
|
||||
if (tidalPlaylistId && tidalPlaylistStates[tidalPlaylistId]) {
|
||||
// Preserve discovery data but reset phase
|
||||
const preservedData = {
|
||||
|
|
@ -25348,7 +25352,7 @@ function closeYouTubeDiscoveryModal(urlHash) {
|
|||
|
||||
// Update backend state
|
||||
try {
|
||||
fetch(`/api/tidal/update-phase/${tidalPlaylistId}`, {
|
||||
fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phase: 'discovered' })
|
||||
|
|
@ -25381,7 +25385,7 @@ function closeYouTubeDiscoveryModal(urlHash) {
|
|||
|
||||
// Update backend state
|
||||
try {
|
||||
fetch(`/api/youtube/update-phase/${urlHash}`, {
|
||||
fetch(`/api/youtube/update_phase/${urlHash}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phase: 'discovered' })
|
||||
|
|
@ -32582,6 +32586,12 @@ function createLibraryArtistCard(artist) {
|
|||
if (artist.genius_url) {
|
||||
badgeSources.push({ cls: 'genius-card-icon', logo: GENIUS_LOGO_URL, fallback: 'GEN', title: 'View on Genius', url: artist.genius_url });
|
||||
}
|
||||
if (artist.tidal_id) {
|
||||
badgeSources.push({ cls: 'tidal-card-icon', logo: TIDAL_LOGO_URL, fallback: 'TD', title: 'View on Tidal', url: `https://tidal.com/browse/artist/${artist.tidal_id}` });
|
||||
}
|
||||
if (artist.qobuz_id) {
|
||||
badgeSources.push({ cls: 'qobuz-card-icon', logo: QOBUZ_LOGO_URL, fallback: 'Qz', title: 'View on Qobuz', url: `https://www.qobuz.com/artist/${artist.qobuz_id}` });
|
||||
}
|
||||
// Add watchlist indicator as the last badge
|
||||
const hasExternalId = artist.itunes_artist_id || artist.spotify_artist_id;
|
||||
if (artist.is_watched) {
|
||||
|
|
@ -33076,6 +33086,8 @@ function updateArtistDetailPageHeaderWithData(artist) {
|
|||
{ id: artist.audiodb_id, url: `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${adbSlug}`, logo: getAudioDBLogoURL(), label: 'TheAudioDB' },
|
||||
{ id: artist.lastfm_url, url: artist.lastfm_url, logo: LASTFM_LOGO_URL, label: 'Last.fm' },
|
||||
{ id: artist.genius_url, url: artist.genius_url, logo: GENIUS_LOGO_URL, label: 'Genius' },
|
||||
{ id: artist.tidal_id, url: `https://tidal.com/browse/artist/${artist.tidal_id}`, logo: TIDAL_LOGO_URL, label: 'Tidal' },
|
||||
{ id: artist.qobuz_id, url: `https://www.qobuz.com/artist/${artist.qobuz_id}`, logo: QOBUZ_LOGO_URL, label: 'Qobuz' },
|
||||
];
|
||||
|
||||
sources.forEach(source => {
|
||||
|
|
@ -34230,6 +34242,8 @@ function renderArtistMetaPanel(artist) {
|
|||
{ key: 'itunes_artist_id', label: 'iTunes', svc: 'itunes' },
|
||||
{ key: 'lastfm_url', label: 'Last.fm', svc: 'lastfm' },
|
||||
{ key: 'genius_url', label: 'Genius', svc: 'genius' },
|
||||
{ key: 'tidal_id', label: 'Tidal', svc: 'tidal' },
|
||||
{ key: 'qobuz_id', label: 'Qobuz', svc: 'qobuz' },
|
||||
];
|
||||
idSources.forEach(src => {
|
||||
if (artist[src.key]) {
|
||||
|
|
@ -34286,6 +34300,8 @@ function renderArtistMetaPanel(artist) {
|
|||
{ id: 'itunes', label: 'iTunes', icon: '🔴' },
|
||||
{ id: 'lastfm', label: 'Last.fm', icon: '⚪' },
|
||||
{ id: 'genius', label: 'Genius', icon: '🟡' },
|
||||
{ id: 'tidal', label: 'Tidal', icon: '⬛' },
|
||||
{ id: 'qobuz', label: 'Qobuz', icon: '🔷' },
|
||||
];
|
||||
services.forEach(svc => {
|
||||
const item = document.createElement('div');
|
||||
|
|
@ -34316,6 +34332,8 @@ function renderArtistMetaPanel(artist) {
|
|||
{ key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' },
|
||||
{ key: 'lastfm_match_status', label: 'Last.fm', attempted: 'lastfm_last_attempted', svc: 'lastfm' },
|
||||
{ key: 'genius_match_status', label: 'Genius', attempted: 'genius_last_attempted', svc: 'genius' },
|
||||
{ key: 'tidal_match_status', label: 'Tidal', attempted: 'tidal_last_attempted', svc: 'tidal' },
|
||||
{ key: 'qobuz_match_status', label: 'Qobuz', attempted: 'qobuz_last_attempted', svc: 'qobuz' },
|
||||
];
|
||||
statusServices.forEach(s => {
|
||||
const status = artist[s.key];
|
||||
|
|
@ -46164,6 +46182,256 @@ if (document.readyState === 'loading') {
|
|||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// TIDAL ENRICHMENT WORKER
|
||||
// ===================================================================
|
||||
|
||||
async function updateTidalEnrichmentStatus() {
|
||||
if (socketConnected) return;
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const response = await fetch('/api/tidal-enrichment/status');
|
||||
if (!response.ok) { console.warn('Tidal status endpoint unavailable'); return; }
|
||||
const data = await response.json();
|
||||
updateTidalEnrichmentStatusFromData(data);
|
||||
} catch (error) {
|
||||
console.error('Error updating Tidal status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTidalEnrichmentStatusFromData(data) {
|
||||
const button = document.getElementById('tidal-enrich-button');
|
||||
if (!button) return;
|
||||
|
||||
const notAuthenticated = data.authenticated === false;
|
||||
|
||||
button.classList.remove('active', 'paused', 'complete', 'no-auth');
|
||||
if (data.paused) {
|
||||
button.classList.add('paused');
|
||||
} else if (notAuthenticated) {
|
||||
button.classList.add('no-auth');
|
||||
} else if (data.idle) {
|
||||
button.classList.add('complete');
|
||||
} else if (data.running && !data.paused) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
|
||||
const tooltipStatus = document.getElementById('tidal-enrich-tooltip-status');
|
||||
const tooltipCurrent = document.getElementById('tidal-enrich-tooltip-current');
|
||||
const tooltipProgress = document.getElementById('tidal-enrich-tooltip-progress');
|
||||
|
||||
if (tooltipStatus) {
|
||||
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
|
||||
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
|
||||
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
|
||||
else if (data.running) { tooltipStatus.textContent = 'Running'; }
|
||||
else { tooltipStatus.textContent = 'Idle'; }
|
||||
}
|
||||
|
||||
if (tooltipCurrent) {
|
||||
if (data.paused) {
|
||||
tooltipCurrent.textContent = notAuthenticated ? 'Connect Tidal in Settings to enrich' : 'Click to resume';
|
||||
} else if (notAuthenticated) {
|
||||
tooltipCurrent.textContent = 'Connect Tidal in Settings to enrich';
|
||||
} else if (data.idle) {
|
||||
tooltipCurrent.textContent = 'All items processed';
|
||||
} else if (data.current_item && data.current_item.name) {
|
||||
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.progress && tooltipProgress) {
|
||||
if (notAuthenticated) {
|
||||
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
|
||||
} else {
|
||||
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' || (!albumsComplete && !currentType)) {
|
||||
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
|
||||
} else {
|
||||
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
|
||||
}
|
||||
|
||||
tooltipProgress.textContent = progressText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTidalEnrichment() {
|
||||
try {
|
||||
const button = document.getElementById('tidal-enrich-button');
|
||||
if (!button) return;
|
||||
|
||||
const isRunning = button.classList.contains('active');
|
||||
const endpoint = isRunning ? '/api/tidal-enrichment/pause' : '/api/tidal-enrichment/resume';
|
||||
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Tidal enrichment`);
|
||||
}
|
||||
|
||||
await updateTidalEnrichmentStatus();
|
||||
console.log(`Tidal enrichment ${isRunning ? 'paused' : 'resumed'}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error toggling Tidal enrichment:', error);
|
||||
showToast(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const button = document.getElementById('tidal-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleTidalEnrichment);
|
||||
updateTidalEnrichmentStatus();
|
||||
setInterval(updateTidalEnrichmentStatus, 2000);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const button = document.getElementById('tidal-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleTidalEnrichment);
|
||||
updateTidalEnrichmentStatus();
|
||||
setInterval(updateTidalEnrichmentStatus, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// QOBUZ ENRICHMENT WORKER
|
||||
// ===================================================================
|
||||
|
||||
async function updateQobuzEnrichmentStatus() {
|
||||
if (socketConnected) return;
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const response = await fetch('/api/qobuz-enrichment/status');
|
||||
if (!response.ok) { console.warn('Qobuz status endpoint unavailable'); return; }
|
||||
const data = await response.json();
|
||||
updateQobuzEnrichmentStatusFromData(data);
|
||||
} catch (error) {
|
||||
console.error('Error updating Qobuz status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateQobuzEnrichmentStatusFromData(data) {
|
||||
const button = document.getElementById('qobuz-enrich-button');
|
||||
if (!button) return;
|
||||
|
||||
const notAuthenticated = data.authenticated === false;
|
||||
|
||||
button.classList.remove('active', 'paused', 'complete', 'no-auth');
|
||||
if (data.paused) {
|
||||
button.classList.add('paused');
|
||||
} else if (notAuthenticated) {
|
||||
button.classList.add('no-auth');
|
||||
} else if (data.idle) {
|
||||
button.classList.add('complete');
|
||||
} else if (data.running && !data.paused) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
|
||||
const tooltipStatus = document.getElementById('qobuz-enrich-tooltip-status');
|
||||
const tooltipCurrent = document.getElementById('qobuz-enrich-tooltip-current');
|
||||
const tooltipProgress = document.getElementById('qobuz-enrich-tooltip-progress');
|
||||
|
||||
if (tooltipStatus) {
|
||||
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
|
||||
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
|
||||
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
|
||||
else if (data.running) { tooltipStatus.textContent = 'Running'; }
|
||||
else { tooltipStatus.textContent = 'Idle'; }
|
||||
}
|
||||
|
||||
if (tooltipCurrent) {
|
||||
if (data.paused) {
|
||||
tooltipCurrent.textContent = notAuthenticated ? 'Connect Qobuz in Settings to enrich' : 'Click to resume';
|
||||
} else if (notAuthenticated) {
|
||||
tooltipCurrent.textContent = 'Connect Qobuz in Settings to enrich';
|
||||
} else if (data.idle) {
|
||||
tooltipCurrent.textContent = 'All items processed';
|
||||
} else if (data.current_item && data.current_item.name) {
|
||||
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.progress && tooltipProgress) {
|
||||
if (notAuthenticated) {
|
||||
tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`;
|
||||
} else {
|
||||
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' || (!albumsComplete && !currentType)) {
|
||||
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
|
||||
} else {
|
||||
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
|
||||
}
|
||||
|
||||
tooltipProgress.textContent = progressText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleQobuzEnrichment() {
|
||||
try {
|
||||
const button = document.getElementById('qobuz-enrich-button');
|
||||
if (!button) return;
|
||||
|
||||
const isRunning = button.classList.contains('active');
|
||||
const endpoint = isRunning ? '/api/qobuz-enrichment/pause' : '/api/qobuz-enrichment/resume';
|
||||
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Qobuz enrichment`);
|
||||
}
|
||||
|
||||
await updateQobuzEnrichmentStatus();
|
||||
console.log(`Qobuz enrichment ${isRunning ? 'paused' : 'resumed'}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error toggling Qobuz enrichment:', error);
|
||||
showToast(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const button = document.getElementById('qobuz-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleQobuzEnrichment);
|
||||
updateQobuzEnrichmentStatus();
|
||||
setInterval(updateQobuzEnrichmentStatus, 2000);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const button = document.getElementById('qobuz-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleQobuzEnrichment);
|
||||
updateQobuzEnrichmentStatus();
|
||||
setInterval(updateQobuzEnrichmentStatus, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// HYDRABASE P2P MIRROR WORKER
|
||||
// ===================================================================
|
||||
|
|
|
|||
|
|
@ -28201,6 +28201,458 @@ body {
|
|||
border-bottom: 8px solid rgba(30, 30, 30, 0.98);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
TIDAL ENRICHMENT STATUS BUTTON
|
||||
======================================== */
|
||||
|
||||
.tidal-enrich-button-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.tidal-enrich-button {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.15) 0%, rgba(30, 30, 30, 0.22) 100%);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.20);
|
||||
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, 0, 0, 0.25), 0 2px 8px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tidal-enrich-button:hover {
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.22) 0%, rgba(30, 30, 30, 0.30) 100%);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35), 0 3px 12px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.tidal-enrich-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.tidal-enrich-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)) invert(1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tidal-enrich-button:hover .tidal-enrich-logo {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tidal-enrich-spinner {
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 3px solid transparent;
|
||||
border-top-color: rgba(255, 255, 255, 0.8);
|
||||
border-right-color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tidal-enrich-button.active .tidal-enrich-spinner {
|
||||
opacity: 1;
|
||||
animation: tidal-spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.tidal-enrich-button.active {
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.28) 0%, rgba(30, 30, 30, 0.36) 100%);
|
||||
border-color: rgba(255, 255, 255, 0.45);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), 0 3px 12px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tidal-enrich-button.active .tidal-enrich-logo {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.6)) invert(1);
|
||||
}
|
||||
|
||||
.tidal-enrich-button.complete {
|
||||
background: linear-gradient(135deg, rgba(76, 175, 80, 0.15) 0%, rgba(56, 142, 60, 0.20) 100%);
|
||||
border-color: rgba(76, 175, 80, 0.35);
|
||||
box-shadow: 0 4px 16px rgba(76, 175, 80, 0.2), 0 2px 8px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tidal-enrich-button.complete .tidal-enrich-logo {
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 6px rgba(76, 175, 80, 0.5)) invert(1);
|
||||
}
|
||||
|
||||
.tidal-enrich-button.complete .tidal-enrich-spinner {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tidal-enrich-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);
|
||||
}
|
||||
|
||||
.tidal-enrich-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);
|
||||
}
|
||||
|
||||
@keyframes tidal-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.tidal-enrich-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;
|
||||
}
|
||||
|
||||
.tidal-enrich-button:hover+.tidal-enrich-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.tidal-enrich-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(255, 255, 255, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 6px 20px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tidal-enrich-tooltip-header {
|
||||
font-family: 'SF Pro Display', -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tidal-enrich-tooltip-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#tidal-enrich-tooltip-status {
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tidal-enrich-button.paused+.tidal-enrich-tooltip #tidal-enrich-tooltip-status {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.tidal-enrich-button.no-auth {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(120, 120, 120, 0.08) 0%,
|
||||
rgba(80, 80, 80, 0.12) 100%);
|
||||
border-color: rgba(120, 120, 120, 0.25);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tidal-enrich-button.no-auth .tidal-enrich-logo {
|
||||
filter: grayscale(1) opacity(0.4);
|
||||
}
|
||||
|
||||
.tidal-enrich-button.no-auth .tidal-enrich-spinner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tidal-enrich-button.no-auth:hover {
|
||||
border-color: rgba(120, 120, 120, 0.35);
|
||||
box-shadow:
|
||||
0 6px 20px rgba(0, 0, 0, 0.2),
|
||||
0 3px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tidal-enrich-button.no-auth+.tidal-enrich-tooltip #tidal-enrich-tooltip-status {
|
||||
color: rgba(120, 120, 120, 0.8);
|
||||
}
|
||||
|
||||
.tidal-enrich-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(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.tidal-enrich-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);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
QOBUZ ENRICHMENT STATUS BUTTON
|
||||
======================================== */
|
||||
|
||||
.qobuz-enrich-button-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.qobuz-enrich-button {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, rgba(1, 112, 239, 0.10) 0%, rgba(1, 90, 200, 0.16) 100%);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 1.5px solid rgba(1, 112, 239, 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(1, 112, 239, 0.15), 0 2px 8px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.qobuz-enrich-button:hover {
|
||||
background: linear-gradient(135deg, rgba(1, 112, 239, 0.16) 0%, rgba(1, 90, 200, 0.22) 100%);
|
||||
border-color: rgba(1, 112, 239, 0.4);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(1, 112, 239, 0.25), 0 3px 12px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.qobuz-enrich-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.qobuz-enrich-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));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.qobuz-enrich-button:hover .qobuz-enrich-logo {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.qobuz-enrich-spinner {
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 3px solid transparent;
|
||||
border-top-color: rgba(1, 112, 239, 0.8);
|
||||
border-right-color: rgba(1, 112, 239, 0.5);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.active .qobuz-enrich-spinner {
|
||||
opacity: 1;
|
||||
animation: qobuz-spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.active {
|
||||
background: linear-gradient(135deg, rgba(1, 112, 239, 0.20) 0%, rgba(1, 90, 200, 0.26) 100%);
|
||||
border-color: rgba(1, 112, 239, 0.5);
|
||||
box-shadow: 0 6px 24px rgba(1, 112, 239, 0.3), 0 3px 12px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.active .qobuz-enrich-logo {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 8px rgba(1, 112, 239, 0.6));
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.complete {
|
||||
background: linear-gradient(135deg, rgba(76, 175, 80, 0.15) 0%, rgba(56, 142, 60, 0.20) 100%);
|
||||
border-color: rgba(76, 175, 80, 0.35);
|
||||
box-shadow: 0 4px 16px rgba(76, 175, 80, 0.2), 0 2px 8px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.complete .qobuz-enrich-logo {
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 6px rgba(76, 175, 80, 0.5));
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.complete .qobuz-enrich-spinner {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.qobuz-enrich-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);
|
||||
}
|
||||
|
||||
.qobuz-enrich-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);
|
||||
}
|
||||
|
||||
@keyframes qobuz-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.qobuz-enrich-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;
|
||||
}
|
||||
|
||||
.qobuz-enrich-button:hover+.qobuz-enrich-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.qobuz-enrich-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(1, 112, 239, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 6px 20px rgba(1, 112, 239, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.qobuz-enrich-tooltip-header {
|
||||
font-family: 'SF Pro Display', -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(1, 112, 239, 0.95);
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.qobuz-enrich-tooltip-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#qobuz-enrich-tooltip-status {
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.paused+.qobuz-enrich-tooltip #qobuz-enrich-tooltip-status {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.no-auth {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(120, 120, 120, 0.08) 0%,
|
||||
rgba(80, 80, 80, 0.12) 100%);
|
||||
border-color: rgba(120, 120, 120, 0.25);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.no-auth .qobuz-enrich-logo {
|
||||
filter: grayscale(1) opacity(0.4);
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.no-auth .qobuz-enrich-spinner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.no-auth:hover {
|
||||
border-color: rgba(120, 120, 120, 0.35);
|
||||
box-shadow:
|
||||
0 6px 20px rgba(0, 0, 0, 0.2),
|
||||
0 3px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.qobuz-enrich-button.no-auth+.qobuz-enrich-tooltip #qobuz-enrich-tooltip-status {
|
||||
color: rgba(120, 120, 120, 0.8);
|
||||
}
|
||||
|
||||
.qobuz-enrich-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(1, 112, 239, 0.3);
|
||||
}
|
||||
|
||||
.qobuz-enrich-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);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
HYDRABASE P2P MIRROR WORKER BUTTON
|
||||
======================================== */
|
||||
|
|
@ -31498,6 +31950,8 @@ a.enhanced-id-badge:visited {
|
|||
.enhanced-id-badge.audiodb { background: rgba(50, 120, 220, 0.15); color: #5a9de6; border: 1px solid rgba(50, 120, 220, 0.25); }
|
||||
.enhanced-id-badge.lastfm { background: rgba(185, 0, 0, 0.15); color: #d42f2f; border: 1px solid rgba(185, 0, 0, 0.25); }
|
||||
.enhanced-id-badge.genius { background: rgba(255, 255, 100, 0.12); color: #e8d44d; border: 1px solid rgba(255, 255, 100, 0.2); }
|
||||
.enhanced-id-badge.tidal { background: rgba(0, 0, 0, 0.15); color: #ffffff; border: 1px solid rgba(255, 255, 255, 0.2); }
|
||||
.enhanced-id-badge.qobuz { background: rgba(1, 112, 239, 0.15); color: #4da6ff; border: 1px solid rgba(1, 112, 239, 0.25); }
|
||||
.enhanced-id-badge.server { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.5); border: 1px solid rgba(255,255,255,0.12); }
|
||||
|
||||
.enhanced-meta-edit-toggle {
|
||||
|
|
|
|||
Loading…
Reference in a new issue