Album bundle: gate Prowlarr release picker by album-title relevance (#730)

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.
This commit is contained in:
BoulderBadgeDad 2026-05-30 18:56:07 -07:00
parent c3f7cf795a
commit 95f4f41c50
4 changed files with 158 additions and 5 deletions

View file

@ -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.01.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)

View file

@ -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

View file

@ -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)",

View file

@ -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
# ---------------------------------------------------------------------------