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.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] @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): self.spotify_client = spotify_client self.plex_client = plex_client self.soulseek_client = soulseek_client self.progress_callback = None self.is_syncing = False self._cancelled = False self.matching_engine = MusicMatchingEngine() def set_progress_callback(self, callback): self.progress_callback = callback 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, step: str, track: str, progress: float, total_steps: int, current_step: int, total_tracks: int = 0, matched_tracks: int = 0, failed_tracks: int = 0): if self.progress_callback: self.progress_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: if self.is_syncing: logger.warning("Sync already in progress") 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=["Sync already in progress"] ) self.is_syncing = True 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("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) self._update_progress("Matching tracks against Plex 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 current_track_name = f"{track.artists[0]} - {track.name}" if track.artists else track.name self._update_progress("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_plex(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("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("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"]) self._update_progress("Creating/updating Plex playlist", "", 80, 5, 4, total_tracks=total_tracks, matched_tracks=len(matched_tracks), failed_tracks=len(unmatched_tracks)) # Get the actual Plex track objects (not PlexTrackInfo) plex_tracks = [r.plex_track for r in matched_tracks if r.plex_track] logger.info(f"Creating playlist with {len(plex_tracks)} matched tracks") sync_success = self.plex_client.update_playlist(playlist.name, plex_tracks) synced_tracks = len(plex_tracks) if sync_success else 0 failed_tracks = len(playlist.tracks) - synced_tracks - downloaded_tracks self._update_progress("Sync completed", "", 100, 5, 5, total_tracks=total_tracks, matched_tracks=len(matched_tracks), failed_tracks=failed_tracks) 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 ) 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: self.is_syncing = False self._cancelled = False async def _find_track_in_plex(self, spotify_track: SpotifyTrack) -> Tuple[Optional[PlexTrackInfo], float]: """Find a track in Plex using the same robust search approach as Download Missing Tracks""" try: if not self.plex_client or not self.plex_client.is_connected(): logger.warning("Plex client not connected") return None, 0.0 # Use same robust search logic as PlaylistTrackAnalysisWorker original_title = spotify_track.name # Create title variations unique_title_variations = [] original_clean = self.matching_engine.get_core_string(original_title) unique_title_variations.append(original_clean) # Add cleaned version cleaned_version = self.matching_engine.clean_title(original_title) if cleaned_version != original_clean: unique_title_variations.append(cleaned_version) all_potential_matches = [] found_match_ids = set() # Search by artist + title combinations for artist in spotify_track.artists[:2]: # Limit to first 2 artists if self._cancelled: return None, 0.0 artist_name = self.matching_engine.clean_artist(artist) for query_title in unique_title_variations: if self._cancelled: logger.debug(f"Sync cancelled during track search for '{original_title}'") return None, 0.0 potential_plex_matches = self.plex_client.search_tracks( title=query_title, artist=artist_name, limit=15 ) # Check cancellation after each search operation if self._cancelled: logger.debug(f"Sync cancelled after search for '{original_title}'") return None, 0.0 for track in potential_plex_matches: if track.id not in found_match_ids: all_potential_matches.append(track) found_match_ids.add(track.id) # Early exit check for confident match if all_potential_matches: match_result = self.matching_engine.find_best_match(spotify_track, all_potential_matches) if match_result.is_match: logger.debug(f"Early confident match found for '{original_title}'") return match_result.plex_track, match_result.confidence # Fallback: Title-only search if not all_potential_matches: if self._cancelled: logger.debug(f"Sync cancelled before title-only search for '{original_title}'") return None, 0.0 logger.debug(f"No artist-based matches found. Using title-only fallback for '{original_title}'") for query_title in unique_title_variations: if self._cancelled: logger.debug(f"Sync cancelled during title-only search for '{original_title}'") return None, 0.0 title_only_matches = self.plex_client.search_tracks(title=query_title, artist="", limit=10) if self._cancelled: logger.debug(f"Sync cancelled after title-only search for '{original_title}'") return None, 0.0 for track in title_only_matches: if track.id not in found_match_ids: all_potential_matches.append(track) found_match_ids.add(track.id) if not all_potential_matches: logger.debug(f"No Plex candidates found for '{original_title}'") return None, 0.0 # Final scoring final_match_result = self.matching_engine.find_best_match(spotify_track, all_potential_matches) if final_match_result.is_match: logger.debug(f"Match found for '{original_title}': '{final_match_result.plex_track.title}' (confidence: {final_match_result.confidence:.2f})") else: logger.debug(f"No confident match for '{original_title}' (best score: {final_match_result.confidence:.2f})") return final_match_result.plex_track, final_match_result.confidence 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_plex_tracks(self) -> List[PlexTrackInfo]: try: return self.plex_client.search_tracks("", limit=10000) except Exception as e: logger.error(f"Error fetching Plex 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 ) 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"} plex_tracks = self.plex_client.search_tracks("", limit=1000) match_results = self.matching_engine.match_playlist_tracks( spotify_playlist.tracks, plex_tracks ) stats = self.matching_engine.get_match_statistics(match_results) preview = { "playlist_name": playlist_name, "total_tracks": len(spotify_playlist.tracks), "available_in_plex": 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]}", "plex_match": result.plex_track.title if result.plex_track 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() plex_playlists = self.plex_client.get_all_playlists() plex_stats = self.plex_client.get_library_stats() spotify_track_count = sum(len(p.tracks) for p in spotify_playlists) comparison = { "spotify": { "playlists": len(spotify_playlists), "total_tracks": spotify_track_count }, "plex": { "playlists": len(plex_playlists), "artists": plex_stats.get("artists", 0), "albums": plex_stats.get("albums", 0), "tracks": plex_stats.get("tracks", 0) }, "sync_potential": { "estimated_matches": min(spotify_track_count, plex_stats.get("tracks", 0)), "potential_downloads": max(0, spotify_track_count - plex_stats.get("tracks", 0)) } } return comparison except Exception as e: logger.error(f"Error generating library comparison: {e}") return {"error": str(e)}