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:
parent
c3f7cf795a
commit
95f4f41c50
4 changed files with 158 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue