soulsync/tests/imports/test_version_mismatch_fallback.py
dev 37140dff34 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>
2026-06-06 16:44:14 +02:00

207 lines
7.5 KiB
Python

"""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