Add artist gate and fix substring matching in matching engine

Prevents downloading tracks from completely wrong artists by adding
minimum artist score gates:
- Soulseek: artist_score < 0.25 → reject (catches Belvedere vs Periphery)
- YouTube: artist_score < 0.15 → reject (catches lizzylou06 vs Muse)

Fixes artist substring matching to use word boundaries instead of plain
containment — "muse" no longer matches "museum", "art" no longer matches
"heart". This was causing false positives where wrong artists passed with
artist_score=1.0 due to accidental substring containment.

Improves similarity fallback by comparing against individual path segments
instead of the full filename, so misspelled artist names (Radiohedd vs
Radiohead) still match correctly.

Adjusts YouTube weights from Title 70%/Artist 10% to Title 60%/Artist 20%
to give artist more influence in YouTube matching.

Addresses user reports of unreleased albums being downloaded with garbage
content from wrong artists on Soulseek and YouTube.
This commit is contained in:
Broque Thomas 2026-04-10 13:45:07 -07:00
parent a6117d5174
commit 603d66ba5d

View file

@ -602,23 +602,42 @@ class MusicMatchingEngine:
# No word boundary match - rely on similarity ratio only # No word boundary match - rely on similarity ratio only
title_score = title_ratio title_score = title_ratio
# 2. Artist Score: Keep substring matching for artists (they're more unique) # 2. Artist Score: Word-boundary matching for artists to prevent false positives
# But add similarity-based fallback for better matching # like "muse" matching "museum" or "art" matching "heart".
# Falls back to similarity matching for misspellings/variations.
artist_score = 0.0 artist_score = 0.0
best_artist_similarity = 0.0 best_artist_similarity = 0.0
# Split original filename into segments for per-segment matching.
# Handles path separators (/, \) and YouTube's || delimiter.
_artist_segments = re.split(r'[/\\|]+', slskd_track.filename)
_artist_segments_norm = [self.normalize_string(s) for s in _artist_segments if s.strip()]
for artist in spotify_artists_norm: for artist in spotify_artists_norm:
# Skip containment for very short names (≤2 chars) — "b" matches everything if not artist:
if artist and len(artist) > 2 and artist in slskd_filename_norm: continue
artist_score = 1.0 # Perfect match if any artist is found # Word boundary match against each segment — "muse" matches "muse" but not "museum"
break found_boundary = False
elif artist and len(artist) <= 2 and re.search(r'\b' + re.escape(artist) + r'\b', slskd_filename_norm): for seg_norm in _artist_segments_norm:
if re.search(r'\b' + re.escape(artist) + r'\b', seg_norm):
found_boundary = True
break
# Also check full normalized string (handles flat filenames without separators)
if not found_boundary and re.search(r'\b' + re.escape(artist) + r'\b', slskd_filename_norm):
found_boundary = True
if found_boundary:
artist_score = 1.0 artist_score = 1.0
break break
else: else:
# Try similarity matching as fallback for misspellings/variations # Try similarity matching per path segment for misspellings/variations.
artist_ratio = SequenceMatcher(None, artist, slskd_filename_norm).ratio() # Comparing against the full filename dilutes the score because the artist
best_artist_similarity = max(best_artist_similarity, artist_ratio) # name is a small fraction of "artist/album/track.flac".
for seg_norm in _artist_segments_norm:
if not seg_norm:
continue
seg_ratio = SequenceMatcher(None, artist, seg_norm).ratio()
best_artist_similarity = max(best_artist_similarity, seg_ratio)
# If no exact artist match, use best similarity with penalty # If no exact artist match, use best similarity with penalty
if artist_score == 0.0 and best_artist_similarity > 0: if artist_score == 0.0 and best_artist_similarity > 0:
@ -672,13 +691,35 @@ class MusicMatchingEngine:
) )
return 0.0 return 0.0
# --- Minimum Artist Gate ---
# Reject matches where the artist has no resemblance to the target.
# Without this, a perfect title match + good duration can push a completely
# wrong artist past the confidence threshold (e.g. "Hexagons" by lizzylou06
# when searching for "Hexagons" by Muse, or "Subhuman Nature" by Belvedere
# when searching for "Subhuman" by Periphery).
if not is_youtube and artist_score < 0.25:
logger.debug(
f"Artist gate reject: '{spotify_track.name}' by {spotify_track.artists} "
f"vs '{slskd_track.filename[:60]}' (artist_score={artist_score:.2f} < 0.25)"
)
return 0.0
# Softer artist gate for YouTube — artist extraction from video titles is
# unreliable, but completely wrong uploaders should still be caught.
if is_youtube and artist_score < 0.15:
logger.debug(
f"YouTube artist gate reject: '{spotify_track.name}' by {spotify_track.artists} "
f"vs '{slskd_track.filename[:60]}' (artist_score={artist_score:.2f} < 0.15)"
)
return 0.0
# --- Final Weighted Score --- # --- Final Weighted Score ---
if is_youtube: if is_youtube:
# For YouTube, rely more on Title and Duration since Artist is often missing from video titles # For YouTube, artist gets more weight than before to reduce wrong-uploader matches.
# and the search query already filtered by artist to some extent. # Previous: Title 70%, Artist 10%, Duration 20% — artist was nearly irrelevant.
# New weights: Title 70%, Artist 10%, Duration 20% # New: Title 60%, Artist 20%, Duration 20%
final_confidence = (title_score * 0.70) + (artist_score * 0.10) + (duration_score * 0.20) final_confidence = (title_score * 0.60) + (artist_score * 0.20) + (duration_score * 0.20)
else: else:
# Standard weights for Soulseek (Artist is critical for correctness) # Standard weights for Soulseek (Artist is critical for correctness)
# Rebalanced weights: Artist matching is now more important to prevent false positives # Rebalanced weights: Artist matching is now more important to prevent false positives