From 95f4f41c50170645977d53d478afc7862f7218a8 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 30 May 2026 18:56:07 -0700 Subject: [PATCH] Album bundle: gate Prowlarr release picker by album-title relevance (#730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporter (IamGroot60): requesting an album via a Prowlarr-backed source (Usenet/Torrent) could download a DIFFERENT album — e.g. asking for Bowie's 'Heroes' downloaded 'Scary Monsters' because the picker ranked purely by seeders/grabs -> quality -> size with NO title check, and the wrong album had ~16x the grabs. (Confirmed the old picker chose the wrong release on exactly this scenario.) Fix (the reporter's proposal): - album_title_relevance(candidate_title, album_name): word-coverage match, accent-folded (Bjork != bj rk) and WORD-BOUNDARY (Heroes != Superheroes), so a wrong album that shares no title words scores 0. - pick_best_album_release gains album_name/artist_name params and a relevance gate (floor 0.6) applied BEFORE the seeders/quality/size ranking. When album_name is given and NOTHING clears the floor, returns None. - torrent.py + usenet.py call sites pass album_name/artist_name and set result['fallback'] = True on None, so the dispatcher (source-agnostic fallback routing) hands off to the per-track flow instead of grabbing a wrong album. Matches what Soulseek already did via its preflight scorer. No album_name -> no gating (old behavior preserved for callers without a title). Tests: 9 new in test_album_bundle.py (relevance math incl. the substring trap + accent fold, the exact Bowie refuse-and-fallback scenario, None-when-no-match, and no-gate-without-name). 125 album-bundle/dispatch/plugin tests pass; compile + ruff clean. --- core/download_plugins/album_bundle.py | 79 ++++++++++++++++++++++++++- core/download_plugins/torrent.py | 11 +++- core/download_plugins/usenet.py | 10 +++- tests/test_album_bundle.py | 63 +++++++++++++++++++++ 4 files changed, 158 insertions(+), 5 deletions(-) diff --git a/core/download_plugins/album_bundle.py b/core/download_plugins/album_bundle.py index cf9030e4..7db3eac4 100644 --- a/core/download_plugins/album_bundle.py +++ b/core/download_plugins/album_bundle.py @@ -21,8 +21,10 @@ folder scan. from __future__ import annotations +import re import shutil import time +import unicodedata import uuid from pathlib import Path from typing import Any, Callable, Iterable, Optional @@ -32,6 +34,13 @@ from utils.logging_config import get_logger logger = get_logger("download_plugins.album_bundle") +# Minimum album-title relevance a Prowlarr candidate must clear to be eligible +# for an album-bundle download (#730). Prowlarr returns broad fuzzy matches — a +# "Heroes" search also returns other Bowie albums — so without this gate the +# most-popular result wins regardless of whether it's the right album. Below +# this floor we refuse the bundle and let the caller fall back to per-track. +_ALBUM_TITLE_RELEVANCE_FLOOR = 0.6 + # Album-pick size floor / ceiling. Single-track torrents (~10 MB) # are rejected when bigger candidates exist; anything past 3 GB is @@ -92,10 +101,60 @@ 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]: +def _normalize_release_text(text: str) -> str: + """Lowercase, fold accents (Björk -> bjork), strip punctuation to spaces. + + NFKD-decompose then drop combining marks so accented characters fold to + their base letter instead of fragmenting (the naive approach turned + 'Björk' into 'bj rk'). Collapses runs of whitespace. + """ + if not text: + return "" + decomposed = unicodedata.normalize("NFKD", text) + stripped = "".join(c for c in decomposed if not unicodedata.combining(c)) + lowered = stripped.lower() + # Punctuation -> space (so "heroes" matches "heroes:" / "heroes -"), + # then collapse whitespace. + cleaned = re.sub(r"[^a-z0-9]+", " ", lowered) + return re.sub(r"\s+", " ", cleaned).strip() + + +def album_title_relevance(candidate_title: str, album_name: str) -> float: + """How well a release title matches the requested album, 0.0–1.0. + + Word-coverage: the fraction of the album-name's words that appear as + whole words in the candidate title. Word-boundary (not substring) so + "Heroes" does NOT match "Superheroes", and a request for "Heroes" is not + satisfied by a different Bowie album whose title shares no words. + + Returns 1.0 when there's no album name to check against (can't gate on + nothing — preserves old behavior for callers that don't pass a title). + """ + norm_album = _normalize_release_text(album_name) + if not norm_album: + return 1.0 + norm_title = _normalize_release_text(candidate_title) + if not norm_title: + return 0.0 + album_words = norm_album.split() + title_words = set(norm_title.split()) + if not album_words: + return 1.0 + matched = sum(1 for w in album_words if w in title_words) + return matched / len(album_words) + + +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: + 0. Album-TITLE relevance gate (#730): drop candidates whose title doesn't + sufficiently match the requested album. Prowlarr returns broad fuzzy + matches, so without this the most-popular result wins even when it's a + different album. When ``album_name`` is given and NOTHING clears the + relevance floor, return None — the caller then falls back to per-track + rather than downloading a confident mismatch. 1. 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). @@ -106,6 +165,24 @@ def pick_best_album_release(candidates, quality_guess) -> Optional[object]: """ if not candidates: return None + + # 0. Title-relevance gate. Only applied when we know the album name; with + # no name we can't judge relevance, so we don't gate (old behavior). + if album_name: + relevant = [ + c for c in candidates + if album_title_relevance(c.title or "", album_name) >= _ALBUM_TITLE_RELEVANCE_FLOOR + ] + if not relevant: + logger.warning( + "[Album Bundle] No candidate cleared the title-relevance floor " + "for '%s' (%d candidates rejected as wrong album) — refusing the " + "bundle so the caller falls back to per-track.", + album_name, len(candidates), + ) + return None + candidates = relevant + sized = [c for c in candidates if ALBUM_PICK_MIN_BYTES <= (c.size or 0) <= ALBUM_PICK_MAX_BYTES] pool = sized or list(candidates) diff --git a/core/download_plugins/torrent.py b/core/download_plugins/torrent.py index 17ef566b..45615bc4 100644 --- a/core/download_plugins/torrent.py +++ b/core/download_plugins/torrent.py @@ -497,9 +497,16 @@ 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' + # No candidate matched the requested album (or none passed filtering). + # Fall back to the per-track flow rather than downloading a wrong + # album (#730) — per-track searches each track individually. + result['error'] = 'No torrent candidate matched the requested album' + result['fallback'] = True return result download_url = picked.magnet_uri or picked.download_url diff --git a/core/download_plugins/usenet.py b/core/download_plugins/usenet.py index a8b0c529..31aee2b0 100644 --- a/core/download_plugins/usenet.py +++ b/core/download_plugins/usenet.py @@ -468,9 +468,15 @@ 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' + # No candidate matched the requested album (or none passed filtering). + # Fall back to per-track rather than grabbing a wrong album (#730). + result['error'] = 'No NZB candidate matched the requested album' + result['fallback'] = True return result logger.info("[Usenet album] Picked '%s' (size=%.1fMB grabs=%s indexer=%s)", diff --git a/tests/test_album_bundle.py b/tests/test_album_bundle.py index 6a237b22..f693a506 100644 --- a/tests/test_album_bundle.py +++ b/tests/test_album_bundle.py @@ -27,6 +27,7 @@ from core.download_plugins.album_bundle import ( DEFAULT_POLL_TIMEOUT_SECONDS, atomic_copy_to_staging, copy_audio_files_atomically, + album_title_relevance, get_completed_no_path_window_seconds, get_poll_interval, get_poll_timeout, @@ -117,6 +118,68 @@ def test_picker_rejects_oversized_box_sets() -> None: assert pick_best_album_release([sane, box], _flac_quality_guess) is sane +# --------------------------------------------------------------------------- +# #730 — album-title relevance gate +# --------------------------------------------------------------------------- + + +def test_relevance_exact_match_is_full(): + assert album_title_relevance("David Bowie - Heroes (2017 Remaster) [FLAC]", "Heroes") == 1.0 + + +def test_relevance_word_boundary_not_substring(): + # "Heroes" must NOT be satisfied by "Superheroes" (the substring trap). + assert album_title_relevance("Various - Superheroes Soundtrack", "Heroes") == 0.0 + + +def test_relevance_wrong_album_scores_zero(): + # The reporter's exact case: a "Heroes" request must not match a different + # Bowie album that shares no title words. + assert album_title_relevance("David Bowie - Scary Monsters and Super Creeps", "Heroes") == 0.0 + + +def test_relevance_accent_folding(): + # Björk folds to bjork, not "bj rk" — so an accented request still matches. + assert album_title_relevance("Bjork - Homogenic [FLAC]", "Björk Homogenic") == 1.0 + + +def test_relevance_partial_word_coverage(): + # 1 of 2 album words present -> 0.5 (below the 0.6 floor). + assert album_title_relevance("Artist - Dark Side [FLAC]", "Dark Moon") == 0.5 + + +def test_relevance_no_album_name_is_neutral(): + # Can't gate on nothing — preserves old behavior for callers w/o a title. + assert album_title_relevance("anything at all", "") == 1.0 + + +def test_picker_refuses_wrong_album_falls_back(): + """The #730 scenario: a hugely-popular WRONG album must NOT be picked over + a less-popular RIGHT one — and if nothing matches, return None so the + caller falls back to per-track.""" + wrong_popular = _Release(title="David Bowie - Scary Monsters [FLAC]", + size=400_000_000, seeders=16000) + right_quiet = _Release(title='David Bowie - "Heroes" 2017 Remaster [FLAC]', + size=400_000_000, seeders=10) + picked = pick_best_album_release( + [wrong_popular, right_quiet], _flac_quality_guess, album_name="Heroes") + assert picked is right_quiet # relevance beats raw popularity + + +def test_picker_returns_none_when_nothing_matches_album(): + # Only the wrong album is available -> refuse (None) -> per-track fallback. + wrong = _Release(title="David Bowie - Scary Monsters [FLAC]", + size=400_000_000, seeders=16000) + assert pick_best_album_release([wrong], _flac_quality_guess, album_name="Heroes") is None + + +def test_picker_without_album_name_unchanged(): + # No album_name passed -> no gating -> old popularity behavior intact. + a = _Release(title="Whatever [FLAC]", size=400_000_000, seeders=5) + b = _Release(title="Other [FLAC]", size=400_000_000, seeders=999) + assert pick_best_album_release([a, b], _flac_quality_guess) is b + + # --------------------------------------------------------------------------- # quality_score # ---------------------------------------------------------------------------