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:
co-authored by
mergetest
Claude Fable 5
parent
34c8a33628
commit
5562aa16a7
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Vendored
+7
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user