good
This commit is contained in:
parent
4405e33aef
commit
39ca1e369a
3 changed files with 82 additions and 74 deletions
|
|
@ -117,9 +117,10 @@ class MusicMatchingEngine:
|
||||||
artist_score = self.similarity_score(spotify_artist, plex_artist)
|
artist_score = self.similarity_score(spotify_artist, plex_artist)
|
||||||
album_score = self.similarity_score(spotify_album, plex_album)
|
album_score = self.similarity_score(spotify_album, plex_album)
|
||||||
|
|
||||||
|
# CORRECTED: Plex duration is already in milliseconds.
|
||||||
duration_score = self.duration_similarity(
|
duration_score = self.duration_similarity(
|
||||||
spotify_track.duration_ms,
|
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:
|
if title_score >= 0.9 and artist_score >= 0.9 and album_score >= 0.8:
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,22 @@ class PlexTrackInfo:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_plex_track(cls, track: PlexTrack) -> 'PlexTrackInfo':
|
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(
|
return cls(
|
||||||
id=str(track.ratingKey),
|
id=str(track.ratingKey),
|
||||||
title=track.title,
|
title=track.title,
|
||||||
artist=track.artist().title if track.artist() else "Unknown Artist",
|
artist=artist_title,
|
||||||
album=track.album().title if track.album() else "Unknown Album",
|
album=album_title,
|
||||||
duration=track.duration,
|
duration=track.duration,
|
||||||
track_number=track.trackNumber,
|
track_number=track.trackNumber,
|
||||||
year=track.year,
|
year=track.year,
|
||||||
|
|
@ -92,8 +103,8 @@ class PlexClient:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if config.get('token'):
|
if config.get('token'):
|
||||||
# Use shorter timeout (5 seconds) to prevent app freezing
|
# Use a longer timeout (15 seconds) to prevent read timeouts on slow servers
|
||||||
self.server = PlexServer(config['base_url'], config['token'], timeout=5)
|
self.server = PlexServer(config['base_url'], config['token'], timeout=15)
|
||||||
else:
|
else:
|
||||||
logger.error("Plex token not configured")
|
logger.error("Plex token not configured")
|
||||||
return
|
return
|
||||||
|
|
@ -237,22 +248,48 @@ class PlexClient:
|
||||||
logger.error(f"Error searching for track '{title}' by '{artist}': {e}")
|
logger.error(f"Error searching for track '{title}' by '{artist}': {e}")
|
||||||
return None
|
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:
|
if not self.music_library:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = self.music_library.search(query, limit=limit)
|
# Step 1: Search for the artist first. This is generally reliable.
|
||||||
tracks = []
|
artist_results = self.music_library.searchArtists(title=artist, limit=1)
|
||||||
|
|
||||||
for result in results:
|
results = []
|
||||||
if isinstance(result, PlexTrack):
|
if artist_results:
|
||||||
tracks.append(PlexTrackInfo.from_plex_track(result))
|
# 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
|
return tracks
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error searching tracks: {e}")
|
logger.error(f"Error searching Plex tracks for '{title}': {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_library_stats(self) -> Dict[str, int]:
|
def get_library_stats(self) -> Dict[str, int]:
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from dataclasses import dataclass
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from core.soulseek_client import TrackResult
|
from core.soulseek_client import TrackResult
|
||||||
import re
|
import re
|
||||||
|
from core.matching_engine import MusicMatchingEngine
|
||||||
|
|
||||||
def clean_track_name_for_search(track_name):
|
def clean_track_name_for_search(track_name):
|
||||||
"""
|
"""
|
||||||
|
|
@ -59,6 +60,8 @@ class PlaylistTrackAnalysisWorker(QRunnable):
|
||||||
self.plex_client = plex_client
|
self.plex_client = plex_client
|
||||||
self.signals = PlaylistTrackAnalysisWorkerSignals()
|
self.signals = PlaylistTrackAnalysisWorkerSignals()
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
|
# Instantiate the matching engine once per worker for efficiency
|
||||||
|
self.matching_engine = MusicMatchingEngine()
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
"""Cancel the analysis operation"""
|
"""Cancel the analysis operation"""
|
||||||
|
|
@ -95,7 +98,8 @@ class PlaylistTrackAnalysisWorker(QRunnable):
|
||||||
# Check if track exists in Plex
|
# Check if track exists in Plex
|
||||||
try:
|
try:
|
||||||
plex_match, confidence = self._check_track_in_plex(track)
|
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.exists_in_plex = True
|
||||||
result.plex_match = plex_match
|
result.plex_match = plex_match
|
||||||
result.confidence = confidence
|
result.confidence = confidence
|
||||||
|
|
@ -110,78 +114,44 @@ class PlaylistTrackAnalysisWorker(QRunnable):
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not self._cancelled:
|
if not self._cancelled:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
self.signals.analysis_failed.emit(str(e))
|
self.signals.analysis_failed.emit(str(e))
|
||||||
|
|
||||||
def _check_track_in_plex(self, spotify_track):
|
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:
|
try:
|
||||||
# Search Plex for similar tracks
|
# Use the first artist for the primary search query
|
||||||
# Use first artist for search
|
|
||||||
artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
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
|
# Use the cleaned track name to get a broader set of potential matches from Plex
|
||||||
plex_tracks = self.plex_client.search_tracks(search_query, limit=10)
|
search_title = clean_track_name_for_search(spotify_track.name)
|
||||||
|
|
||||||
if not plex_tracks:
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
if not potential_plex_matches:
|
||||||
return None, 0.0
|
return None, 0.0
|
||||||
|
|
||||||
# Find best match using confidence scoring
|
# Use the matching engine to find the best match among the candidates.
|
||||||
best_match = None
|
match_result = self.matching_engine.find_best_match(spotify_track, potential_plex_matches)
|
||||||
best_confidence = 0.0
|
|
||||||
|
|
||||||
for plex_track in plex_tracks:
|
# Return the best Plex track found and its confidence score.
|
||||||
confidence = self._calculate_track_confidence(spotify_track, plex_track)
|
return match_result.plex_track, match_result.confidence
|
||||||
if confidence > best_confidence:
|
|
||||||
best_confidence = confidence
|
|
||||||
best_match = plex_track
|
|
||||||
|
|
||||||
return best_match, best_confidence
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
print(f"Error checking track in Plex: {e}")
|
print(f"Error checking track in Plex: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
return None, 0.0
|
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):
|
class TrackDownloadWorkerSignals(QObject):
|
||||||
"""Signals for track download worker"""
|
"""Signals for track download worker"""
|
||||||
download_started = pyqtSignal(int, int, str) # download_index, track_index, download_id
|
download_started = pyqtSignal(int, int, str) # download_index, track_index, download_id
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue