Four pieces of test debt, root-caused and hardened:
1. exports.py test coverage (was: zero dedicated tests): new
tests/test_exports_api.py (26 tests) covering /export, /export/record,
/export/history, /export/reveal — happy paths, traversal/containment
guards (incl. symlink escape), destination validation, error mapping,
and the mp4 watermark-overlay branch with its plain-copy fallback.
Two real bugs found and fixed in the router:
- _safe_destination checked isabs() on realpath()'s output, which is
always absolute — dead check; a relative destination silently exported
to a cwd-dependent location instead of the documented 400.
- _safe_source let "." / ".." through the basename guard (caught only
later by realpath containment as a confusing 404); now 400 up front.
2. CI-Linux fp16 default-dtype leak (test_prefers_vocals_over_mix,
test_final_dub_track_and_seg_wav_are_watermarked): not reproducible on
macOS — instrumenting torch.set_default_dtype across both tests records
zero non-fp32 sets locally. Both tests now carry an opt-in
torch_dtype_isolation fixture (save/restore, so the leak can never
spread), and the conftest guard is demoted to pure insurance. A cheap
permanent recorder wraps torch.set_default_dtype /
set_default_tensor_type once torch appears and captures the setter's
stack only on a non-fp32 set; both fixtures print that stack when they
fire, so the next CI occurrence names the exact culprit call chain.
3. Test-order pollution (both reported combos): root cause was
collection-time sys.modules stubbing in backend/tests — seven modules
installed bare ModuleType stubs for core.config (and test_capture_ws.py
for services.model_manager/asr_backend/ffmpeg_utils, now all lazily
imported by the router anyway). pytest imports test modules during
collection, so the stubs leaked process-wide before any test ran:
- combo (a): monkeypatch.setattr("core.config.OUTPUTS_DIR", ...) in
test_longform_e2e died with AttributeError (core never gets a .config
attribute when the import is satisfied straight from sys.modules).
- combo (b): test_router_smoke's `from main import app` died with
ImportError: cannot import name 'find_ffmpeg' (unknown location).
Fix at source: new backend/tests/conftest.py sets a hermetic
OMNIVOICE_DATA_DIR (mirroring tests/conftest.py, #878) and the real
core.config is imported everywhere — zero sys.modules surgery. New
backend/tests/test_no_module_stubs.py guards the whole class (verified
fail-before/pass-after against the old stub). Stale rationale comments
in pyproject.toml and ci.yml updated to match.
4. batched_tts.py TODO(#312): investigated, comment corrected only —
#312 is closed (the live routes are engine-aware); this module has zero
call sites and stays an unintegrated experiment. See PR notes.
Full tests/ suite: 2796 passed. backend/tests standalone: 130 passed.
Both pollution combos re-run green in the reported orderings.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
162 lines
6.5 KiB
Python
162 lines
6.5 KiB
Python
import os
|
|
import uuid
|
|
import time
|
|
import shutil
|
|
import subprocess
|
|
import platform
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
from core.db import db_conn
|
|
from core.config import OUTPUTS_DIR
|
|
from core import event_bus
|
|
from schemas.requests import ExportRequest, ExportRecordRequest, RevealRequest
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _safe_destination(raw: str) -> str:
|
|
"""Resolve + validate an export destination. Rejects relative/empty paths."""
|
|
if not raw or not raw.strip():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Export needs a destination folder. Pick where the file should go and try again.",
|
|
)
|
|
expanded = os.path.expanduser(raw)
|
|
# Check BEFORE realpath(): realpath absolutizes a relative path against
|
|
# the server's cwd, which made this check dead code — a relative
|
|
# destination silently exported to a cwd-dependent location instead of
|
|
# the documented 400 (regression-tested in tests/test_exports_api.py).
|
|
if not os.path.isabs(expanded):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="The destination needs to be a full path (e.g. /Users/you/Movies/OmniVoice) — not relative.",
|
|
)
|
|
dest = os.path.realpath(expanded)
|
|
parent = os.path.dirname(dest)
|
|
if not parent or not os.path.isdir(parent):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="That destination folder doesn't exist yet. Create it first, or pick an existing one.",
|
|
)
|
|
return dest
|
|
|
|
|
|
def _safe_source(filename: str) -> str:
|
|
"""Resolve a source filename against OUTPUTS_DIR / dub outputs, blocking traversal."""
|
|
base = os.path.basename(filename or "")
|
|
# "." and ".." are their own basename, so they'd slip past the
|
|
# base != filename check and only die later on realpath containment —
|
|
# reject them up front with the same 400 as any other malformed name.
|
|
if not base or base != filename or base in (".", ".."):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="The file to export has an unexpected name. Try re-generating the audio and exporting again.",
|
|
)
|
|
for root in (OUTPUTS_DIR, os.path.join("dub", "outputs")):
|
|
candidate = os.path.realpath(os.path.join(root, base))
|
|
root_real = os.path.realpath(root)
|
|
if candidate.startswith(root_real + os.sep) and os.path.exists(candidate):
|
|
return candidate
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="That file isn't on disk anymore — it may have been cleaned up. Regenerate and try again.",
|
|
)
|
|
|
|
|
|
@router.post("/export")
|
|
def export_file(req: ExportRequest):
|
|
src = _safe_source(req.source_filename)
|
|
dest = _safe_destination(req.destination_path)
|
|
try:
|
|
# Video exports: overlay OmniVoice logo if visible watermark is enabled
|
|
if src.lower().endswith(".mp4"):
|
|
from services.watermark import is_visible_video_enabled, get_ffmpeg_overlay_args
|
|
logo_path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "docs", "logo.png")
|
|
logo_path = os.path.realpath(logo_path)
|
|
if is_visible_video_enabled() and os.path.exists(logo_path):
|
|
overlay_args = get_ffmpeg_overlay_args(logo_path)
|
|
if overlay_args:
|
|
try:
|
|
subprocess.run(
|
|
["ffmpeg", "-y", "-i", src, "-i", logo_path]
|
|
+ overlay_args
|
|
+ ["-codec:a", "copy", dest],
|
|
check=True,
|
|
capture_output=True,
|
|
timeout=120,
|
|
)
|
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
# Fallback: plain copy if ffmpeg overlay fails
|
|
shutil.copy2(src, dest)
|
|
else:
|
|
shutil.copy2(src, dest)
|
|
else:
|
|
shutil.copy2(src, dest)
|
|
else:
|
|
shutil.copy2(src, dest)
|
|
except OSError as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
export_id = str(uuid.uuid4())[:8]
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO export_history (id, filename, destination_path, mode, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
(export_id, req.source_filename, dest, req.mode, time.time()),
|
|
)
|
|
event_bus.emit("export_history", {"action": "exported", "id": export_id})
|
|
return {"success": True, "id": export_id}
|
|
|
|
|
|
@router.post("/export/record")
|
|
def record_export(req: ExportRecordRequest):
|
|
export_id = str(uuid.uuid4())[:8]
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO export_history (id, filename, destination_path, mode, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
(export_id, req.filename, req.destination_path, req.mode, time.time()),
|
|
)
|
|
event_bus.emit("export_history", {"action": "recorded", "id": export_id})
|
|
return {"success": True, "id": export_id}
|
|
|
|
|
|
@router.get("/export/history")
|
|
def get_export_history():
|
|
with db_conn() as conn:
|
|
rows = conn.execute("SELECT * FROM export_history ORDER BY created_at DESC LIMIT 50").fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
@router.post("/export/reveal")
|
|
def reveal_in_folder(req: RevealRequest):
|
|
# Tauri/native dialog-provided path; subprocess uses list args (no shell interpolation).
|
|
if not req.path or not req.path.strip():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="No path was provided — nothing to reveal.",
|
|
)
|
|
target = os.path.realpath(os.path.expanduser(req.path))
|
|
if not os.path.exists(target):
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="That file or folder is no longer on disk. It may have been moved or deleted.",
|
|
)
|
|
|
|
folder = target if os.path.isdir(target) else os.path.dirname(target)
|
|
system = platform.system()
|
|
try:
|
|
if system == "Darwin":
|
|
if os.path.isfile(target):
|
|
subprocess.Popen(["open", "-R", target])
|
|
else:
|
|
subprocess.Popen(["open", folder])
|
|
elif system == "Windows":
|
|
if os.path.isfile(target):
|
|
subprocess.Popen(["explorer", "/select,", target.replace("/", "\\")])
|
|
else:
|
|
subprocess.Popen(["explorer", folder.replace("/", "\\")])
|
|
else:
|
|
subprocess.Popen(["xdg-open", folder])
|
|
return {"success": True}
|
|
except OSError as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|