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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -92,29 +93,114 @@ 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]:
|
# 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.
|
"""Pick the single best torrent / NZB for an album-bundle download.
|
||||||
|
|
||||||
Heuristic, in priority order:
|
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.
|
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
|
Usenet releases use ``grabs`` as a popularity proxy when
|
||||||
seeders is None.
|
seeders is None.
|
||||||
3. Higher quality (FLAC > AAC > MP3) inferred from title.
|
4. Higher quality (FLAC > AAC > MP3) inferred from title.
|
||||||
4. Larger size as tiebreaker (often = higher bitrate).
|
5. Larger size as tiebreaker (often = higher bitrate).
|
||||||
"""
|
"""
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
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]
|
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:
|
if not pool:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _score(c) -> tuple:
|
def _score(c) -> tuple:
|
||||||
seeders = c.seeders if c.seeders is not None else (c.grabs or 0)
|
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)
|
return max(pool, key=_score)
|
||||||
|
|
||||||
|
|
@ -416,6 +502,7 @@ def copy_audio_files_atomically(
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ALBUM_PICK_MIN_BYTES",
|
"ALBUM_PICK_MIN_BYTES",
|
||||||
"ALBUM_PICK_MAX_BYTES",
|
"ALBUM_PICK_MAX_BYTES",
|
||||||
|
"album_title_relevance",
|
||||||
"DEFAULT_POLL_INTERVAL_SECONDS",
|
"DEFAULT_POLL_INTERVAL_SECONDS",
|
||||||
"DEFAULT_POLL_TIMEOUT_SECONDS",
|
"DEFAULT_POLL_TIMEOUT_SECONDS",
|
||||||
"DEFAULT_TRANSIENT_MISS_THRESHOLD",
|
"DEFAULT_TRANSIENT_MISS_THRESHOLD",
|
||||||
|
|
|
||||||
|
|
@ -488,7 +488,10 @@ 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'
|
result['error'] = 'No suitable torrent candidate after filtering'
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -416,7 +416,10 @@ 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'
|
result['error'] = 'No suitable NZB candidate after filtering'
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import pytest
|
||||||
from core.download_plugins.album_bundle import (
|
from core.download_plugins.album_bundle import (
|
||||||
ALBUM_PICK_MAX_BYTES,
|
ALBUM_PICK_MAX_BYTES,
|
||||||
ALBUM_PICK_MIN_BYTES,
|
ALBUM_PICK_MIN_BYTES,
|
||||||
|
album_title_relevance,
|
||||||
DEFAULT_POLL_INTERVAL_SECONDS,
|
DEFAULT_POLL_INTERVAL_SECONDS,
|
||||||
DEFAULT_POLL_TIMEOUT_SECONDS,
|
DEFAULT_POLL_TIMEOUT_SECONDS,
|
||||||
atomic_copy_to_staging,
|
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
|
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
|
# quality_score
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue