From 063c1db69741df1a6ebdacc7c06a63c9117781b9 Mon Sep 17 00:00:00 2001 From: Tyler Richardson-LaPlume <170156756+IamGroot60@users.noreply.github.com> Date: Fri, 29 May 2026 03:03:04 -0400 Subject: [PATCH] =?UTF-8?q?Fix=20album-bundle=20release=20picker=20grabbin?= =?UTF-8?q?g=20the=20wrong=20album=20=E2=80=94=20gate=20by=20title=20relev?= =?UTF-8?q?ance=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/download_plugins/album_bundle.py | 103 ++++++++++++++++++++++++-- core/download_plugins/torrent.py | 5 +- core/download_plugins/usenet.py | 5 +- tests/test_album_bundle.py | 80 ++++++++++++++++++++ 4 files changed, 183 insertions(+), 10 deletions(-) diff --git a/core/download_plugins/album_bundle.py b/core/download_plugins/album_bundle.py index 746022a0..2b92894c 100644 --- a/core/download_plugins/album_bundle.py +++ b/core/download_plugins/album_bundle.py @@ -21,6 +21,7 @@ folder scan. from __future__ import annotations +import re import shutil import time import uuid @@ -92,29 +93,114 @@ def quality_score(title: str, quality_guess) -> int: 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.0–1.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. 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. - 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 seeders is None. - 3. Higher quality (FLAC > AAC > MP3) inferred from title. - 4. Larger size as tiebreaker (often = higher bitrate). + 4. Higher quality (FLAC > AAC > MP3) inferred from title. + 5. Larger size as tiebreaker (often = higher bitrate). """ if not candidates: 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] - pool = sized or list(candidates) + pool = sized or pool if not pool: return None def _score(c) -> tuple: 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) @@ -416,6 +502,7 @@ def copy_audio_files_atomically( __all__ = [ "ALBUM_PICK_MIN_BYTES", "ALBUM_PICK_MAX_BYTES", + "album_title_relevance", "DEFAULT_POLL_INTERVAL_SECONDS", "DEFAULT_POLL_TIMEOUT_SECONDS", "DEFAULT_TRANSIENT_MISS_THRESHOLD", diff --git a/core/download_plugins/torrent.py b/core/download_plugins/torrent.py index 6c7cf4c3..0c27c889 100644 --- a/core/download_plugins/torrent.py +++ b/core/download_plugins/torrent.py @@ -488,7 +488,10 @@ class TorrentDownloadPlugin(DownloadSourcePlugin): result['error'] = f'No torrent results found for "{query}"' 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: result['error'] = 'No suitable torrent candidate after filtering' return result diff --git a/core/download_plugins/usenet.py b/core/download_plugins/usenet.py index 8988fec4..e77cab70 100644 --- a/core/download_plugins/usenet.py +++ b/core/download_plugins/usenet.py @@ -416,7 +416,10 @@ class UsenetDownloadPlugin(DownloadSourcePlugin): result['error'] = f'No usenet results found for "{query}"' 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: result['error'] = 'No suitable NZB candidate after filtering' return result diff --git a/tests/test_album_bundle.py b/tests/test_album_bundle.py index a628b68b..2b049210 100644 --- a/tests/test_album_bundle.py +++ b/tests/test_album_bundle.py @@ -22,6 +22,7 @@ import pytest from core.download_plugins.album_bundle import ( ALBUM_PICK_MAX_BYTES, ALBUM_PICK_MIN_BYTES, + album_title_relevance, DEFAULT_POLL_INTERVAL_SECONDS, DEFAULT_POLL_TIMEOUT_SECONDS, 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 +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 # ---------------------------------------------------------------------------