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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
import unicodedata
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Iterable, Optional
|
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")
|
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)
|
# Album-pick size floor / ceiling. Single-track torrents (~10 MB)
|
||||||
# are rejected when bigger candidates exist; anything past 3 GB is
|
# 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)
|
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.
|
"""Pick the single best torrent / NZB for an album-bundle download.
|
||||||
|
|
||||||
Heuristic, in priority order:
|
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
|
1. 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).
|
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:
|
if not candidates:
|
||||||
return None
|
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
|
sized = [c for c in candidates
|
||||||
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 list(candidates)
|
||||||
|
|
|
||||||
|
|
@ -497,9 +497,16 @@ 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'
|
# 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
|
return result
|
||||||
|
|
||||||
download_url = picked.magnet_uri or picked.download_url
|
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}"'
|
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'
|
# 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
|
return result
|
||||||
|
|
||||||
logger.info("[Usenet album] Picked '%s' (size=%.1fMB grabs=%s indexer=%s)",
|
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,
|
DEFAULT_POLL_TIMEOUT_SECONDS,
|
||||||
atomic_copy_to_staging,
|
atomic_copy_to_staging,
|
||||||
copy_audio_files_atomically,
|
copy_audio_files_atomically,
|
||||||
|
album_title_relevance,
|
||||||
get_completed_no_path_window_seconds,
|
get_completed_no_path_window_seconds,
|
||||||
get_poll_interval,
|
get_poll_interval,
|
||||||
get_poll_timeout,
|
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
|
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
|
# quality_score
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue