Merge 063c1db697 into 4bbb0913fa
This commit is contained in:
commit
34a113142f
4 changed files with 183 additions and 10 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue