Downloads: opt-in last-resort acceptance of repeated version mismatches
Some tracks don't exist on the sources in the wanted cut — every copy is, say, the instrumental. The retry engine correctly rejects each (version mismatch) and gives up, leaving the track missing. New opt-in fallback: once a track's AcoustID retries are fully exhausted, if every quarantined candidate for it failed the SAME version mismatch (same matched version, e.g. all instrumental) and there are >= N of them, accept the best (first-tried = oldest = highest-confidence) one. Safety rules (core/imports/version_mismatch_fallback.py): - Version mismatches only. Audio/artist mismatches (different recording) and integrity/duration failures (truncated/wrong file) never participate. - All qualifying entries must share the same matched version; a mix (instrumental + live) is ambiguous → no acceptance. - Re-import bypasses ONLY the AcoustID gate; integrity/duration/bit-depth still run, so a truncated or genuinely wrong file is never let through here. - Reuses the existing quarantine approve_quarantine_entry + re-verify dispatch. Wired at the AcoustID give-up point in the verification wrapper. Two new post_processing settings surfaced in the Retry Logic tile (default off): accept_version_mismatch_fallback + version_mismatch_min_count. Pure decision core + orchestration covered by tests (11). Acceptance logged at WARNING with track + matched version. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
87a4e41f9e
commit
37140dff34
5 changed files with 466 additions and 1 deletions
|
|
@ -35,7 +35,12 @@ from core.imports.context import (
|
|||
from core.imports.file_integrity import check_audio_integrity, resolve_duration_tolerance
|
||||
from core.imports.filename import extract_track_number_from_filename
|
||||
from core.imports.guards import check_flac_bit_depth, move_to_quarantine
|
||||
from core.imports.quarantine import entry_id_from_quarantined_filename
|
||||
from core.imports.quarantine import (
|
||||
approve_quarantine_entry,
|
||||
entry_id_from_quarantined_filename,
|
||||
list_quarantine_entries,
|
||||
)
|
||||
from core.imports.version_mismatch_fallback import try_accept_version_mismatch_fallback
|
||||
from core.imports.side_effects import (
|
||||
emit_track_downloaded,
|
||||
record_download_provenance,
|
||||
|
|
@ -985,6 +990,53 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
post_process_locks.pop(context_key, None)
|
||||
|
||||
|
||||
def _attempt_version_mismatch_fallback(context, task_id, batch_id, runtime, metadata_runtime):
|
||||
"""Opt-in last resort once AcoustID retries are exhausted: accept the best
|
||||
quarantined version-mismatch candidate for this track instead of failing.
|
||||
|
||||
Delegates the decision + safety rules to
|
||||
``core.imports.version_mismatch_fallback`` (version-mismatch only, all the
|
||||
same matched version, >= min_count, AcoustID-only bypass). Returns True when
|
||||
a candidate was accepted and re-dispatched — the caller then skips marking
|
||||
the task failed.
|
||||
"""
|
||||
try:
|
||||
download_path = docker_resolve_path(
|
||||
config_manager.get('soulseek.download_path', './downloads')
|
||||
)
|
||||
quarantine_dir = os.path.join(download_path, 'ss_quarantine')
|
||||
restore_dir = os.path.join(download_path, 'Transfer')
|
||||
expected_title = get_import_clean_title(context, default='')
|
||||
expected_artist = get_import_clean_artist(context, default='')
|
||||
if not expected_title or not expected_artist:
|
||||
return False
|
||||
|
||||
def _reprocess(restored_path, ctx, tid, bid):
|
||||
new_key = f"vmfallback_{tid}_{int(time.time())}"
|
||||
threading.Thread(
|
||||
target=lambda: post_process_matched_download_with_verification(
|
||||
new_key, ctx, restored_path, tid, bid, runtime, metadata_runtime
|
||||
),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
return try_accept_version_mismatch_fallback(
|
||||
quarantine_dir=quarantine_dir,
|
||||
restore_dir=restore_dir,
|
||||
expected_title=expected_title,
|
||||
expected_artist=expected_artist,
|
||||
task_id=task_id,
|
||||
batch_id=batch_id,
|
||||
config_get=config_manager.get,
|
||||
list_entries=list_quarantine_entries,
|
||||
approve_entry=approve_quarantine_entry,
|
||||
reprocess=_reprocess,
|
||||
)
|
||||
except Exception as exc:
|
||||
pp_logger.debug("[Version-Mismatch Fallback] skipped due to error: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def post_process_matched_download_with_verification(context_key, context, file_path, task_id, batch_id, runtime, metadata_runtime=None):
|
||||
on_download_completed = getattr(runtime, "on_download_completed", None)
|
||||
|
||||
|
|
@ -1026,6 +1078,11 @@ def post_process_matched_download_with_verification(context_key, context, file_p
|
|||
f"AcoustID mismatch for task {task_id} — retrying next-best candidate: {failure_msg}"
|
||||
)
|
||||
return
|
||||
# Retries exhausted. Opt-in last resort: if every quarantined
|
||||
# candidate for this track failed the SAME version mismatch (e.g. all
|
||||
# instrumental), accept the best one rather than leaving it missing.
|
||||
if _attempt_version_mismatch_fallback(context, task_id, batch_id, runtime, metadata_runtime):
|
||||
return
|
||||
logger.info(f"File was quarantined by AcoustID verification (task={task_id}): {failure_msg}")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
|
|
|
|||
185
core/imports/version_mismatch_fallback.py
Normal file
185
core/imports/version_mismatch_fallback.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""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 logging
|
||||
import re
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 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 = highest-confidence (the retry walks candidates
|
||||
# best-first). 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
|
||||
207
tests/imports/test_version_mismatch_fallback.py
Normal file
207
tests/imports/test_version_mismatch_fallback.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""Last-resort acceptance of a version-mismatched quarantine candidate.
|
||||
|
||||
When a track's retries are fully exhausted and EVERY quarantined candidate for
|
||||
it failed the same way (same wrong version, e.g. all instrumental), the only
|
||||
available version is that one — accept the best (first-tried) one instead of
|
||||
leaving the track missing. Strict guards: version-mismatch only, all the same
|
||||
matched version, and a minimum count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.imports.version_mismatch_fallback import (
|
||||
select_version_mismatch_fallback,
|
||||
try_accept_version_mismatch_fallback,
|
||||
)
|
||||
|
||||
|
||||
def _entry(eid, reason, *, track="Barricades (Movie Ver.)", artist="Hiroyuki Sawano",
|
||||
ctx=True):
|
||||
return {
|
||||
"id": eid,
|
||||
"reason": reason,
|
||||
"expected_track": track,
|
||||
"expected_artist": artist,
|
||||
"has_full_context": ctx,
|
||||
}
|
||||
|
||||
|
||||
_VM = "Version mismatch: expected 'Barricades (Movie Ver.)' (original) but file is 'Barricades <MOVIEver.> ({v})' ({v})"
|
||||
|
||||
|
||||
def _vm(version):
|
||||
return _VM.format(v=version)
|
||||
|
||||
|
||||
def test_picks_oldest_when_all_same_version_and_count_met():
|
||||
# 3 instrumental mismatches → all same kind → pick first-tried (smallest id).
|
||||
entries = [
|
||||
_entry("20260605_120300", _vm("instrumental")),
|
||||
_entry("20260605_120100", _vm("instrumental")), # oldest = first tried
|
||||
_entry("20260605_120200", _vm("instrumental")),
|
||||
]
|
||||
chosen = select_version_mismatch_fallback(entries, "Barricades (Movie Ver.)",
|
||||
"Hiroyuki Sawano", min_count=2)
|
||||
assert chosen is not None
|
||||
assert chosen["id"] == "20260605_120100"
|
||||
|
||||
|
||||
def test_none_when_below_min_count():
|
||||
entries = [_entry("20260605_120100", _vm("instrumental"))]
|
||||
assert select_version_mismatch_fallback(
|
||||
entries, "Barricades (Movie Ver.)", "Hiroyuki Sawano", min_count=2) is None
|
||||
|
||||
|
||||
def test_none_when_mixed_versions():
|
||||
# instrumental + live → inconsistent → never auto-accept (ambiguous).
|
||||
entries = [
|
||||
_entry("20260605_120100", _vm("instrumental")),
|
||||
_entry("20260605_120200", _vm("live")),
|
||||
]
|
||||
assert select_version_mismatch_fallback(
|
||||
entries, "Barricades (Movie Ver.)", "Hiroyuki Sawano", min_count=2) is None
|
||||
|
||||
|
||||
def test_ignores_non_version_mismatch_reasons():
|
||||
# Audio mismatch (wrong artist/song) and integrity must NOT count.
|
||||
entries = [
|
||||
_entry("20260605_120100",
|
||||
"Audio mismatch: file identified as 'X' by 'Y' (artist=0%)"),
|
||||
_entry("20260605_120200",
|
||||
"Integrity check failed: Duration mismatch: file is 175s, expected 182s"),
|
||||
]
|
||||
assert select_version_mismatch_fallback(
|
||||
entries, "Barricades (Movie Ver.)", "Hiroyuki Sawano", min_count=1) is None
|
||||
|
||||
|
||||
def test_only_counts_entries_for_this_track():
|
||||
entries = [
|
||||
_entry("20260605_120100", _vm("instrumental")),
|
||||
_entry("20260605_120200", _vm("instrumental"), track="Call Your Name (Gv)"),
|
||||
]
|
||||
# Only one entry matches this track → below min_count of 2.
|
||||
assert select_version_mismatch_fallback(
|
||||
entries, "Barricades (Movie Ver.)", "Hiroyuki Sawano", min_count=2) is None
|
||||
|
||||
|
||||
def test_excludes_thin_sidecar_entries_without_context():
|
||||
# Can't approve without embedded context → exclude from the candidate pool.
|
||||
entries = [
|
||||
_entry("20260605_120100", _vm("instrumental"), ctx=False),
|
||||
_entry("20260605_120200", _vm("instrumental"), ctx=False),
|
||||
]
|
||||
assert select_version_mismatch_fallback(
|
||||
entries, "Barricades (Movie Ver.)", "Hiroyuki Sawano", min_count=2) is None
|
||||
|
||||
|
||||
def test_track_match_is_case_and_space_insensitive():
|
||||
entries = [
|
||||
_entry("20260605_120100", _vm("instrumental")),
|
||||
_entry("20260605_120200", _vm("instrumental")),
|
||||
]
|
||||
chosen = select_version_mismatch_fallback(
|
||||
entries, " barricades (movie ver.) ", "HIROYUKI SAWANO", min_count=2)
|
||||
assert chosen is not None
|
||||
assert chosen["id"] == "20260605_120100"
|
||||
|
||||
|
||||
# ── Orchestration (try_accept_version_mismatch_fallback) ──────────────────────
|
||||
|
||||
def _cfg(enabled=True, min_count=2):
|
||||
values = {
|
||||
"post_processing.accept_version_mismatch_fallback": enabled,
|
||||
"post_processing.version_mismatch_min_count": min_count,
|
||||
}
|
||||
return lambda key, default=None: values.get(key, default)
|
||||
|
||||
|
||||
def _two_instrumental_entries():
|
||||
return [
|
||||
_entry("20260605_120100", _vm("instrumental")),
|
||||
_entry("20260605_120200", _vm("instrumental")),
|
||||
]
|
||||
|
||||
|
||||
def test_orchestration_disabled_does_nothing():
|
||||
calls = {"approve": 0, "reprocess": 0}
|
||||
|
||||
def approve(*a, **k):
|
||||
calls["approve"] += 1
|
||||
return ("/restored.flac", {}, "acoustid")
|
||||
|
||||
def reprocess(*a, **k):
|
||||
calls["reprocess"] += 1
|
||||
|
||||
ok = try_accept_version_mismatch_fallback(
|
||||
quarantine_dir="/q", restore_dir="/r",
|
||||
expected_title="Barricades (Movie Ver.)", expected_artist="Hiroyuki Sawano",
|
||||
task_id="t1", batch_id="b1",
|
||||
config_get=_cfg(enabled=False),
|
||||
list_entries=lambda d: _two_instrumental_entries(),
|
||||
approve_entry=approve, reprocess=reprocess,
|
||||
)
|
||||
assert ok is False
|
||||
assert calls == {"approve": 0, "reprocess": 0}
|
||||
|
||||
|
||||
def test_orchestration_accepts_and_reprocesses_with_acoustid_bypass():
|
||||
captured = {}
|
||||
|
||||
def approve(qdir, entry_id, rdir):
|
||||
captured["entry_id"] = entry_id
|
||||
return ("/restored.flac", {"existing": 1}, "acoustid")
|
||||
|
||||
def reprocess(path, context, task_id, batch_id):
|
||||
captured["path"] = path
|
||||
captured["context"] = context
|
||||
captured["task_id"] = task_id
|
||||
|
||||
ok = try_accept_version_mismatch_fallback(
|
||||
quarantine_dir="/q", restore_dir="/r",
|
||||
expected_title="Barricades (Movie Ver.)", expected_artist="Hiroyuki Sawano",
|
||||
task_id="t1", batch_id="b1",
|
||||
config_get=_cfg(),
|
||||
list_entries=lambda d: _two_instrumental_entries(),
|
||||
approve_entry=approve, reprocess=reprocess,
|
||||
)
|
||||
assert ok is True
|
||||
assert captured["entry_id"] == "20260605_120100" # oldest/best
|
||||
assert captured["path"] == "/restored.flac"
|
||||
assert captured["task_id"] == "t1"
|
||||
# Only AcoustID bypassed — integrity/bit-depth gates still run.
|
||||
assert captured["context"]["_skip_quarantine_check"] == "acoustid"
|
||||
assert captured["context"]["_version_mismatch_fallback"] == "instrumental"
|
||||
assert captured["context"]["task_id"] == "t1"
|
||||
assert captured["context"]["batch_id"] == "b1"
|
||||
|
||||
|
||||
def test_orchestration_no_candidate_does_not_reprocess():
|
||||
calls = {"reprocess": 0}
|
||||
|
||||
def reprocess(*a, **k):
|
||||
calls["reprocess"] += 1
|
||||
|
||||
ok = try_accept_version_mismatch_fallback(
|
||||
quarantine_dir="/q", restore_dir="/r",
|
||||
expected_title="Barricades (Movie Ver.)", expected_artist="Hiroyuki Sawano",
|
||||
task_id="t1", batch_id="b1",
|
||||
config_get=_cfg(),
|
||||
list_entries=lambda d: [_entry("20260605_120100", _vm("instrumental"))], # 1 < min 2
|
||||
approve_entry=lambda *a, **k: ("/x", {}, "acoustid"),
|
||||
reprocess=reprocess,
|
||||
)
|
||||
assert ok is False
|
||||
assert calls["reprocess"] == 0
|
||||
|
||||
|
||||
def test_orchestration_approve_failure_returns_false():
|
||||
ok = try_accept_version_mismatch_fallback(
|
||||
quarantine_dir="/q", restore_dir="/r",
|
||||
expected_title="Barricades (Movie Ver.)", expected_artist="Hiroyuki Sawano",
|
||||
task_id="t1", batch_id="b1",
|
||||
config_get=_cfg(),
|
||||
list_entries=lambda d: _two_instrumental_entries(),
|
||||
approve_entry=lambda *a, **k: None, # thin sidecar / move failed
|
||||
reprocess=lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not reprocess")),
|
||||
)
|
||||
assert ok is False
|
||||
|
|
@ -5192,6 +5192,18 @@
|
|||
<input type="number" id="retries-per-query" min="1" max="20" step="1" value="5" style="width: 100px;" autocomplete="off" data-bwignore="" data-1p-ignore="" data-lpignore="true">
|
||||
<small class="settings-hint">In exhaustive mode, how many candidates to try per search query per source. The per-source budget is <em>number of search queries × this value</em>. Only used when Exhaustive retry is on.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="accept-version-mismatch-fallback">
|
||||
Accept best version mismatch as last resort
|
||||
</label>
|
||||
<small class="settings-hint">When retries are fully exhausted and <em>every</em> candidate for a track failed the <strong>same</strong> version mismatch (e.g. only an instrumental exists), accept the best (first-tried) one instead of leaving the track missing. Only AcoustID is bypassed — integrity/duration/bit-depth checks still run, so truncated or genuinely wrong files are never let through. Off = leave such tracks failed.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="version-mismatch-min-count">Minimum matching mismatches before accepting</label>
|
||||
<input type="number" id="version-mismatch-min-count" min="1" max="20" step="1" value="2" style="width: 100px;" autocomplete="off" data-bwignore="" data-1p-ignore="" data-lpignore="true">
|
||||
<small class="settings-hint">How many quarantined candidates must have failed the <em>same</em> version mismatch before the last-resort acceptance kicks in. Higher = more confirmation that no correct version exists. Only used when the option above is on.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- end Retry Logic body -->
|
||||
|
|
|
|||
|
|
@ -1171,6 +1171,8 @@ async function loadSettingsData() {
|
|||
document.getElementById('retry-next-candidate').checked = settings.post_processing?.retry_next_candidate_on_mismatch !== false;
|
||||
document.getElementById('retry-exhaustive').checked = settings.post_processing?.retry_exhaustive === true;
|
||||
document.getElementById('retries-per-query').value = settings.post_processing?.retries_per_query ?? 5;
|
||||
document.getElementById('accept-version-mismatch-fallback').checked = settings.post_processing?.accept_version_mismatch_fallback === true;
|
||||
document.getElementById('version-mismatch-min-count').value = settings.post_processing?.version_mismatch_min_count ?? 2;
|
||||
// Load service master toggles
|
||||
document.getElementById('embed-spotify').checked = settings.spotify?.embed_tags !== false;
|
||||
document.getElementById('embed-itunes').checked = settings.itunes?.embed_tags !== false;
|
||||
|
|
@ -3002,6 +3004,8 @@ async function saveSettings(quiet = false) {
|
|||
retry_next_candidate_on_mismatch: document.getElementById('retry-next-candidate').checked,
|
||||
retry_exhaustive: document.getElementById('retry-exhaustive').checked,
|
||||
retries_per_query: Math.max(1, parseInt(document.getElementById('retries-per-query').value, 10) || 5),
|
||||
accept_version_mismatch_fallback: document.getElementById('accept-version-mismatch-fallback').checked,
|
||||
version_mismatch_min_count: Math.max(1, parseInt(document.getElementById('version-mismatch-min-count').value, 10) || 2),
|
||||
},
|
||||
library: {
|
||||
music_paths: collectMusicPaths(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue