diff --git a/core/matching_engine.py b/core/matching_engine.py index e8f14193..c3bac689 100644 --- a/core/matching_engine.py +++ b/core/matching_engine.py @@ -117,9 +117,10 @@ class MusicMatchingEngine: artist_score = self.similarity_score(spotify_artist, plex_artist) album_score = self.similarity_score(spotify_album, plex_album) + # CORRECTED: Plex duration is already in milliseconds. duration_score = self.duration_similarity( spotify_track.duration_ms, - plex_track.duration * 1000 if plex_track.duration else 0 + plex_track.duration if plex_track.duration else 0 ) if title_score >= 0.9 and artist_score >= 0.9 and album_score >= 0.8: @@ -220,4 +221,4 @@ class MusicMatchingEngine: return f"{main_artist} {clean_title}" -matching_engine = MusicMatchingEngine() \ No newline at end of file +matching_engine = MusicMatchingEngine() diff --git a/core/plex_client.py b/core/plex_client.py index bc68ed8e..c84035db 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -24,11 +24,22 @@ class PlexTrackInfo: @classmethod def from_plex_track(cls, track: PlexTrack) -> 'PlexTrackInfo': + # Gracefully handle tracks that might be missing artist or album metadata in Plex + try: + artist_title = track.artist().title if track.artist() else "Unknown Artist" + except (NotFound, AttributeError): + artist_title = "Unknown Artist" + + try: + album_title = track.album().title if track.album() else "Unknown Album" + except (NotFound, AttributeError): + album_title = "Unknown Album" + return cls( id=str(track.ratingKey), title=track.title, - artist=track.artist().title if track.artist() else "Unknown Artist", - album=track.album().title if track.album() else "Unknown Album", + artist=artist_title, + album=album_title, duration=track.duration, track_number=track.trackNumber, year=track.year, @@ -92,8 +103,8 @@ class PlexClient: try: if config.get('token'): - # Use shorter timeout (5 seconds) to prevent app freezing - self.server = PlexServer(config['base_url'], config['token'], timeout=5) + # Use a longer timeout (15 seconds) to prevent read timeouts on slow servers + self.server = PlexServer(config['base_url'], config['token'], timeout=15) else: logger.error("Plex token not configured") return @@ -237,24 +248,50 @@ class PlexClient: logger.error(f"Error searching for track '{title}' by '{artist}': {e}") return None - def search_tracks(self, query: str, limit: int = 20) -> List[PlexTrackInfo]: + def search_tracks(self, title: str, artist: str, limit: int = 10) -> List[PlexTrackInfo]: + """ + Searches for tracks in the Plex music library using a more robust, two-step method + that is more compatible with different Plex server versions. + """ if not self.music_library: return [] - + try: - results = self.music_library.search(query, limit=limit) - tracks = [] + # Step 1: Search for the artist first. This is generally reliable. + artist_results = self.music_library.searchArtists(title=artist, limit=1) - for result in results: - if isinstance(result, PlexTrack): - tracks.append(PlexTrackInfo.from_plex_track(result)) + results = [] + if artist_results: + # If artist is found, get all their tracks and filter by title in Python. + # This avoids the API filter issue entirely. + plex_artist = artist_results[0] + all_artist_tracks = plex_artist.tracks() + + # Use a case-insensitive substring match to find potential tracks. + # The matching engine will do the final, more precise comparison. + lower_title = title.lower() + for track in all_artist_tracks: + if lower_title in track.title.lower(): + results.append(track) + else: + # Fallback: If the artist wasn't found, search for the track title + # across the entire library. This is less precise but better than nothing. + logger.debug(f"Artist '{artist}' not found. Falling back to title search for '{title}'.") + results = self.music_library.searchTracks(title=title, limit=limit) + + tracks = [] + for result in results[:limit]: # Apply limit after filtering + tracks.append(PlexTrackInfo.from_plex_track(result)) + + if tracks: + logger.debug(f"Plex search for '{title}' by '{artist}' found {len(tracks)} potential matches.") return tracks except Exception as e: - logger.error(f"Error searching tracks: {e}") + logger.error(f"Error searching Plex tracks for '{title}': {e}") return [] - + def get_library_stats(self) -> Dict[str, int]: if not self.music_library: return {} @@ -295,4 +332,4 @@ class PlexClient: except Exception as e: logger.error(f"Error updating track metadata: {e}") - return False \ No newline at end of file + return False diff --git a/ui/pages/sync.py b/ui/pages/sync.py index 2f0255b4..56172ff7 100644 --- a/ui/pages/sync.py +++ b/ui/pages/sync.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from typing import List, Optional from core.soulseek_client import TrackResult import re +from core.matching_engine import MusicMatchingEngine def clean_track_name_for_search(track_name): """ @@ -59,6 +60,8 @@ class PlaylistTrackAnalysisWorker(QRunnable): self.plex_client = plex_client self.signals = PlaylistTrackAnalysisWorkerSignals() self._cancelled = False + # Instantiate the matching engine once per worker for efficiency + self.matching_engine = MusicMatchingEngine() def cancel(self): """Cancel the analysis operation""" @@ -95,7 +98,8 @@ class PlaylistTrackAnalysisWorker(QRunnable): # Check if track exists in Plex try: plex_match, confidence = self._check_track_in_plex(track) - if plex_match and confidence >= 0.8: # High confidence threshold + # Use the 0.8 confidence threshold + if plex_match and confidence >= 0.8: result.exists_in_plex = True result.plex_match = plex_match result.confidence = confidence @@ -110,77 +114,43 @@ class PlaylistTrackAnalysisWorker(QRunnable): except Exception as e: if not self._cancelled: + import traceback + traceback.print_exc() self.signals.analysis_failed.emit(str(e)) def _check_track_in_plex(self, spotify_track): - """Check if a Spotify track exists in Plex with confidence scoring""" + """ + Check if a Spotify track exists in Plex by searching Plex and using the + MusicMatchingEngine to find the best match. + """ try: - # Search Plex for similar tracks - # Use first artist for search + # Use the first artist for the primary search query artist_name = spotify_track.artists[0] if spotify_track.artists else "" - search_query = f"{artist_name} {spotify_track.name}".strip() - # Get potential matches from Plex - plex_tracks = self.plex_client.search_tracks(search_query, limit=10) + # Use the cleaned track name to get a broader set of potential matches from Plex + search_title = clean_track_name_for_search(spotify_track.name) - if not plex_tracks: - return None, 0.0 + # Call the updated search_tracks with separate artist and title + potential_plex_matches = self.plex_client.search_tracks( + title=search_title, + artist=artist_name, + limit=10 + ) - # Find best match using confidence scoring - best_match = None - best_confidence = 0.0 + if not potential_plex_matches: + return None, 0.0 - for plex_track in plex_tracks: - confidence = self._calculate_track_confidence(spotify_track, plex_track) - if confidence > best_confidence: - best_confidence = confidence - best_match = plex_track + # Use the matching engine to find the best match among the candidates. + match_result = self.matching_engine.find_best_match(spotify_track, potential_plex_matches) - return best_match, best_confidence + # Return the best Plex track found and its confidence score. + return match_result.plex_track, match_result.confidence except Exception as e: + import traceback print(f"Error checking track in Plex: {e}") + traceback.print_exc() return None, 0.0 - - def _calculate_track_confidence(self, spotify_track, plex_track): - """Calculate confidence score between Spotify and Plex tracks""" - try: - # Basic string similarity for now (can be enhanced with existing matching engine) - import re - - def normalize_string(s): - return re.sub(r'[^a-zA-Z0-9\s]', '', s.lower()).strip() - - # Normalize track titles - spotify_title = normalize_string(spotify_track.name) - plex_title = normalize_string(plex_track.title) - - # Normalize artist names - spotify_artist = normalize_string(spotify_track.artists[0]) if spotify_track.artists else "" - plex_artist = normalize_string(plex_track.artist) - - # Simple similarity scoring - title_similarity = 1.0 if spotify_title == plex_title else 0.0 - artist_similarity = 1.0 if spotify_artist == plex_artist else 0.0 - - # Weight title more heavily - confidence = (title_similarity * 0.7) + (artist_similarity * 0.3) - - # Duration check (allow 10% variance) - if hasattr(spotify_track, 'duration_ms') and hasattr(plex_track, 'duration'): - spotify_duration = spotify_track.duration_ms / 1000 - plex_duration = plex_track.duration / 1000 if plex_track.duration else 0 - - if plex_duration > 0: - duration_diff = abs(spotify_duration - plex_duration) / max(spotify_duration, plex_duration) - if duration_diff <= 0.1: # Within 10% - confidence += 0.1 # Bonus for duration match - - return min(confidence, 1.0) # Cap at 1.0 - - except Exception as e: - print(f"Error calculating track confidence: {e}") - return 0.0 class TrackDownloadWorkerSignals(QObject): """Signals for track download worker"""