Remove emojis from all Python log and print statements

Stripped 4,200+ emoji characters from print(), logger calls across
39 Python files. Logs are now clean text — easier to grep, more
professional, no encoding issues on terminals without Unicode support.

Seasonal config icons preserved for UI display.
This commit is contained in:
Broque Thomas 2026-04-11 21:11:02 -07:00
parent faba4d5847
commit 71e4df65e3
39 changed files with 4163 additions and 4163 deletions

File diff suppressed because it is too large Load diff

View file

@ -33,7 +33,7 @@ class ConfigManager:
# Default to project path even if it doesn't exist yet (for creation/fallback) # Default to project path even if it doesn't exist yet (for creation/fallback)
self.config_path = project_path self.config_path = project_path
print(f"🔧 ConfigManager initialized with path: {self.config_path}") print(f"ConfigManager initialized with path: {self.config_path}")
self.config_data: Dict[str, Any] = {} self.config_data: Dict[str, Any] = {}
self._fernet: Optional[Fernet] = None self._fernet: Optional[Fernet] = None
@ -45,7 +45,7 @@ class ConfigManager:
else: else:
self.database_path = self.base_dir / "database" / "music_library.db" self.database_path = self.base_dir / "database" / "music_library.db"
print(f"💾 Database path set to: {self.database_path}") print(f"Database path set to: {self.database_path}")
self.load_config(str(self.config_path)) self.load_config(str(self.config_path))
@ -107,7 +107,7 @@ class ConfigManager:
try: try:
import shutil import shutil
shutil.move(str(old_key_file), str(key_file)) shutil.move(str(old_key_file), str(key_file))
print(f"[MIGRATE] 🔑 Moved encryption key to {key_file}") print(f"[MIGRATE] Moved encryption key to {key_file}")
except Exception: except Exception:
key_file = old_key_file # Fall back to old location key_file = old_key_file # Fall back to old location
if key_file.exists(): if key_file.exists():
@ -155,7 +155,7 @@ class ConfigManager:
return decrypted return decrypted
except InvalidToken: except InvalidToken:
# Key mismatch — encrypted with a different key (key file deleted/replaced) # Key mismatch — encrypted with a different key (key file deleted/replaced)
print(f"[ERROR] ⚠️ Failed to decrypt a config value — encryption key may have changed. " print(f"[ERROR] Failed to decrypt a config value — encryption key may have changed. "
f"Re-enter credentials in Settings or restore the original .encryption_key file.") f"Re-enter credentials in Settings or restore the original .encryption_key file.")
return value return value
except Exception: except Exception:
@ -243,9 +243,9 @@ class ConfigManager:
needs_migration = True needs_migration = True
break break
if needs_migration: if needs_migration:
print("[MIGRATE] 🔐 Encrypting sensitive config values at rest...") print("[MIGRATE] Encrypting sensitive config values at rest...")
self._save_to_database(self.config_data) self._save_to_database(self.config_data)
print("[OK] Sensitive config values encrypted successfully") print("[OK] Sensitive config values encrypted successfully")
except Exception as e: except Exception as e:
print(f"[WARN] Could not migrate encryption: {e}") print(f"[WARN] Could not migrate encryption: {e}")
@ -505,7 +505,7 @@ class ConfigManager:
2. config.json (migration from file-based config) 2. config.json (migration from file-based config)
3. Defaults (fresh install) 3. Defaults (fresh install)
""" """
print(f"📥 Loading configuration...") print(f"Loading configuration...")
# Try loading from database first # Try loading from database first
config_data = self._load_from_database() config_data = self._load_from_database()
@ -518,18 +518,18 @@ class ConfigManager:
return return
# Database is empty - try migration from config.json # Database is empty - try migration from config.json
print(f"⚠️ Configuration not found in database. Attempting migration from: {self.config_path}") print(f"Configuration not found in database. Attempting migration from: {self.config_path}")
config_data = self._load_from_config_file() config_data = self._load_from_config_file()
if config_data: if config_data:
# Migrate from config.json to database # Migrate from config.json to database
print("[MIGRATE] 🚀 Migrating configuration from config.json to database...") print("[MIGRATE] Migrating configuration from config.json to database...")
if self._save_to_database(config_data): if self._save_to_database(config_data):
print("[OK] Configuration migrated successfully to database.") print("[OK] Configuration migrated successfully to database.")
self.config_data = config_data self.config_data = config_data
return return
else: else:
print("[WARN] ⚠️ Migration failed - using file-based config temporarily.") print("[WARN] Migration failed - using file-based config temporarily.")
self.config_data = config_data self.config_data = config_data
return return
@ -539,9 +539,9 @@ class ConfigManager:
# Try to save defaults to database # Try to save defaults to database
if self._save_to_database(config_data): if self._save_to_database(config_data):
print("[OK] Default configuration saved to database") print("[OK] Default configuration saved to database")
else: else:
print("[WARN] ⚠️ Could not save defaults to database - using in-memory config") print("[WARN] Could not save defaults to database - using in-memory config")
self.config_data = config_data self.config_data = config_data

View file

@ -143,7 +143,7 @@ class DatabaseUpdateWorker(QThread):
else: else:
freed_items = "unknown" freed_items = "unknown"
self.media_client.clear_cache() self.media_client.clear_cache()
logger.info(f"🧹 Cleared {self.server_type} cache after user stop - freed ~{freed_items} items from memory") logger.info(f"Cleared {self.server_type} cache after user stop - freed ~{freed_items} items from memory")
except Exception as e: except Exception as e:
logger.warning(f"Could not clear {self.server_type} cache on stop: {e}") logger.warning(f"Could not clear {self.server_type} cache on stop: {e}")
@ -168,7 +168,7 @@ class DatabaseUpdateWorker(QThread):
# Connect Navidrome client progress to UI # Connect Navidrome client progress to UI
if hasattr(self.media_client, 'set_progress_callback'): if hasattr(self.media_client, 'set_progress_callback'):
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg)) self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg))
logger.info("Connected Navidrome progress callback") logger.info("Connected Navidrome progress callback")
# For full refresh, get all artists # For full refresh, get all artists
artists_to_process = self._get_all_artists() artists_to_process = self._get_all_artists()
@ -189,7 +189,7 @@ class DatabaseUpdateWorker(QThread):
merge_results = self.database.merge_duplicate_artists() merge_results = self.database.merge_duplicate_artists()
merged = merge_results.get('artists_merged', 0) merged = merge_results.get('artists_merged', 0)
if merged > 0: if merged > 0:
logger.info(f"🧹 Merged {merged} duplicate artists") logger.info(f"Merged {merged} duplicate artists")
except Exception as e: except Exception as e:
logger.warning(f"Could not merge duplicate artists: {e}") logger.warning(f"Could not merge duplicate artists: {e}")
self._emit_signal('finished', 0, 0, 0, 0, 0) self._emit_signal('finished', 0, 0, 0, 0, 0)
@ -204,7 +204,7 @@ class DatabaseUpdateWorker(QThread):
self._process_jellyfin_new_tracks_directly(artists_to_process) self._process_jellyfin_new_tracks_directly(artists_to_process)
else: else:
# Standard artist processing for Plex or full refresh # Standard artist processing for Plex or full refresh
logger.info(f"🎯 About to process {len(artists_to_process) if artists_to_process else 0} artists for {self.server_type}") logger.info(f"About to process {len(artists_to_process) if artists_to_process else 0} artists for {self.server_type}")
self._process_all_artists(artists_to_process) self._process_all_artists(artists_to_process)
# Record full refresh completion for tracking purposes # Record full refresh completion for tracking purposes
@ -224,7 +224,7 @@ class DatabaseUpdateWorker(QThread):
else: else:
freed_items = "cache data" freed_items = "cache data"
self.media_client.clear_cache() self.media_client.clear_cache()
logger.info(f"🧹 Cleared {self.server_type} cache after full refresh - freed ~{freed_items} items from memory") logger.info(f"Cleared {self.server_type} cache after full refresh - freed ~{freed_items} items from memory")
except Exception as e: except Exception as e:
logger.warning(f"Could not clear {self.server_type} cache: {e}") logger.warning(f"Could not clear {self.server_type} cache: {e}")
@ -240,7 +240,7 @@ class DatabaseUpdateWorker(QThread):
r_albums = removal_results.get('albums_removed', 0) r_albums = removal_results.get('albums_removed', 0)
r_tracks = removal_results.get('tracks_removed', 0) r_tracks = removal_results.get('tracks_removed', 0)
if r_artists > 0 or r_albums > 0: if r_artists > 0 or r_albums > 0:
logger.info(f"🗑️ Removal detection: {r_artists} artists, " logger.info(f"Removal detection: {r_artists} artists, "
f"{r_albums} albums, {r_tracks} tracks removed") f"{r_albums} albums, {r_tracks} tracks removed")
except Exception as e: except Exception as e:
logger.warning(f"Removal detection failed (non-fatal): {e}") logger.warning(f"Removal detection failed (non-fatal): {e}")
@ -253,9 +253,9 @@ class DatabaseUpdateWorker(QThread):
orphaned_albums = cleanup_results.get('orphaned_albums_removed', 0) orphaned_albums = cleanup_results.get('orphaned_albums_removed', 0)
if orphaned_artists > 0 or orphaned_albums > 0: if orphaned_artists > 0 or orphaned_albums > 0:
logger.info(f"🧹 Cleanup complete: {orphaned_artists} orphaned artists, {orphaned_albums} orphaned albums removed") logger.info(f"Cleanup complete: {orphaned_artists} orphaned artists, {orphaned_albums} orphaned albums removed")
else: else:
logger.debug("🧹 Cleanup complete: No orphaned records found") logger.debug("Cleanup complete: No orphaned records found")
except Exception as e: except Exception as e:
logger.warning(f"Could not cleanup orphaned records: {e}") logger.warning(f"Could not cleanup orphaned records: {e}")
@ -265,7 +265,7 @@ class DatabaseUpdateWorker(QThread):
merge_results = self.database.merge_duplicate_artists() merge_results = self.database.merge_duplicate_artists()
merged = merge_results.get('artists_merged', 0) merged = merge_results.get('artists_merged', 0)
if merged > 0: if merged > 0:
logger.info(f"🧹 Merged {merged} duplicate artists") logger.info(f"Merged {merged} duplicate artists")
except Exception as e: except Exception as e:
logger.warning(f"Could not merge duplicate artists: {e}") logger.warning(f"Could not merge duplicate artists: {e}")
@ -437,9 +437,9 @@ class DatabaseUpdateWorker(QThread):
logger.error(f"Could not connect to {self.server_type} server — check URL, credentials, and network (Docker users: use container name or host.docker.internal instead of host IP)") logger.error(f"Could not connect to {self.server_type} server — check URL, credentials, and network (Docker users: use container name or host.docker.internal instead of host IP)")
return [] return []
logger.info(f"🎯 _get_all_artists: Calling media_client.get_all_artists() for {self.server_type}") logger.info(f"_get_all_artists: Calling media_client.get_all_artists() for {self.server_type}")
artists = self.media_client.get_all_artists() artists = self.media_client.get_all_artists()
logger.info(f"🎯 _get_all_artists: Received {len(artists) if artists else 0} artists from {self.server_type}") logger.info(f"_get_all_artists: Received {len(artists) if artists else 0} artists from {self.server_type}")
return artists return artists
except Exception as e: except Exception as e:
@ -581,9 +581,9 @@ class DatabaseUpdateWorker(QThread):
if not track_exists: if not track_exists:
missing_tracks_count += 1 missing_tracks_count += 1
album_has_new_tracks = True album_has_new_tracks = True
logger.debug(f"📀 Track '{track_title}' is new - album needs processing") logger.debug(f"Track '{track_title}' is new - album needs processing")
else: else:
logger.debug(f"Track '{track_title}' already exists") logger.debug(f"Track '{track_title}' already exists")
except Exception as track_error: except Exception as track_error:
logger.debug(f"Error checking individual track: {track_error}") logger.debug(f"Error checking individual track: {track_error}")
@ -595,23 +595,23 @@ class DatabaseUpdateWorker(QThread):
if album_has_new_tracks: if album_has_new_tracks:
albums_with_new_content += 1 albums_with_new_content += 1
consecutive_complete_albums = 0 # Reset counter consecutive_complete_albums = 0 # Reset counter
logger.info(f"📀 Album '{album_title}' has {missing_tracks_count} new tracks - needs processing") logger.info(f"Album '{album_title}' has {missing_tracks_count} new tracks - needs processing")
else: else:
# Check if existing tracks have metadata changes (catches Plex corrections) # Check if existing tracks have metadata changes (catches Plex corrections)
metadata_changed = self._check_for_metadata_changes(tracks) metadata_changed = self._check_for_metadata_changes(tracks)
if metadata_changed: if metadata_changed:
albums_with_new_content += 1 albums_with_new_content += 1
consecutive_complete_albums = 0 # Reset counter consecutive_complete_albums = 0 # Reset counter
logger.info(f"🔄 Album '{album_title}' has metadata changes - needs processing") logger.info(f"Album '{album_title}' has metadata changes - needs processing")
album_has_new_tracks = True # Mark for artist processing album_has_new_tracks = True # Mark for artist processing
else: else:
consecutive_complete_albums += 1 consecutive_complete_albums += 1
logger.debug(f"Album '{album_title}' is fully up-to-date (consecutive complete: {consecutive_complete_albums})") logger.debug(f"Album '{album_title}' is fully up-to-date (consecutive complete: {consecutive_complete_albums})")
# Very conservative stopping criteria: 25 consecutive complete albums after metadata fixes # Very conservative stopping criteria: 25 consecutive complete albums after metadata fixes
# This ensures we don't miss scattered updated content from manual corrections # This ensures we don't miss scattered updated content from manual corrections
if consecutive_complete_albums >= 25: if consecutive_complete_albums >= 25:
logger.info(f"🛑 Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums") logger.info(f"Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
stopped_early = True stopped_early = True
break break
@ -633,7 +633,7 @@ class DatabaseUpdateWorker(QThread):
if artist_id not in processed_artist_ids: if artist_id not in processed_artist_ids:
processed_artist_ids.add(artist_id) processed_artist_ids.add(artist_id)
artists_to_process.append(album_artist) artists_to_process.append(album_artist)
logger.info(f"Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)") logger.info(f"Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
except Exception as artist_error: except Exception as artist_error:
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}") logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
@ -649,7 +649,7 @@ class DatabaseUpdateWorker(QThread):
else: else:
result_msg += f" (checked all {total_tracks_checked} tracks from {len(recent_albums)} recent albums)" result_msg += f" (checked all {total_tracks_checked} tracks from {len(recent_albums)} recent albums)"
logger.info(f"📊 Incremental scan stats: {len(recent_albums)} recent albums examined, {albums_with_new_content} needed processing") logger.info(f"Incremental scan stats: {len(recent_albums)} recent albums examined, {albums_with_new_content} needed processing")
logger.info(result_msg) logger.info(result_msg)
return artists_to_process return artists_to_process
@ -662,7 +662,7 @@ class DatabaseUpdateWorker(QThread):
def _get_artists_for_navidrome_incremental_update(self) -> List: def _get_artists_for_navidrome_incremental_update(self) -> List:
"""Get artists for Navidrome incremental update using smart early-stopping logic like Plex/Jellyfin""" """Get artists for Navidrome incremental update using smart early-stopping logic like Plex/Jellyfin"""
try: try:
logger.info("🎵 Navidrome incremental: Getting recent albums and checking for new content...") logger.info("Navidrome incremental: Getting recent albums and checking for new content...")
# Get recent albums from Navidrome (use the generic method that calls Navidrome-specific logic) # Get recent albums from Navidrome (use the generic method that calls Navidrome-specific logic)
recent_albums = self._get_recent_albums_for_server() recent_albums = self._get_recent_albums_for_server()
@ -720,11 +720,11 @@ class DatabaseUpdateWorker(QThread):
# If no new tracks found, increment consecutive complete counter # If no new tracks found, increment consecutive complete counter
if not album_has_new_tracks: if not album_has_new_tracks:
consecutive_complete_albums += 1 consecutive_complete_albums += 1
logger.debug(f"Album '{album_title}' is up-to-date (consecutive: {consecutive_complete_albums})") logger.debug(f"Album '{album_title}' is up-to-date (consecutive: {consecutive_complete_albums})")
# Early stopping after 25 consecutive complete albums (same as Plex/Jellyfin) # Early stopping after 25 consecutive complete albums (same as Plex/Jellyfin)
if consecutive_complete_albums >= 25: if consecutive_complete_albums >= 25:
logger.info(f"🛑 Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums") logger.info(f"Found 25 consecutive complete albums - stopping incremental scan after checking {total_tracks_checked} tracks from {i+1} albums")
break break
except Exception as tracks_error: except Exception as tracks_error:
@ -744,7 +744,7 @@ class DatabaseUpdateWorker(QThread):
if artist_id not in processed_artist_ids: if artist_id not in processed_artist_ids:
processed_artist_ids.add(artist_id) processed_artist_ids.add(artist_id)
artists_to_process.append(album_artist) artists_to_process.append(album_artist)
logger.info(f"Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)") logger.info(f"Added artist '{album_artist.title}' for processing (from album '{album_title}' with new tracks)")
except Exception as artist_error: except Exception as artist_error:
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}") logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
@ -753,7 +753,7 @@ class DatabaseUpdateWorker(QThread):
consecutive_complete_albums = 0 # Reset on error consecutive_complete_albums = 0 # Reset on error
continue continue
logger.info(f"🎵 Navidrome incremental complete: {len(artists_to_process)} artists need processing (checked {total_tracks_checked} tracks from {len(recent_albums)} recent albums)") logger.info(f"Navidrome incremental complete: {len(artists_to_process)} artists need processing (checked {total_tracks_checked} tracks from {len(recent_albums)} recent albums)")
return artists_to_process return artists_to_process
except Exception as e: except Exception as e:
@ -763,7 +763,7 @@ class DatabaseUpdateWorker(QThread):
def _get_artists_for_jellyfin_track_incremental_update(self) -> List: def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)""" """FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
try: try:
logger.info("🚀 FAST Jellyfin incremental: getting recent tracks directly...") logger.info("FAST Jellyfin incremental: getting recent tracks directly...")
# Get recent tracks directly from Jellyfin (FAST - 2 API calls) # Get recent tracks directly from Jellyfin (FAST - 2 API calls)
recent_added_tracks = self.media_client.get_recently_added_tracks(5000) recent_added_tracks = self.media_client.get_recently_added_tracks(5000)
@ -799,14 +799,14 @@ class DatabaseUpdateWorker(QThread):
if not track_exists: if not track_exists:
new_tracks.append(track) new_tracks.append(track)
consecutive_existing_tracks = 0 # Reset counter consecutive_existing_tracks = 0 # Reset counter
logger.debug(f"🎵 New track: {track.title}") logger.debug(f"New track: {track.title}")
else: else:
consecutive_existing_tracks += 1 consecutive_existing_tracks += 1
logger.debug(f"Track exists: {track.title}") logger.debug(f"Track exists: {track.title}")
# Early stopping: if we find 100 consecutive existing tracks, we're done # Early stopping: if we find 100 consecutive existing tracks, we're done
if consecutive_existing_tracks >= 100: if consecutive_existing_tracks >= 100:
logger.info(f"🛑 Found 100 consecutive existing tracks - stopping after checking {i+1} tracks") logger.info(f"Found 100 consecutive existing tracks - stopping after checking {i+1} tracks")
break break
except Exception as e: except Exception as e:
@ -833,12 +833,12 @@ class DatabaseUpdateWorker(QThread):
if artist_id not in processed_artists: if artist_id not in processed_artists:
processed_artists.add(artist_id) processed_artists.add(artist_id)
artists_to_process.append(track_artist) artists_to_process.append(track_artist)
logger.info(f"Added artist '{track_artist.title}' (from new track '{track.title}')") logger.info(f"Added artist '{track_artist.title}' (from new track '{track.title}')")
except Exception as e: except Exception as e:
logger.debug(f"Error getting artist for track {getattr(track, 'title', 'Unknown')}: {e}") logger.debug(f"Error getting artist for track {getattr(track, 'title', 'Unknown')}: {e}")
continue continue
logger.info(f"🚀 FAST incremental complete: {len(artists_to_process)} artists need processing (from {len(new_tracks)} new tracks)") logger.info(f"FAST incremental complete: {len(artists_to_process)} artists need processing (from {len(new_tracks)} new tracks)")
return artists_to_process return artists_to_process
except Exception as e: except Exception as e:
@ -853,7 +853,7 @@ class DatabaseUpdateWorker(QThread):
logger.warning("No new tracks to process directly") logger.warning("No new tracks to process directly")
return return
logger.info(f"🚀 FAST PROCESSING: Directly processing {len(new_tracks)} new tracks...") logger.info(f"FAST PROCESSING: Directly processing {len(new_tracks)} new tracks...")
# Group tracks by album and artist for efficient processing # Group tracks by album and artist for efficient processing
tracks_by_album = {} tracks_by_album = {}
@ -921,7 +921,7 @@ class DatabaseUpdateWorker(QThread):
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type) track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
if track_success: if track_success:
total_processed_tracks += 1 total_processed_tracks += 1
logger.debug(f"Processed new track: {track.title}") logger.debug(f"Processed new track: {track.title}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}") logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
except Exception as e: except Exception as e:
@ -943,7 +943,7 @@ class DatabaseUpdateWorker(QThread):
self.processed_tracks += total_processed_tracks self.processed_tracks += total_processed_tracks
self.successful_operations += total_processed_artists # Count successful artists self.successful_operations += total_processed_artists # Count successful artists
logger.info(f"🚀 FAST PROCESSING COMPLETE: {total_processed_artists} artists, {total_processed_albums} albums, {total_processed_tracks} tracks") logger.info(f"FAST PROCESSING COMPLETE: {total_processed_artists} artists, {total_processed_albums} albums, {total_processed_tracks} tracks")
# Clean up # Clean up
delattr(self, '_jellyfin_new_tracks') delattr(self, '_jellyfin_new_tracks')
@ -976,7 +976,7 @@ class DatabaseUpdateWorker(QThread):
if (db_track.title != current_title or if (db_track.title != current_title or
db_track.artist_name != current_artist or db_track.artist_name != current_artist or
db_track.album_title != current_album): db_track.album_title != current_album):
logger.debug(f"🔄 Metadata change detected for track ID {track_id}:") logger.debug(f"Metadata change detected for track ID {track_id}:")
logger.debug(f" Title: '{db_track.title}''{current_title}'") logger.debug(f" Title: '{db_track.title}''{current_title}'")
logger.debug(f" Artist: '{db_track.artist_name}''{current_artist}'") logger.debug(f" Artist: '{db_track.artist_name}''{current_artist}'")
logger.debug(f" Album: '{db_track.album_title}''{current_album}'") logger.debug(f" Album: '{db_track.album_title}''{current_album}'")
@ -987,7 +987,7 @@ class DatabaseUpdateWorker(QThread):
continue continue
if changes_detected > 0: if changes_detected > 0:
logger.info(f"🔄 Found {changes_detected} tracks with metadata changes") logger.info(f"Found {changes_detected} tracks with metadata changes")
return True return True
return False return False
@ -1014,7 +1014,7 @@ class DatabaseUpdateWorker(QThread):
return None return None
# Fetch current IDs from media server (lightweight calls) # Fetch current IDs from media server (lightweight calls)
logger.info(f"🔍 Removal detection: fetching current IDs from {self.server_type}...") logger.info(f"Removal detection: fetching current IDs from {self.server_type}...")
self._emit_signal('phase_changed', f"Fetching artist catalog from {self.server_type}...") self._emit_signal('phase_changed', f"Fetching artist catalog from {self.server_type}...")
server_artist_ids = self.media_client.get_all_artist_ids() server_artist_ids = self.media_client.get_all_artist_ids()
self._emit_signal('phase_changed', f"Fetching album catalog from {self.server_type}...") self._emit_signal('phase_changed', f"Fetching album catalog from {self.server_type}...")
@ -1022,7 +1022,7 @@ class DatabaseUpdateWorker(QThread):
# Safety: if both come back empty, the server is unreachable # Safety: if both come back empty, the server is unreachable
if not server_artist_ids and not server_album_ids: if not server_artist_ids and not server_album_ids:
logger.warning("🛡️ SAFETY: Server returned zero artists AND zero albums — " logger.warning("SAFETY: Server returned zero artists AND zero albums — "
"skipping removal detection") "skipping removal detection")
return None return None
@ -1044,19 +1044,19 @@ class DatabaseUpdateWorker(QThread):
if check_artists and db_artist_count > 100: if check_artists and db_artist_count > 100:
if len(server_artist_ids) < db_artist_count * 0.5: if len(server_artist_ids) < db_artist_count * 0.5:
logger.warning( logger.warning(
f"🛡️ SAFETY: Server reported {len(server_artist_ids)} artists but " f"SAFETY: Server reported {len(server_artist_ids)} artists but "
f"database has {db_artist_count} — skipping artist removal check") f"database has {db_artist_count} — skipping artist removal check")
check_artists = False check_artists = False
if check_albums and db_album_count > 100: if check_albums and db_album_count > 100:
if len(server_album_ids) < db_album_count * 0.5: if len(server_album_ids) < db_album_count * 0.5:
logger.warning( logger.warning(
f"🛡️ SAFETY: Server reported {len(server_album_ids)} albums but " f"SAFETY: Server reported {len(server_album_ids)} albums but "
f"database has {db_album_count} — skipping album removal check") f"database has {db_album_count} — skipping album removal check")
check_albums = False check_albums = False
if not check_artists and not check_albums: if not check_artists and not check_albums:
logger.warning("🛡️ SAFETY: Both artist and album checks disabled — " logger.warning("SAFETY: Both artist and album checks disabled — "
"skipping removal detection") "skipping removal detection")
return None return None
@ -1090,11 +1090,11 @@ class DatabaseUpdateWorker(QThread):
pass # If this optimization fails, double-delete is harmless pass # If this optimization fails, double-delete is harmless
if not removed_artist_ids and not removed_album_ids: if not removed_artist_ids and not removed_album_ids:
logger.info("🔍 Removal detection: no stale content found") logger.info("Removal detection: no stale content found")
self._emit_signal('phase_changed', "No removed content detected") self._emit_signal('phase_changed', "No removed content detected")
return {'artists_removed': 0, 'albums_removed': 0, 'tracks_removed': 0} return {'artists_removed': 0, 'albums_removed': 0, 'tracks_removed': 0}
logger.info(f"🗑️ Removal detection: found {len(removed_artist_ids)} removed artists, " logger.info(f"Removal detection: found {len(removed_artist_ids)} removed artists, "
f"{len(removed_album_ids)} removed albums") f"{len(removed_album_ids)} removed albums")
self._emit_signal('phase_changed', self._emit_signal('phase_changed',
@ -1219,7 +1219,7 @@ class DatabaseUpdateWorker(QThread):
def _process_all_artists(self, artists: List): def _process_all_artists(self, artists: List):
"""Process all artists and their albums/tracks using thread pool""" """Process all artists and their albums/tracks using thread pool"""
total_artists = len(artists) total_artists = len(artists)
logger.info(f"🎯 Processing {total_artists} artists with progress tracking") logger.info(f"Processing {total_artists} artists with progress tracking")
def process_single_artist(artist): def process_single_artist(artist):
"""Process a single artist and return results""" """Process a single artist and return results"""
@ -1240,7 +1240,7 @@ class DatabaseUpdateWorker(QThread):
total_artists, total_artists,
progress_percent progress_percent
) )
logger.debug(f"🔄 Progress: {self.processed_artists}/{total_artists} ({progress_percent:.1f}%) - {artist_name}") logger.debug(f"Progress: {self.processed_artists}/{total_artists} ({progress_percent:.1f}%) - {artist_name}")
# Process the artist # Process the artist
success, details, album_count, track_count = self._process_artist_with_content(artist) success, details, album_count, track_count = self._process_artist_with_content(artist)

View file

@ -51,7 +51,7 @@ class DownloadOrchestrator:
self.lidarr = self._safe_init('Lidarr', LidarrDownloadClient) self.lidarr = self._safe_init('Lidarr', LidarrDownloadClient)
if self._init_failures: if self._init_failures:
logger.warning(f"⚠️ Download clients failed to initialize: {', '.join(self._init_failures)}") logger.warning(f"Download clients failed to initialize: {', '.join(self._init_failures)}")
# Load mode from config # Load mode from config
self.mode = config_manager.get('download_source.mode', 'soulseek') self.mode = config_manager.get('download_source.mode', 'soulseek')
@ -59,7 +59,7 @@ class DownloadOrchestrator:
self.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube') self.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube')
self.hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) self.hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
logger.info(f"🎛️ Download Orchestrator initialized - Mode: {self.mode}") logger.info(f"Download Orchestrator initialized - Mode: {self.mode}")
if self.mode == 'hybrid': if self.mode == 'hybrid':
if self.hybrid_order: if self.hybrid_order:
logger.info(f" Source priority: {''.join(self.hybrid_order)}") logger.info(f" Source priority: {''.join(self.hybrid_order)}")
@ -71,7 +71,7 @@ class DownloadOrchestrator:
try: try:
return cls() return cls()
except Exception as e: except Exception as e:
logger.error(f"{name} download client failed to initialize: {e}") logger.error(f"{name} download client failed to initialize: {e}")
self._init_failures.append(name) self._init_failures.append(name)
return None return None
@ -85,7 +85,7 @@ class DownloadOrchestrator:
# Reload underlying client configs (SLSKD URL, API key, etc.) # Reload underlying client configs (SLSKD URL, API key, etc.)
if self.soulseek: if self.soulseek:
self.soulseek._setup_client() self.soulseek._setup_client()
logger.info(f"🔄 Soulseek client config reloaded") logger.info(f"Soulseek client config reloaded")
# Reconnect Deezer if ARL changed # Reconnect Deezer if ARL changed
deezer_arl = config_manager.get('deezer_download.arl', '') deezer_arl = config_manager.get('deezer_download.arl', '')
@ -93,7 +93,7 @@ class DownloadOrchestrator:
self.deezer_dl.reconnect(deezer_arl) self.deezer_dl.reconnect(deezer_arl)
self.deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac') self.deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac')
logger.info(f"🔄 Download Orchestrator settings reloaded - Mode: {self.mode}") logger.info(f"Download Orchestrator settings reloaded - Mode: {self.mode}")
def _client(self, name): def _client(self, name):
"""Get a client by name, returning None if not initialized.""" """Get a client by name, returning None if not initialized."""
@ -143,7 +143,7 @@ class DownloadOrchestrator:
except Exception: except Exception:
results[source] = False results[source] = False
status_parts = [f"{s}: {'' if ok else ''}" for s, ok in results.items()] status_parts = [f"{s}: {'' if ok else ''}" for s, ok in results.items()]
logger.info(f" {' | '.join(status_parts)}") logger.info(f" {' | '.join(status_parts)}")
return any(results.values()) return any(results.values())
@ -168,9 +168,9 @@ class DownloadOrchestrator:
if self.mode != 'hybrid': if self.mode != 'hybrid':
client = self._client(self.mode) client = self._client(self.mode)
if not client: if not client:
logger.error(f"{source_names.get(self.mode, self.mode)} client not available (failed to initialize)") logger.error(f"{source_names.get(self.mode, self.mode)} client not available (failed to initialize)")
return [], [] return [], []
logger.info(f"🔍 Searching {source_names.get(self.mode, self.mode)}: {query}") logger.info(f"Searching {source_names.get(self.mode, self.mode)}: {query}")
return await client.search(query, timeout, progress_callback) return await client.search(query, timeout, progress_callback)
elif self.mode == 'hybrid': elif self.mode == 'hybrid':
@ -189,33 +189,33 @@ class DownloadOrchestrator:
if not source_order: if not source_order:
source_order = ['soulseek'] source_order = ['soulseek']
logger.info(f"🔍 Hybrid search ({''.join(source_order)}): {query}") logger.info(f"Hybrid search ({''.join(source_order)}): {query}")
# Try each source in priority order (skip unconfigured/unavailable ones) # Try each source in priority order (skip unconfigured/unavailable ones)
for i, source_name in enumerate(source_order): for i, source_name in enumerate(source_order):
client = clients.get(source_name) client = clients.get(source_name)
if not client: if not client:
logger.info(f"⏭️ Skipping {source_name} (not available)") logger.info(f"Skipping {source_name} (not available)")
continue continue
if hasattr(client, 'is_configured') and not client.is_configured(): if hasattr(client, 'is_configured') and not client.is_configured():
logger.info(f"⏭️ Skipping {source_name} (not configured)") logger.info(f"Skipping {source_name} (not configured)")
continue continue
try: try:
if i == 0: if i == 0:
logger.info(f"🔍 Trying {source_name} (priority {i+1}): {query}") logger.info(f"Trying {source_name} (priority {i+1}): {query}")
else: else:
logger.info(f"🔄 Trying {source_name} (priority {i+1}): {query}") logger.info(f"Trying {source_name} (priority {i+1}): {query}")
tracks, albums = await client.search(query, timeout, progress_callback) tracks, albums = await client.search(query, timeout, progress_callback)
if tracks: if tracks:
logger.info(f"{source_name} found {len(tracks)} tracks") logger.info(f"{source_name} found {len(tracks)} tracks")
return (tracks, albums) return (tracks, albums)
except Exception as e: except Exception as e:
logger.warning(f"⚠️ {source_name} search failed: {e}") logger.warning(f"{source_name} search failed: {e}")
# Nothing found from any source # Nothing found from any source
logger.warning(f"Hybrid search: all sources ({', '.join(source_order)}) found nothing for: {query}") logger.warning(f"Hybrid search: all sources ({', '.join(source_order)}) found nothing for: {query}")
return ([], []) return ([], [])
# Fallback: empty results # Fallback: empty results
@ -289,10 +289,10 @@ class DownloadOrchestrator:
if scored: if scored:
scored.sort(key=lambda x: x._match_confidence, reverse=True) scored.sort(key=lambda x: x._match_confidence, reverse=True)
filtered_results = scored filtered_results = scored
logger.info(f"🎵 Streaming validation: {len(scored)}/{len(tracks)} passed " logger.info(f"Streaming validation: {len(scored)}/{len(tracks)} passed "
f"(best: {scored[0]._match_confidence:.2f})") f"(best: {scored[0]._match_confidence:.2f})")
else: else:
logger.warning(f"⚠️ No streaming results passed validation for: {query}") logger.warning(f"No streaming results passed validation for: {query}")
return None return None
elif is_streaming: elif is_streaming:
filtered_results = tracks filtered_results = tracks
@ -337,12 +337,12 @@ class DownloadOrchestrator:
client = source_map[username] client = source_map[username]
if not client: if not client:
raise RuntimeError(f"{source_names[username]} download client not available (failed to initialize)") raise RuntimeError(f"{source_names[username]} download client not available (failed to initialize)")
logger.info(f"📥 Downloading from {source_names[username]}: {filename}") logger.info(f"Downloading from {source_names[username]}: {filename}")
return await client.download(username, filename, file_size) return await client.download(username, filename, file_size)
else: else:
if not self.soulseek: if not self.soulseek:
raise RuntimeError("Soulseek client not available (failed to initialize)") raise RuntimeError("Soulseek client not available (failed to initialize)")
logger.info(f"📥 Downloading from Soulseek: {filename}") logger.info(f"Downloading from Soulseek: {filename}")
return await self.soulseek.download(username, filename, file_size) return await self.soulseek.download(username, filename, file_size)
async def get_all_downloads(self) -> List[DownloadStatus]: async def get_all_downloads(self) -> List[DownloadStatus]:

View file

@ -1053,7 +1053,7 @@ class iTunesClient:
logger.debug(f"Replacing clean version with explicit: {album.name} (verified {track_count} tracks)") logger.debug(f"Replacing clean version with explicit: {album.name} (verified {track_count} tracks)")
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit} seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
else: else:
logger.warning(f"⚠️ Skipping broken explicit album {album.name} (ID {album.id}): reports tracks but has 0") logger.warning(f"Skipping broken explicit album {album.name} (ID {album.id}): reports tracks but has 0")
except Exception as e: except Exception as e:
logger.warning(f"Failed to validate explicit album {album.name}: {e}, keeping clean version") logger.warning(f"Failed to validate explicit album {album.name}: {e}, keeping clean version")
else: else:
@ -1072,7 +1072,7 @@ class iTunesClient:
logger.debug(f" Verified explicit album has {track_count} tracks") logger.debug(f" Verified explicit album has {track_count} tracks")
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit} seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
else: else:
logger.warning(f"⚠️ Skipping broken explicit album {album.name} (ID {album.id}): reports tracks but has 0") logger.warning(f"Skipping broken explicit album {album.name} (ID {album.id}): reports tracks but has 0")
# Don't add to seen_albums so a clean version can be added later # Don't add to seen_albums so a clean version can be added later
except Exception as e: except Exception as e:
logger.warning(f"Failed to validate explicit album {album.name}: {e}, skipping") logger.warning(f"Failed to validate explicit album {album.name}: {e}, skipping")

View file

@ -176,7 +176,7 @@ class JellyfinClient:
self.music_library_id = None self.music_library_id = None
self._connection_attempted = False self._connection_attempted = False
self.clear_cache() self.clear_cache()
logger.info("🔄 Jellyfin client config reset — will reconnect with new settings") logger.info("Jellyfin client config reset — will reconnect with new settings")
def ensure_connection(self) -> bool: def ensure_connection(self) -> bool:
"""Ensure connection to Jellyfin server with lazy initialization.""" """Ensure connection to Jellyfin server with lazy initialization."""
@ -493,17 +493,17 @@ class JellyfinClient:
# Check if we're in metadata-only mode and skip expensive operations # Check if we're in metadata-only mode and skip expensive operations
if self._metadata_only_mode: if self._metadata_only_mode:
logger.info("🎯 Skipping cache population for metadata-only operation") logger.info("Skipping cache population for metadata-only operation")
self._cache_populated = True self._cache_populated = True
return return
logger.info("🚀 Starting aggressive Jellyfin cache population to eliminate slow individual API calls...") logger.info("Starting aggressive Jellyfin cache population to eliminate slow individual API calls...")
if self._progress_callback: if self._progress_callback:
self._progress_callback("Fetching all tracks in bulk...") self._progress_callback("Fetching all tracks in bulk...")
try: try:
# SIMPLIFIED APPROACH: Fetch all tracks, then all albums separately (robust and fast) # SIMPLIFIED APPROACH: Fetch all tracks, then all albums separately (robust and fast)
logger.info("🎵 Fetching all tracks in bulk...") logger.info("Fetching all tracks in bulk...")
all_tracks = [] all_tracks = []
start_index = 0 start_index = 0
limit = 10000 limit = 10000
@ -530,13 +530,13 @@ class JellyfinClient:
if limit > 1000: if limit > 1000:
limit = limit // 2 limit = limit // 2
consecutive_failures = 0 # Reset — give the smaller batch a fair chance consecutive_failures = 0 # Reset — give the smaller batch a fair chance
logger.warning(f"⚠️ Track fetch failed - reducing batch size to {limit}") logger.warning(f"Track fetch failed - reducing batch size to {limit}")
continue continue
elif consecutive_failures >= 2: elif consecutive_failures >= 2:
logger.warning("🚨 Multiple track fetch failures at minimum batch size - stopping") logger.warning("Multiple track fetch failures at minimum batch size - stopping")
break break
else: else:
logger.warning("⚠️ Track fetch failed at minimum batch size - retrying once") logger.warning("Track fetch failed at minimum batch size - retrying once")
continue continue
consecutive_failures = 0 consecutive_failures = 0
@ -551,7 +551,7 @@ class JellyfinClient:
start_index += limit start_index += limit
progress_msg = f"Fetched {len(all_tracks)} tracks so far..." progress_msg = f"Fetched {len(all_tracks)} tracks so far..."
logger.info(f" 🎵 {progress_msg} (batch size: {limit})") logger.info(f" {progress_msg} (batch size: {limit})")
if self._progress_callback: if self._progress_callback:
self._progress_callback(progress_msg) self._progress_callback(progress_msg)
@ -564,12 +564,12 @@ class JellyfinClient:
self._track_cache[album_id] = [] self._track_cache[album_id] = []
self._track_cache[album_id].append(JellyfinTrack(track_data, self)) self._track_cache[album_id].append(JellyfinTrack(track_data, self))
logger.info(f"Cached {len(all_tracks)} tracks for {len(self._track_cache)} albums") logger.info(f"Cached {len(all_tracks)} tracks for {len(self._track_cache)} albums")
if self._progress_callback: if self._progress_callback:
self._progress_callback(f"Cached {len(all_tracks)} tracks. Now fetching albums...") self._progress_callback(f"Cached {len(all_tracks)} tracks. Now fetching albums...")
# STEP 2: Fetch all albums in bulk (same proven pattern) # STEP 2: Fetch all albums in bulk (same proven pattern)
logger.info("📀 Fetching all albums in bulk...") logger.info("Fetching all albums in bulk...")
all_albums = [] all_albums = []
start_index = 0 start_index = 0
limit = 10000 limit = 10000
@ -596,13 +596,13 @@ class JellyfinClient:
if limit > 1000: if limit > 1000:
limit = limit // 2 limit = limit // 2
consecutive_failures = 0 # Reset — give the smaller batch a fair chance consecutive_failures = 0 # Reset — give the smaller batch a fair chance
logger.warning(f"⚠️ Album fetch failed - reducing batch size to {limit}") logger.warning(f"Album fetch failed - reducing batch size to {limit}")
continue continue
elif consecutive_failures >= 2: elif consecutive_failures >= 2:
logger.warning("🚨 Multiple album fetch failures at minimum batch size - stopping") logger.warning("Multiple album fetch failures at minimum batch size - stopping")
break break
else: else:
logger.warning("⚠️ Album fetch failed at minimum batch size - retrying once") logger.warning("Album fetch failed at minimum batch size - retrying once")
continue continue
consecutive_failures = 0 consecutive_failures = 0
@ -617,7 +617,7 @@ class JellyfinClient:
start_index += limit start_index += limit
progress_msg = f"Fetched {len(all_albums)} albums so far..." progress_msg = f"Fetched {len(all_albums)} albums so far..."
logger.info(f" 📀 {progress_msg} (batch size: {limit})") logger.info(f" {progress_msg} (batch size: {limit})")
if self._progress_callback: if self._progress_callback:
self._progress_callback(progress_msg) self._progress_callback(progress_msg)
@ -632,10 +632,10 @@ class JellyfinClient:
self._album_cache[artist_id] = [] self._album_cache[artist_id] = []
self._album_cache[artist_id].append(JellyfinAlbum(album_data, self)) self._album_cache[artist_id].append(JellyfinAlbum(album_data, self))
logger.info(f"Cached {len(all_albums)} albums for {len(self._album_cache)} artists") logger.info(f"Cached {len(all_albums)} albums for {len(self._album_cache)} artists")
self._cache_populated = True self._cache_populated = True
logger.info("🎯 AGGRESSIVE CACHE COMPLETE! All subsequent album/track lookups will be INSTANT!") logger.info("AGGRESSIVE CACHE COMPLETE! All subsequent album/track lookups will be INSTANT!")
if self._progress_callback: if self._progress_callback:
self._progress_callback("Cache complete! Now processing artists...") self._progress_callback("Cache complete! Now processing artists...")
@ -648,7 +648,7 @@ class JellyfinClient:
if not albums: if not albums:
return return
logger.info(f"🎯 Starting targeted Jellyfin cache for {len(albums)} recent albums...") logger.info(f"Starting targeted Jellyfin cache for {len(albums)} recent albums...")
if self._progress_callback: if self._progress_callback:
self._progress_callback(f"Caching tracks for {len(albums)} recent albums...") self._progress_callback(f"Caching tracks for {len(albums)} recent albums...")
@ -688,11 +688,11 @@ class JellyfinClient:
# Progress update every 50 albums # Progress update every 50 albums
if (i + 1) % 50 == 0 or i == len(album_ids) - 1: if (i + 1) % 50 == 0 or i == len(album_ids) - 1:
progress_msg = f"Cached {cached_tracks} tracks from {i + 1} albums..." progress_msg = f"Cached {cached_tracks} tracks from {i + 1} albums..."
logger.info(f" 🎯 {progress_msg}") logger.info(f" {progress_msg}")
if self._progress_callback: if self._progress_callback:
self._progress_callback(progress_msg) self._progress_callback(progress_msg)
logger.info(f"Targeted cache complete: {cached_tracks} tracks cached for {len(self._track_cache)} albums") logger.info(f"Targeted cache complete: {cached_tracks} tracks cached for {len(self._track_cache)} albums")
if self._progress_callback: if self._progress_callback:
self._progress_callback("Targeted cache complete! Now checking for new tracks...") self._progress_callback("Targeted cache complete! Now checking for new tracks...")
@ -1300,7 +1300,7 @@ class JellyfinClient:
result = response.json() result = response.json()
if result and 'Id' in result: if result and 'Id' in result:
logger.info(f"Created Jellyfin playlist '{name}' with {len(track_ids)} tracks") logger.info(f"Created Jellyfin playlist '{name}' with {len(track_ids)} tracks")
return True return True
else: else:
logger.error(f"Failed to create Jellyfin playlist '{name}': No playlist ID returned") logger.error(f"Failed to create Jellyfin playlist '{name}': No playlist ID returned")
@ -1407,7 +1407,7 @@ class JellyfinClient:
logger.error(f" Request params: Ids={add_params['Ids'][:200]}... (truncated)") logger.error(f" Request params: Ids={add_params['Ids'][:200]}... (truncated)")
# Continue with other batches even if one fails # Continue with other batches even if one fails
logger.info(f"Created large Jellyfin playlist '{name}' with {len(track_ids)} tracks in {total_batches} batches") logger.info(f"Created large Jellyfin playlist '{name}' with {len(track_ids)} tracks in {total_batches} batches")
return True return True
except Exception as e: except Exception as e:
@ -1453,7 +1453,7 @@ class JellyfinClient:
try: try:
success = self.create_playlist(target_name, source_tracks) success = self.create_playlist(target_name, source_tracks)
if success: if success:
logger.info(f"Created backup playlist '{target_name}' with {len(source_tracks)} tracks") logger.info(f"Created backup playlist '{target_name}' with {len(source_tracks)} tracks")
return True return True
else: else:
logger.error(f"Failed to create backup playlist '{target_name}'") logger.error(f"Failed to create backup playlist '{target_name}'")
@ -1541,12 +1541,12 @@ class JellyfinClient:
if existing_playlist and create_backup: if existing_playlist and create_backup:
backup_name = f"{playlist_name} Backup" backup_name = f"{playlist_name} Backup"
logger.info(f"🛡️ Creating backup playlist '{backup_name}' before sync") logger.info(f"Creating backup playlist '{backup_name}' before sync")
if self.copy_playlist(playlist_name, backup_name): if self.copy_playlist(playlist_name, backup_name):
logger.info(f"Backup created successfully") logger.info(f"Backup created successfully")
else: else:
logger.warning(f"⚠️ Failed to create backup, continuing with sync") logger.warning(f"Failed to create backup, continuing with sync")
if existing_playlist: if existing_playlist:
# Delete existing playlist using DELETE request # Delete existing playlist using DELETE request
@ -1612,7 +1612,7 @@ class JellyfinClient:
response = requests.post(url, headers=headers, params=params, timeout=10) response = requests.post(url, headers=headers, params=params, timeout=10)
response.raise_for_status() response.raise_for_status()
logger.info(f"🎵 Triggered Jellyfin library scan for '{library_name}'") logger.info(f"Triggered Jellyfin library scan for '{library_name}'")
return True return True
except Exception as e: except Exception as e:
@ -1622,14 +1622,14 @@ class JellyfinClient:
def is_library_scanning(self, library_name: str = "Music") -> bool: def is_library_scanning(self, library_name: str = "Music") -> bool:
"""Check if Jellyfin library is currently scanning""" """Check if Jellyfin library is currently scanning"""
if not self.ensure_connection(): if not self.ensure_connection():
logger.debug("🔍 DEBUG: Not connected to Jellyfin, cannot check scan status") logger.debug("DEBUG: Not connected to Jellyfin, cannot check scan status")
return False return False
try: try:
# Check scheduled tasks for library scan activities # Check scheduled tasks for library scan activities
response = self._make_request('/ScheduledTasks') response = self._make_request('/ScheduledTasks')
if not response: if not response:
logger.debug("🔍 DEBUG: Could not get scheduled tasks") logger.debug("DEBUG: Could not get scheduled tasks")
return False return False
for task in response: for task in response:
@ -1639,10 +1639,10 @@ class JellyfinClient:
# Look for library scan related tasks that are running # Look for library scan related tasks that are running
if ('scan' in task_name or 'refresh' in task_name or 'library' in task_name): if ('scan' in task_name or 'refresh' in task_name or 'library' in task_name):
if task_state in ['Running', 'Cancelling']: if task_state in ['Running', 'Cancelling']:
logger.debug(f"🔍 DEBUG: Found running scan task: {task.get('Name')} (State: {task_state})") logger.debug(f"DEBUG: Found running scan task: {task.get('Name')} (State: {task_state})")
return True return True
logger.debug("🔍 DEBUG: No active scan tasks detected") logger.debug("DEBUG: No active scan tasks detected")
return False return False
except Exception as e: except Exception as e:
@ -1802,9 +1802,9 @@ class JellyfinClient:
try: try:
self._metadata_only_mode = enabled self._metadata_only_mode = enabled
if enabled: if enabled:
logger.info("🎯 Metadata-only mode enabled - will skip expensive track caching") logger.info("Metadata-only mode enabled - will skip expensive track caching")
else: else:
logger.info("🎯 Metadata-only mode disabled") logger.info("Metadata-only mode disabled")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error setting metadata-only mode: {e}") logger.error(f"Error setting metadata-only mode: {e}")

View file

@ -72,10 +72,10 @@ class ListenBrainzClient:
data = response.json() data = response.json()
if data.get('valid'): if data.get('valid'):
self.username = data.get('user_name') self.username = data.get('user_name')
logger.info(f"ListenBrainz authenticated as: {self.username}") logger.info(f"ListenBrainz authenticated as: {self.username}")
return True return True
logger.warning("Invalid ListenBrainz token") logger.warning("Invalid ListenBrainz token")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Error validating ListenBrainz token: {e}") logger.error(f"Error validating ListenBrainz token: {e}")
@ -179,7 +179,7 @@ class ListenBrainzClient:
if response and response.status_code == 200: if response and response.status_code == 200:
data = response.json() data = response.json()
playlists = data.get('playlists', []) playlists = data.get('playlists', [])
logger.info(f"📋 Fetched {len(playlists)} playlists created for {self.username}") logger.info(f"Fetched {len(playlists)} playlists created for {self.username}")
return playlists return playlists
elif response and response.status_code == 404: elif response and response.status_code == 404:
logger.warning(f"User {self.username} not found") logger.warning(f"User {self.username} not found")
@ -215,7 +215,7 @@ class ListenBrainzClient:
if response and response.status_code == 200: if response and response.status_code == 200:
data = response.json() data = response.json()
playlists = data.get('playlists', []) playlists = data.get('playlists', [])
logger.info(f"📋 Fetched {len(playlists)} user playlists for {self.username}") logger.info(f"Fetched {len(playlists)} user playlists for {self.username}")
return playlists return playlists
elif response and response.status_code == 404: elif response and response.status_code == 404:
logger.warning(f"User {self.username} not found") logger.warning(f"User {self.username} not found")
@ -251,7 +251,7 @@ class ListenBrainzClient:
if response and response.status_code == 200: if response and response.status_code == 200:
data = response.json() data = response.json()
playlists = data.get('playlists', []) playlists = data.get('playlists', [])
logger.info(f"📋 Fetched {len(playlists)} collaborative playlists for {self.username}") logger.info(f"Fetched {len(playlists)} collaborative playlists for {self.username}")
return playlists return playlists
elif response and response.status_code == 404: elif response and response.status_code == 404:
logger.warning(f"User {self.username} not found") logger.warning(f"User {self.username} not found")
@ -291,7 +291,7 @@ class ListenBrainzClient:
data = response.json() data = response.json()
playlist = data.get('playlist', {}) playlist = data.get('playlist', {})
track_count = len(playlist.get('track', [])) track_count = len(playlist.get('track', []))
logger.info(f"📋 Fetched playlist '{playlist.get('title')}' with {track_count} tracks") logger.info(f"Fetched playlist '{playlist.get('title')}' with {track_count} tracks")
return playlist return playlist
elif response and response.status_code == 404: elif response and response.status_code == 404:
logger.warning(f"Playlist {playlist_mbid} not found") logger.warning(f"Playlist {playlist_mbid} not found")
@ -333,7 +333,7 @@ class ListenBrainzClient:
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
playlists = data.get('playlists', []) playlists = data.get('playlists', [])
logger.info(f"🔍 Found {len(playlists)} playlists matching '{query}'") logger.info(f"Found {len(playlists)} playlists matching '{query}'")
return playlists return playlists
else: else:
logger.error(f"Failed to search playlists: {response.status_code}") logger.error(f"Failed to search playlists: {response.status_code}")

View file

@ -79,7 +79,7 @@ class ListenBrainzManager:
"error": "Not authenticated" "error": "Not authenticated"
} }
logger.info("🔄 Starting ListenBrainz playlists update...") logger.info("Starting ListenBrainz playlists update...")
summary = { summary = {
"created_for": {"updated": 0, "skipped": 0, "new": 0}, "created_for": {"updated": 0, "skipped": 0, "new": 0},
@ -97,7 +97,7 @@ class ListenBrainzManager:
for playlist_type, fetch_func in playlist_types: for playlist_type, fetch_func in playlist_types:
try: try:
playlists = fetch_func() playlists = fetch_func()
logger.info(f"📋 Fetched {len(playlists)} {playlist_type} playlists") logger.info(f"Fetched {len(playlists)} {playlist_type} playlists")
for playlist in playlists: for playlist in playlists:
result = self._update_playlist(playlist, playlist_type) result = self._update_playlist(playlist, playlist_type)
@ -114,7 +114,7 @@ class ListenBrainzManager:
# Cleanup old playlists (keep only 4 most recent per type) # Cleanup old playlists (keep only 4 most recent per type)
self._cleanup_old_playlists() self._cleanup_old_playlists()
logger.info(f"ListenBrainz update complete: {summary}") logger.info(f"ListenBrainz update complete: {summary}")
return { return {
"success": True, "success": True,
"summary": summary "summary": summary
@ -165,11 +165,11 @@ class ListenBrainzManager:
# Skip if track count hasn't changed (playlist content likely the same) # Skip if track count hasn't changed (playlist content likely the same)
if db_track_count == track_count: if db_track_count == track_count:
logger.debug(f"Playlist '{title}' unchanged, skipping") logger.debug(f"Playlist '{title}' unchanged, skipping")
conn.close() conn.close()
return "skipped" return "skipped"
logger.info(f"🔄 Playlist '{title}' changed ({db_track_count}{track_count} tracks), updating...") logger.info(f"Playlist '{title}' changed ({db_track_count}{track_count} tracks), updating...")
# Delete old tracks # Delete old tracks
cursor.execute("DELETE FROM listenbrainz_tracks WHERE playlist_id = ?", (db_id,)) cursor.execute("DELETE FROM listenbrainz_tracks WHERE playlist_id = ?", (db_id,))
@ -185,7 +185,7 @@ class ListenBrainzManager:
result_type = "updated" result_type = "updated"
else: else:
logger.info(f" New playlist '{title}', adding to database...") logger.info(f"New playlist '{title}', adding to database...")
# Insert new playlist # Insert new playlist
cursor.execute(""" cursor.execute("""
@ -218,7 +218,7 @@ class ListenBrainzManager:
""" """
Cache tracks for a playlist, including fetching cover art URLs in parallel Cache tracks for a playlist, including fetching cover art URLs in parallel
""" """
logger.info(f"🎵 Caching {len(tracks)} tracks with cover art...") logger.info(f"Caching {len(tracks)} tracks with cover art...")
# First pass: extract track data # First pass: extract track data
track_data_list = [] track_data_list = []
@ -324,7 +324,7 @@ class ListenBrainzManager:
logger.debug(f"Error fetching cover for track {idx}: {e}") logger.debug(f"Error fetching cover for track {idx}: {e}")
covers_found = sum(1 for t in track_data_list if t.get('album_cover_url')) covers_found = sum(1 for t in track_data_list if t.get('album_cover_url'))
logger.info(f"Fetched {covers_found}/{len(track_data_list)} cover art URLs") logger.info(f"Fetched {covers_found}/{len(track_data_list)} cover art URLs")
def _cleanup_old_playlists(self): def _cleanup_old_playlists(self):
"""Remove old playlists, keeping only the 25 most recent per type""" """Remove old playlists, keeping only the 25 most recent per type"""
@ -354,7 +354,7 @@ class ListenBrainzManager:
# Delete old playlists # Delete old playlists
cursor.execute(f"DELETE FROM listenbrainz_playlists WHERE id IN ({placeholders})", old_playlist_ids) cursor.execute(f"DELETE FROM listenbrainz_playlists WHERE id IN ({placeholders})", old_playlist_ids)
logger.info(f"🗑️ Removed {len(old_playlist_ids)} old {playlist_type} playlists") logger.info(f"Removed {len(old_playlist_ids)} old {playlist_type} playlists")
except Exception as e: except Exception as e:
logger.error(f"Error cleaning up {playlist_type} playlists: {e}") logger.error(f"Error cleaning up {playlist_type} playlists: {e}")

View file

@ -113,14 +113,14 @@ class LyricsClient:
f.write(synced) f.write(synced)
# Embed synced lyrics in audio tags # Embed synced lyrics in audio tags
self._embed_lyrics(audio_file_path, synced) self._embed_lyrics(audio_file_path, synced)
logger.info(f"Created synced LRC + embedded: {os.path.basename(lrc_path)}") logger.info(f"Created synced LRC + embedded: {os.path.basename(lrc_path)}")
else: else:
# Plain lyrics only → write as .txt (not .lrc, which requires timestamps) # Plain lyrics only → write as .txt (not .lrc, which requires timestamps)
with open(txt_path, 'w', encoding='utf-8') as f: with open(txt_path, 'w', encoding='utf-8') as f:
f.write(plain) f.write(plain)
# Still embed plain lyrics in audio tags (players can display unsynced lyrics) # Still embed plain lyrics in audio tags (players can display unsynced lyrics)
self._embed_lyrics(audio_file_path, plain) self._embed_lyrics(audio_file_path, plain)
logger.info(f"Created plain lyrics .txt + embedded: {os.path.basename(txt_path)}") logger.info(f"Created plain lyrics .txt + embedded: {os.path.basename(txt_path)}")
return True return True
except Exception as e: except Exception as e:

View file

@ -421,7 +421,7 @@ class MusicMatchingEngine:
if is_likely_album and 4 <= len(potential_album_part) <= 30: if is_likely_album and 4 <= len(potential_album_part) <= 30:
cleaned_title = re.sub(dash_pattern, '', track_title).strip() cleaned_title = re.sub(dash_pattern, '', track_title).strip()
print(f"🎵 Heuristic album detection: '{original_title}''{cleaned_title}' (removed: '{potential_album_part}')") print(f"Heuristic album detection: '{original_title}''{cleaned_title}' (removed: '{potential_album_part}')")
return cleaned_title, True return cleaned_title, True
return track_title, False return track_title, False
@ -750,7 +750,7 @@ class MusicMatchingEngine:
f"vs '{slskd_track.filename[:60]}...' | " f"vs '{slskd_track.filename[:60]}...' | "
f"Title: {title_score:.2f} (ratio: {title_ratio:.2f}, boundary: {has_word_boundary}), " f"Title: {title_score:.2f} (ratio: {title_ratio:.2f}, boundary: {has_word_boundary}), "
f"Artist: {artist_score:.2f}, Duration: {duration_score:.2f}{album_tag}, " f"Artist: {artist_score:.2f}, Duration: {duration_score:.2f}{album_tag}, "
f"Final: {final_confidence:.2f} {'PASS' if final_confidence > 0.63 else 'FAIL'}" f"Final: {final_confidence:.2f} {'PASS' if final_confidence > 0.63 else 'FAIL'}"
) )
# Ensure the final score doesn't exceed 1.0 # Ensure the final score doesn't exceed 1.0
@ -982,11 +982,11 @@ class MusicMatchingEngine:
# Debug logging for troubleshooting # Debug logging for troubleshooting
if scored_results and not confident_results: if scored_results and not confident_results:
print(f"⚠️ DEBUG: Found {len(scored_results)} scored results but none met confidence threshold 0.58") print(f"DEBUG: Found {len(scored_results)} scored results but none met confidence threshold 0.58")
for i, result in enumerate(sorted_results[:3]): # Show top 3 for i, result in enumerate(sorted_results[:3]): # Show top 3
print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...") print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...")
elif confident_results: elif confident_results:
print(f"DEBUG: {len(confident_results)} results passed confidence threshold 0.58") print(f"DEBUG: {len(confident_results)} results passed confidence threshold 0.58")
for i, result in enumerate(confident_results[:3]): # Show top 3 for i, result in enumerate(confident_results[:3]): # Show top 3
print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...") print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...")

View file

@ -122,15 +122,15 @@ class MediaScanManager:
if self._scan_in_progress: if self._scan_in_progress:
# Server is currently scanning - mark that we need another scan later # Server is currently scanning - mark that we need another scan later
self._downloads_during_scan = True self._downloads_during_scan = True
logger.info(f"📡 Media scan in progress - queueing follow-up scan ({reason})") logger.info(f"Media scan in progress - queueing follow-up scan ({reason})")
return return
# Cancel any existing timer and start a new one # Cancel any existing timer and start a new one
if self._timer: if self._timer:
self._timer.cancel() self._timer.cancel()
logger.debug(f"Resetting scan timer ({reason})") logger.debug(f"Resetting scan timer ({reason})")
else: else:
logger.info(f"Media scan queued - will execute in {self.delay}s ({reason})") logger.info(f"Media scan queued - will execute in {self.delay}s ({reason})")
# Start the debounce timer # Start the debounce timer
self._timer = threading.Timer(self.delay, self._execute_scan) self._timer = threading.Timer(self.delay, self._execute_scan)
@ -176,21 +176,21 @@ class MediaScanManager:
# Get the active media client # Get the active media client
media_client, server_type = self._get_active_media_client() media_client, server_type = self._get_active_media_client()
if not media_client: if not media_client:
logger.error("No active media client available for library scan") logger.error("No active media client available for library scan")
self._reset_scan_state() self._reset_scan_state()
return return
logger.info(f"🎵 Starting {server_type.upper()} library scan...") logger.info(f"Starting {server_type.upper()} library scan...")
try: try:
success = media_client.trigger_library_scan() success = media_client.trigger_library_scan()
if success: if success:
logger.info(f"{server_type.upper()} library scan initiated successfully") logger.info(f"{server_type.upper()} library scan initiated successfully")
# Start new periodic update system instead of completion detection # Start new periodic update system instead of completion detection
self._start_periodic_updates() self._start_periodic_updates()
else: else:
logger.error(f"Failed to initiate {server_type.upper()} library scan") logger.error(f"Failed to initiate {server_type.upper()} library scan")
self._reset_scan_state() self._reset_scan_state()
except Exception as e: except Exception as e:
@ -207,7 +207,7 @@ class MediaScanManager:
self._is_doing_periodic_updates = True self._is_doing_periodic_updates = True
logger.info(f"🕒 Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes") logger.info(f"Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes")
# Schedule first periodic update after 5 minutes # Schedule first periodic update after 5 minutes
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update) self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
@ -242,20 +242,20 @@ class MediaScanManager:
is_scanning = media_client.is_library_scanning("Music") is_scanning = media_client.is_library_scanning("Music")
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0 elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
logger.info(f"🕒 PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - {server_type.upper()} scanning: {is_scanning}") logger.info(f"PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - {server_type.upper()} scanning: {is_scanning}")
if is_scanning: if is_scanning:
# Still scanning - trigger database update and continue periodic updates # Still scanning - trigger database update and continue periodic updates
logger.info(f"🔄 {server_type.upper()} still scanning - triggering database update") logger.info(f"{server_type.upper()} still scanning - triggering database update")
self._call_completion_callbacks() self._call_completion_callbacks()
# Schedule next periodic update # Schedule next periodic update
logger.info(f"🕒 Scheduling next periodic update in {self._periodic_update_interval//60} minutes") logger.info(f"Scheduling next periodic update in {self._periodic_update_interval//60} minutes")
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update) self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.start() self._periodic_update_timer.start()
else: else:
# Scanning stopped - final update and cleanup # Scanning stopped - final update and cleanup
logger.info(f"{server_type.upper()} scanning completed - doing final database update") logger.info(f"{server_type.upper()} scanning completed - doing final database update")
self._call_completion_callbacks() self._call_completion_callbacks()
self._stop_periodic_updates() self._stop_periodic_updates()
@ -273,7 +273,7 @@ class MediaScanManager:
self._periodic_update_timer.cancel() self._periodic_update_timer.cancel()
self._periodic_update_timer = None self._periodic_update_timer = None
logger.info("🕒 Stopped periodic database updates") logger.info("Stopped periodic database updates")
self._scan_completed() self._scan_completed()
except Exception as e: except Exception as e:
@ -292,17 +292,17 @@ class MediaScanManager:
logger.debug("Scan completion callback called but scan was not in progress") logger.debug("Scan completion callback called but scan was not in progress")
return return
logger.info("📡 Media library scan completed") logger.info("Media library scan completed")
# Call registered completion callbacks # Call registered completion callbacks
self._call_completion_callbacks() self._call_completion_callbacks()
# Check if we need a follow-up scan # Check if we need a follow-up scan
if downloads_during_scan: if downloads_during_scan:
logger.info("🔄 Downloads occurred during scan - triggering follow-up scan") logger.info("Downloads occurred during scan - triggering follow-up scan")
self.request_scan("Follow-up scan for downloads during previous scan") self.request_scan("Follow-up scan for downloads during previous scan")
else: else:
logger.info("No downloads during scan - scan cycle complete") logger.info("No downloads during scan - scan cycle complete")
def _call_completion_callbacks(self): def _call_completion_callbacks(self):
"""Call all registered scan completion callbacks""" """Call all registered scan completion callbacks"""
@ -344,7 +344,7 @@ class MediaScanManager:
logger.warning("Force scan requested but scan already in progress") logger.warning("Force scan requested but scan already in progress")
return return
logger.info("🚀 Force scan requested - executing immediately") logger.info("Force scan requested - executing immediately")
self._execute_scan() self._execute_scan()
def get_status(self) -> dict: def get_status(self) -> dict:

View file

@ -212,8 +212,8 @@ class MetadataService:
def _log_initialization(self): def _log_initialization(self):
"""Log initialization status""" """Log initialization status"""
spotify_status = "Authenticated" if self.spotify.is_spotify_authenticated() else "Not authenticated" spotify_status = "Authenticated" if self.spotify.is_spotify_authenticated() else "Not authenticated"
fallback_status = "Available" if self.itunes.is_authenticated() else "Not available" fallback_status = "Available" if self.itunes.is_authenticated() else "Not available"
logger.info(f"MetadataService initialized - Spotify: {spotify_status}, {self._fallback_source.capitalize()}: {fallback_status}") logger.info(f"MetadataService initialized - Spotify: {spotify_status}, {self._fallback_source.capitalize()}: {fallback_status}")
logger.info(f"Preferred provider: {self.preferred_provider}") logger.info(f"Preferred provider: {self.preferred_provider}")

View file

@ -288,11 +288,11 @@ class MusicBrainzWorker:
if result and result.get('mbid'): if result and result.get('mbid'):
self.mb_service.update_artist_mbid(item_id, result['mbid'], 'matched') self.mb_service.update_artist_mbid(item_id, result['mbid'], 'matched')
self.stats['matched'] += 1 self.stats['matched'] += 1
logger.info(f"Matched artist '{item_name}' → MBID: {result['mbid']}") logger.info(f"Matched artist '{item_name}' → MBID: {result['mbid']}")
else: else:
self.mb_service.update_artist_mbid(item_id, None, 'not_found') self.mb_service.update_artist_mbid(item_id, None, 'not_found')
self.stats['not_found'] += 1 self.stats['not_found'] += 1
logger.debug(f"No match for artist '{item_name}'") logger.debug(f"No match for artist '{item_name}'")
elif item_type == 'album': elif item_type == 'album':
artist_name = item.get('artist') artist_name = item.get('artist')
@ -300,11 +300,11 @@ class MusicBrainzWorker:
if result and result.get('mbid'): if result and result.get('mbid'):
self.mb_service.update_album_mbid(item_id, result['mbid'], 'matched') self.mb_service.update_album_mbid(item_id, result['mbid'], 'matched')
self.stats['matched'] += 1 self.stats['matched'] += 1
logger.info(f"Matched album '{item_name}' → MBID: {result['mbid']}") logger.info(f"Matched album '{item_name}' → MBID: {result['mbid']}")
else: else:
self.mb_service.update_album_mbid(item_id, None, 'not_found') self.mb_service.update_album_mbid(item_id, None, 'not_found')
self.stats['not_found'] += 1 self.stats['not_found'] += 1
logger.debug(f"No match for album '{item_name}'") logger.debug(f"No match for album '{item_name}'")
elif item_type == 'track': elif item_type == 'track':
artist_name = item.get('artist') artist_name = item.get('artist')
@ -312,11 +312,11 @@ class MusicBrainzWorker:
if result and result.get('mbid'): if result and result.get('mbid'):
self.mb_service.update_track_mbid(item_id, result['mbid'], 'matched') self.mb_service.update_track_mbid(item_id, result['mbid'], 'matched')
self.stats['matched'] += 1 self.stats['matched'] += 1
logger.info(f"Matched track '{item_name}' → MBID: {result['mbid']}") logger.info(f"Matched track '{item_name}' → MBID: {result['mbid']}")
else: else:
self.mb_service.update_track_mbid(item_id, None, 'not_found') self.mb_service.update_track_mbid(item_id, None, 'not_found')
self.stats['not_found'] += 1 self.stats['not_found'] += 1
logger.debug(f"No match for track '{item_name}'") logger.debug(f"No match for track '{item_name}'")
except Exception as e: except Exception as e:
logger.error(f"Error processing {item['type']} #{item['id']}: {e}") logger.error(f"Error processing {item['type']} #{item['id']}: {e}")

View file

@ -173,7 +173,7 @@ class NavidromeClient:
self._artist_cache.clear() self._artist_cache.clear()
self._album_cache.clear() self._album_cache.clear()
self._track_cache.clear() self._track_cache.clear()
logger.info("🔄 Navidrome client config reset — will reconnect with new settings") logger.info("Navidrome client config reset — will reconnect with new settings")
def get_music_folders(self) -> list: def get_music_folders(self) -> list:
"""Get available music folders from Navidrome.""" """Get available music folders from Navidrome."""
@ -833,7 +833,7 @@ class NavidromeClient:
response = self._make_request('createPlaylist', params) response = self._make_request('createPlaylist', params)
if response and response.get('status') == 'ok': if response and response.get('status') == 'ok':
logger.info(f"{'Updated' if playlist_id else 'Created'} Navidrome playlist '{name}' with {len(track_ids)} tracks") logger.info(f"{'Updated' if playlist_id else 'Created'} Navidrome playlist '{name}' with {len(track_ids)} tracks")
return True return True
else: else:
logger.error(f"Failed to {'update' if playlist_id else 'create'} Navidrome playlist '{name}'") logger.error(f"Failed to {'update' if playlist_id else 'create'} Navidrome playlist '{name}'")
@ -877,7 +877,7 @@ class NavidromeClient:
try: try:
success = self.create_playlist(target_name, source_tracks) success = self.create_playlist(target_name, source_tracks)
if success: if success:
logger.info(f"Created backup playlist '{target_name}' with {len(source_tracks)} tracks") logger.info(f"Created backup playlist '{target_name}' with {len(source_tracks)} tracks")
return True return True
else: else:
logger.error(f"Failed to create backup playlist '{target_name}'") logger.error(f"Failed to create backup playlist '{target_name}'")
@ -938,13 +938,13 @@ class NavidromeClient:
# If we have existing playlists and want to backup, use the first one found # If we have existing playlists and want to backup, use the first one found
if existing_playlists and create_backup: if existing_playlists and create_backup:
backup_name = f"{playlist_name} Backup" backup_name = f"{playlist_name} Backup"
logger.info(f"🛡️ Creating backup playlist '{backup_name}' before sync") logger.info(f"Creating backup playlist '{backup_name}' before sync")
# We only need to backup once, even if duplicates exist # We only need to backup once, even if duplicates exist
if self.copy_playlist(playlist_name, backup_name): if self.copy_playlist(playlist_name, backup_name):
logger.info(f"Backup created successfully") logger.info(f"Backup created successfully")
else: else:
logger.warning(f"⚠️ Failed to create backup, continuing with sync") logger.warning(f"Failed to create backup, continuing with sync")
# STRATEGY: Update the first match, delete the rest # STRATEGY: Update the first match, delete the rest
if existing_playlists: if existing_playlists:

View file

@ -427,7 +427,7 @@ class PlexClient:
# Create new playlist with copied tracks # Create new playlist with copied tracks
try: try:
self.server.createPlaylist(target_name, items=valid_tracks) self.server.createPlaylist(target_name, items=valid_tracks)
logger.info(f"Created backup playlist '{target_name}' with {len(valid_tracks)} tracks") logger.info(f"Created backup playlist '{target_name}' with {len(valid_tracks)} tracks")
return True return True
except Exception as create_error: except Exception as create_error:
logger.error(f"Failed to create backup playlist: {create_error}") logger.error(f"Failed to create backup playlist: {create_error}")
@ -435,7 +435,7 @@ class PlexClient:
try: try:
new_playlist = self.server.createPlaylist(target_name) new_playlist = self.server.createPlaylist(target_name)
new_playlist.addItems(valid_tracks) new_playlist.addItems(valid_tracks)
logger.info(f"Created backup playlist '{target_name}' with {len(valid_tracks)} tracks (alternative method)") logger.info(f"Created backup playlist '{target_name}' with {len(valid_tracks)} tracks (alternative method)")
return True return True
except Exception as alt_error: except Exception as alt_error:
logger.error(f"Alternative backup creation also failed: {alt_error}") logger.error(f"Alternative backup creation also failed: {alt_error}")
@ -461,12 +461,12 @@ class PlexClient:
if create_backup: if create_backup:
backup_name = f"{playlist_name} Backup" backup_name = f"{playlist_name} Backup"
logger.info(f"🛡️ Creating backup playlist '{backup_name}' before sync") logger.info(f"Creating backup playlist '{backup_name}' before sync")
if self.copy_playlist(playlist_name, backup_name): if self.copy_playlist(playlist_name, backup_name):
logger.info(f"Backup created successfully") logger.info(f"Backup created successfully")
else: else:
logger.warning(f"⚠️ Failed to create backup, continuing with sync") logger.warning(f"Failed to create backup, continuing with sync")
# Delete original and recreate # Delete original and recreate
existing_playlist.delete() existing_playlist.delete()
@ -931,7 +931,7 @@ class PlexClient:
try: try:
library = self.server.library.section(library_name) library = self.server.library.section(library_name)
library.update() # Non-blocking scan request library.update() # Non-blocking scan request
logger.info(f"🎵 Triggered Plex library scan for '{library_name}'") logger.info(f"Triggered Plex library scan for '{library_name}'")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to trigger library scan for '{library_name}': {e}") logger.error(f"Failed to trigger library scan for '{library_name}': {e}")
@ -940,7 +940,7 @@ class PlexClient:
def is_library_scanning(self, library_name: str = "Music") -> bool: def is_library_scanning(self, library_name: str = "Music") -> bool:
"""Check if Plex library is currently scanning""" """Check if Plex library is currently scanning"""
if not self.ensure_connection(): if not self.ensure_connection():
logger.debug(f"🔍 DEBUG: Not connected to Plex, cannot check scan status") logger.debug(f"DEBUG: Not connected to Plex, cannot check scan status")
return False return False
try: try:
@ -949,31 +949,31 @@ class PlexClient:
# Check if library has a scanning attribute or is refreshing # Check if library has a scanning attribute or is refreshing
# The Plex API exposes this through the library's refreshing property # The Plex API exposes this through the library's refreshing property
refreshing = hasattr(library, 'refreshing') and library.refreshing refreshing = hasattr(library, 'refreshing') and library.refreshing
logger.debug(f"🔍 DEBUG: Library.refreshing = {refreshing}") logger.debug(f"DEBUG: Library.refreshing = {refreshing}")
if refreshing: if refreshing:
logger.debug(f"🔍 DEBUG: Library is refreshing") logger.debug(f"DEBUG: Library is refreshing")
return True return True
# Alternative method: Check server activities for scanning # Alternative method: Check server activities for scanning
try: try:
activities = self.server.activities() activities = self.server.activities()
logger.debug(f"🔍 DEBUG: Found {len(activities)} server activities") logger.debug(f"DEBUG: Found {len(activities)} server activities")
for activity in activities: for activity in activities:
# Look for library scan activities # Look for library scan activities
activity_type = getattr(activity, 'type', 'unknown') activity_type = getattr(activity, 'type', 'unknown')
activity_title = getattr(activity, 'title', 'unknown') activity_title = getattr(activity, 'title', 'unknown')
logger.debug(f"🔍 DEBUG: Activity - type: {activity_type}, title: {activity_title}") logger.debug(f"DEBUG: Activity - type: {activity_type}, title: {activity_title}")
if (activity_type in ['library.scan', 'library.refresh'] and if (activity_type in ['library.scan', 'library.refresh'] and
library_name.lower() in activity_title.lower()): library_name.lower() in activity_title.lower()):
logger.debug(f"🔍 DEBUG: Found matching scan activity: {activity_title}") logger.debug(f"DEBUG: Found matching scan activity: {activity_title}")
return True return True
except Exception as activities_error: except Exception as activities_error:
logger.debug(f"Could not check server activities: {activities_error}") logger.debug(f"Could not check server activities: {activities_error}")
logger.debug(f"🔍 DEBUG: No scan activity detected") logger.debug(f"DEBUG: No scan activity detected")
return False return False
except Exception as e: except Exception as e:

View file

@ -54,15 +54,15 @@ class PlexScanManager:
if self._scan_in_progress: if self._scan_in_progress:
# Plex is currently scanning - mark that we need another scan later # Plex is currently scanning - mark that we need another scan later
self._downloads_during_scan = True self._downloads_during_scan = True
logger.info(f"📡 Plex scan in progress - queueing follow-up scan ({reason})") logger.info(f"Plex scan in progress - queueing follow-up scan ({reason})")
return return
# Cancel any existing timer and start a new one # Cancel any existing timer and start a new one
if self._timer: if self._timer:
self._timer.cancel() self._timer.cancel()
logger.debug(f"Resetting scan timer ({reason})") logger.debug(f"Resetting scan timer ({reason})")
else: else:
logger.info(f"Plex scan queued - will execute in {self.delay}s ({reason})") logger.info(f"Plex scan queued - will execute in {self.delay}s ({reason})")
# Start the debounce timer # Start the debounce timer
self._timer = threading.Timer(self.delay, self._execute_scan) self._timer = threading.Timer(self.delay, self._execute_scan)
@ -105,17 +105,17 @@ class PlexScanManager:
self._timer = None self._timer = None
self._scan_start_time = time.time() self._scan_start_time = time.time()
logger.info("🎵 Starting Plex library scan...") logger.info("Starting Plex library scan...")
try: try:
success = self.plex_client.trigger_library_scan() success = self.plex_client.trigger_library_scan()
if success: if success:
logger.info("Plex library scan initiated successfully") logger.info("Plex library scan initiated successfully")
# Start new periodic update system instead of completion detection # Start new periodic update system instead of completion detection
self._start_periodic_updates() self._start_periodic_updates()
else: else:
logger.error("Failed to initiate Plex library scan") logger.error("Failed to initiate Plex library scan")
self._reset_scan_state() self._reset_scan_state()
except Exception as e: except Exception as e:
@ -132,7 +132,7 @@ class PlexScanManager:
self._is_doing_periodic_updates = True self._is_doing_periodic_updates = True
logger.info(f"🕒 Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes") logger.info(f"Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes")
# Schedule first periodic update after 5 minutes # Schedule first periodic update after 5 minutes
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update) self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
@ -160,20 +160,20 @@ class PlexScanManager:
is_scanning = self.plex_client.is_library_scanning("Music") is_scanning = self.plex_client.is_library_scanning("Music")
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0 elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
logger.info(f"🕒 PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - Plex scanning: {is_scanning}") logger.info(f"PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - Plex scanning: {is_scanning}")
if is_scanning: if is_scanning:
# Still scanning - trigger database update and continue periodic updates # Still scanning - trigger database update and continue periodic updates
logger.info("🔄 Plex still scanning - triggering database update") logger.info("Plex still scanning - triggering database update")
self._call_completion_callbacks() self._call_completion_callbacks()
# Schedule next periodic update # Schedule next periodic update
logger.info(f"🕒 Scheduling next periodic update in {self._periodic_update_interval//60} minutes") logger.info(f"Scheduling next periodic update in {self._periodic_update_interval//60} minutes")
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update) self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.start() self._periodic_update_timer.start()
else: else:
# Scanning stopped - final update and cleanup # Scanning stopped - final update and cleanup
logger.info("Plex scanning completed - doing final database update") logger.info("Plex scanning completed - doing final database update")
self._call_completion_callbacks() self._call_completion_callbacks()
self._stop_periodic_updates() self._stop_periodic_updates()
@ -191,7 +191,7 @@ class PlexScanManager:
self._periodic_update_timer.cancel() self._periodic_update_timer.cancel()
self._periodic_update_timer = None self._periodic_update_timer = None
logger.info("🕒 Stopped periodic database updates") logger.info("Stopped periodic database updates")
self._scan_completed() self._scan_completed()
except Exception as e: except Exception as e:
@ -222,7 +222,7 @@ class PlexScanManager:
else: else:
# Scan completed! # Scan completed!
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0 elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
logger.info(f"🎵 Plex library scan detected as completed (took {elapsed_time:.1f} seconds)") logger.info(f"Plex library scan detected as completed (took {elapsed_time:.1f} seconds)")
self._scan_completed() self._scan_completed()
except Exception as e: except Exception as e:
@ -243,17 +243,17 @@ class PlexScanManager:
logger.debug("Scan completion callback called but scan was not in progress") logger.debug("Scan completion callback called but scan was not in progress")
return return
logger.info("📡 Plex library scan completed") logger.info("Plex library scan completed")
# Call registered completion callbacks # Call registered completion callbacks
self._call_completion_callbacks() self._call_completion_callbacks()
# Check if we need a follow-up scan # Check if we need a follow-up scan
if downloads_during_scan: if downloads_during_scan:
logger.info("🔄 Downloads occurred during scan - triggering follow-up scan") logger.info("Downloads occurred during scan - triggering follow-up scan")
self.request_scan("Follow-up scan for downloads during previous scan") self.request_scan("Follow-up scan for downloads during previous scan")
else: else:
logger.info("No downloads during scan - scan cycle complete") logger.info("No downloads during scan - scan cycle complete")
def _call_completion_callbacks(self): def _call_completion_callbacks(self):
"""Call all registered scan completion callbacks""" """Call all registered scan completion callbacks"""
@ -295,7 +295,7 @@ class PlexScanManager:
logger.warning("Force scan requested but scan already in progress") logger.warning("Force scan requested but scan already in progress")
return return
logger.info("🚀 Force scan requested - executing immediately") logger.info("Force scan requested - executing immediately")
self._execute_scan() self._execute_scan()
def get_status(self) -> dict: def get_status(self) -> dict:

View file

@ -28,7 +28,7 @@ SEASONAL_CONFIG = {
"keywords": ["christmas", "xmas", "holiday", "santa", "jingle", "winter wonderland", "sleigh", "noel", "carol"], "keywords": ["christmas", "xmas", "holiday", "santa", "jingle", "winter wonderland", "sleigh", "noel", "carol"],
"active_months": [11, 12], # November-December "active_months": [11, 12], # November-December
"playlist_size": 50, "playlist_size": 50,
"icon": "🎄" "icon": "🎅"
}, },
"valentines": { "valentines": {
"name": "Love Songs", "name": "Love Songs",
@ -36,7 +36,7 @@ SEASONAL_CONFIG = {
"keywords": ["love", "valentine", "romance", "heart", "romantic", "darling"], "keywords": ["love", "valentine", "romance", "heart", "romantic", "darling"],
"active_months": [2], # February "active_months": [2], # February
"playlist_size": 50, "playlist_size": 50,
"icon": "❤️" "icon": "💝"
}, },
"summer": { "summer": {
"name": "Summer Vibes", "name": "Summer Vibes",

View file

@ -1029,10 +1029,10 @@ class SoulseekClient:
logger.debug(f"{action} download (attempt {i+1}/3) with endpoint: {endpoint}") logger.debug(f"{action} download (attempt {i+1}/3) with endpoint: {endpoint}")
response = await self._make_request('DELETE', endpoint) response = await self._make_request('DELETE', endpoint)
if response is not None: if response is not None:
logger.info(f"Successfully cancelled download using endpoint format {i+1}") logger.info(f"Successfully cancelled download using endpoint format {i+1}")
return True return True
else: else:
logger.debug(f"Endpoint format {i+1} failed: {endpoint}") logger.debug(f"Endpoint format {i+1} failed: {endpoint}")
# Fallback: if download_id looks like a filename (contains path separators), # Fallback: if download_id looks like a filename (contains path separators),
# list all transfers, find by filename, and cancel with the real transfer ID # list all transfers, find by filename, and cancel with the real transfer ID
@ -1049,12 +1049,12 @@ class SoulseekClient:
logger.debug(f"Found matching transfer with real ID, trying: {fallback_endpoint}") logger.debug(f"Found matching transfer with real ID, trying: {fallback_endpoint}")
response = await self._make_request('DELETE', fallback_endpoint) response = await self._make_request('DELETE', fallback_endpoint)
if response is not None: if response is not None:
logger.info(f"Successfully cancelled download via filename fallback") logger.info(f"Successfully cancelled download via filename fallback")
return True return True
except Exception as fallback_error: except Exception as fallback_error:
logger.debug(f"Filename fallback failed: {fallback_error}") logger.debug(f"Filename fallback failed: {fallback_error}")
logger.error(f"All cancel endpoint formats failed for download_id: {download_id}") logger.error(f"All cancel endpoint formats failed for download_id: {download_id}")
return False return False
except Exception as e: except Exception as e:
@ -1725,7 +1725,7 @@ class SoulseekClient:
async with session.get(swagger_url, headers=headers) as response: async with session.get(swagger_url, headers=headers) as response:
if response.status == 200: if response.status == 200:
swagger_data = await response.json() swagger_data = await response.json()
logger.info("Found Swagger documentation") logger.info("Found Swagger documentation")
# Look for download/transfer related endpoints # Look for download/transfer related endpoints
paths = swagger_data.get('paths', {}) paths = swagger_data.get('paths', {})

View file

@ -99,7 +99,7 @@ def _set_global_rate_limit(retry_after_seconds, endpoint_name, has_real_header=F
_rate_limit_endpoint = endpoint_name _rate_limit_endpoint = endpoint_name
_rate_limit_set_at = now _rate_limit_set_at = now
logger.warning( logger.warning(
f"⚠️ GLOBAL RATE LIMIT ACTIVATED: {retry_after_seconds}s ban " f"GLOBAL RATE LIMIT ACTIVATED: {retry_after_seconds}s ban "
f"(expires {time.strftime('%H:%M:%S', time.localtime(new_until))}) " f"(expires {time.strftime('%H:%M:%S', time.localtime(new_until))}) "
f"triggered by {endpoint_name}" f"triggered by {endpoint_name}"
) )
@ -227,7 +227,7 @@ def _detect_and_set_rate_limit(exception, endpoint_name="unknown"):
logger.info(f"Rate limit detected on {endpoint_name} — Retry-After header: {delay}s") logger.info(f"Rate limit detected on {endpoint_name} — Retry-After header: {delay}s")
except (ValueError, TypeError): except (ValueError, TypeError):
delay = _BASE_UNKNOWN_BAN delay = _BASE_UNKNOWN_BAN
logger.warning(f"⚠️ Rate limit detected on {endpoint_name} — unparseable Retry-After: {retry_after}") logger.warning(f"Rate limit detected on {endpoint_name} — unparseable Retry-After: {retry_after}")
else: else:
# No Retry-After header available # No Retry-After header available
if "max retries" in error_str.lower(): if "max retries" in error_str.lower():
@ -237,7 +237,7 @@ def _detect_and_set_rate_limit(exception, endpoint_name="unknown"):
delay = _BASE_MAX_RETRIES_BAN # 4 hours delay = _BASE_MAX_RETRIES_BAN # 4 hours
else: else:
delay = _BASE_UNKNOWN_BAN # 30 min delay = _BASE_UNKNOWN_BAN # 30 min
logger.warning(f"⚠️ Rate limit detected on {endpoint_name} — no Retry-After header, using {delay}s default") logger.warning(f"Rate limit detected on {endpoint_name} — no Retry-After header, using {delay}s default")
_set_global_rate_limit(delay, endpoint_name, has_real_header=has_real_header) _set_global_rate_limit(delay, endpoint_name, has_real_header=has_real_header)
return True return True
@ -312,7 +312,7 @@ def rate_limited(func):
delay = 3.0 * (2 ** attempt) # 3, 6, 12, 24, 48 delay = 3.0 * (2 ** attempt) # 3, 6, 12, 24, 48
if attempt < max_retries: if attempt < max_retries:
logger.warning(f"⚠️ Spotify rate limit hit, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}") logger.warning(f"Spotify rate limit hit, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}")
time.sleep(delay) time.sleep(delay)
continue continue
else: else:
@ -323,7 +323,7 @@ def rate_limited(func):
elif is_server_error and attempt < max_retries: elif is_server_error and attempt < max_retries:
delay = 2.0 * (2 ** attempt) # 2, 4, 8, 16, 32 delay = 2.0 * (2 ** attempt) # 2, 4, 8, 16, 32
logger.warning(f"⚠️ Spotify server error, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}") logger.warning(f"Spotify server error, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}")
time.sleep(delay) time.sleep(delay)
continue continue
@ -534,7 +534,7 @@ class SpotifyClient:
config = config_manager.get_spotify_config() config = config_manager.get_spotify_config()
if not config.get('client_id') or not config.get('client_secret'): if not config.get('client_id') or not config.get('client_secret'):
logger.warning("⚠️ Spotify credentials not configured") logger.warning("Spotify credentials not configured")
return return
try: try:
@ -630,7 +630,7 @@ class SpotifyClient:
# Minimum 30 min for auth probe 429s — these indicate persistent throttling # Minimum 30 min for auth probe 429s — these indicate persistent throttling
ban_duration = max(delay, _BASE_UNKNOWN_BAN) ban_duration = max(delay, _BASE_UNKNOWN_BAN)
_set_global_rate_limit(ban_duration, 'is_spotify_authenticated', has_real_header=has_real_header) _set_global_rate_limit(ban_duration, 'is_spotify_authenticated', has_real_header=has_real_header)
logger.warning(f"⚠️ Auth probe rate limited — activating {ban_duration}s global ban") logger.warning(f"Auth probe rate limited — activating {ban_duration}s global ban")
result = True result = True
else: else:
logger.debug(f"Spotify authentication check failed: {e}") logger.debug(f"Spotify authentication check failed: {e}")
@ -656,7 +656,7 @@ class SpotifyClient:
os.remove(cache_path) os.remove(cache_path)
logger.info("Deleted Spotify cache file") logger.info("Deleted Spotify cache file")
except Exception as e: except Exception as e:
logger.warning(f"⚠️ Failed to delete Spotify cache: {e}") logger.warning(f"Failed to delete Spotify cache: {e}")
logger.info("Spotify client disconnected") logger.info("Spotify client disconnected")
@ -1075,7 +1075,7 @@ class SpotifyClient:
return artists return artists
except Exception as e: except Exception as e:
if '403' in str(e) or 'Forbidden' in str(e): if '403' in str(e) or 'Forbidden' in str(e):
logger.warning("⚠️ Spotify user-follow-read scope not granted — re-authorize to see followed artists") logger.warning("Spotify user-follow-read scope not granted — re-authorize to see followed artists")
return [] return []
_detect_and_set_rate_limit(e, 'get_followed_artists') _detect_and_set_rate_limit(e, 'get_followed_artists')
logger.error(f"Error fetching followed artists: {e}") logger.error(f"Error fetching followed artists: {e}")

View file

@ -472,9 +472,9 @@ class TidalClient:
result = self._exchange_code_for_tokens() result = self._exchange_code_for_tokens()
if result: if result:
logger.info("Token exchange successful") logger.info("Token exchange successful")
else: else:
logger.error("Token exchange failed") logger.error("Token exchange failed")
return result return result
except Exception as e: except Exception as e:

View file

@ -128,7 +128,7 @@ def clean_track_name_for_search(track_name):
# Log cleaning if significant changes were made # Log cleaning if significant changes were made
if cleaned_name != track_name: if cleaned_name != track_name:
logger.debug(f"🧹 Intelligent track cleaning: '{track_name}' -> '{cleaned_name}'") logger.debug(f"Intelligent track cleaning: '{track_name}' -> '{cleaned_name}'")
return cleaned_name return cleaned_name
@ -406,7 +406,7 @@ class WatchlistScanner:
def _disable_spotify_for_run(self, reason: str): def _disable_spotify_for_run(self, reason: str):
"""Disable Spotify for rest of current run, once.""" """Disable Spotify for rest of current run, once."""
if not self._spotify_disabled_for_run: if not self._spotify_disabled_for_run:
logger.warning(f"⚠️ Spotify disabled for rest of run: {reason}") logger.warning(f"Spotify disabled for rest of run: {reason}")
self._spotify_disabled_for_run = True self._spotify_disabled_for_run = True
self._spotify_disabled_reason = reason self._spotify_disabled_reason = reason
@ -640,7 +640,7 @@ class WatchlistScanner:
try: try:
self._backfill_missing_ids(all_watchlist_artists, provider) self._backfill_missing_ids(all_watchlist_artists, provider)
except Exception as backfill_error: except Exception as backfill_error:
logger.warning(f"⚠️ Error during {provider} ID backfilling: {backfill_error}") logger.warning(f"Error during {provider} ID backfilling: {backfill_error}")
# Continue with scan even if backfilling fails # Continue with scan even if backfilling fails
scan_results = [] scan_results = []
@ -655,9 +655,9 @@ class WatchlistScanner:
self._disable_spotify_for_run("global Spotify rate limit active") self._disable_spotify_for_run("global Spotify rate limit active")
if result.success: if result.success:
logger.info(f"Scanned {artist.artist_name}: {result.new_tracks_found} new tracks found") logger.info(f"Scanned {artist.artist_name}: {result.new_tracks_found} new tracks found")
else: else:
logger.warning(f"Failed to scan {artist.artist_name}: {result.error_message}") logger.warning(f"Failed to scan {artist.artist_name}: {result.error_message}")
# Rate limiting: Add delay between artists to avoid hitting Spotify API limits # Rate limiting: Add delay between artists to avoid hitting Spotify API limits
# This is critical to prevent getting banned for 6+ hours # This is critical to prevent getting banned for 6+ hours
@ -701,7 +701,7 @@ class WatchlistScanner:
self._disable_spotify_for_run("global Spotify rate limit active") self._disable_spotify_for_run("global Spotify rate limit active")
self.sync_spotify_library_cache() self.sync_spotify_library_cache()
except Exception as lib_err: except Exception as lib_err:
logger.warning(f"⚠️ Error syncing Spotify library cache: {lib_err}") logger.warning(f"Error syncing Spotify library cache: {lib_err}")
return scan_results return scan_results
@ -1054,10 +1054,10 @@ class WatchlistScanner:
artists_to_match = [a for a in artists if not getattr(a, id_attr, None)] artists_to_match = [a for a in artists if not getattr(a, id_attr, None)]
if not artists_to_match: if not artists_to_match:
logger.info(f"All artists already have {provider} IDs") logger.info(f"All artists already have {provider} IDs")
return return
logger.info(f"🔄 Backfilling {len(artists_to_match)} artists with {provider} IDs...") logger.info(f"Backfilling {len(artists_to_match)} artists with {provider} IDs...")
match_fn = { match_fn = {
'spotify': self._match_to_spotify, 'spotify': self._match_to_spotify,
@ -1086,7 +1086,7 @@ class WatchlistScanner:
update_fn(artist.id, new_id) update_fn(artist.id, new_id)
setattr(artist, id_attr, new_id) setattr(artist, id_attr, new_id)
matched_count += 1 matched_count += 1
logger.info(f"Matched '{artist.artist_name}' to {provider}: {new_id}") logger.info(f"Matched '{artist.artist_name}' to {provider}: {new_id}")
else: else:
unmatched_names.append(artist.artist_name) unmatched_names.append(artist.artist_name)
@ -1097,9 +1097,9 @@ class WatchlistScanner:
unmatched_names.append(artist.artist_name) unmatched_names.append(artist.artist_name)
continue continue
logger.info(f"Backfilled {matched_count}/{len(artists_to_match)} artists with {provider} IDs") logger.info(f"Backfilled {matched_count}/{len(artists_to_match)} artists with {provider} IDs")
if unmatched_names: if unmatched_names:
logger.warning(f"⚠️ Could not confidently match {len(unmatched_names)} artists: {', '.join(unmatched_names[:10])}" logger.warning(f"Could not confidently match {len(unmatched_names)} artists: {', '.join(unmatched_names[:10])}"
f"{'...' if len(unmatched_names) > 10 else ''} — use Watchlist Settings to link manually") f"{'...' if len(unmatched_names) > 10 else ''} — use Watchlist Settings to link manually")
@staticmethod @staticmethod
@ -1511,11 +1511,11 @@ class WatchlistScanner:
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server, album=album_name) db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server, album=album_name)
if db_track and confidence >= 0.7: if db_track and confidence >= 0.7:
logger.debug(f"✔️ Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})") logger.debug(f"Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})")
return False # Track exists in library return False # Track exists in library
# No match found with any variation or artist # No match found with any variation or artist
logger.info(f"Track missing from library: '{original_title}' by '{artists_to_search[0] if artists_to_search else 'Unknown'}' - adding to wishlist") logger.info(f"Track missing from library: '{original_title}' by '{artists_to_search[0] if artists_to_search else 'Unknown'}' - adding to wishlist")
return True # Track is missing return True # Track is missing
except Exception as e: except Exception as e:
@ -3212,7 +3212,7 @@ class WatchlistScanner:
logger.debug("Spotify not authenticated, skipping library cache sync") logger.debug("Spotify not authenticated, skipping library cache sync")
return return
logger.info("📚 Syncing Spotify library cache...") logger.info("Syncing Spotify library cache...")
try: try:
last_sync = self.database.get_metadata('spotify_library_last_sync') last_sync = self.database.get_metadata('spotify_library_last_sync')
@ -3256,7 +3256,7 @@ class WatchlistScanner:
# Update last sync timestamp # Update last sync timestamp
self.database.set_metadata('spotify_library_last_sync', datetime.now().isoformat()) self.database.set_metadata('spotify_library_last_sync', datetime.now().isoformat())
logger.info(f"Spotify library cache sync complete — {len(albums)} albums processed") logger.info(f"Spotify library cache sync complete — {len(albums)} albums processed")
except Exception as e: except Exception as e:
logger.error(f"Error syncing Spotify library cache: {e}") logger.error(f"Error syncing Spotify library cache: {e}")

View file

@ -91,7 +91,7 @@ class WebScanManager:
if self._scan_in_progress: if self._scan_in_progress:
# Server is currently scanning - mark that we need another scan later # Server is currently scanning - mark that we need another scan later
self._downloads_during_scan = True self._downloads_during_scan = True
logger.info(f"📡 Web scan in progress - queueing follow-up scan ({reason})") logger.info(f"Web scan in progress - queueing follow-up scan ({reason})")
return { return {
"status": "queued", "status": "queued",
"message": "Scan already in progress, queued for later", "message": "Scan already in progress, queued for later",
@ -101,9 +101,9 @@ class WebScanManager:
# Cancel any existing timer and start a new one # Cancel any existing timer and start a new one
if self._timer: if self._timer:
self._timer.cancel() self._timer.cancel()
logger.debug(f"Resetting web scan timer ({reason})") logger.debug(f"Resetting web scan timer ({reason})")
else: else:
logger.info(f"Web scan queued - will execute in {self.delay}s ({reason})") logger.info(f"Web scan queued - will execute in {self.delay}s ({reason})")
# Start the debounce timer # Start the debounce timer
self._timer = threading.Timer(self.delay, self._execute_scan) self._timer = threading.Timer(self.delay, self._execute_scan)
@ -182,12 +182,12 @@ class WebScanManager:
# Get the active media client # Get the active media client
media_client, server_type = self._get_active_media_client() media_client, server_type = self._get_active_media_client()
if not media_client: if not media_client:
logger.error("No active media client available for web library scan") logger.error("No active media client available for web library scan")
self._reset_scan_state() self._reset_scan_state()
return return
self._current_server_type = server_type self._current_server_type = server_type
logger.info(f"🎵 Starting {server_type.upper()} library scan via web interface...") logger.info(f"Starting {server_type.upper()} library scan via web interface...")
try: try:
# Update progress # Update progress
@ -200,7 +200,7 @@ class WebScanManager:
success = media_client.trigger_library_scan() success = media_client.trigger_library_scan()
if success: if success:
logger.info(f"{server_type.upper()} library scan initiated successfully via web") logger.info(f"{server_type.upper()} library scan initiated successfully via web")
with self._lock: with self._lock:
self._scan_progress = { self._scan_progress = {
"status": "active", "status": "active",
@ -210,7 +210,7 @@ class WebScanManager:
# Start periodic completion checking # Start periodic completion checking
self._start_periodic_completion_check() self._start_periodic_completion_check()
else: else:
logger.error(f"Failed to initiate {server_type.upper()} library scan via web") logger.error(f"Failed to initiate {server_type.upper()} library scan via web")
with self._lock: with self._lock:
self._scan_progress = { self._scan_progress = {
"status": "failed", "status": "failed",
@ -219,7 +219,7 @@ class WebScanManager:
self._reset_scan_state() self._reset_scan_state()
except Exception as e: except Exception as e:
logger.error(f"Error during {server_type.upper()} library scan via web: {e}") logger.error(f"Error during {server_type.upper()} library scan via web: {e}")
with self._lock: with self._lock:
self._scan_progress = { self._scan_progress = {
"status": "error", "status": "error",
@ -265,7 +265,7 @@ class WebScanManager:
def _handle_scan_completion(self): def _handle_scan_completion(self):
"""Handle scan completion and trigger callbacks""" """Handle scan completion and trigger callbacks"""
logger.info(f"🏁 Web {self._current_server_type.upper()} library scan completed") logger.info(f"Web {self._current_server_type.upper()} library scan completed")
# Call completion callbacks # Call completion callbacks
callbacks_to_call = [] callbacks_to_call = []
@ -274,7 +274,7 @@ class WebScanManager:
for callback in callbacks_to_call: for callback in callbacks_to_call:
try: try:
logger.info(f"🔄 Calling web scan completion callback: {callback.__name__}") logger.info(f"Calling web scan completion callback: {callback.__name__}")
callback() callback()
except Exception as e: except Exception as e:
logger.error(f"Error in web scan completion callback {callback.__name__}: {e}") logger.error(f"Error in web scan completion callback {callback.__name__}: {e}")
@ -285,7 +285,7 @@ class WebScanManager:
# Check if we need another scan due to downloads during this scan # Check if we need another scan due to downloads during this scan
with self._lock: with self._lock:
if self._downloads_during_scan: if self._downloads_during_scan:
logger.info("🔄 Web scan follow-up needed for downloads during scan") logger.info("Web scan follow-up needed for downloads during scan")
self.request_scan("Follow-up scan for downloads during previous scan") self.request_scan("Follow-up scan for downloads during previous scan")
def _reset_scan_state(self): def _reset_scan_state(self):

View file

@ -107,7 +107,7 @@ class YouTubeClient:
self.download_path = Path(download_path) self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True) self.download_path.mkdir(parents=True, exist_ok=True)
logger.info(f"📁 YouTube client using download path: {self.download_path}") logger.info(f"YouTube client using download path: {self.download_path}")
# Callback for shutdown check (avoids circular imports) # Callback for shutdown check (avoids circular imports)
self.shutdown_check = None self.shutdown_check = None
@ -123,11 +123,11 @@ class YouTubeClient:
# Initialize production matching engine for parity with Soulseek # Initialize production matching engine for parity with Soulseek
self.matching_engine = MusicMatchingEngine() self.matching_engine = MusicMatchingEngine()
logger.info("Initialized production MusicMatchingEngine") logger.info("Initialized production MusicMatchingEngine")
# Check for ffmpeg (REQUIRED for MP3 conversion) # Check for ffmpeg (REQUIRED for MP3 conversion)
if not self._check_ffmpeg(): if not self._check_ffmpeg():
logger.error("ffmpeg is required but not found") logger.error("ffmpeg is required but not found")
logger.error("The client will attempt to auto-download ffmpeg on first use") logger.error("The client will attempt to auto-download ffmpeg on first use")
# Download queue management (mirrors Soulseek's download tracking) # Download queue management (mirrors Soulseek's download tracking)
@ -201,7 +201,7 @@ class YouTubeClient:
self.download_opts['cookiesfrombrowser'] = (cookies_browser,) self.download_opts['cookiesfrombrowser'] = (cookies_browser,)
elif 'cookiesfrombrowser' in self.download_opts: elif 'cookiesfrombrowser' in self.download_opts:
del self.download_opts['cookiesfrombrowser'] del self.download_opts['cookiesfrombrowser']
logger.info(f"🔄 YouTube settings reloaded (delay={self._download_delay}s, cookies={'enabled' if cookies_browser else 'disabled'})") logger.info(f"YouTube settings reloaded (delay={self._download_delay}s, cookies={'enabled' if cookies_browser else 'disabled'})")
async def check_connection(self) -> bool: async def check_connection(self) -> bool:
""" """
@ -356,7 +356,7 @@ class YouTubeClient:
# Check if ffmpeg is in system PATH # Check if ffmpeg is in system PATH
if shutil.which('ffmpeg'): if shutil.which('ffmpeg'):
logger.info("Found ffmpeg in system PATH") logger.info("Found ffmpeg in system PATH")
return True return True
# Auto-download ffmpeg to tools folder if not found # Auto-download ffmpeg to tools folder if not found
@ -373,7 +373,7 @@ class YouTubeClient:
# If we already have both locally, use them # If we already have both locally, use them
if ffmpeg_path.exists() and ffprobe_path.exists(): if ffmpeg_path.exists() and ffprobe_path.exists():
logger.info(f"Found ffmpeg and ffprobe in tools folder") logger.info(f"Found ffmpeg and ffprobe in tools folder")
# Add to PATH so yt-dlp can find them # Add to PATH so yt-dlp can find them
tools_dir_str = str(tools_dir.absolute()) tools_dir_str = str(tools_dir.absolute())
os.environ['PATH'] = tools_dir_str + os.pathsep + os.environ.get('PATH', '') os.environ['PATH'] = tools_dir_str + os.pathsep + os.environ.get('PATH', '')
@ -451,10 +451,10 @@ class YouTubeClient:
ffprobe_zip.unlink() # Clean up zip ffprobe_zip.unlink() # Clean up zip
else: else:
logger.error(f"Unsupported platform: {system}") logger.error(f"Unsupported platform: {system}")
return False return False
logger.info(f"Downloaded ffmpeg to: {ffmpeg_path}") logger.info(f"Downloaded ffmpeg to: {ffmpeg_path}")
# Add to PATH # Add to PATH
tools_dir_str = str(tools_dir.absolute()) tools_dir_str = str(tools_dir.absolute())
@ -463,7 +463,7 @@ class YouTubeClient:
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to download ffmpeg: {e}") logger.error(f"Failed to download ffmpeg: {e}")
logger.error(f" Please install manually:") logger.error(f" Please install manually:")
logger.error(f" Windows: scoop install ffmpeg") logger.error(f" Windows: scoop install ffmpeg")
logger.error(f" Linux: sudo apt install ffmpeg") logger.error(f" Linux: sudo apt install ffmpeg")
@ -568,7 +568,7 @@ class YouTubeClient:
this returns YouTubeSearchResult objects with video-specific metadata this returns YouTubeSearchResult objects with video-specific metadata
(thumbnails, view counts, channel names) for UI display. (thumbnails, view counts, channel names) for UI display.
""" """
logger.info(f"🎬 Searching YouTube videos for: {query}") logger.info(f"Searching YouTube videos for: {query}")
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@ -643,7 +643,7 @@ class YouTubeClient:
Returns: Returns:
Tuple of (track_results, album_results). Album results will always be empty for YouTube. Tuple of (track_results, album_results). Album results will always be empty for YouTube.
""" """
logger.info(f"🔍 Searching YouTube for: {query}") logger.info(f"Searching YouTube for: {query}")
try: try:
# Run yt-dlp in executor to avoid blocking event loop # Run yt-dlp in executor to avoid blocking event loop
@ -693,13 +693,13 @@ class YouTubeClient:
track_result = self._youtube_to_track_result(entry, best_audio) track_result = self._youtube_to_track_result(entry, best_audio)
track_results.append(track_result) track_results.append(track_result)
logger.info(f"Found {len(track_results)} YouTube tracks") logger.info(f"Found {len(track_results)} YouTube tracks")
# Return tuple: (tracks, albums) - YouTube doesn't have albums, so return empty list # Return tuple: (tracks, albums) - YouTube doesn't have albums, so return empty list
return (track_results, []) return (track_results, [])
except Exception as e: except Exception as e:
logger.error(f"YouTube search failed: {e}") logger.error(f"YouTube search failed: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return ([], []) return ([], [])
@ -846,7 +846,7 @@ class YouTubeClient:
# Sort by confidence (best first) # Sort by confidence (best first)
matches.sort(key=lambda r: r.confidence, reverse=True) matches.sort(key=lambda r: r.confidence, reverse=True)
logger.info(f"Found {len(matches)} matches above {min_confidence} confidence") logger.info(f"Found {len(matches)} matches above {min_confidence} confidence")
return matches return matches
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]: async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
@ -867,13 +867,13 @@ class YouTubeClient:
try: try:
# Parse filename to extract video_id # Parse filename to extract video_id
if '||' not in filename: if '||' not in filename:
logger.error(f"Invalid filename format: {filename}") logger.error(f"Invalid filename format: {filename}")
return None return None
video_id, title = filename.split('||', 1) video_id, title = filename.split('||', 1)
youtube_url = f"https://www.youtube.com/watch?v={video_id}" youtube_url = f"https://www.youtube.com/watch?v={video_id}"
logger.info(f"📥 Starting YouTube download: {title}") logger.info(f"Starting YouTube download: {title}")
logger.info(f" URL: {youtube_url}") logger.info(f" URL: {youtube_url}")
# Create unique download ID # Create unique download ID
@ -905,11 +905,11 @@ class YouTubeClient:
) )
download_thread.start() download_thread.start()
logger.info(f"YouTube download {download_id} started in background") logger.info(f"YouTube download {download_id} started in background")
return download_id return download_id
except Exception as e: except Exception as e:
logger.error(f"Failed to start YouTube download: {e}") logger.error(f"Failed to start YouTube download: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return None return None
@ -926,7 +926,7 @@ class YouTubeClient:
elapsed = time.time() - self._last_download_time elapsed = time.time() - self._last_download_time
if self._last_download_time > 0 and elapsed < self._download_delay: if self._last_download_time > 0 and elapsed < self._download_delay:
wait_time = self._download_delay - elapsed wait_time = self._download_delay - elapsed
logger.info(f"Rate limiting: waiting {wait_time:.1f}s before next YouTube download") logger.info(f"Rate limiting: waiting {wait_time:.1f}s before next YouTube download")
time.sleep(wait_time) time.sleep(wait_time)
# Update state to downloading # Update state to downloading
@ -958,17 +958,17 @@ class YouTubeClient:
self.active_downloads[download_id]['file_path'] = file_path self.active_downloads[download_id]['file_path'] = file_path
# DO NOT update filename - keep original_filename for context matching # DO NOT update filename - keep original_filename for context matching
logger.info(f"YouTube download {download_id} completed: {file_path}") logger.info(f"YouTube download {download_id} completed: {file_path}")
else: else:
# Mark as errored # Mark as errored
with self._download_lock: with self._download_lock:
if download_id in self.active_downloads: if download_id in self.active_downloads:
self.active_downloads[download_id]['state'] = 'Errored' self.active_downloads[download_id]['state'] = 'Errored'
logger.error(f"YouTube download {download_id} failed") logger.error(f"YouTube download {download_id} failed")
except Exception as e: except Exception as e:
logger.error(f"YouTube download thread failed for {download_id}: {e}") logger.error(f"YouTube download thread failed for {download_id}: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@ -997,7 +997,7 @@ class YouTubeClient:
for attempt in range(max_retries): for attempt in range(max_retries):
# Check for server shutdown using callback # Check for server shutdown using callback
if self.shutdown_check and self.shutdown_check(): if self.shutdown_check and self.shutdown_check():
logger.info(f"🛑 Server shutting down, aborting download attempt {attempt + 1}") logger.info(f"Server shutting down, aborting download attempt {attempt + 1}")
return None return None
try: try:
@ -1012,15 +1012,15 @@ class YouTubeClient:
if attempt == 1: if attempt == 1:
# Drop browser cookies — authenticated sessions sometimes get restricted formats # Drop browser cookies — authenticated sessions sometimes get restricted formats
if 'cookiesfrombrowser' in download_opts: if 'cookiesfrombrowser' in download_opts:
logger.info(f"🔄 Retry {attempt + 1}/{max_retries} without browser cookies") logger.info(f"Retry {attempt + 1}/{max_retries} without browser cookies")
download_opts.pop('cookiesfrombrowser', None) download_opts.pop('cookiesfrombrowser', None)
else: else:
logger.info(f"🔄 Retry {attempt + 1}/{max_retries} with web_creator client") logger.info(f"Retry {attempt + 1}/{max_retries} with web_creator client")
download_opts['extractor_args'] = { download_opts['extractor_args'] = {
'youtube': { 'player_client': ['web_creator'] } 'youtube': { 'player_client': ['web_creator'] }
} }
elif attempt >= 2: elif attempt >= 2:
logger.info(f"🔄 Retry {attempt + 1}/{max_retries} with 'best' format (video fallback)") logger.info(f"Retry {attempt + 1}/{max_retries} with 'best' format (video fallback)")
download_opts['format'] = 'best' download_opts['format'] = 'best'
download_opts.pop('cookiesfrombrowser', None) download_opts.pop('cookiesfrombrowser', None)
download_opts.pop('extractor_args', None) download_opts.pop('extractor_args', None)
@ -1036,19 +1036,19 @@ class YouTubeClient:
if filename.exists(): if filename.exists():
return str(filename) return str(filename)
else: else:
logger.error(f"Download completed but file not found: {filename}") logger.error(f"Download completed but file not found: {filename}")
if attempt < max_retries - 1: if attempt < max_retries - 1:
continue # Retry continue # Retry
return None return None
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
logger.error(f"Download attempt {attempt + 1} failed: {error_msg}") logger.error(f"Download attempt {attempt + 1} failed: {error_msg}")
# Check if it's a 403 error # Check if it's a 403 error
if '403' in error_msg or 'Forbidden' in error_msg: if '403' in error_msg or 'Forbidden' in error_msg:
if attempt < max_retries - 1: if attempt < max_retries - 1:
logger.info(f"Waiting 2 seconds before retry...") logger.info(f"Waiting 2 seconds before retry...")
import time import time
time.sleep(2) time.sleep(2)
continue # Retry on 403 continue # Retry on 403
@ -1065,7 +1065,7 @@ class YouTubeClient:
return None # All retries failed return None # All retries failed
except Exception as e: except Exception as e:
logger.error(f"Download failed: {e}") logger.error(f"Download failed: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return None return None
@ -1204,7 +1204,7 @@ class YouTubeClient:
# Remove them # Remove them
for download_id in ids_to_remove: for download_id in ids_to_remove:
del self.active_downloads[download_id] del self.active_downloads[download_id]
logger.debug(f"🗑️ Cleared finished download {download_id}") logger.debug(f"Cleared finished download {download_id}")
return True return True
except Exception as e: except Exception as e:
@ -1229,22 +1229,22 @@ class YouTubeClient:
try: try:
with self._download_lock: with self._download_lock:
if download_id not in self.active_downloads: if download_id not in self.active_downloads:
logger.warning(f"⚠️ Download {download_id} not found") logger.warning(f"Download {download_id} not found")
return False return False
# Update state to cancelled # Update state to cancelled
self.active_downloads[download_id]['state'] = 'Cancelled' self.active_downloads[download_id]['state'] = 'Cancelled'
logger.info(f"⚠️ Marked YouTube download {download_id} as cancelled") logger.info(f"Marked YouTube download {download_id} as cancelled")
# Remove from active downloads if requested # Remove from active downloads if requested
if remove: if remove:
del self.active_downloads[download_id] del self.active_downloads[download_id]
logger.info(f"🗑️ Removed YouTube download {download_id} from queue") logger.info(f"Removed YouTube download {download_id} from queue")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to cancel download {download_id}: {e}") logger.error(f"Failed to cancel download {download_id}: {e}")
return False return False
def _enhance_metadata(self, filepath: str, spotify_track: Optional[SpotifyTrack], yt_result: YouTubeSearchResult, track_number: int = 1, disc_number: int = 1, release_year: str = None, artist_genres: list = None): def _enhance_metadata(self, filepath: str, spotify_track: Optional[SpotifyTrack], yt_result: YouTubeSearchResult, track_number: int = 1, disc_number: int = 1, release_year: str = None, artist_genres: list = None):
@ -1258,7 +1258,7 @@ class YouTubeClient:
from mutagen.id3 import ID3NoHeaderError from mutagen.id3 import ID3NoHeaderError
import requests import requests
logger.info(f"🏷️ Enhancing metadata for: {Path(filepath).name}") logger.info(f"Enhancing metadata for: {Path(filepath).name}")
# Load MP3 file # Load MP3 file
audio = MP3(filepath) audio = MP3(filepath)
@ -1267,11 +1267,11 @@ class YouTubeClient:
if audio.tags is not None: if audio.tags is not None:
# Delete ALL existing frames # Delete ALL existing frames
audio.tags.clear() audio.tags.clear()
logger.debug(f" 🧹 Cleared all existing tag frames") logger.debug(f" Cleared all existing tag frames")
else: else:
# No tags exist, add them # No tags exist, add them
audio.add_tags() audio.add_tags()
logger.debug(f" Added new tag structure") logger.debug(f" Added new tag structure")
if spotify_track: if spotify_track:
# Use Spotify metadata # Use Spotify metadata
@ -1295,7 +1295,7 @@ class YouTubeClient:
except: except:
pass pass
logger.debug(f" 📝 Setting metadata tags...") logger.debug(f" Setting metadata tags...")
# Set ID3 tags (using setall to ensure they're set) # Set ID3 tags (using setall to ensure they're set)
audio.tags.setall('TIT2', [TIT2(encoding=3, text=title)]) audio.tags.setall('TIT2', [TIT2(encoding=3, text=title)])
@ -1314,21 +1314,21 @@ class YouTubeClient:
# Combine up to 3 genres (matches production logic) # Combine up to 3 genres (matches production logic)
genre = ', '.join(artist_genres[:3]) genre = ', '.join(artist_genres[:3])
audio.tags.setall('TCON', [TCON(encoding=3, text=genre)]) audio.tags.setall('TCON', [TCON(encoding=3, text=genre)])
logger.debug(f" Genre: {genre}") logger.debug(f" Genre: {genre}")
audio.tags.setall('COMM', [COMM(encoding=3, lang='eng', desc='', audio.tags.setall('COMM', [COMM(encoding=3, lang='eng', desc='',
text=f'Downloaded via SoulSync (YouTube)\nSource: {yt_result.url}\nConfidence: {yt_result.confidence:.2f}')]) text=f'Downloaded via SoulSync (YouTube)\nSource: {yt_result.url}\nConfidence: {yt_result.confidence:.2f}')])
logger.debug(f" Artist: {artist}") logger.debug(f" Artist: {artist}")
logger.debug(f" Album Artist: {album_artist}") logger.debug(f" Album Artist: {album_artist}")
logger.debug(f" Title: {title}") logger.debug(f" Title: {title}")
logger.debug(f" Album: {album}") logger.debug(f" Album: {album}")
logger.debug(f" Track #: {track_number}") logger.debug(f" Track #: {track_number}")
logger.debug(f" Disc #: {disc_number}") logger.debug(f" Disc #: {disc_number}")
logger.debug(f" Year: {year}") logger.debug(f" Year: {year}")
# Fetch and embed album art from Spotify (via search) # Fetch and embed album art from Spotify (via search)
logger.debug(f" 🎨 Fetching album art from Spotify...") logger.debug(f" Fetching album art from Spotify...")
album_art_url = self._get_spotify_album_art(spotify_track) album_art_url = self._get_spotify_album_art(spotify_track)
if album_art_url: if album_art_url:
@ -1354,25 +1354,25 @@ class YouTubeClient:
data=response.content data=response.content
)) ))
logger.debug(f" Album art embedded ({len(response.content) // 1024} KB)") logger.debug(f" Album art embedded ({len(response.content) // 1024} KB)")
except Exception as art_error: except Exception as art_error:
logger.warning(f" ⚠️ Could not embed album art: {art_error}") logger.warning(f" Could not embed album art: {art_error}")
else: else:
logger.warning(f" ⚠️ No album art found on Spotify") logger.warning(f" No album art found on Spotify")
# Save all tags # Save all tags
audio.save() audio.save()
logger.info(f"Metadata enhanced successfully") logger.info(f"Metadata enhanced successfully")
# Return album art URL for cover.jpg creation # Return album art URL for cover.jpg creation
return album_art_url return album_art_url
except ImportError: except ImportError:
logger.warning("⚠️ mutagen not installed - skipping enhanced metadata tagging") logger.warning("mutagen not installed - skipping enhanced metadata tagging")
logger.warning(" Install with: pip install mutagen") logger.warning(" Install with: pip install mutagen")
return None return None
except Exception as e: except Exception as e:
logger.warning(f"⚠️ Could not enhance metadata: {e}") logger.warning(f"Could not enhance metadata: {e}")
return None return None
def _get_spotify_album_art(self, spotify_track: SpotifyTrack) -> Optional[str]: def _get_spotify_album_art(self, spotify_track: SpotifyTrack) -> Optional[str]:
@ -1409,7 +1409,7 @@ class YouTubeClient:
logger.debug(f" cover.jpg already exists, skipping") logger.debug(f" cover.jpg already exists, skipping")
return return
logger.debug(f" 📥 Downloading cover.jpg...") logger.debug(f" Downloading cover.jpg...")
response = requests.get(album_art_url, timeout=10) response = requests.get(album_art_url, timeout=10)
response.raise_for_status() response.raise_for_status()
@ -1417,10 +1417,10 @@ class YouTubeClient:
# Save to file # Save to file
cover_path.write_bytes(response.content) cover_path.write_bytes(response.content)
logger.debug(f" Saved cover.jpg ({len(response.content) // 1024} KB)") logger.debug(f" Saved cover.jpg ({len(response.content) // 1024} KB)")
except Exception as e: except Exception as e:
logger.warning(f" ⚠️ Could not save cover.jpg: {e}") logger.warning(f" Could not save cover.jpg: {e}")
def _create_lyrics_file(self, audio_file_path: str, spotify_track: SpotifyTrack): def _create_lyrics_file(self, audio_file_path: str, spotify_track: SpotifyTrack):
""" """
@ -1431,10 +1431,10 @@ class YouTubeClient:
from core.lyrics_client import lyrics_client from core.lyrics_client import lyrics_client
if not lyrics_client.api: if not lyrics_client.api:
logger.debug(f" 🎵 LRClib API not available - skipping lyrics") logger.debug(f" LRClib API not available - skipping lyrics")
return return
logger.debug(f" 🎵 Fetching lyrics from LRClib...") logger.debug(f" Fetching lyrics from LRClib...")
# Get track metadata # Get track metadata
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist" artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
@ -1452,14 +1452,14 @@ class YouTubeClient:
) )
if success: if success:
logger.debug(f" Created .lrc lyrics file") logger.debug(f" Created .lrc lyrics file")
else: else:
logger.debug(f" 🎵 No lyrics found on LRClib") logger.debug(f" No lyrics found on LRClib")
except ImportError: except ImportError:
logger.debug(f" ⚠️ lyrics_client not available - skipping lyrics") logger.debug(f" lyrics_client not available - skipping lyrics")
except Exception as e: except Exception as e:
logger.warning(f" ⚠️ Could not create lyrics file: {e}") logger.warning(f" Could not create lyrics file: {e}")
def search_and_download_best(self, spotify_track: SpotifyTrack, min_confidence: float = 0.58) -> Optional[str]: def search_and_download_best(self, spotify_track: SpotifyTrack, min_confidence: float = 0.58) -> Optional[str]:
""" """
@ -1473,7 +1473,7 @@ class YouTubeClient:
Returns: Returns:
Path to downloaded file, or None if failed Path to downloaded file, or None if failed
""" """
logger.info(f"🎯 Starting YouTube download flow for: {spotify_track.name} by {spotify_track.artists[0]}") logger.info(f"Starting YouTube download flow for: {spotify_track.name} by {spotify_track.artists[0]}")
# Generate search query # Generate search query
query = f"{spotify_track.artists[0]} {spotify_track.name}" query = f"{spotify_track.artists[0]} {spotify_track.name}"
@ -1482,19 +1482,19 @@ class YouTubeClient:
results = self.search(query, max_results=10) results = self.search(query, max_results=10)
if not results: if not results:
logger.error(f"No YouTube results found for query: {query}") logger.error(f"No YouTube results found for query: {query}")
return None return None
# Find best matches # Find best matches
matches = self.find_best_matches(spotify_track, results, min_confidence=min_confidence) matches = self.find_best_matches(spotify_track, results, min_confidence=min_confidence)
if not matches: if not matches:
logger.error(f"No matches above {min_confidence} confidence threshold") logger.error(f"No matches above {min_confidence} confidence threshold")
return None return None
# Try downloading best match # Try downloading best match
best_match = matches[0] best_match = matches[0]
logger.info(f"🎯 Best match: {best_match.title} (confidence: {best_match.confidence:.2f})") logger.info(f"Best match: {best_match.title} (confidence: {best_match.confidence:.2f})")
downloaded_file = self.download(best_match, spotify_track) downloaded_file = self.download(best_match, spotify_track)

View file

@ -1524,7 +1524,7 @@ class MusicDatabase:
cursor.execute("ALTER TABLE artists ADD COLUMN musicbrainz_match_status TEXT") cursor.execute("ALTER TABLE artists ADD COLUMN musicbrainz_match_status TEXT")
columns_added = True columns_added = True
if columns_added: if columns_added:
logger.info("Added MusicBrainz columns to artists table") logger.info("Added MusicBrainz columns to artists table")
# --- Albums --- # --- Albums ---
cursor.execute("PRAGMA table_info(albums)") cursor.execute("PRAGMA table_info(albums)")
@ -1542,7 +1542,7 @@ class MusicDatabase:
added_albums = True added_albums = True
if added_albums: if added_albums:
columns_added = True columns_added = True
logger.info("Added MusicBrainz columns to albums table") logger.info("Added MusicBrainz columns to albums table")
# --- Tracks --- # --- Tracks ---
cursor.execute("PRAGMA table_info(tracks)") cursor.execute("PRAGMA table_info(tracks)")
@ -1560,7 +1560,7 @@ class MusicDatabase:
added_tracks = True added_tracks = True
if added_tracks: if added_tracks:
columns_added = True columns_added = True
logger.info("Added MusicBrainz columns to tracks table") logger.info("Added MusicBrainz columns to tracks table")
# Create MusicBrainz cache table for storing API results # Create MusicBrainz cache table for storing API results
cursor.execute(""" cursor.execute("""
@ -1593,7 +1593,7 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mb_cache_failed ON musicbrainz_cache (entity_type, last_updated) WHERE musicbrainz_id IS NULL") cursor.execute("CREATE INDEX IF NOT EXISTS idx_mb_cache_failed ON musicbrainz_cache (entity_type, last_updated) WHERE musicbrainz_id IS NULL")
if columns_added: if columns_added:
logger.info("🎉 MusicBrainz migration completed successfully") logger.info("MusicBrainz migration completed successfully")
except Exception as e: except Exception as e:
logger.error(f"Error in MusicBrainz migration: {e}") logger.error(f"Error in MusicBrainz migration: {e}")
@ -2902,7 +2902,7 @@ class MusicDatabase:
cleared = cursor.rowcount cleared = cursor.rowcount
cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES ('soulid_v2_migration', '1')") cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES ('soulid_v2_migration', '1')")
if cleared > 0: if cleared > 0:
logger.info(f"🔄 SoulID v2 migration: cleared {cleared} artist soul_ids for regeneration") logger.info(f"SoulID v2 migration: cleared {cleared} artist soul_ids for regeneration")
except Exception as e: except Exception as e:
logger.error(f"Error adding soul_id columns: {e}") logger.error(f"Error adding soul_id columns: {e}")
@ -3932,7 +3932,7 @@ class MusicDatabase:
DELETE FROM artists DELETE FROM artists
WHERE id NOT IN (SELECT DISTINCT artist_id FROM tracks WHERE artist_id IS NOT NULL) WHERE id NOT IN (SELECT DISTINCT artist_id FROM tracks WHERE artist_id IS NOT NULL)
""") """)
logger.info(f"🧹 Removed {orphaned_artists_count} orphaned artists") logger.info(f"Removed {orphaned_artists_count} orphaned artists")
# Delete orphaned albums # Delete orphaned albums
if orphaned_albums_count > 0: if orphaned_albums_count > 0:
@ -3940,7 +3940,7 @@ class MusicDatabase:
DELETE FROM albums DELETE FROM albums
WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks WHERE album_id IS NOT NULL) WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks WHERE album_id IS NOT NULL)
""") """)
logger.info(f"🧹 Removed {orphaned_albums_count} orphaned albums") logger.info(f"Removed {orphaned_albums_count} orphaned albums")
conn.commit() conn.commit()
@ -3973,7 +3973,7 @@ class MusicDatabase:
duplicate_groups = cursor.fetchall() duplicate_groups = cursor.fetchall()
if not duplicate_groups: if not duplicate_groups:
logger.debug("🧹 No duplicate artists found") logger.debug("No duplicate artists found")
return {'artists_merged': 0, 'albums_migrated': 0} return {'artists_merged': 0, 'albums_migrated': 0}
total_merged = 0 total_merged = 0
@ -3993,7 +3993,7 @@ class MusicDatabase:
server_source = group['server_source'] server_source = group['server_source']
ids = group['ids'].split(',') ids = group['ids'].split(',')
logger.info(f"🔄 Merging duplicate artist '{artist_name}' ({server_source}): IDs {ids}") logger.info(f"Merging duplicate artist '{artist_name}' ({server_source}): IDs {ids}")
# Pick the keeper: the one with the most enrichment data # Pick the keeper: the one with the most enrichment data
best_id = ids[0] best_id = ids[0]
@ -4061,7 +4061,7 @@ class MusicDatabase:
conn.commit() conn.commit()
if total_merged > 0: if total_merged > 0:
logger.info(f"🧹 Duplicate merge complete: {total_merged} duplicates merged, {total_albums_migrated} albums migrated") logger.info(f"Duplicate merge complete: {total_merged} duplicates merged, {total_albums_migrated} albums migrated")
return {'artists_merged': total_merged, 'albums_migrated': total_albums_migrated} return {'artists_merged': total_merged, 'albums_migrated': total_albums_migrated}
@ -4292,7 +4292,7 @@ class MusicDatabase:
if existing_by_name: if existing_by_name:
old_id = existing_by_name['id'] old_id = existing_by_name['id']
# ratingKey changed — migrate old artist to new ID, preserving enrichment data # ratingKey changed — migrate old artist to new ID, preserving enrichment data
logger.info(f"🔄 Artist ratingKey migrated: '{name}' ({old_id}{artist_id})") logger.info(f"Artist ratingKey migrated: '{name}' ({old_id}{artist_id})")
# Step 1: Insert new artist record, copying enrichment data from old # Step 1: Insert new artist record, copying enrichment data from old
enrichment_cols = [ enrichment_cols = [
@ -4463,7 +4463,7 @@ class MusicDatabase:
if existing_by_title: if existing_by_title:
old_id = existing_by_title['id'] old_id = existing_by_title['id']
# ratingKey changed — migrate old album to new ID, preserving enrichment data # ratingKey changed — migrate old album to new ID, preserving enrichment data
logger.info(f"🔄 Album ratingKey migrated: '{title}' ({old_id}{album_id})") logger.info(f"Album ratingKey migrated: '{title}' ({old_id}{album_id})")
enrichment_cols = [ enrichment_cols = [
'musicbrainz_release_id', 'musicbrainz_last_attempted', 'musicbrainz_match_status', 'musicbrainz_release_id', 'musicbrainz_last_attempted', 'musicbrainz_match_status',
@ -4857,13 +4857,13 @@ class MusicDatabase:
basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source) basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source)
if basic_results: if basic_results:
logger.debug(f"🔍 Basic search found {len(basic_results)} results") logger.debug(f"Basic search found {len(basic_results)} results")
return basic_results return basic_results
# STRATEGY 2: Broader fuzzy search - splits into individual words with OR matching # STRATEGY 2: Broader fuzzy search - splits into individual words with OR matching
fuzzy_results = self._search_tracks_fuzzy_fallback(cursor, title, artist, limit, server_source) fuzzy_results = self._search_tracks_fuzzy_fallback(cursor, title, artist, limit, server_source)
if fuzzy_results: if fuzzy_results:
logger.debug(f"🔍 Fuzzy fallback search found {len(fuzzy_results)} results") logger.debug(f"Fuzzy fallback search found {len(fuzzy_results)} results")
return fuzzy_results return fuzzy_results
@ -5108,7 +5108,7 @@ class MusicDatabase:
# Generate title variations for better matching (similar to album approach) # Generate title variations for better matching (similar to album approach)
title_variations = self._generate_track_title_variations(title) title_variations = self._generate_track_title_variations(title)
logger.debug(f"🔍 Enhanced track matching for '{title}' by '{artist}': trying {len(title_variations)} variations") logger.debug(f"Enhanced track matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
for i, var in enumerate(title_variations): for i, var in enumerate(title_variations):
logger.debug(f" {i+1}. '{var}'") logger.debug(f" {i+1}. '{var}'")
@ -5126,12 +5126,12 @@ class MusicDatabase:
if not potential_matches: if not potential_matches:
continue continue
logger.debug(f"🎵 Found {len(potential_matches)} tracks for variation '{title_variation}'") logger.debug(f"Found {len(potential_matches)} tracks for variation '{title_variation}'")
# Score each potential match # Score each potential match
for track in potential_matches: for track in potential_matches:
confidence = self._calculate_track_confidence(title, artist, track) confidence = self._calculate_track_confidence(title, artist, track)
logger.debug(f" 🎯 '{track.title}' confidence: {confidence:.3f}") logger.debug(f" '{track.title}' confidence: {confidence:.3f}")
if confidence > best_confidence: if confidence > best_confidence:
best_confidence = confidence best_confidence = confidence
@ -5139,13 +5139,13 @@ class MusicDatabase:
# Return match only if it meets threshold # Return match only if it meets threshold
if best_match and best_confidence >= confidence_threshold: if best_match and best_confidence >= confidence_threshold:
logger.debug(f"Enhanced track match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})") logger.debug(f"Enhanced track match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
return best_match, best_confidence return best_match, best_confidence
# Album-aware fallback: find album by title (any artist), check tracks on it # Album-aware fallback: find album by title (any artist), check tracks on it
# Handles multi-artist albums filed under a different artist in the library # Handles multi-artist albums filed under a different artist in the library
if album and best_confidence < confidence_threshold: if album and best_confidence < confidence_threshold:
logger.debug(f"⚠️ Artist-specific search failed, trying album-aware fallback: '{title}' on '{album}'") logger.debug(f"Artist-specific search failed, trying album-aware fallback: '{title}' on '{album}'")
try: try:
album_candidates = self.search_albums(title=album, artist="", limit=10, server_source=server_source) album_candidates = self.search_albums(title=album, artist="", limit=10, server_source=server_source)
for album_candidate in album_candidates: for album_candidate in album_candidates:
@ -5185,12 +5185,12 @@ class MusicDatabase:
best_match = db_track best_match = db_track
if best_match and best_confidence >= 0.7: if best_match and best_confidence >= 0.7:
logger.debug(f"Album-aware fallback matched: '{title}' on '{album}' -> '{best_match.title}' by '{best_match.artist_name}' (title_sim: {best_confidence:.3f})") logger.debug(f"Album-aware fallback matched: '{title}' on '{album}' -> '{best_match.title}' by '{best_match.artist_name}' (title_sim: {best_confidence:.3f})")
return best_match, best_confidence return best_match, best_confidence
except Exception as album_fallback_err: except Exception as album_fallback_err:
logger.debug(f"Album-aware fallback error: {album_fallback_err}") logger.debug(f"Album-aware fallback error: {album_fallback_err}")
logger.debug(f"No confident track match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})") logger.debug(f"No confident track match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})")
return None, best_confidence return None, best_confidence
except Exception as e: except Exception as e:
@ -5441,7 +5441,7 @@ class MusicDatabase:
# Generate album title variations for edition matching # Generate album title variations for edition matching
title_variations = self._generate_album_title_variations(title) title_variations = self._generate_album_title_variations(title)
logger.debug(f"🔍 Edition matching for '{title}' by '{artist}': trying {len(title_variations)} variations") logger.debug(f"Edition matching for '{title}' by '{artist}': trying {len(title_variations)} variations")
for i, var in enumerate(title_variations): for i, var in enumerate(title_variations):
logger.debug(f" {i+1}. '{var}'") logger.debug(f" {i+1}. '{var}'")
@ -5462,7 +5462,7 @@ class MusicDatabase:
existing_ids.add(album.id) existing_ids.add(album.id)
if albums: if albums:
logger.debug(f"📀 Found {len(albums)} albums for variation '{variation}'") logger.debug(f"Found {len(albums)} albums for variation '{variation}'")
if not albums: if not albums:
continue continue
@ -5470,7 +5470,7 @@ class MusicDatabase:
# Score each potential match with Smart Edition Matching # Score each potential match with Smart Edition Matching
for album in albums: for album in albums:
confidence = self._calculate_album_confidence(title, artist, album, expected_track_count) confidence = self._calculate_album_confidence(title, artist, album, expected_track_count)
logger.debug(f" 🎯 '{album.title}' confidence: {confidence:.3f}") logger.debug(f" '{album.title}' confidence: {confidence:.3f}")
if confidence > best_confidence: if confidence > best_confidence:
best_confidence = confidence best_confidence = confidence
@ -5478,13 +5478,13 @@ class MusicDatabase:
# Return match only if it meets threshold # Return match only if it meets threshold
if best_match and best_confidence >= confidence_threshold: if best_match and best_confidence >= confidence_threshold:
logger.debug(f"Edition match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})") logger.debug(f"Edition match found: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
return best_match, best_confidence return best_match, best_confidence
# Fallback: Check ALL albums by this artist (resolves SQL accent sensitivity issues #101) # Fallback: Check ALL albums by this artist (resolves SQL accent sensitivity issues #101)
# If we haven't found a match yet, fetch broader list from artist and double check # If we haven't found a match yet, fetch broader list from artist and double check
if best_confidence < confidence_threshold: if best_confidence < confidence_threshold:
logger.debug(f"⚠️ specific title search failed, trying broad artist search fallback for '{artist}'") logger.debug(f"specific title search failed, trying broad artist search fallback for '{artist}'")
try: try:
# Get ALL albums by this artist (limit 100 to be safe) # Get ALL albums by this artist (limit 100 to be safe)
# This bypasses SQL 'LIKE' limitations for diacritics (e.g. 'ă' vs 'a') # This bypasses SQL 'LIKE' limitations for diacritics (e.g. 'ă' vs 'a')
@ -5508,18 +5508,18 @@ class MusicDatabase:
if confidence > best_confidence: if confidence > best_confidence:
best_confidence = confidence best_confidence = confidence
best_match = album best_match = album
logger.debug(f" 🎯 Fallback match: '{album.title}' confidence: {confidence:.3f}") logger.debug(f" Fallback match: '{album.title}' confidence: {confidence:.3f}")
except Exception as fallback_error: except Exception as fallback_error:
logger.warning(f"Fallback artist search failed: {fallback_error}") logger.warning(f"Fallback artist search failed: {fallback_error}")
if best_match and best_confidence >= confidence_threshold: if best_match and best_confidence >= confidence_threshold:
logger.debug(f"Fallback match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})") logger.debug(f"Fallback match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
return best_match, best_confidence return best_match, best_confidence
# Multi-artist fallback: search by title only (any artist) # Multi-artist fallback: search by title only (any artist)
# Handles collaborative albums filed under a different artist in the library # Handles collaborative albums filed under a different artist in the library
if best_confidence < confidence_threshold: if best_confidence < confidence_threshold:
logger.debug(f"⚠️ Artist-specific search failed, trying title-only fallback for '{title}'") logger.debug(f"Artist-specific search failed, trying title-only fallback for '{title}'")
try: try:
title_only_albums = self.search_albums(title=title, artist="", limit=20, server_source=server_source) title_only_albums = self.search_albums(title=title, artist="", limit=20, server_source=server_source)
for album in title_only_albums: for album in title_only_albums:
@ -5528,15 +5528,15 @@ class MusicDatabase:
if confidence > best_confidence: if confidence > best_confidence:
best_confidence = confidence best_confidence = confidence
best_match = album best_match = album
logger.debug(f" 🎯 Title-only match: '{album.title}' (confidence: {confidence:.3f})") logger.debug(f" Title-only match: '{album.title}' (confidence: {confidence:.3f})")
except Exception as title_error: except Exception as title_error:
logger.warning(f"Title-only fallback search failed: {title_error}") logger.warning(f"Title-only fallback search failed: {title_error}")
if best_match and best_confidence >= confidence_threshold: if best_match and best_confidence >= confidence_threshold:
logger.debug(f"Title-only match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})") logger.debug(f"Title-only match succeeded: '{title}' -> '{best_match.title}' (confidence: {best_confidence:.3f})")
return best_match, best_confidence return best_match, best_confidence
logger.debug(f"No confident edition match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})") logger.debug(f"No confident edition match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})")
return None, best_confidence return None, best_confidence
except Exception as e: except Exception as e:
@ -5662,7 +5662,7 @@ class MusicDatabase:
# Log when normalized matching helps (only if it's the best score and better than others) # Log when normalized matching helps (only if it's the best score and better than others)
if normalized_title_similarity == best_title_similarity and normalized_title_similarity > max(title_similarity, clean_title_similarity): if normalized_title_similarity == best_title_similarity and normalized_title_similarity > max(title_similarity, clean_title_similarity):
logger.debug(f" 🌍 Diacritic normalization improved match: '{search_title}' -> '{db_album.title}' (normalized: {normalized_title_similarity:.3f} vs raw: {title_similarity:.3f})") logger.debug(f" Diacritic normalization improved match: '{search_title}' -> '{db_album.title}' (normalized: {normalized_title_similarity:.3f} vs raw: {title_similarity:.3f})")
# Require minimum title similarity to prevent a perfect artist match from # Require minimum title similarity to prevent a perfect artist match from
# carrying a bad title match over the threshold (e.g. "divisions" vs "silos") # carrying a bad title match over the threshold (e.g. "divisions" vs "silos")
@ -5684,12 +5684,12 @@ class MusicDatabase:
# Found same/better edition (e.g., Deluxe when searching for Standard) # Found same/better edition (e.g., Deluxe when searching for Standard)
edition_bonus = min(0.15, (db_album.track_count - expected_track_count) / expected_track_count * 0.1) edition_bonus = min(0.15, (db_album.track_count - expected_track_count) / expected_track_count * 0.1)
confidence += edition_bonus confidence += edition_bonus
logger.debug(f" 📀 Edition upgrade bonus: +{edition_bonus:.3f} ({db_album.track_count} >= {expected_track_count} tracks)") logger.debug(f" Edition upgrade bonus: +{edition_bonus:.3f} ({db_album.track_count} >= {expected_track_count} tracks)")
elif db_album.track_count < expected_track_count * 0.8: elif db_album.track_count < expected_track_count * 0.8:
# Found significantly smaller edition, apply penalty # Found significantly smaller edition, apply penalty
edition_penalty = 0.1 edition_penalty = 0.1
confidence -= edition_penalty confidence -= edition_penalty
logger.debug(f" 📀 Edition downgrade penalty: -{edition_penalty:.3f} ({db_album.track_count} << {expected_track_count} tracks)") logger.debug(f" Edition downgrade penalty: -{edition_penalty:.3f} ({db_album.track_count} << {expected_track_count} tracks)")
return min(confidence, 1.0) # Cap at 1.0 return min(confidence, 1.0) # Cap at 1.0
@ -5800,7 +5800,7 @@ class MusicDatabase:
# Debug logging for Unicode normalization # Debug logging for Unicode normalization
if search_title != search_title_norm or search_artist != search_artist_norm or \ if search_title != search_title_norm or search_artist != search_artist_norm or \
db_track.title != db_title_norm or db_track.artist_name != db_artist_norm: db_track.title != db_title_norm or db_track.artist_name != db_artist_norm:
logger.debug(f"🔤 Unicode normalization:") logger.debug(f"Unicode normalization:")
logger.debug(f" Search: '{search_title}''{search_title_norm}' | '{search_artist}''{search_artist_norm}'") logger.debug(f" Search: '{search_title}''{search_title_norm}' | '{search_artist}''{search_artist_norm}'")
logger.debug(f" Database: '{db_track.title}''{db_title_norm}' | '{db_track.artist_name}''{db_artist_norm}'") logger.debug(f" Database: '{db_track.title}''{db_title_norm}' | '{db_track.artist_name}''{db_artist_norm}'")

View file

@ -271,9 +271,9 @@ class PlaylistSyncService:
for i, track in enumerate(media_tracks): for i, track in enumerate(media_tracks):
if track and hasattr(track, 'ratingKey'): if track and hasattr(track, 'ratingKey'):
valid_tracks.append(track) valid_tracks.append(track)
logger.debug(f"✔️ Track {i+1} valid for playlist: '{track.title}' (ratingKey: {track.ratingKey})") logger.debug(f"Track {i+1} valid for playlist: '{track.title}' (ratingKey: {track.ratingKey})")
else: else:
logger.warning(f"Track {i+1} invalid for playlist: {track} (type: {type(track)}, has ratingKey: {hasattr(track, 'ratingKey') if track else 'N/A'})") logger.warning(f"Track {i+1} invalid for playlist: {track} (type: {type(track)}, has ratingKey: {hasattr(track, 'ratingKey') if track else 'N/A'})")
logger.info(f"Playlist validation: {len(valid_tracks)}/{len(media_tracks)} tracks are valid {server_type.title()} objects with ratingKeys") logger.info(f"Playlist validation: {len(valid_tracks)}/{len(media_tracks)} tracks are valid {server_type.title()} objects with ratingKeys")
@ -312,7 +312,7 @@ class PlaylistSyncService:
# Auto-add unmatched tracks to wishlist (skip in Wing It mode) # Auto-add unmatched tracks to wishlist (skip in Wing It mode)
wishlist_added_count = 0 wishlist_added_count = 0
if unmatched_tracks and getattr(self, '_skip_wishlist', False): if unmatched_tracks and getattr(self, '_skip_wishlist', False):
logger.info(f"[Wing It] Skipping wishlist for {len(unmatched_tracks)} unmatched tracks") logger.info(f"[Wing It] Skipping wishlist for {len(unmatched_tracks)} unmatched tracks")
unmatched_tracks = [] # Clear so the loop below doesn't run unmatched_tracks = [] # Clear so the loop below doesn't run
if unmatched_tracks: if unmatched_tracks:
try: try:
@ -484,10 +484,10 @@ class PlaylistSyncService:
actual_track = None actual_track = None
if actual_track: if actual_track:
logger.debug(f"Sync cache hit: '{original_title}' → server track {server_track_id}") logger.debug(f"Sync cache hit: '{original_title}' → server track {server_track_id}")
return actual_track, cached['confidence'] return actual_track, cached['confidence']
logger.debug(f"🔄 Sync cache stale for '{original_title}' — track {server_track_id} gone") logger.debug(f"Sync cache stale for '{original_title}' — track {server_track_id} gone")
except Exception as cache_err: except Exception as cache_err:
logger.debug(f"Sync cache lookup error: {cache_err}") logger.debug(f"Sync cache lookup error: {cache_err}")
# --- End cache fast-path --- # --- End cache fast-path ---
@ -511,7 +511,7 @@ class PlaylistSyncService:
db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server) db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7: if db_track and confidence >= 0.7:
logger.debug(f"✔️ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}") logger.debug(f"Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
# Save to sync match cache for next time # Save to sync match cache for next time
if spotify_id: if spotify_id:
@ -536,7 +536,7 @@ class PlaylistSyncService:
self.id = db_track.id self.id = db_track.id
actual_track = JellyfinTrackFromDB(db_track) actual_track = JellyfinTrackFromDB(db_track)
logger.debug(f"✔️ Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})") logger.debug(f"Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
return actual_track, confidence return actual_track, confidence
elif server_type == "navidrome": elif server_type == "navidrome":
# For Navidrome, create a track object from database info (similar to Jellyfin) # For Navidrome, create a track object from database info (similar to Jellyfin)
@ -547,7 +547,7 @@ class PlaylistSyncService:
self.id = db_track.id self.id = db_track.id
actual_track = NavidromeTrackFromDB(db_track) actual_track = NavidromeTrackFromDB(db_track)
logger.debug(f"✔️ Created Navidrome track object for '{db_track.title}' (ID: {actual_track.ratingKey})") logger.debug(f"Created Navidrome track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
return actual_track, confidence return actual_track, confidence
else: else:
# For Plex, use the original fetchItem approach # For Plex, use the original fetchItem approach
@ -556,16 +556,16 @@ class PlaylistSyncService:
track_id = int(db_track.id) track_id = int(db_track.id)
actual_plex_track = media_client.server.fetchItem(track_id) actual_plex_track = media_client.server.fetchItem(track_id)
if actual_plex_track and hasattr(actual_plex_track, 'ratingKey'): if actual_plex_track and hasattr(actual_plex_track, 'ratingKey'):
logger.debug(f"✔️ Successfully fetched actual Plex track for '{db_track.title}' (ratingKey: {actual_plex_track.ratingKey})") logger.debug(f"Successfully fetched actual Plex track for '{db_track.title}' (ratingKey: {actual_plex_track.ratingKey})")
return actual_plex_track, confidence return actual_plex_track, confidence
else: else:
logger.warning(f"Fetched Plex track for '{db_track.title}' lacks ratingKey attribute") logger.warning(f"Fetched Plex track for '{db_track.title}' lacks ratingKey attribute")
except ValueError: except ValueError:
logger.warning(f"Invalid Plex track ID format for '{db_track.title}' (ID: {db_track.id}) - skipping this track") logger.warning(f"Invalid Plex track ID format for '{db_track.title}' (ID: {db_track.id}) - skipping this track")
continue continue
except Exception as fetch_error: except Exception as fetch_error:
logger.error(f"Failed to fetch actual {server_type} track for '{db_track.title}' (ID: {db_track.id}): {fetch_error}") logger.error(f"Failed to fetch actual {server_type} track for '{db_track.title}' (ID: {db_track.id}): {fetch_error}")
# Continue to try other artists rather than fail completely # Continue to try other artists rather than fail completely
continue continue
@ -573,7 +573,7 @@ class PlaylistSyncService:
logger.error(f"Error checking track existence for '{original_title}' by '{artist_name}': {db_error}") logger.error(f"Error checking track existence for '{original_title}' by '{artist_name}': {db_error}")
continue continue
logger.debug(f"No database match found for '{original_title}' by any of the artists {spotify_track.artists}") logger.debug(f"No database match found for '{original_title}' by any of the artists {spotify_track.artists}")
return None, 0.0 return None, 0.0
except Exception as e: except Exception as e:

View file

@ -188,7 +188,7 @@ _DEFAULT_DISCOVERY_STATES = {
'discovery_progress': 30, 'spotify_matches': 3, 'spotify_total': 10, 'discovery_progress': 30, 'spotify_matches': 3, 'spotify_total': 10,
'discovery_results': [ 'discovery_results': [
{'index': 0, 'yt_track': 'Song B', 'yt_artist': 'Artist B', {'index': 0, 'yt_track': 'Song B', 'yt_artist': 'Artist B',
'status': 'Found', 'status_class': 'found', 'status': 'Found', 'status_class': 'found',
'spotify_track': 'Song B', 'spotify_artist': 'Artist B', 'spotify_track': 'Song B', 'spotify_artist': 'Artist B',
'spotify_album': 'Album B'}, 'spotify_album': 'Album B'},
], ],

View file

@ -103,7 +103,7 @@ class TestActivityFeed:
client = socketio.test_client(app) client = socketio.test_client(app)
# Add some activities first # Add some activities first
add_item = shared_state['add_activity_item'] add_item = shared_state['add_activity_item']
add_item('🎵', 'Download Complete', 'Artist - Song', show_toast=False) add_item('', 'Download Complete', 'Artist - Song', show_toast=False)
build = shared_state['build_activity_feed_payload'] build = shared_state['build_activity_feed_payload']
socketio.emit('dashboard:activity', build()) socketio.emit('dashboard:activity', build())
@ -123,7 +123,7 @@ class TestActivityFeed:
# Add an activity # Add an activity
add_item = shared_state['add_activity_item'] add_item = shared_state['add_activity_item']
add_item('🎵', 'Test Activity', 'Test subtitle', show_toast=False) add_item('', 'Test Activity', 'Test subtitle', show_toast=False)
http_data = flask_client.get('/api/activity/feed').get_json() http_data = flask_client.get('/api/activity/feed').get_json()
build = shared_state['build_activity_feed_payload'] build = shared_state['build_activity_feed_payload']
@ -158,7 +158,7 @@ class TestToasts:
client.get_received() # clear client.get_received() # clear
add_item = shared_state['add_activity_item'] add_item = shared_state['add_activity_item']
add_item('', 'Download Complete', 'Artist - Song', show_toast=True) add_item('', 'Download Complete', 'Artist - Song', show_toast=True)
received = client.get_received() received = client.get_received()
toast_events = [e for e in received if e['name'] == 'dashboard:toast'] toast_events = [e for e in received if e['name'] == 'dashboard:toast']
@ -174,7 +174,7 @@ class TestToasts:
client.get_received() # clear client.get_received() # clear
add_item = shared_state['add_activity_item'] add_item = shared_state['add_activity_item']
add_item('📊', 'Background Task', 'Silent update', show_toast=False) add_item('', 'Background Task', 'Silent update', show_toast=False)
received = client.get_received() received = client.get_received()
toast_events = [e for e in received if e['name'] == 'dashboard:toast'] toast_events = [e for e in received if e['name'] == 'dashboard:toast']
@ -187,7 +187,7 @@ class TestToasts:
client.get_received() # clear client.get_received() # clear
add_item = shared_state['add_activity_item'] add_item = shared_state['add_activity_item']
add_item('', 'Test Title', 'Test Subtitle', 'Now', show_toast=True) add_item('', 'Test Title', 'Test Subtitle', 'Now', show_toast=True)
received = client.get_received() received = client.get_received()
toast_events = [e for e in received if e['name'] == 'dashboard:toast'] toast_events = [e for e in received if e['name'] == 'dashboard:toast']
@ -205,7 +205,7 @@ class TestToasts:
"""GET /api/activity/toasts returns 200 with expected structure.""" """GET /api/activity/toasts returns 200 with expected structure."""
# Add a toast-worthy activity first # Add a toast-worthy activity first
add_item = shared_state['add_activity_item'] add_item = shared_state['add_activity_item']
add_item('', 'Test', 'Sub', show_toast=True) add_item('', 'Test', 'Sub', show_toast=True)
resp = flask_client.get('/api/activity/toasts') resp = flask_client.get('/api/activity/toasts')
assert resp.status_code == 200 assert resp.status_code == 200

View file

@ -32,7 +32,7 @@ if sys.platform == 'win32':
try: try:
import yt_dlp import yt_dlp
except ImportError: except ImportError:
print("yt-dlp not installed. Install with: pip install yt-dlp") print("yt-dlp not installed. Install with: pip install yt-dlp")
sys.exit(1) sys.exit(1)
# Add parent directory to path to import from core # Add parent directory to path to import from core
@ -122,12 +122,12 @@ class YouTubeClient:
# Initialize production matching engine for parity with Soulseek # Initialize production matching engine for parity with Soulseek
self.matching_engine = MusicMatchingEngine() self.matching_engine = MusicMatchingEngine()
logger.info("Initialized production MusicMatchingEngine") logger.info("Initialized production MusicMatchingEngine")
# Check for ffmpeg (REQUIRED for MP3 conversion) # Check for ffmpeg (REQUIRED for MP3 conversion)
if not self._check_ffmpeg(): if not self._check_ffmpeg():
print("\n" + "="*80) print("\n" + "="*80)
print("ERROR: ffmpeg is required but not found in PATH") print("ERROR: ffmpeg is required but not found in PATH")
print("="*80) print("="*80)
print("\nInstall ffmpeg:") print("\nInstall ffmpeg:")
print(" Windows: scoop install ffmpeg") print(" Windows: scoop install ffmpeg")
@ -195,7 +195,7 @@ class YouTubeClient:
# Check if ffmpeg is in system PATH # Check if ffmpeg is in system PATH
if shutil.which('ffmpeg'): if shutil.which('ffmpeg'):
logger.info("Found ffmpeg in system PATH") logger.info("Found ffmpeg in system PATH")
return True return True
# Auto-download ffmpeg to tools folder if not found # Auto-download ffmpeg to tools folder if not found
@ -211,7 +211,7 @@ class YouTubeClient:
# If we already have both locally, use them # If we already have both locally, use them
if ffmpeg_path.exists() and ffprobe_path.exists(): if ffmpeg_path.exists() and ffprobe_path.exists():
logger.info(f"Found ffmpeg and ffprobe in tools folder") logger.info(f"Found ffmpeg and ffprobe in tools folder")
# Add to PATH so yt-dlp can find them # Add to PATH so yt-dlp can find them
import os import os
tools_dir_str = str(tools_dir.absolute()) tools_dir_str = str(tools_dir.absolute())
@ -290,10 +290,10 @@ class YouTubeClient:
ffprobe_zip.unlink() # Clean up zip ffprobe_zip.unlink() # Clean up zip
else: else:
logger.error(f"Unsupported platform: {system}") logger.error(f"Unsupported platform: {system}")
return False return False
logger.info(f"Downloaded ffmpeg to: {ffmpeg_path}") logger.info(f"Downloaded ffmpeg to: {ffmpeg_path}")
# Add to PATH # Add to PATH
import os import os
@ -303,7 +303,7 @@ class YouTubeClient:
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to download ffmpeg: {e}") logger.error(f"Failed to download ffmpeg: {e}")
logger.error(f" Please install manually:") logger.error(f" Please install manually:")
logger.error(f" Windows: scoop install ffmpeg") logger.error(f" Windows: scoop install ffmpeg")
logger.error(f" Linux: sudo apt install ffmpeg") logger.error(f" Linux: sudo apt install ffmpeg")
@ -331,7 +331,7 @@ class YouTubeClient:
# Format speed safely # Format speed safely
speed_kb = speed / 1024 if speed else 0 speed_kb = speed / 1024 if speed else 0
logger.info(f"📥 Progress: {progress:.1f}% | Speed: {speed_kb:.1f} KB/s | ETA: {eta}s") logger.info(f"Progress: {progress:.1f}% | Speed: {speed_kb:.1f} KB/s | ETA: {eta}s")
elif d['status'] == 'finished': elif d['status'] == 'finished':
self.current_download_progress = { self.current_download_progress = {
@ -339,14 +339,14 @@ class YouTubeClient:
'progress': 100.0, 'progress': 100.0,
'filename': d.get('filename', '') 'filename': d.get('filename', '')
} }
logger.info(f"Download finished: {d.get('filename', '')}") logger.info(f"Download finished: {d.get('filename', '')}")
elif d['status'] == 'error': elif d['status'] == 'error':
self.current_download_progress = { self.current_download_progress = {
'status': 'error', 'status': 'error',
'error': str(d.get('error', 'Unknown error')) 'error': str(d.get('error', 'Unknown error'))
} }
logger.error(f"Download error: {d.get('error', '')}") logger.error(f"Download error: {d.get('error', '')}")
def search(self, query: str, max_results: int = 10) -> List[YouTubeSearchResult]: def search(self, query: str, max_results: int = 10) -> List[YouTubeSearchResult]:
""" """
@ -359,7 +359,7 @@ class YouTubeClient:
Returns: Returns:
List of YouTubeSearchResult objects List of YouTubeSearchResult objects
""" """
logger.info(f"🔍 Searching YouTube for: '{query}'") logger.info(f"Searching YouTube for: '{query}'")
try: try:
# Use YouTube Music for better music search results # Use YouTube Music for better music search results
@ -406,11 +406,11 @@ class YouTubeClient:
logger.warning(f"Could not get detailed info for {entry['id']}: {e}") logger.warning(f"Could not get detailed info for {entry['id']}: {e}")
continue continue
logger.info(f"Found {len(results)} YouTube results") logger.info(f"Found {len(results)} YouTube results")
return results return results
except Exception as e: except Exception as e:
logger.error(f"YouTube search error: {e}") logger.error(f"YouTube search error: {e}")
return [] return []
def _get_best_audio_format(self, formats: List[Dict]) -> Optional[Dict]: def _get_best_audio_format(self, formats: List[Dict]) -> Optional[Dict]:
@ -555,7 +555,7 @@ class YouTubeClient:
# Sort by confidence (best first) # Sort by confidence (best first)
matches.sort(key=lambda r: r.confidence, reverse=True) matches.sort(key=lambda r: r.confidence, reverse=True)
logger.info(f"Found {len(matches)} matches above {min_confidence} confidence") logger.info(f"Found {len(matches)} matches above {min_confidence} confidence")
return matches return matches
def download(self, yt_result: YouTubeSearchResult, spotify_track: Optional[SpotifyTrack] = None) -> Optional[str]: def download(self, yt_result: YouTubeSearchResult, spotify_track: Optional[SpotifyTrack] = None) -> Optional[str]:
@ -569,7 +569,7 @@ class YouTubeClient:
Returns: Returns:
Path to downloaded file, or None if failed Path to downloaded file, or None if failed
""" """
logger.info(f"📥 Starting download: {yt_result.title}") logger.info(f"Starting download: {yt_result.title}")
logger.info(f" Quality: {yt_result.available_quality}") logger.info(f" Quality: {yt_result.available_quality}")
logger.info(f" URL: {yt_result.url}") logger.info(f" URL: {yt_result.url}")
@ -617,9 +617,9 @@ class YouTubeClient:
except: except:
pass pass
logger.info(f" 📀 Spotify track #{track_number} on album: {spotify_track.album} ({release_year})") logger.info(f" Spotify track #{track_number} on album: {spotify_track.album} ({release_year})")
except Exception as e: except Exception as e:
logger.warning(f" ⚠️ Could not fetch Spotify track details: {e}") logger.warning(f" Could not fetch Spotify track details: {e}")
# If we have Spotify metadata, use production file organization # If we have Spotify metadata, use production file organization
if spotify_track: if spotify_track:
@ -644,8 +644,8 @@ class YouTubeClient:
# Override output template with production folder structure # Override output template with production folder structure
download_opts['outtmpl'] = str(album_folder / f'{final_filename}.%(ext)s') download_opts['outtmpl'] = str(album_folder / f'{final_filename}.%(ext)s')
logger.info(f" 📁 Album folder: {album_artist}/{album_artist} - {album}/") logger.info(f" Album folder: {album_artist}/{album_artist} - {album}/")
logger.info(f" 📝 Filename: {final_filename}.mp3") logger.info(f" Filename: {final_filename}.mp3")
# Add metadata postprocessor with Spotify info # Add metadata postprocessor with Spotify info
download_opts['postprocessor_args'] = { download_opts['postprocessor_args'] = {
@ -669,7 +669,7 @@ class YouTubeClient:
filename = Path(ydl.prepare_filename(info)).with_suffix('.mp3') filename = Path(ydl.prepare_filename(info)).with_suffix('.mp3')
if filename.exists(): if filename.exists():
logger.info(f"Download successful: {filename}") logger.info(f"Download successful: {filename}")
# Post-download: Enhance metadata with mutagen # Post-download: Enhance metadata with mutagen
album_art_url = self._enhance_metadata(str(filename), spotify_track, yt_result, track_number, disc_number, release_year, artist_genres) album_art_url = self._enhance_metadata(str(filename), spotify_track, yt_result, track_number, disc_number, release_year, artist_genres)
@ -684,11 +684,11 @@ class YouTubeClient:
return str(filename) return str(filename)
else: else:
logger.error(f"Download completed but file not found: {filename}") logger.error(f"Download completed but file not found: {filename}")
return None return None
except Exception as e: except Exception as e:
logger.error(f"Download failed: {e}") logger.error(f"Download failed: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return None return None
@ -704,7 +704,7 @@ class YouTubeClient:
from mutagen.id3 import ID3NoHeaderError from mutagen.id3 import ID3NoHeaderError
import requests import requests
logger.info(f"🏷️ Enhancing metadata for: {Path(filepath).name}") logger.info(f"Enhancing metadata for: {Path(filepath).name}")
# Load MP3 file # Load MP3 file
audio = MP3(filepath) audio = MP3(filepath)
@ -713,11 +713,11 @@ class YouTubeClient:
if audio.tags is not None: if audio.tags is not None:
# Delete ALL existing frames # Delete ALL existing frames
audio.tags.clear() audio.tags.clear()
logger.info(f" 🧹 Cleared all existing tag frames") logger.info(f" Cleared all existing tag frames")
else: else:
# No tags exist, add them # No tags exist, add them
audio.add_tags() audio.add_tags()
logger.info(f" Added new tag structure") logger.info(f" Added new tag structure")
if spotify_track: if spotify_track:
# Use Spotify metadata # Use Spotify metadata
@ -741,7 +741,7 @@ class YouTubeClient:
except: except:
pass pass
logger.info(f" 📝 Setting metadata tags...") logger.info(f" Setting metadata tags...")
# Set ID3 tags (using setall to ensure they're set) # Set ID3 tags (using setall to ensure they're set)
audio.tags.setall('TIT2', [TIT2(encoding=3, text=title)]) audio.tags.setall('TIT2', [TIT2(encoding=3, text=title)])
@ -760,21 +760,21 @@ class YouTubeClient:
# Combine up to 3 genres (matches production logic) # Combine up to 3 genres (matches production logic)
genre = ', '.join(artist_genres[:3]) genre = ', '.join(artist_genres[:3])
audio.tags.setall('TCON', [TCON(encoding=3, text=genre)]) audio.tags.setall('TCON', [TCON(encoding=3, text=genre)])
logger.info(f" Genre: {genre}") logger.info(f" Genre: {genre}")
audio.tags.setall('COMM', [COMM(encoding=3, lang='eng', desc='', audio.tags.setall('COMM', [COMM(encoding=3, lang='eng', desc='',
text=f'Downloaded via SoulSync (YouTube)\nSource: {yt_result.url}\nConfidence: {yt_result.confidence:.2f}')]) text=f'Downloaded via SoulSync (YouTube)\nSource: {yt_result.url}\nConfidence: {yt_result.confidence:.2f}')])
logger.info(f" Artist: {artist}") logger.info(f" Artist: {artist}")
logger.info(f" Album Artist: {album_artist}") logger.info(f" Album Artist: {album_artist}")
logger.info(f" Title: {title}") logger.info(f" Title: {title}")
logger.info(f" Album: {album}") logger.info(f" Album: {album}")
logger.info(f" Track #: {track_number}") logger.info(f" Track #: {track_number}")
logger.info(f" Disc #: {disc_number}") logger.info(f" Disc #: {disc_number}")
logger.info(f" Year: {year}") logger.info(f" Year: {year}")
# Fetch and embed album art from Spotify (via search) # Fetch and embed album art from Spotify (via search)
logger.info(f" 🎨 Fetching album art from Spotify...") logger.info(f" Fetching album art from Spotify...")
album_art_url = self._get_spotify_album_art(spotify_track) album_art_url = self._get_spotify_album_art(spotify_track)
if album_art_url: if album_art_url:
@ -800,25 +800,25 @@ class YouTubeClient:
data=response.content data=response.content
)) ))
logger.info(f" Album art embedded ({len(response.content) // 1024} KB)") logger.info(f" Album art embedded ({len(response.content) // 1024} KB)")
except Exception as art_error: except Exception as art_error:
logger.warning(f" ⚠️ Could not embed album art: {art_error}") logger.warning(f" Could not embed album art: {art_error}")
else: else:
logger.warning(f" ⚠️ No album art found on Spotify") logger.warning(f" No album art found on Spotify")
# Save all tags # Save all tags
audio.save() audio.save()
logger.info(f"Metadata enhanced successfully") logger.info(f"Metadata enhanced successfully")
# Return album art URL for cover.jpg creation # Return album art URL for cover.jpg creation
return album_art_url return album_art_url
except ImportError: except ImportError:
logger.warning("⚠️ mutagen not installed - skipping enhanced metadata tagging") logger.warning("mutagen not installed - skipping enhanced metadata tagging")
logger.warning(" Install with: pip install mutagen") logger.warning(" Install with: pip install mutagen")
return None return None
except Exception as e: except Exception as e:
logger.warning(f"⚠️ Could not enhance metadata: {e}") logger.warning(f"Could not enhance metadata: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return None return None
@ -833,83 +833,83 @@ class YouTubeClient:
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from core.spotify_client import SpotifyClient from core.spotify_client import SpotifyClient
logger.info(f" 🔍 Getting Spotify client...") logger.info(f" Getting Spotify client...")
# Get authenticated Spotify client # Get authenticated Spotify client
spotify_client = SpotifyClient() spotify_client = SpotifyClient()
if not spotify_client: if not spotify_client:
logger.warning(f" ⚠️ Spotify client not available") logger.warning(f" Spotify client not available")
return None return None
if not spotify_client.is_authenticated(): if not spotify_client.is_authenticated():
logger.warning(f" ⚠️ Spotify client not authenticated") logger.warning(f" Spotify client not authenticated")
return None return None
logger.info(f" Spotify client authenticated") logger.info(f" Spotify client authenticated")
# Use the track ID if available (real Spotify IDs) # Use the track ID if available (real Spotify IDs)
if spotify_track.id and not spotify_track.id.startswith('test'): if spotify_track.id and not spotify_track.id.startswith('test'):
logger.info(f" 🔍 Fetching track info for ID: {spotify_track.id}") logger.info(f" Fetching track info for ID: {spotify_track.id}")
try: try:
# Get track info from Spotify API # Get track info from Spotify API
track_info = spotify_client.sp.track(spotify_track.id) track_info = spotify_client.sp.track(spotify_track.id)
if track_info: if track_info:
logger.info(f" Got track info from Spotify") logger.info(f" Got track info from Spotify")
if 'album' in track_info: if 'album' in track_info:
album_images = track_info['album'].get('images', []) album_images = track_info['album'].get('images', [])
logger.info(f" 📸 Found {len(album_images)} album images") logger.info(f" Found {len(album_images)} album images")
if album_images: if album_images:
# Get highest quality image (first in list) # Get highest quality image (first in list)
album_art_url = album_images[0]['url'] album_art_url = album_images[0]['url']
logger.info(f" Album art URL: {album_art_url[:50]}...") logger.info(f" Album art URL: {album_art_url[:50]}...")
return album_art_url return album_art_url
else: else:
logger.warning(f" ⚠️ No album data in track info") logger.warning(f" No album data in track info")
else: else:
logger.warning(f" ⚠️ Track info is empty") logger.warning(f" Track info is empty")
except Exception as e: except Exception as e:
logger.warning(f" Error fetching via track ID: {e}") logger.warning(f" Error fetching via track ID: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# Fallback: Search for the track # Fallback: Search for the track
query = f"track:{spotify_track.name} artist:{spotify_track.artists[0]}" query = f"track:{spotify_track.name} artist:{spotify_track.artists[0]}"
logger.info(f" 🔍 Searching Spotify: {query}") logger.info(f" Searching Spotify: {query}")
try: try:
search_results = spotify_client.sp.search(q=query, type='track', limit=1) search_results = spotify_client.sp.search(q=query, type='track', limit=1)
if search_results and 'tracks' in search_results: if search_results and 'tracks' in search_results:
tracks = search_results['tracks'].get('items', []) tracks = search_results['tracks'].get('items', [])
logger.info(f" 📋 Search returned {len(tracks)} tracks") logger.info(f" Search returned {len(tracks)} tracks")
if tracks: if tracks:
album_images = tracks[0].get('album', {}).get('images', []) album_images = tracks[0].get('album', {}).get('images', [])
if album_images: if album_images:
# Get highest quality image (first in list) # Get highest quality image (first in list)
album_art_url = album_images[0]['url'] album_art_url = album_images[0]['url']
logger.info(f" Found via search: {album_art_url[:50]}...") logger.info(f" Found via search: {album_art_url[:50]}...")
return album_art_url return album_art_url
except Exception as search_error: except Exception as search_error:
logger.warning(f" Search error: {search_error}") logger.warning(f" Search error: {search_error}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
logger.warning(f" ⚠️ No album art found on Spotify") logger.warning(f" No album art found on Spotify")
return None return None
except ImportError as e: except ImportError as e:
logger.warning(f" Could not import Spotify client: {e}") logger.warning(f" Could not import Spotify client: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return None return None
except Exception as e: except Exception as e:
logger.warning(f" Error fetching album art: {e}") logger.warning(f" Error fetching album art: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return None return None
@ -925,10 +925,10 @@ class YouTubeClient:
# Skip if already exists # Skip if already exists
if cover_path.exists(): if cover_path.exists():
logger.info(f" 📷 cover.jpg already exists") logger.info(f" cover.jpg already exists")
return return
logger.info(f" 📷 Downloading cover.jpg to album folder...") logger.info(f" Downloading cover.jpg to album folder...")
# Download album art # Download album art
response = requests.get(album_art_url, timeout=10) response = requests.get(album_art_url, timeout=10)
@ -937,10 +937,10 @@ class YouTubeClient:
# Save to file # Save to file
cover_path.write_bytes(response.content) cover_path.write_bytes(response.content)
logger.info(f" Saved cover.jpg ({len(response.content) // 1024} KB)") logger.info(f" Saved cover.jpg ({len(response.content) // 1024} KB)")
except Exception as e: except Exception as e:
logger.warning(f" ⚠️ Could not save cover.jpg: {e}") logger.warning(f" Could not save cover.jpg: {e}")
def _create_lyrics_file(self, audio_file_path: str, spotify_track: SpotifyTrack): def _create_lyrics_file(self, audio_file_path: str, spotify_track: SpotifyTrack):
""" """
@ -952,10 +952,10 @@ class YouTubeClient:
from core.lyrics_client import lyrics_client from core.lyrics_client import lyrics_client
if not lyrics_client.api: if not lyrics_client.api:
logger.debug(f" 🎵 LRClib API not available - skipping lyrics") logger.debug(f" LRClib API not available - skipping lyrics")
return return
logger.info(f" 🎵 Fetching lyrics from LRClib...") logger.info(f" Fetching lyrics from LRClib...")
# Get track metadata # Get track metadata
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist" artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
@ -973,14 +973,14 @@ class YouTubeClient:
) )
if success: if success:
logger.info(f" Created .lrc lyrics file") logger.info(f" Created .lrc lyrics file")
else: else:
logger.info(f" 🎵 No lyrics found on LRClib") logger.info(f" No lyrics found on LRClib")
except ImportError: except ImportError:
logger.debug(f" ⚠️ lyrics_client not available - skipping lyrics") logger.debug(f" lyrics_client not available - skipping lyrics")
except Exception as e: except Exception as e:
logger.warning(f" ⚠️ Could not create lyrics file: {e}") logger.warning(f" Could not create lyrics file: {e}")
def search_and_download_best(self, spotify_track: SpotifyTrack, min_confidence: float = 0.58) -> Optional[str]: def search_and_download_best(self, spotify_track: SpotifyTrack, min_confidence: float = 0.58) -> Optional[str]:
""" """
@ -994,7 +994,7 @@ class YouTubeClient:
Returns: Returns:
Path to downloaded file, or None if failed Path to downloaded file, or None if failed
""" """
logger.info(f"🎯 Starting YouTube download flow for: {spotify_track.name} by {spotify_track.artists[0]}") logger.info(f"Starting YouTube download flow for: {spotify_track.name} by {spotify_track.artists[0]}")
# Generate search query # Generate search query
query = f"{spotify_track.artists[0]} {spotify_track.name}" query = f"{spotify_track.artists[0]} {spotify_track.name}"
@ -1003,19 +1003,19 @@ class YouTubeClient:
results = self.search(query, max_results=10) results = self.search(query, max_results=10)
if not results: if not results:
logger.error(f"No YouTube results found for query: {query}") logger.error(f"No YouTube results found for query: {query}")
return None return None
# Find best matches # Find best matches
matches = self.find_best_matches(spotify_track, results, min_confidence=min_confidence) matches = self.find_best_matches(spotify_track, results, min_confidence=min_confidence)
if not matches: if not matches:
logger.error(f"No matches above {min_confidence} confidence threshold") logger.error(f"No matches above {min_confidence} confidence threshold")
return None return None
# Try downloading best match # Try downloading best match
best_match = matches[0] best_match = matches[0]
logger.info(f"🎯 Best match: {best_match.title} (confidence: {best_match.confidence:.2f})") logger.info(f"Best match: {best_match.title} (confidence: {best_match.confidence:.2f})")
downloaded_file = self.download(best_match, spotify_track) downloaded_file = self.download(best_match, spotify_track)
@ -1030,7 +1030,7 @@ def test_youtube_download():
"""Test the YouTube download flow with a curated playlist""" """Test the YouTube download flow with a curated playlist"""
print("=" * 80) print("=" * 80)
print("🎵 YouTube Download Test - Curated Playlist (5 Tracks)") print("YouTube Download Test - Curated Playlist (5 Tracks)")
print("=" * 80) print("=" * 80)
print() print()
@ -1082,7 +1082,7 @@ def test_youtube_download():
), ),
] ]
print("🎧 Curated Playlist - Testing Diverse Genres:") print("Curated Playlist - Testing Diverse Genres:")
print() print()
for i, track in enumerate(test_playlist, 1): for i, track in enumerate(test_playlist, 1):
print(f" {i}. {track.artists[0]:20s} - {track.name}") print(f" {i}. {track.artists[0]:20s} - {track.name}")
@ -1133,7 +1133,7 @@ def test_youtube_download():
'error': None 'error': None
}) })
print(f"\nSUCCESS - Downloaded in {track_duration:.1f}s") print(f"\nSUCCESS - Downloaded in {track_duration:.1f}s")
print(f" File: {Path(downloaded_file).name}") print(f" File: {Path(downloaded_file).name}")
print(f" Size: {file_size:.2f} MB") print(f" Size: {file_size:.2f} MB")
else: else:
@ -1148,13 +1148,13 @@ def test_youtube_download():
'error': 'Download failed or no matches found' 'error': 'Download failed or no matches found'
}) })
print(f"\nFAILED - No suitable match found") print(f"\nFAILED - No suitable match found")
except Exception as e: except Exception as e:
track_end_time = datetime.now() track_end_time = datetime.now()
track_duration = (track_end_time - track_start_time).total_seconds() track_duration = (track_end_time - track_start_time).total_seconds()
print(f"\nERROR: {e}") print(f"\nERROR: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@ -1177,7 +1177,7 @@ def test_youtube_download():
# ======================================================================== # ========================================================================
print("\n\n" + "=" * 80) print("\n\n" + "=" * 80)
print("📊 FINAL SUMMARY REPORT") print("FINAL SUMMARY REPORT")
print("=" * 80) print("=" * 80)
print() print()
@ -1185,7 +1185,7 @@ def test_youtube_download():
failed = [r for r in results if not r['success']] failed = [r for r in results if not r['success']]
print(f"⏱️ Total Time: {total_duration:.1f}s ({total_duration/60:.1f} minutes)") print(f"⏱️ Total Time: {total_duration:.1f}s ({total_duration/60:.1f} minutes)")
print(f"Success Rate: {len(successful)}/{len(results)} ({len(successful)/len(results)*100:.1f}%)") print(f"Success Rate: {len(successful)}/{len(results)} ({len(successful)/len(results)*100:.1f}%)")
print() print()
if successful: if successful:
@ -1193,7 +1193,7 @@ def test_youtube_download():
avg_time = sum(r['duration_seconds'] for r in successful) / len(successful) avg_time = sum(r['duration_seconds'] for r in successful) / len(successful)
print("" * 80) print("" * 80)
print("SUCCESSFUL DOWNLOADS:") print("SUCCESSFUL DOWNLOADS:")
print("" * 80) print("" * 80)
for i, result in enumerate(successful, 1): for i, result in enumerate(successful, 1):
print(f"\n{i}. {result['artist']} - {result['track']}") print(f"\n{i}. {result['artist']} - {result['track']}")
@ -1203,13 +1203,13 @@ def test_youtube_download():
print(f" Time: {result['duration_seconds']:.1f}s") print(f" Time: {result['duration_seconds']:.1f}s")
print() print()
print(f"📦 Total Downloaded: {total_size:.2f} MB") print(f"Total Downloaded: {total_size:.2f} MB")
print(f"Average Download Time: {avg_time:.1f}s per track") print(f"Average Download Time: {avg_time:.1f}s per track")
print() print()
if failed: if failed:
print("" * 80) print("" * 80)
print("FAILED DOWNLOADS:") print("FAILED DOWNLOADS:")
print("" * 80) print("" * 80)
for i, result in enumerate(failed, 1): for i, result in enumerate(failed, 1):
print(f"\n{i}. {result['artist']} - {result['track']}") print(f"\n{i}. {result['artist']} - {result['track']}")
@ -1219,7 +1219,7 @@ def test_youtube_download():
# File list for easy access # File list for easy access
if successful: if successful:
print("" * 80) print("" * 80)
print("📁 DOWNLOAD LOCATION:") print("DOWNLOAD LOCATION:")
print("" * 80) print("" * 80)
print(f"\n{yt_client.download_path.absolute()}\n") print(f"\n{yt_client.download_path.absolute()}\n")
print("Files:") print("Files:")
@ -1228,13 +1228,13 @@ def test_youtube_download():
print() print()
print("=" * 80) print("=" * 80)
print("🎉 Test Complete!") print("Test Complete!")
print("=" * 80) print("=" * 80)
print() print()
# Quality check reminder # Quality check reminder
if successful: if successful:
print("📝 Next Steps:") print("Next Steps:")
print(" 1. Listen to downloaded files to verify quality") print(" 1. Listen to downloaded files to verify quality")
print(" 2. Check metadata tags in your music player") print(" 2. Check metadata tags in your music player")
print(" 3. Compare with Soulseek downloads if available") print(" 3. Compare with Soulseek downloads if available")

View file

@ -52,7 +52,7 @@ class DatabaseUpdaterWidget(QFrame):
info_label.setWordWrap(True) info_label.setWordWrap(True)
# Recommendation label # Recommendation label
self.recommendation_label = QLabel("💡 Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy") self.recommendation_label = QLabel("Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy")
self.recommendation_label.setFont(QFont("Arial", 9)) self.recommendation_label.setFont(QFont("Arial", 9))
self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;") self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;")
self.recommendation_label.setWordWrap(True) self.recommendation_label.setWordWrap(True)
@ -389,8 +389,8 @@ class DatabaseUpdaterWidget(QFrame):
def _update_recommendation_urgency(self, urgent: bool = False): def _update_recommendation_urgency(self, urgent: bool = False):
"""Update the recommendation label styling based on urgency""" """Update the recommendation label styling based on urgency"""
if urgent: if urgent:
self.recommendation_label.setText("⚠️ Recommended: Run a Full Refresh - it's been over 2 weeks!") self.recommendation_label.setText("Recommended: Run a Full Refresh - it's been over 2 weeks!")
self.recommendation_label.setStyleSheet("color: #ffffff; margin-bottom: 8px; padding: 6px 8px; background: #cc3300; border-radius: 4px;") self.recommendation_label.setStyleSheet("color: #ffffff; margin-bottom: 8px; padding: 6px 8px; background: #cc3300; border-radius: 4px;")
else: else:
self.recommendation_label.setText("💡 Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy") self.recommendation_label.setText("Tip: Run a Full Refresh every 1-2 weeks to ensure database accuracy")
self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;") self.recommendation_label.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;")

View file

@ -62,17 +62,17 @@ class Toast(QWidget):
def apply_styling(self): def apply_styling(self):
"""Apply styling based on toast type""" """Apply styling based on toast type"""
if self.toast_type == ToastType.SUCCESS: if self.toast_type == ToastType.SUCCESS:
icon = "" icon = ""
accent_color = "#1db954" # Spotify green accent_color = "#1db954" # Spotify green
bg_color = "rgba(29, 185, 84, 0.15)" bg_color = "rgba(29, 185, 84, 0.15)"
border_color = "rgba(29, 185, 84, 0.3)" border_color = "rgba(29, 185, 84, 0.3)"
elif self.toast_type == ToastType.ERROR: elif self.toast_type == ToastType.ERROR:
icon = "" icon = ""
accent_color = "#f04747" accent_color = "#f04747"
bg_color = "rgba(240, 71, 71, 0.15)" bg_color = "rgba(240, 71, 71, 0.15)"
border_color = "rgba(240, 71, 71, 0.3)" border_color = "rgba(240, 71, 71, 0.3)"
elif self.toast_type == ToastType.WARNING: elif self.toast_type == ToastType.WARNING:
icon = "⚠️" icon = ""
accent_color = "#ffa500" accent_color = "#ffa500"
bg_color = "rgba(255, 165, 0, 0.15)" bg_color = "rgba(255, 165, 0, 0.15)"
border_color = "rgba(255, 165, 0, 0.3)" border_color = "rgba(255, 165, 0, 0.3)"

View file

@ -114,7 +114,7 @@ class VersionInfoModal(QDialog):
# WebUI Transformation # WebUI Transformation
webui_section = self.create_feature_section( webui_section = self.create_feature_section(
"🌐 Complete WebUI Transformation", "Complete WebUI Transformation",
"SoulSync has been completely rebuilt from the ground up as a modern web application, moving from desktop GUI to web-based interface", "SoulSync has been completely rebuilt from the ground up as a modern web application, moving from desktop GUI to web-based interface",
[ [
"• Full transition from PyQt6 desktop application to responsive web interface", "• Full transition from PyQt6 desktop application to responsive web interface",
@ -131,7 +131,7 @@ class VersionInfoModal(QDialog):
# Docker Support # Docker Support
docker_section = self.create_feature_section( docker_section = self.create_feature_section(
"🐳 Docker Container Support", "Docker Container Support",
"Complete containerization with Docker for easy deployment and scalability", "Complete containerization with Docker for easy deployment and scalability",
[ [
"• Pre-built Docker images available for instant deployment", "• Pre-built Docker images available for instant deployment",
@ -147,7 +147,7 @@ class VersionInfoModal(QDialog):
# Enhanced Music Management # Enhanced Music Management
music_section = self.create_feature_section( music_section = self.create_feature_section(
"🎵 Enhanced Music Management", "Enhanced Music Management",
"All beloved features preserved and enhanced with new web-based capabilities", "All beloved features preserved and enhanced with new web-based capabilities",
[ [
"• Complete Spotify, Tidal, and YouTube Music playlist synchronization", "• Complete Spotify, Tidal, and YouTube Music playlist synchronization",
@ -163,7 +163,7 @@ class VersionInfoModal(QDialog):
# Performance & Reliability # Performance & Reliability
performance_section = self.create_feature_section( performance_section = self.create_feature_section(
"🚀 Performance & Reliability", "Performance & Reliability",
"Significant improvements in speed, stability, and resource efficiency", "Significant improvements in speed, stability, and resource efficiency",
[ [
"• Asynchronous processing for improved responsiveness", "• Asynchronous processing for improved responsiveness",
@ -234,7 +234,7 @@ class VersionInfoModal(QDialog):
# Usage note if provided # Usage note if provided
if usage_note: if usage_note:
usage_label = QLabel(f"💡 {usage_note}") usage_label = QLabel(f"{usage_note}")
usage_label.setFont(QFont("SF Pro Text", 10)) usage_label.setFont(QFont("SF Pro Text", 10))
usage_label.setStyleSheet(""" usage_label.setStyleSheet("""
color: #1ed760; color: #1ed760;

View file

@ -483,7 +483,7 @@ class WatchlistStatusModal(QDialog):
# Search bar # Search bar
self.search_bar = QLineEdit() self.search_bar = QLineEdit()
self.search_bar.setPlaceholderText("🔍 Search all artists...") self.search_bar.setPlaceholderText("Search all artists...")
self.search_bar.setFixedHeight(32) self.search_bar.setFixedHeight(32)
self.search_bar.setStyleSheet(""" self.search_bar.setStyleSheet("""
QLineEdit { QLineEdit {
@ -675,7 +675,7 @@ class WatchlistStatusModal(QDialog):
def get_artist_status_icon(self, artist): def get_artist_status_icon(self, artist):
"""Determine the appropriate status icon and color for an artist based on scan history""" """Determine the appropriate status icon and color for an artist based on scan history"""
if not artist.last_scan_timestamp: if not artist.last_scan_timestamp:
return "", "#888888" # Not scanned yet (gray circle) return "", "#888888" # Not scanned yet (gray circle)
try: try:
from datetime import datetime, timezone from datetime import datetime, timezone
@ -693,13 +693,13 @@ class WatchlistStatusModal(QDialog):
# If scanned within the last 24 hours, show as up to date # If scanned within the last 24 hours, show as up to date
# If older, show as potentially stale but still scanned # If older, show as potentially stale but still scanned
if hours_ago <= 24: if hours_ago <= 24:
return "", "#4caf50" # Recently up to date (bright green) return "", "#4caf50" # Recently up to date (bright green)
else: else:
return "", "#888888" # Scanned but older (gray checkmark) return "", "#888888" # Scanned but older (gray checkmark)
except Exception: except Exception:
# Fallback if datetime parsing fails # Fallback if datetime parsing fails
return "", "#4caf50" # Default to up to date return "", "#4caf50" # Default to up to date
def create_artist_card(self, artist: WatchlistArtist) -> QFrame: def create_artist_card(self, artist: WatchlistArtist) -> QFrame:
"""Create a professional artist card widget""" """Create a professional artist card widget"""
@ -773,7 +773,7 @@ class WatchlistStatusModal(QDialog):
info_layout.addWidget(sync_label) info_layout.addWidget(sync_label)
# Delete button with modern styling # Delete button with modern styling
delete_button = QPushButton("") delete_button = QPushButton("")
delete_button.setFixedSize(28, 28) delete_button.setFixedSize(28, 28)
delete_button.setFont(QFont("Arial", 12, QFont.Weight.Bold)) delete_button.setFont(QFont("Arial", 12, QFont.Weight.Bold))
delete_button.setStyleSheet(""" delete_button.setStyleSheet("""
@ -870,7 +870,7 @@ class WatchlistStatusModal(QDialog):
if item and item.widget(): if item and item.widget():
card = item.widget() card = item.widget()
if hasattr(card, 'status_indicator'): if hasattr(card, 'status_indicator'):
card.status_indicator.setText("") # Not scanned yet card.status_indicator.setText("") # Not scanned yet
card.status_indicator.setStyleSheet("color: #888888; border: none; background: transparent;") card.status_indicator.setStyleSheet("color: #888888; border: none; background: transparent;")
# Use shared scan worker so it persists across modal close/open # Use shared scan worker so it persists across modal close/open
@ -945,7 +945,7 @@ class WatchlistStatusModal(QDialog):
# Find artist by name (we don't have ID in signal) # Find artist by name (we don't have ID in signal)
for artist in self.current_artists: for artist in self.current_artists:
if artist.artist_name == artist_name: if artist.artist_name == artist_name:
card.status_indicator.setText("🔍") # Scanning card.status_indicator.setText("") # Scanning
card.status_indicator.setStyleSheet("color: #ffc107; border: none; background: transparent;") card.status_indicator.setStyleSheet("color: #ffc107; border: none; background: transparent;")
break break
@ -1018,13 +1018,13 @@ class WatchlistStatusModal(QDialog):
if artist.artist_name == artist_name: if artist.artist_name == artist_name:
if success: if success:
if new_tracks > 0: if new_tracks > 0:
card.status_indicator.setText("") # New tracks found card.status_indicator.setText("") # New tracks found
card.status_indicator.setStyleSheet("color: #1db954; border: none; background: transparent;") card.status_indicator.setStyleSheet("color: #1db954; border: none; background: transparent;")
else: else:
card.status_indicator.setText("") # Up to date card.status_indicator.setText("") # Up to date
card.status_indicator.setStyleSheet("color: #4caf50; border: none; background: transparent;") card.status_indicator.setStyleSheet("color: #4caf50; border: none; background: transparent;")
else: else:
card.status_indicator.setText("") # Error card.status_indicator.setText("") # Error
card.status_indicator.setStyleSheet("color: #f44336; border: none; background: transparent;") card.status_indicator.setStyleSheet("color: #f44336; border: none; background: transparent;")
break break
@ -1083,7 +1083,7 @@ class WatchlistStatusModal(QDialog):
if item and item.widget(): if item and item.widget():
card = item.widget() card = item.widget()
if hasattr(card, 'status_indicator'): if hasattr(card, 'status_indicator'):
card.status_indicator.setText("") # Not scanned yet card.status_indicator.setText("") # Not scanned yet
card.status_indicator.setStyleSheet("color: #888888; border: none; background: transparent;") card.status_indicator.setStyleSheet("color: #888888; border: none; background: transparent;")
def on_background_scan_completed(self, total_artists: int, total_new_tracks: int, total_added_to_wishlist: int): def on_background_scan_completed(self, total_artists: int, total_new_tracks: int, total_added_to_wishlist: int):

File diff suppressed because it is too large Load diff

View file

@ -381,10 +381,10 @@ class DownloadMissingWishlistTracksModal(QDialog):
title_section.addWidget(subtitle) title_section.addWidget(subtitle)
dashboard_layout = QHBoxLayout() dashboard_layout = QHBoxLayout()
self.total_card = self.create_compact_counter_card("📀 Total", "0", "#1db954") self.total_card = self.create_compact_counter_card("Total", "0", "#1db954")
self.matched_card = self.create_compact_counter_card("Found", "0", "#4CAF50") self.matched_card = self.create_compact_counter_card("Found", "0", "#4CAF50")
self.download_card = self.create_compact_counter_card("⬇️ Missing", "0", "#ff6b6b") self.download_card = self.create_compact_counter_card("⬇️ Missing", "0", "#ff6b6b")
self.downloaded_card = self.create_compact_counter_card("Downloaded", "0", "#4CAF50") self.downloaded_card = self.create_compact_counter_card("Downloaded", "0", "#4CAF50")
dashboard_layout.addWidget(self.total_card) dashboard_layout.addWidget(self.total_card)
dashboard_layout.addWidget(self.matched_card) dashboard_layout.addWidget(self.matched_card)
dashboard_layout.addWidget(self.download_card) dashboard_layout.addWidget(self.download_card)
@ -421,7 +421,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
progress_frame.setStyleSheet("background-color: #2d2d2d; border: 1px solid #444444; border-radius: 8px; padding: 12px;") progress_frame.setStyleSheet("background-color: #2d2d2d; border: 1px solid #444444; border-radius: 8px; padding: 12px;")
layout = QVBoxLayout(progress_frame) layout = QVBoxLayout(progress_frame)
analysis_container = QVBoxLayout() analysis_container = QVBoxLayout()
analysis_label = QLabel("🔍 Library Analysis") analysis_label = QLabel("Library Analysis")
analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold)) analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
self.analysis_progress = QProgressBar() self.analysis_progress = QProgressBar()
self.analysis_progress.setFixedHeight(20) self.analysis_progress.setFixedHeight(20)
@ -482,7 +482,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
# --- DURATION LOGIC REMOVED --- # --- DURATION LOGIC REMOVED ---
# "Matched" is now column 2 # "Matched" is now column 2
matched_item = QTableWidgetItem("Pending") matched_item = QTableWidgetItem("Pending")
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(i, 2, matched_item) self.track_table.setItem(i, 2, matched_item)
@ -575,10 +575,10 @@ class DownloadMissingWishlistTracksModal(QDialog):
cancel_button = layout.itemAt(0).widget() cancel_button = layout.itemAt(0).widget()
if cancel_button: if cancel_button:
cancel_button.setEnabled(False) cancel_button.setEnabled(False)
cancel_button.setText("") cancel_button.setText("")
# Update status to cancelled (column 3 for dashboard) # Update status to cancelled (column 3 for dashboard)
self.track_table.setItem(row, 3, QTableWidgetItem("🚫 Cancelled")) self.track_table.setItem(row, 3, QTableWidgetItem("Cancelled"))
# Add to cancelled tracks set # Add to cancelled tracks set
if not hasattr(self, 'cancelled_tracks'): if not hasattr(self, 'cancelled_tracks'):
@ -586,7 +586,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
self.cancelled_tracks.add(row) self.cancelled_tracks.add(row)
track = self.wishlist_tracks[row] track = self.wishlist_tracks[row]
print(f"🚫 Track cancelled: {track.name} (row {row})") print(f"Track cancelled: {track.name} (row {row})")
# If downloads are active, also handle active download cancellation # If downloads are active, also handle active download cancellation
download_index = None download_index = None
@ -596,7 +596,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
for download in self.active_downloads: for download in self.active_downloads:
if download.get('table_index') == row: if download.get('table_index') == row:
download_index = download.get('download_index', row) download_index = download.get('download_index', row)
print(f"🚫 Found active download {download_index} for cancelled track") print(f"Found active download {download_index} for cancelled track")
break break
# Check parallel_search_tracking for download index # Check parallel_search_tracking for download index
@ -604,23 +604,23 @@ class DownloadMissingWishlistTracksModal(QDialog):
for idx, track_info in self.parallel_search_tracking.items(): for idx, track_info in self.parallel_search_tracking.items():
if track_info.get('table_index') == row: if track_info.get('table_index') == row:
download_index = idx download_index = idx
print(f"🚫 Found parallel tracking {download_index} for cancelled track") print(f"Found parallel tracking {download_index} for cancelled track")
break break
# If we found an active download, trigger completion to free up the worker # If we found an active download, trigger completion to free up the worker
if download_index is not None and hasattr(self, 'on_parallel_track_completed'): if download_index is not None and hasattr(self, 'on_parallel_track_completed'):
print(f"🚫 Triggering completion for active download {download_index}") print(f"Triggering completion for active download {download_index}")
self.on_parallel_track_completed(download_index, success=False) self.on_parallel_track_completed(download_index, success=False)
def create_buttons(self): def create_buttons(self):
button_frame = QFrame(styleSheet="background-color: transparent; padding: 10px;") button_frame = QFrame(styleSheet="background-color: transparent; padding: 10px;")
layout = QHBoxLayout(button_frame) layout = QHBoxLayout(button_frame)
self.correct_failed_btn = QPushButton("🔧 Correct Failed Matches") self.correct_failed_btn = QPushButton("Correct Failed Matches")
self.correct_failed_btn.setFixedWidth(220) self.correct_failed_btn.setFixedWidth(220)
self.correct_failed_btn.setStyleSheet("QPushButton { background-color: #ffc107; color: #000; border-radius: 20px; font-weight: bold; }") self.correct_failed_btn.setStyleSheet("QPushButton { background-color: #ffc107; color: #000; border-radius: 20px; font-weight: bold; }")
self.correct_failed_btn.clicked.connect(self.on_correct_failed_matches_clicked) self.correct_failed_btn.clicked.connect(self.on_correct_failed_matches_clicked)
self.correct_failed_btn.hide() self.correct_failed_btn.hide()
self.clear_wishlist_btn = QPushButton("🗑️ Clear Wishlist") self.clear_wishlist_btn = QPushButton("Clear Wishlist")
self.clear_wishlist_btn.setFixedSize(150, 40) self.clear_wishlist_btn.setFixedSize(150, 40)
self.clear_wishlist_btn.setStyleSheet("QPushButton { background-color: #d32f2f; color: #fff; border-radius: 20px; font-size: 14px; font-weight: bold; }") self.clear_wishlist_btn.setStyleSheet("QPushButton { background-color: #d32f2f; color: #fff; border-radius: 20px; font-size: 14px; font-weight: bold; }")
self.clear_wishlist_btn.clicked.connect(self.on_clear_wishlist_clicked) self.clear_wishlist_btn.clicked.connect(self.on_clear_wishlist_clicked)
@ -701,7 +701,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
self.analysis_progress.setValue(track_index) self.analysis_progress.setValue(track_index)
row_index = track_index - 1 row_index = track_index - 1
if result.exists_in_plex: if result.exists_in_plex:
matched_text = f"Found ({result.confidence:.1f})" matched_text = f"Found ({result.confidence:.1f})"
self.matched_tracks_count += 1 self.matched_tracks_count += 1
self.matched_count_label.setText(str(self.matched_tracks_count)) self.matched_count_label.setText(str(self.matched_tracks_count))
@ -713,7 +713,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
logger.warning(f"Could not remove pre-existing track '{track_id_to_remove}' from wishlist.") logger.warning(f"Could not remove pre-existing track '{track_id_to_remove}' from wishlist.")
else: else:
matched_text = "Missing" matched_text = "Missing"
self.tracks_to_download_count += 1 self.tracks_to_download_count += 1
self.download_count_label.setText(str(self.tracks_to_download_count)) self.download_count_label.setText(str(self.tracks_to_download_count))
# Add cancel button for missing tracks only # Add cancel button for missing tracks only
@ -764,13 +764,13 @@ class DownloadMissingWishlistTracksModal(QDialog):
if track_index != -1: if track_index != -1:
# Skip if track was cancelled # Skip if track was cancelled
if hasattr(self, 'cancelled_tracks') and track_index in self.cancelled_tracks: if hasattr(self, 'cancelled_tracks') and track_index in self.cancelled_tracks:
print(f"🚫 Skipping cancelled track at index {track_index}: {track.name}") print(f"Skipping cancelled track at index {track_index}: {track.name}")
self.download_queue_index += 1 self.download_queue_index += 1
self.completed_downloads += 1 self.completed_downloads += 1
continue continue
# FIX: Changed column index from 4 to 3 to target the "Status" column. # FIX: Changed column index from 4 to 3 to target the "Status" column.
self.track_table.setItem(track_index, 3, QTableWidgetItem("🔍 Searching...")) self.track_table.setItem(track_index, 3, QTableWidgetItem("Searching..."))
self.search_and_download_track_parallel(track, self.download_queue_index, track_index) self.search_and_download_track_parallel(track, self.download_queue_index, track_index)
self.active_parallel_downloads += 1 self.active_parallel_downloads += 1
self.download_queue_index += 1 self.download_queue_index += 1
@ -940,18 +940,18 @@ class DownloadMissingWishlistTracksModal(QDialog):
if not next_candidate: if not next_candidate:
self.on_parallel_track_failed(download_index, "No alternative sources in cache") self.on_parallel_track_failed(download_index, "No alternative sources in cache")
return return
self.track_table.setItem(failed_download_info['table_index'], 3, QTableWidgetItem(f"🔄 Retrying ({track_info['retry_count']})...")) self.track_table.setItem(failed_download_info['table_index'], 3, QTableWidgetItem(f"Retrying ({track_info['retry_count']})..."))
self.start_validated_download_parallel(next_candidate, track_info['spotify_track'], track_info['track_index'], track_info['table_index'], download_index) self.start_validated_download_parallel(next_candidate, track_info['spotify_track'], track_info['track_index'], track_info['table_index'], download_index)
def on_parallel_track_completed(self, download_index, success): def on_parallel_track_completed(self, download_index, success):
if not hasattr(self, 'parallel_search_tracking'): if not hasattr(self, 'parallel_search_tracking'):
print(f"⚠️ parallel_search_tracking not initialized yet, skipping completion for download {download_index}") print(f"parallel_search_tracking not initialized yet, skipping completion for download {download_index}")
return return
track_info = self.parallel_search_tracking.get(download_index) track_info = self.parallel_search_tracking.get(download_index)
if not track_info or track_info.get('completed', False): return if not track_info or track_info.get('completed', False): return
track_info['completed'] = True track_info['completed'] = True
if success: if success:
self.track_table.setItem(track_info['table_index'], 3, QTableWidgetItem("Downloaded")) self.track_table.setItem(track_info['table_index'], 3, QTableWidgetItem("Downloaded"))
# Hide cancel button since track is now downloaded # Hide cancel button since track is now downloaded
self.hide_cancel_button_for_row(track_info['table_index']) self.hide_cancel_button_for_row(track_info['table_index'])
self.downloaded_tracks_count += 1 self.downloaded_tracks_count += 1
@ -966,10 +966,10 @@ class DownloadMissingWishlistTracksModal(QDialog):
# Check if track was cancelled (don't overwrite cancelled status) # Check if track was cancelled (don't overwrite cancelled status)
table_index = track_info['table_index'] table_index = track_info['table_index']
current_status = self.track_table.item(table_index, 3) current_status = self.track_table.item(table_index, 3)
if current_status and "🚫 Cancelled" in current_status.text(): if current_status and "Cancelled" in current_status.text():
print(f"🔧 Track {download_index} was cancelled - preserving cancelled status") print(f"Track {download_index} was cancelled - preserving cancelled status")
else: else:
self.track_table.setItem(table_index, 3, QTableWidgetItem("Failed")) self.track_table.setItem(table_index, 3, QTableWidgetItem("Failed"))
if track_info not in self.permanently_failed_tracks: if track_info not in self.permanently_failed_tracks:
self.permanently_failed_tracks.append(track_info) self.permanently_failed_tracks.append(track_info)
self.failed_downloads += 1 self.failed_downloads += 1
@ -986,7 +986,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
def update_failed_matches_button(self): def update_failed_matches_button(self):
count = len(self.permanently_failed_tracks) count = len(self.permanently_failed_tracks)
if count > 0: if count > 0:
self.correct_failed_btn.setText(f"🔧 Correct {count} Failed Match{'es' if count > 1 else ''}") self.correct_failed_btn.setText(f"Correct {count} Failed Match{'es' if count > 1 else ''}")
self.correct_failed_btn.show() self.correct_failed_btn.show()
else: else:
self.correct_failed_btn.hide() self.correct_failed_btn.hide()
@ -1033,8 +1033,8 @@ class DownloadMissingWishlistTracksModal(QDialog):
status_item = self.track_table.item(cancelled_row, 3) status_item = self.track_table.item(cancelled_row, 3)
current_status = status_item.text() if status_item else "" current_status = status_item.text() if status_item else ""
if "Downloaded" in current_status: if "Downloaded" in current_status:
print(f"🚫 Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist re-addition") print(f"Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist re-addition")
else: else:
cancelled_track_info = { cancelled_track_info = {
'download_index': cancelled_row, 'download_index': cancelled_row,
@ -1048,9 +1048,9 @@ class DownloadMissingWishlistTracksModal(QDialog):
# Check if not already in permanently_failed_tracks # Check if not already in permanently_failed_tracks
if not any(t.get('table_index') == cancelled_row for t in self.permanently_failed_tracks): if not any(t.get('table_index') == cancelled_row for t in self.permanently_failed_tracks):
self.permanently_failed_tracks.append(cancelled_track_info) self.permanently_failed_tracks.append(cancelled_track_info)
print(f"🚫 Added cancelled missing track {cancelled_track.name} to failed list for wishlist re-addition") print(f"Added cancelled missing track {cancelled_track.name} to failed list for wishlist re-addition")
else: else:
print(f"🚫 Cancelled track {cancelled_track.name} was not missing from Plex, skipping wishlist re-addition") print(f"Cancelled track {cancelled_track.name} was not missing from Plex, skipping wishlist re-addition")
wishlist_added_count = 0 wishlist_added_count = 0
if self.permanently_failed_tracks: if self.permanently_failed_tracks:
@ -1061,7 +1061,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\n" final_message = f"Completed downloading {self.successful_downloads}/{len(self.missing_tracks)} missing tracks!\n\n"
if wishlist_added_count > 0: if wishlist_added_count > 0:
final_message += f"Re-added {wishlist_added_count} failed track{'s' if wishlist_added_count > 1 else ''} to wishlist for future retry.\n\n" final_message += f"Re-added {wishlist_added_count} failed track{'s' if wishlist_added_count > 1 else ''} to wishlist for future retry.\n\n"
if self.permanently_failed_tracks: if self.permanently_failed_tracks:
final_message += "You can also manually correct failed downloads." final_message += "You can also manually correct failed downloads."
else: else:
@ -1322,7 +1322,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
"""Run the download with detailed status updates""" """Run the download with detailed status updates"""
try: try:
# Update status: Starting search # Update status: Starting search
self.signals.status_updated.emit(self.download_index, "🔍 Searching...") self.signals.status_updated.emit(self.download_index, "Searching...")
# Use async method in sync context # Use async method in sync context
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@ -1330,7 +1330,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
try: try:
# Update status: Found candidates, analyzing # Update status: Found candidates, analyzing
self.signals.status_updated.emit(self.download_index, "🔎 Analyzing results...") self.signals.status_updated.emit(self.download_index, "Analyzing results...")
# Use the enhanced search method that provides more feedback # Use the enhanced search method that provides more feedback
results = loop.run_until_complete( results = loop.run_until_complete(
@ -1339,7 +1339,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
if results and len(results) > 0: if results and len(results) > 0:
# Update status: Found candidates, starting download # Update status: Found candidates, starting download
self.signals.status_updated.emit(self.download_index, f"📋 Found {len(results)} candidates") self.signals.status_updated.emit(self.download_index, f"Found {len(results)} candidates")
time.sleep(0.5) # Brief pause so user can see the status time.sleep(0.5) # Brief pause so user can see the status
# Get the best result and start download # Get the best result and start download
@ -1369,7 +1369,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
"""Search for tracks with progress updates""" """Search for tracks with progress updates"""
try: try:
# Emit search progress # Emit search progress
self.signals.status_updated.emit(self.download_index, "🌐 Searching network...") self.signals.status_updated.emit(self.download_index, "Searching network...")
# Perform the search (this would ideally use the soulseek client's search methods) # Perform the search (this would ideally use the soulseek client's search methods)
# For now, we'll use the existing search_and_download_best method # For now, we'll use the existing search_and_download_best method
@ -1690,7 +1690,7 @@ class MetadataUpdateWorker(QThread):
print(f"No albums found for artist '{artist.title}'") print(f"No albums found for artist '{artist.title}'")
return 0 return 0
print(f"🎨 Checking artwork for {len(albums)} albums by '{artist.title}'...") print(f"Checking artwork for {len(albums)} albums by '{artist.title}'...")
for album in albums: for album in albums:
try: try:
@ -1741,10 +1741,10 @@ class MetadataUpdateWorker(QThread):
continue continue
total_processed = updated_count + skipped_count total_processed = updated_count + skipped_count
print(f"🎨 Artwork summary for '{artist.title}': {updated_count} updated, {skipped_count} skipped (already have good artwork)") print(f"Artwork summary for '{artist.title}': {updated_count} updated, {skipped_count} skipped (already have good artwork)")
if updated_count == 0 and skipped_count == len(albums): if updated_count == 0 and skipped_count == len(albums):
print(f" All albums already have good artwork - no Spotify API calls needed!") print(f" All albums already have good artwork - no Spotify API calls needed!")
return updated_count return updated_count
except Exception as e: except Exception as e:
@ -1758,17 +1758,17 @@ class MetadataUpdateWorker(QThread):
# Check if album has any thumb at all # Check if album has any thumb at all
if not hasattr(album, 'thumb') or not album.thumb: if not hasattr(album, 'thumb') or not album.thumb:
if debug: print(f" 🎨 Album '{album_title}' has NO THUMB - needs update") if debug: print(f" Album '{album_title}' has NO THUMB - needs update")
return False return False
thumb_url = str(album.thumb) thumb_url = str(album.thumb)
if debug: print(f" 🔍 Album '{album_title}' artwork URL: {thumb_url}") if debug: print(f" Album '{album_title}' artwork URL: {thumb_url}")
# CONSERVATIVE APPROACH: Only mark as "needs update" in very obvious cases # CONSERVATIVE APPROACH: Only mark as "needs update" in very obvious cases
# Case 1: Completely empty or None # Case 1: Completely empty or None
if not thumb_url or thumb_url.strip() == '': if not thumb_url or thumb_url.strip() == '':
if debug: print(f" 🎨 Album '{album_title}' has empty URL - needs update") if debug: print(f" Album '{album_title}' has empty URL - needs update")
return False return False
# Case 2: Obvious placeholder text in URL # Case 2: Obvious placeholder text in URL
@ -1784,20 +1784,20 @@ class MetadataUpdateWorker(QThread):
thumb_lower = thumb_url.lower() thumb_lower = thumb_url.lower()
for placeholder in obvious_placeholders: for placeholder in obvious_placeholders:
if placeholder in thumb_lower: if placeholder in thumb_lower:
if debug: print(f" 🎨 Album '{album_title}' has obvious placeholder ({placeholder}) - needs update") if debug: print(f" Album '{album_title}' has obvious placeholder ({placeholder}) - needs update")
return False return False
# Case 3: Extremely short URLs (likely broken) # Case 3: Extremely short URLs (likely broken)
if len(thumb_url) < 20: if len(thumb_url) < 20:
if debug: print(f" 🎨 Album '{album_title}' has very short URL ({len(thumb_url)} chars) - needs update") if debug: print(f" Album '{album_title}' has very short URL ({len(thumb_url)} chars) - needs update")
return False return False
# OTHERWISE: Assume it has valid artwork and SKIP updating # OTHERWISE: Assume it has valid artwork and SKIP updating
if debug: print(f" Album '{album_title}' appears to have artwork - SKIPPING (URL: {len(thumb_url)} chars)") if debug: print(f" Album '{album_title}' appears to have artwork - SKIPPING (URL: {len(thumb_url)} chars)")
return True return True
except Exception as e: except Exception as e:
if debug: print(f" Error checking artwork for album '{album_title}': {e}") if debug: print(f" Error checking artwork for album '{album_title}': {e}")
# If we can't check, be conservative and skip updating # If we can't check, be conservative and skip updating
return True return True
@ -1819,9 +1819,9 @@ class MetadataUpdateWorker(QThread):
# Upload using media client # Upload using media client
success = self.media_client.update_album_poster(album, image_data) success = self.media_client.update_album_poster(album, image_data)
if success: if success:
print(f"Updated artwork for album '{album_title}'") print(f"Updated artwork for album '{album_title}'")
else: else:
print(f"Failed to upload artwork for album '{album_title}'") print(f"Failed to upload artwork for album '{album_title}'")
return success return success
@ -1980,7 +1980,7 @@ class DashboardDataProvider(QObject):
self.session_completed_downloads += 1 self.session_completed_downloads += 1
# Emit signal for activity feed with specific track info # Emit signal for activity feed with specific track info
self.activity_item_added.emit("📥", "Download Complete", f"'{title}' by {artist}", "Now") self.activity_item_added.emit("", "Download Complete", f"'{title}' by {artist}", "Now")
def update_service_status(self, service: str, connected: bool, response_time: float = 0.0, error: str = ""): def update_service_status(self, service: str, connected: bool, response_time: float = 0.0, error: str = ""):
if service in self.service_status: if service in self.service_status:
@ -2734,7 +2734,7 @@ class DashboardPage(QWidget):
self.scan_manager = MediaScanManager(delay_seconds=60) self.scan_manager = MediaScanManager(delay_seconds=60)
# Add automatic incremental database update after scan completion # Add automatic incremental database update after scan completion
self.scan_manager.add_scan_completion_callback(self._on_media_scan_completed) self.scan_manager.add_scan_completion_callback(self._on_media_scan_completed)
logger.info("MediaScanManager initialized for Dashboard wishlist modal") logger.info("MediaScanManager initialized for Dashboard wishlist modal")
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize MediaScanManager: {e}") logger.error(f"Failed to initialize MediaScanManager: {e}")
@ -2792,7 +2792,7 @@ class DashboardPage(QWidget):
return return
# All conditions met - start incremental update # All conditions met - start incremental update
logger.info(f"🎵 Starting automatic incremental database update after {active_server.upper()} scan") logger.info(f"Starting automatic incremental database update after {active_server.upper()} scan")
self._start_automatic_incremental_update() self._start_automatic_incremental_update()
except Exception as e: except Exception as e:
@ -2843,9 +2843,9 @@ class DashboardPage(QWidget):
"""Handle completion of automatic database update""" """Handle completion of automatic database update"""
try: try:
if successful > 0: if successful > 0:
logger.info(f"Automatic database update completed: {successful} items processed successfully") logger.info(f"Automatic database update completed: {successful} items processed successfully")
else: else:
logger.info("💡 Automatic database update completed - no new content found") logger.info("Automatic database update completed - no new content found")
self.refresh_database_statistics() self.refresh_database_statistics()
# Clean up the worker # Clean up the worker
if hasattr(self, '_auto_database_worker'): if hasattr(self, '_auto_database_worker'):
@ -2970,7 +2970,7 @@ class DashboardPage(QWidget):
buttons_layout.setSpacing(10) buttons_layout.setSpacing(10)
# Wishlist button # Wishlist button
self.wishlist_button = QPushButton("🎵 Wishlist (0)") self.wishlist_button = QPushButton("Wishlist (0)")
self.wishlist_button.setFixedHeight(45) self.wishlist_button.setFixedHeight(45)
self.wishlist_button.setFixedWidth(150) self.wishlist_button.setFixedWidth(150)
self.wishlist_button.clicked.connect(self.on_wishlist_button_clicked) self.wishlist_button.clicked.connect(self.on_wishlist_button_clicked)
@ -2997,7 +2997,7 @@ class DashboardPage(QWidget):
""") """)
# Watchlist button # Watchlist button
self.watchlist_button = QPushButton("👁️ Watchlist (0)") self.watchlist_button = QPushButton("Watchlist (0)")
self.watchlist_button.setFixedHeight(45) self.watchlist_button.setFixedHeight(45)
self.watchlist_button.setFixedWidth(150) self.watchlist_button.setFixedWidth(150)
self.watchlist_button.clicked.connect(self.on_watchlist_button_clicked) self.watchlist_button.clicked.connect(self.on_watchlist_button_clicked)
@ -3166,7 +3166,7 @@ class DashboardPage(QWidget):
self.activity_layout = activity_layout self.activity_layout = activity_layout
# Add initial placeholder # Add initial placeholder
placeholder_item = ActivityItem("📊", "System Started", "Dashboard initialized successfully", "Now") placeholder_item = ActivityItem("", "System Started", "Dashboard initialized successfully", "Now")
activity_layout.addWidget(placeholder_item) activity_layout.addWidget(placeholder_item)
layout.addWidget(header_label) layout.addWidget(header_label)
@ -3192,7 +3192,7 @@ class DashboardPage(QWidget):
card.status_text.setText("Testing connection...") card.status_text.setText("Testing connection...")
# Add activity item for test initiation # Add activity item for test initiation
self.add_activity_item("🔍", f"Testing {service.capitalize()}", "Connection test initiated", "Now") self.add_activity_item("", f"Testing {service.capitalize()}", "Connection test initiated", "Now")
# Start test # Start test
self.data_provider.test_service_connection(service) self.data_provider.test_service_connection(service)
@ -3216,7 +3216,7 @@ class DashboardPage(QWidget):
# Check that we have a data provider # Check that we have a data provider
if not hasattr(self, 'data_provider'): if not hasattr(self, 'data_provider'):
self.add_activity_item("", "Database Update", "Service clients not available", "Now") self.add_activity_item("", "Database Update", "Service clients not available", "Now")
return return
# Get the active media server and check if client is available # Get the active media server and check if client is available
@ -3224,13 +3224,13 @@ class DashboardPage(QWidget):
active_server = config_manager.get_active_media_server() active_server = config_manager.get_active_media_server()
if active_server == "plex" and not self.data_provider.service_clients.get('plex_client'): if active_server == "plex" and not self.data_provider.service_clients.get('plex_client'):
self.add_activity_item("", "Database Update", "Plex client not available", "Now") self.add_activity_item("", "Database Update", "Plex client not available", "Now")
return return
elif active_server == "jellyfin": elif active_server == "jellyfin":
# Jellyfin client will be created on-demand, just verify config exists # Jellyfin client will be created on-demand, just verify config exists
jellyfin_config = config_manager.get_jellyfin_config() jellyfin_config = config_manager.get_jellyfin_config()
if not jellyfin_config.get('base_url') or not jellyfin_config.get('api_key'): if not jellyfin_config.get('base_url') or not jellyfin_config.get('api_key'):
self.add_activity_item("", "Database Update", "Jellyfin not configured", "Now") self.add_activity_item("", "Database Update", "Jellyfin not configured", "Now")
return return
try: try:
@ -3242,7 +3242,7 @@ class DashboardPage(QWidget):
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"Confirm Full Database Refresh", "Confirm Full Database Refresh",
"⚠️ You've selected FULL REFRESH mode.\n\n" "You've selected FULL REFRESH mode.\n\n"
"This will completely rebuild your database and may take several minutes.\n" "This will completely rebuild your database and may take several minutes.\n"
"All existing data will be cleared and rebuilt from your Plex library.\n\n" "All existing data will be cleared and rebuilt from your Plex library.\n\n"
"Are you sure you want to continue?", "Are you sure you want to continue?",
@ -3267,7 +3267,7 @@ class DashboardPage(QWidget):
media_client = JellyfinClient() media_client = JellyfinClient()
else: else:
logger.error(f"Unknown active server: {active_server}") logger.error(f"Unknown active server: {active_server}")
self.add_activity_item("", "Database Update", f"Unknown server type: {active_server}", "Now") self.add_activity_item("", "Database Update", f"Unknown server type: {active_server}", "Now")
return return
# Start the database update worker # Start the database update worker
@ -3289,7 +3289,7 @@ class DashboardPage(QWidget):
self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0) self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0)
update_type = "Full refresh" if full_refresh else "Incremental update" update_type = "Full refresh" if full_refresh else "Incremental update"
server_display = active_server.title() # "Plex" or "Jellyfin" server_display = active_server.title() # "Plex" or "Jellyfin"
self.add_activity_item("🗄️", "Database Update", f"Starting {update_type.lower()} from {server_display}...", "Now") self.add_activity_item("", "Database Update", f"Starting {update_type.lower()} from {server_display}...", "Now")
self.database_worker.start() self.database_worker.start()
@ -3297,7 +3297,7 @@ class DashboardPage(QWidget):
self.start_database_stats_refresh() self.start_database_stats_refresh()
except Exception as e: except Exception as e:
self.add_activity_item("", "Database Update", f"Failed to start: {str(e)}", "Now") self.add_activity_item("", "Database Update", f"Failed to start: {str(e)}", "Now")
def stop_database_update(self): def stop_database_update(self):
"""Stop the database update process""" """Stop the database update process"""
@ -3308,7 +3308,7 @@ class DashboardPage(QWidget):
self.database_worker.terminate() self.database_worker.terminate()
self.database_widget.update_progress(False, "", 0, 0, 0.0) self.database_widget.update_progress(False, "", 0, 0, 0.0)
self.add_activity_item("⏹️", "Database Update", "Stopped database update process", "Now") self.add_activity_item("", "Database Update", "Stopped database update process", "Now")
# Stop statistics refresh timer # Stop statistics refresh timer
self.stop_database_stats_refresh() self.stop_database_stats_refresh()
@ -3320,15 +3320,15 @@ class DashboardPage(QWidget):
def on_database_artist_processed(self, artist_name: str, success: bool, details: str, album_count: int, track_count: int): def on_database_artist_processed(self, artist_name: str, success: bool, details: str, album_count: int, track_count: int):
"""Handle individual artist processing completion""" """Handle individual artist processing completion"""
if success: if success:
self.add_activity_item("", "Artist Processed", f"'{artist_name}' - {details}", "Now") self.add_activity_item("", "Artist Processed", f"'{artist_name}' - {details}", "Now")
else: else:
self.add_activity_item("", "Artist Failed", f"'{artist_name}' - {details}", "Now") self.add_activity_item("", "Artist Failed", f"'{artist_name}' - {details}", "Now")
def on_database_finished(self, total_artists: int, total_albums: int, total_tracks: int, successful: int, failed: int): def on_database_finished(self, total_artists: int, total_albums: int, total_tracks: int, successful: int, failed: int):
"""Handle database update completion""" """Handle database update completion"""
self.database_widget.update_progress(False, "", 0, 0, 0.0) self.database_widget.update_progress(False, "", 0, 0, 0.0)
summary = f"Processed {total_artists} artists, {total_albums} albums, {total_tracks} tracks" summary = f"Processed {total_artists} artists, {total_albums} albums, {total_tracks} tracks"
self.add_activity_item("🗄️", "Database Complete", summary, "Now") self.add_activity_item("", "Database Complete", summary, "Now")
# Stop statistics refresh timer and do final update # Stop statistics refresh timer and do final update
self.stop_database_stats_refresh() self.stop_database_stats_refresh()
@ -3337,7 +3337,7 @@ class DashboardPage(QWidget):
def on_database_error(self, error_message: str): def on_database_error(self, error_message: str):
"""Handle database update error""" """Handle database update error"""
self.database_widget.update_progress(False, "", 0, 0, 0.0) self.database_widget.update_progress(False, "", 0, 0, 0.0)
self.add_activity_item("", "Database Error", error_message, "Now") self.add_activity_item("", "Database Error", error_message, "Now")
# Stop statistics refresh timer # Stop statistics refresh timer
self.stop_database_stats_refresh() self.stop_database_stats_refresh()
@ -3461,16 +3461,16 @@ class DashboardPage(QWidget):
if active_server == "jellyfin": if active_server == "jellyfin":
media_client = self.data_provider.service_clients.get('jellyfin_client') media_client = self.data_provider.service_clients.get('jellyfin_client')
if not media_client: if not media_client:
self.add_activity_item("", "Metadata Update", "Jellyfin client not available", "Now") self.add_activity_item("", "Metadata Update", "Jellyfin client not available", "Now")
return return
else: else:
media_client = self.data_provider.service_clients.get('plex_client') media_client = self.data_provider.service_clients.get('plex_client')
if not media_client: if not media_client:
self.add_activity_item("", "Metadata Update", "Plex client not available", "Now") self.add_activity_item("", "Metadata Update", "Plex client not available", "Now")
return return
if not self.data_provider.service_clients.get('spotify_client'): if not self.data_provider.service_clients.get('spotify_client'):
self.add_activity_item("", "Metadata Update", "Spotify client not available", "Now") self.add_activity_item("", "Metadata Update", "Spotify client not available", "Now")
return return
try: try:
@ -3496,19 +3496,19 @@ class DashboardPage(QWidget):
# Update UI and start # Update UI and start
if self.metadata_widget: if self.metadata_widget:
self.metadata_widget.update_progress(True, "Loading artists...", 0, 0, 0.0) self.metadata_widget.update_progress(True, "Loading artists...", 0, 0, 0.0)
self.add_activity_item("🎵", "Metadata Update", "Loading artists from library...", "Now") self.add_activity_item("", "Metadata Update", "Loading artists from library...", "Now")
self.metadata_worker.start() self.metadata_worker.start()
except Exception as e: except Exception as e:
self.add_activity_item("", "Metadata Update", f"Failed to start: {str(e)}", "Now") self.add_activity_item("", "Metadata Update", f"Failed to start: {str(e)}", "Now")
def on_artists_loaded(self, total_artists, artists_to_process): def on_artists_loaded(self, total_artists, artists_to_process):
"""Handle when artists are loaded and filtered""" """Handle when artists are loaded and filtered"""
if artists_to_process == 0: if artists_to_process == 0:
self.add_activity_item("", "Metadata Update", "All artists already have good metadata", "Now") self.add_activity_item("", "Metadata Update", "All artists already have good metadata", "Now")
else: else:
self.add_activity_item("🎵", "Metadata Update", f"Processing {artists_to_process} of {total_artists} artists", "Now") self.add_activity_item("", "Metadata Update", f"Processing {artists_to_process} of {total_artists} artists", "Now")
def stop_metadata_update(self): def stop_metadata_update(self):
"""Stop the metadata update process""" """Stop the metadata update process"""
@ -3520,7 +3520,7 @@ class DashboardPage(QWidget):
if self.metadata_widget: if self.metadata_widget:
self.metadata_widget.update_progress(False, "", 0, 0, 0.0) self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
self.add_activity_item("⏹️", "Metadata Update", "Stopped metadata update process", "Now") self.add_activity_item("", "Metadata Update", "Stopped metadata update process", "Now")
def artist_needs_processing(self, artist): def artist_needs_processing(self, artist):
"""Check if an artist needs metadata processing using smart detection""" """Check if an artist needs metadata processing using smart detection"""
@ -3564,22 +3564,22 @@ class DashboardPage(QWidget):
def on_artist_updated(self, artist_name, success, details): def on_artist_updated(self, artist_name, success, details):
"""Handle individual artist update completion""" """Handle individual artist update completion"""
if success: if success:
self.add_activity_item("", "Artist Updated", f"'{artist_name}' - {details}", "Now") self.add_activity_item("", "Artist Updated", f"'{artist_name}' - {details}", "Now")
else: else:
self.add_activity_item("", "Artist Failed", f"'{artist_name}' - {details}", "Now") self.add_activity_item("", "Artist Failed", f"'{artist_name}' - {details}", "Now")
def on_metadata_finished(self, total_processed, successful, failed): def on_metadata_finished(self, total_processed, successful, failed):
"""Handle metadata update completion""" """Handle metadata update completion"""
if self.metadata_widget: if self.metadata_widget:
self.metadata_widget.update_progress(False, "", 0, 0, 0.0) self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
summary = f"Processed {total_processed} artists: {successful} updated, {failed} failed" summary = f"Processed {total_processed} artists: {successful} updated, {failed} failed"
self.add_activity_item("🎵", "Metadata Complete", summary, "Now") self.add_activity_item("", "Metadata Complete", summary, "Now")
def on_metadata_error(self, error_message): def on_metadata_error(self, error_message):
"""Handle metadata update error""" """Handle metadata update error"""
if self.metadata_widget: if self.metadata_widget:
self.metadata_widget.update_progress(False, "", 0, 0, 0.0) self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
self.add_activity_item("", "Metadata Error", error_message, "Now") self.add_activity_item("", "Metadata Error", error_message, "Now")
def on_service_status_updated(self, service: str, connected: bool, response_time: float, error: str): def on_service_status_updated(self, service: str, connected: bool, response_time: float, error: str):
"""Handle service status updates from data provider""" """Handle service status updates from data provider"""
@ -3591,7 +3591,7 @@ class DashboardPage(QWidget):
self.previous_service_status[service] = connected self.previous_service_status[service] = connected
status = "Connected" if connected else "Disconnected" status = "Connected" if connected else "Disconnected"
icon = "" if connected else "" icon = "" if connected else ""
self.add_activity_item(icon, f"{service.capitalize()} {status}", self.add_activity_item(icon, f"{service.capitalize()} {status}",
f"Response time: {response_time:.0f}ms" if connected else f"Error: {error}" if error else "Connection test completed", f"Response time: {response_time:.0f}ms" if connected else f"Error: {error}" if error else "Connection test completed",
"Now") "Now")
@ -3676,20 +3676,20 @@ class DashboardPage(QWidget):
from ui.components.toast_manager import ToastType from ui.components.toast_manager import ToastType
# Success activities that deserve toasts # Success activities that deserve toasts
if icon == "" and any(keyword in title.lower() for keyword in ["download started", "sync completed", "complete"]): if icon == "" and any(keyword in title.lower() for keyword in ["download started", "sync completed", "complete"]):
self.toast_manager.success(f"{title}: {subtitle}") self.toast_manager.success(f"{title}: {subtitle}")
return return
if icon == "📥" and "Download Started" in title: if icon == "" and "Download Started" in title:
self.toast_manager.success(f"{subtitle}") self.toast_manager.success(f"{subtitle}")
return return
if icon == "🔍" and "Search Complete" in title: if icon == "" and "Search Complete" in title:
self.toast_manager.info(f"{subtitle}") self.toast_manager.info(f"{subtitle}")
return return
# Error activities that need immediate attention # Error activities that need immediate attention
if icon == "": if icon == "":
# Skip routine background errors # Skip routine background errors
if any(skip_term in title.lower() for skip_term in ["metadata", "connection test", "routine"]): if any(skip_term in title.lower() for skip_term in ["metadata", "connection test", "routine"]):
return return
@ -3700,12 +3700,12 @@ class DashboardPage(QWidget):
return return
# Warning activities # Warning activities
if icon == "⚠️": if icon == "":
self.toast_manager.warning(f"{title}: {subtitle}") self.toast_manager.warning(f"{title}: {subtitle}")
return return
# Info activities for searches and connections # Info activities for searches and connections
if icon == "🔍" and "Search Started" in title: if icon == "" and "Search Started" in title:
self.toast_manager.info(f"{subtitle}") self.toast_manager.info(f"{subtitle}")
return return
@ -3811,7 +3811,7 @@ class DashboardPage(QWidget):
count = self.wishlist_service.get_wishlist_count() count = self.wishlist_service.get_wishlist_count()
if hasattr(self, 'wishlist_button'): if hasattr(self, 'wishlist_button'):
self.wishlist_button.setText(f"🎵 Wishlist ({count})") self.wishlist_button.setText(f"Wishlist ({count})")
# Enable/disable button based on count # Enable/disable button based on count
if count == 0: if count == 0:
@ -3913,7 +3913,7 @@ class DashboardPage(QWidget):
count = database.get_watchlist_count() count = database.get_watchlist_count()
if hasattr(self, 'watchlist_button'): if hasattr(self, 'watchlist_button'):
self.watchlist_button.setText(f"👁️ Watchlist ({count})") self.watchlist_button.setText(f"Watchlist ({count})")
# Enable/disable button based on count # Enable/disable button based on count
if count == 0: if count == 0:

File diff suppressed because it is too large Load diff

View file

@ -474,7 +474,7 @@ class ServiceTestThread(QThread):
# Basic validation first # Basic validation first
if not self.test_config.get('client_id') or not self.test_config.get('client_secret'): if not self.test_config.get('client_id') or not self.test_config.get('client_secret'):
return False, "Please enter both Client ID and Client Secret" return False, "Please enter both Client ID and Client Secret"
# Save temporarily to test # Save temporarily to test
original_client_id = config_manager.get('spotify.client_id') original_client_id = config_manager.get('spotify.client_id')
@ -489,7 +489,7 @@ class ServiceTestThread(QThread):
# Check if client was created successfully (has sp object) # Check if client was created successfully (has sp object)
if client.sp is None: if client.sp is None:
message = "Failed to create Spotify client.\nCheck your credentials." message = "Failed to create Spotify client.\nCheck your credentials."
success = False success = False
else: else:
# Try a simple auth check with timeout # Try a simple auth check with timeout
@ -498,17 +498,17 @@ class ServiceTestThread(QThread):
if client.is_authenticated(): if client.is_authenticated():
user_info = client.get_user_info() user_info = client.get_user_info()
username = user_info.get('display_name', 'Unknown') if user_info else 'Unknown' username = user_info.get('display_name', 'Unknown') if user_info else 'Unknown'
message = f"Spotify connection successful!\nConnected as: {username}" message = f"Spotify connection successful!\nConnected as: {username}"
success = True success = True
else: else:
message = "Spotify authentication failed.\nPlease complete the OAuth flow in your browser." message = "Spotify authentication failed.\nPlease complete the OAuth flow in your browser."
success = False success = False
except Exception as auth_e: except Exception as auth_e:
message = f"Spotify authentication failed:\n{str(auth_e)}" message = f"Spotify authentication failed:\n{str(auth_e)}"
success = False success = False
except Exception as client_e: except Exception as client_e:
message = f"Failed to create Spotify client:\n{str(client_e)}" message = f"Failed to create Spotify client:\n{str(client_e)}"
success = False success = False
# Restore original values # Restore original values
@ -524,7 +524,7 @@ class ServiceTestThread(QThread):
config_manager.set('spotify.client_secret', original_client_secret) config_manager.set('spotify.client_secret', original_client_secret)
except: except:
pass pass
return False, f"Spotify test failed:\n{str(e)}" return False, f"Spotify test failed:\n{str(e)}"
def _test_tidal(self): def _test_tidal(self):
"""Test Tidal connection""" """Test Tidal connection"""
@ -533,7 +533,7 @@ class ServiceTestThread(QThread):
# Basic validation first # Basic validation first
if not self.test_config.get('client_id') or not self.test_config.get('client_secret'): if not self.test_config.get('client_id') or not self.test_config.get('client_secret'):
return False, "Please enter both Client ID and Client Secret" return False, "Please enter both Client ID and Client Secret"
# Save temporarily to test # Save temporarily to test
original_client_id = config_manager.get('tidal.client_id') original_client_id = config_manager.get('tidal.client_id')
@ -550,14 +550,14 @@ class ServiceTestThread(QThread):
if client.is_authenticated() or client._ensure_valid_token(): if client.is_authenticated() or client._ensure_valid_token():
user_info = client.get_user_info() user_info = client.get_user_info()
username = user_info.get('display_name', 'Tidal User') if user_info else 'Tidal User' username = user_info.get('display_name', 'Tidal User') if user_info else 'Tidal User'
message = f"Tidal connection successful!\nConnected as: {username}\nOAuth flow completed." message = f"Tidal connection successful!\nConnected as: {username}\nOAuth flow completed."
success = True success = True
else: else:
message = "Tidal authentication failed.\nPlease complete the OAuth flow in your browser.\nCheck your credentials and redirect URI." message = "Tidal authentication failed.\nPlease complete the OAuth flow in your browser.\nCheck your credentials and redirect URI."
success = False success = False
except Exception as client_e: except Exception as client_e:
message = f"Failed to create Tidal client:\n{str(client_e)}" message = f"Failed to create Tidal client:\n{str(client_e)}"
success = False success = False
# Restore original values # Restore original values
@ -573,7 +573,7 @@ class ServiceTestThread(QThread):
config_manager.set('tidal.client_secret', original_client_secret) config_manager.set('tidal.client_secret', original_client_secret)
except: except:
pass pass
return False, f"Tidal test failed:\n{str(e)}" return False, f"Tidal test failed:\n{str(e)}"
def _test_plex(self): def _test_plex(self):
"""Test Plex connection""" """Test Plex connection"""
@ -591,10 +591,10 @@ class ServiceTestThread(QThread):
client = PlexClient() client = PlexClient()
if client.is_connected(): if client.is_connected():
server_name = client.server.friendlyName if client.server else 'Unknown' server_name = client.server.friendlyName if client.server else 'Unknown'
message = f"Plex connection successful!\nServer: {server_name}" message = f"Plex connection successful!\nServer: {server_name}"
success = True success = True
else: else:
message = "Plex connection failed.\nCheck your server URL and token." message = "Plex connection failed.\nCheck your server URL and token."
success = False success = False
# Restore original values # Restore original values
@ -604,7 +604,7 @@ class ServiceTestThread(QThread):
return success, message return success, message
except Exception as e: except Exception as e:
return False, f"Plex test failed:\n{str(e)}" return False, f"Plex test failed:\n{str(e)}"
def _test_jellyfin(self): def _test_jellyfin(self):
"""Test Jellyfin connection""" """Test Jellyfin connection"""
@ -634,19 +634,19 @@ class ServiceTestThread(QThread):
data = response.json() data = response.json()
server_name = data.get('ServerName', 'Unknown') server_name = data.get('ServerName', 'Unknown')
version = data.get('Version', 'Unknown') version = data.get('Version', 'Unknown')
message = f"Jellyfin connection successful!\nServer: {server_name}\nVersion: {version}" message = f"Jellyfin connection successful!\nServer: {server_name}\nVersion: {version}"
return True, message return True, message
elif response.status_code == 401: elif response.status_code == 401:
return False, "Jellyfin authentication failed.\nCheck your API key." return False, "Jellyfin authentication failed.\nCheck your API key."
else: else:
return False, f"Jellyfin connection failed.\nHTTP {response.status_code}: {response.text}" return False, f"Jellyfin connection failed.\nHTTP {response.status_code}: {response.text}"
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
return False, "Jellyfin connection timeout.\nCheck your server URL." return False, "Jellyfin connection timeout.\nCheck your server URL."
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
return False, "Cannot connect to Jellyfin server.\nCheck your server URL and network." return False, "Cannot connect to Jellyfin server.\nCheck your server URL and network."
except Exception as e: except Exception as e:
return False, f"Jellyfin test failed:\n{str(e)}" return False, f"Jellyfin test failed:\n{str(e)}"
def _test_navidrome(self): def _test_navidrome(self):
"""Test Navidrome connection""" """Test Navidrome connection"""
@ -695,23 +695,23 @@ class ServiceTestThread(QThread):
if subsonic_response.get('status') == 'ok': if subsonic_response.get('status') == 'ok':
version = subsonic_response.get('version', 'Unknown') version = subsonic_response.get('version', 'Unknown')
message = f"Navidrome connection successful!\nSubsonic API Version: {version}" message = f"Navidrome connection successful!\nSubsonic API Version: {version}"
return True, message return True, message
elif subsonic_response.get('status') == 'failed': elif subsonic_response.get('status') == 'failed':
error = subsonic_response.get('error', {}) error = subsonic_response.get('error', {})
error_message = error.get('message', 'Unknown error') error_message = error.get('message', 'Unknown error')
return False, f"Navidrome authentication failed:\n{error_message}" return False, f"Navidrome authentication failed:\n{error_message}"
else: else:
return False, "Unexpected response from Navidrome server" return False, "Unexpected response from Navidrome server"
else: else:
return False, f"Navidrome connection failed.\nHTTP {response.status_code}: {response.text}" return False, f"Navidrome connection failed.\nHTTP {response.status_code}: {response.text}"
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
return False, "Navidrome connection timeout.\nCheck your server URL." return False, "Navidrome connection timeout.\nCheck your server URL."
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
return False, "Cannot connect to Navidrome server.\nCheck your server URL and network." return False, "Cannot connect to Navidrome server.\nCheck your server URL and network."
except Exception as e: except Exception as e:
return False, f"Navidrome test failed:\n{str(e)}" return False, f"Navidrome test failed:\n{str(e)}"
def _test_soulseek(self): def _test_soulseek(self):
"""Test Soulseek connection""" """Test Soulseek connection"""
@ -734,31 +734,31 @@ class ServiceTestThread(QThread):
response = requests.get(f"{slskd_url}/api/v0/session", headers=headers, timeout=5) response = requests.get(f"{slskd_url}/api/v0/session", headers=headers, timeout=5)
if response.status_code == 200: if response.status_code == 200:
return True, "Soulseek connection successful!\nslskd is responding." return True, "Soulseek connection successful!\nslskd is responding."
elif response.status_code == 401: elif response.status_code == 401:
return False, ("Invalid API key\n\n" return False, ("Invalid API key\n\n"
"Please check your slskd API key in the configuration.") "Please check your slskd API key in the configuration.")
else: else:
return False, (f"Soulseek connection failed\nHTTP {response.status_code}\n\n" return False, (f"Soulseek connection failed\nHTTP {response.status_code}\n\n"
"slskd is running but returned an error.") "slskd is running but returned an error.")
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
if "refused" in str(e).lower(): if "refused" in str(e).lower():
return False, ("Cannot connect to slskd\n\n" return False, ("Cannot connect to slskd\n\n"
"slskd appears to not be running on the specified URL.\n\n" "slskd appears to not be running on the specified URL.\n\n"
"To fix this:\n" "To fix this:\n"
"1. Install slskd from: https://github.com/slskd/slskd\n" "1. Install slskd from: https://github.com/slskd/slskd\n"
"2. Start slskd service\n" "2. Start slskd service\n"
"3. Ensure it's running on the correct port (default: 5030)") "3. Ensure it's running on the correct port (default: 5030)")
else: else:
return False, f"Network error:\n{str(e)}" return False, f"Network error:\n{str(e)}"
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
return False, ("Connection timed out\n\n" return False, ("Connection timed out\n\n"
"slskd is not responding. Check if it's running and accessible.") "slskd is not responding. Check if it's running and accessible.")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
return False, f"Request failed:\n{str(e)}" return False, f"Request failed:\n{str(e)}"
except Exception as e: except Exception as e:
return False, f"Unexpected error:\n{str(e)}" return False, f"Unexpected error:\n{str(e)}"
class JellyfinDetectionThread(QThread): class JellyfinDetectionThread(QThread):
progress_updated = pyqtSignal(int, str) # progress value, current url progress_updated = pyqtSignal(int, str) # progress value, current url
@ -1004,7 +1004,7 @@ class NavidromeDetectionThread(QThread):
print(f"Response data: {data}") print(f"Response data: {data}")
# Check if it's a valid Subsonic API response # Check if it's a valid Subsonic API response
if 'subsonic-response' in data: if 'subsonic-response' in data:
print(f"Found Navidrome server at {url}") print(f"Found Navidrome server at {url}")
return True return True
except Exception as e: except Exception as e:
print(f"JSON parse error: {e}") print(f"JSON parse error: {e}")
@ -1013,7 +1013,7 @@ class NavidromeDetectionThread(QThread):
try: try:
root_response = requests.get(url, timeout=timeout) root_response = requests.get(url, timeout=timeout)
if root_response.status_code == 200 and 'navidrome' in root_response.text.lower(): if root_response.status_code == 200 and 'navidrome' in root_response.text.lower():
print(f"Found Navidrome web interface at {url}") print(f"Found Navidrome web interface at {url}")
return True return True
except: except:
pass pass
@ -1166,7 +1166,7 @@ class SettingsPage(QWidget):
content_layout.addStretch() content_layout.addStretch()
# Save button # Save button
self.save_btn = QPushButton("💾 Save Settings") self.save_btn = QPushButton("Save Settings")
self.save_btn.setFixedHeight(45) self.save_btn.setFixedHeight(45)
self.save_btn.clicked.connect(self.save_settings) self.save_btn.clicked.connect(self.save_settings)
self.save_btn.setStyleSheet(""" self.save_btn.setStyleSheet("""
@ -1333,7 +1333,7 @@ class SettingsPage(QWidget):
# Update button text temporarily # Update button text temporarily
original_text = self.save_btn.text() original_text = self.save_btn.text()
self.save_btn.setText("Saved!") self.save_btn.setText("Saved!")
self.save_btn.setStyleSheet(""" self.save_btn.setStyleSheet("""
QPushButton { QPushButton {
background: #1aa34a; background: #1aa34a;
@ -1397,20 +1397,20 @@ class SettingsPage(QWidget):
# Create client and authenticate # Create client and authenticate
client = TidalClient() client = TidalClient()
self.tidal_auth_btn.setText("🔐 Authenticating...") self.tidal_auth_btn.setText("Authenticating...")
self.tidal_auth_btn.setEnabled(False) self.tidal_auth_btn.setEnabled(False)
if client.authenticate(): if client.authenticate():
QMessageBox.information(self, "Success", "Tidal authentication successful!\nYou can now use Tidal playlists.") QMessageBox.information(self, "Success", "Tidal authentication successful!\nYou can now use Tidal playlists.")
self.tidal_auth_btn.setText("Authenticated") self.tidal_auth_btn.setText("Authenticated")
else: else:
QMessageBox.warning(self, "Authentication Failed", "Tidal authentication failed.\nPlease check your credentials and try again.") QMessageBox.warning(self, "Authentication Failed", "Tidal authentication failed.\nPlease check your credentials and try again.")
self.tidal_auth_btn.setText("🔐 Authenticate") self.tidal_auth_btn.setText("Authenticate")
self.tidal_auth_btn.setEnabled(True) self.tidal_auth_btn.setEnabled(True)
except Exception as e: except Exception as e:
self.tidal_auth_btn.setText("🔐 Authenticate") self.tidal_auth_btn.setText("Authenticate")
self.tidal_auth_btn.setEnabled(True) self.tidal_auth_btn.setEnabled(True)
QMessageBox.critical(self, "Error", f"Failed to authenticate with Tidal:\n{str(e)}") QMessageBox.critical(self, "Error", f"Failed to authenticate with Tidal:\n{str(e)}")
@ -1826,7 +1826,7 @@ class SettingsPage(QWidget):
# Success message # Success message
location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network" location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network"
success_label = QLabel(f"Found Plex server running {location_type}!") success_label = QLabel(f"Found Plex server running {location_type}!")
success_label.setStyleSheet("color: #e5a00d; font-size: 13px; font-weight: bold;") success_label.setStyleSheet("color: #e5a00d; font-size: 13px; font-weight: bold;")
success_label.setAlignment(Qt.AlignmentFlag.AlignCenter) success_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(success_label) layout.addWidget(success_label)
@ -1937,7 +1937,7 @@ class SettingsPage(QWidget):
# Success message # Success message
location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network" location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network"
success_label = QLabel(f"Found Jellyfin server running {location_type}!") success_label = QLabel(f"Found Jellyfin server running {location_type}!")
success_label.setStyleSheet("color: #aa5cc3; font-size: 13px; font-weight: bold;") success_label.setStyleSheet("color: #aa5cc3; font-size: 13px; font-weight: bold;")
success_label.setAlignment(Qt.AlignmentFlag.AlignCenter) success_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(success_label) layout.addWidget(success_label)
@ -2048,7 +2048,7 @@ class SettingsPage(QWidget):
# Success message # Success message
location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network" location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network"
success_label = QLabel(f"Found slskd running {location_type}!") success_label = QLabel(f"Found slskd running {location_type}!")
success_label.setStyleSheet("color: #1db954; font-size: 13px; font-weight: bold;") success_label.setStyleSheet("color: #1db954; font-size: 13px; font-weight: bold;")
success_label.setAlignment(Qt.AlignmentFlag.AlignCenter) success_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(success_label) layout.addWidget(success_label)
@ -2315,7 +2315,7 @@ class SettingsPage(QWidget):
tidal_layout.addWidget(oauth_url_label) tidal_layout.addWidget(oauth_url_label)
# Authenticate button # Authenticate button
self.tidal_auth_btn = QPushButton("🔐 Authenticate") self.tidal_auth_btn = QPushButton("Authenticate")
self.tidal_auth_btn.setFixedHeight(30) self.tidal_auth_btn.setFixedHeight(30)
self.tidal_auth_btn.setStyleSheet(""" self.tidal_auth_btn.setStyleSheet("""
QPushButton { QPushButton {
@ -2378,7 +2378,7 @@ class SettingsPage(QWidget):
server_selection_layout.addLayout(toggle_container) server_selection_layout.addLayout(toggle_container)
# Restart warning (initially hidden) # Restart warning (initially hidden)
self.restart_warning_frame = QLabel("⚠️ Server change requires restart - Save settings then restart SoulSync") self.restart_warning_frame = QLabel("Server change requires restart - Save settings then restart SoulSync")
self.restart_warning_frame.setStyleSheet(""" self.restart_warning_frame.setStyleSheet("""
color: #ffc107; color: #ffc107;
font-size: 11px; font-size: 11px;
@ -2764,7 +2764,7 @@ class SettingsPage(QWidget):
database_layout.addWidget(workers_help) database_layout.addWidget(workers_help)
# Metadata Enhancement Settings # Metadata Enhancement Settings
metadata_group = SettingsGroup("🎵 Metadata Enhancement") metadata_group = SettingsGroup("Metadata Enhancement")
metadata_layout = QVBoxLayout(metadata_group) metadata_layout = QVBoxLayout(metadata_group)
metadata_layout.setContentsMargins(16, 20, 16, 16) metadata_layout.setContentsMargins(16, 20, 16, 16)
metadata_layout.setSpacing(12) metadata_layout.setSpacing(12)
@ -2831,13 +2831,13 @@ class SettingsPage(QWidget):
metadata_layout.addWidget(help_text) metadata_layout.addWidget(help_text)
# Playlist Sync Settings # Playlist Sync Settings
playlist_sync_group = SettingsGroup("🎶 Playlist Sync") playlist_sync_group = SettingsGroup("Playlist Sync")
playlist_sync_layout = QVBoxLayout(playlist_sync_group) playlist_sync_layout = QVBoxLayout(playlist_sync_group)
playlist_sync_layout.setContentsMargins(16, 20, 16, 16) playlist_sync_layout.setContentsMargins(16, 20, 16, 16)
playlist_sync_layout.setSpacing(12) playlist_sync_layout.setSpacing(12)
# Create backup checkbox # Create backup checkbox
self.create_backup_checkbox = QCheckBox("🛡️ Create backup of existing playlists before sync") self.create_backup_checkbox = QCheckBox("Create backup of existing playlists before sync")
self.create_backup_checkbox.setChecked(True) self.create_backup_checkbox.setChecked(True)
self.create_backup_checkbox.setStyleSheet(""" self.create_backup_checkbox.setStyleSheet("""
QCheckBox { QCheckBox {
@ -3367,12 +3367,12 @@ class SettingsPage(QWidget):
# Show success toast # Show success toast
from ui.components.toast_manager import ToastManager from ui.components.toast_manager import ToastManager
toast_manager = ToastManager(self) toast_manager = ToastManager(self)
toast_manager.show_toast(f"Navidrome server detected: {found_url}", "success", 4000) toast_manager.show_toast(f"Navidrome server detected: {found_url}", "success", 4000)
else: else:
# Show error toast # Show error toast
from ui.components.toast_manager import ToastManager from ui.components.toast_manager import ToastManager
toast_manager = ToastManager(self) toast_manager = ToastManager(self)
toast_manager.show_toast("No Navidrome servers found on the network", "error", 4000) toast_manager.show_toast("No Navidrome servers found on the network", "error", 4000)
def on_jellyfin_detection_completed(self, found_url): def on_jellyfin_detection_completed(self, found_url):
"""Handle Jellyfin detection completion""" """Handle Jellyfin detection completion"""

File diff suppressed because it is too large Load diff

View file

@ -892,7 +892,7 @@ class MediaPlayer(QWidget):
volume_layout = QHBoxLayout() volume_layout = QHBoxLayout()
volume_layout.setSpacing(10) volume_layout.setSpacing(10)
volume_icon = QLabel("🔊") volume_icon = QLabel("")
volume_icon.setStyleSheet(""" volume_icon.setStyleSheet("""
QLabel { QLabel {
color: #b3b3b3; color: #b3b3b3;
@ -933,7 +933,7 @@ class MediaPlayer(QWidget):
self.volume_slider.valueChanged.connect(self.on_volume_changed) self.volume_slider.valueChanged.connect(self.on_volume_changed)
# Stop button - more visible Spotify style # Stop button - more visible Spotify style
self.stop_btn = QPushButton("") self.stop_btn = QPushButton("")
self.stop_btn.setFixedSize(32, 32) self.stop_btn.setFixedSize(32, 32)
self.stop_btn.setStyleSheet(""" self.stop_btn.setStyleSheet("""
QPushButton { QPushButton {
@ -1029,7 +1029,7 @@ class MediaPlayer(QWidget):
"""Update play/pause button state""" """Update play/pause button state"""
self.is_playing = playing self.is_playing = playing
if playing: if playing:
self.play_pause_btn.setText("⏸︎") self.play_pause_btn.setText("")
# Start scrolling animation when playing # Start scrolling animation when playing
if self.track_info.should_scroll and not self.track_info.is_scrolling: if self.track_info.should_scroll and not self.track_info.is_scrolling:
self.track_info.start_scroll_animation() self.track_info.start_scroll_animation()
@ -1207,11 +1207,11 @@ class ModernSidebar(QWidget):
# Navigation buttons # Navigation buttons
nav_items = [ nav_items = [
("dashboard", "Dashboard", "📊"), ("dashboard", "Dashboard", ""),
("sync", "Sync", "🔄"), ("sync", "Sync", ""),
("downloads", "Search", "📥"), ("downloads", "Search", ""),
("artists", "Artists", "🎵"), ("artists", "Artists", ""),
("settings", "Settings", "⚙️") ("settings", "Settings", "")
] ]
for page_id, title, icon in nav_items: for page_id, title, icon in nav_items:

File diff suppressed because it is too large Load diff