* fix(dub): break the dialect↔cinematic guidance loop (#372, #373) - Cinematic toggle refuses the pick when no LLM endpoint is configured, pointing at Settings → Credentials → LLM endpoint - backend Fast fallback now syncs the quality toggle to 'fast' - the dialect warning no longer fires alongside the cinematic-no-LLM warning (the pair formed the loop), and both messages point at the LLM endpoint settings instead of each other Fixes #372 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ffmpeg): validate resolved ffmpeg/ffprobe actually runs — fall through on WinError 193 (#360, #361, #362) A corrupt or wrong-arch imageio-ffmpeg download (and WindowsApps alias stubs) passes os.path.isfile/shutil.which but explodes at spawn with '[WinError 193] %1 is not a valid Win32 application', killing transcription with an opaque 500. Every resolution step now probes the candidate with '-version' (cached per process), logs the rejected basename, and falls through to the next source. Fixes #362 Fixes #361 Fixes #360 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
146 lines
4.8 KiB
Python
146 lines
4.8 KiB
Python
"""Tests for backend/services/ffmpeg_utils.py — Phase 1 Wave 3 (issue #76).
|
|
|
|
Covers :func:`resolve_ffprobe`'s env-first / PATH-fallback cascade and the
|
|
legacy-name alias (``FFPROBE_PATH`` still works, ``OMNIVOICE_FFPROBE_PATH``
|
|
takes precedence).
|
|
"""
|
|
import os
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_env(monkeypatch):
|
|
"""Strip both ffprobe env vars before each test so we control the cascade."""
|
|
monkeypatch.delenv("OMNIVOICE_FFPROBE_PATH", raising=False)
|
|
monkeypatch.delenv("FFPROBE_PATH", raising=False)
|
|
|
|
|
|
def test_resolve_ffprobe_prefers_omnivoice_env_var(monkeypatch, tmp_path):
|
|
"""OMNIVOICE_FFPROBE_PATH set to a real file → that path wins."""
|
|
from services import ffmpeg_utils
|
|
|
|
fake = tmp_path / "ffprobe"
|
|
fake.write_text("#!/bin/sh\necho 1\n")
|
|
fake.chmod(0o755)
|
|
monkeypatch.setenv("OMNIVOICE_FFPROBE_PATH", str(fake))
|
|
|
|
assert ffmpeg_utils.resolve_ffprobe() == str(fake)
|
|
|
|
|
|
def test_resolve_ffprobe_falls_back_to_legacy_FFPROBE_PATH(monkeypatch, tmp_path):
|
|
"""OMNIVOICE_FFPROBE_PATH absent but legacy FFPROBE_PATH set → legacy used."""
|
|
from services import ffmpeg_utils
|
|
|
|
fake = tmp_path / "ffprobe-legacy"
|
|
fake.write_text("#!/bin/sh\necho 1\n")
|
|
fake.chmod(0o755)
|
|
monkeypatch.setenv("FFPROBE_PATH", str(fake))
|
|
|
|
assert ffmpeg_utils.resolve_ffprobe() == str(fake)
|
|
|
|
|
|
def test_resolve_ffprobe_omnivoice_path_takes_precedence_over_legacy(
|
|
monkeypatch, tmp_path,
|
|
):
|
|
"""Both env vars set → OMNIVOICE_FFPROBE_PATH wins."""
|
|
from services import ffmpeg_utils
|
|
|
|
canonical = tmp_path / "ffprobe-canonical"
|
|
legacy = tmp_path / "ffprobe-legacy"
|
|
for f in (canonical, legacy):
|
|
f.write_text("#!/bin/sh\necho 1\n")
|
|
f.chmod(0o755)
|
|
|
|
monkeypatch.setenv("OMNIVOICE_FFPROBE_PATH", str(canonical))
|
|
monkeypatch.setenv("FFPROBE_PATH", str(legacy))
|
|
|
|
assert ffmpeg_utils.resolve_ffprobe() == str(canonical)
|
|
|
|
|
|
def test_resolve_ffprobe_falls_back_to_PATH(monkeypatch, tmp_path):
|
|
"""No env var → shutil.which result is returned."""
|
|
from services import ffmpeg_utils
|
|
|
|
# Stub shutil.which inside the module so we do not depend on the host PATH.
|
|
fake = "/fake/path/from/system/ffprobe"
|
|
|
|
def _fake_which(name):
|
|
if name == "ffprobe":
|
|
return fake
|
|
return None
|
|
|
|
monkeypatch.setattr(ffmpeg_utils, "shutil", _ShutilStub(_fake_which))
|
|
# The fake path isn't a real binary — bypass the runnability probe here
|
|
# (rejection behavior has its own test below).
|
|
monkeypatch.setattr(ffmpeg_utils, "_binary_runs", lambda _p: True)
|
|
assert ffmpeg_utils.resolve_ffprobe() == fake
|
|
|
|
|
|
def test_resolve_ffprobe_rejects_non_runnable_candidate(monkeypatch, tmp_path):
|
|
"""#360/#361/#362: a candidate that exists but cannot execute (corrupt /
|
|
wrong-arch binary → WinError 193 on Windows) is skipped and the cascade
|
|
falls through to the next runnable source."""
|
|
from services import ffmpeg_utils
|
|
|
|
broken = tmp_path / "ffprobe-broken"
|
|
broken.write_bytes(b"\x00\x01not-a-binary")
|
|
broken.chmod(0o755)
|
|
good = tmp_path / "ffprobe-good"
|
|
good.write_text("#!/bin/sh\necho 1\n")
|
|
good.chmod(0o755)
|
|
|
|
monkeypatch.setenv("OMNIVOICE_FFPROBE_PATH", str(broken))
|
|
monkeypatch.setattr(
|
|
ffmpeg_utils, "shutil",
|
|
_ShutilStub(lambda name: str(good) if name == "ffprobe" else None),
|
|
)
|
|
ffmpeg_utils._BINARY_OK.clear()
|
|
|
|
assert ffmpeg_utils.resolve_ffprobe() == str(good)
|
|
|
|
|
|
def test_resolve_ffprobe_returns_None_when_nothing_resolves(monkeypatch):
|
|
"""No env, no PATH → returns None (no crash)."""
|
|
from services import ffmpeg_utils
|
|
|
|
monkeypatch.setattr(ffmpeg_utils, "shutil", _ShutilStub(lambda _name: None))
|
|
assert ffmpeg_utils.resolve_ffprobe() is None
|
|
|
|
|
|
def test_resolve_ffprobe_env_var_with_command_name_resolves_via_which(
|
|
monkeypatch, tmp_path,
|
|
):
|
|
"""Legacy shape: env var set to a bare command name (not a path) — resolve
|
|
via shutil.which so older Tauri shells that set FFPROBE_PATH=ffprobe still
|
|
work."""
|
|
from services import ffmpeg_utils
|
|
|
|
fake = tmp_path / "ffprobe"
|
|
fake.write_text("#!/bin/sh\necho 1\n")
|
|
fake.chmod(0o755)
|
|
|
|
def _fake_which(name):
|
|
if name == "ffprobe":
|
|
return str(fake)
|
|
return None
|
|
|
|
monkeypatch.setattr(ffmpeg_utils, "shutil", _ShutilStub(_fake_which))
|
|
monkeypatch.setenv("OMNIVOICE_FFPROBE_PATH", "ffprobe")
|
|
|
|
assert ffmpeg_utils.resolve_ffprobe() == str(fake)
|
|
|
|
|
|
class _ShutilStub:
|
|
"""A tiny shim that mimics the parts of shutil ffmpeg_utils touches.
|
|
|
|
monkeypatching the whole module avoids pulling in real PATH lookups while
|
|
keeping the existing `import shutil` call sites untouched.
|
|
"""
|
|
|
|
def __init__(self, which_impl):
|
|
self._which = which_impl
|
|
|
|
def which(self, name):
|
|
return self._which(name)
|