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)
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._fernet: Optional[Fernet] = None
@ -45,7 +45,7 @@ class ConfigManager:
else:
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))
@ -107,7 +107,7 @@ class ConfigManager:
try:
import shutil
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:
key_file = old_key_file # Fall back to old location
if key_file.exists():
@ -155,7 +155,7 @@ class ConfigManager:
return decrypted
except InvalidToken:
# 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.")
return value
except Exception:
@ -243,9 +243,9 @@ class ConfigManager:
needs_migration = True
break
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)
print("[OK] Sensitive config values encrypted successfully")
print("[OK] Sensitive config values encrypted successfully")
except Exception as e:
print(f"[WARN] Could not migrate encryption: {e}")
@ -505,7 +505,7 @@ class ConfigManager:
2. config.json (migration from file-based config)
3. Defaults (fresh install)
"""
print(f"📥 Loading configuration...")
print(f"Loading configuration...")
# Try loading from database first
config_data = self._load_from_database()
@ -518,18 +518,18 @@ class ConfigManager:
return
# 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()
if config_data:
# 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):
print("[OK] Configuration migrated successfully to database.")
print("[OK] Configuration migrated successfully to database.")
self.config_data = config_data
return
else:
print("[WARN] ⚠️ Migration failed - using file-based config temporarily.")
print("[WARN] Migration failed - using file-based config temporarily.")
self.config_data = config_data
return
@ -539,9 +539,9 @@ class ConfigManager:
# Try to save defaults to database
if self._save_to_database(config_data):
print("[OK] Default configuration saved to database")
print("[OK] Default configuration saved to database")
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

View file

@ -143,7 +143,7 @@ class DatabaseUpdateWorker(QThread):
else:
freed_items = "unknown"
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:
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
if hasattr(self.media_client, 'set_progress_callback'):
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
artists_to_process = self._get_all_artists()
@ -189,7 +189,7 @@ class DatabaseUpdateWorker(QThread):
merge_results = self.database.merge_duplicate_artists()
merged = merge_results.get('artists_merged', 0)
if merged > 0:
logger.info(f"🧹 Merged {merged} duplicate artists")
logger.info(f"Merged {merged} duplicate artists")
except Exception as e:
logger.warning(f"Could not merge duplicate artists: {e}")
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)
else:
# 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)
# Record full refresh completion for tracking purposes
@ -224,7 +224,7 @@ class DatabaseUpdateWorker(QThread):
else:
freed_items = "cache data"
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:
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_tracks = removal_results.get('tracks_removed', 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")
except Exception as 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)
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:
logger.debug("🧹 Cleanup complete: No orphaned records found")
logger.debug("Cleanup complete: No orphaned records found")
except Exception as e:
logger.warning(f"Could not cleanup orphaned records: {e}")
@ -265,7 +265,7 @@ class DatabaseUpdateWorker(QThread):
merge_results = self.database.merge_duplicate_artists()
merged = merge_results.get('artists_merged', 0)
if merged > 0:
logger.info(f"🧹 Merged {merged} duplicate artists")
logger.info(f"Merged {merged} duplicate artists")
except Exception as 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)")
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()
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
except Exception as e:
@ -581,9 +581,9 @@ class DatabaseUpdateWorker(QThread):
if not track_exists:
missing_tracks_count += 1
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:
logger.debug(f"Track '{track_title}' already exists")
logger.debug(f"Track '{track_title}' already exists")
except Exception as track_error:
logger.debug(f"Error checking individual track: {track_error}")
@ -595,23 +595,23 @@ class DatabaseUpdateWorker(QThread):
if album_has_new_tracks:
albums_with_new_content += 1
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:
# Check if existing tracks have metadata changes (catches Plex corrections)
metadata_changed = self._check_for_metadata_changes(tracks)
if metadata_changed:
albums_with_new_content += 1
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
else:
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
# This ensures we don't miss scattered updated content from manual corrections
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
break
@ -633,7 +633,7 @@ class DatabaseUpdateWorker(QThread):
if artist_id not in processed_artist_ids:
processed_artist_ids.add(artist_id)
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:
logger.warning(f"Error getting artist for album '{album_title}': {artist_error}")
@ -649,7 +649,7 @@ class DatabaseUpdateWorker(QThread):
else:
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)
return artists_to_process
@ -662,7 +662,7 @@ class DatabaseUpdateWorker(QThread):
def _get_artists_for_navidrome_incremental_update(self) -> List:
"""Get artists for Navidrome incremental update using smart early-stopping logic like Plex/Jellyfin"""
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)
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 not album_has_new_tracks:
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)
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
except Exception as tracks_error:
@ -744,7 +744,7 @@ class DatabaseUpdateWorker(QThread):
if artist_id not in processed_artist_ids:
processed_artist_ids.add(artist_id)
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:
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
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
except Exception as e:
@ -763,7 +763,7 @@ class DatabaseUpdateWorker(QThread):
def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
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)
recent_added_tracks = self.media_client.get_recently_added_tracks(5000)
@ -799,14 +799,14 @@ class DatabaseUpdateWorker(QThread):
if not track_exists:
new_tracks.append(track)
consecutive_existing_tracks = 0 # Reset counter
logger.debug(f"🎵 New track: {track.title}")
logger.debug(f"New track: {track.title}")
else:
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
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
except Exception as e:
@ -833,12 +833,12 @@ class DatabaseUpdateWorker(QThread):
if artist_id not in processed_artists:
processed_artists.add(artist_id)
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:
logger.debug(f"Error getting artist for track {getattr(track, 'title', 'Unknown')}: {e}")
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
except Exception as e:
@ -853,7 +853,7 @@ class DatabaseUpdateWorker(QThread):
logger.warning("No new tracks to process directly")
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
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)
if track_success:
total_processed_tracks += 1
logger.debug(f"Processed new track: {track.title}")
logger.debug(f"Processed new track: {track.title}")
except Exception as e:
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
except Exception as e:
@ -943,7 +943,7 @@ class DatabaseUpdateWorker(QThread):
self.processed_tracks += total_processed_tracks
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
delattr(self, '_jellyfin_new_tracks')
@ -976,7 +976,7 @@ class DatabaseUpdateWorker(QThread):
if (db_track.title != current_title or
db_track.artist_name != current_artist or
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" Artist: '{db_track.artist_name}''{current_artist}'")
logger.debug(f" Album: '{db_track.album_title}''{current_album}'")
@ -987,7 +987,7 @@ class DatabaseUpdateWorker(QThread):
continue
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 False
@ -1014,7 +1014,7 @@ class DatabaseUpdateWorker(QThread):
return None
# 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}...")
server_artist_ids = self.media_client.get_all_artist_ids()
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
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")
return None
@ -1044,19 +1044,19 @@ class DatabaseUpdateWorker(QThread):
if check_artists and db_artist_count > 100:
if len(server_artist_ids) < db_artist_count * 0.5:
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")
check_artists = False
if check_albums and db_album_count > 100:
if len(server_album_ids) < db_album_count * 0.5:
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")
check_albums = False
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")
return None
@ -1090,11 +1090,11 @@ class DatabaseUpdateWorker(QThread):
pass # If this optimization fails, double-delete is harmless
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")
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")
self._emit_signal('phase_changed',
@ -1219,7 +1219,7 @@ class DatabaseUpdateWorker(QThread):
def _process_all_artists(self, artists: List):
"""Process all artists and their albums/tracks using thread pool"""
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):
"""Process a single artist and return results"""
@ -1240,7 +1240,7 @@ class DatabaseUpdateWorker(QThread):
total_artists,
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
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)
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
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_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.hybrid_order:
logger.info(f" Source priority: {''.join(self.hybrid_order)}")
@ -71,7 +71,7 @@ class DownloadOrchestrator:
try:
return cls()
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)
return None
@ -85,7 +85,7 @@ class DownloadOrchestrator:
# Reload underlying client configs (SLSKD URL, API key, etc.)
if self.soulseek:
self.soulseek._setup_client()
logger.info(f"🔄 Soulseek client config reloaded")
logger.info(f"Soulseek client config reloaded")
# Reconnect Deezer if ARL changed
deezer_arl = config_manager.get('deezer_download.arl', '')
@ -93,7 +93,7 @@ class DownloadOrchestrator:
self.deezer_dl.reconnect(deezer_arl)
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):
"""Get a client by name, returning None if not initialized."""
@ -143,7 +143,7 @@ class DownloadOrchestrator:
except Exception:
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)}")
return any(results.values())
@ -168,9 +168,9 @@ class DownloadOrchestrator:
if self.mode != 'hybrid':
client = self._client(self.mode)
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 [], []
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)
elif self.mode == 'hybrid':
@ -189,33 +189,33 @@ class DownloadOrchestrator:
if not source_order:
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)
for i, source_name in enumerate(source_order):
client = clients.get(source_name)
if not client:
logger.info(f"⏭️ Skipping {source_name} (not available)")
logger.info(f"Skipping {source_name} (not available)")
continue
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
try:
if i == 0:
logger.info(f"🔍 Trying {source_name} (priority {i+1}): {query}")
logger.info(f"Trying {source_name} (priority {i+1}): {query}")
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)
if tracks:
logger.info(f"{source_name} found {len(tracks)} tracks")
logger.info(f"{source_name} found {len(tracks)} tracks")
return (tracks, albums)
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
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 ([], [])
# Fallback: empty results
@ -289,10 +289,10 @@ class DownloadOrchestrator:
if scored:
scored.sort(key=lambda x: x._match_confidence, reverse=True)
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})")
else:
logger.warning(f"⚠️ No streaming results passed validation for: {query}")
logger.warning(f"No streaming results passed validation for: {query}")
return None
elif is_streaming:
filtered_results = tracks
@ -337,12 +337,12 @@ class DownloadOrchestrator:
client = source_map[username]
if not client:
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)
else:
if not self.soulseek:
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)
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)")
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
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:
logger.warning(f"Failed to validate explicit album {album.name}: {e}, keeping clean version")
else:
@ -1072,7 +1072,7 @@ class iTunesClient:
logger.debug(f" Verified explicit album has {track_count} tracks")
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
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
except Exception as e:
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._connection_attempted = False
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:
"""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
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
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:
self._progress_callback("Fetching all tracks in bulk...")
try:
# 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 = []
start_index = 0
limit = 10000
@ -530,13 +530,13 @@ class JellyfinClient:
if limit > 1000:
limit = limit // 2
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
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
else:
logger.warning("⚠️ Track fetch failed at minimum batch size - retrying once")
logger.warning("Track fetch failed at minimum batch size - retrying once")
continue
consecutive_failures = 0
@ -551,7 +551,7 @@ class JellyfinClient:
start_index += limit
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:
self._progress_callback(progress_msg)
@ -564,12 +564,12 @@ class JellyfinClient:
self._track_cache[album_id] = []
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:
self._progress_callback(f"Cached {len(all_tracks)} tracks. Now fetching albums...")
# 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 = []
start_index = 0
limit = 10000
@ -596,13 +596,13 @@ class JellyfinClient:
if limit > 1000:
limit = limit // 2
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
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
else:
logger.warning("⚠️ Album fetch failed at minimum batch size - retrying once")
logger.warning("Album fetch failed at minimum batch size - retrying once")
continue
consecutive_failures = 0
@ -617,7 +617,7 @@ class JellyfinClient:
start_index += limit
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:
self._progress_callback(progress_msg)
@ -632,10 +632,10 @@ class JellyfinClient:
self._album_cache[artist_id] = []
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
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:
self._progress_callback("Cache complete! Now processing artists...")
@ -648,7 +648,7 @@ class JellyfinClient:
if not albums:
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:
self._progress_callback(f"Caching tracks for {len(albums)} recent albums...")
@ -688,11 +688,11 @@ class JellyfinClient:
# Progress update every 50 albums
if (i + 1) % 50 == 0 or i == len(album_ids) - 1:
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:
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:
self._progress_callback("Targeted cache complete! Now checking for new tracks...")
@ -1300,7 +1300,7 @@ class JellyfinClient:
result = response.json()
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
else:
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)")
# 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
except Exception as e:
@ -1453,7 +1453,7 @@ class JellyfinClient:
try:
success = self.create_playlist(target_name, source_tracks)
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
else:
logger.error(f"Failed to create backup playlist '{target_name}'")
@ -1541,12 +1541,12 @@ class JellyfinClient:
if existing_playlist and create_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):
logger.info(f"Backup created successfully")
logger.info(f"Backup created successfully")
else:
logger.warning(f"⚠️ Failed to create backup, continuing with sync")
logger.warning(f"Failed to create backup, continuing with sync")
if existing_playlist:
# Delete existing playlist using DELETE request
@ -1612,7 +1612,7 @@ class JellyfinClient:
response = requests.post(url, headers=headers, params=params, timeout=10)
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
except Exception as e:
@ -1622,14 +1622,14 @@ class JellyfinClient:
def is_library_scanning(self, library_name: str = "Music") -> bool:
"""Check if Jellyfin library is currently scanning"""
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
try:
# Check scheduled tasks for library scan activities
response = self._make_request('/ScheduledTasks')
if not response:
logger.debug("🔍 DEBUG: Could not get scheduled tasks")
logger.debug("DEBUG: Could not get scheduled tasks")
return False
for task in response:
@ -1639,10 +1639,10 @@ class JellyfinClient:
# 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 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
logger.debug("🔍 DEBUG: No active scan tasks detected")
logger.debug("DEBUG: No active scan tasks detected")
return False
except Exception as e:
@ -1802,9 +1802,9 @@ class JellyfinClient:
try:
self._metadata_only_mode = 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:
logger.info("🎯 Metadata-only mode disabled")
logger.info("Metadata-only mode disabled")
return True
except Exception as e:
logger.error(f"Error setting metadata-only mode: {e}")

View file

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

View file

@ -79,7 +79,7 @@ class ListenBrainzManager:
"error": "Not authenticated"
}
logger.info("🔄 Starting ListenBrainz playlists update...")
logger.info("Starting ListenBrainz playlists update...")
summary = {
"created_for": {"updated": 0, "skipped": 0, "new": 0},
@ -97,7 +97,7 @@ class ListenBrainzManager:
for playlist_type, fetch_func in playlist_types:
try:
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:
result = self._update_playlist(playlist, playlist_type)
@ -114,7 +114,7 @@ class ListenBrainzManager:
# Cleanup old playlists (keep only 4 most recent per type)
self._cleanup_old_playlists()
logger.info(f"ListenBrainz update complete: {summary}")
logger.info(f"ListenBrainz update complete: {summary}")
return {
"success": True,
"summary": summary
@ -165,11 +165,11 @@ class ListenBrainzManager:
# Skip if track count hasn't changed (playlist content likely the same)
if db_track_count == track_count:
logger.debug(f"Playlist '{title}' unchanged, skipping")
logger.debug(f"Playlist '{title}' unchanged, skipping")
conn.close()
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
cursor.execute("DELETE FROM listenbrainz_tracks WHERE playlist_id = ?", (db_id,))
@ -185,7 +185,7 @@ class ListenBrainzManager:
result_type = "updated"
else:
logger.info(f" New playlist '{title}', adding to database...")
logger.info(f"New playlist '{title}', adding to database...")
# Insert new playlist
cursor.execute("""
@ -218,7 +218,7 @@ class ListenBrainzManager:
"""
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
track_data_list = []
@ -324,7 +324,7 @@ class ListenBrainzManager:
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'))
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):
"""Remove old playlists, keeping only the 25 most recent per type"""
@ -354,7 +354,7 @@ class ListenBrainzManager:
# Delete old playlists
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:
logger.error(f"Error cleaning up {playlist_type} playlists: {e}")

View file

@ -113,14 +113,14 @@ class LyricsClient:
f.write(synced)
# Embed synced lyrics in audio tags
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:
# Plain lyrics only → write as .txt (not .lrc, which requires timestamps)
with open(txt_path, 'w', encoding='utf-8') as f:
f.write(plain)
# Still embed plain lyrics in audio tags (players can display unsynced lyrics)
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
except Exception as e:

View file

@ -421,7 +421,7 @@ class MusicMatchingEngine:
if is_likely_album and 4 <= len(potential_album_part) <= 30:
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 track_title, False
@ -750,7 +750,7 @@ class MusicMatchingEngine:
f"vs '{slskd_track.filename[:60]}...' | "
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"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
@ -982,11 +982,11 @@ class MusicMatchingEngine:
# Debug logging for troubleshooting
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
print(f" {i+1}. {result.confidence:.3f} - {getattr(result, 'version_type', 'unknown')} - {result.filename[:60]}...")
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
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:
# Server is currently scanning - mark that we need another scan later
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
# Cancel any existing timer and start a new one
if self._timer:
self._timer.cancel()
logger.debug(f"Resetting scan timer ({reason})")
logger.debug(f"Resetting scan timer ({reason})")
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
self._timer = threading.Timer(self.delay, self._execute_scan)
@ -176,21 +176,21 @@ class MediaScanManager:
# Get the active media client
media_client, server_type = self._get_active_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()
return
logger.info(f"🎵 Starting {server_type.upper()} library scan...")
logger.info(f"Starting {server_type.upper()} library scan...")
try:
success = media_client.trigger_library_scan()
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
self._start_periodic_updates()
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()
except Exception as e:
@ -207,7 +207,7 @@ class MediaScanManager:
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
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")
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:
# 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()
# 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.start()
else:
# 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._stop_periodic_updates()
@ -273,7 +273,7 @@ class MediaScanManager:
self._periodic_update_timer.cancel()
self._periodic_update_timer = None
logger.info("🕒 Stopped periodic database updates")
logger.info("Stopped periodic database updates")
self._scan_completed()
except Exception as e:
@ -292,17 +292,17 @@ class MediaScanManager:
logger.debug("Scan completion callback called but scan was not in progress")
return
logger.info("📡 Media library scan completed")
logger.info("Media library scan completed")
# Call registered completion callbacks
self._call_completion_callbacks()
# Check if we need a follow-up 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")
else:
logger.info("No downloads during scan - scan cycle complete")
logger.info("No downloads during scan - scan cycle complete")
def _call_completion_callbacks(self):
"""Call all registered scan completion callbacks"""
@ -344,7 +344,7 @@ class MediaScanManager:
logger.warning("Force scan requested but scan already in progress")
return
logger.info("🚀 Force scan requested - executing immediately")
logger.info("Force scan requested - executing immediately")
self._execute_scan()
def get_status(self) -> dict:

View file

@ -212,8 +212,8 @@ class MetadataService:
def _log_initialization(self):
"""Log initialization status"""
spotify_status = "Authenticated" if self.spotify.is_spotify_authenticated() else "Not authenticated"
fallback_status = "Available" if self.itunes.is_authenticated() else "Not available"
spotify_status = "Authenticated" if self.spotify.is_spotify_authenticated() else "Not authenticated"
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"Preferred provider: {self.preferred_provider}")

View file

@ -288,11 +288,11 @@ class MusicBrainzWorker:
if result and result.get('mbid'):
self.mb_service.update_artist_mbid(item_id, result['mbid'], 'matched')
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:
self.mb_service.update_artist_mbid(item_id, None, 'not_found')
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':
artist_name = item.get('artist')
@ -300,11 +300,11 @@ class MusicBrainzWorker:
if result and result.get('mbid'):
self.mb_service.update_album_mbid(item_id, result['mbid'], 'matched')
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:
self.mb_service.update_album_mbid(item_id, None, 'not_found')
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':
artist_name = item.get('artist')
@ -312,11 +312,11 @@ class MusicBrainzWorker:
if result and result.get('mbid'):
self.mb_service.update_track_mbid(item_id, result['mbid'], 'matched')
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:
self.mb_service.update_track_mbid(item_id, None, 'not_found')
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:
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")

View file

@ -173,7 +173,7 @@ class NavidromeClient:
self._artist_cache.clear()
self._album_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:
"""Get available music folders from Navidrome."""
@ -833,7 +833,7 @@ class NavidromeClient:
response = self._make_request('createPlaylist', params)
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
else:
logger.error(f"Failed to {'update' if playlist_id else 'create'} Navidrome playlist '{name}'")
@ -877,7 +877,7 @@ class NavidromeClient:
try:
success = self.create_playlist(target_name, source_tracks)
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
else:
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 existing_playlists and create_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
if self.copy_playlist(playlist_name, backup_name):
logger.info(f"Backup created successfully")
logger.info(f"Backup created successfully")
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
if existing_playlists:

View file

@ -427,7 +427,7 @@ class PlexClient:
# Create new playlist with copied tracks
try:
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
except Exception as create_error:
logger.error(f"Failed to create backup playlist: {create_error}")
@ -435,7 +435,7 @@ class PlexClient:
try:
new_playlist = self.server.createPlaylist(target_name)
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
except Exception as alt_error:
logger.error(f"Alternative backup creation also failed: {alt_error}")
@ -461,12 +461,12 @@ class PlexClient:
if create_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):
logger.info(f"Backup created successfully")
logger.info(f"Backup created successfully")
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
existing_playlist.delete()
@ -931,7 +931,7 @@ class PlexClient:
try:
library = self.server.library.section(library_name)
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
except Exception as 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:
"""Check if Plex library is currently scanning"""
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
try:
@ -949,31 +949,31 @@ class PlexClient:
# Check if library has a scanning attribute or is refreshing
# The Plex API exposes this through the library's refreshing property
refreshing = hasattr(library, 'refreshing') and library.refreshing
logger.debug(f"🔍 DEBUG: Library.refreshing = {refreshing}")
logger.debug(f"DEBUG: Library.refreshing = {refreshing}")
if refreshing:
logger.debug(f"🔍 DEBUG: Library is refreshing")
logger.debug(f"DEBUG: Library is refreshing")
return True
# Alternative method: Check server activities for scanning
try:
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:
# Look for library scan activities
activity_type = getattr(activity, 'type', '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
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
except Exception as 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
except Exception as e:

View file

@ -54,15 +54,15 @@ class PlexScanManager:
if self._scan_in_progress:
# Plex is currently scanning - mark that we need another scan later
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
# Cancel any existing timer and start a new one
if self._timer:
self._timer.cancel()
logger.debug(f"Resetting scan timer ({reason})")
logger.debug(f"Resetting scan timer ({reason})")
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
self._timer = threading.Timer(self.delay, self._execute_scan)
@ -105,17 +105,17 @@ class PlexScanManager:
self._timer = None
self._scan_start_time = time.time()
logger.info("🎵 Starting Plex library scan...")
logger.info("Starting Plex library scan...")
try:
success = self.plex_client.trigger_library_scan()
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
self._start_periodic_updates()
else:
logger.error("Failed to initiate Plex library scan")
logger.error("Failed to initiate Plex library scan")
self._reset_scan_state()
except Exception as e:
@ -132,7 +132,7 @@ class PlexScanManager:
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
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")
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:
# 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()
# 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.start()
else:
# 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._stop_periodic_updates()
@ -191,7 +191,7 @@ class PlexScanManager:
self._periodic_update_timer.cancel()
self._periodic_update_timer = None
logger.info("🕒 Stopped periodic database updates")
logger.info("Stopped periodic database updates")
self._scan_completed()
except Exception as e:
@ -222,7 +222,7 @@ class PlexScanManager:
else:
# Scan completed!
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()
except Exception as e:
@ -243,17 +243,17 @@ class PlexScanManager:
logger.debug("Scan completion callback called but scan was not in progress")
return
logger.info("📡 Plex library scan completed")
logger.info("Plex library scan completed")
# Call registered completion callbacks
self._call_completion_callbacks()
# Check if we need a follow-up 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")
else:
logger.info("No downloads during scan - scan cycle complete")
logger.info("No downloads during scan - scan cycle complete")
def _call_completion_callbacks(self):
"""Call all registered scan completion callbacks"""
@ -295,7 +295,7 @@ class PlexScanManager:
logger.warning("Force scan requested but scan already in progress")
return
logger.info("🚀 Force scan requested - executing immediately")
logger.info("Force scan requested - executing immediately")
self._execute_scan()
def get_status(self) -> dict:

View file

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

View file

@ -1029,10 +1029,10 @@ class SoulseekClient:
logger.debug(f"{action} download (attempt {i+1}/3) with endpoint: {endpoint}")
response = await self._make_request('DELETE', endpoint)
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
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),
# 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}")
response = await self._make_request('DELETE', fallback_endpoint)
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
except Exception as 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
except Exception as e:
@ -1725,7 +1725,7 @@ class SoulseekClient:
async with session.get(swagger_url, headers=headers) as response:
if response.status == 200:
swagger_data = await response.json()
logger.info("Found Swagger documentation")
logger.info("Found Swagger documentation")
# Look for download/transfer related endpoints
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_set_at = now
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"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")
except (ValueError, TypeError):
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:
# No Retry-After header available
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
else:
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)
return True
@ -312,7 +312,7 @@ def rate_limited(func):
delay = 3.0 * (2 ** attempt) # 3, 6, 12, 24, 48
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)
continue
else:
@ -323,7 +323,7 @@ def rate_limited(func):
elif is_server_error and attempt < max_retries:
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)
continue
@ -534,7 +534,7 @@ class SpotifyClient:
config = config_manager.get_spotify_config()
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
try:
@ -630,7 +630,7 @@ class SpotifyClient:
# Minimum 30 min for auth probe 429s — these indicate persistent throttling
ban_duration = max(delay, _BASE_UNKNOWN_BAN)
_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
else:
logger.debug(f"Spotify authentication check failed: {e}")
@ -656,7 +656,7 @@ class SpotifyClient:
os.remove(cache_path)
logger.info("Deleted Spotify cache file")
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")
@ -1075,7 +1075,7 @@ class SpotifyClient:
return artists
except Exception as 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 []
_detect_and_set_rate_limit(e, 'get_followed_artists')
logger.error(f"Error fetching followed artists: {e}")

View file

@ -472,9 +472,9 @@ class TidalClient:
result = self._exchange_code_for_tokens()
if result:
logger.info("Token exchange successful")
logger.info("Token exchange successful")
else:
logger.error("Token exchange failed")
logger.error("Token exchange failed")
return result
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
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
@ -406,7 +406,7 @@ class WatchlistScanner:
def _disable_spotify_for_run(self, reason: str):
"""Disable Spotify for rest of current run, once."""
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_reason = reason
@ -640,7 +640,7 @@ class WatchlistScanner:
try:
self._backfill_missing_ids(all_watchlist_artists, provider)
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
scan_results = []
@ -655,9 +655,9 @@ class WatchlistScanner:
self._disable_spotify_for_run("global Spotify rate limit active")
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:
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
# 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.sync_spotify_library_cache()
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
@ -1054,10 +1054,10 @@ class WatchlistScanner:
artists_to_match = [a for a in artists if not getattr(a, id_attr, None)]
if not artists_to_match:
logger.info(f"All artists already have {provider} IDs")
logger.info(f"All artists already have {provider} IDs")
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 = {
'spotify': self._match_to_spotify,
@ -1086,7 +1086,7 @@ class WatchlistScanner:
update_fn(artist.id, new_id)
setattr(artist, id_attr, new_id)
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:
unmatched_names.append(artist.artist_name)
@ -1097,9 +1097,9 @@ class WatchlistScanner:
unmatched_names.append(artist.artist_name)
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:
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")
@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)
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
# 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
except Exception as e:
@ -3212,7 +3212,7 @@ class WatchlistScanner:
logger.debug("Spotify not authenticated, skipping library cache sync")
return
logger.info("📚 Syncing Spotify library cache...")
logger.info("Syncing Spotify library cache...")
try:
last_sync = self.database.get_metadata('spotify_library_last_sync')
@ -3256,7 +3256,7 @@ class WatchlistScanner:
# Update last sync timestamp
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:
logger.error(f"Error syncing Spotify library cache: {e}")

View file

@ -91,7 +91,7 @@ class WebScanManager:
if self._scan_in_progress:
# Server is currently scanning - mark that we need another scan later
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 {
"status": "queued",
"message": "Scan already in progress, queued for later",
@ -101,9 +101,9 @@ class WebScanManager:
# Cancel any existing timer and start a new one
if self._timer:
self._timer.cancel()
logger.debug(f"Resetting web scan timer ({reason})")
logger.debug(f"Resetting web scan timer ({reason})")
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
self._timer = threading.Timer(self.delay, self._execute_scan)
@ -182,12 +182,12 @@ class WebScanManager:
# Get the active media client
media_client, server_type = self._get_active_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()
return
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:
# Update progress
@ -200,7 +200,7 @@ class WebScanManager:
success = media_client.trigger_library_scan()
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:
self._scan_progress = {
"status": "active",
@ -210,7 +210,7 @@ class WebScanManager:
# Start periodic completion checking
self._start_periodic_completion_check()
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:
self._scan_progress = {
"status": "failed",
@ -219,7 +219,7 @@ class WebScanManager:
self._reset_scan_state()
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:
self._scan_progress = {
"status": "error",
@ -265,7 +265,7 @@ class WebScanManager:
def _handle_scan_completion(self):
"""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
callbacks_to_call = []
@ -274,7 +274,7 @@ class WebScanManager:
for callback in callbacks_to_call:
try:
logger.info(f"🔄 Calling web scan completion callback: {callback.__name__}")
logger.info(f"Calling web scan completion callback: {callback.__name__}")
callback()
except Exception as 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
with self._lock:
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")
def _reset_scan_state(self):

View file

@ -107,7 +107,7 @@ class YouTubeClient:
self.download_path = Path(download_path)
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)
self.shutdown_check = None
@ -123,11 +123,11 @@ class YouTubeClient:
# Initialize production matching engine for parity with Soulseek
self.matching_engine = MusicMatchingEngine()
logger.info("Initialized production MusicMatchingEngine")
logger.info("Initialized production MusicMatchingEngine")
# Check for ffmpeg (REQUIRED for MP3 conversion)
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")
# Download queue management (mirrors Soulseek's download tracking)
@ -201,7 +201,7 @@ class YouTubeClient:
self.download_opts['cookiesfrombrowser'] = (cookies_browser,)
elif 'cookiesfrombrowser' in self.download_opts:
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:
"""
@ -356,7 +356,7 @@ class YouTubeClient:
# Check if ffmpeg is in system PATH
if shutil.which('ffmpeg'):
logger.info("Found ffmpeg in system PATH")
logger.info("Found ffmpeg in system PATH")
return True
# Auto-download ffmpeg to tools folder if not found
@ -373,7 +373,7 @@ class YouTubeClient:
# If we already have both locally, use them
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
tools_dir_str = str(tools_dir.absolute())
os.environ['PATH'] = tools_dir_str + os.pathsep + os.environ.get('PATH', '')
@ -451,10 +451,10 @@ class YouTubeClient:
ffprobe_zip.unlink() # Clean up zip
else:
logger.error(f"Unsupported platform: {system}")
logger.error(f"Unsupported platform: {system}")
return False
logger.info(f"Downloaded ffmpeg to: {ffmpeg_path}")
logger.info(f"Downloaded ffmpeg to: {ffmpeg_path}")
# Add to PATH
tools_dir_str = str(tools_dir.absolute())
@ -463,7 +463,7 @@ class YouTubeClient:
return True
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" Windows: scoop install ffmpeg")
logger.error(f" Linux: sudo apt install ffmpeg")
@ -568,7 +568,7 @@ class YouTubeClient:
this returns YouTubeSearchResult objects with video-specific metadata
(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:
loop = asyncio.get_event_loop()
@ -643,7 +643,7 @@ class YouTubeClient:
Returns:
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:
# 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_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 (track_results, [])
except Exception as e:
logger.error(f"YouTube search failed: {e}")
logger.error(f"YouTube search failed: {e}")
import traceback
traceback.print_exc()
return ([], [])
@ -846,7 +846,7 @@ class YouTubeClient:
# Sort by confidence (best first)
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
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
@ -867,13 +867,13 @@ class YouTubeClient:
try:
# Parse filename to extract video_id
if '||' not in filename:
logger.error(f"Invalid filename format: {filename}")
logger.error(f"Invalid filename format: {filename}")
return None
video_id, title = filename.split('||', 1)
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}")
# Create unique download ID
@ -905,11 +905,11 @@ class YouTubeClient:
)
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
except Exception as e:
logger.error(f"Failed to start YouTube download: {e}")
logger.error(f"Failed to start YouTube download: {e}")
import traceback
traceback.print_exc()
return None
@ -926,7 +926,7 @@ class YouTubeClient:
elapsed = time.time() - self._last_download_time
if self._last_download_time > 0 and elapsed < self._download_delay:
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)
# Update state to downloading
@ -958,17 +958,17 @@ class YouTubeClient:
self.active_downloads[download_id]['file_path'] = file_path
# 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:
# Mark as errored
with self._download_lock:
if download_id in self.active_downloads:
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:
logger.error(f"YouTube download thread failed for {download_id}: {e}")
logger.error(f"YouTube download thread failed for {download_id}: {e}")
import traceback
traceback.print_exc()
@ -997,7 +997,7 @@ class YouTubeClient:
for attempt in range(max_retries):
# Check for server shutdown using callback
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
try:
@ -1012,15 +1012,15 @@ class YouTubeClient:
if attempt == 1:
# Drop browser cookies — authenticated sessions sometimes get restricted formats
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)
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'] = {
'youtube': { 'player_client': ['web_creator'] }
}
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.pop('cookiesfrombrowser', None)
download_opts.pop('extractor_args', None)
@ -1036,19 +1036,19 @@ class YouTubeClient:
if filename.exists():
return str(filename)
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:
continue # Retry
return None
except Exception as 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
if '403' in error_msg or 'Forbidden' in error_msg:
if attempt < max_retries - 1:
logger.info(f"Waiting 2 seconds before retry...")
logger.info(f"Waiting 2 seconds before retry...")
import time
time.sleep(2)
continue # Retry on 403
@ -1065,7 +1065,7 @@ class YouTubeClient:
return None # All retries failed
except Exception as e:
logger.error(f"Download failed: {e}")
logger.error(f"Download failed: {e}")
import traceback
traceback.print_exc()
return None
@ -1204,7 +1204,7 @@ class YouTubeClient:
# Remove them
for download_id in ids_to_remove:
del self.active_downloads[download_id]
logger.debug(f"🗑️ Cleared finished download {download_id}")
logger.debug(f"Cleared finished download {download_id}")
return True
except Exception as e:
@ -1229,22 +1229,22 @@ class YouTubeClient:
try:
with self._download_lock:
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
# Update state to 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
if remove:
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
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
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
import requests
logger.info(f"🏷️ Enhancing metadata for: {Path(filepath).name}")
logger.info(f"Enhancing metadata for: {Path(filepath).name}")
# Load MP3 file
audio = MP3(filepath)
@ -1267,11 +1267,11 @@ class YouTubeClient:
if audio.tags is not None:
# Delete ALL existing frames
audio.tags.clear()
logger.debug(f" 🧹 Cleared all existing tag frames")
logger.debug(f" Cleared all existing tag frames")
else:
# No tags exist, add them
audio.add_tags()
logger.debug(f" Added new tag structure")
logger.debug(f" Added new tag structure")
if spotify_track:
# Use Spotify metadata
@ -1295,7 +1295,7 @@ class YouTubeClient:
except:
pass
logger.debug(f" 📝 Setting metadata tags...")
logger.debug(f" Setting metadata tags...")
# Set ID3 tags (using setall to ensure they're set)
audio.tags.setall('TIT2', [TIT2(encoding=3, text=title)])
@ -1314,21 +1314,21 @@ class YouTubeClient:
# Combine up to 3 genres (matches production logic)
genre = ', '.join(artist_genres[:3])
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='',
text=f'Downloaded via SoulSync (YouTube)\nSource: {yt_result.url}\nConfidence: {yt_result.confidence:.2f}')])
logger.debug(f" Artist: {artist}")
logger.debug(f" Album Artist: {album_artist}")
logger.debug(f" Title: {title}")
logger.debug(f" Album: {album}")
logger.debug(f" Track #: {track_number}")
logger.debug(f" Disc #: {disc_number}")
logger.debug(f" Year: {year}")
logger.debug(f" Artist: {artist}")
logger.debug(f" Album Artist: {album_artist}")
logger.debug(f" Title: {title}")
logger.debug(f" Album: {album}")
logger.debug(f" Track #: {track_number}")
logger.debug(f" Disc #: {disc_number}")
logger.debug(f" Year: {year}")
# 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)
if album_art_url:
@ -1354,25 +1354,25 @@ class YouTubeClient:
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:
logger.warning(f" ⚠️ Could not embed album art: {art_error}")
logger.warning(f" Could not embed album art: {art_error}")
else:
logger.warning(f" ⚠️ No album art found on Spotify")
logger.warning(f" No album art found on Spotify")
# Save all tags
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
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")
return None
except Exception as e:
logger.warning(f"⚠️ Could not enhance metadata: {e}")
logger.warning(f"Could not enhance metadata: {e}")
return None
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")
return
logger.debug(f" 📥 Downloading cover.jpg...")
logger.debug(f" Downloading cover.jpg...")
response = requests.get(album_art_url, timeout=10)
response.raise_for_status()
@ -1417,10 +1417,10 @@ class YouTubeClient:
# Save to file
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:
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):
"""
@ -1431,10 +1431,10 @@ class YouTubeClient:
from core.lyrics_client import lyrics_client
if not lyrics_client.api:
logger.debug(f" 🎵 LRClib API not available - skipping lyrics")
logger.debug(f" LRClib API not available - skipping lyrics")
return
logger.debug(f" 🎵 Fetching lyrics from LRClib...")
logger.debug(f" Fetching lyrics from LRClib...")
# Get track metadata
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
@ -1452,14 +1452,14 @@ class YouTubeClient:
)
if success:
logger.debug(f" Created .lrc lyrics file")
logger.debug(f" Created .lrc lyrics file")
else:
logger.debug(f" 🎵 No lyrics found on LRClib")
logger.debug(f" No lyrics found on LRClib")
except ImportError:
logger.debug(f" ⚠️ lyrics_client not available - skipping lyrics")
logger.debug(f" lyrics_client not available - skipping lyrics")
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]:
"""
@ -1473,7 +1473,7 @@ class YouTubeClient:
Returns:
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
query = f"{spotify_track.artists[0]} {spotify_track.name}"
@ -1482,19 +1482,19 @@ class YouTubeClient:
results = self.search(query, max_results=10)
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
# Find best matches
matches = self.find_best_matches(spotify_track, results, min_confidence=min_confidence)
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
# Try downloading best match
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)

View file

@ -1524,7 +1524,7 @@ class MusicDatabase:
cursor.execute("ALTER TABLE artists ADD COLUMN musicbrainz_match_status TEXT")
columns_added = True
if columns_added:
logger.info("Added MusicBrainz columns to artists table")
logger.info("Added MusicBrainz columns to artists table")
# --- Albums ---
cursor.execute("PRAGMA table_info(albums)")
@ -1542,7 +1542,7 @@ class MusicDatabase:
added_albums = True
if added_albums:
columns_added = True
logger.info("Added MusicBrainz columns to albums table")
logger.info("Added MusicBrainz columns to albums table")
# --- Tracks ---
cursor.execute("PRAGMA table_info(tracks)")
@ -1560,7 +1560,7 @@ class MusicDatabase:
added_tracks = True
if added_tracks:
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
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")
if columns_added:
logger.info("🎉 MusicBrainz migration completed successfully")
logger.info("MusicBrainz migration completed successfully")
except Exception as e:
logger.error(f"Error in MusicBrainz migration: {e}")
@ -2902,7 +2902,7 @@ class MusicDatabase:
cleared = cursor.rowcount
cursor.execute("INSERT OR REPLACE INTO metadata (key, value) VALUES ('soulid_v2_migration', '1')")
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:
logger.error(f"Error adding soul_id columns: {e}")
@ -3932,7 +3932,7 @@ class MusicDatabase:
DELETE FROM artists
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
if orphaned_albums_count > 0:
@ -3940,7 +3940,7 @@ class MusicDatabase:
DELETE FROM albums
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()
@ -3973,7 +3973,7 @@ class MusicDatabase:
duplicate_groups = cursor.fetchall()
if not duplicate_groups:
logger.debug("🧹 No duplicate artists found")
logger.debug("No duplicate artists found")
return {'artists_merged': 0, 'albums_migrated': 0}
total_merged = 0
@ -3993,7 +3993,7 @@ class MusicDatabase:
server_source = group['server_source']
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
best_id = ids[0]
@ -4061,7 +4061,7 @@ class MusicDatabase:
conn.commit()
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}
@ -4292,7 +4292,7 @@ class MusicDatabase:
if existing_by_name:
old_id = existing_by_name['id']
# 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
enrichment_cols = [
@ -4463,7 +4463,7 @@ class MusicDatabase:
if existing_by_title:
old_id = existing_by_title['id']
# 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 = [
'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)
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
# 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)
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
@ -5108,7 +5108,7 @@ class MusicDatabase:
# Generate title variations for better matching (similar to album approach)
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):
logger.debug(f" {i+1}. '{var}'")
@ -5126,12 +5126,12 @@ class MusicDatabase:
if not potential_matches:
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
for track in potential_matches:
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:
best_confidence = confidence
@ -5139,13 +5139,13 @@ class MusicDatabase:
# Return match only if it meets 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
# 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
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:
album_candidates = self.search_albums(title=album, artist="", limit=10, server_source=server_source)
for album_candidate in album_candidates:
@ -5185,12 +5185,12 @@ class MusicDatabase:
best_match = db_track
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
except Exception as 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
except Exception as e:
@ -5441,7 +5441,7 @@ class MusicDatabase:
# Generate album title variations for edition matching
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):
logger.debug(f" {i+1}. '{var}'")
@ -5462,7 +5462,7 @@ class MusicDatabase:
existing_ids.add(album.id)
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:
continue
@ -5470,7 +5470,7 @@ class MusicDatabase:
# Score each potential match with Smart Edition Matching
for album in albums:
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:
best_confidence = confidence
@ -5478,13 +5478,13 @@ class MusicDatabase:
# Return match only if it meets 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
# 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 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:
# Get ALL albums by this artist (limit 100 to be safe)
# This bypasses SQL 'LIKE' limitations for diacritics (e.g. 'ă' vs 'a')
@ -5508,18 +5508,18 @@ class MusicDatabase:
if confidence > best_confidence:
best_confidence = confidence
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:
logger.warning(f"Fallback artist search failed: {fallback_error}")
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
# Multi-artist fallback: search by title only (any artist)
# Handles collaborative albums filed under a different artist in the library
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:
title_only_albums = self.search_albums(title=title, artist="", limit=20, server_source=server_source)
for album in title_only_albums:
@ -5528,15 +5528,15 @@ class MusicDatabase:
if confidence > best_confidence:
best_confidence = confidence
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:
logger.warning(f"Title-only fallback search failed: {title_error}")
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
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
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)
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
# 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)
edition_bonus = min(0.15, (db_album.track_count - expected_track_count) / expected_track_count * 0.1)
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:
# Found significantly smaller edition, apply penalty
edition_penalty = 0.1
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
@ -5800,7 +5800,7 @@ class MusicDatabase:
# Debug logging for Unicode normalization
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:
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" 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):
if track and hasattr(track, 'ratingKey'):
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:
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")
@ -312,7 +312,7 @@ class PlaylistSyncService:
# Auto-add unmatched tracks to wishlist (skip in Wing It mode)
wishlist_added_count = 0
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
if unmatched_tracks:
try:
@ -484,10 +484,10 @@ class PlaylistSyncService:
actual_track = None
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']
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:
logger.debug(f"Sync cache lookup error: {cache_err}")
# --- 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)
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
if spotify_id:
@ -536,7 +536,7 @@ class PlaylistSyncService:
self.id = db_track.id
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
elif server_type == "navidrome":
# For Navidrome, create a track object from database info (similar to Jellyfin)
@ -547,7 +547,7 @@ class PlaylistSyncService:
self.id = db_track.id
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
else:
# For Plex, use the original fetchItem approach
@ -556,16 +556,16 @@ class PlaylistSyncService:
track_id = int(db_track.id)
actual_plex_track = media_client.server.fetchItem(track_id)
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
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:
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
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
@ -573,7 +573,7 @@ class PlaylistSyncService:
logger.error(f"Error checking track existence for '{original_title}' by '{artist_name}': {db_error}")
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
except Exception as e:

View file

@ -188,7 +188,7 @@ _DEFAULT_DISCOVERY_STATES = {
'discovery_progress': 30, 'spotify_matches': 3, 'spotify_total': 10,
'discovery_results': [
{'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_album': 'Album B'},
],

View file

@ -103,7 +103,7 @@ class TestActivityFeed:
client = socketio.test_client(app)
# Add some activities first
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']
socketio.emit('dashboard:activity', build())
@ -123,7 +123,7 @@ class TestActivityFeed:
# Add an activity
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()
build = shared_state['build_activity_feed_payload']
@ -158,7 +158,7 @@ class TestToasts:
client.get_received() # clear
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()
toast_events = [e for e in received if e['name'] == 'dashboard:toast']
@ -174,7 +174,7 @@ class TestToasts:
client.get_received() # clear
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()
toast_events = [e for e in received if e['name'] == 'dashboard:toast']
@ -187,7 +187,7 @@ class TestToasts:
client.get_received() # clear
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()
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."""
# Add a toast-worthy activity first
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')
assert resp.status_code == 200

View file

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

View file

@ -52,7 +52,7 @@ class DatabaseUpdaterWidget(QFrame):
info_label.setWordWrap(True)
# 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.setStyleSheet("color: #ffaa00; margin-bottom: 8px; padding: 6px 8px; background: #332200; border-radius: 4px;")
self.recommendation_label.setWordWrap(True)
@ -389,8 +389,8 @@ class DatabaseUpdaterWidget(QFrame):
def _update_recommendation_urgency(self, urgent: bool = False):
"""Update the recommendation label styling based on urgency"""
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;")
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;")

View file

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

View file

@ -114,7 +114,7 @@ class VersionInfoModal(QDialog):
# WebUI Transformation
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",
[
"• Full transition from PyQt6 desktop application to responsive web interface",
@ -131,7 +131,7 @@ class VersionInfoModal(QDialog):
# Docker Support
docker_section = self.create_feature_section(
"🐳 Docker Container Support",
"Docker Container Support",
"Complete containerization with Docker for easy deployment and scalability",
[
"• Pre-built Docker images available for instant deployment",
@ -147,7 +147,7 @@ class VersionInfoModal(QDialog):
# Enhanced Music Management
music_section = self.create_feature_section(
"🎵 Enhanced Music Management",
"Enhanced Music Management",
"All beloved features preserved and enhanced with new web-based capabilities",
[
"• Complete Spotify, Tidal, and YouTube Music playlist synchronization",
@ -163,7 +163,7 @@ class VersionInfoModal(QDialog):
# Performance & Reliability
performance_section = self.create_feature_section(
"🚀 Performance & Reliability",
"Performance & Reliability",
"Significant improvements in speed, stability, and resource efficiency",
[
"• Asynchronous processing for improved responsiveness",
@ -234,7 +234,7 @@ class VersionInfoModal(QDialog):
# Usage note if provided
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.setStyleSheet("""
color: #1ed760;

View file

@ -483,7 +483,7 @@ class WatchlistStatusModal(QDialog):
# Search bar
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.setStyleSheet("""
QLineEdit {
@ -675,7 +675,7 @@ class WatchlistStatusModal(QDialog):
def get_artist_status_icon(self, artist):
"""Determine the appropriate status icon and color for an artist based on scan history"""
if not artist.last_scan_timestamp:
return "", "#888888" # Not scanned yet (gray circle)
return "", "#888888" # Not scanned yet (gray circle)
try:
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 older, show as potentially stale but still scanned
if hours_ago <= 24:
return "", "#4caf50" # Recently up to date (bright green)
return "", "#4caf50" # Recently up to date (bright green)
else:
return "", "#888888" # Scanned but older (gray checkmark)
return "", "#888888" # Scanned but older (gray checkmark)
except Exception:
# 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:
"""Create a professional artist card widget"""
@ -773,7 +773,7 @@ class WatchlistStatusModal(QDialog):
info_layout.addWidget(sync_label)
# Delete button with modern styling
delete_button = QPushButton("")
delete_button = QPushButton("")
delete_button.setFixedSize(28, 28)
delete_button.setFont(QFont("Arial", 12, QFont.Weight.Bold))
delete_button.setStyleSheet("""
@ -870,7 +870,7 @@ class WatchlistStatusModal(QDialog):
if item and item.widget():
card = item.widget()
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;")
# 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)
for artist in self.current_artists:
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;")
break
@ -1018,13 +1018,13 @@ class WatchlistStatusModal(QDialog):
if artist.artist_name == artist_name:
if success:
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;")
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;")
else:
card.status_indicator.setText("") # Error
card.status_indicator.setText("") # Error
card.status_indicator.setStyleSheet("color: #f44336; border: none; background: transparent;")
break
@ -1083,7 +1083,7 @@ class WatchlistStatusModal(QDialog):
if item and item.widget():
card = item.widget()
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;")
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)
dashboard_layout = QHBoxLayout()
self.total_card = self.create_compact_counter_card("📀 Total", "0", "#1db954")
self.matched_card = self.create_compact_counter_card("Found", "0", "#4CAF50")
self.total_card = self.create_compact_counter_card("Total", "0", "#1db954")
self.matched_card = self.create_compact_counter_card("Found", "0", "#4CAF50")
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.matched_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;")
layout = QVBoxLayout(progress_frame)
analysis_container = QVBoxLayout()
analysis_label = QLabel("🔍 Library Analysis")
analysis_label = QLabel("Library Analysis")
analysis_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
self.analysis_progress = QProgressBar()
self.analysis_progress.setFixedHeight(20)
@ -482,7 +482,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
# --- DURATION LOGIC REMOVED ---
# "Matched" is now column 2
matched_item = QTableWidgetItem("Pending")
matched_item = QTableWidgetItem("Pending")
matched_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.track_table.setItem(i, 2, matched_item)
@ -575,10 +575,10 @@ class DownloadMissingWishlistTracksModal(QDialog):
cancel_button = layout.itemAt(0).widget()
if cancel_button:
cancel_button.setEnabled(False)
cancel_button.setText("")
cancel_button.setText("")
# 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
if not hasattr(self, 'cancelled_tracks'):
@ -586,7 +586,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
self.cancelled_tracks.add(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
download_index = None
@ -596,7 +596,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
for download in self.active_downloads:
if download.get('table_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
# Check parallel_search_tracking for download index
@ -604,23 +604,23 @@ class DownloadMissingWishlistTracksModal(QDialog):
for idx, track_info in self.parallel_search_tracking.items():
if track_info.get('table_index') == row:
download_index = idx
print(f"🚫 Found parallel tracking {download_index} for cancelled track")
print(f"Found parallel tracking {download_index} for cancelled track")
break
# 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'):
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)
def create_buttons(self):
button_frame = QFrame(styleSheet="background-color: transparent; padding: 10px;")
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.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.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.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)
@ -701,7 +701,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
self.analysis_progress.setValue(track_index)
row_index = track_index - 1
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_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.")
else:
matched_text = "Missing"
matched_text = "Missing"
self.tracks_to_download_count += 1
self.download_count_label.setText(str(self.tracks_to_download_count))
# Add cancel button for missing tracks only
@ -764,13 +764,13 @@ class DownloadMissingWishlistTracksModal(QDialog):
if track_index != -1:
# Skip if track was cancelled
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.completed_downloads += 1
continue
# 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.active_parallel_downloads += 1
self.download_queue_index += 1
@ -940,18 +940,18 @@ class DownloadMissingWishlistTracksModal(QDialog):
if not next_candidate:
self.on_parallel_track_failed(download_index, "No alternative sources in cache")
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)
def on_parallel_track_completed(self, download_index, success):
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
track_info = self.parallel_search_tracking.get(download_index)
if not track_info or track_info.get('completed', False): return
track_info['completed'] = True
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
self.hide_cancel_button_for_row(track_info['table_index'])
self.downloaded_tracks_count += 1
@ -966,10 +966,10 @@ class DownloadMissingWishlistTracksModal(QDialog):
# Check if track was cancelled (don't overwrite cancelled status)
table_index = track_info['table_index']
current_status = self.track_table.item(table_index, 3)
if current_status and "🚫 Cancelled" in current_status.text():
print(f"🔧 Track {download_index} was cancelled - preserving cancelled status")
if current_status and "Cancelled" in current_status.text():
print(f"Track {download_index} was cancelled - preserving cancelled status")
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:
self.permanently_failed_tracks.append(track_info)
self.failed_downloads += 1
@ -986,7 +986,7 @@ class DownloadMissingWishlistTracksModal(QDialog):
def update_failed_matches_button(self):
count = len(self.permanently_failed_tracks)
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()
else:
self.correct_failed_btn.hide()
@ -1033,8 +1033,8 @@ class DownloadMissingWishlistTracksModal(QDialog):
status_item = self.track_table.item(cancelled_row, 3)
current_status = status_item.text() if status_item else ""
if "Downloaded" in current_status:
print(f"🚫 Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist re-addition")
if "Downloaded" in current_status:
print(f"Cancelled track {cancelled_track.name} was already downloaded, skipping wishlist re-addition")
else:
cancelled_track_info = {
'download_index': cancelled_row,
@ -1048,9 +1048,9 @@ class DownloadMissingWishlistTracksModal(QDialog):
# Check if not already in 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)
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:
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
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"
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:
final_message += "You can also manually correct failed downloads."
else:
@ -1322,7 +1322,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
"""Run the download with detailed status updates"""
try:
# 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
loop = asyncio.new_event_loop()
@ -1330,7 +1330,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
try:
# 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
results = loop.run_until_complete(
@ -1339,7 +1339,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
if results and len(results) > 0:
# 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
# Get the best result and start download
@ -1369,7 +1369,7 @@ class SimpleWishlistDownloadWorker(QRunnable):
"""Search for tracks with progress updates"""
try:
# 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)
# 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}'")
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:
try:
@ -1741,10 +1741,10 @@ class MetadataUpdateWorker(QThread):
continue
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):
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
except Exception as e:
@ -1758,17 +1758,17 @@ class MetadataUpdateWorker(QThread):
# Check if album has any thumb at all
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
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
# Case 1: Completely empty or None
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
# Case 2: Obvious placeholder text in URL
@ -1784,20 +1784,20 @@ class MetadataUpdateWorker(QThread):
thumb_lower = thumb_url.lower()
for placeholder in obvious_placeholders:
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
# Case 3: Extremely short URLs (likely broken)
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
# 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
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
return True
@ -1819,9 +1819,9 @@ class MetadataUpdateWorker(QThread):
# Upload using media client
success = self.media_client.update_album_poster(album, image_data)
if success:
print(f"Updated artwork for album '{album_title}'")
print(f"Updated artwork for album '{album_title}'")
else:
print(f"Failed to upload artwork for album '{album_title}'")
print(f"Failed to upload artwork for album '{album_title}'")
return success
@ -1980,7 +1980,7 @@ class DashboardDataProvider(QObject):
self.session_completed_downloads += 1
# 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 = ""):
if service in self.service_status:
@ -2734,7 +2734,7 @@ class DashboardPage(QWidget):
self.scan_manager = MediaScanManager(delay_seconds=60)
# Add automatic incremental database update after scan completion
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:
logger.error(f"Failed to initialize MediaScanManager: {e}")
@ -2792,7 +2792,7 @@ class DashboardPage(QWidget):
return
# 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()
except Exception as e:
@ -2843,9 +2843,9 @@ class DashboardPage(QWidget):
"""Handle completion of automatic database update"""
try:
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:
logger.info("💡 Automatic database update completed - no new content found")
logger.info("Automatic database update completed - no new content found")
self.refresh_database_statistics()
# Clean up the worker
if hasattr(self, '_auto_database_worker'):
@ -2970,7 +2970,7 @@ class DashboardPage(QWidget):
buttons_layout.setSpacing(10)
# Wishlist button
self.wishlist_button = QPushButton("🎵 Wishlist (0)")
self.wishlist_button = QPushButton("Wishlist (0)")
self.wishlist_button.setFixedHeight(45)
self.wishlist_button.setFixedWidth(150)
self.wishlist_button.clicked.connect(self.on_wishlist_button_clicked)
@ -2997,7 +2997,7 @@ class DashboardPage(QWidget):
""")
# Watchlist button
self.watchlist_button = QPushButton("👁️ Watchlist (0)")
self.watchlist_button = QPushButton("Watchlist (0)")
self.watchlist_button.setFixedHeight(45)
self.watchlist_button.setFixedWidth(150)
self.watchlist_button.clicked.connect(self.on_watchlist_button_clicked)
@ -3166,7 +3166,7 @@ class DashboardPage(QWidget):
self.activity_layout = activity_layout
# 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)
layout.addWidget(header_label)
@ -3192,7 +3192,7 @@ class DashboardPage(QWidget):
card.status_text.setText("Testing connection...")
# 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
self.data_provider.test_service_connection(service)
@ -3216,7 +3216,7 @@ class DashboardPage(QWidget):
# Check that we have a 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
# 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()
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
elif active_server == "jellyfin":
# Jellyfin client will be created on-demand, just verify config exists
jellyfin_config = config_manager.get_jellyfin_config()
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
try:
@ -3242,7 +3242,7 @@ class DashboardPage(QWidget):
reply = QMessageBox.question(
self,
"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"
"All existing data will be cleared and rebuilt from your Plex library.\n\n"
"Are you sure you want to continue?",
@ -3267,7 +3267,7 @@ class DashboardPage(QWidget):
media_client = JellyfinClient()
else:
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
# Start the database update worker
@ -3289,7 +3289,7 @@ class DashboardPage(QWidget):
self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0)
update_type = "Full refresh" if full_refresh else "Incremental update"
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()
@ -3297,7 +3297,7 @@ class DashboardPage(QWidget):
self.start_database_stats_refresh()
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):
"""Stop the database update process"""
@ -3308,7 +3308,7 @@ class DashboardPage(QWidget):
self.database_worker.terminate()
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
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):
"""Handle individual artist processing completion"""
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:
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):
"""Handle database update completion"""
self.database_widget.update_progress(False, "", 0, 0, 0.0)
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
self.stop_database_stats_refresh()
@ -3337,7 +3337,7 @@ class DashboardPage(QWidget):
def on_database_error(self, error_message: str):
"""Handle database update error"""
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
self.stop_database_stats_refresh()
@ -3461,16 +3461,16 @@ class DashboardPage(QWidget):
if active_server == "jellyfin":
media_client = self.data_provider.service_clients.get('jellyfin_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
else:
media_client = self.data_provider.service_clients.get('plex_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
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
try:
@ -3496,19 +3496,19 @@ class DashboardPage(QWidget):
# Update UI and start
if self.metadata_widget:
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()
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):
"""Handle when artists are loaded and filtered"""
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:
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):
"""Stop the metadata update process"""
@ -3520,7 +3520,7 @@ class DashboardPage(QWidget):
if self.metadata_widget:
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):
"""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):
"""Handle individual artist update completion"""
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:
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):
"""Handle metadata update completion"""
if self.metadata_widget:
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
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):
"""Handle metadata update error"""
if self.metadata_widget:
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):
"""Handle service status updates from data provider"""
@ -3591,7 +3591,7 @@ class DashboardPage(QWidget):
self.previous_service_status[service] = connected
status = "Connected" if connected else "Disconnected"
icon = "" if connected else ""
icon = "" if connected else ""
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",
"Now")
@ -3676,20 +3676,20 @@ class DashboardPage(QWidget):
from ui.components.toast_manager import ToastType
# 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}")
return
if icon == "📥" and "Download Started" in title:
if icon == "" and "Download Started" in title:
self.toast_manager.success(f"{subtitle}")
return
if icon == "🔍" and "Search Complete" in title:
if icon == "" and "Search Complete" in title:
self.toast_manager.info(f"{subtitle}")
return
# Error activities that need immediate attention
if icon == "":
if icon == "":
# Skip routine background errors
if any(skip_term in title.lower() for skip_term in ["metadata", "connection test", "routine"]):
return
@ -3700,12 +3700,12 @@ class DashboardPage(QWidget):
return
# Warning activities
if icon == "⚠️":
if icon == "":
self.toast_manager.warning(f"{title}: {subtitle}")
return
# 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}")
return
@ -3811,7 +3811,7 @@ class DashboardPage(QWidget):
count = self.wishlist_service.get_wishlist_count()
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
if count == 0:
@ -3913,7 +3913,7 @@ class DashboardPage(QWidget):
count = database.get_watchlist_count()
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
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
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
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)
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
else:
# Try a simple auth check with timeout
@ -498,17 +498,17 @@ class ServiceTestThread(QThread):
if client.is_authenticated():
user_info = client.get_user_info()
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
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
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
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
# Restore original values
@ -524,7 +524,7 @@ class ServiceTestThread(QThread):
config_manager.set('spotify.client_secret', original_client_secret)
except:
pass
return False, f"Spotify test failed:\n{str(e)}"
return False, f"Spotify test failed:\n{str(e)}"
def _test_tidal(self):
"""Test Tidal connection"""
@ -533,7 +533,7 @@ class ServiceTestThread(QThread):
# Basic validation first
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
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():
user_info = client.get_user_info()
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
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
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
# Restore original values
@ -573,7 +573,7 @@ class ServiceTestThread(QThread):
config_manager.set('tidal.client_secret', original_client_secret)
except:
pass
return False, f"Tidal test failed:\n{str(e)}"
return False, f"Tidal test failed:\n{str(e)}"
def _test_plex(self):
"""Test Plex connection"""
@ -591,10 +591,10 @@ class ServiceTestThread(QThread):
client = PlexClient()
if client.is_connected():
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
else:
message = "Plex connection failed.\nCheck your server URL and token."
message = "Plex connection failed.\nCheck your server URL and token."
success = False
# Restore original values
@ -604,7 +604,7 @@ class ServiceTestThread(QThread):
return success, message
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):
"""Test Jellyfin connection"""
@ -634,19 +634,19 @@ class ServiceTestThread(QThread):
data = response.json()
server_name = data.get('ServerName', '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
elif response.status_code == 401:
return False, "Jellyfin authentication failed.\nCheck your API key."
return False, "Jellyfin authentication failed.\nCheck your API key."
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:
return False, "Jellyfin connection timeout.\nCheck your server URL."
return False, "Jellyfin connection timeout.\nCheck your server URL."
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:
return False, f"Jellyfin test failed:\n{str(e)}"
return False, f"Jellyfin test failed:\n{str(e)}"
def _test_navidrome(self):
"""Test Navidrome connection"""
@ -695,23 +695,23 @@ class ServiceTestThread(QThread):
if subsonic_response.get('status') == 'ok':
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
elif subsonic_response.get('status') == 'failed':
error = subsonic_response.get('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:
return False, "Unexpected response from Navidrome server"
return False, "Unexpected response from Navidrome server"
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:
return False, "Navidrome connection timeout.\nCheck your server URL."
return False, "Navidrome connection timeout.\nCheck your server URL."
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:
return False, f"Navidrome test failed:\n{str(e)}"
return False, f"Navidrome test failed:\n{str(e)}"
def _test_soulseek(self):
"""Test Soulseek connection"""
@ -734,31 +734,31 @@ class ServiceTestThread(QThread):
response = requests.get(f"{slskd_url}/api/v0/session", headers=headers, timeout=5)
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:
return False, ("Invalid API key\n\n"
return False, ("Invalid API key\n\n"
"Please check your slskd API key in the configuration.")
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.")
except requests.exceptions.ConnectionError as e:
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"
"To fix this:\n"
"1. Install slskd from: https://github.com/slskd/slskd\n"
"2. Start slskd service\n"
"3. Ensure it's running on the correct port (default: 5030)")
else:
return False, f"Network error:\n{str(e)}"
return False, f"Network error:\n{str(e)}"
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.")
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:
return False, f"Unexpected error:\n{str(e)}"
return False, f"Unexpected error:\n{str(e)}"
class JellyfinDetectionThread(QThread):
progress_updated = pyqtSignal(int, str) # progress value, current url
@ -1004,7 +1004,7 @@ class NavidromeDetectionThread(QThread):
print(f"Response data: {data}")
# Check if it's a valid Subsonic API response
if 'subsonic-response' in data:
print(f"Found Navidrome server at {url}")
print(f"Found Navidrome server at {url}")
return True
except Exception as e:
print(f"JSON parse error: {e}")
@ -1013,7 +1013,7 @@ class NavidromeDetectionThread(QThread):
try:
root_response = requests.get(url, timeout=timeout)
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
except:
pass
@ -1166,7 +1166,7 @@ class SettingsPage(QWidget):
content_layout.addStretch()
# Save button
self.save_btn = QPushButton("💾 Save Settings")
self.save_btn = QPushButton("Save Settings")
self.save_btn.setFixedHeight(45)
self.save_btn.clicked.connect(self.save_settings)
self.save_btn.setStyleSheet("""
@ -1333,7 +1333,7 @@ class SettingsPage(QWidget):
# Update button text temporarily
original_text = self.save_btn.text()
self.save_btn.setText("Saved!")
self.save_btn.setText("Saved!")
self.save_btn.setStyleSheet("""
QPushButton {
background: #1aa34a;
@ -1397,20 +1397,20 @@ class SettingsPage(QWidget):
# Create client and authenticate
client = TidalClient()
self.tidal_auth_btn.setText("🔐 Authenticating...")
self.tidal_auth_btn.setText("Authenticating...")
self.tidal_auth_btn.setEnabled(False)
if client.authenticate():
QMessageBox.information(self, "Success", "Tidal authentication successful!\nYou can now use Tidal playlists.")
self.tidal_auth_btn.setText("Authenticated")
QMessageBox.information(self, "Success", "Tidal authentication successful!\nYou can now use Tidal playlists.")
self.tidal_auth_btn.setText("Authenticated")
else:
QMessageBox.warning(self, "Authentication Failed", "Tidal authentication failed.\nPlease check your credentials and try again.")
self.tidal_auth_btn.setText("🔐 Authenticate")
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.setEnabled(True)
except Exception as e:
self.tidal_auth_btn.setText("🔐 Authenticate")
self.tidal_auth_btn.setText("Authenticate")
self.tidal_auth_btn.setEnabled(True)
QMessageBox.critical(self, "Error", f"Failed to authenticate with Tidal:\n{str(e)}")
@ -1826,7 +1826,7 @@ class SettingsPage(QWidget):
# Success message
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.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(success_label)
@ -1937,7 +1937,7 @@ class SettingsPage(QWidget):
# Success message
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.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(success_label)
@ -2048,7 +2048,7 @@ class SettingsPage(QWidget):
# Success message
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.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(success_label)
@ -2315,7 +2315,7 @@ class SettingsPage(QWidget):
tidal_layout.addWidget(oauth_url_label)
# Authenticate button
self.tidal_auth_btn = QPushButton("🔐 Authenticate")
self.tidal_auth_btn = QPushButton("Authenticate")
self.tidal_auth_btn.setFixedHeight(30)
self.tidal_auth_btn.setStyleSheet("""
QPushButton {
@ -2378,7 +2378,7 @@ class SettingsPage(QWidget):
server_selection_layout.addLayout(toggle_container)
# 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("""
color: #ffc107;
font-size: 11px;
@ -2764,7 +2764,7 @@ class SettingsPage(QWidget):
database_layout.addWidget(workers_help)
# Metadata Enhancement Settings
metadata_group = SettingsGroup("🎵 Metadata Enhancement")
metadata_group = SettingsGroup("Metadata Enhancement")
metadata_layout = QVBoxLayout(metadata_group)
metadata_layout.setContentsMargins(16, 20, 16, 16)
metadata_layout.setSpacing(12)
@ -2831,13 +2831,13 @@ class SettingsPage(QWidget):
metadata_layout.addWidget(help_text)
# 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.setContentsMargins(16, 20, 16, 16)
playlist_sync_layout.setSpacing(12)
# 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.setStyleSheet("""
QCheckBox {
@ -3367,12 +3367,12 @@ class SettingsPage(QWidget):
# Show success toast
from ui.components.toast_manager import ToastManager
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:
# Show error toast
from ui.components.toast_manager import ToastManager
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):
"""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.setSpacing(10)
volume_icon = QLabel("🔊")
volume_icon = QLabel("")
volume_icon.setStyleSheet("""
QLabel {
color: #b3b3b3;
@ -933,7 +933,7 @@ class MediaPlayer(QWidget):
self.volume_slider.valueChanged.connect(self.on_volume_changed)
# Stop button - more visible Spotify style
self.stop_btn = QPushButton("")
self.stop_btn = QPushButton("")
self.stop_btn.setFixedSize(32, 32)
self.stop_btn.setStyleSheet("""
QPushButton {
@ -1029,7 +1029,7 @@ class MediaPlayer(QWidget):
"""Update play/pause button state"""
self.is_playing = playing
if playing:
self.play_pause_btn.setText("⏸︎")
self.play_pause_btn.setText("")
# Start scrolling animation when playing
if self.track_info.should_scroll and not self.track_info.is_scrolling:
self.track_info.start_scroll_animation()
@ -1207,11 +1207,11 @@ class ModernSidebar(QWidget):
# Navigation buttons
nav_items = [
("dashboard", "Dashboard", "📊"),
("sync", "Sync", "🔄"),
("downloads", "Search", "📥"),
("artists", "Artists", "🎵"),
("settings", "Settings", "⚙️")
("dashboard", "Dashboard", ""),
("sync", "Sync", ""),
("downloads", "Search", ""),
("artists", "Artists", ""),
("settings", "Settings", "")
]
for page_id, title, icon in nav_items:

File diff suppressed because it is too large Load diff