Add Last.fm and Genius to on-demand enrichment, settings reload, and enrich dropdown parity
This commit is contained in:
parent
f8d23ec37c
commit
dc7140c459
8 changed files with 2117 additions and 11 deletions
496
core/genius_worker.py
Normal file
496
core/genius_worker.py
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
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.genius_client import GeniusClient
|
||||
from config.settings import config_manager
|
||||
|
||||
logger = get_logger("genius_worker")
|
||||
|
||||
|
||||
class GeniusWorker:
|
||||
"""Background worker for enriching library artists and tracks with Genius metadata.
|
||||
|
||||
Enriches:
|
||||
- Artists: Genius ID, description, alternate names, image
|
||||
- Tracks: Genius ID, lyrics, description, song art URL
|
||||
Note: Genius is song/artist-focused — album enrichment is minimal (ID only from song data).
|
||||
"""
|
||||
|
||||
def __init__(self, database: MusicDatabase):
|
||||
self.db = database
|
||||
self._init_client()
|
||||
|
||||
# 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.75 # Slightly lower — Genius titles often include featured artists
|
||||
|
||||
logger.info("Genius background worker initialized")
|
||||
|
||||
def _init_client(self):
|
||||
"""Initialize or reinitialize the Genius client from config"""
|
||||
access_token = config_manager.get('genius.access_token', '')
|
||||
self.client = GeniusClient(access_token=access_token)
|
||||
|
||||
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("Genius background worker started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the background worker"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
logger.info("Stopping Genius worker...")
|
||||
self.should_stop = True
|
||||
self.running = False
|
||||
|
||||
if self.thread:
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
logger.info("Genius 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("Genius 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("Genius 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
|
||||
|
||||
return {
|
||||
'enabled': True,
|
||||
'running': is_actually_running and not self.paused,
|
||||
'paused': self.paused,
|
||||
'idle': is_idle,
|
||||
'current_item': self.current_item,
|
||||
'stats': self.stats.copy(),
|
||||
'progress': progress
|
||||
}
|
||||
|
||||
def _run(self):
|
||||
"""Main worker loop"""
|
||||
logger.info("Genius worker thread started")
|
||||
|
||||
while not self.should_stop:
|
||||
try:
|
||||
if self.paused:
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# Check if access token is configured
|
||||
if not self.client.access_token:
|
||||
self._init_client()
|
||||
if not self.client.access_token:
|
||||
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)
|
||||
|
||||
# Genius rate limiting is conservative (500ms per call) + lyrics scraping
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in worker loop: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
logger.info("Genius worker thread finished")
|
||||
|
||||
def _get_next_item(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get next item to process from priority queue.
|
||||
Genius is artist+track focused — we skip album-level processing
|
||||
since Genius doesn't have direct album endpoints."""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Priority 1: Unattempted artists
|
||||
cursor.execute("""
|
||||
SELECT id, name
|
||||
FROM artists
|
||||
WHERE genius_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 tracks (skip albums — Genius is song-centric)
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE t.genius_match_status IS NULL
|
||||
ORDER BY t.id ASC
|
||||
LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2]}
|
||||
|
||||
# Priority 3: Retry 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 (genius_match_status = 'not_found' AND genius_last_attempted < ?)
|
||||
OR (genius_match_status = 'error' AND genius_last_attempted < ?)
|
||||
ORDER BY genius_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 4: Retry tracks
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE (t.genius_match_status = 'not_found' AND t.genius_last_attempted < ?)
|
||||
OR (t.genius_match_status = 'error' AND t.genius_last_attempted < ?)
|
||||
ORDER BY t.genius_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]}
|
||||
|
||||
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'\s*\[.*?\]\s*', ' ', name) # Also strip brackets (Genius uses these)
|
||||
name = re.sub(r'\s*feat\.?\s+.*$', '', name) # Strip featuring
|
||||
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 result name matches our query with fuzzy matching"""
|
||||
norm_query = self._normalize_name(query_name)
|
||||
norm_result = self._normalize_name(result_name)
|
||||
similarity = SequenceMatcher(None, norm_query, norm_result).ratio()
|
||||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||
return similarity >= self.name_similarity_threshold
|
||||
|
||||
def _process_item(self, item: Dict[str, Any]):
|
||||
"""Process a single item (artist 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 == 'track':
|
||||
self._process_track(item_id, item_name, item.get('artist', ''))
|
||||
|
||||
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 Genius, get full artist details"""
|
||||
result = self.client.search_artist(artist_name)
|
||||
if result:
|
||||
result_name = result.get('name', '')
|
||||
if self._name_matches(artist_name, result_name):
|
||||
genius_id = result.get('id')
|
||||
# Fetch full artist details
|
||||
full_artist = None
|
||||
if genius_id:
|
||||
try:
|
||||
full_artist = self.client.get_artist(genius_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full artist details for '{artist_name}': {e}")
|
||||
|
||||
if full_artist is None:
|
||||
self._mark_status('artist', artist_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Artist '{artist_name}' matched but full details unavailable, will retry")
|
||||
return
|
||||
|
||||
self._update_artist(artist_id, result, full_artist)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' -> Genius ID: {genius_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_track(self, track_id: int, track_name: str, artist_name: str):
|
||||
"""Process a track: search Genius, get full song details + lyrics"""
|
||||
result = self.client.search_song(artist_name, track_name)
|
||||
if result:
|
||||
result_title = result.get('title', '')
|
||||
if self._name_matches(track_name, result_title):
|
||||
genius_id = result.get('id')
|
||||
# Fetch full song details
|
||||
full_song = None
|
||||
if genius_id:
|
||||
try:
|
||||
full_song = self.client.get_song(genius_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full song details for '{track_name}': {e}")
|
||||
|
||||
if full_song 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
|
||||
|
||||
# Scrape lyrics
|
||||
lyrics = None
|
||||
song_url = result.get('url') or full_song.get('url')
|
||||
if song_url:
|
||||
try:
|
||||
lyrics = self.client.get_lyrics(song_url)
|
||||
except Exception as e:
|
||||
logger.debug(f"Lyrics scraping failed for '{track_name}': {e}")
|
||||
|
||||
self._update_track(track_id, result, full_song, lyrics)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched track '{track_name}' -> Genius ID: {genius_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_title}')")
|
||||
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, search_data: Dict[str, Any], full_data: Dict[str, Any]):
|
||||
"""Store Genius metadata for an artist"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
genius_id = str(full_data.get('id', search_data.get('id', '')))
|
||||
description = self.client.extract_description(full_data.get('description'))
|
||||
image_url = full_data.get('image_url') or search_data.get('image_url')
|
||||
|
||||
# Alternate names
|
||||
alt_names = full_data.get('alternate_names', [])
|
||||
alt_names_json = json.dumps(alt_names) if alt_names else None
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE artists SET
|
||||
genius_id = ?,
|
||||
genius_match_status = 'matched',
|
||||
genius_last_attempted = CURRENT_TIMESTAMP,
|
||||
genius_description = ?,
|
||||
genius_alt_names = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (genius_id, description, alt_names_json, artist_id))
|
||||
|
||||
# Backfill thumb_url
|
||||
if image_url:
|
||||
cursor.execute("""
|
||||
UPDATE artists SET thumb_url = ?
|
||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
||||
""", (image_url, artist_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating artist #{artist_id} with Genius data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _update_track(self, track_id: int, search_data: Dict[str, Any], full_data: Dict[str, Any], lyrics: Optional[str]):
|
||||
"""Store Genius metadata for a track"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
genius_id = str(full_data.get('id', search_data.get('id', '')))
|
||||
description = self.client.extract_description(full_data.get('description'))
|
||||
genius_url = full_data.get('url') or search_data.get('url')
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET
|
||||
genius_id = ?,
|
||||
genius_match_status = 'matched',
|
||||
genius_last_attempted = CURRENT_TIMESTAMP,
|
||||
genius_lyrics = ?,
|
||||
genius_description = ?,
|
||||
genius_url = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (genius_id, lyrics, description, genius_url, track_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating track #{track_id} with Genius 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
|
||||
genius_match_status = ?,
|
||||
genius_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 (artists + tracks only)"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM artists WHERE genius_match_status IS NULL) +
|
||||
(SELECT COUNT(*) FROM tracks WHERE genius_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 = {}
|
||||
|
||||
for entity, table in [('artists', 'artists'), ('tracks', 'tracks')]:
|
||||
cursor.execute(f"""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN genius_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed
|
||||
FROM {table}
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
total, processed = row[0], row[1] or 0
|
||||
progress[entity] = {
|
||||
'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()
|
||||
|
|
@ -54,8 +54,14 @@ class LastFMClient:
|
|||
})
|
||||
logger.info("Last.fm client initialized")
|
||||
|
||||
def _make_request(self, method: str, params: Dict = None, timeout: int = 10) -> Optional[Dict]:
|
||||
"""Make a request to the Last.fm API"""
|
||||
def _make_request(self, method: str, params: Dict = None, timeout: int = 10, raise_on_transient: bool = False) -> Optional[Dict]:
|
||||
"""Make a request to the Last.fm API.
|
||||
|
||||
Args:
|
||||
raise_on_transient: If True, raise exceptions on transient errors (timeouts, HTTP errors)
|
||||
instead of returning None. Used by get_*_info methods so the worker can distinguish
|
||||
'not found' (mark not_found, retry in 30 days) from 'API failed' (mark error, retry in 7 days).
|
||||
"""
|
||||
if not self.api_key:
|
||||
logger.warning("Last.fm API key not configured")
|
||||
return None
|
||||
|
|
@ -85,6 +91,9 @@ class LastFMClient:
|
|||
# Error 6 = "Artist/Album/Track not found" — not a real error
|
||||
if error_code == 6:
|
||||
return None
|
||||
# Transient errors: 11=Service Offline, 16=Temporarily Unavailable, 29=Rate Limit
|
||||
if raise_on_transient and error_code in (11, 16, 29):
|
||||
raise Exception(f"Last.fm transient error ({error_code}): {error_msg}")
|
||||
logger.error(f"Last.fm API error ({error_code}): {error_msg}")
|
||||
return None
|
||||
|
||||
|
|
@ -92,9 +101,13 @@ class LastFMClient:
|
|||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"Last.fm API timeout for method: {method}")
|
||||
if raise_on_transient:
|
||||
raise
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Last.fm API request error ({method}): {e}")
|
||||
if raise_on_transient:
|
||||
raise
|
||||
return None
|
||||
|
||||
# ── Artist Methods ──
|
||||
|
|
@ -134,7 +147,7 @@ class LastFMClient:
|
|||
data = self._make_request('artist.getinfo', {
|
||||
'artist': artist_name,
|
||||
'autocorrect': 1
|
||||
})
|
||||
}, raise_on_transient=True)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
|
|
@ -220,7 +233,7 @@ class LastFMClient:
|
|||
'artist': artist_name,
|
||||
'album': album_title,
|
||||
'autocorrect': 1
|
||||
})
|
||||
}, raise_on_transient=True)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
|
|
@ -270,7 +283,7 @@ class LastFMClient:
|
|||
'artist': artist_name,
|
||||
'track': track_title,
|
||||
'autocorrect': 1
|
||||
})
|
||||
}, raise_on_transient=True)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
|
|
|
|||
607
core/lastfm_worker.py
Normal file
607
core/lastfm_worker.py
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
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.lastfm_client import LastFMClient
|
||||
from config.settings import config_manager
|
||||
|
||||
logger = get_logger("lastfm_worker")
|
||||
|
||||
|
||||
class LastFMWorker:
|
||||
"""Background worker for enriching library artists, albums, and tracks with Last.fm metadata.
|
||||
|
||||
Enriches:
|
||||
- Artists: listeners, playcount, bio/summary, tags, similar artists, images
|
||||
- Albums: listeners, playcount, tags, wiki/summary, images
|
||||
- Tracks: listeners, playcount, tags, duration
|
||||
"""
|
||||
|
||||
def __init__(self, database: MusicDatabase):
|
||||
self.db = database
|
||||
self._init_client()
|
||||
|
||||
# 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("Last.fm background worker initialized")
|
||||
|
||||
def _init_client(self):
|
||||
"""Initialize or reinitialize the Last.fm client from config"""
|
||||
api_key = config_manager.get('lastfm.api_key', '')
|
||||
self.client = LastFMClient(api_key=api_key)
|
||||
|
||||
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("Last.fm background worker started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the background worker"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
logger.info("Stopping Last.fm worker...")
|
||||
self.should_stop = True
|
||||
self.running = False
|
||||
|
||||
if self.thread:
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
logger.info("Last.fm 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("Last.fm 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("Last.fm 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
|
||||
|
||||
return {
|
||||
'enabled': True,
|
||||
'running': is_actually_running and not self.paused,
|
||||
'paused': self.paused,
|
||||
'idle': is_idle,
|
||||
'current_item': self.current_item,
|
||||
'stats': self.stats.copy(),
|
||||
'progress': progress
|
||||
}
|
||||
|
||||
def _run(self):
|
||||
"""Main worker loop"""
|
||||
logger.info("Last.fm worker thread started")
|
||||
|
||||
while not self.should_stop:
|
||||
try:
|
||||
if self.paused:
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
# Check if API key is configured
|
||||
if not self.client.api_key:
|
||||
self._init_client()
|
||||
if not self.client.api_key:
|
||||
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)
|
||||
|
||||
# Last.fm allows 5 req/sec but we use multiple calls per item
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in worker loop: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
logger.info("Last.fm 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 lastfm_match_status IS NULL
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
||||
|
||||
# Priority 2: Unattempted albums
|
||||
cursor.execute("""
|
||||
SELECT a.id, a.title, ar.name AS artist_name
|
||||
FROM albums a
|
||||
JOIN artists ar ON a.artist_id = ar.id
|
||||
WHERE a.lastfm_match_status IS NULL
|
||||
ORDER BY a.id ASC
|
||||
LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2]}
|
||||
|
||||
# Priority 3: Unattempted tracks
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE t.lastfm_match_status IS NULL
|
||||
ORDER BY t.id ASC
|
||||
LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2]}
|
||||
|
||||
# Priority 4: Retry 'not_found' 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 (lastfm_match_status = 'not_found' AND lastfm_last_attempted < ?)
|
||||
OR (lastfm_match_status = 'error' AND lastfm_last_attempted < ?)
|
||||
ORDER BY lastfm_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 albums
|
||||
cursor.execute("""
|
||||
SELECT a.id, a.title, ar.name AS artist_name
|
||||
FROM albums a
|
||||
JOIN artists ar ON a.artist_id = ar.id
|
||||
WHERE (a.lastfm_match_status = 'not_found' AND a.lastfm_last_attempted < ?)
|
||||
OR (a.lastfm_match_status = 'error' AND a.lastfm_last_attempted < ?)
|
||||
ORDER BY a.lastfm_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]}
|
||||
|
||||
# Priority 6: Retry tracks
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE (t.lastfm_match_status = 'not_found' AND t.lastfm_last_attempted < ?)
|
||||
OR (t.lastfm_match_status = 'error' AND t.lastfm_last_attempted < ?)
|
||||
ORDER BY t.lastfm_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]}
|
||||
|
||||
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 result name matches our query with fuzzy matching"""
|
||||
norm_query = self._normalize_name(query_name)
|
||||
norm_result = self._normalize_name(result_name)
|
||||
similarity = SequenceMatcher(None, norm_query, norm_result).ratio()
|
||||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||
return similarity >= self.name_similarity_threshold
|
||||
|
||||
def _process_item(self, item: Dict[str, Any]):
|
||||
"""Process a single item (artist, album, or track)"""
|
||||
try:
|
||||
item_type = item['type']
|
||||
item_id = item['id']
|
||||
item_name = item['name']
|
||||
|
||||
logger.debug(f"Processing {item_type} #{item_id}: {item_name}")
|
||||
|
||||
if item_type == 'artist':
|
||||
self._process_artist(item_id, item_name)
|
||||
elif item_type == 'album':
|
||||
self._process_album(item_id, item_name, item.get('artist', ''))
|
||||
elif item_type == 'track':
|
||||
self._process_track(item_id, item_name, item.get('artist', ''))
|
||||
|
||||
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: get full info from Last.fm"""
|
||||
# Use get_artist_info for detailed data (includes stats, bio, tags, similar)
|
||||
result = self.client.get_artist_info(artist_name)
|
||||
if result:
|
||||
result_name = result.get('name', '')
|
||||
if self._name_matches(artist_name, result_name):
|
||||
self._update_artist(artist_id, result)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' on Last.fm")
|
||||
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):
|
||||
"""Process an album: get full info from Last.fm"""
|
||||
result = self.client.get_album_info(artist_name, album_name)
|
||||
if result:
|
||||
result_name = result.get('name', '')
|
||||
if self._name_matches(album_name, result_name):
|
||||
self._update_album(album_id, result)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched album '{album_name}' on Last.fm")
|
||||
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):
|
||||
"""Process a track: get full info from Last.fm"""
|
||||
result = self.client.get_track_info(artist_name, track_name)
|
||||
if result:
|
||||
result_name = result.get('name', '')
|
||||
if self._name_matches(track_name, result_name):
|
||||
self._update_track(track_id, result)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched track '{track_name}' on Last.fm")
|
||||
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]):
|
||||
"""Store Last.fm metadata for an artist"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Extract stats
|
||||
stats = data.get('stats', {})
|
||||
listeners = int(stats.get('listeners', 0)) if stats.get('listeners') else None
|
||||
playcount = int(stats.get('playcount', 0)) if stats.get('playcount') else None
|
||||
|
||||
# Extract bio summary
|
||||
bio = data.get('bio', {})
|
||||
summary = bio.get('summary', '') if bio else ''
|
||||
# Clean Last.fm's HTML link from summary
|
||||
if summary:
|
||||
summary = re.sub(r'<a href=".*?">.*?</a>\.?', '', summary).strip()
|
||||
|
||||
# Extract tags
|
||||
tags = self.client.extract_tags(data.get('tags'))
|
||||
tags_json = json.dumps(tags) if tags else None
|
||||
|
||||
# Extract similar artists (Last.fm returns a single dict instead of list when only 1 result)
|
||||
similar_data = data.get('similar')
|
||||
similar_raw = similar_data.get('artist', []) if isinstance(similar_data, dict) else []
|
||||
if similar_raw and not isinstance(similar_raw, list):
|
||||
similar_raw = [similar_raw]
|
||||
if similar_raw:
|
||||
similar = [{'name': s.get('name', ''), 'match': s.get('match', '')} for s in similar_raw[:10] if isinstance(s, dict)]
|
||||
similar_json = json.dumps(similar) if similar else None
|
||||
else:
|
||||
similar_json = None
|
||||
|
||||
# Get best image
|
||||
thumb_url = self.client.get_best_image(data.get('image', []))
|
||||
|
||||
# Last.fm URL (serves as unique identifier)
|
||||
lastfm_url = data.get('url')
|
||||
|
||||
# Update core lastfm fields
|
||||
cursor.execute("""
|
||||
UPDATE artists SET
|
||||
lastfm_match_status = 'matched',
|
||||
lastfm_last_attempted = CURRENT_TIMESTAMP,
|
||||
lastfm_listeners = ?,
|
||||
lastfm_playcount = ?,
|
||||
lastfm_tags = ?,
|
||||
lastfm_similar = ?,
|
||||
lastfm_bio = ?,
|
||||
lastfm_url = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (listeners, playcount, tags_json, similar_json, summary or None, lastfm_url, artist_id))
|
||||
|
||||
# Backfill thumb_url if missing
|
||||
if thumb_url:
|
||||
cursor.execute("""
|
||||
UPDATE artists SET thumb_url = ?
|
||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
||||
""", (thumb_url, artist_id))
|
||||
|
||||
# Backfill style from tags if missing
|
||||
if tags:
|
||||
cursor.execute("""
|
||||
UPDATE artists SET style = ?
|
||||
WHERE id = ? AND (style IS NULL OR style = '')
|
||||
""", (', '.join(tags[:5]), artist_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating artist #{artist_id} with Last.fm data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _update_album(self, album_id: int, data: Dict[str, Any]):
|
||||
"""Store Last.fm metadata for an album"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
listeners = int(data.get('listeners', 0)) if data.get('listeners') else None
|
||||
playcount = int(data.get('playcount', 0)) if data.get('playcount') else None
|
||||
|
||||
# Extract tags
|
||||
tags = self.client.extract_tags(data.get('tags'))
|
||||
tags_json = json.dumps(tags) if tags else None
|
||||
|
||||
# Extract wiki summary
|
||||
wiki = data.get('wiki', {})
|
||||
summary = wiki.get('summary', '') if wiki else ''
|
||||
if summary:
|
||||
summary = re.sub(r'<a href=".*?">.*?</a>\.?', '', summary).strip()
|
||||
|
||||
# Get best image
|
||||
thumb_url = self.client.get_best_image(data.get('image', []))
|
||||
|
||||
# Last.fm URL
|
||||
lastfm_url = data.get('url')
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE albums SET
|
||||
lastfm_match_status = 'matched',
|
||||
lastfm_last_attempted = CURRENT_TIMESTAMP,
|
||||
lastfm_listeners = ?,
|
||||
lastfm_playcount = ?,
|
||||
lastfm_tags = ?,
|
||||
lastfm_wiki = ?,
|
||||
lastfm_url = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (listeners, playcount, tags_json, summary or None, lastfm_url, album_id))
|
||||
|
||||
# Backfill thumb_url
|
||||
if thumb_url:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET thumb_url = ?
|
||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
||||
""", (thumb_url, album_id))
|
||||
|
||||
# Backfill genres from tags
|
||||
if tags:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET genres = ?
|
||||
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
|
||||
""", (json.dumps(tags[:10]), album_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating album #{album_id} with Last.fm data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _update_track(self, track_id: int, data: Dict[str, Any]):
|
||||
"""Store Last.fm metadata for a track"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
listeners = int(data.get('listeners', 0)) if data.get('listeners') else None
|
||||
playcount = int(data.get('playcount', 0)) if data.get('playcount') else None
|
||||
|
||||
# Extract tags
|
||||
tags_data = data.get('toptags', {})
|
||||
tags = self.client.extract_tags(tags_data)
|
||||
tags_json = json.dumps(tags) if tags else None
|
||||
|
||||
# Last.fm URL
|
||||
lastfm_url = data.get('url')
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET
|
||||
lastfm_match_status = 'matched',
|
||||
lastfm_last_attempted = CURRENT_TIMESTAMP,
|
||||
lastfm_listeners = ?,
|
||||
lastfm_playcount = ?,
|
||||
lastfm_tags = ?,
|
||||
lastfm_url = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (listeners, playcount, tags_json, lastfm_url, track_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating track #{track_id} with Last.fm 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
|
||||
lastfm_match_status = ?,
|
||||
lastfm_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 lastfm_match_status IS NULL) +
|
||||
(SELECT COUNT(*) FROM albums WHERE lastfm_match_status IS NULL) +
|
||||
(SELECT COUNT(*) FROM tracks WHERE lastfm_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 = {}
|
||||
|
||||
for entity, table in [('artists', 'artists'), ('albums', 'albums'), ('tracks', 'tracks')]:
|
||||
cursor.execute(f"""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN lastfm_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed
|
||||
FROM {table}
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
total, processed = row[0], row[1] or 0
|
||||
progress[entity] = {
|
||||
'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()
|
||||
|
|
@ -316,6 +316,9 @@ class MusicDatabase:
|
|||
# Add Spotify/iTunes enrichment tracking columns (migration)
|
||||
self._add_spotify_itunes_enrichment_columns(cursor)
|
||||
|
||||
# Add Last.fm and Genius enrichment columns (migration)
|
||||
self._add_lastfm_genius_columns(cursor)
|
||||
|
||||
# Bubble snapshots table for persisting UI state across page refreshes
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS bubble_snapshots (
|
||||
|
|
@ -1439,6 +1442,109 @@ class MusicDatabase:
|
|||
logger.error(f"Error adding Spotify/iTunes enrichment columns: {e}")
|
||||
# Don't raise - this is a migration, database can still function
|
||||
|
||||
def _add_lastfm_genius_columns(self, cursor):
|
||||
"""Add Last.fm and Genius enrichment tracking + metadata columns to artists, albums, tracks"""
|
||||
try:
|
||||
# --- Artists ---
|
||||
cursor.execute("PRAGMA table_info(artists)")
|
||||
artists_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
# Last.fm columns
|
||||
if 'lastfm_match_status' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_match_status TEXT")
|
||||
if 'lastfm_last_attempted' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_last_attempted TIMESTAMP")
|
||||
if 'lastfm_listeners' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_listeners INTEGER")
|
||||
if 'lastfm_playcount' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_playcount INTEGER")
|
||||
if 'lastfm_tags' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_tags TEXT")
|
||||
if 'lastfm_similar' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_similar TEXT")
|
||||
if 'lastfm_bio' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_bio TEXT")
|
||||
if 'lastfm_url' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN lastfm_url TEXT")
|
||||
|
||||
# Genius columns
|
||||
if 'genius_id' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN genius_id TEXT")
|
||||
if 'genius_match_status' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN genius_match_status TEXT")
|
||||
if 'genius_last_attempted' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN genius_last_attempted TIMESTAMP")
|
||||
if 'genius_description' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN genius_description TEXT")
|
||||
if 'genius_alt_names' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN genius_alt_names TEXT")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_lastfm_status ON artists (lastfm_match_status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_genius_id ON artists (genius_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_genius_status ON artists (genius_match_status)")
|
||||
|
||||
# --- Albums ---
|
||||
cursor.execute("PRAGMA table_info(albums)")
|
||||
albums_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
# Last.fm columns
|
||||
if 'lastfm_match_status' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_match_status TEXT")
|
||||
if 'lastfm_last_attempted' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_last_attempted TIMESTAMP")
|
||||
if 'lastfm_listeners' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_listeners INTEGER")
|
||||
if 'lastfm_playcount' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_playcount INTEGER")
|
||||
if 'lastfm_tags' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_tags TEXT")
|
||||
if 'lastfm_wiki' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_wiki TEXT")
|
||||
if 'lastfm_url' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN lastfm_url TEXT")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_lastfm_status ON albums (lastfm_match_status)")
|
||||
|
||||
# --- Tracks ---
|
||||
cursor.execute("PRAGMA table_info(tracks)")
|
||||
tracks_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
# Last.fm columns
|
||||
if 'lastfm_match_status' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_match_status TEXT")
|
||||
if 'lastfm_last_attempted' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_last_attempted TIMESTAMP")
|
||||
if 'lastfm_listeners' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_listeners INTEGER")
|
||||
if 'lastfm_playcount' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_playcount INTEGER")
|
||||
if 'lastfm_tags' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_tags TEXT")
|
||||
if 'lastfm_url' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN lastfm_url TEXT")
|
||||
|
||||
# Genius columns
|
||||
if 'genius_id' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN genius_id TEXT")
|
||||
if 'genius_match_status' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN genius_match_status TEXT")
|
||||
if 'genius_last_attempted' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN genius_last_attempted TIMESTAMP")
|
||||
if 'genius_lyrics' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN genius_lyrics TEXT")
|
||||
if 'genius_description' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN genius_description TEXT")
|
||||
if 'genius_url' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN genius_url TEXT")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_lastfm_status ON tracks (lastfm_match_status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_genius_id ON tracks (genius_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_genius_status ON tracks (genius_match_status)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding Last.fm/Genius 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:
|
||||
|
|
|
|||
219
web_server.py
219
web_server.py
|
|
@ -93,6 +93,8 @@ from core.audiodb_worker import AudioDBWorker
|
|||
from core.deezer_worker import DeezerWorker
|
||||
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.hydrabase_worker import HydrabaseWorker
|
||||
from core.hydrabase_client import HydrabaseClient
|
||||
from core.automation_engine import AutomationEngine
|
||||
|
|
@ -3956,6 +3958,11 @@ def handle_settings():
|
|||
soulseek_client.youtube.reload_settings()
|
||||
# FIX: Re-instantiate the global tidal_client to pick up new settings
|
||||
tidal_client = TidalClient()
|
||||
# Reload enrichment worker clients for key-based services
|
||||
if lastfm_worker:
|
||||
lastfm_worker._init_client()
|
||||
if genius_worker:
|
||||
genius_worker._init_client()
|
||||
print("✅ Service clients re-initialized with new settings.")
|
||||
return jsonify({"success": True, "message": "Settings saved successfully."})
|
||||
except Exception as e:
|
||||
|
|
@ -9553,14 +9560,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')}
|
||||
_enrichment_locks = {svc: threading.Lock() for svc in ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius')}
|
||||
|
||||
@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'
|
||||
service: 'audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius'
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
|
@ -9579,7 +9586,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')
|
||||
valid_services = ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius')
|
||||
if service not in valid_services:
|
||||
return jsonify({"success": False, "error": f"service must be one of: {', '.join(valid_services)}"}), 400
|
||||
|
||||
|
|
@ -9701,6 +9708,26 @@ def _run_single_enrichment(service, entity_type, entity_id, name, artist_name):
|
|||
itunes_enrichment_worker._process_track_individual({'type': 'track_individual', 'id': entity_id, 'name': name, 'artist': artist_name})
|
||||
return {"success": True, "message": f"iTunes lookup complete for {entity_type}"}
|
||||
|
||||
elif service == 'lastfm':
|
||||
if not lastfm_worker:
|
||||
return {"success": False, "error": "Last.fm worker not initialized"}
|
||||
item = {'type': entity_type, 'id': entity_id, 'name': name}
|
||||
if entity_type in ('album', 'track'):
|
||||
item['artist'] = artist_name
|
||||
lastfm_worker._process_item(item)
|
||||
return {"success": True, "message": f"Last.fm lookup complete for {entity_type}"}
|
||||
|
||||
elif service == 'genius':
|
||||
if not genius_worker:
|
||||
return {"success": False, "error": "Genius worker not initialized"}
|
||||
item = {'type': entity_type, 'id': entity_id, 'name': name}
|
||||
if entity_type == 'track':
|
||||
item['artist'] = artist_name
|
||||
elif entity_type == 'album':
|
||||
return {"success": False, "error": "Genius does not support album enrichment"}
|
||||
genius_worker._process_item(item)
|
||||
return {"success": True, "message": f"Genius lookup complete for {entity_type}"}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown service: {service}"}
|
||||
|
||||
|
|
@ -9817,6 +9844,48 @@ def _search_service(service, entity_type, query):
|
|||
'extra': f"{artist_name} · {album_name}"})
|
||||
return results
|
||||
|
||||
elif service == 'lastfm':
|
||||
if not lastfm_worker or not lastfm_worker.client:
|
||||
raise ValueError("Last.fm worker not initialized")
|
||||
client = lastfm_worker.client
|
||||
if entity_type == 'artist':
|
||||
result = client.search_artist(query)
|
||||
if result:
|
||||
image = client.get_best_image(result.get('image', []))
|
||||
return [{'id': result.get('url', ''), 'name': result.get('name', ''),
|
||||
'image': image, 'extra': f"{result.get('listeners', '0')} listeners"}]
|
||||
elif entity_type == 'album':
|
||||
result = client.search_album(query, '')
|
||||
if result:
|
||||
image = client.get_best_image(result.get('image', []))
|
||||
return [{'id': result.get('url', ''), 'name': result.get('name', ''),
|
||||
'image': image, 'extra': result.get('artist', '')}]
|
||||
elif entity_type == 'track':
|
||||
# search_track takes separate artist/track params
|
||||
parts = query.split(' - ', 1) if ' - ' in query else ['', query]
|
||||
result = client.search_track(parts[0], parts[1])
|
||||
if result:
|
||||
artist_name = result.get('artist', '')
|
||||
return [{'id': result.get('url', ''), 'name': result.get('name', ''),
|
||||
'image': None, 'extra': f"{artist_name} · {result.get('listeners', '0')} listeners"}]
|
||||
return []
|
||||
|
||||
elif service == 'genius':
|
||||
if not genius_worker or not genius_worker.client:
|
||||
raise ValueError("Genius worker not initialized")
|
||||
client = genius_worker.client
|
||||
if entity_type == 'artist':
|
||||
result = client.search_artist(query)
|
||||
if result:
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('name', ''),
|
||||
'image': result.get('image_url'), 'extra': ''}]
|
||||
elif entity_type == 'track':
|
||||
result = client.search_song(query, '')
|
||||
if result:
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('title', ''),
|
||||
'image': result.get('song_art_image_url'), 'extra': result.get('artist_names', '')}]
|
||||
return []
|
||||
|
||||
elif service == 'audiodb':
|
||||
if not audiodb_worker or not audiodb_worker.client:
|
||||
raise ValueError("AudioDB worker not initialized")
|
||||
|
|
@ -9853,6 +9922,8 @@ _SERVICE_ID_COLUMNS = {
|
|||
'deezer': {'artist': 'deezer_id', 'album': 'deezer_id', 'track': 'deezer_id'},
|
||||
'audiodb': {'artist': 'audiodb_id', 'album': 'audiodb_id', 'track': 'audiodb_id'},
|
||||
'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'},
|
||||
}
|
||||
|
||||
@app.route('/api/library/manual-match', methods=['PUT'])
|
||||
|
|
@ -33391,6 +33462,146 @@ def itunes_enrichment_resume():
|
|||
# ================================================================================================
|
||||
|
||||
|
||||
# ================================================================================================
|
||||
# LAST.FM ENRICHMENT WORKER
|
||||
# ================================================================================================
|
||||
|
||||
lastfm_worker = None
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
lastfm_db = MusicDatabase()
|
||||
lastfm_worker = LastFMWorker(database=lastfm_db)
|
||||
lastfm_worker.start()
|
||||
print("✅ Last.fm enrichment worker initialized and started")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Last.fm worker initialization failed: {e}")
|
||||
lastfm_worker = None
|
||||
|
||||
# --- Last.fm API Endpoints ---
|
||||
|
||||
@app.route('/api/lastfm-enrichment/status', methods=['GET'])
|
||||
def lastfm_enrichment_status():
|
||||
"""Get Last.fm enrichment status for UI polling"""
|
||||
try:
|
||||
if lastfm_worker is None:
|
||||
return jsonify({
|
||||
'enabled': False,
|
||||
'running': False,
|
||||
'paused': False,
|
||||
'current_item': None,
|
||||
'stats': {'matched': 0, 'not_found': 0, 'pending': 0, 'errors': 0},
|
||||
'progress': {}
|
||||
}), 200
|
||||
|
||||
status = lastfm_worker.get_stats()
|
||||
return jsonify(status), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Last.fm enrichment status: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/lastfm-enrichment/pause', methods=['POST'])
|
||||
def lastfm_enrichment_pause():
|
||||
"""Pause Last.fm enrichment worker"""
|
||||
try:
|
||||
if lastfm_worker is None:
|
||||
return jsonify({'error': 'Last.fm worker not initialized'}), 400
|
||||
|
||||
lastfm_worker.pause()
|
||||
logger.info("Last.fm worker paused via UI")
|
||||
return jsonify({'status': 'paused'}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error pausing Last.fm worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/lastfm-enrichment/resume', methods=['POST'])
|
||||
def lastfm_enrichment_resume():
|
||||
"""Resume Last.fm enrichment worker"""
|
||||
try:
|
||||
if lastfm_worker is None:
|
||||
return jsonify({'error': 'Last.fm worker not initialized'}), 400
|
||||
|
||||
lastfm_worker.resume()
|
||||
logger.info("Last.fm worker resumed via UI")
|
||||
return jsonify({'status': 'running'}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error resuming Last.fm worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# ================================================================================================
|
||||
# END LAST.FM ENRICHMENT INTEGRATION
|
||||
# ================================================================================================
|
||||
|
||||
|
||||
# ================================================================================================
|
||||
# GENIUS ENRICHMENT WORKER
|
||||
# ================================================================================================
|
||||
|
||||
genius_worker = None
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
genius_db = MusicDatabase()
|
||||
genius_worker = GeniusWorker(database=genius_db)
|
||||
genius_worker.start()
|
||||
print("✅ Genius enrichment worker initialized and started")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Genius worker initialization failed: {e}")
|
||||
genius_worker = None
|
||||
|
||||
# --- Genius API Endpoints ---
|
||||
|
||||
@app.route('/api/genius-enrichment/status', methods=['GET'])
|
||||
def genius_enrichment_status():
|
||||
"""Get Genius enrichment status for UI polling"""
|
||||
try:
|
||||
if genius_worker is None:
|
||||
return jsonify({
|
||||
'enabled': False,
|
||||
'running': False,
|
||||
'paused': False,
|
||||
'current_item': None,
|
||||
'stats': {'matched': 0, 'not_found': 0, 'pending': 0, 'errors': 0},
|
||||
'progress': {}
|
||||
}), 200
|
||||
|
||||
status = genius_worker.get_stats()
|
||||
return jsonify(status), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Genius enrichment status: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/genius-enrichment/pause', methods=['POST'])
|
||||
def genius_enrichment_pause():
|
||||
"""Pause Genius enrichment worker"""
|
||||
try:
|
||||
if genius_worker is None:
|
||||
return jsonify({'error': 'Genius worker not initialized'}), 400
|
||||
|
||||
genius_worker.pause()
|
||||
logger.info("Genius worker paused via UI")
|
||||
return jsonify({'status': 'paused'}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error pausing Genius worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/genius-enrichment/resume', methods=['POST'])
|
||||
def genius_enrichment_resume():
|
||||
"""Resume Genius enrichment worker"""
|
||||
try:
|
||||
if genius_worker is None:
|
||||
return jsonify({'error': 'Genius worker not initialized'}), 400
|
||||
|
||||
genius_worker.resume()
|
||||
logger.info("Genius worker resumed via UI")
|
||||
return jsonify({'status': 'running'}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error resuming Genius worker: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# ================================================================================================
|
||||
# END GENIUS ENRICHMENT INTEGRATION
|
||||
# ================================================================================================
|
||||
|
||||
|
||||
# ================================================================================================
|
||||
# HYDRABASE P2P MIRROR WORKER
|
||||
# ================================================================================================
|
||||
|
|
@ -34626,6 +34837,8 @@ def _emit_enrichment_status_loop():
|
|||
'deezer': lambda: deezer_worker,
|
||||
'spotify-enrichment': lambda: spotify_enrichment_worker,
|
||||
'itunes-enrichment': lambda: itunes_enrichment_worker,
|
||||
'lastfm-enrichment': lambda: lastfm_worker,
|
||||
'genius-enrichment': lambda: genius_worker,
|
||||
'hydrabase': lambda: hydrabase_worker,
|
||||
'repair': lambda: repair_worker,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -356,6 +356,50 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Last.fm Enrichment Status Icon -->
|
||||
<div class="lastfm-enrich-button-container">
|
||||
<button class="lastfm-enrich-button" id="lastfm-enrich-button" title="Last.fm Library Enrichment">
|
||||
<img src="https://www.last.fm/static/images/lastfm_avatar_twitter.52a5d69a85ac.png"
|
||||
alt="Last.fm" class="lastfm-enrich-logo">
|
||||
<div class="lastfm-enrich-spinner"></div>
|
||||
</button>
|
||||
<div class="lastfm-enrich-tooltip" id="lastfm-enrich-tooltip">
|
||||
<div class="lastfm-enrich-tooltip-content">
|
||||
<div class="lastfm-enrich-tooltip-header">Last.fm Enrichment</div>
|
||||
<div class="lastfm-enrich-tooltip-body" id="lastfm-enrich-tooltip-body">
|
||||
<div class="tooltip-status">Status: <span
|
||||
id="lastfm-enrich-tooltip-status">Idle</span>
|
||||
</div>
|
||||
<div class="tooltip-current" id="lastfm-enrich-tooltip-current">No active matches
|
||||
</div>
|
||||
<div class="tooltip-progress" id="lastfm-enrich-tooltip-progress">Progress: 0 / 0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Genius Enrichment Status Icon -->
|
||||
<div class="genius-enrich-button-container">
|
||||
<button class="genius-enrich-button" id="genius-enrich-button" title="Genius Library Enrichment">
|
||||
<img src="https://images.genius.com/8ed669cadd956443e29c70361ec4f372.1000x1000x1.png"
|
||||
alt="Genius" class="genius-enrich-logo">
|
||||
<div class="genius-enrich-spinner"></div>
|
||||
</button>
|
||||
<div class="genius-enrich-tooltip" id="genius-enrich-tooltip">
|
||||
<div class="genius-enrich-tooltip-content">
|
||||
<div class="genius-enrich-tooltip-header">Genius Enrichment</div>
|
||||
<div class="genius-enrich-tooltip-body" id="genius-enrich-tooltip-body">
|
||||
<div class="tooltip-status">Status: <span
|
||||
id="genius-enrich-tooltip-status">Idle</span>
|
||||
</div>
|
||||
<div class="tooltip-current" id="genius-enrich-tooltip-current">No active matches
|
||||
</div>
|
||||
<div class="tooltip-progress" id="genius-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">
|
||||
|
|
|
|||
|
|
@ -249,6 +249,8 @@ function initializeWebSocket() {
|
|||
socket.on('enrichment:deezer', (data) => updateDeezerStatusFromData(data));
|
||||
socket.on('enrichment:spotify-enrichment', (data) => updateSpotifyEnrichmentStatusFromData(data));
|
||||
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:hydrabase', (data) => updateHydrabaseStatusFromData(data));
|
||||
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
|
||||
|
||||
|
|
@ -33487,6 +33489,8 @@ function renderArtistMetaPanel(artist) {
|
|||
{ id: 'deezer', label: 'Deezer', icon: '🟣' },
|
||||
{ id: 'audiodb', label: 'AudioDB', icon: '🔵' },
|
||||
{ id: 'itunes', label: 'iTunes', icon: '🔴' },
|
||||
{ id: 'lastfm', label: 'Last.fm', icon: '⚪' },
|
||||
{ id: 'genius', label: 'Genius', icon: '🟡' },
|
||||
];
|
||||
services.forEach(svc => {
|
||||
const item = document.createElement('div');
|
||||
|
|
@ -33515,6 +33519,8 @@ function renderArtistMetaPanel(artist) {
|
|||
{ key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' },
|
||||
{ key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' },
|
||||
{ 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' },
|
||||
];
|
||||
statusServices.forEach(s => {
|
||||
const status = artist[s.key];
|
||||
|
|
@ -33867,6 +33873,7 @@ function renderExpandedAlbumHeader(album) {
|
|||
{ key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' },
|
||||
{ key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' },
|
||||
{ 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' },
|
||||
];
|
||||
statusSvcs.forEach(s => {
|
||||
const status = album[s.key];
|
||||
|
|
@ -33906,6 +33913,7 @@ function renderExpandedAlbumHeader(album) {
|
|||
{ id: 'deezer', label: 'Deezer', icon: '🟣' },
|
||||
{ id: 'audiodb', label: 'AudioDB', icon: '🔵' },
|
||||
{ id: 'itunes', label: 'iTunes', icon: '🔴' },
|
||||
{ id: 'lastfm', label: 'Last.fm', icon: '⚪' },
|
||||
].forEach(svc => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'enhanced-enrich-menu-item';
|
||||
|
|
@ -34184,6 +34192,8 @@ function renderTrackTable(album) {
|
|||
{ svc: 'deezer', col: 'deezer_id', label: 'Dz' },
|
||||
{ svc: 'audiodb', col: 'audiodb_id', label: 'ADB' },
|
||||
{ svc: 'itunes', col: 'itunes_track_id', label: 'iT' },
|
||||
{ svc: 'lastfm', col: 'lastfm_url', label: 'LFM' },
|
||||
{ svc: 'genius', col: 'genius_id', label: 'Gen' },
|
||||
];
|
||||
const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : null;
|
||||
trackServices.forEach(s => {
|
||||
|
|
@ -34806,7 +34816,7 @@ function openManualMatchModal(entityType, entityId, service, defaultQuery, artis
|
|||
|
||||
const serviceLabels = {
|
||||
spotify: 'Spotify', musicbrainz: 'MusicBrainz', deezer: 'Deezer',
|
||||
audiodb: 'AudioDB', itunes: 'iTunes'
|
||||
audiodb: 'AudioDB', itunes: 'iTunes', lastfm: 'Last.fm', genius: 'Genius'
|
||||
};
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
|
|
@ -34992,10 +35002,19 @@ async function runEnrichment(entityType, entityId, service, name, artistName, ar
|
|||
_enrichmentInFlight = true;
|
||||
|
||||
// Add loading class to all match chips for this service
|
||||
const chipPrefixes = {
|
||||
'spotify': ['spotify', 'sp'],
|
||||
'musicbrainz': ['musicbrainz', 'mb'],
|
||||
'deezer': ['deezer', 'dz'],
|
||||
'audiodb': ['audiodb', 'adb'],
|
||||
'itunes': ['itunes', 'it'],
|
||||
'lastfm': ['last.fm', 'lfm'],
|
||||
'genius': ['genius', 'gen'],
|
||||
};
|
||||
const prefixes = chipPrefixes[service] || [service];
|
||||
document.querySelectorAll('.enhanced-match-chip').forEach(chip => {
|
||||
const chipText = chip.textContent.toLowerCase();
|
||||
if (chipText.startsWith(service === 'musicbrainz' ? 'mb' : service) ||
|
||||
(service === 'musicbrainz' && chipText.startsWith('musicbrainz'))) {
|
||||
if (prefixes.some(p => chipText.startsWith(p))) {
|
||||
chip.classList.add('loading');
|
||||
}
|
||||
});
|
||||
|
|
@ -45064,6 +45083,228 @@ if (document.readyState === 'loading') {
|
|||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// LAST.FM ENRICHMENT STATUS
|
||||
// ===================================================================
|
||||
|
||||
async function updateLastFMEnrichmentStatus() {
|
||||
if (socketConnected) return;
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const response = await fetch('/api/lastfm-enrichment/status');
|
||||
if (!response.ok) { console.warn('Last.fm status endpoint unavailable'); return; }
|
||||
const data = await response.json();
|
||||
updateLastFMEnrichmentStatusFromData(data);
|
||||
} catch (error) {
|
||||
console.error('Error updating Last.fm status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLastFMEnrichmentStatusFromData(data) {
|
||||
const button = document.getElementById('lastfm-enrich-button');
|
||||
if (!button) return;
|
||||
|
||||
button.classList.remove('active', 'paused', 'complete');
|
||||
if (data.idle) {
|
||||
button.classList.add('complete');
|
||||
} else if (data.running && !data.paused) {
|
||||
button.classList.add('active');
|
||||
} else if (data.paused) {
|
||||
button.classList.add('paused');
|
||||
}
|
||||
|
||||
const tooltipStatus = document.getElementById('lastfm-enrich-tooltip-status');
|
||||
const tooltipCurrent = document.getElementById('lastfm-enrich-tooltip-current');
|
||||
const tooltipProgress = document.getElementById('lastfm-enrich-tooltip-progress');
|
||||
|
||||
if (tooltipStatus) {
|
||||
if (data.idle) { tooltipStatus.textContent = 'Complete'; }
|
||||
else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; }
|
||||
else if (data.paused) { tooltipStatus.textContent = 'Paused'; }
|
||||
else { tooltipStatus.textContent = 'Idle'; }
|
||||
}
|
||||
|
||||
if (tooltipCurrent) {
|
||||
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) {
|
||||
const artists = data.progress.artists || {};
|
||||
const albums = data.progress.albums || {};
|
||||
const tracks = data.progress.tracks || {};
|
||||
|
||||
const currentType = data.current_item?.type;
|
||||
let progressText = '';
|
||||
|
||||
const artistsComplete = artists.matched >= artists.total;
|
||||
const albumsComplete = albums.matched >= albums.total;
|
||||
|
||||
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
|
||||
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
|
||||
} else if (currentType === 'album' || (artistsComplete && !albumsComplete)) {
|
||||
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
|
||||
} else if (currentType === 'track' || (artistsComplete && albumsComplete)) {
|
||||
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
|
||||
} else {
|
||||
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
|
||||
}
|
||||
|
||||
tooltipProgress.textContent = progressText;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLastFMEnrichment() {
|
||||
try {
|
||||
const button = document.getElementById('lastfm-enrich-button');
|
||||
if (!button) return;
|
||||
|
||||
const isRunning = button.classList.contains('active');
|
||||
const endpoint = isRunning ? '/api/lastfm-enrichment/pause' : '/api/lastfm-enrichment/resume';
|
||||
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Last.fm enrichment`);
|
||||
}
|
||||
|
||||
await updateLastFMEnrichmentStatus();
|
||||
console.log(`Last.fm enrichment ${isRunning ? 'paused' : 'resumed'}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error toggling Last.fm enrichment:', error);
|
||||
showToast(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const button = document.getElementById('lastfm-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleLastFMEnrichment);
|
||||
updateLastFMEnrichmentStatus();
|
||||
setInterval(updateLastFMEnrichmentStatus, 2000);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const button = document.getElementById('lastfm-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleLastFMEnrichment);
|
||||
updateLastFMEnrichmentStatus();
|
||||
setInterval(updateLastFMEnrichmentStatus, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// GENIUS ENRICHMENT STATUS
|
||||
// ===================================================================
|
||||
|
||||
async function updateGeniusEnrichmentStatus() {
|
||||
if (socketConnected) return;
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const response = await fetch('/api/genius-enrichment/status');
|
||||
if (!response.ok) { console.warn('Genius status endpoint unavailable'); return; }
|
||||
const data = await response.json();
|
||||
updateGeniusEnrichmentStatusFromData(data);
|
||||
} catch (error) {
|
||||
console.error('Error updating Genius status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateGeniusEnrichmentStatusFromData(data) {
|
||||
const button = document.getElementById('genius-enrich-button');
|
||||
if (!button) return;
|
||||
|
||||
button.classList.remove('active', 'paused', 'complete');
|
||||
if (data.idle) {
|
||||
button.classList.add('complete');
|
||||
} else if (data.running && !data.paused) {
|
||||
button.classList.add('active');
|
||||
} else if (data.paused) {
|
||||
button.classList.add('paused');
|
||||
}
|
||||
|
||||
const tooltipStatus = document.getElementById('genius-enrich-tooltip-status');
|
||||
const tooltipCurrent = document.getElementById('genius-enrich-tooltip-current');
|
||||
const tooltipProgress = document.getElementById('genius-enrich-tooltip-progress');
|
||||
|
||||
if (tooltipStatus) {
|
||||
if (data.idle) { tooltipStatus.textContent = 'Complete'; }
|
||||
else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; }
|
||||
else if (data.paused) { tooltipStatus.textContent = 'Paused'; }
|
||||
else { tooltipStatus.textContent = 'Idle'; }
|
||||
}
|
||||
|
||||
if (tooltipCurrent) {
|
||||
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) {
|
||||
const artists = data.progress.artists || {};
|
||||
const tracks = data.progress.tracks || {};
|
||||
|
||||
const currentType = data.current_item?.type;
|
||||
let progressText = '';
|
||||
|
||||
const artistsComplete = artists.matched >= artists.total;
|
||||
|
||||
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
|
||||
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
|
||||
} else {
|
||||
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
|
||||
}
|
||||
|
||||
tooltipProgress.textContent = progressText;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleGeniusEnrichment() {
|
||||
try {
|
||||
const button = document.getElementById('genius-enrich-button');
|
||||
if (!button) return;
|
||||
|
||||
const isRunning = button.classList.contains('active');
|
||||
const endpoint = isRunning ? '/api/genius-enrichment/pause' : '/api/genius-enrichment/resume';
|
||||
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Genius enrichment`);
|
||||
}
|
||||
|
||||
await updateGeniusEnrichmentStatus();
|
||||
console.log(`Genius enrichment ${isRunning ? 'paused' : 'resumed'}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error toggling Genius enrichment:', error);
|
||||
showToast(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const button = document.getElementById('genius-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleGeniusEnrichment);
|
||||
updateGeniusEnrichmentStatus();
|
||||
setInterval(updateGeniusEnrichmentStatus, 2000);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const button = document.getElementById('genius-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleGeniusEnrichment);
|
||||
updateGeniusEnrichmentStatus();
|
||||
setInterval(updateGeniusEnrichmentStatus, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// HYDRABASE P2P MIRROR WORKER
|
||||
// ===================================================================
|
||||
|
|
|
|||
|
|
@ -27269,6 +27269,392 @@ body {
|
|||
border-bottom: 8px solid rgba(30, 30, 30, 0.98);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
LAST.FM ENRICHMENT STATUS BUTTON
|
||||
======================================== */
|
||||
|
||||
.lastfm-enrich-button-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.lastfm-enrich-button {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, rgba(213, 16, 7, 0.12) 0%, rgba(180, 10, 5, 0.18) 100%);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 1.5px solid rgba(213, 16, 7, 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(213, 16, 7, 0.2), 0 2px 8px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.lastfm-enrich-button:hover {
|
||||
background: linear-gradient(135deg, rgba(213, 16, 7, 0.18) 0%, rgba(180, 10, 5, 0.25) 100%);
|
||||
border-color: rgba(213, 16, 7, 0.4);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(213, 16, 7, 0.3), 0 3px 12px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.lastfm-enrich-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.lastfm-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%;
|
||||
}
|
||||
|
||||
.lastfm-enrich-button:hover .lastfm-enrich-logo {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lastfm-enrich-spinner {
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 3px solid transparent;
|
||||
border-top-color: rgba(213, 16, 7, 0.8);
|
||||
border-right-color: rgba(213, 16, 7, 0.5);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lastfm-enrich-button.active .lastfm-enrich-spinner {
|
||||
opacity: 1;
|
||||
animation: lastfm-spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.lastfm-enrich-button.active {
|
||||
background: linear-gradient(135deg, rgba(213, 16, 7, 0.22) 0%, rgba(180, 10, 5, 0.28) 100%);
|
||||
border-color: rgba(213, 16, 7, 0.5);
|
||||
box-shadow: 0 6px 24px rgba(213, 16, 7, 0.4), 0 3px 12px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.lastfm-enrich-button.active .lastfm-enrich-logo {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 8px rgba(213, 16, 7, 0.6));
|
||||
}
|
||||
|
||||
.lastfm-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);
|
||||
}
|
||||
|
||||
.lastfm-enrich-button.complete .lastfm-enrich-logo {
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 6px rgba(76, 175, 80, 0.5));
|
||||
}
|
||||
|
||||
.lastfm-enrich-button.complete .lastfm-enrich-spinner {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.lastfm-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);
|
||||
}
|
||||
|
||||
.lastfm-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 lastfm-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.lastfm-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;
|
||||
}
|
||||
|
||||
.lastfm-enrich-button:hover+.lastfm-enrich-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.lastfm-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(213, 16, 7, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 6px 20px rgba(213, 16, 7, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.lastfm-enrich-tooltip-header {
|
||||
font-family: 'SF Pro Display', -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(213, 16, 7, 0.95);
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.lastfm-enrich-tooltip-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#lastfm-enrich-tooltip-status {
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lastfm-enrich-button.paused+.lastfm-enrich-tooltip #lastfm-enrich-tooltip-status {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.lastfm-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(213, 16, 7, 0.3);
|
||||
}
|
||||
|
||||
.lastfm-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);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
GENIUS ENRICHMENT STATUS BUTTON
|
||||
======================================== */
|
||||
|
||||
.genius-enrich-button-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.genius-enrich-button {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 100, 0.10) 0%, rgba(200, 200, 50, 0.16) 100%);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
border: 1.5px solid rgba(255, 255, 100, 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(255, 255, 100, 0.15), 0 2px 8px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.genius-enrich-button:hover {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 100, 0.16) 0%, rgba(200, 200, 50, 0.22) 100%);
|
||||
border-color: rgba(255, 255, 100, 0.4);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(255, 255, 100, 0.25), 0 3px 12px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.genius-enrich-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.genius-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%;
|
||||
}
|
||||
|
||||
.genius-enrich-button:hover .genius-enrich-logo {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.genius-enrich-spinner {
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 3px solid transparent;
|
||||
border-top-color: rgba(255, 255, 100, 0.8);
|
||||
border-right-color: rgba(255, 255, 100, 0.5);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.genius-enrich-button.active .genius-enrich-spinner {
|
||||
opacity: 1;
|
||||
animation: genius-spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.genius-enrich-button.active {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 100, 0.20) 0%, rgba(200, 200, 50, 0.26) 100%);
|
||||
border-color: rgba(255, 255, 100, 0.5);
|
||||
box-shadow: 0 6px 24px rgba(255, 255, 100, 0.3), 0 3px 12px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.genius-enrich-button.active .genius-enrich-logo {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 255, 100, 0.6));
|
||||
}
|
||||
|
||||
.genius-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);
|
||||
}
|
||||
|
||||
.genius-enrich-button.complete .genius-enrich-logo {
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 6px rgba(76, 175, 80, 0.5));
|
||||
}
|
||||
|
||||
.genius-enrich-button.complete .genius-enrich-spinner {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.genius-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);
|
||||
}
|
||||
|
||||
.genius-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 genius-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.genius-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;
|
||||
}
|
||||
|
||||
.genius-enrich-button:hover+.genius-enrich-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.genius-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, 100, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 6px 20px rgba(255, 255, 100, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.genius-enrich-tooltip-header {
|
||||
font-family: 'SF Pro Display', -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 100, 0.95);
|
||||
letter-spacing: -0.2px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.genius-enrich-tooltip-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#genius-enrich-tooltip-status {
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.genius-enrich-button.paused+.genius-enrich-tooltip #genius-enrich-tooltip-status {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.genius-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, 100, 0.3);
|
||||
}
|
||||
|
||||
.genius-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
|
||||
======================================== */
|
||||
|
|
|
|||
Loading…
Reference in a new issue