From 7ca786539ede0058b9a84958fcccc3e1af61412b Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:20:41 -0700 Subject: [PATCH] Lift WebMetadataUpdateWorker to core/workers/metadata_update.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class body byte-identical to original. The shared metadata_update_state dict is bound at import time via init() so the class body can mutate it without web_server.py rebinding. web_server.py: 39754 → 39122 (-632 lines). --- core/workers/__init__.py | 0 core/workers/metadata_update.py | 662 ++++++++++++++++++++++++++++++++ web_server.py | 637 +----------------------------- 3 files changed, 665 insertions(+), 634 deletions(-) create mode 100644 core/workers/__init__.py create mode 100644 core/workers/metadata_update.py diff --git a/core/workers/__init__.py b/core/workers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/workers/metadata_update.py b/core/workers/metadata_update.py new file mode 100644 index 00000000..085015f1 --- /dev/null +++ b/core/workers/metadata_update.py @@ -0,0 +1,662 @@ +"""WebMetadataUpdateWorker — lifted from web_server.py. + +Body is byte-identical to the original. The module-level +``metadata_update_state`` global must be initialized via ``init()`` before +``WebMetadataUpdateWorker`` is instantiated, so the dict reference inside +the class body resolves to the same dict that web_server.py owns. +""" +import logging +import threading +import requests +from datetime import datetime + +from core.matching_engine import MusicMatchingEngine +from core.runtime_state import add_activity_item +from config.settings import config_manager + +logger = logging.getLogger(__name__) + +# Injected at runtime via init() — points to web_server.metadata_update_state. +metadata_update_state = None + + +def init(state): + """Bind the shared metadata_update_state dict from web_server.""" + global metadata_update_state + metadata_update_state = state + + +class WebMetadataUpdateWorker: + """Web-based metadata update worker - EXACT port of dashboard.py MetadataUpdateWorker""" + + def __init__(self, artists, media_client, spotify_client, server_type, refresh_interval_days=30): + self.artists = artists + self.media_client = media_client # Can be plex_client or jellyfin_client + self.spotify_client = spotify_client + self.server_type = server_type # "plex" or "jellyfin" + self.matching_engine = MusicMatchingEngine() + self.refresh_interval_days = refresh_interval_days + self.should_stop = False + self.processed_count = 0 + self.successful_count = 0 + self.failed_count = 0 + self.max_workers = 1 + # DB-first: reuse existing metadata from SoulSync database + try: + from database.music_database import MusicDatabase + self._db = MusicDatabase() + except Exception: + self._db = None + self.thread_lock = threading.Lock() + + def stop(self): + self.should_stop = True + + def get_artist_name(self, artist): + """Get artist name consistently across Plex and Jellyfin""" + return getattr(artist, 'title', 'Unknown Artist') + + def run(self): + """Process all artists one by one - EXACT copy from dashboard.py""" + global metadata_update_state + + try: + # Load artists in background if not provided - EXACTLY like dashboard.py + if self.artists is None: + # Enable lightweight mode for Jellyfin to skip track caching + if self.server_type == "jellyfin": + self.media_client.set_metadata_only_mode(True) + elif self.server_type == "navidrome": + # Navidrome doesn't need special mode setting + pass + + all_artists = self.media_client.get_all_artists() + logger.debug(f"Raw artists returned: {[getattr(a, 'title', 'NO_TITLE') for a in (all_artists or [])]}") + if not all_artists: + metadata_update_state['status'] = 'error' + metadata_update_state['error'] = f"No artists found in {self.server_type.title()} library" + add_activity_item("", "Metadata Update", metadata_update_state['error'], "Now") + return + + # Filter artists that need processing + artists_to_process = [artist for artist in all_artists if self.artist_needs_processing(artist)] + self.artists = artists_to_process + + # Emit loaded signal equivalent - EXACTLY like dashboard.py + if len(artists_to_process) == 0: + metadata_update_state['status'] = 'completed' + metadata_update_state['completed_at'] = datetime.now() + add_activity_item("", "Metadata Update", "All artists already have good metadata", "Now") + return + else: + add_activity_item("", "Metadata Update", f"Processing {len(artists_to_process)} of {len(all_artists)} artists", "Now") + + if not artists_to_process: + metadata_update_state['status'] = 'completed' + metadata_update_state['completed_at'] = datetime.now() + return + + total_artists = len(self.artists) + metadata_update_state['total'] = total_artists + + # Process artists in parallel using ThreadPoolExecutor - EXACTLY like dashboard.py + def process_single_artist(artist): + """Process a single artist and return results""" + if self.should_stop or metadata_update_state['status'] == 'stopping': + return None + + artist_name = getattr(artist, 'title', 'Unknown Artist') + + # Double-check ignore flag right before processing + if self.media_client.is_artist_ignored(artist): + return (artist_name, True, "Skipped (ignored)") + + try: + success, details = self.update_artist_metadata(artist) + return (artist_name, success, details) + except Exception as e: + return (artist_name, False, f"Error: {str(e)}") + + # Process artists sequentially with rate limiting + # (no ThreadPoolExecutor — API rate limits make parallelism counterproductive) + import time + for artist in self.artists: + if self.should_stop or metadata_update_state['status'] == 'stopping': + break + + result = process_single_artist(artist) + if result is None: + continue + + artist_name, success, details = result + + with self.thread_lock: + self.processed_count += 1 + if success: + self.successful_count += 1 + else: + self.failed_count += 1 + + progress_percent = (self.processed_count / total_artists) * 100 + metadata_update_state.update({ + 'current_artist': artist_name, + 'processed': self.processed_count, + 'percentage': progress_percent, + 'successful': self.successful_count, + 'failed': self.failed_count + }) + + # Rate limit: 1.5s between artists (this actually runs between artists now) + time.sleep(1.5) + + # Mark as completed - equivalent to finished.emit + metadata_update_state['status'] = 'completed' + metadata_update_state['completed_at'] = datetime.now() + metadata_update_state['current_artist'] = 'Completed' + + summary = f"Processed {self.processed_count} artists: {self.successful_count} updated, {self.failed_count} failed" + add_activity_item("", "Metadata Complete", summary, "Now") + + except Exception as e: + logger.error(f"Metadata update failed: {e}") + metadata_update_state['status'] = 'error' + metadata_update_state['error'] = str(e) + add_activity_item("", "Metadata Error", str(e), "Now") + + def artist_needs_processing(self, artist): + """Check if an artist needs metadata processing using age-based detection - EXACT copy from dashboard.py""" + try: + # Check if artist is manually ignored + if self.media_client.is_artist_ignored(artist): + return False + + # Use media client's age-based checking with configured interval + return self.media_client.needs_update_by_age(artist, self.refresh_interval_days) + + except Exception as e: + logger.error(f"Error checking artist {getattr(artist, 'title', 'Unknown')}: {e}") + return True # Process if we can't determine status + + def _check_db_artist(self, artist_name): + """Check SoulSync DB for existing artist metadata (genres, spotify_artist_id). + + NOTE: DB thumb_url is a Plex/Jellyfin internal path, NOT a downloadable URL. + Photos must be checked via the media server object, not the DB. + + Returns (db_artist_dict, has_genres, spotify_artist_id) or (None, False, None) if not found.""" + if not self._db: + return None, False, None + try: + db_artists = self._db.search_artists(artist_name, limit=5) + if not db_artists: + return None, False, None + # Find best name match + best = None + best_score = 0.0 + norm_name = self.matching_engine.normalize_string(artist_name) + for dba in db_artists: + score = self.matching_engine.similarity_score( + norm_name, self.matching_engine.normalize_string(dba.name)) + if score > best_score: + best_score = score + best = dba + if not best or best_score < 0.85: + return None, False, None + has_genres = bool(best.genres and len(best.genres) > 0) + # Get spotify_artist_id from raw DB row (not in dataclass) + spotify_artist_id = None + try: + raw = self._db.api_get_artist(best.id) + if raw: + spotify_artist_id = raw.get('spotify_artist_id') + except Exception: + pass + return best, has_genres, spotify_artist_id + except Exception: + return None, False, None + + def update_artist_metadata(self, artist): + """Update a single artist's metadata. Checks SoulSync DB first to avoid unnecessary API calls. + + DB-first strategy: + - Genres: DB stores real genre strings → can apply directly, skip Spotify + - spotify_artist_id: DB may have it from enrichment → skip search_artists() call + - Photos/album art: DB thumb_url is a media-server internal path (not downloadable) + so these MUST come from Spotify API + """ + try: + artist_name = getattr(artist, 'title', 'Unknown Artist') + + # Skip processing for artists with no valid name + if artist_name == 'Unknown Artist' or not artist_name or not artist_name.strip(): + return False, "Skipped: No valid artist name" + + # DB-first: check what we already have cached + db_artist, db_has_genres, db_spotify_id = self._check_db_artist(artist_name) + + # Check what the media server artist is currently missing + needs_photo = not self.artist_has_valid_photo(artist) if self.server_type != "jellyfin" else True + needs_genres = not getattr(artist, 'genres', None) + needs_album_art = self.server_type == "plex" + + # If media server already has valid photo + genres + album art, skip entirely + if not needs_photo and not needs_genres and not needs_album_art: + self.media_client.update_artist_biography(artist) + return True, "Already up to date" + + # Determine if we actually need Spotify + # Photos and album art MUST come from Spotify (DB only has internal media server paths) + # Genres CAN come from DB if available + need_spotify = needs_photo or needs_album_art or (needs_genres and not db_has_genres) + + spotify_artist = None + highest_score = 0.0 + + if need_spotify: + # Try direct lookup by cached spotify_artist_id first (1 API call vs search) + if db_spotify_id: + try: + from core.spotify_client import Artist as SpotifyArtistDC + raw = self.spotify_client.get_artist(db_spotify_id) + if raw and 'name' in raw: + spotify_artist = SpotifyArtistDC.from_spotify_artist(raw) + highest_score = 1.0 + logger.debug(f"Metadata updater: direct Spotify lookup for '{artist_name}' via cached ID {db_spotify_id}") + except Exception as e: + logger.debug(f"Direct Spotify lookup failed for {db_spotify_id}: {e}") + spotify_artist = None + + # Fall back to search if direct lookup didn't work + if not spotify_artist: + spotify_artists = self.spotify_client.search_artists(artist_name, limit=5) + if not spotify_artists: + # Spotify failed — apply DB genres if available, skip photos/art + changes_made = [] + if needs_genres and db_has_genres and db_artist: + if self._apply_db_genres(artist, db_artist.genres): + changes_made.append("genres (DB)") + if changes_made: + self.media_client.update_artist_biography(artist) + return True, f"Updated {', '.join(changes_made)} (Spotify unavailable)" + return False, "Not found on Spotify" + + # Find the best match + best_match = None + plex_artist_normalized = self.matching_engine.normalize_string(artist_name) + + for sa in spotify_artists: + spotify_artist_normalized = self.matching_engine.normalize_string(sa.name) + score = self.matching_engine.similarity_score(plex_artist_normalized, spotify_artist_normalized) + if score > highest_score: + highest_score = score + best_match = sa + + if not best_match or highest_score < 0.7: + # No good Spotify match — still try DB genres + changes_made = [] + if needs_genres and db_has_genres and db_artist: + if self._apply_db_genres(artist, db_artist.genres): + changes_made.append("genres (DB)") + if changes_made: + self.media_client.update_artist_biography(artist) + return True, f"Updated {', '.join(changes_made)} (no Spotify match)" + return False, f"No confident match found (best: '{getattr(best_match, 'name', 'N/A')}', score: {highest_score:.2f})" + + spotify_artist = best_match + + changes_made = [] + + # Update photo (always from Spotify — DB only has media server paths) + if needs_photo and spotify_artist: + photo_updated = self.update_artist_photo(artist, spotify_artist) + if photo_updated: + changes_made.append("photo") + + # Update genres — use DB if available, otherwise Spotify + if needs_genres: + if db_has_genres and db_artist: + genres_updated = self._apply_db_genres(artist, db_artist.genres) + if genres_updated: + changes_made.append("genres (DB)") + elif spotify_artist: + # DB genres didn't result in changes, try Spotify for newer/different genres + genres_updated = self.update_artist_genres(artist, spotify_artist) + if genres_updated: + changes_made.append("genres") + elif spotify_artist: + genres_updated = self.update_artist_genres(artist, spotify_artist) + if genres_updated: + changes_made.append("genres") + + # Update album artwork (only for Plex, always from Spotify) + if self.server_type == "plex" and spotify_artist: + albums_updated = self.update_album_artwork(artist, spotify_artist) + if albums_updated > 0: + changes_made.append(f"{albums_updated} album art") + elif self.server_type != "plex": + logger.info(f"Skipping album artwork updates for Jellyfin artist: {artist.title}") + + if changes_made: + biography_updated = self.media_client.update_artist_biography(artist) + if biography_updated: + changes_made.append("timestamp") + + source = f"match: '{spotify_artist.name}', score: {highest_score:.2f}" if spotify_artist else "DB cache" + details = f"Updated {', '.join(changes_made)} ({source})" + return True, details + else: + self.media_client.update_artist_biography(artist) + return True, "Already up to date" + + except Exception as e: + return False, str(e) + + def _apply_db_genres(self, artist, genres): + """Apply genres from DB cache to media server.""" + try: + if not genres: + return False + existing_genres = set(genre.tag if hasattr(genre, 'tag') else str(genre) + for genre in (getattr(artist, 'genres', None) or [])) + db_genres = set(g for g in genres if g and g.strip() and len(g.strip()) > 1) + if db_genres and db_genres != existing_genres: + return self.media_client.update_artist_genres(artist, list(db_genres)[:10]) + return False + except Exception: + return False + + def update_artist_photo(self, artist, spotify_artist): + """Update artist photo from Spotify - EXACT copy from dashboard.py""" + try: + # Check if artist already has a good photo (skip check for Jellyfin) + if self.server_type != "jellyfin" and self.artist_has_valid_photo(artist): + logger.info(f"Skipping {artist.title}: already has valid photo ({getattr(artist, 'thumb', 'None')})") + return False + + # Get the image URL from Spotify + if not spotify_artist.image_url: + logger.warning(f"Skipping {artist.title}: no Spotify image URL available") + return False + + logger.info(f"Processing {artist.title}: downloading from Spotify...") + + image_url = spotify_artist.image_url + + # Download and validate image + response = requests.get(image_url, timeout=10) + response.raise_for_status() + + # Validate and convert image (skip conversion for Jellyfin to preserve format) + if self.server_type == "jellyfin": + # For Jellyfin, use raw image data to preserve original format + image_data = response.content + logger.info(f"Using raw image data for Jellyfin ({len(image_data)} bytes)") + else: + # For other servers, validate and convert + image_data = self.validate_and_convert_image(response.content) + if not image_data: + return False + + # Upload to media server using client's method + return self.media_client.update_artist_poster(artist, image_data) + + except Exception as e: + logger.error(f"Error updating photo for {getattr(artist, 'title', 'Unknown')}: {e}") + return False + + def update_artist_genres(self, artist, spotify_artist): + """Update artist genres from Spotify and albums - EXACT copy from dashboard.py""" + try: + # Get existing genres + existing_genres = set(genre.tag if hasattr(genre, 'tag') else str(genre) + for genre in (artist.genres or [])) + + # Get Spotify artist genres + spotify_genres = set(spotify_artist.genres or []) + + # Get genres from all albums + album_genres = set() + try: + for album in artist.albums(): + if hasattr(album, 'genres') and album.genres: + album_genres.update(genre.tag if hasattr(genre, 'tag') else str(genre) + for genre in album.genres) + except Exception: + pass # Albums might not be accessible + + # Combine all genres (prioritize Spotify genres) + all_genres = spotify_genres.union(album_genres) + + # Filter out empty/invalid genres + all_genres = {g for g in all_genres if g and g.strip() and len(g.strip()) > 1} + + # Only update if we have new genres and they're different + if all_genres and (not existing_genres or all_genres != existing_genres): + # Convert to list and limit to 10 genres + genre_list = list(all_genres)[:10] + + # Use media client API to update genres + success = self.media_client.update_artist_genres(artist, genre_list) + if success: + return True + else: + return False + else: + return False + + except Exception as e: + logger.error(f"Error updating genres for {getattr(artist, 'title', 'Unknown')}: {e}") + return False + + def update_album_artwork(self, artist, spotify_artist): + """Update album artwork for all albums by this artist from Spotify. + DB thumb_url is a media-server internal path, so album art must come from Spotify.""" + try: + updated_count = 0 + skipped_count = 0 + + # Get all albums for this artist + try: + albums = list(artist.albums()) + except Exception: + logger.error(f"Could not access albums for artist '{artist.title}'") + return 0 + + if not albums: + logger.warning(f"No albums found for artist '{artist.title}'") + return 0 + + import time + for album in albums: + try: + album_title = getattr(album, 'title', 'Unknown Album') + + # Check if album already has good artwork on the media server + if self.album_has_valid_artwork(album): + skipped_count += 1 + continue + + # Rate limit between album API calls + time.sleep(0.5) + + # Search for this specific album on Spotify + album_query = f"album:{album_title} artist:{spotify_artist.name}" + spotify_albums = self.spotify_client.search_albums(album_query, limit=3) + + if not spotify_albums: + continue + + # Find the best matching album + best_album = None + highest_score = 0.0 + + plex_album_normalized = self.matching_engine.normalize_string(album_title) + + for spotify_album in spotify_albums: + spotify_album_normalized = self.matching_engine.normalize_string(spotify_album.name) + score = self.matching_engine.similarity_score(plex_album_normalized, spotify_album_normalized) + + if score > highest_score: + highest_score = score + best_album = spotify_album + + # If we found a good match with artwork, download it + if best_album and highest_score > 0.7 and best_album.image_url: + if self.download_and_upload_album_artwork(album, best_album.image_url): + updated_count += 1 + + except Exception as e: + logger.error(f"Error processing album '{getattr(album, 'title', 'Unknown')}': {e}") + continue + + return updated_count + + except Exception as e: + logger.error(f"Error updating album artwork for artist '{getattr(artist, 'title', 'Unknown')}': {e}") + return 0 + + def album_has_valid_artwork(self, album): + """Check if album has valid artwork - EXACT copy from dashboard.py""" + try: + if not hasattr(album, 'thumb') or not album.thumb: + return False + + thumb_url = str(album.thumb) + + # Completely empty or None + if not thumb_url or thumb_url.strip() == '': + return False + + # Obvious placeholder text in URL + obvious_placeholders = ['no-image', 'placeholder', 'missing', 'default-album', 'blank.jpg', 'empty.png'] + thumb_lower = thumb_url.lower() + for placeholder in obvious_placeholders: + if placeholder in thumb_lower: + return False + + # Extremely short URLs (likely broken) + if len(thumb_url) < 20: + return False + + return True + + except Exception as e: + return True + + def download_and_upload_album_artwork(self, album, image_url): + """Download artwork from Spotify and upload to media server - EXACT copy from dashboard.py""" + try: + # Download image from Spotify + response = requests.get(image_url, timeout=10) + response.raise_for_status() + + # Validate and convert image + image_data = self.validate_and_convert_image(response.content) + if not image_data: + return False + + # Upload using media client + success = self.media_client.update_album_poster(album, image_data) + return success + + except Exception as e: + logger.error(f"Error downloading/uploading artwork for album '{getattr(album, 'title', 'Unknown')}': {e}") + return False + + def artist_has_valid_photo(self, artist): + """Check if artist has a valid photo - EXACT copy from dashboard.py""" + try: + if not hasattr(artist, 'thumb') or not artist.thumb: + return False + + thumb_url = str(artist.thumb) + if 'default' in thumb_url.lower() or len(thumb_url) < 50: + return False + + return True + + except Exception: + return False + + def validate_and_convert_image(self, image_data): + """Validate and convert image for media server compatibility - EXACT copy from dashboard.py""" + try: + from PIL import Image + import io + + # Open and validate image + image = Image.open(io.BytesIO(image_data)) + + # Check minimum dimensions + width, height = image.size + if width < 200 or height < 200: + return None + + # Convert to JPEG for consistency + if image.format != 'JPEG': + buffer = io.BytesIO() + image.convert('RGB').save(buffer, format='JPEG', quality=95) + return buffer.getvalue() + + return image_data + + except Exception: + return None + + def upload_artist_poster(self, artist, image_data): + """Upload poster using media client - EXACT copy from dashboard.py""" + try: + # Use media client's update method if available + if hasattr(self.media_client, 'update_artist_poster'): + return self.media_client.update_artist_poster(artist, image_data) + + # Fallback for Plex: direct API call + if self.server_type == "plex": + import requests + server = self.media_client.server + upload_url = f"{server._baseurl}/library/metadata/{artist.ratingKey}/posters" + headers = { + 'X-Plex-Token': server._token, + 'Content-Type': 'image/jpeg' + } + + response = requests.post(upload_url, data=image_data, headers=headers) + response.raise_for_status() + + # Refresh artist to see changes + artist.refresh() + return True + + # Jellyfin: Use Jellyfin API to upload artist image + elif self.server_type == "jellyfin": + import requests + jellyfin_config = config_manager.get_jellyfin_config() + jellyfin_base_url = jellyfin_config.get('base_url', '') + jellyfin_token = jellyfin_config.get('api_key', '') + + if not jellyfin_base_url or not jellyfin_token: + logger.warning("Jellyfin configuration missing for image upload") + return False + + upload_url = f"{jellyfin_base_url.rstrip('/')}/Items/{artist.ratingKey}/Images/Primary" + headers = { + 'Authorization': f'MediaBrowser Token="{jellyfin_token}"', + 'Content-Type': 'image/jpeg' + } + + response = requests.post(upload_url, data=image_data, headers=headers) + response.raise_for_status() + return True + + # Navidrome: Currently not supported (Subsonic API doesn't support image uploads) + elif self.server_type == "navidrome": + logger.info("ℹ️ Navidrome does not support artist image uploads via Subsonic API") + return False + + else: + # Unknown server type + return False + + except Exception as e: + logger.error(f"Error uploading poster: {e}") + return False diff --git a/web_server.py b/web_server.py index fc195926..38d8c9c2 100644 --- a/web_server.py +++ b/web_server.py @@ -28586,6 +28586,9 @@ metadata_update_worker = None metadata_update_runtime_worker = None metadata_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="metadata_update") +from core.workers.metadata_update import WebMetadataUpdateWorker, init as _init_metadata_update_worker +_init_metadata_update_worker(metadata_update_state) + # =============================== # == DISCOVER PAGE ENDPOINTS == # =============================== @@ -35912,640 +35915,6 @@ def convert_beatport_results_to_spotify_tracks(discovery_results): # Beatport download missing tracks is handled frontend-only (like YouTube) # No backend endpoint needed - uses existing download modal infrastructure -class WebMetadataUpdateWorker: - """Web-based metadata update worker - EXACT port of dashboard.py MetadataUpdateWorker""" - - def __init__(self, artists, media_client, spotify_client, server_type, refresh_interval_days=30): - self.artists = artists - self.media_client = media_client # Can be plex_client or jellyfin_client - self.spotify_client = spotify_client - self.server_type = server_type # "plex" or "jellyfin" - self.matching_engine = MusicMatchingEngine() - self.refresh_interval_days = refresh_interval_days - self.should_stop = False - self.processed_count = 0 - self.successful_count = 0 - self.failed_count = 0 - self.max_workers = 1 - # DB-first: reuse existing metadata from SoulSync database - try: - from database.music_database import MusicDatabase - self._db = MusicDatabase() - except Exception: - self._db = None - self.thread_lock = threading.Lock() - - def stop(self): - self.should_stop = True - - def get_artist_name(self, artist): - """Get artist name consistently across Plex and Jellyfin""" - return getattr(artist, 'title', 'Unknown Artist') - - def run(self): - """Process all artists one by one - EXACT copy from dashboard.py""" - global metadata_update_state - - try: - # Load artists in background if not provided - EXACTLY like dashboard.py - if self.artists is None: - # Enable lightweight mode for Jellyfin to skip track caching - if self.server_type == "jellyfin": - self.media_client.set_metadata_only_mode(True) - elif self.server_type == "navidrome": - # Navidrome doesn't need special mode setting - pass - - all_artists = self.media_client.get_all_artists() - logger.debug(f"Raw artists returned: {[getattr(a, 'title', 'NO_TITLE') for a in (all_artists or [])]}") - if not all_artists: - metadata_update_state['status'] = 'error' - metadata_update_state['error'] = f"No artists found in {self.server_type.title()} library" - add_activity_item("", "Metadata Update", metadata_update_state['error'], "Now") - return - - # Filter artists that need processing - artists_to_process = [artist for artist in all_artists if self.artist_needs_processing(artist)] - self.artists = artists_to_process - - # Emit loaded signal equivalent - EXACTLY like dashboard.py - if len(artists_to_process) == 0: - metadata_update_state['status'] = 'completed' - metadata_update_state['completed_at'] = datetime.now() - add_activity_item("", "Metadata Update", "All artists already have good metadata", "Now") - return - else: - add_activity_item("", "Metadata Update", f"Processing {len(artists_to_process)} of {len(all_artists)} artists", "Now") - - if not artists_to_process: - metadata_update_state['status'] = 'completed' - metadata_update_state['completed_at'] = datetime.now() - return - - total_artists = len(self.artists) - metadata_update_state['total'] = total_artists - - # Process artists in parallel using ThreadPoolExecutor - EXACTLY like dashboard.py - def process_single_artist(artist): - """Process a single artist and return results""" - if self.should_stop or metadata_update_state['status'] == 'stopping': - return None - - artist_name = getattr(artist, 'title', 'Unknown Artist') - - # Double-check ignore flag right before processing - if self.media_client.is_artist_ignored(artist): - return (artist_name, True, "Skipped (ignored)") - - try: - success, details = self.update_artist_metadata(artist) - return (artist_name, success, details) - except Exception as e: - return (artist_name, False, f"Error: {str(e)}") - - # Process artists sequentially with rate limiting - # (no ThreadPoolExecutor — API rate limits make parallelism counterproductive) - import time - for artist in self.artists: - if self.should_stop or metadata_update_state['status'] == 'stopping': - break - - result = process_single_artist(artist) - if result is None: - continue - - artist_name, success, details = result - - with self.thread_lock: - self.processed_count += 1 - if success: - self.successful_count += 1 - else: - self.failed_count += 1 - - progress_percent = (self.processed_count / total_artists) * 100 - metadata_update_state.update({ - 'current_artist': artist_name, - 'processed': self.processed_count, - 'percentage': progress_percent, - 'successful': self.successful_count, - 'failed': self.failed_count - }) - - # Rate limit: 1.5s between artists (this actually runs between artists now) - time.sleep(1.5) - - # Mark as completed - equivalent to finished.emit - metadata_update_state['status'] = 'completed' - metadata_update_state['completed_at'] = datetime.now() - metadata_update_state['current_artist'] = 'Completed' - - summary = f"Processed {self.processed_count} artists: {self.successful_count} updated, {self.failed_count} failed" - add_activity_item("", "Metadata Complete", summary, "Now") - - except Exception as e: - logger.error(f"Metadata update failed: {e}") - metadata_update_state['status'] = 'error' - metadata_update_state['error'] = str(e) - add_activity_item("", "Metadata Error", str(e), "Now") - - def artist_needs_processing(self, artist): - """Check if an artist needs metadata processing using age-based detection - EXACT copy from dashboard.py""" - try: - # Check if artist is manually ignored - if self.media_client.is_artist_ignored(artist): - return False - - # Use media client's age-based checking with configured interval - return self.media_client.needs_update_by_age(artist, self.refresh_interval_days) - - except Exception as e: - logger.error(f"Error checking artist {getattr(artist, 'title', 'Unknown')}: {e}") - return True # Process if we can't determine status - - def _check_db_artist(self, artist_name): - """Check SoulSync DB for existing artist metadata (genres, spotify_artist_id). - - NOTE: DB thumb_url is a Plex/Jellyfin internal path, NOT a downloadable URL. - Photos must be checked via the media server object, not the DB. - - Returns (db_artist_dict, has_genres, spotify_artist_id) or (None, False, None) if not found.""" - if not self._db: - return None, False, None - try: - db_artists = self._db.search_artists(artist_name, limit=5) - if not db_artists: - return None, False, None - # Find best name match - best = None - best_score = 0.0 - norm_name = self.matching_engine.normalize_string(artist_name) - for dba in db_artists: - score = self.matching_engine.similarity_score( - norm_name, self.matching_engine.normalize_string(dba.name)) - if score > best_score: - best_score = score - best = dba - if not best or best_score < 0.85: - return None, False, None - has_genres = bool(best.genres and len(best.genres) > 0) - # Get spotify_artist_id from raw DB row (not in dataclass) - spotify_artist_id = None - try: - raw = self._db.api_get_artist(best.id) - if raw: - spotify_artist_id = raw.get('spotify_artist_id') - except Exception: - pass - return best, has_genres, spotify_artist_id - except Exception: - return None, False, None - - def update_artist_metadata(self, artist): - """Update a single artist's metadata. Checks SoulSync DB first to avoid unnecessary API calls. - - DB-first strategy: - - Genres: DB stores real genre strings → can apply directly, skip Spotify - - spotify_artist_id: DB may have it from enrichment → skip search_artists() call - - Photos/album art: DB thumb_url is a media-server internal path (not downloadable) - so these MUST come from Spotify API - """ - try: - artist_name = getattr(artist, 'title', 'Unknown Artist') - - # Skip processing for artists with no valid name - if artist_name == 'Unknown Artist' or not artist_name or not artist_name.strip(): - return False, "Skipped: No valid artist name" - - # DB-first: check what we already have cached - db_artist, db_has_genres, db_spotify_id = self._check_db_artist(artist_name) - - # Check what the media server artist is currently missing - needs_photo = not self.artist_has_valid_photo(artist) if self.server_type != "jellyfin" else True - needs_genres = not getattr(artist, 'genres', None) - needs_album_art = self.server_type == "plex" - - # If media server already has valid photo + genres + album art, skip entirely - if not needs_photo and not needs_genres and not needs_album_art: - self.media_client.update_artist_biography(artist) - return True, "Already up to date" - - # Determine if we actually need Spotify - # Photos and album art MUST come from Spotify (DB only has internal media server paths) - # Genres CAN come from DB if available - need_spotify = needs_photo or needs_album_art or (needs_genres and not db_has_genres) - - spotify_artist = None - highest_score = 0.0 - - if need_spotify: - # Try direct lookup by cached spotify_artist_id first (1 API call vs search) - if db_spotify_id: - try: - from core.spotify_client import Artist as SpotifyArtistDC - raw = self.spotify_client.get_artist(db_spotify_id) - if raw and 'name' in raw: - spotify_artist = SpotifyArtistDC.from_spotify_artist(raw) - highest_score = 1.0 - logger.debug(f"Metadata updater: direct Spotify lookup for '{artist_name}' via cached ID {db_spotify_id}") - except Exception as e: - logger.debug(f"Direct Spotify lookup failed for {db_spotify_id}: {e}") - spotify_artist = None - - # Fall back to search if direct lookup didn't work - if not spotify_artist: - spotify_artists = self.spotify_client.search_artists(artist_name, limit=5) - if not spotify_artists: - # Spotify failed — apply DB genres if available, skip photos/art - changes_made = [] - if needs_genres and db_has_genres and db_artist: - if self._apply_db_genres(artist, db_artist.genres): - changes_made.append("genres (DB)") - if changes_made: - self.media_client.update_artist_biography(artist) - return True, f"Updated {', '.join(changes_made)} (Spotify unavailable)" - return False, "Not found on Spotify" - - # Find the best match - best_match = None - plex_artist_normalized = self.matching_engine.normalize_string(artist_name) - - for sa in spotify_artists: - spotify_artist_normalized = self.matching_engine.normalize_string(sa.name) - score = self.matching_engine.similarity_score(plex_artist_normalized, spotify_artist_normalized) - if score > highest_score: - highest_score = score - best_match = sa - - if not best_match or highest_score < 0.7: - # No good Spotify match — still try DB genres - changes_made = [] - if needs_genres and db_has_genres and db_artist: - if self._apply_db_genres(artist, db_artist.genres): - changes_made.append("genres (DB)") - if changes_made: - self.media_client.update_artist_biography(artist) - return True, f"Updated {', '.join(changes_made)} (no Spotify match)" - return False, f"No confident match found (best: '{getattr(best_match, 'name', 'N/A')}', score: {highest_score:.2f})" - - spotify_artist = best_match - - changes_made = [] - - # Update photo (always from Spotify — DB only has media server paths) - if needs_photo and spotify_artist: - photo_updated = self.update_artist_photo(artist, spotify_artist) - if photo_updated: - changes_made.append("photo") - - # Update genres — use DB if available, otherwise Spotify - if needs_genres: - if db_has_genres and db_artist: - genres_updated = self._apply_db_genres(artist, db_artist.genres) - if genres_updated: - changes_made.append("genres (DB)") - elif spotify_artist: - # DB genres didn't result in changes, try Spotify for newer/different genres - genres_updated = self.update_artist_genres(artist, spotify_artist) - if genres_updated: - changes_made.append("genres") - elif spotify_artist: - genres_updated = self.update_artist_genres(artist, spotify_artist) - if genres_updated: - changes_made.append("genres") - - # Update album artwork (only for Plex, always from Spotify) - if self.server_type == "plex" and spotify_artist: - albums_updated = self.update_album_artwork(artist, spotify_artist) - if albums_updated > 0: - changes_made.append(f"{albums_updated} album art") - elif self.server_type != "plex": - logger.info(f"Skipping album artwork updates for Jellyfin artist: {artist.title}") - - if changes_made: - biography_updated = self.media_client.update_artist_biography(artist) - if biography_updated: - changes_made.append("timestamp") - - source = f"match: '{spotify_artist.name}', score: {highest_score:.2f}" if spotify_artist else "DB cache" - details = f"Updated {', '.join(changes_made)} ({source})" - return True, details - else: - self.media_client.update_artist_biography(artist) - return True, "Already up to date" - - except Exception as e: - return False, str(e) - - def _apply_db_genres(self, artist, genres): - """Apply genres from DB cache to media server.""" - try: - if not genres: - return False - existing_genres = set(genre.tag if hasattr(genre, 'tag') else str(genre) - for genre in (getattr(artist, 'genres', None) or [])) - db_genres = set(g for g in genres if g and g.strip() and len(g.strip()) > 1) - if db_genres and db_genres != existing_genres: - return self.media_client.update_artist_genres(artist, list(db_genres)[:10]) - return False - except Exception: - return False - - def update_artist_photo(self, artist, spotify_artist): - """Update artist photo from Spotify - EXACT copy from dashboard.py""" - try: - # Check if artist already has a good photo (skip check for Jellyfin) - if self.server_type != "jellyfin" and self.artist_has_valid_photo(artist): - logger.info(f"Skipping {artist.title}: already has valid photo ({getattr(artist, 'thumb', 'None')})") - return False - - # Get the image URL from Spotify - if not spotify_artist.image_url: - logger.warning(f"Skipping {artist.title}: no Spotify image URL available") - return False - - logger.info(f"Processing {artist.title}: downloading from Spotify...") - - image_url = spotify_artist.image_url - - # Download and validate image - response = requests.get(image_url, timeout=10) - response.raise_for_status() - - # Validate and convert image (skip conversion for Jellyfin to preserve format) - if self.server_type == "jellyfin": - # For Jellyfin, use raw image data to preserve original format - image_data = response.content - logger.info(f"Using raw image data for Jellyfin ({len(image_data)} bytes)") - else: - # For other servers, validate and convert - image_data = self.validate_and_convert_image(response.content) - if not image_data: - return False - - # Upload to media server using client's method - return self.media_client.update_artist_poster(artist, image_data) - - except Exception as e: - logger.error(f"Error updating photo for {getattr(artist, 'title', 'Unknown')}: {e}") - return False - - def update_artist_genres(self, artist, spotify_artist): - """Update artist genres from Spotify and albums - EXACT copy from dashboard.py""" - try: - # Get existing genres - existing_genres = set(genre.tag if hasattr(genre, 'tag') else str(genre) - for genre in (artist.genres or [])) - - # Get Spotify artist genres - spotify_genres = set(spotify_artist.genres or []) - - # Get genres from all albums - album_genres = set() - try: - for album in artist.albums(): - if hasattr(album, 'genres') and album.genres: - album_genres.update(genre.tag if hasattr(genre, 'tag') else str(genre) - for genre in album.genres) - except Exception: - pass # Albums might not be accessible - - # Combine all genres (prioritize Spotify genres) - all_genres = spotify_genres.union(album_genres) - - # Filter out empty/invalid genres - all_genres = {g for g in all_genres if g and g.strip() and len(g.strip()) > 1} - - # Only update if we have new genres and they're different - if all_genres and (not existing_genres or all_genres != existing_genres): - # Convert to list and limit to 10 genres - genre_list = list(all_genres)[:10] - - # Use media client API to update genres - success = self.media_client.update_artist_genres(artist, genre_list) - if success: - return True - else: - return False - else: - return False - - except Exception as e: - logger.error(f"Error updating genres for {getattr(artist, 'title', 'Unknown')}: {e}") - return False - - def update_album_artwork(self, artist, spotify_artist): - """Update album artwork for all albums by this artist from Spotify. - DB thumb_url is a media-server internal path, so album art must come from Spotify.""" - try: - updated_count = 0 - skipped_count = 0 - - # Get all albums for this artist - try: - albums = list(artist.albums()) - except Exception: - logger.error(f"Could not access albums for artist '{artist.title}'") - return 0 - - if not albums: - logger.warning(f"No albums found for artist '{artist.title}'") - return 0 - - import time - for album in albums: - try: - album_title = getattr(album, 'title', 'Unknown Album') - - # Check if album already has good artwork on the media server - if self.album_has_valid_artwork(album): - skipped_count += 1 - continue - - # Rate limit between album API calls - time.sleep(0.5) - - # Search for this specific album on Spotify - album_query = f"album:{album_title} artist:{spotify_artist.name}" - spotify_albums = self.spotify_client.search_albums(album_query, limit=3) - - if not spotify_albums: - continue - - # Find the best matching album - best_album = None - highest_score = 0.0 - - plex_album_normalized = self.matching_engine.normalize_string(album_title) - - for spotify_album in spotify_albums: - spotify_album_normalized = self.matching_engine.normalize_string(spotify_album.name) - score = self.matching_engine.similarity_score(plex_album_normalized, spotify_album_normalized) - - if score > highest_score: - highest_score = score - best_album = spotify_album - - # If we found a good match with artwork, download it - if best_album and highest_score > 0.7 and best_album.image_url: - if self.download_and_upload_album_artwork(album, best_album.image_url): - updated_count += 1 - - except Exception as e: - logger.error(f"Error processing album '{getattr(album, 'title', 'Unknown')}': {e}") - continue - - return updated_count - - except Exception as e: - logger.error(f"Error updating album artwork for artist '{getattr(artist, 'title', 'Unknown')}': {e}") - return 0 - - def album_has_valid_artwork(self, album): - """Check if album has valid artwork - EXACT copy from dashboard.py""" - try: - if not hasattr(album, 'thumb') or not album.thumb: - return False - - thumb_url = str(album.thumb) - - # Completely empty or None - if not thumb_url or thumb_url.strip() == '': - return False - - # Obvious placeholder text in URL - obvious_placeholders = ['no-image', 'placeholder', 'missing', 'default-album', 'blank.jpg', 'empty.png'] - thumb_lower = thumb_url.lower() - for placeholder in obvious_placeholders: - if placeholder in thumb_lower: - return False - - # Extremely short URLs (likely broken) - if len(thumb_url) < 20: - return False - - return True - - except Exception as e: - return True - - def download_and_upload_album_artwork(self, album, image_url): - """Download artwork from Spotify and upload to media server - EXACT copy from dashboard.py""" - try: - # Download image from Spotify - response = requests.get(image_url, timeout=10) - response.raise_for_status() - - # Validate and convert image - image_data = self.validate_and_convert_image(response.content) - if not image_data: - return False - - # Upload using media client - success = self.media_client.update_album_poster(album, image_data) - return success - - except Exception as e: - logger.error(f"Error downloading/uploading artwork for album '{getattr(album, 'title', 'Unknown')}': {e}") - return False - - def artist_has_valid_photo(self, artist): - """Check if artist has a valid photo - EXACT copy from dashboard.py""" - try: - if not hasattr(artist, 'thumb') or not artist.thumb: - return False - - thumb_url = str(artist.thumb) - if 'default' in thumb_url.lower() or len(thumb_url) < 50: - return False - - return True - - except Exception: - return False - - def validate_and_convert_image(self, image_data): - """Validate and convert image for media server compatibility - EXACT copy from dashboard.py""" - try: - from PIL import Image - import io - - # Open and validate image - image = Image.open(io.BytesIO(image_data)) - - # Check minimum dimensions - width, height = image.size - if width < 200 or height < 200: - return None - - # Convert to JPEG for consistency - if image.format != 'JPEG': - buffer = io.BytesIO() - image.convert('RGB').save(buffer, format='JPEG', quality=95) - return buffer.getvalue() - - return image_data - - except Exception: - return None - - def upload_artist_poster(self, artist, image_data): - """Upload poster using media client - EXACT copy from dashboard.py""" - try: - # Use media client's update method if available - if hasattr(self.media_client, 'update_artist_poster'): - return self.media_client.update_artist_poster(artist, image_data) - - # Fallback for Plex: direct API call - if self.server_type == "plex": - import requests - server = self.media_client.server - upload_url = f"{server._baseurl}/library/metadata/{artist.ratingKey}/posters" - headers = { - 'X-Plex-Token': server._token, - 'Content-Type': 'image/jpeg' - } - - response = requests.post(upload_url, data=image_data, headers=headers) - response.raise_for_status() - - # Refresh artist to see changes - artist.refresh() - return True - - # Jellyfin: Use Jellyfin API to upload artist image - elif self.server_type == "jellyfin": - import requests - jellyfin_config = config_manager.get_jellyfin_config() - jellyfin_base_url = jellyfin_config.get('base_url', '') - jellyfin_token = jellyfin_config.get('api_key', '') - - if not jellyfin_base_url or not jellyfin_token: - logger.warning("Jellyfin configuration missing for image upload") - return False - - upload_url = f"{jellyfin_base_url.rstrip('/')}/Items/{artist.ratingKey}/Images/Primary" - headers = { - 'Authorization': f'MediaBrowser Token="{jellyfin_token}"', - 'Content-Type': 'image/jpeg' - } - - response = requests.post(upload_url, data=image_data, headers=headers) - response.raise_for_status() - return True - - # Navidrome: Currently not supported (Subsonic API doesn't support image uploads) - elif self.server_type == "navidrome": - logger.info("ℹ️ Navidrome does not support artist image uploads via Subsonic API") - return False - - else: - # Unknown server type - return False - - except Exception as e: - logger.error(f"Error uploading poster: {e}") - return False # --- Docker Helper Functions ---