Add album-aware track matching for multi-artist albums
When artist-specific track search fails, falls back to album-aware
matching: finds the album by title (any artist), then checks if the
track exists on it. Fixes daily re-downloads of collaborative albums
filed under a different artist (e.g., "Spiral Staircases" tagged
under "The Alchemist" but scanned from "Larry June's" watchlist).
- check_track_exists: new album parameter, album-aware fallback with
0.8 album title threshold + 0.7 track title threshold
- Watchlist scanner: passes album_data.get('name') to track checks
- Download modal: passes batch_album_context to fallback track search
- Wishlist callers (4 spots): extract and pass track album name
- Backwards compatible: album=None default, no change for callers
without album context (singles, playlists)
This commit is contained in:
parent
4baf5e53d4
commit
7f9755a26e
3 changed files with 85 additions and 25 deletions
|
|
@ -779,7 +779,7 @@ class WatchlistScanner:
|
||||||
if not self._should_include_track(track, album_data, watchlist_artist):
|
if not self._should_include_track(track, album_data, watchlist_artist):
|
||||||
continue # Skip this track based on content type preferences
|
continue # Skip this track based on content type preferences
|
||||||
|
|
||||||
if self.is_track_missing_from_library(track):
|
if self.is_track_missing_from_library(track, album_name=album_data.get('name')):
|
||||||
new_tracks_found += 1
|
new_tracks_found += 1
|
||||||
|
|
||||||
# Add to wishlist
|
# Add to wishlist
|
||||||
|
|
@ -1337,9 +1337,9 @@ class WatchlistScanner:
|
||||||
logger.warning(f"Error checking track content type inclusion: {e}")
|
logger.warning(f"Error checking track content type inclusion: {e}")
|
||||||
return True # Default to including on error
|
return True # Default to including on error
|
||||||
|
|
||||||
def is_track_missing_from_library(self, track) -> bool:
|
def is_track_missing_from_library(self, track, album_name: str = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a track is missing from the local Plex library.
|
Check if a track is missing from the local library.
|
||||||
Uses the same matching logic as the download missing tracks modals.
|
Uses the same matching logic as the download missing tracks modals.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1374,7 +1374,7 @@ class WatchlistScanner:
|
||||||
# Use same database check as modals with server awareness
|
# Use same database check as modals with server awareness
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
active_server = config_manager.get_active_media_server()
|
active_server = config_manager.get_active_media_server()
|
||||||
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server)
|
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:
|
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})")
|
||||||
|
|
|
||||||
|
|
@ -4830,10 +4830,13 @@ class MusicDatabase:
|
||||||
return list(set(variations))
|
return list(set(variations))
|
||||||
|
|
||||||
|
|
||||||
def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8, server_source: str = None) -> Tuple[Optional[DatabaseTrack], float]:
|
def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8, server_source: str = None, album: str = None) -> Tuple[Optional[DatabaseTrack], float]:
|
||||||
"""
|
"""
|
||||||
Check if a track exists in the database with enhanced fuzzy matching and confidence scoring.
|
Check if a track exists in the database with enhanced fuzzy matching and confidence scoring.
|
||||||
Now uses the same sophisticated matching approach as album checking for consistency.
|
|
||||||
|
Args:
|
||||||
|
album: Optional album name — enables album-aware matching for multi-artist albums
|
||||||
|
|
||||||
Returns (track, confidence) tuple where confidence is 0.0-1.0
|
Returns (track, confidence) tuple where confidence is 0.0-1.0
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -4873,9 +4876,57 @@ class MusicDatabase:
|
||||||
if best_match and best_confidence >= confidence_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
|
return best_match, best_confidence
|
||||||
else:
|
|
||||||
logger.debug(f"❌ No confident track match for '{title}' (best: {best_confidence:.3f}, threshold: {confidence_threshold})")
|
# Album-aware fallback: find album by title (any artist), check tracks on it
|
||||||
return None, best_confidence
|
# 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}'")
|
||||||
|
try:
|
||||||
|
album_candidates = self.search_albums(title=album, artist="", limit=10, server_source=server_source)
|
||||||
|
for album_candidate in album_candidates:
|
||||||
|
album_title_sim = max(
|
||||||
|
self._string_similarity(self._normalize_for_comparison(album), self._normalize_for_comparison(album_candidate.title)),
|
||||||
|
self._string_similarity(self._clean_album_title_for_comparison(album), self._clean_album_title_for_comparison(album_candidate.title))
|
||||||
|
)
|
||||||
|
if album_title_sim < 0.8:
|
||||||
|
continue
|
||||||
|
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
source_filter = "AND t.server_source = ?" if server_source else ""
|
||||||
|
params = [album_candidate.id] + ([server_source] if server_source else [])
|
||||||
|
cursor.execute(f"""
|
||||||
|
SELECT t.*, a.name as artist_name, al.title as album_title
|
||||||
|
FROM tracks t
|
||||||
|
JOIN artists a ON a.id = t.artist_id
|
||||||
|
JOIN albums al ON al.id = t.album_id
|
||||||
|
WHERE t.album_id = ? {source_filter}
|
||||||
|
""", params)
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
db_track = DatabaseTrack(
|
||||||
|
id=row['id'], title=row['title'], artist_name=row['artist_name'],
|
||||||
|
album_title=row['album_title'], album_id=row['album_id'],
|
||||||
|
track_number=row['track_number'], duration=row['duration'],
|
||||||
|
file_path=row['file_path'], bitrate=row['bitrate'],
|
||||||
|
artist_id=row['artist_id'], server_source=row['server_source']
|
||||||
|
)
|
||||||
|
title_sim = max(
|
||||||
|
self._string_similarity(self._normalize_for_comparison(title), self._normalize_for_comparison(db_track.title)),
|
||||||
|
self._string_similarity(self._clean_track_title_for_comparison(title), self._clean_track_title_for_comparison(db_track.title))
|
||||||
|
)
|
||||||
|
if title_sim > best_confidence and title_sim >= 0.7:
|
||||||
|
best_confidence = title_sim
|
||||||
|
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})")
|
||||||
|
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})")
|
||||||
|
return None, best_confidence
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking track existence for '{title}' by '{artist}': {e}")
|
logger.error(f"Error checking track existence for '{title}' by '{artist}': {e}")
|
||||||
|
|
|
||||||
|
|
@ -18584,6 +18584,7 @@ def _automatic_wishlist_cleanup_after_db_update():
|
||||||
track_name = track.get('name', '')
|
track_name = track.get('name', '')
|
||||||
artists = track.get('artists', [])
|
artists = track.get('artists', [])
|
||||||
spotify_track_id = track.get('spotify_track_id') or track.get('id')
|
spotify_track_id = track.get('spotify_track_id') or track.get('id')
|
||||||
|
track_album = track.get('album', {}).get('name') if isinstance(track.get('album'), dict) else track.get('album')
|
||||||
|
|
||||||
# Skip if no essential data
|
# Skip if no essential data
|
||||||
if not track_name or not artists or not spotify_track_id:
|
if not track_name or not artists or not spotify_track_id:
|
||||||
|
|
@ -18604,7 +18605,8 @@ def _automatic_wishlist_cleanup_after_db_update():
|
||||||
db_track, confidence = db.check_track_exists(
|
db_track, confidence = db.check_track_exists(
|
||||||
track_name, artist_name,
|
track_name, artist_name,
|
||||||
confidence_threshold=0.7,
|
confidence_threshold=0.7,
|
||||||
server_source=active_server
|
server_source=active_server,
|
||||||
|
album=track_album
|
||||||
)
|
)
|
||||||
|
|
||||||
if db_track and confidence >= 0.7:
|
if db_track and confidence >= 0.7:
|
||||||
|
|
@ -19786,6 +19788,7 @@ def _process_wishlist_automatically(automation_id=None):
|
||||||
track_name = track.get('name', '')
|
track_name = track.get('name', '')
|
||||||
artists = track.get('artists', [])
|
artists = track.get('artists', [])
|
||||||
spotify_track_id = track.get('spotify_track_id') or track.get('id')
|
spotify_track_id = track.get('spotify_track_id') or track.get('id')
|
||||||
|
track_album = track.get('album', {}).get('name') if isinstance(track.get('album'), dict) else track.get('album')
|
||||||
|
|
||||||
if not track_name or not artists or not spotify_track_id:
|
if not track_name or not artists or not spotify_track_id:
|
||||||
continue
|
continue
|
||||||
|
|
@ -19804,7 +19807,8 @@ def _process_wishlist_automatically(automation_id=None):
|
||||||
db_track, confidence = db.check_track_exists(
|
db_track, confidence = db.check_track_exists(
|
||||||
track_name, artist_name,
|
track_name, artist_name,
|
||||||
confidence_threshold=0.7,
|
confidence_threshold=0.7,
|
||||||
server_source=active_server
|
server_source=active_server,
|
||||||
|
album=track_album
|
||||||
)
|
)
|
||||||
|
|
||||||
if db_track and confidence >= 0.7:
|
if db_track and confidence >= 0.7:
|
||||||
|
|
@ -20658,6 +20662,7 @@ def start_wishlist_missing_downloads():
|
||||||
track_name = track.get('name', '')
|
track_name = track.get('name', '')
|
||||||
artists = track.get('artists', [])
|
artists = track.get('artists', [])
|
||||||
spotify_track_id = track.get('spotify_track_id') or track.get('id')
|
spotify_track_id = track.get('spotify_track_id') or track.get('id')
|
||||||
|
track_album = track.get('album', {}).get('name') if isinstance(track.get('album'), dict) else track.get('album')
|
||||||
|
|
||||||
if not track_name or not artists or not spotify_track_id:
|
if not track_name or not artists or not spotify_track_id:
|
||||||
continue
|
continue
|
||||||
|
|
@ -20676,7 +20681,8 @@ def start_wishlist_missing_downloads():
|
||||||
db_track, confidence = db.check_track_exists(
|
db_track, confidence = db.check_track_exists(
|
||||||
track_name, artist_name,
|
track_name, artist_name,
|
||||||
confidence_threshold=0.7,
|
confidence_threshold=0.7,
|
||||||
server_source=active_server
|
server_source=active_server,
|
||||||
|
album=track_album
|
||||||
)
|
)
|
||||||
|
|
||||||
if db_track and confidence >= 0.7:
|
if db_track and confidence >= 0.7:
|
||||||
|
|
@ -20890,6 +20896,7 @@ def cleanup_wishlist():
|
||||||
track_name = track.get('name', '')
|
track_name = track.get('name', '')
|
||||||
artists = track.get('artists', [])
|
artists = track.get('artists', [])
|
||||||
spotify_track_id = track.get('spotify_track_id') or track.get('id')
|
spotify_track_id = track.get('spotify_track_id') or track.get('id')
|
||||||
|
track_album = track.get('album', {}).get('name') if isinstance(track.get('album'), dict) else track.get('album')
|
||||||
|
|
||||||
# Skip if no essential data
|
# Skip if no essential data
|
||||||
if not track_name or not artists or not spotify_track_id:
|
if not track_name or not artists or not spotify_track_id:
|
||||||
|
|
@ -20912,7 +20919,8 @@ def cleanup_wishlist():
|
||||||
db_track, confidence = db.check_track_exists(
|
db_track, confidence = db.check_track_exists(
|
||||||
track_name, artist_name,
|
track_name, artist_name,
|
||||||
confidence_threshold=0.7,
|
confidence_threshold=0.7,
|
||||||
server_source=active_server
|
server_source=active_server,
|
||||||
|
album=track_album
|
||||||
)
|
)
|
||||||
|
|
||||||
if db_track and confidence >= 0.7:
|
if db_track and confidence >= 0.7:
|
||||||
|
|
@ -23324,6 +23332,7 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
|
||||||
found, confidence = True, best_sim
|
found, confidence = True, best_sim
|
||||||
else:
|
else:
|
||||||
# Fall back to global per-track search for this track
|
# Fall back to global per-track search for this track
|
||||||
|
_fallback_album = batch_album_context.get('name') if batch_album_context else None
|
||||||
for artist in artists:
|
for artist in artists:
|
||||||
if isinstance(artist, str):
|
if isinstance(artist, str):
|
||||||
artist_name = artist
|
artist_name = artist
|
||||||
|
|
@ -23332,7 +23341,7 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
|
||||||
else:
|
else:
|
||||||
artist_name = str(artist)
|
artist_name = str(artist)
|
||||||
db_track, track_confidence = db.check_track_exists(
|
db_track, track_confidence = db.check_track_exists(
|
||||||
track_name, artist_name, confidence_threshold=0.7, server_source=active_server
|
track_name, artist_name, confidence_threshold=0.7, server_source=active_server, album=_fallback_album
|
||||||
)
|
)
|
||||||
if db_track and track_confidence >= 0.7:
|
if db_track and track_confidence >= 0.7:
|
||||||
found, confidence = True, track_confidence
|
found, confidence = True, track_confidence
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue