feat(setup): media tools become invisible — bundled by default, controllable in Settings (#1071)

Most users should never learn what ffmpeg is. The Setup Wizard's SYSTEM
PREFLIGHT stops listing FFmpeg / FFprobe / yt-dlp as user-installed
requirements ("brew install ffmpeg…"): they are internal dependencies the
app provisions for itself. Genuine user facts (OS, RAM, disk, GPU,
network, Python) are untouched.

Backend
- New services/media_tools.py: per-tool status {version, path, origin:
  sidecar|bundled|system|custom}; background acquisition of a pinned,
  SHA-256-verified static ffmpeg+ffprobe build (immutable-commit fetch
  from the same upstream the static-ffmpeg pip package uses — that
  package itself was audited and rejected: mutable raw/main URL, no
  checksums, writes into site-packages); binaries are `-version`-probed
  via the existing _binary_runs before being trusted, installed under
  DATA_DIR (update-surviving, frozen-build-safe), zero new Python deps.
- ffmpeg_utils resolution chain gains the acquired-bundled tier — and
  ffprobe finally has a bundled tier at all (imageio-ffmpeg ships none),
  closing the source-install gap.
- New /media-tools router (loopback-gated, same contract as
  /system/set-env): status, acquire, {tool}/custom-path | use-system |
  restore, ytdlp/update | restore. Overrides persist via the existing
  env.FFMPEG_PATH / env.FFPROBE_PATH prefs convention — one store, no
  competing controls.
- yt-dlp updates: audited in-venv pip/uv upgrade and rejected (venv is
  uv-managed with no pip; yt-dlp is a locked dep, so the updater's
  --inexact drift sync would revert it). Instead the newest wheel —
  verified against PyPI's own sha256 — lands in a DATA_DIR overlay
  prepended to sys.path at startup: survives app updates, works in
  frozen builds, and "Restore tested version" is just deleting the
  overlay. Gallery now runs yt-dlp via `python -m yt_dlp` (module, not
  PATH) so the CLI can never be a user-install task either.
- /setup/preflight drops the three tool rows, carries a media_tools
  verdict, and self-heals: kicks the bundled download in the background
  when no tier resolves (never re-fires after a failure — the wizard's
  card owns Retry). diagnose + the ffmpeg-missing notification now point
  at Settings → Audio tools instead of package managers.

Frontend
- Wizard: new MediaEngineCard — renders NOTHING when the engine is ready,
  a one-line progress while acquiring, and only on failure an actionable
  card (Retry / Use a system copy / Choose file…).
- Settings → Audio tools (new category, System group): FFmpeg + FFprobe
  rows with version, path, origin badge, Use system copy / Choose file… /
  Restore bundled, header-level "Update bundled build"; yt-dlp row with
  Update + Restore tested version (+ restart affordance). Package-manager
  commands appear only as copyable prose, never executed.
- The FFmpeg-path override moved out of Settings → Network (pointer row
  deep-links to Audio tools; no second writer of env.FFMPEG_PATH).
  Notifications gain a settings-tab action type.
- All strings i18n (en + defaultValue), a11y labels on every control.

Tests: 29 new backend (origin classification, checksum/size/probe
rejection, override persistence, overlay update/restore, router gating +
route-shadowing) + preflight contract tests (tool rows gone, verdict
present, auto-acquire fires once); 14 new frontend (wizard hide/progress/
failure-card, Audio tools rows/badges/actions). Route snapshot
regenerated. Docs (macos/linux install, troubleshooting §7b) describe the
new reality in the same commit.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-11 12:22:49 +05:30
committed by GitHub
co-authored by mergetest Claude Fable 5
parent 34c8a33628
commit 5562aa16a7
27 changed files with 2321 additions and 182 deletions
+10 -2
View File
@@ -161,13 +161,18 @@ async def search_youtube(
list. Users are responsible for the licensing of whatever they import.
"""
try:
# yt-dlp is an importable module, never a PATH requirement — run it
# via the interpreter (honors the Settings → Audio tools overlay).
from services.media_tools import ytdlp_invocation
ytdlp_argv, ytdlp_env = ytdlp_invocation()
result = await spawn_subprocess(
"yt-dlp",
*ytdlp_argv,
"--dump-json",
"--remote-components", "ejs:github",
f"ytsearch{max_results}:{query}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=ytdlp_env,
)
stdout, stderr = await result.communicate()
@@ -218,8 +223,10 @@ async def download_youtube_clip(
temp_path = str(VOICE_GALLERY_DIR / f"{voice_id}.%(ext)s")
try:
from services.media_tools import ytdlp_invocation
ytdlp_argv, ytdlp_env = ytdlp_invocation()
cmd = [
"yt-dlp",
*ytdlp_argv,
"--remote-components", "ejs:github",
"-f",
"bestaudio",
@@ -239,6 +246,7 @@ async def download_youtube_clip(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=ytdlp_env,
)
stdout, stderr = await result.communicate()
+85
View File
@@ -0,0 +1,85 @@
"""Media-tools endpoints — the backend for Settings → Audio tools and the
wizard's invisible media-engine self-heal.
Every route is loopback-gated: ``custom-path`` / ``use-system`` point the app
at an arbitrary executable (an RCE primitive if remote-reachable), and the
rest mutate local state. Same contract as ``/system/set-env``.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.dependencies import require_loopback
logger = logging.getLogger("omnivoice.api")
router = APIRouter(dependencies=[Depends(require_loopback)])
class CustomPathRequest(BaseModel):
path: str
def _svc():
# Late import so a service-level failure surfaces as a 500 with detail,
# not an app-boot failure.
from services import media_tools
return media_tools
@router.get("/media-tools/status")
def media_tools_status():
"""Per-tool {ok, path, version, origin} + background-op states."""
return _svc().status()
@router.post("/media-tools/acquire")
def media_tools_acquire():
"""(Re-)fetch the pinned, checksummed static ffmpeg/ffprobe build in the
background. Idempotent; poll /media-tools/status for progress."""
return _svc().acquire_bundled()
# Literal ytdlp routes MUST register before the parametrized {tool} routes —
# FastAPI matches in declaration order, and `/media-tools/{tool}/restore`
# would otherwise swallow `/media-tools/ytdlp/restore` into a 400.
@router.post("/media-tools/ytdlp/update")
def media_tools_ytdlp_update():
"""Fetch the newest yt-dlp wheel (sha256-verified against PyPI metadata)
into the update-surviving overlay. Applies on next backend start."""
return _svc().update_ytdlp()
@router.post("/media-tools/ytdlp/restore")
def media_tools_ytdlp_restore():
"""Drop the overlay — the app-tested, locked yt-dlp takes over on next
start. Always safe (the locked install is never modified)."""
return _svc().restore_ytdlp()
@router.post("/media-tools/{tool}/custom-path")
def media_tools_custom_path(tool: str, body: CustomPathRequest):
try:
return _svc().set_custom_path(tool, body.path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/media-tools/{tool}/use-system")
def media_tools_use_system(tool: str):
try:
return _svc().use_system(tool)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except LookupError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/media-tools/{tool}/restore")
def media_tools_restore(tool: str):
try:
return _svc().restore_bundled(tool)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
+16 -54
View File
@@ -3,7 +3,9 @@
Extracted from the monolithic ``setup.py``.
- ``GET /setup/status`` — missing-model gate for boot screen
- ``GET /setup/preflight`` — system health check (OS, RAM, GPU, ffmpeg…)
- ``GET /setup/preflight`` — system health check (OS, RAM, disk, GPU, network —
genuine user facts only; the media engine (ffmpeg/ffprobe/yt-dlp) is an
internal concern that self-heals via ``services.media_tools``)
- ``POST /setup/warmup`` — background model pre-load
"""
from __future__ import annotations
@@ -12,7 +14,6 @@ import asyncio
import logging
import os
import platform as _platform
import shutil as _shutil
import sys
from fastapi import APIRouter
@@ -280,59 +281,19 @@ def preflight():
f"Fix write permissions on {cache} or point HF_HOME elsewhere.",
})
# ── FFmpeg
ffmpeg_path = None
# ── Media engine (ffmpeg/ffprobe/yt-dlp) — deliberately NOT a check row.
# These are internal dependencies the app provisions for itself, not user
# facts: when the resolution chain has no tier at all, preflight kicks the
# bundled acquisition in the background and the wizard shows a quiet
# progress line (a failure card only if that fails — with Retry / use a
# system copy). yt-dlp is an importable locked module and never appears.
# Power users manage all three in Settings → Audio tools.
media_tools = None
try:
from services.ffmpeg_utils import find_ffmpeg
ffmpeg_path = find_ffmpeg()
except Exception as e:
checks.append({
"id": "ffmpeg", "label": "FFmpeg", "status": "fail",
"detail": str(e)[:200],
"fix": "Install ffmpeg via your package manager "
"(brew install ffmpeg / apt install ffmpeg / choco install ffmpeg).",
})
else:
checks.append({
"id": "ffmpeg", "label": "FFmpeg", "status": "pass",
"detail": ffmpeg_path, "fix": None,
})
# ── FFprobe
ffprobe_path = None
try:
from services.ffmpeg_utils import find_ffprobe
ffprobe_path = find_ffprobe()
except Exception:
pass
if ffprobe_path:
checks.append({
"id": "ffprobe", "label": "FFprobe", "status": "pass",
"detail": ffprobe_path, "fix": None,
})
else:
checks.append({
"id": "ffprobe", "label": "FFprobe", "status": "warn",
"detail": "Not bundled alongside ffmpeg.",
"fix": "File-probe endpoint (/tools/probe) will 501. "
"Install system ffmpeg (includes ffprobe) to enable it.",
})
# ── yt-dlp
yt_dlp_path = _shutil.which("yt-dlp")
if yt_dlp_path:
rc_ytv, yt_ver = _run_cmd([yt_dlp_path, "--version"], timeout=3.0)
yt_version = yt_ver.strip() if rc_ytv == 0 else "unknown"
checks.append({
"id": "yt-dlp", "label": "yt-dlp", "status": "pass",
"detail": f"{yt_dlp_path} (v{yt_version})", "fix": None,
})
else:
checks.append({
"id": "yt-dlp", "label": "yt-dlp", "status": "warn",
"detail": "Not found in system PATH.",
"fix": "YouTube clip downloads in Voice Gallery will fail. Download the standalone binary from https://github.com/yt-dlp/yt-dlp/releases and place it in your PATH.",
})
from services.media_tools import summary as _media_summary
media_tools = _media_summary(auto_acquire=True)
except Exception as exc: # never break preflight on the media engine
logger.warning("preflight media_tools summary failed: %s", exc)
# ── GPU
gpu = _detect_gpu()
@@ -492,6 +453,7 @@ def preflight():
"disk_free_gb": round(free, 1),
},
"gpu_routing": gpu_routing,
"media_tools": media_tools,
}
+8 -6
View File
@@ -629,15 +629,17 @@ def system_notifications():
notes.append({
"id": "ffmpeg-missing",
"level": "error",
"title": "ffmpeg not found",
"title": "Media engine unavailable",
"message": (
"Video processing, audio conversion, and dubbing require ffmpeg. "
"Install it with: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)."
"Video processing, audio conversion, and dubbing need the "
"media engine (ffmpeg), which the app normally provisions "
"itself. Open Settings > Audio tools and press Restore "
"bundled to re-download it, or point it at a system copy."
),
"action": {
"label": "Install guide",
"type": "link",
"target": "https://ffmpeg.org/download.html",
"label": "Open Audio tools",
"type": "settings-tab",
"target": "audio-tools",
},
})
+5
View File
@@ -164,6 +164,11 @@ class PreflightResponse(BaseModel):
# Explicit field (PreflightResponse has no extra="allow") so the verdict
# survives serialization instead of being silently dropped.
gpu_routing: GpuRouting | None = None
# Media-engine verdict (ffmpeg/ffprobe) — NOT a check row: an internal
# dependency the app provisions for itself. Shape: {ready, acquire:
# {state, progress, error}}. The wizard renders a quiet progress line /
# failure card from it instead of "install ffmpeg" system requirements.
media_tools: dict | None = None
class InstallModelRequest(BaseModel):
+24 -8
View File
@@ -88,17 +88,33 @@ def _check_device() -> dict:
def _check_ffmpeg() -> dict:
"""Media engine (ffmpeg + ffprobe) — an internal dependency the app
bundles/acquires itself, so a failure here means the self-heal also has
nothing to work with (and the hint says where the controls live)."""
ffmpeg = ffprobe = None
try:
from services.ffmpeg_utils import find_ffmpeg
path = find_ffmpeg()
from services.ffmpeg_utils import find_ffmpeg, find_ffprobe
ffmpeg = find_ffmpeg()
ffprobe = find_ffprobe()
except Exception:
path = None
if path:
return _check("ffmpeg", "ffmpeg", OK, str(path))
pass
if ffmpeg and ffprobe:
return _check("ffmpeg", "Media engine (ffmpeg)", OK,
f"ffmpeg: {ffmpeg}; ffprobe: {ffprobe}")
if ffmpeg:
return _check(
"ffmpeg", "Media engine (ffmpeg)", WARN,
f"ffmpeg: {ffmpeg}; ffprobe missing",
"Media probing (Smart Fit, file inspection) is degraded. Open "
"Settings > Audio tools and press Restore bundled to fetch the "
"app's own ffprobe, or point it at a system copy there.",
)
return _check(
"ffmpeg", "ffmpeg", FAIL,
"not found on PATH or FFMPEG_PATH",
"Dubbing and audio conversion need ffmpeg: brew install ffmpeg (macOS), apt install ffmpeg (Linux), or set the path in Settings > General.",
"ffmpeg", "Media engine (ffmpeg)", FAIL,
"no runnable ffmpeg in any tier (sidecar, bundled, system, custom)",
"Dubbing and audio conversion are unavailable. The app normally "
"provisions ffmpeg itself — open Settings > Audio tools and press "
"Restore bundled (needs network once), or choose a system copy there.",
)
+14
View File
@@ -195,6 +195,18 @@ try:
except Exception:
pass # prefs.json missing or broken — fine on first run
# ── Activate the yt-dlp user-update overlay (Settings → Audio tools) ──────
# Must run before anything imports yt_dlp so a user-updated version (stored
# under DATA_DIR, surviving app updates and uv drift syncs) wins over the
# locked wheel. Best-effort: a broken overlay must never block startup.
try:
from services.media_tools import activate_ytdlp_overlay
activate_ytdlp_overlay()
except Exception:
# Best-effort by design: a broken/corrupt overlay must never block
# startup — the locked wheel on sys.path is the fallback.
pass
warnings.filterwarnings("ignore", category=UserWarning)
torchaudio.set_audio_backend("soundfile")
@@ -375,6 +387,7 @@ from api.routers import (
longform_jobs,
pronunciation, # Expressive-TTS Spec 01: user pronunciation dictionary
settings as settings_router, # Phase 1 AUTH-03: HF token save/clear/state
media_tools as media_tools_router, # Audio tools: ffmpeg/ffprobe/yt-dlp management
)
from utils import hf_progress
@@ -1045,6 +1058,7 @@ app.include_router(audiobook.router)
app.include_router(longform_jobs.router)
app.include_router(pronunciation.router) # Expressive-TTS Spec 01: pronunciation dictionary
app.include_router(settings_router.router) # Phase 1 AUTH-03 endpoints
app.include_router(media_tools_router.router) # Settings → Audio tools + wizard media-engine self-heal
from api.routers import mcp_bindings as _mcp_bindings_router # noqa: E402
app.include_router(_mcp_bindings_router.router) # Wave 2.2 per-agent voice bindings
+39 -5
View File
@@ -57,9 +57,13 @@ def find_ffmpeg():
"""Locate an ffmpeg binary.
Resolution order:
1. ``FFMPEG_PATH`` env var (set by Tauri when a sidecar is bundled).
1. ``FFMPEG_PATH`` env var (set by Tauri when a sidecar is bundled, or
by the user's Settings → Audio tools override via prefs).
2. ``imageio-ffmpeg`` pip package (ships a static binary per platform).
3. Common system paths / ``PATH``.
3. OmniVoice-acquired static bundle (``services.media_tools``) — the
checksummed build the app downloads itself when nothing else
resolves; the only bundled tier that also ships ffprobe.
4. Common system paths / ``PATH``.
Returns the path string, or ``None`` if nothing found.
"""
@@ -78,7 +82,13 @@ def find_ffmpeg():
logger.debug("imageio_ffmpeg binary not usable at %s", candidate)
except Exception as e:
logger.debug("imageio_ffmpeg unavailable: %s", e)
# 3. Well-known system paths + PATH lookup
# 3. OmniVoice-acquired bundled static binary (never downloads here —
# acquisition is media_tools' background job; this only picks up an
# already-installed build).
candidate = _acquired_bundled("ffmpeg")
if candidate:
return candidate
# 4. Well-known system paths + PATH lookup
common = [
"/opt/homebrew/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
@@ -95,6 +105,22 @@ def find_ffmpeg():
return None
def _acquired_bundled(tool: str) -> "str | None":
"""Already-acquired media_tools static binary, validated — or None.
Lazy import: media_tools imports from this module at its top, so this
module must only reach back at call time (no cycle).
"""
try:
from services.media_tools import bundled_tool_path
candidate = bundled_tool_path(tool)
if candidate and _binary_runs(candidate):
return candidate
except Exception as e:
logger.debug("media_tools bundled %s unavailable: %s", tool, e)
return None
def resolve_ffprobe() -> str | None:
"""Resolve an ffprobe binary path.
@@ -103,8 +129,12 @@ def resolve_ffprobe() -> str | None:
injected by Tauri pointing at the bundled sidecar (e.g.
``/usr/lib/omnivoice-studio/bin/ffprobe`` on .deb installs).
2. ``FFPROBE_PATH`` env var — legacy alias kept for backward
compatibility with older Tauri shells / dev environments.
3. ``shutil.which("ffprobe")`` — system ``PATH`` fallback.
compatibility with older Tauri shells / dev environments; also the
key Settings → Audio tools persists a user override under.
3. OmniVoice-acquired static bundle (``services.media_tools``) —
imageio-ffmpeg ships no ffprobe, so this is the bundled tier that
closes the source-install gap.
4. ``shutil.which("ffprobe")`` — system ``PATH`` fallback.
Returns the resolved path string, or ``None`` if nothing found. Callers
that need a hard failure should use :func:`find_ffprobe` instead.
@@ -121,6 +151,10 @@ def resolve_ffprobe() -> str | None:
if resolved and _binary_runs(resolved):
return resolved
bundled = _acquired_bundled("ffprobe")
if bundled:
return bundled
system_probe = shutil.which("ffprobe")
if system_probe and _binary_runs(system_probe):
return system_probe
+637
View File
@@ -0,0 +1,637 @@
"""Media tools — ffmpeg / ffprobe / yt-dlp as an invisible internal concern.
Most users should never learn what ffmpeg is. This module makes the media
engine self-contained: it reports where each tool comes from, acquires a
bundled static build in the background when no tier of the resolution chain
(``services.ffmpeg_utils``) resolves, and gives power users explicit
control (custom path / system copy / restore bundled) through the
``/media-tools`` router — persisted via the same ``env.FFMPEG_PATH`` /
``env.FFPROBE_PATH`` prefs convention the Settings env writer already uses,
so there is exactly one override mechanism.
Bundled-binary source (decision record)
---------------------------------------
The gap: ``imageio-ffmpeg`` (already a locked dep) ships a static *ffmpeg*
inside its platform wheels but **no ffprobe**, so source installs without a
system ffmpeg lose ``/tools/probe``, Smart-Fit duration checks, and VFR
detection. Two options were audited:
(a) the ``static-ffmpeg`` pip package — ships BOTH binaries per platform via
lazy download. **Rejected**: it downloads from a *mutable* URL
(``.../ffmpeg_bins/raw/main/...`` — the branch tip, not a pinned
release), performs **no checksum validation**, extracts into its own
``site-packages`` directory (read-only / non-existent in the frozen
PyInstaller backend), and drags in ``requests``/``filelock``/``progress``
plus a stdout spinner.
(b) fetch the same upstream static builds ourselves, pinned to an immutable
commit. **Chosen**: we download the platform zip from
``github.com/zackees/ffmpeg_bins`` at a pinned commit SHA (immutable
URL), verify size + SHA-256 against constants recorded from that
commit's git-LFS pointers, extract only ffmpeg/ffprobe into a
user-writable, update-surviving dir under ``DATA_DIR``, and trust a
binary only after the existing ``_binary_runs`` ``-version`` probe.
Stdlib-only (urllib honors HTTP(S)_PROXY), identical behavior on
macOS (arm64 + x86_64), Windows x64, and Linux (x64 + arm64), and zero
new Python dependencies.
yt-dlp updates (decision record)
--------------------------------
yt-dlp is an importable locked dep — never a user-installed requirement.
But site support rots faster than app releases, so Settings offers a
user-triggered "Update". A plain in-venv upgrade was audited and rejected:
the app venv is uv-managed (no pip module), and the updater's drift sync
(#1029/#1030, ``uv sync --frozen --inexact``) preserves only packages *not*
in the lockfile — yt-dlp IS locked, so an in-venv upgrade would be silently
reverted on the next app update, and the frozen build has no installer at
all. Instead we install the new wheel (pure-python, no required deps —
matching the plain ``yt-dlp`` spec pinned in pyproject) into an **overlay
directory** under ``DATA_DIR`` — SHA-256-verified against PyPI's own
metadata — and prepend it to ``sys.path`` at startup. It survives app
updates and drift syncs, works identically in source and frozen builds, and
"Restore tested version" is simply deleting the overlay: the locked wheel
underneath was never touched.
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import zipfile
from core.config import DATA_DIR
from core import prefs
from services.ffmpeg_utils import _binary_runs, _BINARY_OK
logger = logging.getLogger("omnivoice.media_tools")
# ── Pinned bundled build ────────────────────────────────────────────────────
# Immutable commit of github.com/zackees/ffmpeg_bins (the upstream the
# static-ffmpeg pip package also consumes, but pinned + checksummed here).
# SHA-256 values are the git-LFS oids of the v8.0 platform zips at this
# commit, independently verified by downloading and hashing.
_FFBIN_REPO = "zackees/ffmpeg_bins"
_FFBIN_COMMIT = "df95abcb0ce6efff710dda5ef28a2f6f1dc21493" # 2026-01-16
_FFBIN_TREE = "v8.0"
#: platform key → (sha256, size in bytes) of the zip at the pinned commit.
_FFBIN_SHA256 = {
"darwin": ("70fd5b21cb37b6ea97c8b584cf76b3cc6a90179831c9c269811b9716c28605fb", 53079896),
"darwin_arm64": ("b2da44a8169c4d09a97db996250690c3346f72e4795521d23d3dbb1e72421207", 41925556),
"linux": ("ca75b05e887c7a97676632f673031875847be83daa9794298fed9cef8cac14ad", 142008975),
"linux_arm64": ("e03efe471c03b999f10988d5db62ae3bd94837463291b3c7755528b100e97d6f", 131816005),
"win32": ("92662c2241e93fe71b3f3a01e94a0b0dc8cfad726019f96b83bc109ce44c5d0b", 72065209),
}
_PYPI_YTDLP_URL = "https://pypi.org/pypi/yt-dlp/json"
_DOWNLOAD_TIMEOUT_S = 30 # per-read socket timeout; downloads stream in chunks
_CHUNK = 256 * 1024
#: tool → env keys honored by the resolution chain, in precedence order.
_ENV_KEYS = {
"ffmpeg": ("FFMPEG_PATH",),
"ffprobe": ("OMNIVOICE_FFPROBE_PATH", "FFPROBE_PATH"),
}
#: tool → the env key the *user override* is persisted under (prefs `env.<KEY>`).
_PREF_ENV_KEY = {"ffmpeg": "FFMPEG_PATH", "ffprobe": "FFPROBE_PATH"}
TOOLS = ("ffmpeg", "ffprobe")
# ── Background-operation state (poll via status()) ─────────────────────────
_lock = threading.Lock()
_ops: dict[str, dict] = {
"acquire": {"state": "idle", "progress": 0.0, "error": None},
"ytdlp_update": {"state": "idle", "progress": 0.0, "error": None, "version": None},
}
_version_cache: dict[str, str] = {}
def _set_op(op: str, **fields) -> None:
with _lock:
_ops[op].update(fields)
def _op_snapshot() -> dict:
with _lock:
return {k: dict(v) for k, v in _ops.items()}
# ── Platform / paths ────────────────────────────────────────────────────────
def _platform_key() -> str:
import platform as _p
is_arm = _p.machine().lower() in ("arm64", "aarch64")
if sys.platform == "win32":
return "win32"
if sys.platform == "darwin":
return "darwin_arm64" if is_arm else "darwin"
if sys.platform.startswith("linux"):
return "linux_arm64" if is_arm else "linux"
return sys.platform
def media_tools_dir() -> str:
"""User-writable root for acquired binaries + the yt-dlp overlay.
Lives in DATA_DIR so it survives app updates (the app bundle / venv are
replaced wholesale on update; DATA_DIR is user state) and is writable in
frozen installs.
"""
return os.path.join(DATA_DIR, "media_tools")
def bundled_dir() -> str:
# Versioned by the pin so a future pin bump lands in a fresh dir and
# "Update" is a plain re-acquire — no in-place mutation of a live binary.
return os.path.join(media_tools_dir(), f"ffbin-{_FFBIN_COMMIT[:12]}", _platform_key())
def _exe(name: str) -> str:
return f"{name}.exe" if sys.platform == "win32" else name
def bundled_tool_path(tool: str) -> str | None:
"""Path of an already-acquired bundled binary, or None. Never downloads."""
p = os.path.join(bundled_dir(), _exe(tool))
return p if os.path.isfile(p) else None
def _bundle_url() -> str:
# github.com/<repo>/raw/<commit> redirects to the LFS media host and
# serves the real zip (raw.githubusercontent.com would return the
# 133-byte LFS pointer instead).
return f"https://github.com/{_FFBIN_REPO}/raw/{_FFBIN_COMMIT}/{_FFBIN_TREE}/{_platform_key()}.zip"
def _expected_bundle() -> tuple[str, str, int]:
"""(url, sha256, size) for this platform. Raises on unsupported platform."""
key = _platform_key()
if key not in _FFBIN_SHA256:
raise RuntimeError(f"no bundled media-engine build for platform '{key}'")
sha, size = _FFBIN_SHA256[key]
return _bundle_url(), sha, size
# ── Download helper ─────────────────────────────────────────────────────────
def _download(url: str, dest_path: str, expected_sha256: str,
expected_size: int | None, op: str) -> None:
"""Stream *url* to *dest_path*, hashing on the fly; raise on mismatch.
Progress is reported into ``_ops[op]["progress"]``. urllib honors the
HTTP(S)_PROXY env vars, so restricted-network users' proxy settings apply.
"""
import urllib.request
if not url.startswith("https://"):
raise ValueError("media-tools downloads must be https")
req = urllib.request.Request(url, headers={"User-Agent": "OmniVoice-Studio"})
hasher = hashlib.sha256()
done = 0
with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT_S) as resp:
total = expected_size or int(resp.headers.get("Content-Length") or 0)
with open(dest_path, "wb") as f:
while True:
chunk = resp.read(_CHUNK)
if not chunk:
break
f.write(chunk)
hasher.update(chunk)
done += len(chunk)
if total:
_set_op(op, progress=min(done / total, 1.0))
digest = hasher.hexdigest()
if expected_size is not None and done != expected_size:
raise RuntimeError(f"download size mismatch: got {done}, expected {expected_size}")
if digest != expected_sha256:
raise RuntimeError("download checksum mismatch — refusing to install")
# ── Bundled acquisition ─────────────────────────────────────────────────────
def acquire_bundled(wait: bool = False) -> dict:
"""Fetch + verify + install the pinned static ffmpeg/ffprobe build.
Idempotent: a no-op when the binaries are already present and runnable,
or when an acquisition is already running. Runs in a daemon thread so it
never blocks the caller (``wait=True`` is for tests/CLI use).
Returns the op-state snapshot.
"""
with _lock:
if _ops["acquire"]["state"] == "running":
return dict(_ops["acquire"])
_ops["acquire"].update(state="running", progress=0.0, error=None)
if all(bundled_tool_path(t) and _binary_runs(bundled_tool_path(t)) for t in TOOLS):
_set_op("acquire", state="done", progress=1.0)
return _op_snapshot()["acquire"]
def _worker():
try:
_do_acquire()
_set_op("acquire", state="done", progress=1.0, error=None)
logger.info("media-tools: bundled ffmpeg/ffprobe installed at %s", bundled_dir())
except Exception as e:
logger.warning("media-tools: bundled acquisition failed: %s", e)
_set_op("acquire", state="error", error=str(e)[:300])
if wait:
_worker()
else:
threading.Thread(target=_worker, name="media-tools-acquire", daemon=True).start()
return _op_snapshot()["acquire"]
def _do_acquire() -> None:
url, sha, size = _expected_bundle()
target = bundled_dir()
os.makedirs(os.path.dirname(target), exist_ok=True)
with tempfile.TemporaryDirectory(dir=os.path.dirname(target)) as tmp:
zip_path = os.path.join(tmp, "bundle.zip")
_download(url, zip_path, sha, size, op="acquire")
# Extract only the two binaries, flattened by basename — layout-agnostic
# and immune to zip-slip (we never honor archive paths).
wanted = {_exe(t): t for t in TOOLS}
staged = os.path.join(tmp, "staged")
os.makedirs(staged, exist_ok=True)
found: dict[str, str] = {}
with zipfile.ZipFile(zip_path) as zf:
for member in zf.infolist():
base = os.path.basename(member.filename)
if base in wanted and not member.is_dir():
out = os.path.join(staged, base)
with zf.open(member) as src, open(out, "wb") as dst:
shutil.copyfileobj(src, dst)
# Owner-only rwx — the backend process is the sole consumer
# of these binaries (least privilege; py/overly-permissive-file).
os.chmod(out, 0o700)
found[base] = out
missing = set(wanted) - set(found)
if missing:
raise RuntimeError(f"bundle is missing {sorted(missing)}")
# Probe BEFORE trusting — a corrupt / wrong-arch binary must never
# be installed (same contract as ffmpeg_utils._binary_runs at
# resolution time, applied at install time).
for base, path in found.items():
_BINARY_OK.pop(path, None)
if not _binary_runs(path):
raise RuntimeError(f"downloaded {base} failed its -version probe")
# Finalize: swap the staged dir into place.
if os.path.isdir(target):
shutil.rmtree(target, ignore_errors=True)
os.replace(staged, target)
# Resolution caches may hold negative verdicts for the old paths.
for t in TOOLS:
p = os.path.join(target, _exe(t))
_BINARY_OK.pop(p, None)
_version_cache.pop(p, None)
# ── Status / origin classification ─────────────────────────────────────────
def _tool_version(path: str) -> str | None:
cached = _version_cache.get(path)
if cached:
return cached
try:
out = subprocess.run(
[path, "-version"], capture_output=True, text=True, timeout=10, check=False,
).stdout
m = re.match(r"^(?:ffmpeg|ffprobe) version (\S+)", out or "")
if m:
_version_cache[path] = m.group(1)
return m.group(1)
except Exception as e:
logger.debug("version probe failed for %s: %s", os.path.basename(path), e)
return None
def _imageio_pkg_dir() -> str | None:
try:
import imageio_ffmpeg
return os.path.dirname(os.path.abspath(imageio_ffmpeg.__file__))
except Exception:
return None
def _classify_origin(tool: str, path: str) -> str:
"""sidecar | bundled | system | custom — where the resolved binary lives."""
rp = os.path.realpath(path)
for root in filter(None, (media_tools_dir(), _imageio_pkg_dir())):
if rp.startswith(os.path.realpath(root) + os.sep):
return "bundled"
for key in _ENV_KEYS[tool]:
v = os.environ.get(key)
if not v:
continue
if v == path or os.path.realpath(v) == rp or shutil.which(v) == path:
# The same env var serves two masters: the Tauri sidecar injects
# it at spawn; a user override persists it via prefs `env.<KEY>`.
return "custom" if prefs.get(f"env.{key}") else "sidecar"
return "system"
def _resolve(tool: str) -> str | None:
from services import ffmpeg_utils
if tool == "ffmpeg":
return ffmpeg_utils.find_ffmpeg()
return ffmpeg_utils.find_ffprobe()
def _ytdlp_status() -> dict:
"""yt-dlp is a python module, not a binary — status reads its version
without paying the full package import."""
info: dict = {"tool": "yt-dlp", "ok": False, "path": None, "version": None,
"origin": "bundled", "overlay_version": None,
"baseline_version": prefs.get("media_tools.ytdlp_baseline")}
try:
import importlib.util
spec = importlib.util.find_spec("yt_dlp")
origin = getattr(spec, "origin", None)
if origin:
pkg_dir = os.path.dirname(origin)
info["path"] = pkg_dir
info["ok"] = True
info["version"] = _read_ytdlp_version(pkg_dir)
if os.path.realpath(pkg_dir).startswith(
os.path.realpath(_ytdlp_overlay_dir()) + os.sep):
info["origin"] = "custom"
except Exception as e:
logger.debug("yt_dlp spec lookup failed: %s", e)
ov = _read_ytdlp_version(os.path.join(_ytdlp_overlay_dir(), "yt_dlp"))
info["overlay_version"] = ov
return info
def _read_ytdlp_version(pkg_dir: str) -> str | None:
try:
with open(os.path.join(pkg_dir, "version.py"), encoding="utf-8") as f:
m = re.search(r"__version__\s*=\s*['\"]([^'\"]+)['\"]", f.read())
return m.group(1) if m else None
except OSError:
return None
def status() -> dict:
"""Full media-tools report: per-tool {ok, path, version, origin} + op states."""
tools = {}
for tool in TOOLS:
path = _resolve(tool)
tools[tool] = {
"tool": tool,
"ok": bool(path),
"path": path,
"version": _tool_version(path) if path else None,
"origin": _classify_origin(tool, path) if path else None,
}
tools["ytdlp"] = _ytdlp_status()
ops = _op_snapshot()
return {
"ready": tools["ffmpeg"]["ok"] and tools["ffprobe"]["ok"],
"tools": tools,
"ops": ops,
"platform_key": _platform_key(),
}
def summary(auto_acquire: bool = False) -> dict:
"""Small preflight-embeddable verdict. With ``auto_acquire``, kicks off
the bundled download in the background when nothing resolves (first-run
self-heal) — but never re-fires after a failed attempt (the wizard's
failure card owns the Retry)."""
st = status()
op = st["ops"]["acquire"]
if auto_acquire and not st["ready"] and op["state"] == "idle":
op = acquire_bundled()
return {
"ready": st["ready"],
"acquire": {"state": op["state"], "progress": op["progress"], "error": op["error"]},
}
# ── User overrides (persisted via the existing env-prefs convention) ───────
def _validate_binary_path(path: str) -> None:
# Same defense-in-depth as /system/set-env: no control chars, must be an
# existing file, and must actually run before we trust it.
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in path):
raise ValueError("Invalid path: control characters are not allowed")
if not os.path.isfile(path):
raise ValueError(f"File not found: {path}")
_BINARY_OK.pop(path, None)
if not _binary_runs(path):
raise ValueError(
"That file exists but does not run as a media tool "
"(its `-version` probe failed) — wrong architecture or not executable."
)
def set_custom_path(tool: str, path: str) -> dict:
"""Pin *tool* to an explicit binary. Persists via prefs `env.<KEY>` —
the exact mechanism /system/set-env uses, so there is one override store."""
if tool not in TOOLS:
raise ValueError(f"unknown tool '{tool}'")
path = path.strip()
_validate_binary_path(path)
key = _PREF_ENV_KEY[tool]
os.environ[key] = path
prefs.set_(f"env.{key}", path)
_version_cache.pop(path, None)
logger.info("media-tools: %s pinned to user path (origin=%s)",
tool, _classify_origin(tool, path))
return status()["tools"][tool]
def use_system(tool: str) -> dict:
"""Auto-detect a system-installed copy and pin it."""
if tool not in TOOLS:
raise ValueError(f"unknown tool '{tool}'")
candidate = _detect_system(tool)
if not candidate:
raise LookupError(
f"No system {tool} found on PATH or in the usual install locations."
)
return set_custom_path(tool, candidate)
def _detect_system(tool: str) -> str | None:
roots = [r for r in (media_tools_dir(), _imageio_pkg_dir()) if r]
def _is_bundled(p: str) -> bool:
rp = os.path.realpath(p)
return any(rp.startswith(os.path.realpath(r) + os.sep) for r in roots)
candidates = [
f"/opt/homebrew/bin/{tool}",
f"/usr/local/bin/{tool}",
f"/usr/bin/{tool}",
f"C:\\ffmpeg\\bin\\{tool}.exe",
f"C:\\Program Files\\ffmpeg\\bin\\{tool}.exe",
tool,
]
for c in candidates:
resolved = shutil.which(c)
if resolved and not _is_bundled(resolved) and _binary_runs(resolved):
return resolved
return None
def restore_bundled(tool: str) -> dict:
"""Clear the user override so the chain resolves sidecar → bundled →
system again; kick acquisition if no bundled build is present. Always safe."""
if tool not in TOOLS:
raise ValueError(f"unknown tool '{tool}'")
for key in _ENV_KEYS[tool]:
if prefs.get(f"env.{key}"):
prefs.delete(f"env.{key}")
os.environ.pop(key, None)
_version_cache.clear()
if not (bundled_tool_path(tool) and _binary_runs(bundled_tool_path(tool))):
# No local bundled build to fall back to (imageio may still cover
# ffmpeg) — fetch ours in the background so the revert lands somewhere.
if not _resolve(tool):
acquire_bundled()
return status()["tools"][tool]
# ── yt-dlp overlay ──────────────────────────────────────────────────────────
def _ytdlp_overlay_dir() -> str:
return os.path.join(media_tools_dir(), "ytdlp_overlay")
def activate_ytdlp_overlay() -> bool:
"""Prepend the user-updated yt-dlp overlay to sys.path. Called once at
backend startup, before anything imports yt_dlp."""
overlay = _ytdlp_overlay_dir()
if os.path.isdir(os.path.join(overlay, "yt_dlp")) and overlay not in sys.path:
sys.path.insert(0, overlay)
logger.info("media-tools: yt-dlp overlay active (%s)",
_read_ytdlp_version(os.path.join(overlay, "yt_dlp")) or "?")
return True
return False
def _fetch_pypi_ytdlp() -> tuple[str, str, str]:
"""(version, wheel_url, sha256) of the latest yt-dlp wheel on PyPI."""
import json
import urllib.request
req = urllib.request.Request(_PYPI_YTDLP_URL, headers={"User-Agent": "OmniVoice-Studio"})
with urllib.request.urlopen(req, timeout=_DOWNLOAD_TIMEOUT_S) as resp:
meta = json.load(resp)
version = meta["info"]["version"]
for artifact in meta.get("urls", []):
if artifact.get("packagetype") == "bdist_wheel" and \
artifact["filename"].endswith("py3-none-any.whl"):
return version, artifact["url"], artifact["digests"]["sha256"]
raise RuntimeError(f"no universal wheel found for yt-dlp {version}")
def update_ytdlp(wait: bool = False) -> dict:
"""Install the newest yt-dlp into the overlay dir (background thread).
The wheel is verified against PyPI's own sha256 digest before a single
byte lands in the overlay; the swap is atomic (staged dir + os.replace).
Takes effect on the next backend start (the running process already
imported the old module) — the UI shows the restart affordance.
"""
with _lock:
if _ops["ytdlp_update"]["state"] == "running":
return dict(_ops["ytdlp_update"])
_ops["ytdlp_update"].update(state="running", progress=0.0, error=None, version=None)
def _worker():
try:
version = _do_update_ytdlp()
_set_op("ytdlp_update", state="done", progress=1.0, version=version)
logger.info("media-tools: yt-dlp overlay updated to %s", version)
except Exception as e:
logger.warning("media-tools: yt-dlp update failed: %s", e)
_set_op("ytdlp_update", state="error", error=str(e)[:300])
if wait:
_worker()
else:
threading.Thread(target=_worker, name="media-tools-ytdlp", daemon=True).start()
return _op_snapshot()["ytdlp_update"]
def _do_update_ytdlp() -> str:
version, url, sha = _fetch_pypi_ytdlp()
# Record the locked ("tested") version once, before the first overlay
# ever activates — that's what "Restore tested version" reverts to.
if prefs.get("media_tools.ytdlp_baseline") is None:
current = _ytdlp_status()
if current["origin"] == "bundled" and current["version"]:
prefs.set_("media_tools.ytdlp_baseline", current["version"])
overlay = _ytdlp_overlay_dir()
os.makedirs(media_tools_dir(), exist_ok=True)
with tempfile.TemporaryDirectory(dir=media_tools_dir()) as tmp:
whl = os.path.join(tmp, "yt_dlp.whl")
_download(url, whl, sha, None, op="ytdlp_update")
staged = os.path.join(tmp, "staged")
with zipfile.ZipFile(whl) as zf:
for member in zf.infolist():
name = member.filename
# Only the package itself; wheels carry no absolute paths but
# guard against traversal anyway.
if not name.startswith("yt_dlp/") or ".." in name:
continue
zf.extract(member, staged)
got = _read_ytdlp_version(os.path.join(staged, "yt_dlp"))
if not got:
raise RuntimeError("downloaded wheel has no readable yt_dlp version")
if os.path.isdir(overlay):
shutil.rmtree(overlay, ignore_errors=True)
os.replace(staged, overlay)
return version
def ytdlp_invocation() -> "tuple[list[str], dict[str, str] | None]":
"""(argv prefix, env-or-None) for running the yt-dlp CLI.
Prefers ``[sys.executable, -m, yt_dlp]`` so the CLI always matches the
module the app ships (or the user's overlay — propagated via PYTHONPATH),
with no PATH requirement: yt-dlp is never something the user installs.
Frozen builds can't re-invoke an interpreter, so they keep the historical
PATH lookup as a last resort.
"""
if not getattr(sys, "frozen", False):
try:
import importlib.util
if importlib.util.find_spec("yt_dlp") is not None:
env = None
overlay = _ytdlp_overlay_dir()
if os.path.isdir(os.path.join(overlay, "yt_dlp")):
env = dict(os.environ)
env["PYTHONPATH"] = overlay + os.pathsep + env.get("PYTHONPATH", "")
return [sys.executable, "-m", "yt_dlp"], env
except Exception as e:
logger.debug("yt_dlp module CLI unavailable: %s", e)
exe = shutil.which("yt-dlp")
return ([exe] if exe else ["yt-dlp"]), None
def restore_ytdlp() -> dict:
"""Delete the overlay — the locked, tested yt-dlp underneath takes over on
next start. Always safe: the locked install was never modified."""
overlay = _ytdlp_overlay_dir()
if os.path.isdir(overlay):
shutil.rmtree(overlay, ignore_errors=True)
_set_op("ytdlp_update", state="idle", progress=0.0, error=None, version=None)
return _ytdlp_status()
+5 -8
View File
@@ -12,13 +12,11 @@ working OmniVoice Studio install on a Debian / Ubuntu / Fedora / Arch host.
- **~10 GB free disk** for the app, its Python environment, and model weights.
- Optional: an **NVIDIA driver** for CUDA GPU acceleration — the app runs
CPU-only without one. For AMD GPUs see [AMD GPU (ROCm)](#amd-gpu-rocm).
- Optional: **yt-dlp** for downloading YouTube/video clips directly in the
Voice Gallery and Dub tabs — `sudo apt install yt-dlp` (Debian/Ubuntu),
`sudo dnf install yt-dlp` (Fedora), or `sudo pacman -S yt-dlp` (Arch).
Without it those downloads fail; everything else works fine.
That's it — Python, FFmpeg, and the model weights are bundled or bootstrapped
by the app itself on first launch. No toolchain needed.
That's it — Python, FFmpeg/FFprobe, yt-dlp, and the model weights are bundled
or bootstrapped by the app itself on first launch. No toolchain needed. (If no
FFmpeg resolves anywhere, the app downloads its own checksummed static build
in the background during setup; **Settings → Audio tools** shows exactly which
binaries are in use and lets you override them or update yt-dlp.)
### Building from source
@@ -29,7 +27,6 @@ Everything above, plus the toolchain:
- **Python 3.11+** — typically `sudo apt install python3.11` on Debian/Ubuntu,
`sudo dnf install python3.11` on Fedora, or already installed on Arch.
- **Bun**`curl -fsSL https://bun.sh/install | bash`.
- **FFmpeg**`sudo apt install ffmpeg` (Debian/Ubuntu), `sudo dnf install ffmpeg-free` (Fedora), or `sudo pacman -S ffmpeg` (Arch).
- **Rust / Cargo**`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or via your package manager (e.g., `sudo apt install rustc cargo`).
If you use rustup, reopen the shell or source `"$HOME/.cargo/env"` before running `bun run desktop-prod`.
- **GTK/WebKit deps** for the Tauri shell:
+6 -1
View File
@@ -35,10 +35,15 @@ Everything above, plus the toolchain:
and the C toolchain; `curl` ships with macOS).
- **Python 3.11+**`brew install python@3.11` (or use `pyenv` / the system Python if you already have ≥3.11).
- **Bun**`curl -fsSL https://bun.sh/install | bash`.
- **FFmpeg** (used by the dubbing + capture pipelines) — `brew install ffmpeg`.
- **Rust / Cargo**`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or `brew install rust`.
If you use rustup, reopen the terminal or source `"$HOME/.cargo/env"` before running `bun run desktop-prod`.
FFmpeg/FFprobe and yt-dlp are **not** prerequisites on any install path: the
app resolves them itself (a static build ships with the Python environment;
if nothing resolves, the app downloads its own checksummed build on first
run). Power users can inspect or override the binaries in
**Settings → Audio tools** — including pointing at a Homebrew copy.
Optional but recommended:
- **A Hugging Face account** for diarization and the larger TTS models. See
+26
View File
@@ -167,6 +167,32 @@ that rely on `/usr/bin/ffprobe`.
**Fix:** see [linux.md#deb-ffprobe-conflict](linux.md#deb-ffprobe-conflict).
## 7b. "Media engine unavailable" / FFmpeg questions
FFmpeg, FFprobe, and yt-dlp are **not** things you install for OmniVoice.
The app resolves them itself, in order: a path provided by the desktop shell →
the static build shipped with the Python environment → the app's own
downloaded build → whatever is on your PATH. When nothing resolves at all
(some source installs on a fresh machine), the Setup Wizard downloads a
pinned, checksum-verified static build in the background — you'll see a
one-line "Preparing media engine…" progress and, only if that download fails,
a card with **Retry** and **Use a system copy**.
If a running install ever reports "Media engine unavailable":
1. Open **Settings → Audio tools**. Each row shows the binary actually in use
(version, path, and origin — Bundled / System / Custom).
2. Press **Restore bundled** to re-fetch the app's own build (needs network
once), or **Use system copy** / **Choose file…** to point at an FFmpeg you
already have. Installing via a package manager (`brew install ffmpeg`,
`sudo apt install ffmpeg`, `winget install ffmpeg`) also works — press
**Use system copy** afterwards.
The same panel updates **yt-dlp** (video imports): site support changes
faster than app releases, so when video-URL imports start failing, press
**Update** there — the new version survives app updates, and **Restore tested
version** reverts to the build the app shipped with.
## 8. Docker LAN access — media preview 404
**Symptom:** OmniVoice loads on `http://<lan-ip>:3900` but the audio preview
+3
View File
@@ -798,6 +798,9 @@ export default function LogsFooter() {
if (notif.action.type === 'navigate') {
useAppStore.getState().setMode?.(notif.action.target);
setCollapsed(true);
} else if (notif.action.type === 'settings-tab') {
useAppStore.getState().openSettingsTab?.(notif.action.target);
setCollapsed(true);
} else if (notif.action.type === 'link') {
import('../api/external').then((m) => m.openExternal(notif.action.target));
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Media engine invisible unless it needs help.
*
* The media engine (ffmpeg/ffprobe) is an internal dependency, not a system
* requirement: when the backend's resolution chain finds nothing, preflight
* already kicked a background download of the app's own pinned static build.
* This renders NOTHING when the engine is ready (the ideal outcome), a quiet
* one-line progress while acquiring, and an actionable card only on failure
* (Retry / use a copy already on the machine). yt-dlp never appears here
* it's an importable module, not a user task.
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader } from 'lucide-react';
import { apiJson, apiFetch } from '../api/client';
import { Button } from '../ui';
export default function MediaEngineCard() {
const { t } = useTranslation();
const [status, setStatus] = useState(null);
const [detectError, setDetectError] = useState(null);
const [customPath, setCustomPath] = useState('');
const [showPathInput, setShowPathInput] = useState(false);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
const st = await apiJson('/media-tools/status');
setStatus(st);
return st;
} catch {
return null;
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const acquiring = status?.ops?.acquire?.state === 'running';
useEffect(() => {
if (!acquiring) return undefined;
const iv = setInterval(refresh, 1500);
return () => clearInterval(iv);
}, [acquiring, refresh]);
const post = async (path, body) => {
setBusy(true);
setDetectError(null);
try {
const res = await apiFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let detail = `HTTP ${res.status}`;
try {
detail = (await res.json())?.detail || detail;
} catch {
/* non-JSON body */
}
throw new Error(detail);
}
return true;
} catch (e) {
setDetectError(e?.message || String(e));
return false;
} finally {
setBusy(false);
refresh();
}
};
const useSystemCopy = async () => {
// ffprobe rides along: the resolver derives the sibling ffprobe from a
// resolved ffmpeg, so pinning ffmpeg is enough in the common case.
await post('/media-tools/ffmpeg/use-system');
};
const chooseFile = async () => {
try {
if ('__TAURI_INTERNALS__' in window) {
const { open } = await import('@tauri-apps/plugin-dialog');
const picked = await open({ multiple: false, directory: false, title: 'FFmpeg' });
if (typeof picked === 'string') {
await post('/media-tools/ffmpeg/custom-path', { path: picked });
return;
}
}
} catch {
/* picker unavailable — fall through to the inline input */
}
setShowPathInput(true);
};
if (!status || status.ready) return null; // the ideal outcome: nothing.
const op = status.ops?.acquire || {};
if (op.state === 'running' || op.state === 'idle') {
// idle-and-not-ready = preflight is about to kick the download (or a
// recheck is in flight) show the quiet line, never flash the card.
return (
<div
className="mt-3 flex items-center gap-2 text-xs text-fg-muted"
data-testid="media-engine-progress"
>
<Loader className="animate-spin" size={12} aria-hidden="true" />
{t('setup.media_engine_preparing', { defaultValue: 'Preparing media engine…' })}
{op.state === 'running' && ` ${Math.round((op.progress || 0) * 100)}%`}
</div>
);
}
return (
<div
className="mt-3 flex flex-col gap-1.5 rounded-md border border-border px-3 py-2.5"
data-testid="media-engine-card"
>
<span className="text-sm font-semibold">
{t('setup.media_engine_failed_title', { defaultValue: 'Media engine download failed' })}
</span>
<span className="text-xs leading-snug text-fg-muted">
{t('setup.media_engine_failed_desc', {
defaultValue:
"The app couldn't fetch its bundled audio/video engine (FFmpeg). Retry, or point it at a copy already on this computer.",
})}
</span>
{(op.error || detectError) && (
<span className="text-xs text-danger" role="alert" data-testid="media-engine-error">
{detectError || op.error}
</span>
)}
<div className="mt-1 flex flex-wrap items-center gap-2">
<Button
variant="subtle"
size="sm"
loading={busy}
disabled={busy}
onClick={() => post('/media-tools/acquire')}
data-testid="media-engine-retry"
>
{t('setup.media_engine_retry', { defaultValue: 'Retry' })}
</Button>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={useSystemCopy}
data-testid="media-engine-use-system"
>
{t('setup.media_engine_use_system', { defaultValue: 'Use a system copy' })}
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={chooseFile}>
{t('setup.media_engine_choose_file', { defaultValue: 'Choose file…' })}
</Button>
{showPathInput && (
<>
<input
type="text"
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
placeholder="/usr/bin/ffmpeg"
className="min-w-[220px] flex-1 rounded border border-border bg-transparent px-2 py-1 font-mono text-xs text-fg"
aria-label={t('settings.ffmpeg_input_aria', { defaultValue: 'FFmpeg path' })}
data-testid="media-engine-path"
/>
<Button
variant="subtle"
size="sm"
disabled={busy || !customPath.trim()}
onClick={() => post('/media-tools/ffmpeg/custom-path', { path: customPath.trim() })}
>
{t('credentials.save', { defaultValue: 'Save' })}
</Button>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,100 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
vi.mock('../api/client', () => ({
apiJson: vi.fn(),
apiFetch: vi.fn(),
}));
import { apiJson, apiFetch } from '../api/client';
import MediaEngineCard from './MediaEngineCard';
const statusWith = (ready, acquire) => ({
ready,
tools: {},
ops: { acquire: acquire || { state: 'idle', progress: 0, error: null } },
});
describe('MediaEngineCard — invisible-by-default media engine', () => {
beforeEach(() => {
vi.clearAllMocks();
apiFetch.mockResolvedValue({ ok: true, json: async () => ({}) });
});
it('renders NOTHING when the media engine is resolved (the ideal outcome)', async () => {
apiJson.mockResolvedValue(statusWith(true));
const { container } = render(<MediaEngineCard />);
await waitFor(() => expect(apiJson).toHaveBeenCalledWith('/media-tools/status'));
expect(container).toBeEmptyDOMElement();
expect(screen.queryByTestId('media-engine-card')).not.toBeInTheDocument();
});
it('shows only a quiet progress line while the bundled build downloads', async () => {
apiJson.mockResolvedValue(statusWith(false, { state: 'running', progress: 0.42, error: null }));
render(<MediaEngineCard />);
const line = await screen.findByTestId('media-engine-progress');
expect(line).toHaveTextContent('Preparing media engine…');
expect(line).toHaveTextContent('42%');
// No requirements-style card, no mention of package managers.
expect(screen.queryByTestId('media-engine-card')).not.toBeInTheDocument();
expect(document.body.textContent).not.toMatch(/brew|apt|choco/i);
});
it('shows the actionable failure card only when acquisition failed', async () => {
apiJson.mockResolvedValue(
statusWith(false, { state: 'error', progress: 0, error: 'download checksum mismatch' }),
);
render(<MediaEngineCard />);
const card = await screen.findByTestId('media-engine-card');
expect(card).toHaveTextContent('Media engine download failed');
expect(screen.getByTestId('media-engine-error')).toHaveTextContent(
'download checksum mismatch',
);
expect(screen.getByTestId('media-engine-retry')).toBeInTheDocument();
expect(screen.getByTestId('media-engine-use-system')).toBeInTheDocument();
});
it('Retry re-posts the acquisition endpoint', async () => {
apiJson.mockResolvedValue(statusWith(false, { state: 'error', error: 'boom' }));
render(<MediaEngineCard />);
fireEvent.click(await screen.findByTestId('media-engine-retry'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/acquire', expect.anything()),
);
});
it('Use a system copy posts use-system and surfaces a not-found detail', async () => {
apiJson.mockResolvedValue(statusWith(false, { state: 'error', error: 'boom' }));
apiFetch.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({
detail: 'No system ffmpeg found on PATH or in the usual install locations.',
}),
});
render(<MediaEngineCard />);
fireEvent.click(await screen.findByTestId('media-engine-use-system'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ffmpeg/use-system', expect.anything()),
);
expect(await screen.findByTestId('media-engine-error')).toHaveTextContent(
'No system ffmpeg found',
);
});
it('Choose file… falls back to an inline path input outside Tauri and saves it', async () => {
apiJson.mockResolvedValue(statusWith(false, { state: 'error', error: 'boom' }));
render(<MediaEngineCard />);
fireEvent.click(await screen.findByText('Choose file…'));
const input = await screen.findByTestId('media-engine-path');
fireEvent.change(input, { target: { value: '/usr/local/bin/ffmpeg' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith(
'/media-tools/ffmpeg/custom-path',
expect.objectContaining({ body: JSON.stringify({ path: '/usr/local/bin/ffmpeg' }) }),
),
);
});
});
@@ -0,0 +1,397 @@
/**
* Settings Audio tools the power-user surface for the media tools most
* users never see (the wizard + backend provision them invisibly).
*
* One row per tool:
* FFmpeg / FFprobe version + origin badge (Bundled / System / Custom /
* App package) + path; actions: Use system copy (auto-detect),
* Choose file (picker in Tauri, inline path input everywhere),
* Restore bundled (always-safe revert). The section header carries
* "Update bundled build" (one download covers both binaries).
* yt-dlp module version + Update (fetches the newest wheel into an
* update-surviving overlay; applies on restart) + Restore tested version.
*
* Absorbs the FFmpeg-path override that used to live in Settings Network
* same backend store (prefs `env.FFMPEG_PATH`), one control surface.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { AudioLines, Film, ScanSearch, DownloadCloud } from 'lucide-react';
import { Button, Badge } from '../../ui';
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
import RestartBadge from './RestartBadge';
import { isTauri } from './native';
const ORIGIN_TONE = {
bundled: 'success',
sidecar: 'success',
system: 'info',
custom: 'warn',
};
function OriginBadge({ origin }) {
const { t } = useTranslation();
if (!origin) return null;
const labels = {
bundled: t('settings.audio_tools_origin_bundled', { defaultValue: 'Bundled' }),
system: t('settings.audio_tools_origin_system', { defaultValue: 'System' }),
custom: t('settings.audio_tools_origin_custom', { defaultValue: 'Custom' }),
sidecar: t('settings.audio_tools_origin_sidecar', { defaultValue: 'App package' }),
};
return (
<Badge tone={ORIGIN_TONE[origin] || 'neutral'} size="xs" data-testid={`origin-${origin}`}>
{labels[origin] || origin}
</Badge>
);
}
/** Open the OS file picker in Tauri; return the chosen path or null. */
async function pickBinary(title) {
if (!isTauri()) return null;
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const picked = await open({ multiple: false, directory: false, title });
return typeof picked === 'string' ? picked : null;
} catch {
return null;
}
}
function BinaryRow({ tool, info, onAction, busy }) {
const { t } = useTranslation();
const [path, setPath] = useState('');
const [showInput, setShowInput] = useState(false);
const label = tool === 'ffmpeg' ? 'FFmpeg' : 'FFprobe';
const chooseFile = async () => {
const picked = await pickBinary(label);
if (picked) {
onAction(`/media-tools/${tool}/custom-path`, { path: picked });
} else {
// Web preview / picker unavailable fall back to the inline input.
setShowInput(true);
}
};
return (
<SettingRow
align="start"
stack
icon={tool === 'ffmpeg' ? Film : ScanSearch}
title={
<>
{label}
<OriginBadge origin={info?.origin} />
{!info?.ok && (
<Badge tone="warn" size="xs">
{t('settings.audio_tools_not_found', { defaultValue: 'Not available' })}
</Badge>
)}
</>
}
note={
info?.ok ? (
<>
{info.version ||
t('settings.audio_tools_version_unknown', { defaultValue: 'version unknown' })}
{' — '}
<code className="font-mono">{info.path}</code>
</>
) : (
t(`settings.audio_tools_${tool}_desc`)
)
}
control={
<>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => onAction(`/media-tools/${tool}/use-system`)}
aria-label={`${label}: ${t('settings.audio_tools_use_system')}`}
>
{t('settings.audio_tools_use_system', { defaultValue: 'Use system copy' })}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={chooseFile}
aria-label={`${label}: ${t('settings.audio_tools_choose_file')}`}
>
{t('settings.audio_tools_choose_file', { defaultValue: 'Choose file…' })}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => onAction(`/media-tools/${tool}/restore`)}
aria-label={`${label}: ${t('settings.audio_tools_restore')}`}
>
{t('settings.audio_tools_restore', { defaultValue: 'Restore bundled' })}
</Button>
{showInput && (
<>
<SettingsInput
placeholder={tool === 'ffmpeg' ? '/usr/bin/ffmpeg' : '/usr/bin/ffprobe'}
value={path}
onChange={(e) => setPath(e.target.value)}
onKeyDown={(e) =>
e.key === 'Enter' &&
path.trim() &&
onAction(`/media-tools/${tool}/custom-path`, { path: path.trim() })
}
aria-label={t('settings.audio_tools_path_input_aria', {
tool: label,
defaultValue: '{{tool}} binary path',
})}
/>
<Button
size="sm"
variant="subtle"
disabled={busy || !path.trim()}
onClick={() => onAction(`/media-tools/${tool}/custom-path`, { path: path.trim() })}
>
{t('credentials.save', { defaultValue: 'Save' })}
</Button>
</>
)}
</>
}
/>
);
}
export default function AudioToolsPanel() {
const { t } = useTranslation();
const [status, setStatus] = useState(null);
const [busy, setBusy] = useState(false);
const acquireWasRunning = useRef(false);
const ytdlpWasRunning = useRef(false);
const load = useCallback(async () => {
try {
const { apiJson } = await import('../../api/client');
const st = await apiJson('/media-tools/status');
setStatus(st);
return st;
} catch {
return null;
}
}, []);
useEffect(() => {
load();
}, [load]);
// Poll while a background op runs; toast exactly once on the edge.
const acquire = status?.ops?.acquire;
const ytdlpOp = status?.ops?.ytdlp_update;
useEffect(() => {
if (acquire?.state === 'running') acquireWasRunning.current = true;
else if (acquireWasRunning.current) {
acquireWasRunning.current = false;
if (acquire?.state === 'done') {
toast.success(
t('settings.audio_tools_bundle_done', { defaultValue: 'Bundled media engine ready.' }),
);
} else if (acquire?.state === 'error') {
toast.error(
t('settings.audio_tools_bundle_failed', {
message: acquire.error,
defaultValue: 'Bundled download failed: {{message}}',
}),
);
}
}
if (ytdlpOp?.state === 'running') ytdlpWasRunning.current = true;
else if (ytdlpWasRunning.current) {
ytdlpWasRunning.current = false;
if (ytdlpOp?.state === 'done') {
toast.success(
t('settings.audio_tools_ytdlp_updated', {
version: ytdlpOp.version,
defaultValue: 'yt-dlp {{version}} installed — restart the backend to apply.',
}),
);
} else if (ytdlpOp?.state === 'error') {
toast.error(
t('settings.audio_tools_ytdlp_update_failed', {
message: ytdlpOp.error,
defaultValue: 'yt-dlp update failed: {{message}}',
}),
);
}
}
if (acquire?.state !== 'running' && ytdlpOp?.state !== 'running') return undefined;
const iv = setInterval(load, 1500);
return () => clearInterval(iv);
}, [acquire?.state, ytdlpOp?.state, load, t, acquire?.error, ytdlpOp?.error, ytdlpOp?.version]);
const post = useCallback(
async (path, body) => {
setBusy(true);
try {
const { apiFetch } = await import('../../api/client');
const res = await apiFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let detail = `HTTP ${res.status}`;
try {
detail = (await res.json())?.detail || detail;
} catch {
/* non-JSON error body */
}
throw new Error(detail);
}
return true;
} catch (e) {
toast.error(
t('settings.audio_tools_path_failed', {
message: e.message,
defaultValue: "Couldn't set path: {{message}}",
}),
);
return false;
} finally {
setBusy(false);
load();
}
},
[load, t],
);
const onToolAction = useCallback(
async (path, body) => {
const ok = await post(path, body);
if (ok && (path.endsWith('/custom-path') || path.endsWith('/use-system'))) {
toast.success(
t('settings.audio_tools_path_set', {
tool: path.includes('ffprobe') ? 'FFprobe' : 'FFmpeg',
path: body?.path || t('settings.audio_tools_origin_system', { defaultValue: 'System' }),
defaultValue: '{{tool}} now uses {{path}}',
}),
);
} else if (ok && path.endsWith('/restore')) {
toast.success(
t('settings.audio_tools_restored', {
tool: path.includes('ffprobe') ? 'FFprobe' : 'FFmpeg',
defaultValue: '{{tool}} restored to the app-managed build.',
}),
);
}
},
[post, t],
);
const ytdlp = status?.tools?.ytdlp;
const ytdlpNeedsRestart =
ytdlpOp?.state === 'done' ||
(ytdlp?.overlay_version && ytdlp.overlay_version !== ytdlp.version);
return (
<SettingsSection
icon={AudioLines}
title={t('settings.audio_tools', { defaultValue: 'Audio tools' })}
description={t('settings.audio_tools_desc', {
defaultValue:
'The media engine (FFmpeg, FFprobe) and video downloader (yt-dlp) the app manages for you.',
})}
actions={
<Button
size="sm"
variant="ghost"
leading={<DownloadCloud size={12} />}
loading={acquire?.state === 'running'}
disabled={busy || acquire?.state === 'running'}
onClick={() => post('/media-tools/acquire')}
aria-label={t('settings.audio_tools_update_bundle', {
defaultValue: 'Update bundled build',
})}
>
{acquire?.state === 'running'
? t('settings.audio_tools_bundle_updating', {
percent: Math.round((acquire.progress || 0) * 100),
defaultValue: 'Downloading bundled build… {{percent}}%',
})
: t('settings.audio_tools_update_bundle', { defaultValue: 'Update bundled build' })}
</Button>
}
>
<BinaryRow tool="ffmpeg" info={status?.tools?.ffmpeg} onAction={onToolAction} busy={busy} />
<BinaryRow tool="ffprobe" info={status?.tools?.ffprobe} onAction={onToolAction} busy={busy} />
<SettingRow
align="start"
stack
icon={DownloadCloud}
title={
<>
{t('settings.audio_tools_ytdlp', { defaultValue: 'yt-dlp (video downloader)' })}
{ytdlp?.origin && (
<OriginBadge origin={ytdlp.origin === 'custom' ? 'custom' : 'bundled'} />
)}
{ytdlpNeedsRestart && <RestartBadge />}
</>
}
note={
<>
{ytdlp?.version ||
t('settings.audio_tools_version_unknown', { defaultValue: 'version unknown' })}
{' — '}
{t('settings.audio_tools_ytdlp_desc', {
defaultValue:
'Powers video/clip imports. Site support changes faster than app releases — update it here when imports start failing.',
})}
</>
}
hint={t('settings.audio_tools_manual_hint', {
defaultValue:
'Prefer your package manager? Install FFmpeg yourself (macOS: brew install ffmpeg · Debian/Ubuntu: sudo apt install ffmpeg · Windows: winget install ffmpeg) and press Use system copy. Nothing is ever installed system-wide by the app.',
})}
control={
<>
<Button
size="sm"
variant="subtle"
loading={ytdlpOp?.state === 'running'}
disabled={busy || ytdlpOp?.state === 'running'}
onClick={() => post('/media-tools/ytdlp/update')}
aria-label={`yt-dlp: ${t('settings.audio_tools_ytdlp_update', { defaultValue: 'Update' })}`}
>
{t('settings.audio_tools_ytdlp_update', { defaultValue: 'Update' })}
</Button>
{(ytdlp?.origin === 'custom' || ytdlp?.overlay_version) && (
<Button
size="sm"
variant="ghost"
disabled={busy || ytdlpOp?.state === 'running'}
onClick={async () => {
const ok = await post('/media-tools/ytdlp/restore');
if (ok) {
toast.success(
t('settings.audio_tools_ytdlp_restored', {
defaultValue: 'Tested yt-dlp restored — restart the backend to apply.',
}),
);
}
}}
aria-label={`yt-dlp: ${t('settings.audio_tools_ytdlp_restore', { defaultValue: 'Restore tested version' })}`}
data-testid="ytdlp-restore"
>
{t('settings.audio_tools_ytdlp_restore', {
defaultValue: 'Restore tested version',
})}
{ytdlp?.baseline_version ? ` (${ytdlp.baseline_version})` : ''}
</Button>
)}
</>
}
/>
</SettingsSection>
);
}
@@ -0,0 +1,155 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
vi.mock('react-hot-toast', () => ({
default: { error: vi.fn(), success: vi.fn() },
toast: { error: vi.fn(), success: vi.fn() },
}));
vi.mock('../../api/client', () => ({
apiJson: vi.fn(),
apiFetch: vi.fn(),
}));
import { toast } from 'react-hot-toast';
import { apiJson, apiFetch } from '../../api/client';
import AudioToolsPanel from './AudioToolsPanel';
const STATUS = {
ready: true,
platform_key: 'darwin_arm64',
tools: {
ffmpeg: {
tool: 'ffmpeg',
ok: true,
path: '/data/media_tools/ffbin-abc/darwin_arm64/ffmpeg',
version: '7.0',
origin: 'bundled',
},
ffprobe: {
tool: 'ffprobe',
ok: true,
path: '/opt/homebrew/bin/ffprobe',
version: '8.1.1',
origin: 'system',
},
ytdlp: {
tool: 'yt-dlp',
ok: true,
path: '/venv/site-packages/yt_dlp',
version: '2026.06.09',
origin: 'bundled',
overlay_version: null,
baseline_version: null,
},
},
ops: {
acquire: { state: 'idle', progress: 0, error: null },
ytdlp_update: { state: 'idle', progress: 0, error: null, version: null },
},
};
const okResponse = { ok: true, json: async () => ({}) };
describe('AudioToolsPanel — power-user surface for the media tools', () => {
beforeEach(() => {
vi.clearAllMocks();
apiJson.mockResolvedValue(JSON.parse(JSON.stringify(STATUS)));
apiFetch.mockResolvedValue(okResponse);
});
it('renders one row per tool with version, path, and origin badge', async () => {
render(<AudioToolsPanel />);
await waitFor(() => expect(apiJson).toHaveBeenCalledWith('/media-tools/status'));
expect(await screen.findByText('FFmpeg')).toBeInTheDocument();
expect(screen.getByText('FFprobe')).toBeInTheDocument();
expect(screen.getByText('yt-dlp (video downloader)')).toBeInTheDocument();
// ffmpeg + yt-dlp are both app-managed here; ffprobe is a system copy.
const bundled = screen.getAllByTestId('origin-bundled');
expect(bundled).toHaveLength(2);
expect(bundled[0]).toHaveTextContent('Bundled');
expect(screen.getByTestId('origin-system')).toHaveTextContent('System');
expect(screen.getByText('/opt/homebrew/bin/ffprobe')).toBeInTheDocument();
expect(screen.getByText(/2026\.06\.09/)).toBeInTheDocument();
});
it('Use system copy posts the endpoint and toasts success', async () => {
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('FFmpeg: Use system copy'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ffmpeg/use-system', expect.anything()),
);
await waitFor(() => expect(toast.success).toHaveBeenCalled());
});
it('Restore bundled is per-tool and always available (safe revert)', async () => {
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('FFprobe: Restore bundled'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ffprobe/restore', expect.anything()),
);
await waitFor(() => expect(toast.success).toHaveBeenCalled());
});
it('surfaces the backend error detail on a failed action', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ detail: 'That file exists but does not run as a media tool' }),
});
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('FFmpeg: Use system copy'));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(String(toast.error.mock.calls[0][0])).toContain('does not run as a media tool');
});
it('yt-dlp row: Update posts the update endpoint', async () => {
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('yt-dlp: Update'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ytdlp/update', expect.anything()),
);
});
it('yt-dlp row: Restore tested version appears only when an overlay is active', async () => {
const { unmount } = render(<AudioToolsPanel />);
await screen.findByText('yt-dlp (video downloader)');
expect(screen.queryByTestId('ytdlp-restore')).not.toBeInTheDocument();
unmount();
const overlaid = JSON.parse(JSON.stringify(STATUS));
overlaid.tools.ytdlp.origin = 'custom';
overlaid.tools.ytdlp.overlay_version = '2026.07.01';
overlaid.tools.ytdlp.version = '2026.07.01';
overlaid.tools.ytdlp.baseline_version = '2026.06.09';
apiJson.mockResolvedValue(overlaid);
render(<AudioToolsPanel />);
const restore = await screen.findByTestId('ytdlp-restore');
expect(restore).toHaveTextContent('Restore tested version (2026.06.09)');
fireEvent.click(restore);
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/ytdlp/restore', expect.anything()),
);
});
it('section header offers Update bundled build (one download covers both binaries)', async () => {
render(<AudioToolsPanel />);
fireEvent.click(await screen.findByLabelText('Update bundled build'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith('/media-tools/acquire', expect.anything()),
);
});
it('package-manager commands are copy-only prose, never buttons', async () => {
render(<AudioToolsPanel />);
await screen.findByText('FFmpeg');
// The InfoHint copy mentions brew/apt as a secondary affordance, but no
// button/control runs a package manager.
const buttons = screen.getAllByRole('button').map((b) => b.textContent || '');
expect(buttons.join(' ')).not.toMatch(/brew|apt|winget|choco/i);
});
});
+21 -70
View File
@@ -1,33 +1,22 @@
/**
* Settings Network.
*
* The proxy + FFmpeg-path controls that used to live in GeneralTab's "Advanced"
* collapsible, promoted to their own top-level category. Logic is unchanged
* both persist via the backend `/system/set-env` durable env writer and
* invalidate the systemInfo query so badges refresh.
*
* FFmpeg takes effect on the next backend start (durable env), so it carries a
* RestartBadge; the proxy applies to subsequent downloads immediately.
* Proxy only. The FFmpeg-path override that used to share this panel moved to
* Settings Audio tools (same backend store prefs `env.FFMPEG_PATH` via
* `/media-tools` richer controls: version, origin, restore bundled); a
* pointer row below deep-links there so muscle memory still lands.
*/
import React, { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { Wifi, Globe, Film } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '../../store';
import { useSystemInfo, queryKeys } from '../../api/hooks';
import { Button, Badge } from '../../ui';
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
import RestartBadge from './RestartBadge';
/** Platform-appropriate FFmpeg example path (sysInfo.platform = Python's sys.platform). */
export function ffmpegPlaceholder(platform) {
if (typeof platform === 'string' && platform.startsWith('win')) {
return 'C:\\ffmpeg\\bin\\ffmpeg.exe';
}
if (platform === 'darwin') return '/opt/homebrew/bin/ffmpeg';
return '/usr/bin/ffmpeg';
}
export default function NetworkTab() {
const { t } = useTranslation();
const { data: sysInfo } = useSystemInfo();
@@ -35,46 +24,20 @@ export default function NetworkTab() {
const [proxySaved, setProxySaved] = useState(false);
const [proxyCleared, setProxyCleared] = useState(false);
const [proxySaving, setProxySaving] = useState(false);
const [ffmpegPath, setFfmpegPath] = useState('');
const [ffmpegSaving, setFfmpegSaving] = useState(false);
const queryClient = useQueryClient();
const openSettingsTab = useAppStore((s) => s.openSettingsTab);
useEffect(() => {
if (!proxyUrl && !proxySaved && !proxyCleared) setProxyUrl(sysInfo?.proxy_url || '');
}, [sysInfo?.proxy_url]);
useEffect(() => {
if (!ffmpegPath) setFfmpegPath(sysInfo?.ffmpeg_path || '');
}, [sysInfo?.ffmpeg_path]);
const ffmpegOk = sysInfo?.ffmpeg_ok;
const ffmpegCurrent = sysInfo?.ffmpeg_path;
// "A proxy is configured" must survive an app reload: derive it from the
// backend-persisted value, not only from a save in this session otherwise
// the Clear button (and the "Set" badge) vanish on reload with the proxy
// still active and no way to remove it.
const proxyConfigured = !proxyCleared && (proxySaved || Boolean(sysInfo?.proxy_url));
const saveFfmpeg = async () => {
const value = ffmpegPath.trim();
setFfmpegSaving(true);
try {
const { apiFetch } = await import('../../api/client');
await apiFetch('/system/set-env', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'FFMPEG_PATH', value }),
});
toast.success(t('settings.ffmpeg_saved'));
setFfmpegPath('');
queryClient.invalidateQueries({ queryKey: queryKeys.systemInfo });
} catch (e) {
toast.error(t('settings.save_failed', { message: e.message }));
} finally {
setFfmpegSaving(false);
}
};
const saveProxy = async () => {
const value = proxyUrl.trim();
setProxySaving(true);
@@ -140,7 +103,7 @@ export default function NetworkTab() {
icon={Wifi}
title={t('settings.network', { defaultValue: 'Network' })}
description={t('settings.network_desc', {
defaultValue: 'Proxy and FFmpeg paths for downloads and media processing.',
defaultValue: 'Proxy for downloads and model fetches.',
})}
>
<SettingRow
@@ -192,9 +155,9 @@ export default function NetworkTab() {
}
/>
{/* Pointer, not a control the FFmpeg override lives in Audio tools now.
Two competing writers of env.FFMPEG_PATH would fight each other. */}
<SettingRow
align="start"
stack
icon={Film}
title={
<>
@@ -202,33 +165,21 @@ export default function NetworkTab() {
<Badge tone={ffmpegOk ? 'success' : 'warn'} size="xs">
{ffmpegOk ? t('settings.ffmpeg_found') : t('settings.ffmpeg_missing')}
</Badge>
<RestartBadge />
</>
}
note={
ffmpegCurrent
? `${t('settings.ffmpeg_current')}: ${ffmpegCurrent}`
: t('settings.ffmpeg_desc')
}
note={t('settings.audio_tools_moved_note', {
defaultValue:
'The FFmpeg override moved to its own panel with more control (version, origin, restore).',
})}
control={
<>
<SettingsInput
placeholder={ffmpegPlaceholder(sysInfo?.platform)}
value={ffmpegPath}
onChange={(e) => setFfmpegPath(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && saveFfmpeg()}
aria-label={t('settings.ffmpeg_input_aria', { defaultValue: 'FFmpeg path' })}
/>
<Button
size="sm"
variant="subtle"
onClick={saveFfmpeg}
loading={ffmpegSaving}
disabled={!ffmpegPath.trim()}
>
{t('credentials.save')}
</Button>
</>
<Button
size="sm"
variant="ghost"
onClick={() => openSettingsTab('audio-tools')}
data-testid="open-audio-tools"
>
{t('settings.audio_tools_open', { defaultValue: 'Open Audio tools' })}
</Button>
}
/>
</SettingsSection>
@@ -21,10 +21,15 @@ vi.mock('../../api/client', () => ({
apiFetch: vi.fn().mockResolvedValue({}),
}));
const { openSettingsTab } = vi.hoisted(() => ({ openSettingsTab: vi.fn() }));
vi.mock('../../store', () => ({
useAppStore: (selector) => selector({ openSettingsTab }),
}));
import { toast } from 'react-hot-toast';
import { useSystemInfo } from '../../api/hooks';
import { apiFetch } from '../../api/client';
import NetworkTab, { ffmpegPlaceholder } from './NetworkTab';
import NetworkTab from './NetworkTab';
describe('NetworkTab', () => {
beforeEach(() => {
@@ -88,33 +93,26 @@ describe('NetworkTab', () => {
fireEvent.change(screen.getByLabelText('Proxy URL'), {
target: { value: 'socks5://127.0.0.1:7890' },
});
// Two Save buttons render (proxy first, then FFmpeg).
fireEvent.click(screen.getAllByText('Save')[0]);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(screen.getByTestId('proxy-clear')).toBeInTheDocument();
expect(screen.getByText('✓ Set')).toBeInTheDocument();
});
it('labels both text inputs for assistive tech', () => {
it('labels the proxy input for assistive tech', () => {
useSystemInfo.mockReturnValue({ data: {} });
render(<NetworkTab />);
expect(screen.getByLabelText('Proxy URL')).toBeInTheDocument();
expect(screen.getByLabelText('FFmpeg path')).toBeInTheDocument();
});
it('picks a platform-appropriate FFmpeg placeholder', () => {
expect(ffmpegPlaceholder('win32')).toBe('C:\\ffmpeg\\bin\\ffmpeg.exe');
expect(ffmpegPlaceholder('darwin')).toBe('/opt/homebrew/bin/ffmpeg');
expect(ffmpegPlaceholder('linux')).toBe('/usr/bin/ffmpeg');
// Unknown/absent platform (backend not up yet) falls back to a POSIX path.
expect(ffmpegPlaceholder(undefined)).toBe('/usr/bin/ffmpeg');
useSystemInfo.mockReturnValue({ data: { platform: 'darwin' } });
it('has NO FFmpeg path control anymore — only the pointer to Audio tools', () => {
// The override moved to Settings Audio tools; a second writer of
// env.FFMPEG_PATH here would fight the new panel.
useSystemInfo.mockReturnValue({ data: { ffmpeg_ok: true } });
render(<NetworkTab />);
expect(screen.getByLabelText('FFmpeg path')).toHaveAttribute(
'placeholder',
'/opt/homebrew/bin/ffmpeg',
);
expect(screen.queryByLabelText('FFmpeg path')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('open-audio-tools'));
expect(openSettingsTab).toHaveBeenCalledWith('audio-tools');
});
});
@@ -15,6 +15,7 @@
* separate keyword translations to maintain).
*/
import {
AudioLines,
Palette,
Settings2,
Plug,
@@ -193,13 +194,31 @@ export const GROUPS = [
labelKey: 'settings.network',
defaultLabel: 'Network',
icon: Wifi,
// The FFmpeg-path row is a durable env write read at process start
// (it renders RestartBadge) the category carries the affordance
// like Models / Performance / Sharing. Guarded by the RestartBadge
// category lockstep test in settingsCategories.test.jsx.
// Only the proxy lives here now (applies immediately) the
// restart-bound FFmpeg override moved to Audio tools below.
keywords: ['network', 'proxy', 'http proxy', 'socks'],
keywordKeys: ['settings.proxy'],
},
{
id: 'audio-tools',
labelKey: 'settings.audio_tools',
defaultLabel: 'Audio tools',
icon: AudioLines,
// yt-dlp updates land in an overlay read at process start (the row
// renders RestartBadge) lockstep-guarded in
// settingsCategories.test.jsx like Models / Performance / Sharing.
restart: true,
keywords: ['network', 'proxy', 'http proxy', 'socks', 'ffmpeg', 'ffmpeg path'],
keywordKeys: ['settings.proxy', 'settings.ffmpeg'],
keywords: [
'ffmpeg',
'ffprobe',
'ffmpeg path',
'yt-dlp',
'ytdlp',
'media engine',
'video downloader',
'bundled binaries',
],
keywordKeys: ['settings.audio_tools', 'settings.ffmpeg', 'settings.audio_tools_ytdlp'],
},
{
id: 'sharing',
@@ -49,7 +49,7 @@ describe('restart flag ↔ RestartBadge lockstep', () => {
'StoragePanel.jsx': 'models',
'HFMirrorPanel.jsx': 'models',
'RemoteBackendPanel.jsx': 'sharing',
'NetworkTab.jsx': 'network',
'AudioToolsPanel.jsx': 'audio-tools',
'PerformancePanel.jsx': 'performance',
};
+41 -2
View File
@@ -425,6 +425,37 @@
"ffmpeg_current": "Current path",
"ffmpeg_desc": "Set a custom ffmpeg path if auto-detection fails.",
"ffmpeg_saved": "FFmpeg path set — restart backend to apply.",
"audio_tools": "Audio tools",
"audio_tools_desc": "The media engine (FFmpeg, FFprobe) and video downloader (yt-dlp) the app manages for you.",
"audio_tools_origin_bundled": "Bundled",
"audio_tools_origin_system": "System",
"audio_tools_origin_custom": "Custom",
"audio_tools_origin_sidecar": "App package",
"audio_tools_not_found": "Not available",
"audio_tools_version_unknown": "version unknown",
"audio_tools_use_system": "Use system copy",
"audio_tools_choose_file": "Choose file…",
"audio_tools_restore": "Restore bundled",
"audio_tools_update_bundle": "Update bundled build",
"audio_tools_bundle_updating": "Downloading bundled build… {{percent}}%",
"audio_tools_bundle_done": "Bundled media engine ready.",
"audio_tools_bundle_failed": "Bundled download failed: {{message}}",
"audio_tools_path_set": "{{tool}} now uses {{path}}",
"audio_tools_path_failed": "Couldn't set path: {{message}}",
"audio_tools_restored": "{{tool}} restored to the app-managed build.",
"audio_tools_path_input_aria": "{{tool}} binary path",
"audio_tools_manual_hint": "Prefer your package manager? Install FFmpeg yourself (macOS: brew install ffmpeg · Debian/Ubuntu: sudo apt install ffmpeg · Windows: winget install ffmpeg) and press Use system copy. Nothing is ever installed system-wide by the app.",
"audio_tools_ffmpeg_desc": "Converts, dubs, and mixes audio/video. The app resolves it automatically — override only if you need a specific build.",
"audio_tools_ffprobe_desc": "Reads media metadata (durations, frame rates) for Smart Fit and imports.",
"audio_tools_ytdlp": "yt-dlp (video downloader)",
"audio_tools_ytdlp_desc": "Powers video/clip imports. Site support changes faster than app releases — update it here when imports start failing.",
"audio_tools_ytdlp_update": "Update",
"audio_tools_ytdlp_updated": "yt-dlp {{version}} installed — restart the backend to apply.",
"audio_tools_ytdlp_update_failed": "yt-dlp update failed: {{message}}",
"audio_tools_ytdlp_restore": "Restore tested version",
"audio_tools_ytdlp_restored": "Tested yt-dlp restored — restart the backend to apply.",
"audio_tools_moved_note": "The FFmpeg override moved to its own panel with more control (version, origin, restore).",
"audio_tools_open": "Open Audio tools",
"diagnostics_copied": "Diagnostics copied — paste into your issue report.",
"updater_desktop": "Updater only runs in the desktop app.",
"latest_version": "You're on the latest version.",
@@ -525,7 +556,7 @@
"storage": "Storage",
"device": "Device & compute",
"device_desc": "Live hardware and backend readouts.",
"network_desc": "Proxy and FFmpeg paths for downloads and media processing.",
"network_desc": "Proxy for downloads and model fetches.",
"translation_desc": "How dubbing translates dialogue, and which engine does it.",
"translate_quality": "Translation quality",
"translate_quality_desc": "Fast is literal and quick; Cinematic uses the LLM for natural phrasing.",
@@ -1357,7 +1388,15 @@
"system_check": "System check",
"install_models": "Install models",
"pick_engines": "Pick engines",
"system_check_desc": "Probe RAM, disk, GPU, ffmpeg, and network. Blockers are flagged upfront so you know before downloading.",
"system_check_desc": "Probe RAM, disk, GPU, and network. Blockers are flagged upfront so you know before downloading.",
"media_engine_preparing": "Preparing media engine…",
"media_engine_failed_title": "Media engine download failed",
"media_engine_failed_desc": "The app couldn't fetch its bundled audio/video engine (FFmpeg). Retry, or point it at a copy already on this computer.",
"media_engine_retry": "Retry",
"media_engine_use_system": "Use a system copy",
"media_engine_detect_failed": "No system copy found — retry the download or choose the file manually.",
"media_engine_choose_file": "Choose file…",
"media_engine_ready": "Media engine configured.",
"install_models_desc": "Download ~5 GB of weights — TTS + Whisper. Required models first, optional ones later.",
"pick_engines_desc": "Choose TTS / ASR / LLM backends. Defaults work out of the box — customize anytime in Settings.",
"hero_desc": "Dubbing, voice cloning, and voice design — all running locally on your machine.",
+3
View File
@@ -36,6 +36,7 @@ import EnginesTab from '../components/settings/EnginesTab';
import HotkeyTab from '../components/settings/HotkeyTab';
import TranslationTab from '../components/settings/TranslationTab';
import NetworkTab from '../components/settings/NetworkTab';
import AudioToolsPanel from '../components/settings/AudioToolsPanel';
import ApiKeysPanel from '../components/settings/ApiKeysPanel';
import LLMProvidersPanel from '../components/settings/LLMProvidersPanel';
import LLMSkillsPanel from '../components/settings/LLMSkillsPanel';
@@ -401,6 +402,8 @@ export default function Settings() {
);
case 'network':
return <NetworkTab />;
case 'audio-tools':
return <AudioToolsPanel />;
case 'sharing':
return (
<>
+5
View File
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { useSetupStatus, usePreflight } from '../api/hooks';
import { apiJson, apiFetch } from '../api/client';
import WizardLibrary from '../components/WizardLibrary';
import MediaEngineCard from '../components/MediaEngineCard';
import HfTokenCard from '../components/HfTokenCard';
import DictationDemo from '../components/DictationDemo';
import { Button } from '../ui';
@@ -388,6 +389,10 @@ export default function SetupWizard({ onReady }) {
<div className="flex min-h-0 flex-auto flex-col gap-3" key="step-0">
<div className="fr-rise min-h-0 flex-1 overflow-y-auto" style={{ '--rise': 1 }}>
<PreflightPanel report={pre} loading={preLoading} onRecheck={recheckPreflight} />
{/* Invisible when the media engine is ready; a quiet progress
line while the backend fetches its own bundled build; an
actionable card only on failure. */}
<MediaEngineCard />
{networkDown && <MirrorRescue onApplied={recheckPreflight} />}
</div>
<div
+7
View File
@@ -90,6 +90,7 @@ GET /jobs/{job_id}
GET /jobs/{job_id}/events
GET /longform/jobs
GET /marketplace/browse
GET /media-tools/status
GET /model/loaded
GET /model/status
GET /models
@@ -180,6 +181,12 @@ POST /marketplace/export/{profile_id}
POST /marketplace/import
POST /marketplace/install/{filename}
POST /marketplace/publish/{profile_id}
POST /media-tools/acquire
POST /media-tools/ytdlp/restore
POST /media-tools/ytdlp/update
POST /media-tools/{tool}/custom-path
POST /media-tools/{tool}/restore
POST /media-tools/{tool}/use-system
POST /model/unload/{model_id}
POST /models/install
POST /models/install/cancel
+423
View File
@@ -0,0 +1,423 @@
"""Tests for services.media_tools + the /media-tools router.
The media engine (ffmpeg/ffprobe) is an internal, self-provisioning
dependency these tests pin the contract: origin classification, checksum
+ probe validation on acquisition, override persistence via the existing
env-prefs convention, and the yt-dlp overlay update/restore cycle. All
network I/O is faked; no test downloads anything.
"""
from __future__ import annotations
import hashlib
import io
import os
import zipfile
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def mt(monkeypatch, tmp_path):
"""media_tools with its filesystem + prefs redirected into tmp_path and
op-state reset (module state is process-global)."""
import core.prefs as prefs
import services.media_tools as mt_mod
monkeypatch.setattr(prefs, "_PREFS_PATH", str(tmp_path / "prefs.json"))
monkeypatch.setattr(mt_mod, "media_tools_dir", lambda: str(tmp_path / "media_tools"))
for op in mt_mod._ops.values():
op.update(state="idle", progress=0.0, error=None)
mt_mod._version_cache.clear()
# Never let a test inherit a real user override.
_OVERRIDE_KEYS = ("FFMPEG_PATH", "FFPROBE_PATH", "OMNIVOICE_FFPROBE_PATH")
for key in _OVERRIDE_KEYS:
monkeypatch.delenv(key, raising=False)
yield mt_mod
# set_custom_path()/use_system() write os.environ directly (that's their
# production contract), and monkeypatch.delenv on an *unset* key records
# nothing to restore — so without this, a test's fake FFMPEG_PATH leaks
# into later suites and poisons find_ffmpeg() for real-ffmpeg tests
# (test_pitch_stretch_async was the victim). Explicitly drop them.
for key in _OVERRIDE_KEYS:
os.environ.pop(key, None)
def _client():
from main import app
return TestClient(app, client=("127.0.0.1", 50000))
def _make_zip(names) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name in names:
zf.writestr(name, "#!/bin/sh\necho fake\n")
return buf.getvalue()
class _FakeResponse(io.BytesIO):
def __init__(self, payload: bytes):
super().__init__(payload)
self.headers = {"Content-Length": str(len(payload))}
def __enter__(self):
return self
def __exit__(self, *a):
return False
# ── status / origin classification ──────────────────────────────────────────
def test_status_shape(mt):
st = mt.status()
assert set(st) >= {"ready", "tools", "ops", "platform_key"}
assert set(st["tools"]) == {"ffmpeg", "ffprobe", "ytdlp"}
for tool in ("ffmpeg", "ffprobe"):
assert set(st["tools"][tool]) >= {"tool", "ok", "path", "version", "origin"}
assert set(st["ops"]) == {"acquire", "ytdlp_update"}
assert isinstance(st["ready"], bool)
def test_origin_bundled_for_acquired_and_imageio_paths(mt):
acquired = os.path.join(mt.bundled_dir(), "ffmpeg")
assert mt._classify_origin("ffmpeg", acquired) == "bundled"
pkg = mt._imageio_pkg_dir()
if pkg: # venv ships imageio-ffmpeg
assert mt._classify_origin("ffmpeg", os.path.join(pkg, "binaries", "ffmpeg")) == "bundled"
def test_origin_system_when_no_override(mt):
assert mt._classify_origin("ffmpeg", "/usr/bin/ffmpeg") == "system"
def test_origin_custom_vs_sidecar_disambiguated_by_pref(mt, monkeypatch):
import core.prefs as prefs
path = "/some/where/ffmpeg"
monkeypatch.setenv("FFMPEG_PATH", path)
# Env set by the Tauri host at spawn (no pref) → sidecar.
assert mt._classify_origin("ffmpeg", path) == "sidecar"
# Same env var persisted through the Settings override → custom.
prefs.set_("env.FFMPEG_PATH", path)
assert mt._classify_origin("ffmpeg", path) == "custom"
def test_ytdlp_reports_module_version_without_binary(mt):
st = mt._ytdlp_status()
# yt-dlp is a locked module — always importable in a healthy install.
assert st["ok"] is True
assert st["origin"] == "bundled"
assert st["version"]
# ── download validation ─────────────────────────────────────────────────────
def test_download_rejects_checksum_mismatch(mt, tmp_path):
payload = b"not the pinned bytes"
with patch("urllib.request.urlopen", return_value=_FakeResponse(payload)):
with pytest.raises(RuntimeError, match="checksum"):
mt._download("https://example.test/x.zip", str(tmp_path / "x.zip"),
hashlib.sha256(b"something else").hexdigest(),
len(payload), op="acquire")
def test_download_rejects_size_mismatch(mt, tmp_path):
payload = b"abc"
with patch("urllib.request.urlopen", return_value=_FakeResponse(payload)):
with pytest.raises(RuntimeError, match="size"):
mt._download("https://example.test/x.zip", str(tmp_path / "x.zip"),
hashlib.sha256(payload).hexdigest(), 9999, op="acquire")
def test_download_refuses_plain_http(mt, tmp_path):
with pytest.raises(ValueError, match="https"):
mt._download("http://example.test/x.zip", str(tmp_path / "x.zip"), "0" * 64, 1, op="acquire")
# ── acquisition ─────────────────────────────────────────────────────────────
def _patched_bundle(mt, monkeypatch, payload: bytes):
monkeypatch.setattr(mt, "_expected_bundle", lambda: (
"https://example.test/bundle.zip",
hashlib.sha256(payload).hexdigest(),
len(payload),
))
def test_acquire_installs_when_checksum_and_probe_pass(mt, monkeypatch):
payload = _make_zip([f"plat/{mt._exe('ffmpeg')}", f"plat/{mt._exe('ffprobe')}"])
_patched_bundle(mt, monkeypatch, payload)
monkeypatch.setattr(mt, "_binary_runs", lambda p: True)
with patch("urllib.request.urlopen", return_value=_FakeResponse(payload)):
state = mt.acquire_bundled(wait=True)
assert state["state"] == "done", state
for tool in ("ffmpeg", "ffprobe"):
p = mt.bundled_tool_path(tool)
assert p and os.path.isfile(p)
if os.name == "posix":
assert os.access(p, os.X_OK)
def test_acquire_rejects_binary_that_fails_version_probe(mt, monkeypatch):
"""A checksum-valid download whose binary won't run (wrong arch, corrupt)
must NOT be installed the WinError-193 class, caught at install time."""
payload = _make_zip([f"plat/{mt._exe('ffmpeg')}", f"plat/{mt._exe('ffprobe')}"])
_patched_bundle(mt, monkeypatch, payload)
monkeypatch.setattr(mt, "_binary_runs", lambda p: False)
with patch("urllib.request.urlopen", return_value=_FakeResponse(payload)):
state = mt.acquire_bundled(wait=True)
assert state["state"] == "error"
assert "probe" in (state["error"] or "")
assert mt.bundled_tool_path("ffmpeg") is None
def test_acquire_errors_on_checksum_mismatch_and_installs_nothing(mt, monkeypatch):
payload = _make_zip([f"plat/{mt._exe('ffmpeg')}", f"plat/{mt._exe('ffprobe')}"])
monkeypatch.setattr(mt, "_expected_bundle", lambda: (
"https://example.test/bundle.zip", "0" * 64, len(payload),
))
with patch("urllib.request.urlopen", return_value=_FakeResponse(payload)):
state = mt.acquire_bundled(wait=True)
assert state["state"] == "error"
assert "checksum" in state["error"]
assert mt.bundled_tool_path("ffmpeg") is None
def test_acquire_errors_when_bundle_lacks_ffprobe(mt, monkeypatch):
payload = _make_zip([f"plat/{mt._exe('ffmpeg')}"]) # no ffprobe in the zip
_patched_bundle(mt, monkeypatch, payload)
monkeypatch.setattr(mt, "_binary_runs", lambda p: True)
with patch("urllib.request.urlopen", return_value=_FakeResponse(payload)):
state = mt.acquire_bundled(wait=True)
assert state["state"] == "error"
assert "ffprobe" in state["error"]
def test_acquired_bundle_joins_the_resolution_chain(mt, monkeypatch):
"""ffmpeg_utils must pick up an acquired build without env/system help."""
payload = _make_zip([f"plat/{mt._exe('ffmpeg')}", f"plat/{mt._exe('ffprobe')}"])
_patched_bundle(mt, monkeypatch, payload)
monkeypatch.setattr(mt, "_binary_runs", lambda p: True)
with patch("urllib.request.urlopen", return_value=_FakeResponse(payload)):
assert mt.acquire_bundled(wait=True)["state"] == "done"
import services.ffmpeg_utils as fu
# The service and the chain share bundled_tool_path; only the probe is
# stubbed (the fake "binaries" are shell stubs, not real ffmpeg).
monkeypatch.setattr(fu, "_binary_runs", lambda p: True)
with patch("services.media_tools.bundled_tool_path", side_effect=mt.bundled_tool_path):
assert fu._acquired_bundled("ffprobe") == mt.bundled_tool_path("ffprobe")
# ── overrides: custom / system / restore ────────────────────────────────────
def test_set_custom_path_persists_via_env_prefs_convention(mt, monkeypatch, tmp_path):
import core.prefs as prefs
fake = tmp_path / "myffmpeg"
fake.write_text("#!/bin/sh\n")
fake.chmod(0o755)
monkeypatch.setattr(mt, "_binary_runs", lambda p: True)
info = mt.set_custom_path("ffmpeg", str(fake))
assert os.environ["FFMPEG_PATH"] == str(fake)
assert prefs.get("env.FFMPEG_PATH") == str(fake)
assert info["origin"] == "custom"
# ffprobe persists under its own (already-PERSISTENT) key.
fakeprobe = tmp_path / "myffprobe"
fakeprobe.write_text("#!/bin/sh\n")
fakeprobe.chmod(0o755)
mt.set_custom_path("ffprobe", str(fakeprobe))
assert prefs.get("env.FFPROBE_PATH") == str(fakeprobe)
def test_set_custom_path_rejects_missing_and_non_running_files(mt, tmp_path, monkeypatch):
with pytest.raises(ValueError, match="not found|File not found"):
mt.set_custom_path("ffmpeg", str(tmp_path / "nope"))
bad = tmp_path / "bad"
bad.write_text("MZ")
monkeypatch.setattr(mt, "_binary_runs", lambda p: False)
with pytest.raises(ValueError, match="does not run"):
mt.set_custom_path("ffmpeg", str(bad))
assert os.environ.get("FFMPEG_PATH") != str(bad)
def test_set_custom_path_rejects_control_characters(mt):
with pytest.raises(ValueError, match="control characters"):
mt.set_custom_path("ffmpeg", "/usr/bin/ff\nmpeg")
def test_use_system_pins_detected_copy_and_404s_when_absent(mt, monkeypatch, tmp_path):
import core.prefs as prefs
sysbin = tmp_path / "sys-ffmpeg"
sysbin.write_text("#!/bin/sh\n")
sysbin.chmod(0o755)
monkeypatch.setattr(mt, "_binary_runs", lambda p: True)
monkeypatch.setattr(mt, "_detect_system", lambda tool: str(sysbin))
info = mt.use_system("ffmpeg")
assert prefs.get("env.FFMPEG_PATH") == str(sysbin)
assert info["path"] == str(sysbin) or info["ok"]
monkeypatch.setattr(mt, "_detect_system", lambda tool: None)
with pytest.raises(LookupError):
mt.use_system("ffprobe")
def test_restore_bundled_clears_override_and_is_always_safe(mt, monkeypatch, tmp_path):
import core.prefs as prefs
fake = tmp_path / "custom-ffmpeg"
fake.write_text("#!/bin/sh\n")
fake.chmod(0o755)
monkeypatch.setattr(mt, "_binary_runs", lambda p: True)
mt.set_custom_path("ffmpeg", str(fake))
assert prefs.get("env.FFMPEG_PATH")
acquired = []
monkeypatch.setattr(mt, "acquire_bundled", lambda wait=False: acquired.append(1) or {"state": "running"})
mt.restore_bundled("ffmpeg")
assert prefs.get("env.FFMPEG_PATH") is None
assert "FFMPEG_PATH" not in os.environ
def test_unknown_tool_rejected_everywhere(mt):
for fn in (mt.set_custom_path, ):
with pytest.raises(ValueError):
fn("nano", "/bin/sh")
with pytest.raises(ValueError):
mt.use_system("nano")
with pytest.raises(ValueError):
mt.restore_bundled("nano")
# ── yt-dlp overlay ──────────────────────────────────────────────────────────
def _fake_wheel(version: str) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("yt_dlp/__init__.py", "")
zf.writestr("yt_dlp/version.py", f"__version__ = '{version}'\n")
zf.writestr(f"yt_dlp-{version}.dist-info/METADATA", "Name: yt-dlp\n")
return buf.getvalue()
def test_update_ytdlp_builds_overlay_and_records_baseline(mt, monkeypatch):
import core.prefs as prefs
wheel = _fake_wheel("2099.01.01")
monkeypatch.setattr(mt, "_fetch_pypi_ytdlp", lambda: (
"2099.01.01", "https://example.test/yt_dlp.whl",
hashlib.sha256(wheel).hexdigest(),
))
with patch("urllib.request.urlopen", return_value=_FakeResponse(wheel)):
state = mt.update_ytdlp(wait=True)
assert state["state"] == "done"
assert state["version"] == "2099.01.01"
overlay_pkg = os.path.join(mt._ytdlp_overlay_dir(), "yt_dlp")
assert os.path.isfile(os.path.join(overlay_pkg, "version.py"))
assert mt._read_ytdlp_version(overlay_pkg) == "2099.01.01"
# dist-info never lands in the overlay (only the package itself).
assert not any("dist-info" in n for n in os.listdir(mt._ytdlp_overlay_dir()))
# The pre-update locked version was recorded as the restore target.
assert prefs.get("media_tools.ytdlp_baseline")
st = mt.status()["tools"]["ytdlp"]
assert st["overlay_version"] == "2099.01.01"
def test_update_ytdlp_rejects_checksum_mismatch(mt, monkeypatch):
wheel = _fake_wheel("2099.01.01")
monkeypatch.setattr(mt, "_fetch_pypi_ytdlp", lambda: (
"2099.01.01", "https://example.test/yt_dlp.whl", "0" * 64,
))
with patch("urllib.request.urlopen", return_value=_FakeResponse(wheel)):
state = mt.update_ytdlp(wait=True)
assert state["state"] == "error"
assert "checksum" in state["error"]
assert not os.path.isdir(mt._ytdlp_overlay_dir())
def test_restore_ytdlp_deletes_overlay(mt, monkeypatch):
wheel = _fake_wheel("2099.01.01")
monkeypatch.setattr(mt, "_fetch_pypi_ytdlp", lambda: (
"2099.01.01", "https://example.test/yt_dlp.whl",
hashlib.sha256(wheel).hexdigest(),
))
with patch("urllib.request.urlopen", return_value=_FakeResponse(wheel)):
mt.update_ytdlp(wait=True)
assert os.path.isdir(mt._ytdlp_overlay_dir())
mt.restore_ytdlp()
assert not os.path.isdir(mt._ytdlp_overlay_dir())
def test_activate_overlay_prepends_sys_path(mt, monkeypatch):
import sys as _sys
overlay = mt._ytdlp_overlay_dir()
os.makedirs(os.path.join(overlay, "yt_dlp"), exist_ok=True)
monkeypatch.setattr(_sys, "path", list(_sys.path))
assert mt.activate_ytdlp_overlay() is True
assert _sys.path[0] == overlay
# Idempotent.
assert mt.activate_ytdlp_overlay() is False
def test_ytdlp_invocation_prefers_module_over_path(mt):
argv, env = mt.ytdlp_invocation()
import sys as _sys
assert argv[:3] == [_sys.executable, "-m", "yt_dlp"]
assert env is None # no overlay → inherit environment
# ── router ──────────────────────────────────────────────────────────────────
def test_router_status_and_acquire_endpoints(mt, monkeypatch):
c = _client()
r = c.get("/media-tools/status")
assert r.status_code == 200
body = r.json()
assert set(body) >= {"ready", "tools", "ops"}
monkeypatch.setattr(mt, "acquire_bundled", lambda wait=False: {"state": "running", "progress": 0.0, "error": None})
r = c.post("/media-tools/acquire")
assert r.status_code == 200
assert r.json()["state"] == "running"
def test_router_custom_path_maps_validation_to_400(mt):
c = _client()
r = c.post("/media-tools/ffmpeg/custom-path", json={"path": "/no/such/binary"})
assert r.status_code == 400
assert "not found" in r.json()["detail"].lower()
def test_router_use_system_maps_lookup_to_404(mt, monkeypatch):
monkeypatch.setattr(mt, "_detect_system", lambda tool: None)
c = _client()
r = c.post("/media-tools/ffprobe/use-system")
assert r.status_code == 404
def test_router_ytdlp_routes_not_shadowed_by_tool_param(mt, monkeypatch):
"""/media-tools/ytdlp/restore must hit the overlay-restore handler, not
the parametrized {tool}/restore (which would 400 on 'ytdlp')."""
c = _client()
r = c.post("/media-tools/ytdlp/restore")
assert r.status_code == 200
assert r.json()["tool"] == "yt-dlp"
monkeypatch.setattr(mt, "update_ytdlp", lambda wait=False: {"state": "running", "progress": 0.0, "error": None, "version": None})
r = c.post("/media-tools/ytdlp/update")
assert r.status_code == 200
assert r.json()["state"] == "running"
def test_router_is_loopback_gated(mt):
from main import app
c = TestClient(app) # client.host = 'testclient' → non-loopback
for method, path in [
("get", "/media-tools/status"),
("post", "/media-tools/acquire"),
("post", "/media-tools/ffmpeg/use-system"),
("post", "/media-tools/ytdlp/update"),
]:
r = getattr(c, method)(path)
assert r.status_code == 403, f"{path} must be loopback-only"
+69 -2
View File
@@ -43,16 +43,83 @@ def test_preflight_every_check_has_required_fields(client):
def test_preflight_always_probes_core_checks(client):
"""The fixed set of checks should always be present — users need a
consistent list regardless of platform."""
consistent list regardless of platform. Genuine user facts only."""
body = client.get("/setup/preflight").json()
ids = {c["id"] for c in body["checks"]}
required_ids = {
"os", "python", "ram", "disk", "hf_cache_writable",
"ffmpeg", "ffprobe", "gpu", "network",
"gpu", "network",
}
assert required_ids.issubset(ids), f"missing: {required_ids - ids}"
def test_preflight_never_lists_media_tools_as_requirements(client):
"""ffmpeg / ffprobe / yt-dlp are internal dependencies the app provisions
for itself they must NOT appear as system-requirement check rows (the
old model told users to `brew install ffmpeg`)."""
body = client.get("/setup/preflight").json()
ids = {c["id"] for c in body["checks"]}
assert not ids & {"ffmpeg", "ffprobe", "yt-dlp"}, ids
joined = " ".join(f"{c['detail']} {c.get('fix') or ''}" for c in body["checks"])
assert "brew install ffmpeg" not in joined
assert "yt-dlp" not in joined
def test_preflight_carries_media_tools_verdict(client):
"""The wizard's quiet progress line / failure card reads a top-level
media_tools verdict: {ready, acquire:{state, progress, error}}."""
body = client.get("/setup/preflight").json()
media = body.get("media_tools")
assert media is not None
assert isinstance(media["ready"], bool)
assert media["acquire"]["state"] in {"idle", "running", "done", "error"}
def test_preflight_kicks_background_acquisition_when_unresolved():
"""No tier resolves → preflight itself starts the bundled download (the
first-run self-heal) instead of telling the user to install anything."""
import services.media_tools as mt
calls = []
with patch.object(mt, "status", return_value={
"ready": False, "tools": {},
"ops": {"acquire": {"state": "idle", "progress": 0.0, "error": None},
"ytdlp_update": {"state": "idle"}},
"platform_key": "test",
}), patch.object(mt, "acquire_bundled",
side_effect=lambda wait=False: calls.append(1) or
{"state": "running", "progress": 0.0, "error": None}):
body = client_factory().get("/setup/preflight").json()
assert calls, "preflight must trigger acquire_bundled when unresolved"
assert body["media_tools"] == {
"ready": False,
"acquire": {"state": "running", "progress": 0.0, "error": None},
}
# And the media engine never blocks the Continue gate.
checks_fail = any(c["status"] == "fail" for c in body["checks"])
assert body["ok"] is (not checks_fail)
def test_preflight_does_not_retrigger_after_failed_acquisition():
"""After a failed download the wizard's failure card owns Retry —
a preflight recheck must not silently re-fire the download."""
import services.media_tools as mt
with patch.object(mt, "status", return_value={
"ready": False, "tools": {},
"ops": {"acquire": {"state": "error", "progress": 0.0,
"error": "download checksum mismatch"},
"ytdlp_update": {"state": "idle"}},
"platform_key": "test",
}), patch.object(mt, "acquire_bundled") as fired:
body = client_factory().get("/setup/preflight").json()
fired.assert_not_called()
assert body["media_tools"]["acquire"]["state"] == "error"
assert "checksum" in body["media_tools"]["acquire"]["error"]
def test_preflight_device_summary(client):
"""device block must include os/arch/gpu_vendor/gpu_backend/ram_gb."""
body = client.get("/setup/preflight").json()