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:
parent
faba4d5847
commit
71e4df65e3
39 changed files with 4163 additions and 4163 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]}...")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', {})
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}'")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"\n✅ SUCCESS - 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"\n❌ FAILED - 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"\n❌ ERROR: {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")
|
||||
|
|
|
|||
|
|
@ -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;")
|
||||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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"""
|
||||
|
|
|
|||
824
ui/pages/sync.py
824
ui/pages/sync.py
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||
|
|
|
|||
4260
web_server.py
4260
web_server.py
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue