Fix album-bundle release picker grabbing the wrong album — gate by title relevance (#730)

pick_best_album_release ranked Prowlarr candidates purely by
popularity (seeders/grabs) -> quality -> size, with no check that the
release is actually the requested album. Since Prowlarr returns broad
fuzzy matches, the most-popular result could win regardless: a
David Bowie '"Heroes" (2017 Remaster)' request downloaded 'Scary
Monsters and Super Creeps' instead (~16x the grabs).

Affects Usenet AND Torrent (both use this shared picker). Soulseek is
unaffected — it uses the title/artist/track-coverage-aware album
pre-flight (_score_album_folder) rather than this picker.

Fix: add album_title_relevance() — token-coverage of the album's core
title (year/edition/remaster noise stripped) against the release name —
and gate candidates by it before popularity ranking. When album_name is
supplied, drop sub-floor candidates and refuse (return None -> caller
falls back to per-track search) rather than download a mismatch. Both
usenet.py and torrent.py call sites now pass album_name/artist_name;
callers that omit it keep the legacy popularity ranking.

Tests: exact Heroes-vs-Scary-Monsters regression, relevance scoring,
no-match refusal, multi-token coverage, and no-album-name back-compat.
This commit is contained in:
Tyler Richardson-LaPlume 2026-05-29 03:03:04 -04:00
parent 4bbb0913fa
commit 063c1db697
4 changed files with 183 additions and 10 deletions

View file

@ -21,6 +21,7 @@ folder scan.
from __future__ import annotations from __future__ import annotations
import re
import shutil import shutil
import time import time
import uuid import uuid
@ -92,29 +93,114 @@ def quality_score(title: str, quality_guess) -> int:
return _QUALITY_SCORE.get(quality_guess(title) or '', 0) return _QUALITY_SCORE.get(quality_guess(title) or '', 0)
def pick_best_album_release(candidates, quality_guess) -> Optional[object]: # Words that decorate an album title in a release name but aren't part of
# the album's identity — stripped before computing title relevance so e.g.
# 'Heroes (2017 Remaster)' and a 'David Bowie - Heroes - ... 2017' release
# still match on the core token 'heroes'.
_ALBUM_TITLE_NOISE = {
'remaster', 'remastered', 'remasters', 'edition', 'deluxe', 'expanded',
'anniversary', 'special', 'platinum', 'collectors', 'collector',
'bonus', 'version', 'mono', 'stereo', 'reissue', 'the',
}
# Minimum fraction of the album's core title tokens that must appear in a
# release title for it to be considered the same album. Below this we refuse
# the candidate — downloading a different (often more-popular) album is far
# worse than failing the bundle and falling back to per-track search.
_ALBUM_TITLE_RELEVANCE_FLOOR = 0.55
def _normalize_album_text(text: Any) -> str:
lowered = str(text or '').lower()
lowered = re.sub(r"['`]", '', lowered) # drop apostrophes/quotes
lowered = re.sub(r'[^a-z0-9]+', ' ', lowered) # everything else → space
return re.sub(r'\s+', ' ', lowered).strip()
def _album_core_tokens(album_name: Any) -> list:
"""Significant title tokens — digits (years) and edition/remaster noise
removed. Falls back to the raw tokens if stripping leaves nothing."""
tokens = _normalize_album_text(album_name).split()
core = [t for t in tokens if not t.isdigit() and t not in _ALBUM_TITLE_NOISE]
return core or tokens
def album_title_relevance(release_title: Any, album_name: Any) -> float:
"""How well a release title matches the requested album (0.01.0).
Token-coverage based: the fraction of the album's core tokens present in
the release title, with a bonus when the full core phrase appears as a
substring. Robust to the codec/year/group noise that pads release names
('Artist-Album-24-192-WEB-FLAC-REMASTERED-2017-GROUP')."""
core = _album_core_tokens(album_name)
if not core:
return 0.0
release_norm = _normalize_album_text(release_title)
release_tokens = set(release_norm.split())
present = sum(1 for token in core if token in release_tokens)
coverage = present / len(core)
phrase = ' '.join(core)
if phrase and phrase in release_norm:
coverage = max(coverage, 0.9)
return coverage
def pick_best_album_release(
candidates, quality_guess, album_name: str = '', artist_name: str = '',
) -> Optional[object]:
"""Pick the single best torrent / NZB for an album-bundle download. """Pick the single best torrent / NZB for an album-bundle download.
Heuristic, in priority order: Heuristic, in priority order:
1. Reasonable album-ish size (40 MB 3 GB) drops single-track 1. Title relevance the release must actually be the requested album.
Prowlarr/indexers return broad fuzzy matches (a 'Heroes' search also
returns 'Scary Monsters'), and ranking purely by popularity then
grabs the wrong, more-popular album. When ``album_name`` is supplied
we drop candidates whose title doesn't cover the album's core tokens
(``_ALBUM_TITLE_RELEVANCE_FLOOR``), and refuse rather than download a
mismatch if none qualify. Soulseek is unaffected it uses the
title/artist/coverage-aware album pre-flight instead of this picker.
2. Reasonable album-ish size (40 MB 3 GB) drops single-track
releases that snuck in and quarantines suspicious giants. releases that snuck in and quarantines suspicious giants.
2. Higher seeders > lower (dead torrents = dead downloads). 3. Higher seeders > lower (dead torrents = dead downloads).
Usenet releases use ``grabs`` as a popularity proxy when Usenet releases use ``grabs`` as a popularity proxy when
seeders is None. seeders is None.
3. Higher quality (FLAC > AAC > MP3) inferred from title. 4. Higher quality (FLAC > AAC > MP3) inferred from title.
4. Larger size as tiebreaker (often = higher bitrate). 5. Larger size as tiebreaker (often = higher bitrate).
""" """
if not candidates: if not candidates:
return None return None
sized = [c for c in candidates
pool = list(candidates)
# 1. Title relevance gate (only when we know the target album).
if album_name:
relevant = [
c for c in pool
if album_title_relevance(getattr(c, 'title', '') or '', album_name)
>= _ALBUM_TITLE_RELEVANCE_FLOOR
]
if not relevant:
logger.warning(
"[album_bundle] No candidate title matched album %r (checked %d) "
"— refusing to grab a mismatched release",
album_name, len(pool),
)
return None
pool = relevant
# 2. Size sanity.
sized = [c for c in pool
if ALBUM_PICK_MIN_BYTES <= (c.size or 0) <= ALBUM_PICK_MAX_BYTES] if ALBUM_PICK_MIN_BYTES <= (c.size or 0) <= ALBUM_PICK_MAX_BYTES]
pool = sized or list(candidates) pool = sized or pool
if not pool: if not pool:
return None return None
def _score(c) -> tuple: def _score(c) -> tuple:
seeders = c.seeders if c.seeders is not None else (c.grabs or 0) seeders = c.seeders if c.seeders is not None else (c.grabs or 0)
return (seeders, quality_score(c.title or '', quality_guess), c.size or 0) # Relevance bucket first so a strong title match always beats a
# weakly-matching but more-popular release.
relevance = album_title_relevance(getattr(c, 'title', '') or '', album_name) if album_name else 0.0
return (round(relevance, 2), seeders, quality_score(c.title or '', quality_guess), c.size or 0)
return max(pool, key=_score) return max(pool, key=_score)
@ -416,6 +502,7 @@ def copy_audio_files_atomically(
__all__ = [ __all__ = [
"ALBUM_PICK_MIN_BYTES", "ALBUM_PICK_MIN_BYTES",
"ALBUM_PICK_MAX_BYTES", "ALBUM_PICK_MAX_BYTES",
"album_title_relevance",
"DEFAULT_POLL_INTERVAL_SECONDS", "DEFAULT_POLL_INTERVAL_SECONDS",
"DEFAULT_POLL_TIMEOUT_SECONDS", "DEFAULT_POLL_TIMEOUT_SECONDS",
"DEFAULT_TRANSIENT_MISS_THRESHOLD", "DEFAULT_TRANSIENT_MISS_THRESHOLD",

View file

@ -488,7 +488,10 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
result['error'] = f'No torrent results found for "{query}"' result['error'] = f'No torrent results found for "{query}"'
return result return result
picked = pick_best_album_release(candidates, _guess_quality_from_title) picked = pick_best_album_release(
candidates, _guess_quality_from_title,
album_name=album_name, artist_name=artist_name,
)
if picked is None: if picked is None:
result['error'] = 'No suitable torrent candidate after filtering' result['error'] = 'No suitable torrent candidate after filtering'
return result return result

View file

@ -416,7 +416,10 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
result['error'] = f'No usenet results found for "{query}"' result['error'] = f'No usenet results found for "{query}"'
return result return result
picked = pick_best_album_release(candidates, _guess_quality_from_title) picked = pick_best_album_release(
candidates, _guess_quality_from_title,
album_name=album_name, artist_name=artist_name,
)
if picked is None: if picked is None:
result['error'] = 'No suitable NZB candidate after filtering' result['error'] = 'No suitable NZB candidate after filtering'
return result return result

View file

@ -22,6 +22,7 @@ import pytest
from core.download_plugins.album_bundle import ( from core.download_plugins.album_bundle import (
ALBUM_PICK_MAX_BYTES, ALBUM_PICK_MAX_BYTES,
ALBUM_PICK_MIN_BYTES, ALBUM_PICK_MIN_BYTES,
album_title_relevance,
DEFAULT_POLL_INTERVAL_SECONDS, DEFAULT_POLL_INTERVAL_SECONDS,
DEFAULT_POLL_TIMEOUT_SECONDS, DEFAULT_POLL_TIMEOUT_SECONDS,
atomic_copy_to_staging, atomic_copy_to_staging,
@ -114,6 +115,85 @@ def test_picker_rejects_oversized_box_sets() -> None:
assert pick_best_album_release([sane, box], _flac_quality_guess) is sane assert pick_best_album_release([sane, box], _flac_quality_guess) is sane
def test_picker_rejects_wrong_album_by_title() -> None:
"""Regression for #730: a 'Heroes' request returned 'Scary Monsters'
because the picker ranked purely by grabs and that release was far more
popular. With the album name supplied, title relevance must gate first
so the actual requested album wins despite ~16x fewer grabs."""
scary = _Release(
title='David Bowie-Scary Monsters And Super Creeps-24-192-WEB-FLAC-REMASTERED-2017-OBZEN',
size=2_000_000_000, seeders=None, grabs=15698,
)
heroes = _Release(
title='David Bowie-Heroes-24-192-WEB-FLAC-REMASTERED-2017-OBZEN',
size=1_000_000_000, seeders=None, grabs=960,
)
picked = pick_best_album_release(
[scary, heroes], _flac_quality_guess,
album_name='"Heroes" (2017 Remaster)', artist_name='David Bowie',
)
assert picked is heroes
def test_picker_returns_none_when_no_candidate_matches_album() -> None:
"""If nothing resembles the requested album, refuse rather than grab a
mismatched (popular) release failing the bundle lets the per-track
flow take over instead of importing the wrong album."""
scary = _Release(
title='David Bowie-Scary Monsters And Super Creeps-FLAC-2017',
size=2_000_000_000, seeders=None, grabs=15698,
)
assert pick_best_album_release(
[scary], _flac_quality_guess, album_name='"Heroes" (2017 Remaster)',
) is None
def test_picker_without_album_name_keeps_legacy_popularity_ranking() -> None:
"""Back-compat: with no album name (older callers), the relevance gate
is skipped and the most-popular album-sized release still wins."""
a = _Release(title='Whatever A [FLAC]', size=200_000_000, seeders=None, grabs=10)
b = _Release(title='Whatever B [FLAC]', size=200_000_000, seeders=None, grabs=999)
assert pick_best_album_release([a, b], _flac_quality_guess) is b
def test_picker_prefers_stronger_title_match_over_grabs() -> None:
"""A multi-token album: a release covering all core tokens beats a
more-popular release that only partially matches."""
exact = _Release(
title='Scary Monsters And Super Creeps [FLAC]',
size=300_000_000, seeders=None, grabs=5,
)
partial = _Release(
title='Scary Monsters (single) [FLAC]',
size=300_000_000, seeders=None, grabs=9000,
)
picked = pick_best_album_release(
[partial, exact], _flac_quality_guess,
album_name='Scary Monsters and Super Creeps',
)
assert picked is exact
def test_album_title_relevance_scoring() -> None:
# Exact / noise-padded release name still matches on the core token.
assert album_title_relevance(
'David Bowie-Heroes-24-192-WEB-FLAC-REMASTERED-2017-OBZEN',
'"Heroes" (2017 Remaster)',
) >= 0.9
# Different album → no shared core tokens.
assert album_title_relevance(
'David Bowie-Scary Monsters And Super Creeps-FLAC-2017',
'"Heroes" (2017 Remaster)',
) == 0.0
# Multi-token album fully covered.
assert album_title_relevance(
'VA - Scary Monsters And Super Creeps (Remastered) FLAC',
'Scary Monsters and Super Creeps',
) >= 0.9
# Empty album name is unscoreable.
assert album_title_relevance('Anything', '') == 0.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# quality_score # quality_score
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------