Adds an opt-in `rank_candidates_by_quality` profile flag. When on, the priority-mode download walk orders candidates by the ranked-target quality (confidence/speed only break ties) instead of confidence-first. Default off keeps the byte-for-byte old behaviour, so existing installs are unaffected. Best-quality search mode is always quality-first regardless of the flag; the toggle only affects priority mode. Search-time source selection is unchanged — nothing is skipped, so a track can never go missing, only the order in which copies are tried changes. The version-mismatch force-import follows automatically: it accepts the first-tried (= best-ordered) quarantined candidate, which is the highest-quality one once the walk is quality-first. No change to its selection logic needed. - core/quality/selection.py: load_rank_candidates_by_quality() (fail-closed). - core/downloads/task_worker.py: _best_quality_ordering -> _candidate_ordering; quality-first when best_quality mode OR the toggle is on. - database/music_database.py: default profile carries the flag (False). - web_server.py: flag is preserved globally across preset apply/reset, like search_mode. - core/imports/version_mismatch_fallback.py: comment clarified (no behaviour change). Tests (TDD): load_rank_candidates_by_quality default/enabled/disabled/error; _candidate_ordering across all mode+toggle combinations + fail-closed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
7.5 KiB
Python
193 lines
7.5 KiB
Python
"""Last-resort acceptance of a version-mismatched download.
|
|
|
|
Some tracks simply don't exist on the configured sources in the wanted cut —
|
|
every copy is, say, the instrumental. The retry engine correctly rejects each
|
|
one (version mismatch) and eventually gives up, leaving the track missing.
|
|
|
|
This module provides an OPT-IN fallback: once a track's retries are fully
|
|
exhausted, if every quarantined candidate for it failed the *same* way (same
|
|
matched version, e.g. all ``instrumental``) and there are at least ``min_count``
|
|
of them, accept the best (first-tried) one rather than failing outright.
|
|
|
|
Hard safety rules:
|
|
- Only ``Version mismatch`` quarantines qualify. Audio/artist mismatches
|
|
(a genuinely different recording) and integrity/duration failures
|
|
(truncated or wrong file) never participate.
|
|
- All qualifying entries must share the same matched version. A mix
|
|
(instrumental + live) is ambiguous → no acceptance.
|
|
- The chosen candidate is re-imported with only the AcoustID gate bypassed;
|
|
the integrity / duration / bit-depth gates still run, so a truncated or
|
|
corrupt file is never let through by this path.
|
|
|
|
``select_version_mismatch_fallback`` is the pure decision core (no I/O) so it
|
|
can be tested directly. ``try_accept_version_mismatch_fallback`` wires it to the
|
|
quarantine store + re-import dispatch via injected callables.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from utils.logging_config import get_logger
|
|
|
|
# Must live under the soulsync.* namespace — handlers only attach there, so a
|
|
# bare getLogger(__name__) sent every line (including the critical "accepting
|
|
# best quarantined candidate as last resort" warning) into the void instead of
|
|
# app.log. Same bug class as the prepare.py fix.
|
|
logger = get_logger("imports.version_fallback")
|
|
|
|
# Matches the reason string written by acoustid_verification's version gate:
|
|
# "Version mismatch: expected '<title>' (<exp>) but file is '<title>' (<got>)"
|
|
# We only need the matched (<got>) version to test cross-entry consistency.
|
|
_VERSION_MISMATCH_RE = re.compile(
|
|
r"^Version mismatch:.*\bbut file is\b.*\(([^()]+)\)\s*$"
|
|
)
|
|
|
|
|
|
def _norm(text: Optional[str]) -> str:
|
|
return (text or "").strip().casefold()
|
|
|
|
|
|
def matched_version(reason: Optional[str]) -> Optional[str]:
|
|
"""Return the matched version token (e.g. ``'instrumental'``) for a
|
|
Version-mismatch reason string, or None if the reason isn't a version
|
|
mismatch / can't be parsed."""
|
|
if not reason:
|
|
return None
|
|
m = _VERSION_MISMATCH_RE.match(reason.strip())
|
|
if not m:
|
|
return None
|
|
return m.group(1).strip().casefold()
|
|
|
|
|
|
def select_version_mismatch_fallback(
|
|
entries: List[Dict[str, Any]],
|
|
expected_title: str,
|
|
expected_artist: str,
|
|
min_count: int,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Pick the quarantine entry to accept as a last resort, or None.
|
|
|
|
``entries`` are dicts as produced by
|
|
:func:`core.imports.quarantine.list_quarantine_entries` (needs ``id``,
|
|
``reason``, ``expected_track``, ``expected_artist``, ``has_full_context``).
|
|
|
|
Returns the chosen entry (the first-tried = oldest = best, by ascending
|
|
``id`` whose timestamp prefix sorts chronologically) when, for this track,
|
|
there are at least ``min_count`` version-mismatch entries that all share the
|
|
same matched version and carry full context. Otherwise None.
|
|
"""
|
|
title = _norm(expected_title)
|
|
artist = _norm(expected_artist)
|
|
|
|
candidates = []
|
|
for e in entries:
|
|
if not e.get("has_full_context"):
|
|
continue
|
|
if _norm(e.get("expected_track")) != title:
|
|
continue
|
|
if _norm(e.get("expected_artist")) != artist:
|
|
continue
|
|
version = matched_version(e.get("reason"))
|
|
if version is None:
|
|
continue
|
|
candidates.append((version, e))
|
|
|
|
if len(candidates) < max(1, int(min_count or 1)):
|
|
return None
|
|
|
|
versions = {v for v, _ in candidates}
|
|
if len(versions) != 1:
|
|
# Inconsistent wrong versions (e.g. instrumental + live) — ambiguous,
|
|
# don't guess which the user wants.
|
|
return None
|
|
|
|
# First tried = oldest = best (the retry walks candidates best-first; what
|
|
# "best" means follows the active ordering — confidence-first by default, or
|
|
# ranked-target quality when best_quality mode / the rank_candidates_by_quality
|
|
# toggle is on, so this naturally accepts the highest-quality candidate then).
|
|
# The id is a "<date>_<time>_<name>" timestamp prefix, so the lexicographically
|
|
# smallest id is the earliest attempt.
|
|
return min((e for _, e in candidates), key=lambda e: e["id"])
|
|
|
|
|
|
def try_accept_version_mismatch_fallback(
|
|
*,
|
|
quarantine_dir: str,
|
|
restore_dir: str,
|
|
expected_title: str,
|
|
expected_artist: str,
|
|
task_id: str,
|
|
batch_id: Optional[str],
|
|
config_get: Callable[[str, Any], Any],
|
|
list_entries: Callable[[str], List[Dict[str, Any]]],
|
|
approve_entry: Callable[..., Optional[Any]],
|
|
reprocess: Callable[..., None],
|
|
) -> bool:
|
|
"""Orchestrate the last-resort acceptance. Returns True if a candidate was
|
|
accepted and re-dispatched (caller must then NOT mark the task failed).
|
|
|
|
All I/O is injected so this is testable without a filesystem or the
|
|
web_server pipeline:
|
|
- ``config_get(key, default)`` — settings lookup.
|
|
- ``list_entries(quarantine_dir)`` — quarantine.list_quarantine_entries.
|
|
- ``approve_entry(quarantine_dir, entry_id, restore_dir)`` ->
|
|
``(restored_path, context, trigger)`` or None — quarantine.approve_quarantine_entry.
|
|
- ``reprocess(restored_path, context, task_id, batch_id)`` — re-run the
|
|
verification pipeline on the restored file.
|
|
"""
|
|
if not config_get("post_processing.accept_version_mismatch_fallback", False):
|
|
return False
|
|
|
|
try:
|
|
min_count = int(config_get("post_processing.version_mismatch_min_count", 2))
|
|
except (TypeError, ValueError):
|
|
min_count = 2
|
|
if min_count < 1:
|
|
min_count = 1
|
|
|
|
try:
|
|
entries = list_entries(quarantine_dir) or []
|
|
except Exception as exc: # never let the fallback break the failure path
|
|
logger.debug("[Version-Mismatch Fallback] listing quarantine failed: %s", exc)
|
|
return False
|
|
|
|
chosen = select_version_mismatch_fallback(
|
|
entries, expected_title, expected_artist, min_count
|
|
)
|
|
if not chosen:
|
|
return False
|
|
|
|
version = matched_version(chosen.get("reason")) or "?"
|
|
try:
|
|
result = approve_entry(quarantine_dir, chosen["id"], restore_dir)
|
|
except Exception as exc:
|
|
logger.error("[Version-Mismatch Fallback] approve failed for %s: %s", chosen["id"], exc)
|
|
return False
|
|
if not result:
|
|
return False
|
|
|
|
restored_path, context, _trigger = result
|
|
if not isinstance(context, dict):
|
|
return False
|
|
# Bypass ONLY the AcoustID gate — integrity / duration / bit-depth still run,
|
|
# so a truncated or genuinely wrong file is still caught.
|
|
context["_skip_quarantine_check"] = "acoustid"
|
|
context["_version_mismatch_fallback"] = version
|
|
context["task_id"] = task_id
|
|
if batch_id:
|
|
context["batch_id"] = batch_id
|
|
|
|
logger.warning(
|
|
"[Version-Mismatch Fallback] retries exhausted for '%s - %s'; accepting "
|
|
"best quarantined candidate (%s, entry %s) as last resort",
|
|
expected_artist, expected_title, version, chosen["id"],
|
|
)
|
|
|
|
try:
|
|
reprocess(restored_path, context, task_id, batch_id)
|
|
except Exception as exc:
|
|
logger.error("[Version-Mismatch Fallback] re-import dispatch failed: %s", exc)
|
|
return False
|
|
return True
|