Adds an opt-in search strategy toggle in the Quality Profile: - priority (default): unchanged — first source in the hybrid chain that meets a quality target wins. - best_quality: pool candidates from EVERY source per query and download them best→worst by actual audio quality; source order only breaks ties. Implementation reuses existing plumbing so the retry system is untouched: - engine.search_all_sources pools raw tracks across all configured, non-exhausted sources (no first-source short-circuit). - candidates.order_candidates: new quality_first sort path — profile quality rank dominates, confidence/peer signals break ties. Priority path is byte-for-byte unchanged (regression-locked by tests). - task_worker passes quality_first + targets through; skips the redundant hybrid-fallback block in best-quality mode (pool already covered it). - Per-source retry budgets unchanged: a source that spends its budget is added to exhausted_download_sources and thus dropped from the whole pool. Independent of post_processing.retry_exhaustive. - Query generator NOT touched. Also clarifies the "Allow fallback" setting wording: it accepts OFF-LIST quality as a last resort (not "walk down my list"), and notes that lossy_copy.downsample_hires also bypasses the quality gate — the cause of 16-bit/MP3 files slipping through a 24-bit-only profile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
102 lines
3.5 KiB
Python
102 lines
3.5 KiB
Python
"""Quality-aware candidate selection shared by the search engine and the
|
|
download orchestrator.
|
|
|
|
``rank_with_targets`` is the pure core: it ranks candidates against a target
|
|
list and reports whether any candidate met a *real* target (strict, fallback
|
|
off). The engine uses that ``satisfied`` flag to decide whether the current
|
|
source is good enough or it should fall through to the next source in the
|
|
hybrid chain.
|
|
|
|
``rank_for_profile`` is the thin DB-backed wrapper that loads the user's
|
|
quality profile (with v2->v3 migration) and delegates.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import List, Tuple
|
|
|
|
from core.quality.model import (
|
|
QualityTarget,
|
|
filter_and_rank,
|
|
v2_qualities_to_ranked_targets,
|
|
)
|
|
|
|
|
|
def rank_with_targets(
|
|
candidates: list,
|
|
targets: List[QualityTarget],
|
|
*,
|
|
fallback_enabled: bool = True,
|
|
) -> Tuple[list, bool]:
|
|
"""Rank *candidates* against *targets*.
|
|
|
|
Returns ``(ranked, satisfied)`` where ``satisfied`` is True when at least
|
|
one candidate meets a real target. When no targets are configured the
|
|
profile imposes no constraint, so any non-empty result counts as
|
|
satisfied (the first source wins, quality-sorted).
|
|
"""
|
|
if not candidates:
|
|
return [], False
|
|
|
|
if not targets:
|
|
ranked = filter_and_rank(candidates, targets, fallback_enabled=True)
|
|
return ranked, bool(ranked)
|
|
|
|
strict = filter_and_rank(candidates, targets, fallback_enabled=False)
|
|
if strict:
|
|
return strict, True
|
|
|
|
if fallback_enabled:
|
|
return filter_and_rank(candidates, targets, fallback_enabled=True), False
|
|
return [], False
|
|
|
|
|
|
def load_profile_targets() -> Tuple[List[QualityTarget], bool]:
|
|
"""Load the user's quality profile from the DB and return
|
|
``(targets, fallback_enabled)`` with v2->v3 migration applied.
|
|
|
|
Callers that rank across many sources should load once and reuse via
|
|
:func:`rank_with_targets` rather than calling :func:`rank_for_profile`
|
|
per source.
|
|
"""
|
|
from database.music_database import MusicDatabase
|
|
|
|
profile = MusicDatabase().get_quality_profile()
|
|
raw_targets = profile.get('ranked_targets')
|
|
if not raw_targets and 'qualities' in profile:
|
|
raw_targets = v2_qualities_to_ranked_targets(profile['qualities'])
|
|
|
|
targets = [QualityTarget.from_dict(t) for t in (raw_targets or [])]
|
|
fallback_enabled = profile.get('fallback_enabled', True)
|
|
return targets, fallback_enabled
|
|
|
|
|
|
_VALID_SEARCH_MODES = ("priority", "best_quality")
|
|
|
|
|
|
def load_search_mode() -> str:
|
|
"""Return the download search strategy from the user's quality profile.
|
|
|
|
``'priority'`` (default) keeps today's behaviour — the first source in the
|
|
hybrid chain that meets a quality target wins. ``'best_quality'`` pools
|
|
candidates across all sources and works them best→worst by actual audio
|
|
quality. Any missing/unknown value resolves to ``'priority'`` so existing
|
|
installs are unaffected.
|
|
"""
|
|
from database.music_database import MusicDatabase
|
|
|
|
try:
|
|
profile = MusicDatabase().get_quality_profile()
|
|
mode = profile.get("search_mode", "priority")
|
|
except Exception:
|
|
return "priority"
|
|
return mode if mode in _VALID_SEARCH_MODES else "priority"
|
|
|
|
|
|
def rank_for_profile(candidates: list) -> Tuple[list, bool]:
|
|
"""Load the user's quality profile and rank *candidates* against it.
|
|
|
|
Returns ``(ranked, satisfied)`` — see :func:`rank_with_targets`.
|
|
"""
|
|
targets, fallback_enabled = load_profile_targets()
|
|
return rank_with_targets(candidates, targets, fallback_enabled=fallback_enabled)
|