import asyncio from typing import List, Dict, Any, Optional, Tuple from dataclasses import dataclass from datetime import datetime from utils.logging_config import get_logger from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack from core.plex_client import PlexClient, PlexTrackInfo from core.jellyfin_client import JellyfinClient from core.navidrome_client import NavidromeClient from core.soulseek_client import SoulseekClient from core.matching_engine import MusicMatchingEngine, MatchResult logger = get_logger("sync_service") @dataclass class SyncResult: playlist_name: str total_tracks: int matched_tracks: int synced_tracks: int downloaded_tracks: int failed_tracks: int sync_time: datetime errors: List[str] wishlist_added_count: int = 0 @property def success_rate(self) -> float: if self.total_tracks == 0: return 0.0 return (self.synced_tracks / self.total_tracks) * 100 @dataclass class SyncProgress: current_step: str current_track: str progress: float total_steps: int current_step_number: int # Add detailed track stats for UI updates total_tracks: int = 0 matched_tracks: int = 0 failed_tracks: int = 0 class PlaylistSyncService: def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient, jellyfin_client: JellyfinClient = None, navidrome_client = None): self.spotify_client = spotify_client self.plex_client = plex_client self.jellyfin_client = jellyfin_client self.navidrome_client = navidrome_client self.soulseek_client = soulseek_client self.progress_callbacks = {} # Playlist-specific progress callbacks self.syncing_playlists = set() # Track multiple syncing playlists self._cancelled = False self.matching_engine = MusicMatchingEngine() def _get_active_media_client(self): """Get the active media client based on config settings""" try: from config.settings import config_manager active_server = config_manager.get_active_media_server() if active_server == "jellyfin": if not self.jellyfin_client: logger.error("Jellyfin client not provided to sync service") return None, "jellyfin" return self.jellyfin_client, "jellyfin" elif active_server == "navidrome": if not self.navidrome_client: logger.error("Navidrome client not provided to sync service") return None, "navidrome" return self.navidrome_client, "navidrome" else: # Default to Plex return self.plex_client, "plex" except Exception as e: logger.error(f"Error determining active media server: {e}") return self.plex_client, "plex" # Fallback to Plex @property def is_syncing(self): """Check if any playlist is currently syncing""" return len(self.syncing_playlists) > 0 def set_progress_callback(self, callback, playlist_name=None): """Set progress callback for specific playlist or global if no playlist specified""" if playlist_name: self.progress_callbacks[playlist_name] = callback else: # Legacy support - set for all current syncing playlists for playlist in self.syncing_playlists: self.progress_callbacks[playlist] = callback def clear_progress_callback(self, playlist_name): """Clear progress callback for specific playlist""" if playlist_name in self.progress_callbacks: del self.progress_callbacks[playlist_name] def cancel_sync(self): """Cancel the current sync operation""" logger.info("PlaylistSyncService.cancel_sync() called - setting cancellation flag") self._cancelled = True self.is_syncing = False def _update_progress(self, playlist_name: str, step: str, track: str, progress: float, total_steps: int, current_step: int, total_tracks: int = 0, matched_tracks: int = 0, failed_tracks: int = 0): # Send progress update to the specific playlist's callback callback = self.progress_callbacks.get(playlist_name) if callback: callback(SyncProgress( current_step=step, current_track=track, progress=progress, total_steps=total_steps, current_step_number=current_step, total_tracks=total_tracks, matched_tracks=matched_tracks, failed_tracks=failed_tracks )) async def sync_playlist(self, playlist: SpotifyPlaylist, download_missing: bool = False) -> SyncResult: # Check if THIS specific playlist is already syncing if playlist.name in self.syncing_playlists: logger.warning(f"Sync already in progress for playlist: {playlist.name}") return SyncResult( playlist_name=playlist.name, total_tracks=0, matched_tracks=0, synced_tracks=0, downloaded_tracks=0, failed_tracks=0, sync_time=datetime.now(), errors=[f"Sync already in progress for playlist: {playlist.name}"] ) # Add this playlist to syncing set self.syncing_playlists.add(playlist.name) self._cancelled = False errors = [] try: logger.info(f"Starting sync for playlist: {playlist.name}") if self._cancelled: return self._create_error_result(playlist.name, ["Sync cancelled"]) # Skip fetching playlist since we already have it self._update_progress(playlist.name, "Preparing playlist sync", "", 10, 5, 1) if not playlist.tracks: errors.append(f"Playlist '{playlist.name}' has no tracks") return self._create_error_result(playlist.name, errors) if self._cancelled: return self._create_error_result(playlist.name, ["Sync cancelled"]) total_tracks = len(playlist.tracks) media_client, server_type = self._get_active_media_client() media_client, server_type = self._get_active_media_client() self._update_progress(playlist.name, f"Matching tracks against {server_type.title()} library", "", 20, 5, 2, total_tracks=total_tracks) # Use the same robust matching approach as "Download Missing Tracks" match_results = [] for i, track in enumerate(playlist.tracks): if self._cancelled: return self._create_error_result(playlist.name, ["Sync cancelled"]) # Update progress for each track progress_percent = 20 + (40 * (i + 1) / total_tracks) # 20-60% for matching # Extract artist name from both string and dict formats if track.artists: first_artist = track.artists[0] artist_name = first_artist if isinstance(first_artist, str) else (first_artist.get('name', 'Unknown') if isinstance(first_artist, dict) else str(first_artist)) current_track_name = f"{artist_name} - {track.name}" else: current_track_name = track.name self._update_progress(playlist.name, "Matching tracks", current_track_name, progress_percent, 5, 2, total_tracks=total_tracks, matched_tracks=len([r for r in match_results if r.is_match]), failed_tracks=len([r for r in match_results if not r.is_match])) # Use the robust search approach plex_match, confidence = await self._find_track_in_media_server(track) match_result = MatchResult( spotify_track=track, plex_track=plex_match, confidence=confidence, match_type="robust_search" if plex_match else "no_match" ) match_results.append(match_result) matched_tracks = [r for r in match_results if r.is_match] unmatched_tracks = [r for r in match_results if not r.is_match] logger.info(f"Found {len(matched_tracks)} matches out of {len(playlist.tracks)} tracks") if self._cancelled: return self._create_error_result(playlist.name, ["Sync cancelled"]) # Update progress with match results self._update_progress(playlist.name, "Matching completed", "", 60, 5, 3, total_tracks=total_tracks, matched_tracks=len(matched_tracks), failed_tracks=len(unmatched_tracks)) downloaded_tracks = 0 if download_missing and unmatched_tracks: if self._cancelled: return self._create_error_result(playlist.name, ["Sync cancelled"]) self._update_progress(playlist.name, "Downloading missing tracks", "", 70, 5, 4, total_tracks=total_tracks, matched_tracks=len(matched_tracks), failed_tracks=len(unmatched_tracks)) downloaded_tracks = await self._download_missing_tracks(unmatched_tracks) if self._cancelled: return self._create_error_result(playlist.name, ["Sync cancelled"]) media_client, server_type = self._get_active_media_client() self._update_progress(playlist.name, f"Creating/updating {server_type.title()} playlist", "", 80, 5, 4, total_tracks=total_tracks, matched_tracks=len(matched_tracks), failed_tracks=len(unmatched_tracks)) # Get the actual media server track objects media_tracks = [r.plex_track for r in matched_tracks if r.plex_track] # plex_track is a generic name here logger.info(f"Creating playlist with {len(media_tracks)} matched tracks") # Validate that all tracks have proper ratingKey attributes for playlist creation valid_tracks = [] 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})") 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.info(f"Playlist validation: {len(valid_tracks)}/{len(media_tracks)} tracks are valid {server_type.title()} objects with ratingKeys") # Use the validated tracks for the sync plex_tracks = valid_tracks # Keep variable name for compatibility with the rest of the function # Use active media server for playlist sync media_client, server_type = self._get_active_media_client() if not media_client: logger.error(f"No active media client available for playlist sync") sync_success = False else: logger.info(f"Syncing playlist '{playlist.name}' to {server_type.upper()} server") # THE FIX: Ensure we are passing the correct, native track objects to the client sync_success = media_client.update_playlist(playlist.name, valid_tracks) synced_tracks = len(plex_tracks) if sync_success else 0 failed_tracks = len(playlist.tracks) - synced_tracks - downloaded_tracks self._update_progress(playlist.name, "Sync completed", "", 100, 5, 5, total_tracks=total_tracks, matched_tracks=len(matched_tracks), failed_tracks=failed_tracks) # Auto-add unmatched tracks to wishlist wishlist_added_count = 0 if unmatched_tracks: try: from core.wishlist_service import get_wishlist_service wishlist_service = get_wishlist_service() logger.info(f"Auto-adding {len(unmatched_tracks)} unmatched tracks to wishlist") for match_result in unmatched_tracks: spotify_track = match_result.spotify_track # Check if we have original track data with full album objects original_track_data = None if hasattr(self, '_original_tracks_map') and self._original_tracks_map: original_track_data = self._original_tracks_map.get(spotify_track.id) # Use original data if available (preserves album images), otherwise convert if original_track_data: spotify_track_data = original_track_data else: spotify_track_data = { 'id': spotify_track.id, 'name': spotify_track.name, 'artists': [{'name': a} if isinstance(a, str) else a for a in spotify_track.artists], 'album': {'name': spotify_track.album}, 'duration_ms': spotify_track.duration_ms, 'popularity': getattr(spotify_track, 'popularity', 0), 'preview_url': getattr(spotify_track, 'preview_url', None), 'external_urls': getattr(spotify_track, 'external_urls', {}) } # Add to wishlist with source context success = wishlist_service.add_spotify_track_to_wishlist( spotify_track_data=spotify_track_data, failure_reason='Missing from media server after sync', source_type='playlist', source_context={ 'playlist_name': playlist.name, 'playlist_id': playlist.id, 'sync_type': 'automatic_sync', 'timestamp': datetime.now().isoformat() } ) if success: wishlist_added_count += 1 logger.info(f"Successfully added {wishlist_added_count}/{len(unmatched_tracks)} tracks to wishlist") except Exception as e: logger.warning(f"Failed to auto-add tracks to wishlist: {e}") # Don't fail the sync if wishlist add fails result = SyncResult( playlist_name=playlist.name, total_tracks=len(playlist.tracks), matched_tracks=len(matched_tracks), synced_tracks=synced_tracks, downloaded_tracks=downloaded_tracks, failed_tracks=failed_tracks, sync_time=datetime.now(), errors=errors, wishlist_added_count=wishlist_added_count ) logger.info(f"Sync completed: {result.success_rate:.1f}% success rate") return result except Exception as e: logger.error(f"Error during sync: {e}") errors.append(str(e)) return self._create_error_result(playlist.name, errors) finally: # Remove this playlist from syncing set and clear its callback self.syncing_playlists.discard(playlist.name) self.clear_progress_callback(playlist.name) self._cancelled = False async def _find_track_in_media_server(self, spotify_track: SpotifyTrack) -> Tuple[Optional[PlexTrackInfo], float]: """Find a track using the same improved database matching as Download Missing Tracks modal""" try: # Check active media server connection media_client, server_type = self._get_active_media_client() if not media_client or not media_client.is_connected(): logger.warning(f"{server_type.upper()} client not connected") return None, 0.0 # Use the SAME improved database matching as PlaylistTrackAnalysisWorker from database.music_database import MusicDatabase original_title = spotify_track.name # Try each artist (same as modal logic) for artist in spotify_track.artists: if self._cancelled: return None, 0.0 # Extract artist name from both string and dict formats if isinstance(artist, str): artist_name = artist elif isinstance(artist, dict) and 'name' in artist: artist_name = artist['name'] else: artist_name = str(artist) # Use the improved database check_track_exists method with server awareness try: from config.settings import config_manager active_server = config_manager.get_active_media_server() db = MusicDatabase() 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}") # Fetch the actual track object from active media server using the database track ID try: if server_type == "jellyfin": # For Jellyfin, create a track object from database info (Jellyfin doesn't have fetchItem) class JellyfinTrackFromDB: def __init__(self, db_track): self.ratingKey = db_track.id self.title = db_track.title 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})") return actual_track, confidence elif server_type == "navidrome": # For Navidrome, create a track object from database info (similar to Jellyfin) class NavidromeTrackFromDB: def __init__(self, db_track): self.ratingKey = db_track.id self.title = db_track.title 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})") return actual_track, confidence else: # For Plex, use the original fetchItem approach # Validate that the track ID is numeric (Plex requirement) try: 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})") return actual_plex_track, confidence else: 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") 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}") # Continue to try other artists rather than fail completely continue except Exception as db_error: 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}") return None, 0.0 except Exception as e: logger.error(f"Error searching for track '{spotify_track.name}': {e}") return None, 0.0 async def sync_multiple_playlists(self, playlist_names: List[str], download_missing: bool = False) -> List[SyncResult]: results = [] for i, playlist_name in enumerate(playlist_names): logger.info(f"Syncing playlist {i+1}/{len(playlist_names)}: {playlist_name}") result = await self.sync_playlist(playlist_name, download_missing) results.append(result) if i < len(playlist_names) - 1: await asyncio.sleep(1) return results def _get_spotify_playlist(self, playlist_name: str) -> Optional[SpotifyPlaylist]: try: playlists = self.spotify_client.get_user_playlists() for playlist in playlists: if playlist.name.lower() == playlist_name.lower(): return playlist return None except Exception as e: logger.error(f"Error fetching Spotify playlist: {e}") return None async def _get_media_tracks(self) -> List: """Get tracks from the active media server""" try: media_client, server_type = self._get_active_media_client() if not media_client: logger.error(f"No active media client available") return [] if hasattr(media_client, 'search_tracks'): return media_client.search_tracks("", limit=10000) else: logger.warning(f"{server_type.title()} client doesn't support track search") return [] except Exception as e: logger.error(f"Error fetching {server_type} tracks: {e}") return [] async def _download_missing_tracks(self, unmatched_tracks: List[MatchResult]) -> int: downloaded_count = 0 for match_result in unmatched_tracks: try: query = self.matching_engine.generate_download_query(match_result.spotify_track) logger.info(f"Attempting to download: {query}") download_id = await self.soulseek_client.search_and_download_best(query) if download_id: downloaded_count += 1 logger.info(f"Download started for: {match_result.spotify_track.name}") else: logger.warning(f"No download sources found for: {match_result.spotify_track.name}") await asyncio.sleep(1) except Exception as e: logger.error(f"Error downloading track: {e}") return downloaded_count def _create_error_result(self, playlist_name: str, errors: List[str]) -> SyncResult: return SyncResult( playlist_name=playlist_name, total_tracks=0, matched_tracks=0, synced_tracks=0, downloaded_tracks=0, failed_tracks=0, sync_time=datetime.now(), errors=errors, wishlist_added_count=0 ) def get_sync_preview(self, playlist_name: str) -> Dict[str, Any]: try: spotify_playlist = self._get_spotify_playlist(playlist_name) if not spotify_playlist: return {"error": f"Playlist '{playlist_name}' not found"} media_client, server_type = self._get_active_media_client() if not media_client or not hasattr(media_client, 'search_tracks'): return {"error": f"Active media server ({server_type}) doesn't support track search"} media_tracks = media_client.search_tracks("", limit=1000) match_results = self.matching_engine.match_playlist_tracks( spotify_playlist.tracks, media_tracks ) stats = self.matching_engine.get_match_statistics(match_results) preview = { "playlist_name": playlist_name, "total_tracks": len(spotify_playlist.tracks), f"available_in_{server_type}": stats["matched_tracks"], "needs_download": stats["total_tracks"] - stats["matched_tracks"], "match_percentage": stats["match_percentage"], "confidence_breakdown": stats["confidence_distribution"], "tracks_preview": [] } for result in match_results[:10]: track_info = { "spotify_track": f"{result.spotify_track.name} - {result.spotify_track.artists[0]}", f"{server_type}_match": getattr(result, 'plex_track', None).title if getattr(result, 'plex_track', None) else None, "confidence": result.confidence, "status": "available" if result.is_match else "needs_download" } preview["tracks_preview"].append(track_info) return preview except Exception as e: logger.error(f"Error generating sync preview: {e}") return {"error": str(e)} def get_library_comparison(self) -> Dict[str, Any]: try: spotify_playlists = self.spotify_client.get_user_playlists() spotify_track_count = sum(len(p.tracks) for p in spotify_playlists) media_client, server_type = self._get_active_media_client() if not media_client: return {"error": f"No active media client available"} media_playlists = media_client.get_all_playlists() if hasattr(media_client, 'get_all_playlists') else [] media_stats = media_client.get_library_stats() if hasattr(media_client, 'get_library_stats') else {} comparison = { "spotify": { "playlists": len(spotify_playlists), "total_tracks": spotify_track_count }, server_type: { "playlists": len(media_playlists), "artists": media_stats.get("artists", 0), "albums": media_stats.get("albums", 0), "tracks": media_stats.get("tracks", 0) }, "sync_potential": { "estimated_matches": min(spotify_track_count, media_stats.get("tracks", 0)), "potential_downloads": max(0, spotify_track_count - media_stats.get("tracks", 0)) } } return comparison except Exception as e: logger.error(f"Error generating library comparison: {e}") return {"error": str(e)}