Files
VoiceStudio/tests/backend/services/test_ffmpeg_utils.py
T
6140f888e1 fix(dub+win): dialect↔cinematic guidance loop + WinError 193 ffmpeg validation (#377)
* 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>
2026-06-12 15:54:47 +05:30

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)