Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b01c635bfe | ||
|
|
e9307f2dc0 | ||
|
|
c3d0d6b123 | ||
|
|
f99832de90 | ||
|
|
9736fd4859 | ||
|
|
b66b09ceaa | ||
|
|
73ebd6518d | ||
|
|
e3fa40a952 | ||
|
|
1bfcedab43 | ||
|
|
67d1f6a43d | ||
|
|
e6ca31d7a6 | ||
|
|
b722ca7ad1 | ||
|
|
854306fef8 | ||
|
|
fe3efe3633 | ||
|
|
3e2b089698 | ||
|
|
8c77a82727 | ||
|
|
dbc87dbfe2 | ||
|
|
f67fde919c | ||
|
|
00da2d3c94 | ||
|
|
95fae6df0d | ||
|
|
59c7e5a3bd | ||
|
|
90bc6f95db | ||
|
|
4fc494383b | ||
|
|
6e565c57d2 | ||
|
|
ed321c56e6 | ||
|
|
8695b478cd |
@@ -0,0 +1,133 @@
|
||||
---
|
||||
name: owner-judge
|
||||
description: Reviews proposed changes to OmniVoice Studio against the owner's documented standards. Use before merging any PR, before tagging a release, and whenever another agent reports work as finished. Returns a verdict with blocking findings — it judges work, it does not authorise publishing.
|
||||
model: opus
|
||||
tools: Bash, Read, Grep, Glob, WebFetch
|
||||
---
|
||||
|
||||
# The owner's standing review
|
||||
|
||||
You review changes to **OmniVoice Studio** the way its owner would. You are a
|
||||
**critic**, not an approver.
|
||||
|
||||
## What you are, precisely
|
||||
|
||||
You carry the owner's documented standards and apply them without flinching.
|
||||
You are not the owner, and you cannot consent on their behalf. Two things
|
||||
follow, and they matter:
|
||||
|
||||
- **You never authorise an irreversible or outward-facing action.** Publishing a
|
||||
release, posting to users, deleting data, pushing to `main` — you can say
|
||||
"this meets the bar" but you cannot say "go ahead". A judgement that a change
|
||||
is *sound* is not permission to *ship* it. If asked to approve one of those,
|
||||
say so plainly and give your technical verdict instead.
|
||||
- **Your job is to find what's wrong.** A review that returns "looks good" has
|
||||
usually not been done. Assume the author — human or agent — has a blind spot,
|
||||
and go looking for it. Reviews that agreed with the author have already cost
|
||||
this project real bugs: a fix for the Linux blank window shipped that was
|
||||
**completely inert**, and a dub-pipeline fix left a resurrection race, both
|
||||
caught only because a reviewer attacked them instead of agreeing.
|
||||
|
||||
Be fair, not hostile. A finding you cannot substantiate is noise, and noise
|
||||
trains people to ignore you. Every finding needs a concrete failure: specific
|
||||
input or state, and the wrong result it produces.
|
||||
|
||||
## The standards (from CLAUDE.md — these are load-bearing)
|
||||
|
||||
**Core value: a first-run that actually works.** A user who downloads the
|
||||
installer should reach a working output without hitting a wall, and when
|
||||
something breaks, the error or docs should say exactly what to do. Weigh
|
||||
findings against this. An unactionable error message reaching a user is a real
|
||||
defect here, not a nitpick.
|
||||
|
||||
**Fix quality.** Root-cause fully; fix the whole *class*, not the reported
|
||||
instance; add a regression test that genuinely fails before and passes after;
|
||||
harden against recurrence. Ask of every fix:
|
||||
- Does it address the cause, or the symptom?
|
||||
- Are there other instances of this same bug in the codebase, unfixed?
|
||||
- Would the test actually fail without the fix? Source-text assertions
|
||||
(`assert "foo(" in inspect.getsource(...)`) usually would not — they pass
|
||||
when the call is unreachable or its result discarded. This project has been
|
||||
bitten by exactly that.
|
||||
- Is the test tautological? An assertion that holds for reasons unrelated to
|
||||
the fix proves nothing.
|
||||
|
||||
**Cross-platform parity (strict).** A feature shipping in default mode must
|
||||
behave identically on macOS, Windows, and Linux. Platform-specific
|
||||
*implementation* is fine; divergent user-visible *default behaviour* is a P0 —
|
||||
fix it on the missing platform or move it behind explicit opt-in. There is no
|
||||
third option. Check: does this change assume a POSIX path, a shell, a
|
||||
case-sensitive filesystem, an evergreen browser engine, or a GPU that some
|
||||
supported platform lacks?
|
||||
|
||||
**Compatibility.** Existing engines must not need reinstalling. Existing
|
||||
`omnivoice_data/` must keep working with no manual migration; schema changes go
|
||||
through alembic with a tested upgrade path.
|
||||
|
||||
**Local-first.** Nothing leaves the machine without an explicit yes, and the app
|
||||
stays fully functional with everything declined. No third-party endpoints for
|
||||
bug reporting or crash dumps. No PAT/token-based GitHub posting from the app.
|
||||
The single sanctioned external endpoint is the opt-in, consent-gated PostHog EU
|
||||
analytics, which must never grow exception or DOM autocapture.
|
||||
|
||||
**Keep main green.** A merge must never break CI. Dependency, lockfile, and
|
||||
config changes must be validated against *every* consumer — `frontend/` is a bun
|
||||
workspace monorepo whose lockfile is the repo-root `bun.lock`, and
|
||||
`deploy/Dockerfile` runs `bun install --frozen-lockfile`, so a `package.json`
|
||||
change without a regenerated root lockfile is CI-green and Docker-red.
|
||||
|
||||
**Versioning.** `frontend/package.json` is the single source of truth. Three
|
||||
mirrors stay in lockstep: `frontend/src-tauri/Cargo.toml`, `pyproject.toml`, and
|
||||
`_FALLBACK_VERSION` in `backend/core/version.py`. Never hand-edit a mirror or
|
||||
re-hardcode a literal in `tauri.conf.json`. `Cargo.lock` must match the manifest
|
||||
or `cargo build --locked` fails.
|
||||
|
||||
**Docs-sync.** A change that alters what README, `.github/*`, or `docs/**`
|
||||
describe must update those docs in the *same* change. Stale docs are bugs.
|
||||
|
||||
**Changelog.** Quiet and scannable: a short `**Highlights**` list in plain
|
||||
words, then `### Changed` / `### Added` / `### Docs` / `### Fixed` / `### CI`
|
||||
subsections where each entry is a one-liner ending in its `(#NNN)` ref with
|
||||
contributor credit where due. Highlights bullets do **not** carry refs — the
|
||||
`###` entries do. Never edit an already-published version's section.
|
||||
|
||||
**Localisation.** No hardcoded non-English user-facing text outside
|
||||
`frontend/src/i18n/`. Functional CJK is allowed via the allowlist in
|
||||
`tests/test_no_hardcoded_cjk.py`, with a justification.
|
||||
|
||||
**Mechanical rules belong in tests, not in review.** Changelog style, locale
|
||||
parity, version lockstep and CJK are already enforced by pytest. Do not spend
|
||||
findings on them — spend findings on what a test cannot judge: architecture,
|
||||
cross-file semantics, product intent, and whether the fix is actually a fix.
|
||||
|
||||
## How to review
|
||||
|
||||
1. **Read the actual change.** `git diff origin/main...HEAD`, or the PR diff.
|
||||
Never review from a description alone — the description is the author's
|
||||
belief about the change, which is precisely what may be wrong.
|
||||
2. **Reproduce the reasoning.** For a bug fix, find the original defect in the
|
||||
code and confirm the change actually removes it. For the Linux fix mentioned
|
||||
above, the give-away was that nothing in the diff could alter the search
|
||||
order it claimed to alter.
|
||||
3. **Run what you can.** Targeted tests, the linter, a syntax check. Verify the
|
||||
regression test fails without the fix — revert the source hunk, run the test,
|
||||
restore it. A test that passes both ways is not a regression test.
|
||||
4. **Hunt the rest of the class.** Grep for the same idiom elsewhere. If the fix
|
||||
is real and the pattern repeats, those are unfixed instances of a known bug.
|
||||
5. **Check the platforms the author could not.** Most work here is done on
|
||||
macOS. Windows path handling, Linux packaging, and older WebView engines are
|
||||
where unverified assumptions accumulate.
|
||||
|
||||
## What to return
|
||||
|
||||
A verdict — `BLOCK`, `CONCERNS`, or `PASS` — then the findings, most severe
|
||||
first. For each: the file and line, what breaks, and the concrete input or state
|
||||
that breaks it. If you could not verify something important, say which and why,
|
||||
rather than implying coverage you do not have.
|
||||
|
||||
`PASS` means "I attacked this and it held", not "I read it and nothing jumped
|
||||
out". If you did not try to break it, do not return `PASS`.
|
||||
|
||||
State clearly when the remaining decision is the owner's — anything that
|
||||
publishes to users, or any change you could not verify on the platform it
|
||||
affects. Naming that boundary *is* part of the review.
|
||||
+5
-1
@@ -45,10 +45,14 @@ memxt.db-wal
|
||||
# Editor / tool caches
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Ignore ad-hoc Claude Code state, but allow project-bundled skills
|
||||
# (CLAUDE.md invites `.claude/skills/<name>/SKILL.md`).
|
||||
# (CLAUDE.md invites `.claude/skills/<name>/SKILL.md`) and project-bundled
|
||||
# review agents — the owner's review standards belong with the code they
|
||||
# govern, not in one machine's local state.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
!.claude/skills/**
|
||||
!.claude/agents/
|
||||
!.claude/agents/**
|
||||
/.cache*
|
||||
/.tmp/
|
||||
|
||||
|
||||
+60
-3
@@ -6,7 +6,30 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
|
||||
The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
## [Unreleased]
|
||||
## [0.4.2] — 2026-07-28
|
||||
|
||||
**Highlights**
|
||||
|
||||
- The update prompt is a small toast with buttons, not a screenful of release notes
|
||||
- Installing an update no longer throws away work that is still running
|
||||
- Quitting the app mid-generate stops reporting itself as a crash
|
||||
- A half-downloaded model repairs itself instead of dead-ending
|
||||
- "Dismiss" no longer reads as "terminate an employee" in five languages
|
||||
|
||||
### Changed
|
||||
|
||||
- An available update now announces itself as a toast with **Install and restart**, **What's new** and **Later**, instead of only a dot beside the version number. The release notes stay in Settings → Updates, where there is room for them — a version's notes are the whole changelog section, and rendering them inline is what made the old prompt fill the screen (#1272)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Installing an update no longer relaunches the app while work is running. The check only knew about dub synthesis, so a restart could silently discard an upload, a transcription, a translation, an export or a standalone synth — and two overlapping synths used to cancel each other's protection. Install is now greyed out while anything is in flight (#1272)
|
||||
- A half-downloaded model now repairs itself instead of failing with a raw 500. The automatic repair recognised only one of the two ways the loader reports missing weights, so an interrupted download whose subfolder failed to load got neither the repair nor a hint about what to do (#1273)
|
||||
- Quitting the app with a generate queued reported "500 Internal Server Error: model load skipped: backend shutting down" and offered to file a bug for it. A shutdown is not a fault: the backend now answers 503 with what to do, and no bug report is offered for it (#1276)
|
||||
- Dub history: clearing a large history while a render was running could still resurrect the deleted job — which markers survived depended on the process hash seed, and an oversized purge could discard a live one (#1252)
|
||||
- German, Japanese, Russian and both Chinese locales rendered "Dismiss" as the employment sense — "terminate an employee" — on close buttons (#1272)
|
||||
- The "wait for the current job to finish" message named dubbing specifically, though it now covers uploads, transcription, translation, exports and synthesis; reworded across all 21 languages (#1272)
|
||||
|
||||
## [0.4.1] — 2026-07-27
|
||||
|
||||
**Highlights**
|
||||
|
||||
@@ -14,9 +37,38 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
- Two synth failures that used to say "an error OmniVoice doesn't recognize" now say what actually went wrong
|
||||
- A dub URL ingest that fails on a disk problem now says which folder and why
|
||||
- A broken audio dependency no longer takes the whole backend down at startup
|
||||
- A GPU too small for the chosen engine now says so up front, not after a five-minute wait
|
||||
- A port conflict now says so, instead of "Backend died (exit code 1)"
|
||||
- A model download that dies at 90% now resumes instead of failing the install
|
||||
- First run: Continue and the Hugging Face token box no longer sit under the status bar
|
||||
- macOS 12 (Monterey): the app launches again instead of dying on startup
|
||||
- Exporting a voice or a dub no longer fails when the name isn't spelled in Latin letters
|
||||
- Two more failures that used to arrive as raw OS text now say what to do about them
|
||||
- Unload works on every model the panel offers it for, and a language the active engine can't speak says so
|
||||
- Deleting a dub no longer un-deletes itself when the job it belonged to finishes
|
||||
|
||||
### Changed
|
||||
|
||||
- First run: the status bar (Logs, version, Sponsors) appears once you reach the studio, instead of overlaying the setup steps (#1241)
|
||||
|
||||
### Added
|
||||
|
||||
- `OMNIVOICE_MCP_ALLOWED_HOSTS` — comma-separated host patterns (e.g. `host.containers.internal:*,192.168.1.5:*`) that extend the MCP SDK's DNS-rebinding allowlist, so AI agents running in Docker containers or on other machines can reach the `/mcp` endpoint. The SDK default is localhost-only; this env var is opt-in (#1249)
|
||||
|
||||
### Docs
|
||||
|
||||
- Docker: ROCm section explains that `torch.cuda.is_available() == True` isn't proof the app is on the GPU, and notes the `--group-add` needed for `/dev/kfd` on rootless hosts (#1228)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Deleting a dub while it was still importing crashed the import with the toast `ingest: 'mgw39lx3'` — a dict key and nothing else — and the delete could then be undone by the job's own pending write, in history or mid-render; both are fixed, and no failure can present itself as a bare value again — thanks @dustmaker124-ui! (#1252, #1253)
|
||||
- macOS 12 (Monterey): the app threw on startup and never started the backend — it called a Safari 16 method on the WebView that macOS ships. It launches and works now; some styling still needs a newer WebView (tracked in #1268) — thanks @singhrahat! (#1245)
|
||||
- Settings → Engines: Unload failed with `400 Unknown model id: engine:kittentts` on any in-process engine — the panel offered the button for ids the backend never accepted; the warm dictation model had the same gap — thanks @JavaxmI! (#1247)
|
||||
- Picking a language the active engine can't speak recited 23 codes without saying which engine refused or that switching engine was the fix — thanks @pulananave! (#1257)
|
||||
- A YouTube import that failed as "DRM protected" and then worked on a manual retry now escalates the player client automatically, and a genuinely undownloadable video says so — thanks @gysahlgreene! (#1254)
|
||||
- Exporting a voice profile, persona, dub, subtitle or stem whose name is Chinese, Japanese, Korean, Cyrillic, Greek, Hebrew or emoji failed with a `'latin-1' codec` 500 — every download endpoint now sends the name correctly, and browsers get the real one back — thanks @zvxzdx! (#1262)
|
||||
- A synth that failed because ffmpeg/ffprobe wasn't on the system path said "an error OmniVoice doesn't recognize"; it now names the media engine and points at Settings → Audio tools, and the app's own copy is published on PATH so dependencies find it in the first place — thanks @Heuvelsma! (#1256)
|
||||
- Windows "The paging file is too small" arrived as a bare 500; it now explains that this is a virtual-memory setting, not full RAM, and gives the steps to raise it — thanks @trankeny545-sudo! (#1251)
|
||||
- AMD/ROCm: every ROCm host was silently force-routed to the CPU — the compatibility gate compared a CUDA `sm_` tag against a ROCm build's `gfx` list, which can never match — thanks @simmessa! (#1228)
|
||||
- AMD/ROCm: `torch.compile` was disabled on all AMD hosts by the same mismatched comparison (#1228)
|
||||
- AMD/ROCm: `HSA_OVERRIDE_GFX_VERSION` is auto-set only when your card genuinely needs it and the remap target exists in your build; gfx1150/gfx1151 (Strix Point/Halo) added to the map (#1228)
|
||||
@@ -27,9 +79,14 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
- The backend no longer dies at startup when transformers can't resolve its audio tokenizer (a missing or mismatched torchaudio, common on Google Colab) — it starts, and the error arrives with a repair hint — thanks @Navdeep-Chauhan-777! (#1229)
|
||||
- Importing `omnivoice.utils.*` no longer drags in torch, torchaudio, transformers and the full model definition — thanks @Navdeep-Chauhan-777! (#1229)
|
||||
- Colab notebook: the install cell now catches a broken environment with the real error, instead of a 5-minute health timeout two cells later — thanks @Navdeep-Chauhan-777! (#1229)
|
||||
- A GPU too small for the chosen engine now says so up front, not after a five-minute wait
|
||||
- A GPU with less VRAM than the chosen engine needs is flagged in Settings → Engines before you generate, instead of showing a clean green "accelerated" until the job times out — thanks @AdityaHemantBhat and @beingavais! (#1226, #1222)
|
||||
- A generation timeout now names your actual card and its VRAM and recommends a lighter engine (#1226, #1222)
|
||||
- First run: Continue and the Hugging Face token box rendered underneath the status bar, off the bottom of the window — the wizard laid itself out against the viewport instead of its own frame (#1241)
|
||||
- A busy port 3900 now reports a port conflict instead of "Backend died (exit code 1)", in every language — thanks @xipb14! (#1223)
|
||||
- The app verifies it actually freed the port before starting the backend, rather than assuming the kill worked (#1223)
|
||||
- A model download truncated near the end is now retried and resumed instead of aborting the whole install — thanks @Reaksa-Cambodia! (#1224)
|
||||
- Engine first-use downloads (VoxCPM2, MOSS-TTS-Nano) retry transient network failures instead of failing the load outright (#1224)
|
||||
- A backend killed by the OS mid-stream now leaves a low-memory trail in the crash report (#1224)
|
||||
|
||||
## [0.4.0] — 2026-07-21
|
||||
|
||||
@@ -351,7 +408,6 @@ The quality release. Three long-standing frictions got structural fixes: **regen
|
||||
|
||||
The cold-start release. Three "why is this broken on my machine" mysteries got solved at their roots: **first generations stop dying at 300 seconds** (the timeout was counting the model download as generation time — @moduvoice measured it on a Tesla T4: 0% GPU for the full window), **updates stop deleting engines you installed yourself** (the updater's dependency sync removed anything not in the app's lockfile — including things our own UI told you to install), and **the "slower than v0.3.5" regression is found and fixed** (clone profiles without a transcript were silently re-running a full Whisper transcription on every single generate). Also: Clear History is back, auto-played audio is finally stoppable, @stronghamjji hardened the dub pipeline against wedged transcribes, and @shakib30's community Colab notebook is now the linked no-GPU path. Thank you all.
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **Agent Skills: `npx skills add debpalash/omnivoice-studio`.** Two installable [skills](https://skills.sh) now ship in the repo — `omnivoice` teaches any AI agent (Claude Code, Cursor, Codex, …) to speak and transcribe through your local install via the OpenAI-compatible API, including your cloned voices; `oss-maintainer` packages the maintainer methodology this project is run with.
|
||||
@@ -700,6 +756,7 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
|
||||
above Continue, framed around what it actually buys you — authenticated, faster,
|
||||
more reliable downloads (higher rate limits, fewer stalls) — with a one-click
|
||||
"get a free token" link. (#657, #669)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bug reports redact more secrets and every Windows username casing.** The
|
||||
|
||||
@@ -229,7 +229,16 @@ def clear_dub_history():
|
||||
"""Delete persisted dub rows and their on-disk dirs (scoped to known IDs)."""
|
||||
with db_conn() as conn:
|
||||
ids = [r["id"] for r in conn.execute("SELECT id FROM dub_history").fetchall()]
|
||||
conn.execute("DELETE FROM dub_history")
|
||||
|
||||
def _delete_rows():
|
||||
with db_conn() as conn:
|
||||
conn.execute("DELETE FROM dub_history")
|
||||
|
||||
# Row-delete + in-memory evict together, so an ingest finishing right now
|
||||
# can't re-save a job the user just cleared (#1252 review). This path
|
||||
# never evicted from memory at all before, so an in-flight job survived
|
||||
# "clear history" outright.
|
||||
dub_pipeline.purge_jobs(ids, delete_rows=_delete_rows, include_inflight=True)
|
||||
for jid in ids:
|
||||
safe = _safe_job_dir(jid)
|
||||
if safe and os.path.isdir(safe):
|
||||
@@ -239,12 +248,15 @@ def clear_dub_history():
|
||||
|
||||
@router.delete("/dub/history/{history_id}")
|
||||
def delete_single_dub_history(history_id: str):
|
||||
with db_conn() as conn:
|
||||
conn.execute("DELETE FROM dub_history WHERE id=?", (history_id,))
|
||||
def _delete_row():
|
||||
with db_conn() as conn:
|
||||
conn.execute("DELETE FROM dub_history WHERE id=?", (history_id,))
|
||||
|
||||
# Atomic with the evict — see purge_jobs (#1252 review).
|
||||
dub_pipeline.purge_jobs([history_id], delete_rows=_delete_row)
|
||||
safe = _safe_job_dir(history_id)
|
||||
if safe and os.path.isdir(safe):
|
||||
shutil.rmtree(safe, ignore_errors=True)
|
||||
_dub_jobs.pop(history_id, None)
|
||||
event_bus.emit("dub_history", {"action": "deleted", "id": history_id})
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from core.config import DUB_DIR, dub_seg_path
|
||||
from core.tasks import task_manager
|
||||
from core.http_headers import content_disposition
|
||||
from api.routers.dub_core import _get_job
|
||||
from services.ffmpeg_utils import (
|
||||
bed_mix_filter,
|
||||
@@ -515,7 +516,7 @@ async def dub_download(
|
||||
return _native_save(out_path, save_path, dl_name, media_type=media_type)
|
||||
return FileResponse(
|
||||
out_path, media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
|
||||
headers={"Content-Disposition": content_disposition(dl_name)},
|
||||
)
|
||||
|
||||
# Determine whether this export should drive video through a per-segment
|
||||
@@ -798,7 +799,7 @@ async def dub_download(
|
||||
|
||||
return FileResponse(
|
||||
output_path, media_type="video/mp4",
|
||||
headers={"Content-Disposition": f'attachment; filename="{dl_name}"', **extra_headers},
|
||||
headers={"Content-Disposition": content_disposition(dl_name), **extra_headers},
|
||||
)
|
||||
|
||||
|
||||
@@ -1382,7 +1383,7 @@ async def dub_download_audio(job_id: str, lang: str = Query(None), preserve_bg:
|
||||
return _native_save(wav_path, save_path, dl_name, media_type="audio/wav")
|
||||
return FileResponse(
|
||||
wav_path, media_type="audio/wav",
|
||||
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
|
||||
headers={"Content-Disposition": content_disposition(dl_name)},
|
||||
)
|
||||
|
||||
|
||||
@@ -1469,7 +1470,7 @@ async def dub_export_srt(
|
||||
return Response(
|
||||
content=srt_content,
|
||||
media_type="text/plain",
|
||||
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
|
||||
headers={"Content-Disposition": content_disposition(dl_name)},
|
||||
)
|
||||
|
||||
def _format_vtt_time(seconds):
|
||||
@@ -1516,7 +1517,7 @@ async def dub_export_vtt(
|
||||
return Response(
|
||||
content=vtt_content,
|
||||
media_type="text/vtt",
|
||||
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
|
||||
headers={"Content-Disposition": content_disposition(dl_name)},
|
||||
)
|
||||
|
||||
|
||||
@@ -1557,7 +1558,7 @@ async def dub_export_segments_zip(job_id: str, lang: str = Query(None)):
|
||||
return Response(
|
||||
content=zip_buffer.read(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="segments_{safe_name}.zip"'},
|
||||
headers={"Content-Disposition": content_disposition(f"segments_{safe_name}.zip")},
|
||||
)
|
||||
|
||||
@router.get("/dub/download-mp3/{job_id}")
|
||||
@@ -1637,7 +1638,7 @@ async def dub_download_mp3(job_id: str, lang: str = Query(None), preserve_bg: bo
|
||||
return _native_save(mp3_path, save_path, dl_name, media_type="audio/mpeg")
|
||||
return FileResponse(
|
||||
mp3_path, media_type="audio/mpeg",
|
||||
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
|
||||
headers={"Content-Disposition": content_disposition(dl_name)},
|
||||
)
|
||||
|
||||
@router.get("/dub/export-stems/{job_id}")
|
||||
@@ -1676,5 +1677,5 @@ async def dub_export_stems(job_id: str, lang: str = Query(None)):
|
||||
return Response(
|
||||
content=zip_buffer.read(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="stems_{safe_name}.zip"'},
|
||||
headers={"Content-Disposition": content_disposition(f"stems_{safe_name}.zip")},
|
||||
)
|
||||
|
||||
@@ -634,11 +634,66 @@ def _run_backend_inference(
|
||||
|
||||
except ValueError as e:
|
||||
# Don't wrap validation errors in OOM message
|
||||
raise e
|
||||
raise _language_rejection_or(e, backend, language)
|
||||
except Exception as e:
|
||||
rewritten = _language_rejection_or(e, backend, language)
|
||||
if rewritten is not e:
|
||||
raise rewritten from e
|
||||
_oom_friendly_reraise(e)
|
||||
|
||||
|
||||
# #1257: the language picker offers all 646 languages regardless of engine,
|
||||
# because MLXAudioBackend.supported_languages() returns ["multi"] on the stated
|
||||
# assumption that "each engine silently ignores languages it doesn't know".
|
||||
# That assumption is false — the underlying library raises, and the reporter got
|
||||
# a bare 400 that recited 23 language codes without saying which engine was
|
||||
# refusing, or that switching engines was the fix.
|
||||
# Each signature must be about the LANGUAGE itself. "Unsupported language" as a
|
||||
# bare prefix also matches "Unsupported language model configuration" — a model
|
||||
# problem handed engine-switch advice it has no use for (#1257 review) — so the
|
||||
# looser wordings require the rejected thing to end there or be a code/name.
|
||||
_LANGUAGE_REJECTION_SIGNATURES = (
|
||||
"invalid language code",
|
||||
"language not supported",
|
||||
"language is not supported",
|
||||
"unsupported language code",
|
||||
)
|
||||
|
||||
#: `unsupported language: xx` / `unsupported language 'xx'` — but not
|
||||
#: `unsupported language model ...`.
|
||||
_LANGUAGE_REJECTION_RE = re.compile(
|
||||
r"unsupported language\s*[:=]|unsupported language\s*['\"]|"
|
||||
r"unsupported language\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _language_rejection_or(e: BaseException, backend, language):
|
||||
"""``e`` rewritten with engine context when it's a language rejection.
|
||||
|
||||
Returns ``e`` unchanged otherwise, so this is safe to wrap any failure in.
|
||||
Matched on the message, not the type: the engines multiplex third-party
|
||||
libraries that each raise their own class.
|
||||
"""
|
||||
text = str(e)
|
||||
low = text.lower()
|
||||
if not any(sig in low for sig in _LANGUAGE_REJECTION_SIGNATURES) and not (
|
||||
_LANGUAGE_REJECTION_RE.search(text)
|
||||
):
|
||||
return e
|
||||
engine = getattr(backend, "display_name", None) or getattr(
|
||||
type(backend), "id", type(backend).__name__
|
||||
)
|
||||
requested = f" '{language}'" if language else ""
|
||||
return ValueError(
|
||||
f"The {engine} engine can't speak{requested}. OmniVoice offers every "
|
||||
f"language its default engine supports, but each engine covers a "
|
||||
f"different set — pick one this engine supports, or switch engine in "
|
||||
f"Settings → Engines (the OmniVoice engine has the widest coverage) "
|
||||
f"and generate again. Engine's own message: {e}"
|
||||
)
|
||||
|
||||
|
||||
def _persist_profile_ref_text(profile_id: str, ref_text: str) -> None:
|
||||
"""Cache an auto-transcribed reference transcript onto its profile row.
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ from core.config import OUTPUTS_DIR, VOICES_DIR
|
||||
from core.db import db_conn
|
||||
from core import event_bus
|
||||
from core.version import APP_VERSION
|
||||
from core.http_headers import content_disposition
|
||||
|
||||
logger = logging.getLogger("omnivoice.marketplace")
|
||||
|
||||
@@ -131,7 +132,7 @@ def export_profile(profile_id: str):
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Disposition": content_disposition(filename),
|
||||
"Content-Length": str(buf.getbuffer().nbytes),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.model_manager import _gpu_pool, run_on_gpu_pool_guarded
|
||||
from core.http_headers import content_disposition
|
||||
|
||||
logger = logging.getLogger("omnivoice.openai_compat")
|
||||
|
||||
@@ -466,7 +467,7 @@ async def create_speech(req: SpeechRequest):
|
||||
|
||||
_headers = {
|
||||
"Content-Length": str(len(audio_bytes)),
|
||||
"Content-Disposition": f'inline; filename="speech.{ext}"',
|
||||
"Content-Disposition": content_disposition(f"speech.{ext}", disposition="inline"),
|
||||
}
|
||||
if _routing_notice:
|
||||
from services.engine_routing import header_safe_reason
|
||||
|
||||
@@ -28,6 +28,7 @@ from core import event_bus
|
||||
from core.config import VOICES_DIR # noqa: F401 — re-exported for tests/monkeypatch
|
||||
from core.db import db_conn
|
||||
from core.version import APP_VERSION
|
||||
from core.http_headers import content_disposition
|
||||
from services import persona_bundle as pb
|
||||
|
||||
router = APIRouter()
|
||||
@@ -100,7 +101,7 @@ async def export_persona(
|
||||
BytesIO(content),
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Disposition": content_disposition(filename),
|
||||
"Content-Length": str(len(content)),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core import prefs
|
||||
from core.failure import is_hf_connectivity_error
|
||||
from utils import hf_progress
|
||||
from utils import download_aggregator
|
||||
# Weight-floor scan (MM2-07 / #352) lives in ``models.py`` — the lowest module in
|
||||
@@ -320,6 +321,41 @@ class InstallModelRequest(BaseModel):
|
||||
repo_id: str
|
||||
|
||||
|
||||
|
||||
def _is_retryable_download_error(exc: BaseException) -> bool:
|
||||
"""Whether a failed download attempt is worth retrying.
|
||||
|
||||
Decides by CLASSIFICATION, not by exception type. The type-based tuple this
|
||||
replaced — ``(HfHubHTTPError, LocalEntryNotFoundError, OSError)`` — silently
|
||||
excluded ``httpx.RemoteProtocolError``, which inherits ``Exception``: a
|
||||
4.6 GB model truncated at 4.0 GB escaped all five attempts and aborted the
|
||||
install (#1224). Any future transport error with a novel base class would
|
||||
have reopened the same hole.
|
||||
|
||||
A user cancel is never retryable, and neither is anything
|
||||
``is_hf_connectivity_error`` does not recognise.
|
||||
"""
|
||||
# Imported here, not at module scope, for the same reason the worker does:
|
||||
# huggingface_hub is heavy and this module is on the setup import path.
|
||||
from huggingface_hub.utils import HfHubHTTPError, LocalEntryNotFoundError
|
||||
|
||||
if isinstance(exc, _InstallCancelled):
|
||||
return False
|
||||
if isinstance(exc, HfHubHTTPError):
|
||||
# An auth / not-found / gone answer from the Hub is a settled verdict:
|
||||
# the token is wrong, the repo is gated, or it isn't there. Retrying
|
||||
# five times with backoff just delays the same message and postpones
|
||||
# the install cooldown. (Pre-existing behaviour — the type-based tuple
|
||||
# this replaced retried every HfHubHTTPError; surfaced in #1224 review.)
|
||||
status = getattr(getattr(exc, "response", None), "status_code", None)
|
||||
if status in (401, 403, 404, 410):
|
||||
return False
|
||||
return True
|
||||
if isinstance(exc, (LocalEntryNotFoundError, OSError)):
|
||||
return True
|
||||
return is_hf_connectivity_error(str(exc))
|
||||
|
||||
|
||||
@router.post("/models/install")
|
||||
async def install_model(req: InstallModelRequest):
|
||||
"""Download one HF repo snapshot; progress goes through the shared
|
||||
@@ -492,8 +528,22 @@ async def install_model(req: InstallModelRequest):
|
||||
_snapshot_path = snapshot_download(**dl_kwargs)
|
||||
_validate_snapshot_has_weights(req.repo_id, _snapshot_path)
|
||||
break
|
||||
except (HfHubHTTPError, LocalEntryNotFoundError, OSError) as net_err:
|
||||
if _attempt >= _max_attempts:
|
||||
except Exception as net_err:
|
||||
# #1224: a truncated body ("peer closed connection without
|
||||
# sending complete message body") arrives as
|
||||
# httpx.RemoteProtocolError, which inherits from Exception
|
||||
# — NOT OSError — so it escaped the old
|
||||
# (HfHubHTTPError, LocalEntryNotFoundError, OSError) tuple
|
||||
# and aborted a 4.6 GB install at 4.0 GB with no retry.
|
||||
# Widen to Exception and decide by CLASSIFICATION:
|
||||
# is_hf_connectivity_error is already the single source of
|
||||
# truth for "transient download failure" and now knows the
|
||||
# truncation signatures. Anything unrecognised (a cancel, a
|
||||
# validation failure, a bug) propagates untouched, exactly
|
||||
# as before.
|
||||
if _attempt >= _max_attempts or not _is_retryable_download_error(
|
||||
net_err
|
||||
):
|
||||
raise
|
||||
_backoff = min(30, 2 ** _attempt)
|
||||
logger.info(
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from services.ffmpeg_utils import find_ffmpeg, spawn_subprocess
|
||||
from core.http_headers import content_disposition
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -76,7 +77,7 @@ async def stories_encode(
|
||||
return Response(
|
||||
content=encoded,
|
||||
media_type=mime,
|
||||
headers={"Content-Disposition": f'attachment; filename="story.{ext}"'},
|
||||
headers={"Content-Disposition": content_disposition(f"story.{ext}")},
|
||||
)
|
||||
finally:
|
||||
for p in (in_path, out_path):
|
||||
|
||||
@@ -90,6 +90,20 @@ async def ws_tts(websocket: WebSocket):
|
||||
get_backend_class,
|
||||
)
|
||||
engine_id = data.get("engine")
|
||||
# #1224: leave a breadcrumb when memory is already tight before
|
||||
# a heavy load. /generate has done this since the 16 GB-Mac
|
||||
# reports, but the streaming path — which the desktop UI tries
|
||||
# FIRST — never did, so the load most likely to tip the machine
|
||||
# into an OS OOM kill was the one load with no trail. The
|
||||
# captured stderr tail is what a SIGKILL report has to go on.
|
||||
# Advisory only: the OS can reclaim cache, and refusing here
|
||||
# would brick loads that would actually have coped.
|
||||
try:
|
||||
from services.memory_budget import log_if_low
|
||||
|
||||
log_if_low(f"TTS stream load ({engine_id or 'active engine'})")
|
||||
except Exception:
|
||||
pass
|
||||
if engine_id:
|
||||
cls = get_backend_class(engine_id)
|
||||
backend = cls()
|
||||
|
||||
+140
-10
@@ -42,12 +42,15 @@ _HINTS: dict[str, str] = {
|
||||
"COMPUTE_TYPE_UNSUPPORTED": "Your GPU doesn't support float16 — OmniVoice retried on int8. If transcription still fails, set OMNIVOICE/ASR_COMPUTE_TYPE=int8 or use CPU.",
|
||||
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete, or a package it loads models through (torchaudio) is missing or mismatched with your torch. Reinstall them together (`uv pip install --reinstall torch torchaudio transformers`), then restart the backend. If only transcription is affected, switching ASR to faster-whisper (Settings → Models) also works around it.",
|
||||
"WINDOWS_APP_CONTROL_BLOCKED": "Windows refused to load a file OmniVoice needs — an Application Control policy (Smart App Control, WDAC, or AppLocker) blocked it. On a personal PC: Windows Security → App & browser control → Smart App Control → Off (Windows only lets you turn it off once — re-enabling requires a Windows reset), then restart OmniVoice. On a managed/work PC, ask IT to allow the OmniVoice install folder.",
|
||||
"WINDOWS_PAGING_FILE_TOO_SMALL": "Windows ran out of virtual memory while mapping the model into memory — its paging file is smaller than the model needs. This is not the same as your RAM being full, and closing other apps usually won't fix it: Windows has to be allowed to back the mapping. Set a bigger paging file — Settings → System → About → Advanced system settings → Performance → Settings → Advanced → Virtual memory → Change: untick \"Automatically manage\", pick your system drive, choose \"Custom size\" and set both Initial and Maximum to at least 32768 MB (more than the model's size), then OK and restart Windows. A smaller/quantized engine (OmniVoice GGUF, Supertonic-3) also avoids the large mapping entirely.",
|
||||
"MEDIA_TOOL_MISSING": "OmniVoice's media engine (ffmpeg/ffprobe) wasn't on the system path when a component went looking for it. Open Settings → Audio tools and use Download/Repair to fetch the bundled copy, then retry — a restart picks it up for everything. If you'd rather use a system install, install ffmpeg (macOS: `brew install ffmpeg`; Windows: `winget install Gyan.FFmpeg`; Linux: your package manager) and restart OmniVoice, or point FFMPEG_PATH / OMNIVOICE_FFPROBE_PATH at the binaries in Settings.",
|
||||
"AUDIO_IO_FAILED": "An audio file couldn't be read or written at the OS level. Check the drive isn't full, that the output and temp folders exist and are writable, and that antivirus or OneDrive isn't locking them (add an OmniVoice exclusion if you use one).",
|
||||
"VIDEO_DOWNLOAD_OS_ERROR": "The OS refused a file operation while saving the downloaded video — this is a disk/folder problem, not a network one, so retrying the same link won't help. The download is written to a job folder under your OmniVoice data directory (Settings → Storage shows the path): check that drive isn't full, that the folder exists and is writable, and that antivirus or a cloud-sync client (OneDrive, Dropbox) isn't locking it — add an OmniVoice exclusion if you use one. If your data directory sits on a synced or network drive, move it to a local one.",
|
||||
"OS_INVALID_ARGUMENT": "The OS rejected a file operation (Errno 22 / invalid argument) — in the transcribe path this is the temporary WAV write before ASR. It's almost always the temp directory: missing, read-only, on a full or removed drive, or blocked by antivirus. Check that your system TEMP/TMP folder exists and is writable and the drive has free space (add an OmniVoice antivirus exclusion if you use one), then retry.",
|
||||
"SOCKS_PROXY_SUPPORT_MISSING": "A SOCKS proxy is configured in your environment (ALL_PROXY/HTTPS_PROXY=socks5://…) and the backend's HTTP client is missing SOCKS support. Newer OmniVoice builds ship SOCKS support (the socksio package) — update the app. If you still see this, unset ALL_PROXY/HTTPS_PROXY for OmniVoice, or run `uv pip install 'httpx[socks]'` in the backend venv, then restart.",
|
||||
"SSL_HANDSHAKE_FAILURE": "A corporate or antivirus proxy is intercepting HTTPS traffic and re-signing certificates with its own CA — your OS trusts that CA, but Python's bundled certifi CA list doesn't, so the TLS handshake fails even though the connection reached the server. Newer OmniVoice builds trust the OS certificate store at startup (the truststore package), which should already fix this — update the app and retry. If you still see this, add an HTTPS-scanning exclusion for OmniVoice/Python in your antivirus, or ask IT for the proxy's CA bundle and set SSL_CERT_FILE to it, then restart.",
|
||||
"UNSUPPORTED_VIDEO_URL": "This link isn't a directly downloadable video. Paste a direct video page (e.g. a youtube.com/watch?v=… or douyin.com/video/<id> link), not a share/profile/feed link — or download the file and drop it in directly.",
|
||||
"VIDEO_DRM_PROTECTED": "The video host only offered OmniVoice a DRM-protected copy, which can't be downloaded. This is often not a property of the video itself — the host serves a different format set to different clients, and OmniVoice already retried through every client it has. Try the link again in a minute, or download the video with a browser extension / the host's own download button and drop the file into Dubbing directly.",
|
||||
"VIDEO_DOWNLOAD_NETWORK": "The connection to the video server dropped mid-download (often a transient CDN/network blip or a regional rate-limit). Just retry — OmniVoice already cleaned up the partial download. If it keeps failing, check your network/VPN.",
|
||||
"BROKEN_VENV": "The Python backend environment was moved or damaged. OmniVoice rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
|
||||
"MODEL_CACHE_CORRUPT": "The model cache had broken file links — snapshot entries that no longer point at their downloaded data (interrupted renames or antivirus interference can cause this). OmniVoice repairs this automatically and retries the load once. If the error persists, quit OmniVoice, delete the model's models--<org>--<name> folder inside the Hugging Face cache, and restart — the model re-downloads automatically.",
|
||||
@@ -88,6 +91,18 @@ _HF_CONNECTIVITY_SIGNATURES = (
|
||||
"timed out",
|
||||
"an error happened while trying to locate the file on the hub", # LocalEntryNotFoundError
|
||||
"we cannot find the requested files", # LocalEntryNotFoundError
|
||||
# #1224: a TRUNCATED download — the server closed mid-body, so the client
|
||||
# got fewer bytes than Content-Length promised. httpx words it "peer closed
|
||||
# connection without sending complete message body"; urllib3/http.client
|
||||
# raise IncompleteRead. This is as transient as a refused connection and
|
||||
# must retry — a 4.6 GB model that dies at 4.0 GB used to abort the whole
|
||||
# install (and, on the reporter's 16 GB Mac, take the process with it).
|
||||
"peer closed connection",
|
||||
"incomplete message body",
|
||||
"incompleteread",
|
||||
"incomplete read",
|
||||
"connection broken", # urllib3 ProtocolError wrapper
|
||||
"response ended prematurely",
|
||||
)
|
||||
|
||||
# The failure must also be Hugging-Face-shaped — the configured endpoint/host
|
||||
@@ -260,18 +275,34 @@ def classify(reason: str) -> str:
|
||||
marker in low for marker in _DOWNLOAD_CONTEXT_MARKERS
|
||||
):
|
||||
return "VIDEO_DOWNLOAD_OS_ERROR"
|
||||
# #1256: a third-party library shelled out to `ffprobe`/`ffmpeg` BY NAME and
|
||||
# the OS had nothing to run. OmniVoice's own code always resolves the
|
||||
# bundled sidecar explicitly, so this only ever comes from a dependency —
|
||||
# which meant it arrived with no class at all and the user was told the
|
||||
# engine "stopped with an error OmniVoice doesn't recognize". Checked
|
||||
# before the generic errno-2 rules, which would otherwise claim it.
|
||||
if _is_missing_media_tool(low):
|
||||
return "MEDIA_TOOL_MISSING"
|
||||
# #1251: Windows refused to map the model because the PAGING FILE is too
|
||||
# small. The generate path already counted this as an OOM, but that hint
|
||||
# ("close other apps, use a lighter engine") is the wrong remedy — the
|
||||
# machine had 32 GB of RAM. Matched on the numeric code too, since the OS
|
||||
# translates the message text, and in both the Python (`[WinError 1455]`)
|
||||
# and Rust (`os error 1455`, from the safetensors mmap) spellings.
|
||||
if "1455" in low and ("winerror" in low or "os error" in low):
|
||||
return "WINDOWS_PAGING_FILE_TOO_SMALL"
|
||||
if "paging file is too small" in low:
|
||||
return "WINDOWS_PAGING_FILE_TOO_SMALL"
|
||||
if "errno 22" in low:
|
||||
return "OS_INVALID_ARGUMENT"
|
||||
# An HF cache whose snapshot entries don't resolve (dangling symlinks /
|
||||
# zero-byte stand-ins): transformers reports the weights missing ("does
|
||||
# not appear to have a file named pytorch_model.bin or model.safetensors")
|
||||
# even though the blobs are fully on disk. model_manager self-heals this
|
||||
# (delete broken entries → snapshot_download → retry once); the class here
|
||||
# covers both the raw transformers wording (any load surface can leak it)
|
||||
# and OmniVoice's own repair messages, so the user-facing error and the
|
||||
# auto bug report name the class and its automatic repair.
|
||||
if ("does not appear to have a file named" in low
|
||||
or "broken file link" in low):
|
||||
# zero-byte stand-ins) or which is simply missing its weight shard.
|
||||
# model_manager self-heals this (delete broken entries → snapshot_download
|
||||
# → retry once); the class here covers the raw transformers wordings (any
|
||||
# load surface can leak them) and OmniVoice's own repair messages, so the
|
||||
# user-facing error and the auto bug report name the class and its
|
||||
# automatic repair.
|
||||
if is_incomplete_cache_message(low) or "broken file link" in low:
|
||||
return "MODEL_CACHE_CORRUPT"
|
||||
if (
|
||||
"could not import module" in low
|
||||
@@ -331,6 +362,13 @@ def classify(reason: str) -> str:
|
||||
# Broken pipe" still classifies as a network blip.
|
||||
if "unsupported url" in low or "no video formats" in low or "is not a valid url" in low:
|
||||
return "UNSUPPORTED_VIDEO_URL"
|
||||
# #1254: reported as intermittent — the same URL failed, then succeeded on
|
||||
# a retry. That is a per-player-client format set, not real DRM, so the
|
||||
# download path now escalates the client the way it does for a 403. If
|
||||
# every client still says DRM, the video genuinely can't be fetched and the
|
||||
# user needs to hear that rather than retry a fourth time.
|
||||
if "drm protected" in low or "drm-protected" in low:
|
||||
return "VIDEO_DRM_PROTECTED"
|
||||
if (
|
||||
"broken pipe" in low
|
||||
or "connection reset" in low
|
||||
@@ -468,6 +506,71 @@ _DOWNLOAD_CONTEXT_MARKERS = (
|
||||
)
|
||||
|
||||
|
||||
#: The media binaries a dependency may shell out to by bare name.
|
||||
_MEDIA_TOOLS = ("ffprobe", "ffmpeg")
|
||||
|
||||
|
||||
def _is_missing_media_tool(low: str) -> bool:
|
||||
"""True when the failure is "the OS could not find ffprobe/ffmpeg" (#1256).
|
||||
|
||||
Deliberately narrow: it must name the binary *as the thing that could not
|
||||
be found*, so a perfectly ordinary "ffmpeg failed: no such file or
|
||||
directory: /path/to/input.wav" — a missing INPUT, an entirely different
|
||||
problem — is not handed the "install the media engine" remedy.
|
||||
"""
|
||||
if "no such file or directory" not in low and "[winerror 2]" not in low:
|
||||
return False
|
||||
# The name must appear UNQUALIFIED — quoted with no directory part, which
|
||||
# is how a bare-name spawn fails. A path that merely ends in the tool's
|
||||
# name ('/tmp/ffmpeg', '~/Movies/my-ffmpeg-export.mp4') is a missing FILE,
|
||||
# an entirely different problem that must not get the "repair your media
|
||||
# engine" remedy (#1256 review).
|
||||
return any(
|
||||
f"'{tool}'" in low or f'"{tool}"' in low
|
||||
for tool in _MEDIA_TOOLS
|
||||
)
|
||||
|
||||
|
||||
#: transformers' two ways of saying "this snapshot has no weight shard".
|
||||
#:
|
||||
#: They come from different code paths and share no wording, so matching only
|
||||
#: the first — which both the classifier and model_manager's self-heal used to
|
||||
#: do — silently missed half the class (#1273):
|
||||
#:
|
||||
#: hub load: "<repo> does not appear to have a file named
|
||||
#: pytorch_model.bin or model.safetensors"
|
||||
#: local dir: "Error no file named model.safetensors, or pytorch_model.bin,
|
||||
#: found in directory <path>"
|
||||
#:
|
||||
#: The second is what a load of a *subfolder* inside a cached snapshot raises
|
||||
#: (e.g. `audio_tokenizer/`), which is exactly where an interrupted download
|
||||
#: leaves a repo half-written. Both mean the same thing and both are repaired
|
||||
#: the same way, so both must classify and both must trigger the heal.
|
||||
_INCOMPLETE_CACHE_PHRASES = (
|
||||
"does not appear to have a file named",
|
||||
# Matched as two fragments: the file list between them varies with the
|
||||
# transformers version and the requested weight variant.
|
||||
("error no file named", "found in directory"),
|
||||
)
|
||||
|
||||
|
||||
def is_incomplete_cache_message(text: str) -> bool:
|
||||
"""True when *text* is transformers reporting a snapshot with no weights.
|
||||
|
||||
Takes an already-lowercased string. Shared by :func:`classify` and
|
||||
model_manager's cache self-heal so the healer and the error message can
|
||||
never disagree about what an interrupted download looks like.
|
||||
"""
|
||||
low = str(text).lower()
|
||||
for phrase in _INCOMPLETE_CACHE_PHRASES:
|
||||
if isinstance(phrase, tuple):
|
||||
if all(part in low for part in phrase):
|
||||
return True
|
||||
elif phrase in low:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_os_write_refusal(reason: Optional[str]) -> bool:
|
||||
"""True when *reason* looks like the OS refusing a file operation (a full
|
||||
or removed drive, a read-only folder, an antivirus/cloud-sync lock) rather
|
||||
@@ -504,6 +607,33 @@ def describe_path_target(path: str) -> str:
|
||||
return "; ".join(facts)
|
||||
|
||||
|
||||
#: Exception types whose ``str()`` is a bare VALUE rather than a sentence, so
|
||||
#: showing it alone tells the user nothing about what went wrong.
|
||||
#: ``str(KeyError("mgw39lx3"))`` is ``"'mgw39lx3'"`` — the repr of the key.
|
||||
_VALUE_ONLY_STR_EXCEPTIONS = (KeyError,)
|
||||
|
||||
|
||||
def describe_exception(exc: BaseException) -> str:
|
||||
"""``str(exc)`` in a form a human can act on.
|
||||
|
||||
#1252/#1253: a dub ingest failed with the toast ``ingest: 'mgw39lx3'`` and
|
||||
nothing else — the entire user-facing reason was the repr of a dict key,
|
||||
because ``str(KeyError)`` does not mention that a lookup failed, or that it
|
||||
was an exception at all. The reporter's ``'mgw39lx3'`` was their own job id
|
||||
reflected back at them with no context.
|
||||
|
||||
Naming the class is the floor, not the goal: a failure that reaches here at
|
||||
all is one nobody wrote a message for. It keeps the report diagnosable
|
||||
instead of cryptic while the specific path gets its own handling.
|
||||
"""
|
||||
text = str(exc).strip()
|
||||
if not text:
|
||||
return type(exc).__name__
|
||||
if isinstance(exc, _VALUE_ONLY_STR_EXCEPTIONS):
|
||||
return f"{type(exc).__name__}: {text}"
|
||||
return text
|
||||
|
||||
|
||||
def build_failure(
|
||||
exc_or_msg: Any,
|
||||
*,
|
||||
@@ -517,7 +647,7 @@ def build_failure(
|
||||
"""
|
||||
if isinstance(exc_or_msg, BaseException):
|
||||
error_class = type(exc_or_msg).__name__
|
||||
raw = str(exc_or_msg).strip() or error_class
|
||||
raw = describe_exception(exc_or_msg)
|
||||
else:
|
||||
error_class = "Error"
|
||||
raw = str(exc_or_msg).strip() or "Unknown failure"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""RFC 6266 ``Content-Disposition`` construction.
|
||||
|
||||
#1262: exporting a voice profile whose name isn't spelled in Latin letters
|
||||
returned a 500:
|
||||
|
||||
'latin-1' codec can't encode characters in position 22-25:
|
||||
ordinal not in range(256)
|
||||
|
||||
``attachment; filename="`` is exactly 22 characters, so positions 22-25 were
|
||||
the first four characters of the user's own profile name. HTTP header values
|
||||
are latin-1 by definition, and every download endpoint built the header by
|
||||
f-string interpolation, so any name outside latin-1 — Chinese, Japanese,
|
||||
Korean, Greek, Cyrillic, Hebrew, emoji — crashed the request.
|
||||
|
||||
The sanitisers in front of those f-strings did not catch it because they all
|
||||
filtered with ``str.isalnum()``, which is **True for every alphabetic script**,
|
||||
not just ASCII. ``"我的声音".isalnum()`` is ``True``. They were removing
|
||||
punctuation and passing the exact characters that break the header.
|
||||
|
||||
`content_disposition` is the one construction site: an ASCII-safe
|
||||
``filename=`` that any client can read, plus the RFC 5987 ``filename*=`` that
|
||||
gives modern browsers the user's real name back, correctly encoded.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from urllib.parse import quote
|
||||
|
||||
__all__ = ["ascii_filename", "content_disposition"]
|
||||
|
||||
#: Characters Windows forbids in a filename, plus the quoting/injection risks
|
||||
#: (`"` and `\` end the quoted-string; CR/LF would split the header).
|
||||
_UNSAFE = re.compile(r'[\\/:*?"<>|\r\n\t]')
|
||||
|
||||
|
||||
def _fold(text: str) -> str:
|
||||
"""One filename part, reduced to safe ASCII."""
|
||||
folded = unicodedata.normalize("NFKD", text)
|
||||
# Drop the combining marks NFKD split off, keeping the base letters.
|
||||
folded = "".join(c for c in folded if not unicodedata.combining(c))
|
||||
folded = folded.encode("ascii", "ignore").decode("ascii")
|
||||
return _UNSAFE.sub("_", folded).strip()
|
||||
|
||||
|
||||
def ascii_filename(filename: str, fallback: str = "download") -> str:
|
||||
"""A latin-1-safe rendering of *filename* for the legacy ``filename=``.
|
||||
|
||||
Accented Latin is folded to its base letters (``Sébastien`` →
|
||||
``Sebastien``) rather than deleted, since that stays readable. Scripts with
|
||||
no ASCII form (CJK, Cyrillic, Hebrew, emoji) have no meaningful fold, so
|
||||
they drop out and *fallback* carries the name — the ``filename*`` parameter
|
||||
is what actually preserves those, and every browser released this decade
|
||||
prefers it.
|
||||
"""
|
||||
raw = filename or ""
|
||||
# Split the extension off FIRST: folding runs per-part so a name that is
|
||||
# entirely non-ASCII loses its stem without also losing ".ovsvoice", which
|
||||
# is what tells the OS (and the user) what the file actually is.
|
||||
stem, dot, suffix = raw.rpartition(".")
|
||||
if not dot:
|
||||
stem, suffix = raw, ""
|
||||
|
||||
stem, suffix = _fold(stem), _fold(suffix)
|
||||
if not stem.strip("_ ."):
|
||||
stem = fallback
|
||||
return f"{stem}.{suffix}" if suffix else stem
|
||||
|
||||
|
||||
def content_disposition(
|
||||
filename: str,
|
||||
*,
|
||||
disposition: str = "attachment",
|
||||
fallback: str = "download",
|
||||
) -> str:
|
||||
"""A ``Content-Disposition`` value that is safe for ANY filename (#1262).
|
||||
|
||||
Emits both forms per RFC 6266 §4.3: ``filename=`` for the lowest common
|
||||
denominator and ``filename*=UTF-8''…`` for the real name. Clients that
|
||||
understand the extended form ignore the plain one, so the user gets
|
||||
``我的声音.ovsvoice`` while nothing anywhere has to encode it as latin-1.
|
||||
"""
|
||||
# The fallback is a caller-supplied string that lands in the header
|
||||
# verbatim whenever the real name folds away entirely, so it gets the same
|
||||
# treatment as the name itself — otherwise a non-ASCII or quote/CRLF
|
||||
# fallback walks straight past every guard here (#1262 review).
|
||||
safe_fallback = _fold(fallback) or "download"
|
||||
safe = ascii_filename(filename, fallback=safe_fallback)
|
||||
encoded = quote(_UNSAFE.sub("_", filename or safe_fallback), safe="")
|
||||
return f"{disposition}; filename=\"{safe}\"; filename*=UTF-8''{encoded}"
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
|
||||
# release.yml's version-bump job, so it stays equal to
|
||||
# pyproject/tauri.conf/Cargo/package.json.
|
||||
_FALLBACK_VERSION = "0.4.0"
|
||||
_FALLBACK_VERSION = "0.4.2"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
+136
-10
@@ -383,6 +383,7 @@ from core.config import OUTPUTS_DIR, VOICES_DIR, CRASH_LOG_PATH
|
||||
from core.tasks import task_manager
|
||||
from core import job_store
|
||||
from services.model_manager import (
|
||||
ModelLoadInterruptedByShutdown,
|
||||
begin_shutdown as model_loads_begin_shutdown,
|
||||
idle_worker,
|
||||
preload_model,
|
||||
@@ -460,6 +461,19 @@ except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# #1256: our own ffmpeg/ffprobe call sites pass an explicit path, so a bundled
|
||||
# sidecar that isn't on PATH works for us — but a dependency that shells out to
|
||||
# `ffprobe` by bare name dies with FileNotFoundError, mid-synthesis, on a
|
||||
# machine where the app's own copy was resolvable the whole time. Publish the
|
||||
# resolved directories once here, after prefs have restored any FFMPEG_PATH
|
||||
# override and before any engine loads.
|
||||
try:
|
||||
from services.ffmpeg_utils import ensure_media_tools_on_path
|
||||
ensure_media_tools_on_path()
|
||||
except Exception:
|
||||
pass # best-effort: find_ffprobe() still resolves it for our own callers
|
||||
|
||||
|
||||
def _env_flag(name: str, default: bool = False) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
@@ -874,6 +888,25 @@ async def scalar_docs():
|
||||
)
|
||||
|
||||
|
||||
def _cors_headers_for(request: Request) -> "dict[str, str]":
|
||||
"""Allowed-origin headers for a hand-built error response.
|
||||
|
||||
CORSMiddleware doesn't always get a shot at `exception_handler`-created
|
||||
responses, which leaves the browser reporting the error as a bare CORS
|
||||
failure instead of surfacing the real `detail`. Every error response this
|
||||
module builds must go through here — a 503 whose actionable message the
|
||||
browser discards is no better than the 500 it replaced.
|
||||
"""
|
||||
origin = request.headers.get("origin", "")
|
||||
if origin and (origin in _allowed or "*" in _allowed):
|
||||
return {
|
||||
"Access-Control-Allow-Origin": origin,
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
# Client disconnected mid-stream (browser canceled a <video>/range fetch).
|
||||
@@ -886,6 +919,46 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
) or "Content-Length" in str(exc):
|
||||
logger.info("Client disconnect during %s (%s)", request.url, exc_name)
|
||||
return Response(status_code=499)
|
||||
# The backend is on its way out and a request asked for a model load
|
||||
# (#1276). #1174 already made this benign for the *background preload*,
|
||||
# but a user-initiated request fell through to the generic 500 path below
|
||||
# — crash log, ERROR traceback, journal entry — so quitting the app while
|
||||
# a generate was queued surfaced "500 Internal Server Error: model load
|
||||
# skipped: backend shutting down" and offered to file a bug for it.
|
||||
#
|
||||
# Nothing failed: the process is exiting. 503 + Retry-After is what a
|
||||
# shutting-down server owes a client, and it keeps this out of the
|
||||
# crash/bug-report pipeline entirely.
|
||||
#
|
||||
# Matched by isinstance OR class name: `services.model_manager` can be
|
||||
# imported under two module names (`main`/`backend.main` on different
|
||||
# sys.path roots, and the frozen build's own layout), which makes two
|
||||
# distinct class objects and breaks a bare isinstance. The name check is
|
||||
# the durable half — don't "simplify" it away.
|
||||
if isinstance(exc, ModelLoadInterruptedByShutdown) or exc_name == (
|
||||
"ModelLoadInterruptedByShutdown"
|
||||
):
|
||||
# `.path`, not the full URL — a query string can carry tokens and
|
||||
# newlines, and neither belongs in a log line.
|
||||
logger.info(
|
||||
"Model load skipped during shutdown for %s — benign.", request.url.path
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
# The [shutting_down] marker is what the UI keys off to skip the
|
||||
# "Report" action (the same convention as [clone_ref_unusable]).
|
||||
# NOT the bare 503 status: 503 is also how a real engine-load
|
||||
# timeout and an unavailable engine are reported, and those are
|
||||
# genuinely reportable bugs — suppressing the report button for
|
||||
# every 503 would silence exactly the class users need to file.
|
||||
"detail": (
|
||||
"[shutting_down] OmniVoice is shutting down, so it didn't "
|
||||
"start loading the model. Reopen the app and try again."
|
||||
)
|
||||
},
|
||||
headers={"Retry-After": "5", **_cors_headers_for(request)},
|
||||
)
|
||||
try:
|
||||
# Serialize writes so concurrent unhandled exceptions don't interleave frames.
|
||||
with _crash_log_lock, open(CRASH_LOG_PATH, "a", encoding="utf-8", errors="backslashreplace") as f:
|
||||
@@ -902,15 +975,7 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
_entry = error_journal.record(
|
||||
exc, route=str(request.url.path), trace=traceback.format_exc()
|
||||
)
|
||||
# CORSMiddleware doesn't always get a shot at `exception_handler`-created
|
||||
# responses, which leaves the browser reporting every 500 as a bare CORS
|
||||
# error. Attach the headers manually so the real `detail` bubbles up.
|
||||
origin = request.headers.get("origin", "")
|
||||
headers: dict[str, str] = {}
|
||||
if origin and (origin in _allowed or "*" in _allowed):
|
||||
headers["Access-Control-Allow-Origin"] = origin
|
||||
headers["Access-Control-Allow-Credentials"] = "true"
|
||||
headers["Vary"] = "Origin"
|
||||
headers: dict[str, str] = _cors_headers_for(request)
|
||||
# #874: a model download that failed because the CONFIGURED Hugging Face
|
||||
# mirror (HF_ENDPOINT) is unreachable used to leak the raw transformers
|
||||
# message ("We couldn't connect to 'https://hf-mirror.com' …") as the 500
|
||||
@@ -1295,6 +1360,12 @@ if __name__ == "__main__":
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Distinct exit code for "the port was already taken" (#1223), so the
|
||||
# desktop shell can tell that apart from a crash without parsing an
|
||||
# OS-translated error string. Kept out of the 0-2 range the interpreter
|
||||
# itself uses, and mirrored in frontend/src-tauri/src/backend.rs.
|
||||
_EXIT_PORT_IN_USE = 78 # EX_CONFIG, sysexits.h
|
||||
|
||||
# Port 3900 picked to dodge common 8000 conflicts (Django/Rails/Jupyter).
|
||||
# Rust sidecar launcher in lib.rs::BACKEND_PORT must stay in sync.
|
||||
#
|
||||
@@ -1305,4 +1376,59 @@ if __name__ == "__main__":
|
||||
# set OMNIVOICE_BIND_HOST=0.0.0.0 explicitly (see deploy/docker-compose.yml)
|
||||
# — the host-side port mapping is what enforces 127.0.0.1-only there.
|
||||
_bind_host = os.environ.get("OMNIVOICE_BIND_HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=_bind_host, port=_port)
|
||||
|
||||
def _port_taken(host: str, port: int) -> "OSError | None":
|
||||
"""The EADDRINUSE error a bind would raise, or None if the port is free.
|
||||
|
||||
Mirrors uvicorn's own socket options — notably SO_REUSEADDR off
|
||||
Windows — so this can't report "taken" for a TIME_WAIT socket uvicorn
|
||||
would happily bind. Any non-EADDRINUSE failure returns None: this is a
|
||||
diagnostic, and uvicorn must remain the authority on whether the real
|
||||
bind succeeds.
|
||||
"""
|
||||
import socket
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
if sys.platform != "win32":
|
||||
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
probe.bind((host, port))
|
||||
except OSError as exc:
|
||||
in_use = exc.errno in (48, 98, 10048) or getattr(
|
||||
exc, "winerror", None
|
||||
) == 10048
|
||||
return exc if in_use else None
|
||||
return None
|
||||
|
||||
def _fail_port_in_use(exc: "OSError | None") -> None:
|
||||
print(
|
||||
f"FATAL: port {_port} is already in use — another OmniVoice "
|
||||
f"backend (or another app) is listening on it. Quit the other "
|
||||
f"instance and relaunch; if nothing is visibly running, an "
|
||||
f"orphaned backend from a previous session is still holding the "
|
||||
f"port." + (f" Underlying error: {exc}" if exc else ""),
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
sys.exit(_EXIT_PORT_IN_USE)
|
||||
|
||||
# #1223: uvicorn does NOT let a bind failure reach the caller — it logs the
|
||||
# raw errno and raises SystemExit(1) from inside its startup, so an
|
||||
# `except OSError` around uvicorn.run() never fires (verified, not assumed).
|
||||
# And the message it logs is useless to match on: the Windows wording
|
||||
# ("only one usage of each socket address is normally permitted") is
|
||||
# OS-translated into the user's locale. So probe the port ourselves first —
|
||||
# errno is locale-independent (EADDRINUSE = 48 macOS/BSD, 98 Linux, 10048
|
||||
# Windows) — and exit with a code the shell can recognise.
|
||||
if (_bind_err := _port_taken(_bind_host, _port)) is not None:
|
||||
_fail_port_in_use(_bind_err)
|
||||
try:
|
||||
uvicorn.run(app, host=_bind_host, port=_port)
|
||||
except SystemExit:
|
||||
# Lost the race between the probe above and uvicorn's own bind (a
|
||||
# competing process grabbed the port in between). Re-probe: if the port
|
||||
# is taken now, that is what killed us, whatever exit code uvicorn
|
||||
# chose.
|
||||
if _port_taken(_bind_host, _port) is not None:
|
||||
_fail_port_in_use(None)
|
||||
raise
|
||||
|
||||
@@ -114,6 +114,24 @@ def create_mcp_server():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extend the MCP SDK's DNS-rebinding allowlist so agents on non-localhost
|
||||
# hosts (Docker's host.containers.internal, a LAN IP, a reverse proxy) can
|
||||
# reach the /mcp endpoint. The SDK default is localhost-only.
|
||||
_mcp_hosts = os.environ.get("OMNIVOICE_MCP_ALLOWED_HOSTS", "")
|
||||
if _mcp_hosts.strip():
|
||||
hosts = [h.strip() for h in _mcp_hosts.split(",") if h.strip()]
|
||||
try:
|
||||
mcp.settings.transport_security.allowed_hosts.extend(hosts)
|
||||
# Also extend origins for both http and https (browser-based MCP
|
||||
# clients behind a proxy send an Origin header — agent clients
|
||||
# typically don't, but a reverse proxy may use either scheme).
|
||||
origins = [
|
||||
f"{scheme}://{h}" for h in hosts for scheme in ("http", "https")
|
||||
]
|
||||
mcp.settings.transport_security.allowed_origins.extend(origins)
|
||||
except Exception as e:
|
||||
logger.warning("OMNIVOICE_MCP_ALLOWED_HOSTS not applied (%s)", e)
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _api_base() -> str:
|
||||
|
||||
@@ -37,6 +37,7 @@ import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
import soundfile as sf
|
||||
@@ -67,7 +68,45 @@ logger = logging.getLogger("omnivoice.dub_pipeline")
|
||||
# backward compat during the transition.
|
||||
|
||||
_dub_jobs: dict[str, dict] = {}
|
||||
_dub_jobs_lock = threading.Lock()
|
||||
# Re-entrant: `save_job` takes this lock itself (see below), and the atomic
|
||||
# helpers call it while already holding it.
|
||||
_dub_jobs_lock = threading.RLock()
|
||||
|
||||
#: Ingests currently running. Used only so "clear history" can also sweep a job
|
||||
#: that has no row yet — it would appear in no id list otherwise.
|
||||
_inflight_jobs: set[str] = set()
|
||||
|
||||
#: Recently-deleted job ids, most-recent last. Guarded by ``_dub_jobs_lock``.
|
||||
#:
|
||||
#: Dict membership alone cannot express "the user withdrew this job" (#1252
|
||||
#: review): a job's FIRST persistence creates the entry, so an absent key means
|
||||
#: "not written yet" for a new job and "deleted" for an established one — two
|
||||
#: opposite instructions from one signal.
|
||||
#:
|
||||
#: Scoped to DELETED, not to in-flight ingests. Scoping it to ingests looked
|
||||
#: right and closed nothing that mattered: a dub is imported once and rendered
|
||||
#: many times, so the realistic delete lands during a RENDER, long after its
|
||||
#: ingest ended — and a render's save would then write the row straight back.
|
||||
#:
|
||||
#: Retained rather than cleared on completion, because there is no moment at
|
||||
#: which a delete stops mattering: any operation still holding that job can
|
||||
#: persist it. ``begin_ingest`` drops an id explicitly, since re-importing is a
|
||||
#: deliberate revival.
|
||||
#:
|
||||
#: Expired by AGE, not by count. A count-bounded LRU is evictable by ordinary
|
||||
#: use: ``DELETE /dub/history`` purges every row with no limit, so a user
|
||||
#: clearing a large history mid-render would push the rendering job's own
|
||||
#: marker out and the render would then write it back (#1252 review). Age
|
||||
#: cannot be gamed that way — what matters is how long ago the delete happened,
|
||||
#: not how many others followed it.
|
||||
#:
|
||||
#: The count cap is a memory backstop only, set far above any real history:
|
||||
#: ~4096 short ids is a few hundred KB. Reaching it needs 4096 deletions inside
|
||||
#: one TTL window, at which point the oldest markers are the least likely to
|
||||
#: still be held.
|
||||
_WITHDRAWN_TTL_S = 6 * 3600 # outlives any realistic render or transcribe
|
||||
_WITHDRAWN_MAX = 4096 # memory backstop, not the eviction policy
|
||||
_withdrawn_jobs: "OrderedDict[str, float]" = OrderedDict()
|
||||
|
||||
_DUB_DIR_REAL = os.path.realpath(DUB_DIR)
|
||||
_HASH_BUF_SIZE = 1 << 18 # 256 KB chunks for hashing
|
||||
@@ -197,6 +236,162 @@ def put_job(job_id: str, job: dict) -> None:
|
||||
_dub_jobs[job_id] = job
|
||||
|
||||
|
||||
def merge_job(job_id: str, updates: dict) -> bool:
|
||||
"""Merge *updates* into an existing in-memory job. Does NOT persist.
|
||||
|
||||
Returns ``False`` when the job is gone — which is a real, reachable state,
|
||||
not a defensive nicety: ingest runs for minutes (demucs, scene detection,
|
||||
thumbnailing) and ``DELETE /dub/history/{id}`` pops the entry out from
|
||||
under it. The pipeline used to finish with a bare
|
||||
``_dub_jobs[job_id].update(...)``, so deleting an in-flight dub surfaced as
|
||||
the toast ``ingest: 'mgw39lx3'`` — ``str(KeyError)`` is the repr of the
|
||||
key, nothing more (#1252/#1253). Callers treat ``False`` as "the user
|
||||
withdrew this job" and stop, rather than resurrecting a record that was
|
||||
deliberately deleted.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
job = _dub_jobs.get(job_id)
|
||||
if job is None:
|
||||
return False
|
||||
job.update(updates)
|
||||
return True
|
||||
|
||||
|
||||
def _expire_withdrawn(now: float, protected: int = 0) -> None:
|
||||
"""Drop withdrawal markers that are too old to still matter.
|
||||
|
||||
Caller must hold ``_dub_jobs_lock``. Age first — that is the policy — then
|
||||
a size cap purely so the mapping cannot grow without bound.
|
||||
|
||||
``protected`` is how many markers the current purge just recorded. Those sit
|
||||
at the end (newest) and are never evicted by the size cap: they are the most
|
||||
likely to still be held by a running job, and dropping one is exactly the
|
||||
resurrection this whole mechanism exists to prevent. A single "clear
|
||||
history" larger than the cap would otherwise force us to discard live
|
||||
markers — which is what broke CI. The bound therefore is
|
||||
``cap + one purge``, not ``cap``.
|
||||
"""
|
||||
cutoff = now - _WITHDRAWN_TTL_S
|
||||
while _withdrawn_jobs:
|
||||
_, deleted_at = next(iter(_withdrawn_jobs.items()))
|
||||
if deleted_at >= cutoff:
|
||||
break
|
||||
_withdrawn_jobs.popitem(last=False)
|
||||
floor = max(_WITHDRAWN_MAX, protected)
|
||||
while len(_withdrawn_jobs) > floor:
|
||||
_withdrawn_jobs.popitem(last=False)
|
||||
|
||||
|
||||
def begin_ingest(job_id: str) -> None:
|
||||
"""Mark an ingest as running.
|
||||
|
||||
Re-importing an id is a deliberate revival, so this clears any tombstone —
|
||||
the only thing that legitimately un-deletes a job.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
_inflight_jobs.add(job_id)
|
||||
_withdrawn_jobs.pop(job_id, None)
|
||||
|
||||
|
||||
def end_ingest(job_id: str) -> None:
|
||||
"""Mark an ingest as finished, however it ended.
|
||||
|
||||
Deliberately does NOT clear the tombstone: the ingest ending is not the
|
||||
user un-deleting anything, and a render started before the delete can still
|
||||
be holding that job.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
_inflight_jobs.discard(job_id)
|
||||
|
||||
|
||||
def put_and_save_job(
|
||||
job_id: str,
|
||||
job: dict,
|
||||
*,
|
||||
filename: str = "",
|
||||
duration: float = 0.0,
|
||||
content_hash: str = "",
|
||||
) -> bool:
|
||||
""":func:`put_job` and :func:`save_job` as ONE atomic step.
|
||||
|
||||
Returns ``False`` when the job was withdrawn — the user deleted or cleared
|
||||
its history while this ingest was running — in which case nothing is
|
||||
written. Gating on the tombstone rather than on dict membership is what
|
||||
makes this correct for a job's FIRST write, where an absent key is normal
|
||||
(#1252 review).
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
if job_id in _withdrawn_jobs:
|
||||
return False
|
||||
_dub_jobs[job_id] = job
|
||||
save_job(job_id, job, filename, duration, content_hash)
|
||||
return True
|
||||
|
||||
|
||||
def merge_and_save_job(
|
||||
job_id: str,
|
||||
updates: dict,
|
||||
*,
|
||||
filename: str = "",
|
||||
duration: float = 0.0,
|
||||
content_hash: str = "",
|
||||
) -> bool:
|
||||
""":func:`merge_job` and :func:`save_job` as ONE atomic step.
|
||||
|
||||
Splitting them leaves a window that resurrects deleted work (#1252 review):
|
||||
merge succeeds, the user deletes the dub — removing the row *and* the
|
||||
in-memory entry — and the pending ``save_job`` then UPSERTs the row straight
|
||||
back, so a dub the user deleted reappears in history. The delete endpoints
|
||||
take this same lock around their own row-delete + evict, so the two
|
||||
sequences cannot interleave at all.
|
||||
|
||||
Returns ``False`` when the job is already gone; the caller stops there.
|
||||
|
||||
The ``save_job`` write happens INSIDE the lock deliberately. That serialises
|
||||
dub job-state access against one SQLite UPSERT — normally microseconds under
|
||||
WAL, but up to sqlite3's 5 s default busy timeout if another writer is
|
||||
holding the write lock. The alternative — releasing the lock before the
|
||||
write — is the resurrection race this exists to close, so a rare latency
|
||||
blip is the better trade. No locked region here calls another locked
|
||||
function, so the plain (non-reentrant) ``_dub_jobs_lock`` cannot deadlock.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
job = _dub_jobs.get(job_id)
|
||||
if job is None or job_id in _withdrawn_jobs:
|
||||
return False
|
||||
job.update(updates)
|
||||
save_job(job_id, job, filename, duration, content_hash)
|
||||
return True
|
||||
|
||||
|
||||
def purge_jobs(job_ids, *, delete_rows, include_inflight: bool = False) -> None:
|
||||
"""Delete history rows and evict the in-memory records as ONE atomic step.
|
||||
|
||||
``delete_rows`` is called with the lock held, so a concurrent
|
||||
:func:`merge_and_save_job` cannot slip between the row-delete and the
|
||||
evict and write the job straight back (#1252 review). Both delete
|
||||
endpoints go through here; ``DELETE /dub/history`` previously never evicted
|
||||
from memory at all, so an in-flight job survived "clear history" entirely
|
||||
and re-saved itself on completion.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
delete_rows()
|
||||
# Deterministic order, de-duplicated. A `set` here made which markers
|
||||
# the size cap evicts depend on PYTHONHASHSEED — the test for this very
|
||||
# behaviour passed locally and failed in CI for that reason alone.
|
||||
targets = list(dict.fromkeys(job_ids))
|
||||
if include_inflight:
|
||||
# "Clear history" means everything, including a job whose first row
|
||||
# hasn't been written yet — it wouldn't appear in `job_ids` at all.
|
||||
targets += [j for j in sorted(_inflight_jobs) if j not in set(targets)]
|
||||
now = time.monotonic()
|
||||
for job_id in targets:
|
||||
_dub_jobs.pop(job_id, None)
|
||||
_withdrawn_jobs.pop(job_id, None)
|
||||
_withdrawn_jobs[job_id] = now # most-recent last
|
||||
_expire_withdrawn(now, protected=len(targets))
|
||||
|
||||
|
||||
def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0, content_hash: str = "") -> None:
|
||||
"""Persist dub job state to SQLite so it survives restarts. Uses UPSERT
|
||||
on `id` so repeated saves in a session keep the latest snapshot.
|
||||
@@ -209,6 +404,22 @@ def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0,
|
||||
keys history restore off language_code, so a frozen "" hid finished
|
||||
tracks until the user re-picked a language.
|
||||
"""
|
||||
with _dub_jobs_lock:
|
||||
# The withdrawal gate lives HERE, not in the callers (#1252 review).
|
||||
# Eight call sites across generate / translate / export / core persist
|
||||
# jobs directly, so gating only the ingest helpers left every
|
||||
# post-ingest save able to resurrect a dub the user deleted mid-render.
|
||||
# One choke point closes the class and the ninth caller inherits it.
|
||||
if job_id in _withdrawn_jobs:
|
||||
logger.info(
|
||||
"Dub job %s was deleted while it was still running — not persisting", job_id,
|
||||
)
|
||||
return
|
||||
_persist_job(job_id, job, filename, duration, content_hash)
|
||||
|
||||
|
||||
def _persist_job(job_id: str, job: dict, filename: str, duration: float, content_hash: str) -> None:
|
||||
"""The actual write. Callers go through :func:`save_job`, which gates it."""
|
||||
try:
|
||||
segments = job.get("segments") or []
|
||||
tracks = list((job.get("dubbed_tracks") or {}).keys())
|
||||
@@ -556,10 +767,23 @@ _YT_PLAYER_CLIENTS = ["tv", "android", "web_safari"]
|
||||
|
||||
|
||||
def _is_forbidden_download_error(exc: BaseException) -> bool:
|
||||
"""True for an HTTP 403 — not transient (the same client keeps 403ing), but
|
||||
often fixable by switching the YouTube player client."""
|
||||
"""True for a failure the CURRENT player client can't get past, but another
|
||||
one commonly can.
|
||||
|
||||
A 403 is the original case (#625): extraction worked, the media fetch was
|
||||
refused, and the same client keeps refusing. "This video is DRM protected"
|
||||
(#1254) behaves identically and belongs here for the same reason — YouTube
|
||||
serves a DRM-only format set to *some* player clients for videos that are
|
||||
not actually DRM'd. The reporter saw it fail and then succeed on a plain
|
||||
retry of the same URL, which is exactly what a per-client format set looks
|
||||
like from outside. Escalating the client is the fix; a bare retry only
|
||||
works when the next attempt happens to draw a different one.
|
||||
"""
|
||||
s = str(exc)
|
||||
return "403" in s or "Forbidden" in s
|
||||
if "403" in s or "Forbidden" in s:
|
||||
return True
|
||||
low = s.lower()
|
||||
return "drm protected" in low or "drm-protected" in low
|
||||
|
||||
|
||||
def _cleanup_partial_download(job_dir: str) -> None:
|
||||
@@ -837,6 +1061,9 @@ async def ingest_pipeline(
|
||||
# Audio-only jobs (#119) skip scene detection + thumbnailing below; the
|
||||
# transcribe → translate → TTS core is identical.
|
||||
input_type = (source.get("input_type") or "video").lower()
|
||||
# Declare the run so a "clear history" arriving before this job's first
|
||||
# persistence can still withdraw it (#1252 review).
|
||||
begin_ingest(job_id)
|
||||
try:
|
||||
if source.get("kind") == "url":
|
||||
url = source["url"]
|
||||
@@ -1005,8 +1232,12 @@ async def ingest_pipeline(
|
||||
"youtube_subs": youtube_subs_by_lang or None,
|
||||
"input_type": input_type,
|
||||
}
|
||||
put_job(job_id, full_job)
|
||||
save_job(job_id, full_job, filename, dur, content_hash)
|
||||
if not put_and_save_job(
|
||||
job_id, full_job, filename=filename, duration=dur, content_hash=content_hash,
|
||||
):
|
||||
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
|
||||
yield prep_event("cancelled")
|
||||
return
|
||||
yield prep_event("extract_done", job_id=job_id, duration=round(dur, 2), filename=filename)
|
||||
yield prep_event("cached",
|
||||
has_bg=bool(no_vocals_path and os.path.exists(no_vocals_path)),
|
||||
@@ -1029,8 +1260,12 @@ async def ingest_pipeline(
|
||||
"youtube_subs": youtube_subs_by_lang or None,
|
||||
"input_type": input_type,
|
||||
}
|
||||
put_job(job_id, partial)
|
||||
save_job(job_id, partial, filename, dur, content_hash)
|
||||
if not put_and_save_job(
|
||||
job_id, partial, filename=filename, duration=dur, content_hash=content_hash,
|
||||
):
|
||||
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
|
||||
yield prep_event("cancelled")
|
||||
return
|
||||
yield prep_event("extract_done", job_id=job_id, duration=round(dur, 2), filename=filename)
|
||||
|
||||
vocals_path = os.path.join(job_dir, "vocals.wav")
|
||||
@@ -1122,13 +1357,30 @@ async def ingest_pipeline(
|
||||
logger.warning("Thumbnail extraction failed for %s: %s", job_id, e)
|
||||
yield prep_event("warning", **failure.build_failure(e, stage="thumbnail", include_diagnostic=False))
|
||||
|
||||
_dub_jobs[job_id].update({
|
||||
"vocals_path": vocals_path,
|
||||
"no_vocals_path": no_vocals_path,
|
||||
"thumb_path": thumb_path if (thumb_path and os.path.exists(thumb_path)) else None,
|
||||
"scene_cuts": scene_cuts,
|
||||
})
|
||||
save_job(job_id, _dub_jobs[job_id], filename, dur, content_hash)
|
||||
# The job can legitimately be gone by now — everything above takes
|
||||
# minutes and `DELETE /dub/history/{id}` pops the record. Deleting
|
||||
# an in-flight dub used to raise KeyError here and surface as the
|
||||
# toast `ingest: 'mgw39lx3'` (#1252/#1253). A withdrawn job is not
|
||||
# an error: stop quietly rather than re-persisting what the user
|
||||
# just deleted.
|
||||
# Merge and persist as one step: a delete landing BETWEEN them
|
||||
# would remove the row and then have it written straight back, so
|
||||
# the dub the user deleted reappears in history (#1252 review).
|
||||
if not merge_and_save_job(
|
||||
job_id,
|
||||
{
|
||||
"vocals_path": vocals_path,
|
||||
"no_vocals_path": no_vocals_path,
|
||||
"thumb_path": thumb_path if (thumb_path and os.path.exists(thumb_path)) else None,
|
||||
"scene_cuts": scene_cuts,
|
||||
},
|
||||
filename=filename,
|
||||
duration=dur,
|
||||
content_hash=content_hash,
|
||||
):
|
||||
logger.info("Dub job %s was deleted during ingest — discarding its result", job_id)
|
||||
yield prep_event("cancelled")
|
||||
return
|
||||
yield prep_event("ready", job_id=job_id, duration=round(dur, 2), filename=filename)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
@@ -1148,5 +1400,6 @@ async def ingest_pipeline(
|
||||
yield prep_event("error", **failure.build_failure(e, stage="ingest"))
|
||||
return
|
||||
finally:
|
||||
end_ingest(job_id)
|
||||
with _active_procs_lock:
|
||||
_active_procs.pop(job_id, None)
|
||||
|
||||
@@ -314,6 +314,67 @@ def find_ffprobe():
|
||||
return None
|
||||
|
||||
|
||||
def ensure_media_tools_on_path() -> list[str]:
|
||||
"""Put the resolved ffmpeg/ffprobe on ``PATH`` for third-party code (#1256).
|
||||
|
||||
OmniVoice's own call sites always resolve an explicit path, so a bundled
|
||||
sidecar that was never on ``PATH`` works fine for us. Our dependencies do
|
||||
not get that courtesy: a library that shells out to ``ffprobe`` by bare
|
||||
name dies with ``FileNotFoundError: [Errno 2] No such file or directory:
|
||||
'ffprobe'``. The reporter of #1256 hit that mid-synthesis and was told the
|
||||
engine had "stopped with an error OmniVoice doesn't recognize", on a Mac
|
||||
where the app's OWN ffprobe was sitting on disk, resolvable, the whole
|
||||
time.
|
||||
|
||||
Prepending the resolved binaries' directories fixes every such dependency
|
||||
at once, rather than chasing them one import at a time. Prepended (not
|
||||
appended) so the copy we validated wins over a broken system one.
|
||||
|
||||
Returns the directories added. Idempotent, best-effort, never raises.
|
||||
"""
|
||||
added: list[str] = []
|
||||
try:
|
||||
directories: list[str] = []
|
||||
for resolve in (find_ffmpeg, find_ffprobe):
|
||||
try:
|
||||
path = resolve()
|
||||
except Exception:
|
||||
continue
|
||||
if not path:
|
||||
continue
|
||||
directory = os.path.dirname(os.path.abspath(path))
|
||||
if directory and directory not in directories:
|
||||
directories.append(directory)
|
||||
|
||||
current = os.environ.get("PATH", "")
|
||||
entries = current.split(os.pathsep) if current else []
|
||||
# Case-insensitive comparison on Windows/macOS, where PATH is not
|
||||
# case-sensitive and "already present" must not depend on casing.
|
||||
normalize = os.path.normcase
|
||||
present = {normalize(e) for e in entries if e}
|
||||
for directory in directories:
|
||||
if normalize(directory) in present:
|
||||
continue
|
||||
entries.insert(0, directory)
|
||||
present.add(normalize(directory))
|
||||
added.append(directory)
|
||||
|
||||
if added:
|
||||
os.environ["PATH"] = os.pathsep.join(entries)
|
||||
# Count, not paths: a user-set FFMPEG_PATH resolves under their home
|
||||
# directory, and absolute home paths must not reach the log
|
||||
# (#1256 review). find_ffmpeg/find_ffprobe already log their own
|
||||
# resolution at debug level when that detail is wanted.
|
||||
logger.info(
|
||||
"Published %d media-tool director%s on PATH so dependencies can "
|
||||
"find ffmpeg/ffprobe (#1256)",
|
||||
len(added), "y" if len(added) == 1 else "ies",
|
||||
)
|
||||
except Exception as e: # diagnosis must never break the thing it helps
|
||||
logger.debug("ensure_media_tools_on_path failed (non-fatal): %s", e)
|
||||
return added
|
||||
|
||||
|
||||
async def _spawn_async(cmd, **kwargs):
|
||||
"""Try asyncio subprocess; fall back to thread-based subprocess on Windows
|
||||
where ProactorEventLoop may not be available (e.g. under uvicorn --reload)."""
|
||||
|
||||
@@ -225,6 +225,45 @@ async def unload(model_id: str) -> dict:
|
||||
return {"unloaded": "diarization", "success": True}
|
||||
return {"unloaded": "diarization", "success": False, "reason": "not loaded"}
|
||||
|
||||
# The warm dictation ASR (#1247, same defect). It is listed with
|
||||
# ``"unloadable": True`` and had no branch either — found by the contract
|
||||
# test written for the engine case, which is the whole reason that test
|
||||
# enumerates the listing instead of hard-coding ids.
|
||||
if model_id == "capture-asr":
|
||||
import services.asr_backend as ab
|
||||
|
||||
if getattr(ab, "_capture_backend", None) is None:
|
||||
return {"unloaded": model_id, "success": False, "reason": "not loaded"}
|
||||
# idle_s=0 → release now. Still declines while a dictation stream holds
|
||||
# a lease; yanking the model out from under an open session is exactly
|
||||
# what the lease exists to prevent.
|
||||
if ab.release_idle_capture_backend(0.0):
|
||||
return {"unloaded": model_id, "success": True}
|
||||
return {"unloaded": model_id, "success": False, "reason": "in use by dictation"}
|
||||
|
||||
# In-process engines (#1247). `list_loaded_models` has advertised these as
|
||||
# `engine:<id>` with `"unloadable": True` since they were made visible in
|
||||
# the panel — but this dispatcher never grew a branch for them, so pressing
|
||||
# Unload on any of those rows answered `400 Unknown model id:
|
||||
# engine:kittentts`. The engines already implement `unload()`; only the
|
||||
# routing was missing.
|
||||
if model_id.startswith("engine:"):
|
||||
engine_id = model_id.split(":", 1)[1]
|
||||
from api.routers.engines import _ENGINE_INSTANCES
|
||||
|
||||
for cls, inst in list(_ENGINE_INSTANCES.items()):
|
||||
if (getattr(cls, "id", cls.__name__)) != engine_id:
|
||||
continue
|
||||
held = any(
|
||||
getattr(inst, attr, None) is not None
|
||||
for attr in getattr(inst, "_MODEL_ATTRS", ("_model", "_tts"))
|
||||
)
|
||||
if not held:
|
||||
return {"unloaded": model_id, "success": False, "reason": "not loaded"}
|
||||
inst.unload() # idempotent by contract; frees device caches itself
|
||||
return {"unloaded": model_id, "success": True}
|
||||
return {"unloaded": model_id, "success": False, "reason": "not loaded"}
|
||||
|
||||
raise ValueError(f"Unknown model id: {model_id}")
|
||||
|
||||
|
||||
|
||||
@@ -1024,14 +1024,23 @@ def should_preload_tts_asr() -> bool:
|
||||
|
||||
|
||||
def _is_incomplete_cache_error(exc: BaseException) -> bool:
|
||||
"""True when `exc` is the truncated-HF-cache class (#352 / #581).
|
||||
"""True when `exc` is the truncated-HF-cache class (#352 / #581 / #1273).
|
||||
|
||||
transformers raises an OSError whose message contains "does not appear to
|
||||
have a file named …" when the on-disk snapshot has config/tokenizer files
|
||||
but no weight shard — the signature of an interrupted download. We match on
|
||||
that phrase (stable across transformers 4.x/5.x) rather than the error type,
|
||||
since the same OSError type covers unrelated I/O failures."""
|
||||
return "does not appear to have a file named" in str(exc)
|
||||
transformers raises an OSError when the on-disk snapshot has config and
|
||||
tokenizer files but no weight shard — the signature of an interrupted
|
||||
download. We match on the message (stable across transformers 4.x/5.x)
|
||||
rather than the error type, since the same OSError type covers unrelated
|
||||
I/O failures.
|
||||
|
||||
There are TWO wordings, and this used to match only the first, so a
|
||||
half-written repo whose *subfolder* failed to load (#1273:
|
||||
"Error no file named model.safetensors, … found in directory
|
||||
…/snapshots/<rev>/audio_tokenizer") got neither the automatic repair nor
|
||||
an actionable message — just a raw 500. `core.failure` owns the phrase
|
||||
list so the heal and the error text can't drift apart."""
|
||||
from core.failure import is_incomplete_cache_message
|
||||
|
||||
return is_incomplete_cache_message(str(exc))
|
||||
|
||||
|
||||
def _hf_offline() -> bool:
|
||||
|
||||
@@ -106,27 +106,87 @@ def _is_closed_client_error(e) -> bool:
|
||||
|
||||
def _retry_once_with_fresh_hf_client(loader, what: str):
|
||||
"""Run ``loader()`` — a model constructor that may download from the HF
|
||||
Hub on first use. On the specific closed-client failure above, reset the
|
||||
hub's shared client and retry exactly ONCE. Any other failure (and a
|
||||
repeat closed-client failure) propagates untouched, where the generation
|
||||
error classifier labels it as a network problem (#880)."""
|
||||
try:
|
||||
return loader()
|
||||
except Exception as e:
|
||||
if not _is_closed_client_error(e):
|
||||
raise
|
||||
logger.warning(
|
||||
"%s: HF Hub httpx client was closed mid-download (%s); "
|
||||
"retrying once with a fresh client.", what, e,
|
||||
)
|
||||
Hub on first use — retrying transient download failures.
|
||||
|
||||
Two failure shapes are retried, with deliberately different budgets:
|
||||
|
||||
* the httpx **closed-client** lifecycle error (#880) — retried exactly
|
||||
ONCE, after resetting the hub's shared session. It's a client-state bug,
|
||||
not a network condition: if a fresh session hits it again, repeating
|
||||
won't help, and #880 chose to surface it rather than loop.
|
||||
* any **transient download** failure ``core.failure
|
||||
.is_hf_connectivity_error`` recognises — refused/reset connections, DNS,
|
||||
timeouts, and (#1224) a truncated body ("peer closed connection without
|
||||
sending complete message body"). A multi-GB model that dies at 90% is
|
||||
the single most retry-worthy failure in the path, so this gets the full
|
||||
bounded budget. The HF cache is resumable (correctly-sized blobs are
|
||||
skipped by hash), so each retry continues rather than restarting.
|
||||
|
||||
Anything unrecognised propagates untouched, where the generation error
|
||||
classifier labels it.
|
||||
"""
|
||||
from core.failure import is_hf_connectivity_error
|
||||
|
||||
attempts = max(1, _int_env("OMNIVOICE_MODEL_LOAD_RETRIES", 3))
|
||||
backoff = max(0.0, _float_env("OMNIVOICE_MODEL_LOAD_BACKOFF_S", 2.0))
|
||||
client_reset_used = False
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
from huggingface_hub.utils import close_session
|
||||
close_session()
|
||||
except Exception: # pragma: no cover — hub too old / API renamed
|
||||
return loader()
|
||||
except Exception as e:
|
||||
if _is_closed_client_error(e):
|
||||
if client_reset_used:
|
||||
raise # #880: single-shot — a second one is not transient
|
||||
client_reset_used = True
|
||||
logger.warning(
|
||||
"%s: HF Hub httpx client was closed mid-download (%s); "
|
||||
"retrying once with a fresh client.", what, e,
|
||||
)
|
||||
try:
|
||||
from huggingface_hub.utils import close_session
|
||||
close_session()
|
||||
except Exception: # pragma: no cover — hub too old / renamed
|
||||
logger.warning(
|
||||
"%s: couldn't reset the HF Hub client; retrying anyway.",
|
||||
what,
|
||||
)
|
||||
# Deliberately does NOT consume a download attempt: the two
|
||||
# budgets are independent, and letting the session reset eat
|
||||
# one left a resumable multi-GB download a retry short of its
|
||||
# configured budget (#1224 review).
|
||||
continue # immediate — nothing to back off from
|
||||
attempt += 1
|
||||
if not is_hf_connectivity_error(str(e)) or attempt >= attempts:
|
||||
raise
|
||||
logger.warning(
|
||||
"%s: couldn't reset the HF Hub client; retrying anyway.", what,
|
||||
"%s: model download failed (%s); retrying (attempt %d/%d). "
|
||||
"Already-downloaded files are reused.",
|
||||
what, e, attempt, attempts,
|
||||
)
|
||||
return loader()
|
||||
if backoff:
|
||||
import time as _time
|
||||
_time.sleep(backoff * attempt)
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _float_env(name: str, default: float) -> float:
|
||||
try:
|
||||
value = float(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
# inf/nan parse fine and then poison the caller: `sleep(inf)` raises
|
||||
# OverflowError, turning a retryable download failure into an unrelated
|
||||
# crash that hides the original error (#1224 review).
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
# ── Protocol ────────────────────────────────────────────────────────────────
|
||||
@@ -774,7 +834,12 @@ class VoxCPM2Backend(TTSBackend):
|
||||
from voxcpm import VoxCPM # type: ignore[import-not-found]
|
||||
checkpoint = os.environ.get("OMNIVOICE_VOXCPM_MODEL", "openbmb/VoxCPM2")
|
||||
logger.info("Loading VoxCPM2 from %s", checkpoint)
|
||||
self._model = VoxCPM.from_pretrained(checkpoint, load_denoiser=False)
|
||||
# #1224: this first-use download is multi-GB. Unretried, a truncated
|
||||
# body at 90% aborted the load outright.
|
||||
self._model = _retry_once_with_fresh_hf_client(
|
||||
lambda: VoxCPM.from_pretrained(checkpoint, load_denoiser=False),
|
||||
"VoxCPM2",
|
||||
)
|
||||
|
||||
def generate(self, text, **kw) -> torch.Tensor:
|
||||
self._ensure_loaded()
|
||||
@@ -909,7 +974,10 @@ class MossTTSNanoBackend(TTSBackend):
|
||||
"OMNIVOICE_MOSS_TTS_MODEL", "OpenMOSS-Team/MOSS-TTS-Nano"
|
||||
)
|
||||
logger.info("Loading MOSS-TTS-Nano from %s", checkpoint)
|
||||
self._model = MossTTSNano.from_pretrained(checkpoint, trust_remote_code=True)
|
||||
self._model = _retry_once_with_fresh_hf_client(
|
||||
lambda: MossTTSNano.from_pretrained(checkpoint, trust_remote_code=True),
|
||||
"MOSS-TTS-Nano",
|
||||
)
|
||||
|
||||
def generate(self, text, **kw) -> torch.Tensor:
|
||||
self._ensure_loaded()
|
||||
|
||||
@@ -331,3 +331,112 @@ def test_lifespan_shutdown_mid_load_is_clean_and_clears_sentinel(
|
||||
finally:
|
||||
release.set()
|
||||
run_sentinel._reset_for_tests()
|
||||
|
||||
|
||||
def test_request_during_shutdown_gets_503_not_a_crash_shaped_500():
|
||||
"""A user-initiated request that triggers a load while the backend is
|
||||
shutting down must answer 503, not 500 (#1276).
|
||||
|
||||
#1174 made this benign for the background preload, but a request took the
|
||||
generic unhandled-exception path: crash log, ERROR traceback, and an
|
||||
error-journal entry that feeds the bug-report pipeline. Quitting the app
|
||||
with a generate queued therefore surfaced "500 Internal Server Error:
|
||||
model load skipped: backend shutting down" and offered to file a bug for
|
||||
a normal teardown.
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main as main_mod
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/boom")
|
||||
async def _boom():
|
||||
raise ModelLoadInterruptedByShutdown("model load skipped: backend shutting down")
|
||||
|
||||
app.add_exception_handler(Exception, main_mod.global_exception_handler)
|
||||
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
resp = client.get("/boom")
|
||||
|
||||
assert resp.status_code == 503, resp.status_code
|
||||
# Answer a shutting-down server owes a client, so the UI can retry rather
|
||||
# than treat it as a fault.
|
||||
assert resp.headers.get("Retry-After") == "5"
|
||||
detail = resp.json()["detail"]
|
||||
# Cross-layer contract: the UI keys off this marker to drop the "Report"
|
||||
# action (utils/errorToast.jsx). It must NOT key off the 503 status alone —
|
||||
# a real engine-load timeout and an unavailable engine are 503 too, and
|
||||
# those are reportable bugs. Renaming this marker breaks that; keep in sync.
|
||||
assert "[shutting_down]" in detail
|
||||
# Actionable, and free of the internal phrasing that read as a crash.
|
||||
assert "shutting down" in detail
|
||||
assert "Reopen the app" in detail
|
||||
assert "Internal Server Error" not in detail
|
||||
|
||||
|
||||
def test_shutdown_request_is_not_written_to_the_crash_log_or_journal(tmp_path, monkeypatch):
|
||||
"""The same teardown must leave no crash-log entry and no journal record —
|
||||
those are what the auto bug reporter reads (#1276)."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main as main_mod
|
||||
from core import error_journal
|
||||
|
||||
crash_log = tmp_path / "crash.log"
|
||||
monkeypatch.setattr(main_mod, "CRASH_LOG_PATH", str(crash_log))
|
||||
|
||||
recorded = []
|
||||
monkeypatch.setattr(
|
||||
error_journal, "record", lambda *a, **k: recorded.append(a) or {}
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/boom")
|
||||
async def _boom():
|
||||
raise ModelLoadInterruptedByShutdown("model load skipped: backend shutting down")
|
||||
|
||||
app.add_exception_handler(Exception, main_mod.global_exception_handler)
|
||||
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
assert client.get("/boom").status_code == 503
|
||||
|
||||
assert not crash_log.exists(), crash_log.read_text()
|
||||
assert recorded == []
|
||||
|
||||
|
||||
def test_shutdown_class_is_matched_across_duplicate_imports():
|
||||
"""The handler must recognise the shutdown class even when it arrives from
|
||||
a second copy of the module.
|
||||
|
||||
``services.model_manager`` gets imported under more than one module name
|
||||
depending on which sys.path root is active (and in the frozen build), so
|
||||
``ModelLoadInterruptedByShutdown`` can exist as two distinct class objects.
|
||||
A bare ``isinstance`` silently fails there and the user is back to a 500 —
|
||||
which is exactly what happened when both test suites ran in one session.
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main as main_mod
|
||||
|
||||
# A same-named class from a *different* module object — what a duplicate
|
||||
# import produces.
|
||||
Impostor = type(
|
||||
"ModelLoadInterruptedByShutdown", (RuntimeError,), {"__module__": "other.copy"}
|
||||
)
|
||||
assert not isinstance(Impostor("x"), ModelLoadInterruptedByShutdown)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/boom")
|
||||
async def _boom():
|
||||
raise Impostor("model load skipped: backend shutting down")
|
||||
|
||||
app.add_exception_handler(Exception, main_mod.global_exception_handler)
|
||||
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
assert client.get("/boom").status_code == 503
|
||||
|
||||
@@ -90,12 +90,12 @@ There's also a Compose file in the repo with `cpu` / `gpu` / `rocm` profiles
|
||||
|-----|--------------|
|
||||
| `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
|
||||
| `:stable` | Most recent versioned release (updated on every `v*` git tag) |
|
||||
| `:0.4.0` | Exact release version |
|
||||
| `:0.4.1` | Exact release version |
|
||||
| `:0.4` | Latest patch within the `0.4` minor |
|
||||
| `:main` | Alias of the same rolling `main` build as `:latest` |
|
||||
| `:sha-xxxxxxx` | A specific commit (produced by manual workflow dispatch) |
|
||||
| `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
|
||||
| `:stable-rocm`, `:0.4.0-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding tags above |
|
||||
| `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding tags above |
|
||||
|
||||
Preview builds always come from `main` and never version-sort below `:stable`,
|
||||
so upgrades flow naturally. The same images and tags
|
||||
|
||||
@@ -13,12 +13,12 @@ and [`palashdeb/omnivoice-studio` on Docker Hub](https://hub.docker.com/r/palash
|
||||
> |-----|--------------|
|
||||
> | `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
|
||||
> | `:stable` | Most recent versioned release (updated on every `v*` git tag) |
|
||||
> | `:0.4.0` | Exact release version |
|
||||
> | `:0.4.1` | Exact release version |
|
||||
> | `:0.4` | Latest patch within the 0.4 minor |
|
||||
> | `:main` | Alias of the same rolling `main` build as `:latest` |
|
||||
> | `:sha-xxxxxxx` | Specific commit (produced by manual workflow dispatch) |
|
||||
> | `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
|
||||
> | `:stable-rocm`, `:0.4.0-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
|
||||
> | `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
|
||||
>
|
||||
> Versioning rule: preview builds always come from `main` and never
|
||||
> version-sort below `:stable` — upgrades flow naturally.
|
||||
@@ -89,7 +89,7 @@ PublishPort=127.0.0.1:3900:3900
|
||||
Volume=omnivoice-data:/app/omnivoice_data
|
||||
```
|
||||
|
||||
Release pins exist too: `:stable-rocm`, `:0.4.0-rocm`, `:0.4-rocm` mirror
|
||||
Release pins exist too: `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm` mirror
|
||||
the CUDA tags exactly.
|
||||
|
||||
> **Consumer cards and APUs (RX 6000/7000, Strix Point/Halo):** the backend
|
||||
|
||||
@@ -30,6 +30,14 @@ To bind this agent to a specific voice, send an
|
||||
`X-OmniVoice-Client-Id` header (e.g. `claude-code`). See
|
||||
[per-agent voices](#per-agent-voices).
|
||||
|
||||
**Agents in Docker or on another machine:** the MCP SDK rejects non-localhost
|
||||
Host headers by default (DNS-rebinding guard). Set
|
||||
`OMNIVOICE_MCP_ALLOWED_HOSTS` to a comma-separated list of host patterns the
|
||||
agent connects from (e.g. `host.containers.internal:*,192.168.1.50:*`).
|
||||
Keep this on a trusted LAN or behind TLS (Tailscale Serve, a reverse proxy
|
||||
with HTTPS) — the MCP transport is not authenticated, so don't expose it on
|
||||
the open internet.
|
||||
|
||||
### stdio (clients that only speak stdio)
|
||||
|
||||
Use the bundled shim — it proxies stdio ↔ the mounted HTTP endpoint. Drop
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -2941,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"dirs-next",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
description = "OmniVoice Studio – AI voice cloning & dubbing desktop app"
|
||||
authors = ["Debpalash"]
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
@@ -149,6 +149,39 @@ fn raw_http_get(url: &str, timeout: Duration) -> Result<String, String> {
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Exit code `backend/main.py` uses when it could not bind the port (#1223).
|
||||
/// Keep in sync with `_EXIT_PORT_IN_USE` there.
|
||||
pub const EXIT_PORT_IN_USE: i32 = 78;
|
||||
|
||||
/// Kill whoever holds `port`, then confirm it actually came free.
|
||||
///
|
||||
/// #1223: every caller used to kill-then-sleep-then-spawn unconditionally, so
|
||||
/// a holder we cannot kill — a different user's process, a `taskkill` blocked
|
||||
/// by policy, a socket sitting in TIME_WAIT that the Windows `netstat`
|
||||
/// LISTENING filter can't even see — was indistinguishable from success. The
|
||||
/// backend then died on the bind with a raw errno and the user got "Backend
|
||||
/// died (exit code 1)".
|
||||
///
|
||||
/// Returns true when the port is free afterwards. Polls rather than sleeping a
|
||||
/// flat interval: the common case (our own orphan) frees in well under 500ms,
|
||||
/// and the uncommon case deserves longer than one guess.
|
||||
pub fn free_port_or_report(port: u16) -> bool {
|
||||
kill_orphan_on_port(port);
|
||||
for _ in 0..20 {
|
||||
if !port_in_use(port) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
log::error!(
|
||||
"Port {} is still held after attempting to kill its owner — the \
|
||||
backend cannot bind it. Another application (or a process owned by a \
|
||||
different user) is using the port.",
|
||||
port
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
/// Kill whatever process owns the port.
|
||||
#[cfg(unix)]
|
||||
pub fn kill_orphan_on_port(port: u16) {
|
||||
|
||||
@@ -260,8 +260,24 @@ pub fn respawn_backend(
|
||||
if crate::backend::port_in_use(backend_port()) {
|
||||
log::warn!("Port {} in use — taking ownership", backend_port());
|
||||
set_backend_kill_intended(true); // deliberate kill, not a crash (#941)
|
||||
crate::backend::kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
// #1223: verify the port actually came free. Spawning into a port
|
||||
// we failed to reclaim just moves the failure into the backend,
|
||||
// where it surfaced as an unexplained "exit code 1".
|
||||
if !crate::backend::free_port_or_report(backend_port()) {
|
||||
set_stage(
|
||||
&stage_handle,
|
||||
BootstrapStage::Failed {
|
||||
message: format!(
|
||||
"Port {} is already in use by another application, \
|
||||
and OmniVoice could not free it. Quit whatever is \
|
||||
using that port (another copy of OmniVoice, or an \
|
||||
app that claimed it) and try again.",
|
||||
backend_port()
|
||||
),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
spawn_backend_and_wait(&app, &stage_handle);
|
||||
});
|
||||
@@ -399,7 +415,24 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
|
||||
);
|
||||
return;
|
||||
}
|
||||
let msg = if err_tail.is_empty() {
|
||||
// #1223: the backend exits EXIT_PORT_IN_USE when it could not
|
||||
// bind its port. That is a conflict, not a crash — say what to
|
||||
// do instead of dumping a traceback whose one meaningful line
|
||||
// is an OS-translated errno.
|
||||
let msg = if real_exit
|
||||
.as_ref()
|
||||
.and_then(|e| e.code)
|
||||
.is_some_and(|c| c == crate::backend::EXIT_PORT_IN_USE)
|
||||
{
|
||||
format!(
|
||||
"Port {} is already in use, so the backend could not \
|
||||
start. Another copy of OmniVoice — or an app that \
|
||||
claimed that port — is holding it. Quit it and try \
|
||||
again; if nothing is visibly running, an orphaned \
|
||||
backend from a previous session still has the port.",
|
||||
backend_port()
|
||||
)
|
||||
} else if err_tail.is_empty() {
|
||||
format!("Backend process exited ({}) — no error output captured", exit_info)
|
||||
} else {
|
||||
format!("Backend process exited ({}):\n{}", exit_info, err_tail)
|
||||
@@ -578,10 +611,31 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
|
||||
// poll has already stopped post-Ready, so the stage alone won't show).
|
||||
let _ = app.emit("backend-restarting", exit_info.clone());
|
||||
set_stage(stage_handle, BootstrapStage::StartingBackend);
|
||||
// Clear any orphan still holding the port before the respawn.
|
||||
if crate::backend::port_in_use(backend_port()) {
|
||||
crate::backend::kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
// Clear any orphan still holding the port before the respawn. #1223:
|
||||
// if it can't be cleared, respawning just reproduces the bind failure
|
||||
// — stop and say so rather than burning a restart attempt.
|
||||
if crate::backend::port_in_use(backend_port())
|
||||
&& !crate::backend::free_port_or_report(backend_port())
|
||||
{
|
||||
set_stage(
|
||||
stage_handle,
|
||||
BootstrapStage::Failed {
|
||||
// Wording note: every one of these must contain a phrase
|
||||
// `BootstrapSplash.detectHints` matches ("port … in use"),
|
||||
// because that is what turns an English Rust message into
|
||||
// the LOCALISED `bootstrap.hint_port` the user actually
|
||||
// reads. Pinned in frontend/src/test/portInUseHint.test.js
|
||||
// — an earlier draft of this one said "is held by" and
|
||||
// silently lost the translated guidance.
|
||||
message: format!(
|
||||
"Port {} is still in use by another application and \
|
||||
OmniVoice could not free it, so the backend can't \
|
||||
restart. Quit whatever is using that port and relaunch.",
|
||||
backend_port()
|
||||
),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
let child = crate::backend::spawn_backend(app, Some(stage_handle));
|
||||
track_backend_child(app, child);
|
||||
|
||||
@@ -1165,9 +1165,6 @@ function App() {
|
||||
return (
|
||||
<div style={{ zoom: uiScale }}>
|
||||
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
|
||||
<Suspense fallback={null}>
|
||||
<LogsFooter />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1204,9 +1201,6 @@ function App() {
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<LogsFooter />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { API, apiUrl, apiFetch, apiJson } from './client';
|
||||
import { useAppStore } from '../store';
|
||||
|
||||
export async function generateSpeech(
|
||||
formData: FormData,
|
||||
{ signal }: { signal?: AbortSignal } = {},
|
||||
): Promise<Response> {
|
||||
// Returns the full Response so callers can stream the WAV blob + read headers.
|
||||
return apiFetch('/generate', { method: 'POST', body: formData, signal });
|
||||
// Count this synth as in flight for as long as it runs. Installing an update
|
||||
// relaunches the process, so the updater has to know a synth is running
|
||||
// (utils/appBusy) — and the guard belongs HERE, at the one call every synth
|
||||
// path shares: the Generate tab, voice previews, the compare modal, the
|
||||
// stories editor and profile previews all reach /generate through this
|
||||
// function. Tracking it in any single caller would leave the others able to
|
||||
// be silently discarded, and a new caller would have to remember to opt in.
|
||||
//
|
||||
// A count rather than a flag because these overlap: a boolean would be
|
||||
// cleared by whichever request settled first while the rest were still
|
||||
// running. `finally` so an abort or a network error releases it too.
|
||||
useAppStore.getState().addTtsInflight?.(1);
|
||||
try {
|
||||
// Returns the full Response so callers can stream the WAV blob + read headers.
|
||||
return await apiFetch('/generate', { method: 'POST', body: formData, signal });
|
||||
} finally {
|
||||
useAppStore.getState().addTtsInflight?.(-1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listHistory(): Promise<unknown> {
|
||||
|
||||
@@ -141,7 +141,18 @@ export function detectHints(message, logs = []) {
|
||||
if (/uv sync failed/i.test(all)) hints.push('bootstrap.hint_uv_sync');
|
||||
if (/hatchling|build_editable/i.test(all)) hints.push('bootstrap.hint_build_backend');
|
||||
if (/ffmpeg/i.test(all) && /download|timeout/i.test(all)) hints.push('bootstrap.hint_ffmpeg');
|
||||
if (/port.*in use|address.*in use/i.test(all)) hints.push('bootstrap.hint_port');
|
||||
// #1223: Windows' WSAEADDRINUSE text is "only one usage of each socket
|
||||
// address is normally permitted" — it contains neither "port ... in use" nor
|
||||
// "address ... in use", and the OS translates it into the user's locale
|
||||
// (the report that surfaced this was in Russian). Match the locale-
|
||||
// independent errnos too: 10048 (Windows), 48 (macOS/BSD), 98 (Linux), and
|
||||
// the backend's own EX_CONFIG exit code for this case.
|
||||
if (
|
||||
/port.*in use|address.*in use|errno 10048|errno 48|errno 98|only one usage of each socket|exit code 78/i.test(
|
||||
all,
|
||||
)
|
||||
)
|
||||
hints.push('bootstrap.hint_port');
|
||||
if (/no error output/i.test(all)) hints.push('bootstrap.hint_silent_crash');
|
||||
if (/seems stuck at|never reported ready/i.test(all)) hints.push('bootstrap.hint_stuck');
|
||||
if (/blocking GitHub|couldn't download Python|python-build-standalone|dns error/i.test(all))
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* "An update is available" — as a toast with actions, not a wall of text.
|
||||
*
|
||||
* The release notes for a version are the whole changelog section, which for
|
||||
* v0.4.1 was 42 bullets. Anything that renders them inline becomes unusable:
|
||||
* older builds put them in a blocking OS dialog that filled the screen and had
|
||||
* to be dismissed before the app could be touched.
|
||||
*
|
||||
* The opposite failure is just as real — this app's only remaining signal was a
|
||||
* 6-pixel dot beside the version number in the footer, which nobody notices.
|
||||
*
|
||||
* So: a toast that states the version, offers the two actions that matter, and
|
||||
* leaves. The notes stay one click away in Settings → Updates, where there is
|
||||
* room for them.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAppStore } from '../store';
|
||||
import { installUpdate } from '../utils/updater';
|
||||
import { isAppBusy } from '../utils/appBusy';
|
||||
|
||||
/** Toast id — one per version, so a re-check can't stack duplicates. */
|
||||
const toastId = (version) => `update-available-${version}`;
|
||||
|
||||
function UpdateToastBody({ id, version }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const openNotes = () => {
|
||||
useAppStore.getState().openSettingsTab?.('updates');
|
||||
toast.dismiss(id);
|
||||
};
|
||||
|
||||
const install = () => {
|
||||
// Installing relaunches the process, so anything in flight is lost — not
|
||||
// just a dub synth, but an upload, a transcription, a translation, an
|
||||
// export or a standalone TTS run. `isAppBusy` is the shared answer;
|
||||
// UpdatesPanel asks it too, because that path is reachable without this
|
||||
// toast and the two checks must not drift.
|
||||
if (isAppBusy(useAppStore.getState())) {
|
||||
toast(t('update.busy'), { icon: '⏳' });
|
||||
return;
|
||||
}
|
||||
toast.dismiss(id);
|
||||
installUpdate(useAppStore.getState());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2" data-testid="update-toast">
|
||||
<div className="leading-snug">
|
||||
{t('update.toast_available', {
|
||||
version,
|
||||
defaultValue: 'OmniVoice Studio {{version}} is available',
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={install}
|
||||
className="rounded px-2 py-[3px] text-[0.72rem] font-semibold [background:var(--chrome-accent)] text-black hover:opacity-90"
|
||||
>
|
||||
{t('update.toast_install', { defaultValue: 'Install and restart' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNotes}
|
||||
className="rounded px-2 py-[3px] text-[0.72rem] underline opacity-80 hover:opacity-100"
|
||||
>
|
||||
{t('update.toast_whats_new', { defaultValue: "What's new" })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toast.dismiss(id)}
|
||||
className="ml-auto rounded px-2 py-[3px] text-[0.72rem] opacity-60 hover:opacity-100"
|
||||
>
|
||||
{t('update.toast_later', { defaultValue: 'Later' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce *version* once. Idempotent per version: react-hot-toast replaces a
|
||||
* toast with the same id rather than stacking, so the 6-hourly re-check can't
|
||||
* pile up copies of the same announcement.
|
||||
*
|
||||
* Deliberately never auto-dismisses — an update the user hasn't answered is
|
||||
* still true. It does not block anything, and "Later" removes it.
|
||||
*/
|
||||
export function showUpdateToast(version) {
|
||||
if (!version) return;
|
||||
const id = toastId(version);
|
||||
toast.custom((tst) => <UpdateToastBody id={tst.id} version={version} />, {
|
||||
id,
|
||||
duration: Infinity,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
}
|
||||
|
||||
export default UpdateToastBody;
|
||||
@@ -21,6 +21,7 @@ import { prepareReleases } from '../utils/updatePresentation';
|
||||
import { setChannel } from '../utils/channelControl';
|
||||
import { fetchChangelog, fetchBackupState } from '../utils/updatesApi';
|
||||
import { APP_VERSION } from '../utils/appVersion';
|
||||
import { isAppBusy } from '../utils/appBusy';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import ChangelogViewer from './ChangelogViewer';
|
||||
|
||||
@@ -37,7 +38,11 @@ export default function UpdatesPanel() {
|
||||
const releasesStatus = useAppStore((s) => s.releasesStatus);
|
||||
const loadReleases = useAppStore((s) => s.loadReleases);
|
||||
const dismissUpdate = useAppStore((s) => s.dismissUpdate);
|
||||
// Subscribed, not read via getState() — `busy` disables the install button,
|
||||
// so it has to re-render when the work starts or finishes.
|
||||
const dubStep = useAppStore((s) => s.dubStep);
|
||||
const pillStage = useAppStore((s) => s.stage);
|
||||
const ttsInflight = useAppStore((s) => s.ttsInflight);
|
||||
|
||||
const [changelog, setChangelog] = useState([]);
|
||||
const [backup, setBackup] = useState(null);
|
||||
@@ -66,9 +71,18 @@ export default function UpdatesPanel() {
|
||||
if (v) useAppStore.getState().setWhatsNewSeenVersion?.(v);
|
||||
}, [appVersion]);
|
||||
|
||||
const busy = dubStep === 'generating';
|
||||
// Installing relaunches the process. `dubStep === 'generating'` used to be
|
||||
// the whole check, which let a relaunch through during an upload, a
|
||||
// transcription, a translation, an export or a standalone synth.
|
||||
//
|
||||
// Subscribed (not getState()) so this re-renders when work starts or stops:
|
||||
// it greys the button out and says why, rather than letting the user click
|
||||
// and get a toast back.
|
||||
const busy = isAppBusy({ dubStep, stage: pillStage, ttsInflight });
|
||||
const onInstall = () => {
|
||||
if (busy) {
|
||||
// The click-time read is the authority — work can start between the last
|
||||
// render and the click, so `busy` above cannot be the safety check.
|
||||
if (isAppBusy(useAppStore.getState())) {
|
||||
toast(t('update.busy'), { icon: '⏳' });
|
||||
return;
|
||||
}
|
||||
@@ -81,7 +95,12 @@ export default function UpdatesPanel() {
|
||||
<div className="updates-panel">
|
||||
<div className="updates-panel__live">
|
||||
{status === 'available' && (
|
||||
<button className="updates-panel__cta" onClick={onInstall}>
|
||||
<button
|
||||
className="updates-panel__cta"
|
||||
onClick={onInstall}
|
||||
disabled={busy}
|
||||
title={busy ? t('update.busy') : undefined}
|
||||
>
|
||||
<Download size={13} /> {t('update.available', { version: version || '' })} ·{' '}
|
||||
{t('update.install')}
|
||||
</button>
|
||||
@@ -95,7 +114,12 @@ export default function UpdatesPanel() {
|
||||
</span>
|
||||
)}
|
||||
{status === 'ready' && (
|
||||
<button className="updates-panel__cta" onClick={onInstall}>
|
||||
<button
|
||||
className="updates-panel__cta"
|
||||
onClick={onInstall}
|
||||
disabled={busy}
|
||||
title={busy ? t('update.busy') : undefined}
|
||||
>
|
||||
<RotateCw size={13} /> {t('update.restart')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "الإبلاغ عن هذا الخطأ",
|
||||
"report_failed": "تعذّر فتح تقرير الخطأ. حاول مرة أخرى، أو انسخ التفاصيل أعلاه إلى مشكلة جديدة.",
|
||||
"searchIssues": "البحث عن مشكلات مشابهة",
|
||||
"unexpected": "خطأ غير متوقع: {{message}}"
|
||||
"unexpected": "خطأ غير متوقع: {{message}}",
|
||||
"backend_shutting_down": "يجري إغلاق OmniVoice. أعد فتح التطبيق وحاول مرة أخرى."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "اختصارات لوحة المفاتيح",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "نزّل التحديث وأعد التشغيل إلى الإصدار الجديد",
|
||||
"downloading": "جارٍ التحديث… {{pct}}%",
|
||||
"restart": "أعد التشغيل للتحديث",
|
||||
"busy": "أكمل الدبلجة أولاً — ثم ثبّت التحديث.",
|
||||
"busy": "انتظر حتى ينتهي العمل الجاري — ثم ثبّت التحديث.",
|
||||
"whats_new": "ما هو الجديد",
|
||||
"failed": "فشل التحديث",
|
||||
"retry": "أعد المحاولة",
|
||||
"dismiss": "استبعاد"
|
||||
"dismiss": "استبعاد",
|
||||
"toast_available": "الإصدار {{version}} من OmniVoice Studio متاح",
|
||||
"toast_install": "تثبيت وإعادة التشغيل",
|
||||
"toast_whats_new": "ما الجديد",
|
||||
"toast_later": "لاحقًا"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "مميز",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "ما أبلغ عنه التطبيق",
|
||||
"retry_hint": "جرّب \"إعادة المحاولة\" من الإعدادات → السجلات → الواجهة الخلفية. وإذا فشلت مجددًا، فإن \"تنظيف وإعادة المحاولة\" يعيد بناء البيئة من الصفر."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,8 +308,8 @@
|
||||
"frontend": "Frontend",
|
||||
"tauri": "Tauri",
|
||||
"cancelOp": "Vorgang abbrechen",
|
||||
"dismiss": "Entlassen",
|
||||
"dismissStatus": "Status entlassen",
|
||||
"dismiss": "Schließen",
|
||||
"dismissStatus": "Status schließen",
|
||||
"search": "Suchen…",
|
||||
"no_matches": "Keine Übereinstimmungen",
|
||||
"recent_and_popular": "Neu und beliebt",
|
||||
@@ -1276,7 +1276,7 @@
|
||||
"later": "Vielleicht später",
|
||||
"star": "Stern auf GitHub",
|
||||
"opt_out": "Fragen Sie nicht noch einmal",
|
||||
"dismiss_aria": "Entlassen"
|
||||
"dismiss_aria": "Schließen"
|
||||
}
|
||||
},
|
||||
"enterprise": {
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Diesen Fehler melden",
|
||||
"report_failed": "Der Fehlerbericht konnte nicht geöffnet werden. Versuche es erneut oder kopiere die obigen Details in ein neues Issue.",
|
||||
"searchIssues": "Ähnliche Probleme suchen",
|
||||
"unexpected": "Unerwarteter Fehler: {{message}}"
|
||||
"unexpected": "Unerwarteter Fehler: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice wird beendet. Öffnen Sie die App erneut und versuchen Sie es noch einmal."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Tastaturkürzel",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Update herunterladen und in die neue Version neu starten",
|
||||
"downloading": "Wird aktualisiert… {{pct}} %",
|
||||
"restart": "Zum Aktualisieren neu starten",
|
||||
"busy": "Beende zuerst deine Synchronisation – dann installiere das Update.",
|
||||
"busy": "Warten Sie, bis die laufende Arbeit fertig ist – dann installieren Sie das Update.",
|
||||
"whats_new": "Was ist neu?",
|
||||
"failed": "Update fehlgeschlagen",
|
||||
"retry": "Versuchen Sie es noch einmal",
|
||||
"dismiss": "Entlassen"
|
||||
"dismiss": "Schließen",
|
||||
"toast_available": "OmniVoice Studio {{version}} ist verfügbar",
|
||||
"toast_install": "Installieren und neu starten",
|
||||
"toast_whats_new": "Neuerungen",
|
||||
"toast_later": "Später"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Hervorgehoben",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Meldung der App",
|
||||
"retry_hint": "Versuche \"Erneut versuchen\" unter Einstellungen → Protokolle → Backend. Schlägt es erneut fehl, baut \"Bereinigen & erneut versuchen\" die Umgebung neu auf."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,11 +43,15 @@
|
||||
"install_hint": "Download the update and restart into the new version",
|
||||
"downloading": "Updating… {{pct}}%",
|
||||
"restart": "Restart to update",
|
||||
"busy": "Finish your dub first — then install the update.",
|
||||
"busy": "Wait for the work in progress to finish — then install the update.",
|
||||
"whats_new": "What's new",
|
||||
"failed": "Update failed",
|
||||
"retry": "Retry",
|
||||
"dismiss": "Dismiss"
|
||||
"dismiss": "Dismiss",
|
||||
"toast_available": "OmniVoice Studio {{version}} is available",
|
||||
"toast_install": "Install and restart",
|
||||
"toast_whats_new": "What's new",
|
||||
"toast_later": "Later"
|
||||
},
|
||||
"updates": {
|
||||
"tab": "Updates",
|
||||
@@ -1911,7 +1915,8 @@
|
||||
"report": "Report this bug",
|
||||
"report_failed": "Couldn't open the bug report. Please try again, or copy the details above into a new issue.",
|
||||
"searchIssues": "Search similar issues",
|
||||
"unexpected": "Unexpected error: {{message}}"
|
||||
"unexpected": "Unexpected error: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice is shutting down. Reopen the app and try again."
|
||||
},
|
||||
"crash": {
|
||||
"notice": "The voice backend crashed ({{exit}}) {{ago}} ago and is being restarted automatically.",
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Informar de este error",
|
||||
"report_failed": "No se pudo abrir el informe de error. Inténtalo de nuevo o copia los detalles anteriores en una incidencia nueva.",
|
||||
"searchIssues": "Buscar problemas similares",
|
||||
"unexpected": "Error inesperado: {{message}}"
|
||||
"unexpected": "Error inesperado: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice se está cerrando. Vuelve a abrir la aplicación e inténtalo de nuevo."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Atajos de teclado",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Descarga la actualización y reinicia en la nueva versión",
|
||||
"downloading": "Actualizando… {{pct}}%",
|
||||
"restart": "Reiniciar para actualizar",
|
||||
"busy": "Termina tu doblaje primero — luego instala la actualización.",
|
||||
"busy": "Espera a que termine el trabajo en curso — luego instala la actualización.",
|
||||
"whats_new": "¿Qué hay de nuevo?",
|
||||
"failed": "La actualización falló",
|
||||
"retry": "Reintentar",
|
||||
"dismiss": "Descartar"
|
||||
"dismiss": "Descartar",
|
||||
"toast_available": "OmniVoice Studio {{version}} está disponible",
|
||||
"toast_install": "Instalar y reiniciar",
|
||||
"toast_whats_new": "Novedades",
|
||||
"toast_later": "Más tarde"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Destacado",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Lo que informó la aplicación",
|
||||
"retry_hint": "Prueba \"Reintentar\" en Ajustes → Registros → Backend. Si vuelve a fallar, \"Limpiar y reintentar\" reconstruye el entorno desde cero."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@
|
||||
"tauri": "Taureau",
|
||||
"cancelOp": "Annuler l'opération",
|
||||
"dismiss": "Rejeter",
|
||||
"dismissStatus": "Statut de rejet",
|
||||
"dismissStatus": "Ignorer le statut",
|
||||
"search": "Rechercher…",
|
||||
"no_matches": "Aucune correspondance",
|
||||
"recent_and_popular": "Récents et populaires",
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Signaler ce bogue",
|
||||
"report_failed": "Impossible d'ouvrir le rapport de bug. Réessayez, ou copiez les détails ci-dessus dans un nouveau ticket.",
|
||||
"searchIssues": "Rechercher des problèmes similaires",
|
||||
"unexpected": "Erreur inattendue : {{message}}"
|
||||
"unexpected": "Erreur inattendue : {{message}}",
|
||||
"backend_shutting_down": "OmniVoice est en cours de fermeture. Rouvrez l’application et réessayez."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Raccourcis clavier",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Télécharger la mise à jour et redémarrer dans la nouvelle version",
|
||||
"downloading": "Mise à jour… {{pct}} %",
|
||||
"restart": "Redémarrer pour mettre à jour",
|
||||
"busy": "Terminez d'abord votre doublage, puis installez la mise à jour.",
|
||||
"busy": "Attendez la fin du travail en cours, puis installez la mise à jour.",
|
||||
"whats_new": "Quoi de neuf",
|
||||
"failed": "La mise à jour a échoué",
|
||||
"retry": "Réessayer",
|
||||
"dismiss": "Rejeter"
|
||||
"dismiss": "Rejeter",
|
||||
"toast_available": "OmniVoice Studio {{version}} est disponible",
|
||||
"toast_install": "Installer et redémarrer",
|
||||
"toast_whats_new": "Nouveautés",
|
||||
"toast_later": "Plus tard"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "En vedette",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Ce que l'application a signalé",
|
||||
"retry_hint": "Essayez « Réessayer » dans Paramètres → Journaux → Backend. En cas de nouvel échec, « Nettoyer et réessayer » reconstruit l'environnement de zéro."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "इस बग की रिपोर्ट करें",
|
||||
"report_failed": "बग रिपोर्ट नहीं खुल सकी। कृपया फिर से कोशिश करें, या ऊपर दिए विवरण को नए इशू में कॉपी करें।",
|
||||
"searchIssues": "मिलते-जुलते मुद्दे खोजें",
|
||||
"unexpected": "अप्रत्याशित त्रुटि: {{message}}"
|
||||
"unexpected": "अप्रत्याशित त्रुटि: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice बंद हो रहा है। ऐप दोबारा खोलें और फिर कोशिश करें।"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "कीबोर्ड शॉर्टकट",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "अपडेट डाउनलोड करें और नए संस्करण में पुनः आरंभ करें",
|
||||
"downloading": "अपडेट हो रहा है… {{pct}}%",
|
||||
"restart": "अपडेट करने के लिए पुनः आरंभ करें",
|
||||
"busy": "पहले अपनी डबिंग पूरी करें — फिर अपडेट इंस्टॉल करें।",
|
||||
"busy": "चल रहे काम के पूरा होने का इंतज़ार करें — फिर अपडेट इंस्टॉल करें।",
|
||||
"whats_new": "नया क्या है",
|
||||
"failed": "अद्यतन विफल रहा",
|
||||
"retry": "पुनः प्रयास करें",
|
||||
"dismiss": "ख़ारिज करें"
|
||||
"dismiss": "ख़ारिज करें",
|
||||
"toast_available": "OmniVoice Studio {{version}} उपलब्ध है",
|
||||
"toast_install": "इंस्टॉल करें और पुनः आरंभ करें",
|
||||
"toast_whats_new": "नया क्या है",
|
||||
"toast_later": "बाद में"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "विशेष रुप से प्रदर्शित",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "ऐप ने क्या बताया",
|
||||
"retry_hint": "सेटिंग्स → लॉग → बैकएंड में \"पुनः प्रयास\" आज़माएँ। फिर भी विफल हो तो \"साफ़ करें और पुनः प्रयास\" वातावरण को नए सिरे से बनाता है।"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Laporkan bug ini",
|
||||
"report_failed": "Tidak dapat membuka laporan bug. Coba lagi, atau salin detail di atas ke isu baru.",
|
||||
"searchIssues": "Cari masalah serupa",
|
||||
"unexpected": "Kesalahan tak terduga: {{message}}"
|
||||
"unexpected": "Kesalahan tak terduga: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice sedang ditutup. Buka kembali aplikasinya lalu coba lagi."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Pintasan keyboard",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Unduh pembaruan dan mulai ulang ke versi baru",
|
||||
"downloading": "Memperbarui… {{pct}}%",
|
||||
"restart": "Mulai ulang untuk memperbarui",
|
||||
"busy": "Selesaikan sulih suara Anda dulu — lalu instal pembaruannya.",
|
||||
"busy": "Tunggu pekerjaan yang sedang berjalan selesai — lalu instal pembaruannya.",
|
||||
"whats_new": "Apa yang baru",
|
||||
"failed": "Pembaruan gagal",
|
||||
"retry": "Coba lagi",
|
||||
"dismiss": "Singkirkan"
|
||||
"dismiss": "Singkirkan",
|
||||
"toast_available": "OmniVoice Studio {{version}} tersedia",
|
||||
"toast_install": "Instal dan mulai ulang",
|
||||
"toast_whats_new": "Yang baru",
|
||||
"toast_later": "Nanti"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Unggulan",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Yang dilaporkan aplikasi",
|
||||
"retry_hint": "Coba \"Coba lagi\" di Pengaturan → Log → Backend. Jika gagal lagi, \"Bersihkan & Coba lagi\" akan membangun ulang lingkungan dari awal."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Segnala questo bug",
|
||||
"report_failed": "Impossibile aprire la segnalazione. Riprova oppure copia i dettagli qui sopra in una nuova issue.",
|
||||
"searchIssues": "Cerca problemi simili",
|
||||
"unexpected": "Errore imprevisto: {{message}}"
|
||||
"unexpected": "Errore imprevisto: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice si sta chiudendo. Riapri l’app e riprova."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Scorciatoie da tastiera",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Scarica l'aggiornamento e riavvia nella nuova versione",
|
||||
"downloading": "Aggiornamento… {{pct}}%",
|
||||
"restart": "Riavvia per aggiornare",
|
||||
"busy": "Completa prima il tuo doppiaggio, poi installa l'aggiornamento.",
|
||||
"busy": "Attendi il completamento del lavoro in corso, poi installa l’aggiornamento.",
|
||||
"whats_new": "Cosa c'è di nuovo",
|
||||
"failed": "Aggiornamento non riuscito",
|
||||
"retry": "Riprova",
|
||||
"dismiss": "Ignora"
|
||||
"dismiss": "Ignora",
|
||||
"toast_available": "OmniVoice Studio {{version}} è disponibile",
|
||||
"toast_install": "Installa e riavvia",
|
||||
"toast_whats_new": "Novità",
|
||||
"toast_later": "Più tardi"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "In primo piano",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Cosa ha segnalato l'app",
|
||||
"retry_hint": "Prova \"Riprova\" in Impostazioni → Log → Backend. Se fallisce di nuovo, \"Pulisci e riprova\" ricostruisce l'ambiente da zero."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,8 +308,8 @@
|
||||
"frontend": "フロントエンド",
|
||||
"tauri": "タウリ",
|
||||
"cancelOp": "操作をキャンセルする",
|
||||
"dismiss": "解雇する",
|
||||
"dismissStatus": "ステータスを却下",
|
||||
"dismiss": "閉じる",
|
||||
"dismissStatus": "ステータスを閉じる",
|
||||
"search": "検索…",
|
||||
"no_matches": "一致しません",
|
||||
"recent_and_popular": "最近の人気の",
|
||||
@@ -1276,7 +1276,7 @@
|
||||
"later": "たぶん後で",
|
||||
"star": "GitHub でスターを付ける",
|
||||
"opt_out": "二度と聞かないでください",
|
||||
"dismiss_aria": "解雇する"
|
||||
"dismiss_aria": "閉じる"
|
||||
}
|
||||
},
|
||||
"enterprise": {
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "このバグを報告",
|
||||
"report_failed": "バグレポートを開けませんでした。もう一度お試しいただくか、上記の内容を新しい Issue にコピーしてください。",
|
||||
"searchIssues": "類似の問題を検索",
|
||||
"unexpected": "予期しないエラー: {{message}}"
|
||||
"unexpected": "予期しないエラー: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice を終了しています。アプリを開き直してからもう一度お試しください。"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "キーボードショートカット",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "アップデートをダウンロードして新しいバージョンで再起動します",
|
||||
"downloading": "更新中… {{pct}}%",
|
||||
"restart": "再起動してアップデート",
|
||||
"busy": "先に吹き替えを完了してから、アップデートをインストールしてください。",
|
||||
"busy": "実行中の処理が終わってから、アップデートをインストールしてください。",
|
||||
"whats_new": "新機能",
|
||||
"failed": "アップデートに失敗しました",
|
||||
"retry": "再試行",
|
||||
"dismiss": "解雇する"
|
||||
"dismiss": "閉じる",
|
||||
"toast_available": "OmniVoice Studio {{version}} が利用可能です",
|
||||
"toast_install": "インストールして再起動",
|
||||
"toast_whats_new": "新機能",
|
||||
"toast_later": "後で"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "注目の",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "アプリが報告した内容",
|
||||
"retry_hint": "設定 → ログ → バックエンド の「再試行」をお試しください。再度失敗する場合は「クリーンして再試行」で環境を一から再構築します。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "이 버그 신고",
|
||||
"report_failed": "버그 리포트를 열지 못했습니다. 다시 시도하거나 위 내용을 새 이슈에 복사해 주세요.",
|
||||
"searchIssues": "유사한 문제 검색",
|
||||
"unexpected": "예기치 않은 오류: {{message}}"
|
||||
"unexpected": "예기치 않은 오류: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice를 종료하는 중입니다. 앱을 다시 열고 시도하세요."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "키보드 단축키",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "업데이트를 다운로드하고 새 버전으로 다시 시작합니다",
|
||||
"downloading": "업데이트 중… {{pct}}%",
|
||||
"restart": "다시 시작하여 업데이트",
|
||||
"busy": "먼저 더빙을 완료한 후 업데이트를 설치하세요.",
|
||||
"busy": "진행 중인 작업이 끝난 후 업데이트를 설치하세요.",
|
||||
"whats_new": "새로운 소식",
|
||||
"failed": "업데이트 실패",
|
||||
"retry": "재시도",
|
||||
"dismiss": "닫기"
|
||||
"dismiss": "닫기",
|
||||
"toast_available": "OmniVoice Studio {{version}}을(를) 사용할 수 있습니다",
|
||||
"toast_install": "설치 후 재시작",
|
||||
"toast_whats_new": "새로운 기능",
|
||||
"toast_later": "나중에"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "추천",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "앱이 보고한 내용",
|
||||
"retry_hint": "설정 → 로그 → 백엔드에서 \"다시 시도\"를 눌러 보세요. 다시 실패하면 \"정리 후 다시 시도\"가 환경을 처음부터 다시 구성합니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Deze bug melden",
|
||||
"report_failed": "Kon het bugrapport niet openen. Probeer het opnieuw of kopieer de details hierboven naar een nieuwe issue.",
|
||||
"searchIssues": "Vergelijkbare problemen zoeken",
|
||||
"unexpected": "Onverwachte fout: {{message}}"
|
||||
"unexpected": "Onverwachte fout: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice wordt afgesloten. Open de app opnieuw en probeer het nog eens."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Sneltoetsen",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Download de update en start opnieuw op in de nieuwe versie",
|
||||
"downloading": "Bijwerken… {{pct}}%",
|
||||
"restart": "Opnieuw opstarten om bij te werken",
|
||||
"busy": "Voltooi eerst je nasynchronisatie — installeer daarna de update.",
|
||||
"busy": "Wacht tot het lopende werk klaar is — installeer daarna de update.",
|
||||
"whats_new": "Wat is er nieuw",
|
||||
"failed": "Update mislukt",
|
||||
"retry": "Opnieuw proberen",
|
||||
"dismiss": "Negeren"
|
||||
"dismiss": "Negeren",
|
||||
"toast_available": "OmniVoice Studio {{version}} is beschikbaar",
|
||||
"toast_install": "Installeren en herstarten",
|
||||
"toast_whats_new": "Wat is er nieuw",
|
||||
"toast_later": "Later"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Uitgelicht",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Wat de app meldde",
|
||||
"retry_hint": "Probeer \"Opnieuw\" in Instellingen → Logboeken → Backend. Mislukt het weer, dan bouwt \"Opschonen en opnieuw\" de omgeving helemaal opnieuw op."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Zgłoś ten błąd",
|
||||
"report_failed": "Nie udało się otworzyć zgłoszenia błędu. Spróbuj ponownie lub skopiuj powyższe szczegóły do nowego zgłoszenia.",
|
||||
"searchIssues": "Szukaj podobnych problemów",
|
||||
"unexpected": "Nieoczekiwany błąd: {{message}}"
|
||||
"unexpected": "Nieoczekiwany błąd: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice się zamyka. Otwórz aplikację ponownie i spróbuj jeszcze raz."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Skróty klawiaturowe",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Pobierz aktualizację i uruchom ponownie w nowej wersji",
|
||||
"downloading": "Aktualizowanie… {{pct}}%",
|
||||
"restart": "Uruchom ponownie, aby zaktualizować",
|
||||
"busy": "Najpierw dokończ dubbing — potem zainstaluj aktualizację.",
|
||||
"busy": "Poczekaj na zakończenie trwającej pracy — potem zainstaluj aktualizację.",
|
||||
"whats_new": "Co nowego?",
|
||||
"failed": "Aktualizacja nie powiodła się",
|
||||
"retry": "Spróbuj ponownie",
|
||||
"dismiss": "Odrzuć"
|
||||
"dismiss": "Odrzuć",
|
||||
"toast_available": "OmniVoice Studio {{version}} jest dostępna",
|
||||
"toast_install": "Zainstaluj i uruchom ponownie",
|
||||
"toast_whats_new": "Co nowego",
|
||||
"toast_later": "Później"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Polecane",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Co zgłosiła aplikacja",
|
||||
"retry_hint": "Spróbuj „Ponów” w Ustawienia → Dzienniki → Backend. Jeśli znów się nie uda, „Wyczyść i ponów” odbuduje środowisko od zera."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Relatar este bug",
|
||||
"report_failed": "Não foi possível abrir o relatório de bug. Tente novamente ou copie os detalhes acima para uma nova issue.",
|
||||
"searchIssues": "Pesquisar problemas semelhantes",
|
||||
"unexpected": "Erro inesperado: {{message}}"
|
||||
"unexpected": "Erro inesperado: {{message}}",
|
||||
"backend_shutting_down": "O OmniVoice está sendo encerrado. Reabra o aplicativo e tente novamente."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Atalhos de teclado",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Baixe a atualização e reinicie na nova versão",
|
||||
"downloading": "Atualizando… {{pct}}%",
|
||||
"restart": "Reiniciar para atualizar",
|
||||
"busy": "Termine sua dublagem primeiro — depois instale a atualização.",
|
||||
"busy": "Aguarde o trabalho em andamento terminar — depois instale a atualização.",
|
||||
"whats_new": "O que há de novo",
|
||||
"failed": "Falha na atualização",
|
||||
"retry": "Tentar novamente",
|
||||
"dismiss": "Dispensar"
|
||||
"dismiss": "Dispensar",
|
||||
"toast_available": "OmniVoice Studio {{version}} está disponível",
|
||||
"toast_install": "Instalar e reiniciar",
|
||||
"toast_whats_new": "Novidades",
|
||||
"toast_later": "Mais tarde"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Destaque",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "O que o app relatou",
|
||||
"retry_hint": "Tente \"Tentar novamente\" em Configurações → Logs → Backend. Se falhar de novo, \"Limpar e tentar novamente\" reconstrói o ambiente do zero."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,8 +308,8 @@
|
||||
"frontend": "Внешний интерфейс",
|
||||
"tauri": "Тавр",
|
||||
"cancelOp": "Отменить операцию",
|
||||
"dismiss": "Увольнять",
|
||||
"dismissStatus": "Отклонить статус",
|
||||
"dismiss": "Закрыть",
|
||||
"dismissStatus": "Закрыть статус",
|
||||
"search": "Поиск…",
|
||||
"no_matches": "Нет совпадений",
|
||||
"recent_and_popular": "Последние и популярные",
|
||||
@@ -1276,7 +1276,7 @@
|
||||
"later": "Может быть, позже",
|
||||
"star": "Звезда на GitHub",
|
||||
"opt_out": "Не спрашивай больше",
|
||||
"dismiss_aria": "Увольнять"
|
||||
"dismiss_aria": "Закрыть"
|
||||
}
|
||||
},
|
||||
"enterprise": {
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Сообщить об этой ошибке",
|
||||
"report_failed": "Не удалось открыть отчёт об ошибке. Попробуйте ещё раз или скопируйте детали выше в новое обращение.",
|
||||
"searchIssues": "Искать похожие проблемы",
|
||||
"unexpected": "Непредвиденная ошибка: {{message}}"
|
||||
"unexpected": "Непредвиденная ошибка: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice завершает работу. Откройте приложение заново и повторите попытку."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Сочетания клавиш",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Загрузить обновление и перезапустить в новой версии",
|
||||
"downloading": "Обновление… {{pct}}%",
|
||||
"restart": "Перезапустить для обновления",
|
||||
"busy": "Сначала завершите дубляж — затем установите обновление.",
|
||||
"busy": "Дождитесь завершения текущей задачи — затем установите обновление.",
|
||||
"whats_new": "Что нового",
|
||||
"failed": "Обновление не удалось",
|
||||
"retry": "Повторить попытку",
|
||||
"dismiss": "Увольнять"
|
||||
"dismiss": "Закрыть",
|
||||
"toast_available": "OmniVoice Studio {{version}} доступна",
|
||||
"toast_install": "Установить и перезапустить",
|
||||
"toast_whats_new": "Что нового",
|
||||
"toast_later": "Позже"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Рекомендуемые",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Что сообщило приложение",
|
||||
"retry_hint": "Попробуйте «Повторить» в Настройки → Журналы → Бэкенд. Если снова не выйдет, «Очистить и повторить» пересоберёт окружение с нуля."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Rapportera den här buggen",
|
||||
"report_failed": "Det gick inte att öppna felrapporten. Försök igen, eller kopiera detaljerna ovan till ett nytt ärende.",
|
||||
"searchIssues": "Sök liknande problem",
|
||||
"unexpected": "Oväntat fel: {{message}}"
|
||||
"unexpected": "Oväntat fel: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice stängs av. Öppna appen igen och försök på nytt."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Kortkommandon",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Ladda ner uppdateringen och starta om i den nya versionen",
|
||||
"downloading": "Uppdaterar… {{pct}} %",
|
||||
"restart": "Starta om för att uppdatera",
|
||||
"busy": "Slutför din dubbning först – installera sedan uppdateringen.",
|
||||
"busy": "Vänta tills det pågående arbetet är klart – installera sedan uppdateringen.",
|
||||
"whats_new": "Vad är nytt",
|
||||
"failed": "Uppdateringen misslyckades",
|
||||
"retry": "Försök igen",
|
||||
"dismiss": "Avvisa"
|
||||
"dismiss": "Avvisa",
|
||||
"toast_available": "OmniVoice Studio {{version}} är tillgänglig",
|
||||
"toast_install": "Installera och starta om",
|
||||
"toast_whats_new": "Nyheter",
|
||||
"toast_later": "Senare"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Utvalda",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Vad appen rapporterade",
|
||||
"retry_hint": "Prova \"Försök igen\" under Inställningar → Loggar → Backend. Misslyckas det igen bygger \"Rensa och försök igen\" om miljön från grunden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "รายงานข้อบกพร่องนี้",
|
||||
"report_failed": "เปิดรายงานข้อบกพร่องไม่ได้ โปรดลองอีกครั้ง หรือคัดลอกรายละเอียดด้านบนไปยังรายงานใหม่",
|
||||
"searchIssues": "ค้นหาปัญหาที่คล้ายกัน",
|
||||
"unexpected": "ข้อผิดพลาดที่ไม่คาดคิด: {{message}}"
|
||||
"unexpected": "ข้อผิดพลาดที่ไม่คาดคิด: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice กำลังปิดอยู่ เปิดแอปอีกครั้งแล้วลองใหม่"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "แป้นพิมพ์ลัด",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "ดาวน์โหลดอัปเดตและรีสตาร์ทเป็นเวอร์ชันใหม่",
|
||||
"downloading": "กำลังอัปเดต… {{pct}}%",
|
||||
"restart": "รีสตาร์ทเพื่ออัปเดต",
|
||||
"busy": "ทำการพากย์เสียงของคุณให้เสร็จก่อน แล้วจึงติดตั้งอัปเดต",
|
||||
"busy": "รอให้งานที่กำลังทำอยู่เสร็จก่อน แล้วจึงติดตั้งอัปเดต",
|
||||
"whats_new": "มีอะไรใหม่",
|
||||
"failed": "การอัปเดตล้มเหลว",
|
||||
"retry": "ลองอีกครั้ง",
|
||||
"dismiss": "ยกเลิก"
|
||||
"dismiss": "ยกเลิก",
|
||||
"toast_available": "OmniVoice Studio {{version}} พร้อมใช้งาน",
|
||||
"toast_install": "ติดตั้งและรีสตาร์ท",
|
||||
"toast_whats_new": "มีอะไรใหม่",
|
||||
"toast_later": "ภายหลัง"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "จุดเด่น",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "สิ่งที่แอปรายงาน",
|
||||
"retry_hint": "ลอง \"ลองใหม่\" ใน การตั้งค่า → บันทึก → แบ็กเอนด์ หากยังล้มเหลว \"ล้างและลองใหม่\" จะสร้างสภาพแวดล้อมขึ้นใหม่ทั้งหมด"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Bu hatayı bildir",
|
||||
"report_failed": "Hata raporu açılamadı. Tekrar deneyin veya yukarıdaki ayrıntıları yeni bir konuya kopyalayın.",
|
||||
"searchIssues": "Benzer sorunları ara",
|
||||
"unexpected": "Beklenmeyen hata: {{message}}"
|
||||
"unexpected": "Beklenmeyen hata: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice kapanıyor. Uygulamayı yeniden açıp tekrar dene."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Klavye kısayolları",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Güncellemeyi indir ve yeni sürümle yeniden başlat",
|
||||
"downloading": "Güncelleniyor… %{{pct}}",
|
||||
"restart": "Güncellemek için yeniden başlat",
|
||||
"busy": "Önce dublajını bitir — sonra güncellemeyi yükle.",
|
||||
"busy": "Devam eden işlem bitene kadar bekle — sonra güncellemeyi yükle.",
|
||||
"whats_new": "Yenilikler",
|
||||
"failed": "Güncelleme başarısız oldu",
|
||||
"retry": "Yeniden dene",
|
||||
"dismiss": "Reddet"
|
||||
"dismiss": "Reddet",
|
||||
"toast_available": "OmniVoice Studio {{version}} kullanılabilir",
|
||||
"toast_install": "Yükle ve yeniden başlat",
|
||||
"toast_whats_new": "Yenilikler",
|
||||
"toast_later": "Daha sonra"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Öne Çıkanlar",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Uygulamanın bildirdiği",
|
||||
"retry_hint": "Ayarlar → Günlükler → Arka uç bölümünden \"Yeniden dene\"yi deneyin. Yine başarısız olursa \"Temizle ve yeniden dene\" ortamı sıfırdan kurar."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Повідомити про цю помилку",
|
||||
"report_failed": "Не вдалося відкрити звіт про помилку. Спробуйте ще раз або скопіюйте деталі вище в нове звернення.",
|
||||
"searchIssues": "Шукати схожі проблеми",
|
||||
"unexpected": "Неочікувана помилка: {{message}}"
|
||||
"unexpected": "Неочікувана помилка: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice завершує роботу. Відкрийте застосунок знову та спробуйте ще раз."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Комбінації клавіш",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Завантажити оновлення та перезапустити в новій версії",
|
||||
"downloading": "Оновлення… {{pct}}%",
|
||||
"restart": "Перезапустити для оновлення",
|
||||
"busy": "Спочатку завершіть дубляж — потім встановіть оновлення.",
|
||||
"busy": "Зачекайте, доки завершиться поточна робота — потім встановіть оновлення.",
|
||||
"whats_new": "Що нового",
|
||||
"failed": "Не вдалося оновити",
|
||||
"retry": "Повторіть спробу",
|
||||
"dismiss": "Відхилити"
|
||||
"dismiss": "Відхилити",
|
||||
"toast_available": "OmniVoice Studio {{version}} доступна",
|
||||
"toast_install": "Встановити та перезапустити",
|
||||
"toast_whats_new": "Що нового",
|
||||
"toast_later": "Пізніше"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Рекомендовані",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Що повідомив застосунок",
|
||||
"retry_hint": "Спробуйте «Повторити» в Налаштування → Журнали → Бекенд. Якщо знову не вдасться, «Очистити й повторити» перебудує середовище з нуля."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "Báo cáo lỗi này",
|
||||
"report_failed": "Không mở được báo cáo lỗi. Vui lòng thử lại, hoặc sao chép chi tiết ở trên vào một issue mới.",
|
||||
"searchIssues": "Tìm các vấn đề tương tự",
|
||||
"unexpected": "Lỗi không mong muốn: {{message}}"
|
||||
"unexpected": "Lỗi không mong muốn: {{message}}",
|
||||
"backend_shutting_down": "OmniVoice đang tắt. Mở lại ứng dụng rồi thử lại."
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "Phím tắt",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "Tải bản cập nhật và khởi động lại vào phiên bản mới",
|
||||
"downloading": "Đang cập nhật… {{pct}}%",
|
||||
"restart": "Khởi động lại để cập nhật",
|
||||
"busy": "Hoàn tất lồng tiếng của bạn trước — rồi cài đặt bản cập nhật.",
|
||||
"busy": "Chờ công việc đang chạy hoàn tất — rồi cài đặt bản cập nhật.",
|
||||
"whats_new": "Có gì mới",
|
||||
"failed": "Cập nhật không thành công",
|
||||
"retry": "Thử lại",
|
||||
"dismiss": "Loại bỏ"
|
||||
"dismiss": "Loại bỏ",
|
||||
"toast_available": "OmniVoice Studio {{version}} đã có",
|
||||
"toast_install": "Cài đặt và khởi động lại",
|
||||
"toast_whats_new": "Có gì mới",
|
||||
"toast_later": "Để sau"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "Nổi bật",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "Nội dung ứng dụng báo về",
|
||||
"retry_hint": "Hãy thử \"Thử lại\" trong Cài đặt → Nhật ký → Backend. Nếu vẫn lỗi, \"Dọn dẹp & Thử lại\" sẽ dựng lại môi trường từ đầu."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,8 @@
|
||||
"frontend": "前端",
|
||||
"tauri": "Tauri",
|
||||
"cancelOp": "取消操作",
|
||||
"dismiss": "解雇",
|
||||
"dismissStatus": "解除状态",
|
||||
"dismiss": "关闭",
|
||||
"dismissStatus": "关闭状态",
|
||||
"search": "搜索...",
|
||||
"no_matches": "没有匹配项",
|
||||
"recent_and_popular": "最近和热门",
|
||||
@@ -1239,7 +1239,7 @@
|
||||
"later": "也许稍后",
|
||||
"star": "在 GitHub 上加星标",
|
||||
"opt_out": "不要再问",
|
||||
"dismiss_aria": "解雇"
|
||||
"dismiss_aria": "关闭"
|
||||
}
|
||||
},
|
||||
"enterprise": {
|
||||
@@ -1444,7 +1444,8 @@
|
||||
"report": "报告此错误",
|
||||
"report_failed": "无法打开错误报告。请重试,或将上面的详细信息复制到新的 issue 中。",
|
||||
"searchIssues": "搜索类似问题",
|
||||
"unexpected": "意外错误:{{message}}"
|
||||
"unexpected": "意外错误:{{message}}",
|
||||
"backend_shutting_down": "OmniVoice 正在关闭。请重新打开应用后再试。"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "键盘快捷键",
|
||||
@@ -1898,11 +1899,15 @@
|
||||
"install_hint": "下载更新并重启到新版本",
|
||||
"downloading": "更新中… {{pct}}%",
|
||||
"restart": "重启以更新",
|
||||
"busy": "请先完成配音,再安装更新。",
|
||||
"busy": "请等待正在进行的任务完成,再安装更新。",
|
||||
"whats_new": "更新内容",
|
||||
"failed": "更新失败",
|
||||
"retry": "重试",
|
||||
"dismiss": "解雇"
|
||||
"dismiss": "关闭",
|
||||
"toast_available": "OmniVoice Studio {{version}} 可用",
|
||||
"toast_install": "安装并重启",
|
||||
"toast_whats_new": "新增内容",
|
||||
"toast_later": "稍后"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "精选",
|
||||
@@ -2286,4 +2291,4 @@
|
||||
"output_title": "应用报告的内容",
|
||||
"retry_hint": "请在 设置 → 日志 → 后端 中尝试“重试”。若再次失败,“清理并重试”会从头重建运行环境。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,8 +308,8 @@
|
||||
"frontend": "前端",
|
||||
"tauri": "金牛座",
|
||||
"cancelOp": "取消操作",
|
||||
"dismiss": "解僱",
|
||||
"dismissStatus": "解除狀態",
|
||||
"dismiss": "關閉",
|
||||
"dismissStatus": "關閉狀態",
|
||||
"search": "搜尋...",
|
||||
"no_matches": "沒有匹配項",
|
||||
"recent_and_popular": "最近和熱門",
|
||||
@@ -1276,7 +1276,7 @@
|
||||
"later": "也許稍後",
|
||||
"star": "在 GitHub 上加星標",
|
||||
"opt_out": "不要再問",
|
||||
"dismiss_aria": "解僱"
|
||||
"dismiss_aria": "關閉"
|
||||
}
|
||||
},
|
||||
"enterprise": {
|
||||
@@ -1438,7 +1438,8 @@
|
||||
"report": "回報此錯誤",
|
||||
"report_failed": "無法開啟錯誤回報。請重試,或將上方的詳細資訊複製到新的 issue 中。",
|
||||
"searchIssues": "搜尋類似問題",
|
||||
"unexpected": "未預期的錯誤:{{message}}"
|
||||
"unexpected": "未預期的錯誤:{{message}}",
|
||||
"backend_shutting_down": "OmniVoice 正在關閉。請重新開啟應用程式後再試。"
|
||||
},
|
||||
"keyboard": {
|
||||
"title": "鍵盤快速鍵",
|
||||
@@ -1891,11 +1892,15 @@
|
||||
"install_hint": "下載更新並重新啟動至新版本",
|
||||
"downloading": "更新中… {{pct}}%",
|
||||
"restart": "重新啟動以更新",
|
||||
"busy": "請先完成配音,再安裝更新。",
|
||||
"busy": "請等待正在進行的工作完成,再安裝更新。",
|
||||
"whats_new": "有什麼新消息",
|
||||
"failed": "更新失敗",
|
||||
"retry": "重試",
|
||||
"dismiss": "解僱"
|
||||
"dismiss": "關閉",
|
||||
"toast_available": "OmniVoice Studio {{version}} 可用",
|
||||
"toast_install": "安裝並重新啟動",
|
||||
"toast_whats_new": "新增內容",
|
||||
"toast_later": "稍後"
|
||||
},
|
||||
"archetypes": {
|
||||
"featured": "精選",
|
||||
@@ -2279,4 +2284,4 @@
|
||||
"output_title": "應用程式回報的內容",
|
||||
"retry_hint": "請在 設定 → 記錄 → 後端 中嘗試「重試」。若再次失敗,「清理並重試」會從頭重建執行環境。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2533,12 +2533,16 @@ input[type="file"]::file-selector-button:hover {
|
||||
}
|
||||
.app-startup__title { font-size: 18px; color: #ebdbb2; }
|
||||
.app-wizard-wrap {
|
||||
/* Fill viewport above the fixed LogsFooter */
|
||||
/* Fills the WHOLE viewport: the first-run wizard deliberately renders no
|
||||
LogsFooter (studio chrome belongs to the studio), so there is nothing to
|
||||
reserve space for. While it did reserve 28px, the wizard's own root was
|
||||
`fixed inset-0` and escaped this box anyway — its pinned Continue / HF
|
||||
token row rendered underneath the status bar and was unreachable. */
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--logs-footer-height, 28px);
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg, #1d2021);
|
||||
display: flex;
|
||||
|
||||
@@ -6,6 +6,12 @@ if (import.meta.env.DEV && !window.__vite_plugin_react_preamble_installed__) {
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
}
|
||||
|
||||
// Web-platform gap fills for the oldest WebView we support (macOS 12 ships
|
||||
// WKWebView 15.6). MUST be first: these are touched during the first React
|
||||
// render, so a missing one throws mid-render and leaves a dead window rather
|
||||
// than a degraded feature (#1245).
|
||||
import './utils/webCompat.js';
|
||||
|
||||
// AudioContext autoplay-policy unlock — MUST install before any module that
|
||||
// constructs an AudioContext (wavesurfer.js, the AEC tap, the dictation
|
||||
// capture, etc.). The side-effecting import patches `window.AudioContext`
|
||||
|
||||
@@ -294,7 +294,12 @@ export default function SetupWizard({ onReady }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col items-center overflow-hidden bg-bg px-6 pt-12 font-sans text-fg">
|
||||
// `absolute`, not `fixed`: this mounts inside `.app-wizard-wrap`, and a
|
||||
// fixed root would ignore that box and lay its pinned footer out against
|
||||
// the viewport — putting Continue and the HF-token card behind the status
|
||||
// bar, off the bottom of the window. `pb-4` keeps the pinned row off the
|
||||
// very edge now that it really is the last thing on screen.
|
||||
<div className="absolute inset-0 flex flex-col items-center overflow-hidden bg-bg px-6 pb-4 pt-12 font-sans text-fg">
|
||||
<div className="flex w-full max-w-[1100px] flex-1 flex-col">
|
||||
{/* ── Masthead: identical identity to setup + install acts ────────── */}
|
||||
<header
|
||||
|
||||
@@ -45,6 +45,22 @@ export interface GenerateSlice {
|
||||
designSeed: number | null;
|
||||
keepSeed: boolean;
|
||||
|
||||
/**
|
||||
* How many synth requests are in flight right now.
|
||||
*
|
||||
* A COUNT, not a flag. Syntheses overlap — the Generate tab, voice previews,
|
||||
* the compare modal, the stories editor and profile previews can all be
|
||||
* running at once, and a boolean would be cleared by whichever finished
|
||||
* first while the others were still going, letting the updater relaunch and
|
||||
* discard them.
|
||||
*
|
||||
* Maintained by `api/generate.ts` around the single `/generate` call every
|
||||
* one of those paths goes through, so a new caller is covered without having
|
||||
* to remember this exists. Read via `utils/appBusy`. Transient: never
|
||||
* persisted, since a synth cannot survive a reload.
|
||||
*/
|
||||
ttsInflight: number;
|
||||
|
||||
setText: (v: string) => void;
|
||||
setRefText: (v: string) => void;
|
||||
setInstruct: (v: string) => void;
|
||||
@@ -65,6 +81,8 @@ export interface GenerateSlice {
|
||||
|
||||
setDesignSeed: (v: number | null) => void;
|
||||
setKeepSeed: (v: boolean) => void;
|
||||
/** +1 on synth start, -1 on settle. Floors at 0; never goes negative. */
|
||||
addTtsInflight: (delta: number) => void;
|
||||
}
|
||||
|
||||
const INITIAL_VD: VDStates = {
|
||||
@@ -97,6 +115,7 @@ export const createGenerateSlice: StateCreator<GenerateSlice, [], [], GenerateSl
|
||||
|
||||
designSeed: null,
|
||||
keepSeed: false,
|
||||
ttsInflight: 0,
|
||||
|
||||
setText: (v) => set({ text: v }),
|
||||
setRefText: (v) => set({ refText: v }),
|
||||
@@ -121,4 +140,5 @@ export const createGenerateSlice: StateCreator<GenerateSlice, [], [], GenerateSl
|
||||
|
||||
setDesignSeed: (v) => set({ designSeed: v }),
|
||||
setKeepSeed: (v) => set({ keepSeed: v }),
|
||||
addTtsInflight: (delta) => set((st) => ({ ttsInflight: Math.max(0, st.ttsInflight + delta) })),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* First-run wizard chrome: the pinned action row must stay on screen, and the
|
||||
* studio's status bar must not appear before the user reaches the studio.
|
||||
*
|
||||
* The bug: `SetupWizard`'s root was `fixed inset-0`, so it laid itself out
|
||||
* against the VIEWPORT rather than `.app-wizard-wrap` — the box App.jsx sizes
|
||||
* to stop above the fixed `LogsFooter`. Its pinned footer (Continue, Back, and
|
||||
* the "set a Hugging Face token" card) therefore rendered underneath the status
|
||||
* bar, clipped off the bottom of the window: on the Models & engines step the
|
||||
* Continue button was simply unreachable.
|
||||
*
|
||||
* Both halves are pinned here — the root must not be `fixed`, and the wizard
|
||||
* branch must render no `LogsFooter` — because either one alone reintroduces
|
||||
* the clip.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import i18n from '../i18n';
|
||||
|
||||
vi.mock('../components/WizardLibrary', () => ({ default: () => null }));
|
||||
vi.mock('../components/MediaEngineCard', () => ({ default: () => null }));
|
||||
vi.mock('../components/MirrorRescue', () => ({ default: () => null }));
|
||||
vi.mock('../components/DictationDemo', () => ({ default: () => null }));
|
||||
vi.mock('../components/HfTokenCard', () => ({
|
||||
default: ({ className }) => <div data-testid="hf-token-card" className={className} />,
|
||||
}));
|
||||
vi.mock('../api/external', () => ({ openExternal: vi.fn(() => Promise.resolve()) }));
|
||||
|
||||
vi.mock('../api/hooks', () => ({
|
||||
useSetupStatus: () => ({
|
||||
data: { models_ready: true, missing: [], hf_cache_dir: '/tmp/hf' },
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
usePreflight: () => ({
|
||||
data: { ok: true, has_warnings: false, checks: [] },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
apiJson: vi.fn(() => Promise.resolve({ available: false, prompted: true })),
|
||||
apiFetch: vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
|
||||
API: '',
|
||||
}));
|
||||
|
||||
import SetupWizard from '../pages/SetupWizard';
|
||||
|
||||
const withI18n = (node) => <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
|
||||
|
||||
const readSrc = (rel) => fs.readFileSync(path.resolve(__dirname, '..', rel), 'utf8');
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('SetupWizard — the pinned action row stays on screen', () => {
|
||||
it('does not position its root against the viewport', () => {
|
||||
const { container } = render(withI18n(<SetupWizard onReady={() => {}} />));
|
||||
const root = container.firstElementChild;
|
||||
|
||||
// `fixed` ignores .app-wizard-wrap's box; `absolute` fills it.
|
||||
expect(root.className).not.toMatch(/\bfixed\b/);
|
||||
expect(root.className).toMatch(/\babsolute\b/);
|
||||
// ...and the pinned row needs clearance now that it really is the last
|
||||
// thing on screen — flush against the window edge is the same bug's
|
||||
// cosmetic tail.
|
||||
expect(root.className).toMatch(/\bpb-\d/);
|
||||
});
|
||||
|
||||
it('keeps Continue and the HF-token card OUT of the scrolling region', async () => {
|
||||
render(withI18n(<SetupWizard onReady={() => {}} />));
|
||||
fireEvent.click(await screen.findByText(/All good — continue/i));
|
||||
|
||||
// Models step: the token card and the action row are siblings of the
|
||||
// scroller, not children of it — otherwise they scroll away instead of
|
||||
// staying pinned.
|
||||
const card = await screen.findByTestId('hf-token-card');
|
||||
expect(card.className).toMatch(/shrink-0/);
|
||||
|
||||
const cta = await screen.findByText(/Required models ready/i);
|
||||
const row = cta.closest('div');
|
||||
expect(row.className).toMatch(/shrink-0/);
|
||||
|
||||
let el = card;
|
||||
while (el) {
|
||||
expect(el.className || '').not.toMatch(/overflow-y-auto/);
|
||||
el = el.parentElement;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('studio chrome does not appear before the studio', () => {
|
||||
it('renders no LogsFooter in the first-run wizard or the pre-wizard splash', () => {
|
||||
const app = readSrc('App.jsx');
|
||||
|
||||
// Split App.jsx at the last pre-studio early return. Everything above is a
|
||||
// screen the user sees BEFORE the studio (awaiting_setup splash,
|
||||
// !setupChecked splash, the wizard); everything below is the studio.
|
||||
const split = app.indexOf('// Block the main UI until Rust reports the backend is ready');
|
||||
expect(split).toBeGreaterThan(0);
|
||||
const preStudio = app.slice(app.indexOf("if (bootstrapStage === 'awaiting_setup')"), split);
|
||||
const studio = app.slice(split);
|
||||
|
||||
// Asserted per-branch rather than as a global count: a bare count of 1
|
||||
// would still pass if the mount MOVED from the studio into the splash.
|
||||
expect(preStudio).not.toContain('LogsFooter');
|
||||
expect(studio.match(/<LogsFooter\s*\/>/g) || []).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('gives the wizard the whole viewport, since nothing is reserved below it', () => {
|
||||
const css = readSrc('index.css');
|
||||
const rule = css.slice(css.indexOf('.app-wizard-wrap {'));
|
||||
const body = rule.slice(0, rule.indexOf('}'));
|
||||
// A leftover `bottom: var(--logs-footer-height)` would strand a dead 28px
|
||||
// gap under the wizard now that no footer is rendered there.
|
||||
expect(body).not.toContain('--logs-footer-height');
|
||||
expect(body).toMatch(/bottom:\s*0;/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* The update announcement must be a toast with actions — not a wall of text,
|
||||
* and not a 6-pixel dot.
|
||||
*
|
||||
* Release notes for a version are the whole changelog section: v0.4.1's was 42
|
||||
* bullets. Older builds put that in a blocking OS dialog that filled the screen
|
||||
* and had to be dismissed before the app could be used at all. Removing the
|
||||
* dialog left the opposite failure — the only remaining signal was a dot beside
|
||||
* the version number in the footer, which is easy to never notice.
|
||||
*
|
||||
* What is pinned here: the toast says which version, offers install and a route
|
||||
* to the notes, never renders the notes inline, and cannot stack duplicates
|
||||
* when the 6-hourly re-check fires again.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
|
||||
// vi.hoisted: vi.mock factories are lifted above every import, so anything
|
||||
// they close over has to be hoisted with them rather than declared below.
|
||||
const { toastCalls, dismissed, installUpdate, openSettingsTab, storeState } = vi.hoisted(() => ({
|
||||
toastCalls: [],
|
||||
dismissed: [],
|
||||
installUpdate: vi.fn(),
|
||||
openSettingsTab: vi.fn(),
|
||||
storeState: { dubStep: 'idle', stage: 'idle', ttsInflight: 0 },
|
||||
}));
|
||||
|
||||
vi.mock('react-hot-toast', () => {
|
||||
const toast = vi.fn();
|
||||
toast.custom = vi.fn((render, opts) => {
|
||||
toastCalls.push({ render, opts });
|
||||
return opts?.id;
|
||||
});
|
||||
toast.dismiss = vi.fn((id) => dismissed.push(id));
|
||||
return { default: toast, toast };
|
||||
});
|
||||
|
||||
vi.mock('../utils/updater', () => ({ installUpdate: (...a) => installUpdate(...a) }));
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useAppStore: Object.assign(() => undefined, {
|
||||
getState: () => ({ openSettingsTab, ...storeState }),
|
||||
}),
|
||||
}));
|
||||
|
||||
import UpdateToastBody, { showUpdateToast } from '../components/UpdateToast';
|
||||
|
||||
const withI18n = (node) => <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
toastCalls.length = 0;
|
||||
dismissed.length = 0;
|
||||
installUpdate.mockClear();
|
||||
openSettingsTab.mockClear();
|
||||
Object.assign(storeState, { dubStep: 'idle', stage: 'idle', ttsInflight: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('showUpdateToast', () => {
|
||||
it('announces the version', async () => {
|
||||
showUpdateToast('0.4.2');
|
||||
expect(toastCalls).toHaveLength(1);
|
||||
|
||||
render(withI18n(toastCalls[0].render({ id: 'x' })));
|
||||
expect(await screen.findByText(/0\.4\.2 is available/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses one id per version, so a re-check cannot stack duplicates', () => {
|
||||
showUpdateToast('0.4.2');
|
||||
showUpdateToast('0.4.2');
|
||||
// Same id both times — react-hot-toast replaces rather than appends.
|
||||
expect(toastCalls.map((c) => c.opts.id)).toEqual([
|
||||
'update-available-0.4.2',
|
||||
'update-available-0.4.2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never auto-dismisses — an unanswered update is still true', () => {
|
||||
showUpdateToast('0.4.2');
|
||||
expect(toastCalls[0].opts.duration).toBe(Infinity);
|
||||
});
|
||||
|
||||
it('does nothing without a version', () => {
|
||||
showUpdateToast(null);
|
||||
expect(toastCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the toast body', () => {
|
||||
it('offers install, notes and dismiss', async () => {
|
||||
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
|
||||
expect(await screen.findByText(/Install and restart/i)).toBeTruthy();
|
||||
expect(screen.getByText(/What's new/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Later/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('routes to the notes instead of rendering them', async () => {
|
||||
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
|
||||
fireEvent.click(await screen.findByText(/What's new/i));
|
||||
|
||||
expect(openSettingsTab).toHaveBeenCalledWith('updates');
|
||||
expect(dismissed).toContain('t1');
|
||||
});
|
||||
|
||||
it('installs on request', async () => {
|
||||
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
|
||||
fireEvent.click(await screen.findByText(/Install and restart/i));
|
||||
|
||||
expect(installUpdate).toHaveBeenCalled();
|
||||
expect(dismissed).toContain('t1');
|
||||
});
|
||||
|
||||
// Installing relaunches the process. The guard used to be
|
||||
// `dubStep === 'generating'` alone, which let the relaunch through — and
|
||||
// silently discarded the work — during every other long operation.
|
||||
it.each([
|
||||
['a dub upload', { dubStep: 'uploading' }],
|
||||
['a dub transcription', { dubStep: 'transcribing' }],
|
||||
['a dub synth', { dubStep: 'generating' }],
|
||||
['a dub being stopped', { dubStep: 'stopping' }],
|
||||
['a translation', { stage: 'translating' }],
|
||||
['an ASR model load', { stage: 'loading-model' }],
|
||||
['a dictation capture', { stage: 'recording' }],
|
||||
['an export', { stage: 'exporting' }],
|
||||
['a standalone TTS synth', { ttsInflight: 1 }],
|
||||
['a voice preview overlapping another synth', { ttsInflight: 2 }],
|
||||
])('refuses to restart during %s', async (_label, state) => {
|
||||
Object.assign(storeState, state);
|
||||
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
|
||||
fireEvent.click(await screen.findByText(/Install and restart/i));
|
||||
|
||||
expect(installUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The mirror image: a guard that never lets go is just a broken updater.
|
||||
it.each([
|
||||
['nothing is running', { dubStep: 'idle', stage: 'idle', ttsInflight: 0 }],
|
||||
['the transcript is open for editing', { dubStep: 'editing' }],
|
||||
['a dub has finished', { dubStep: 'done' }],
|
||||
])('still installs when %s', async (_label, state) => {
|
||||
Object.assign(storeState, state);
|
||||
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
|
||||
fireEvent.click(await screen.findByText(/Install and restart/i));
|
||||
|
||||
expect(installUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Later removes it without installing', async () => {
|
||||
render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
|
||||
fireEvent.click(await screen.findByText(/Later/i));
|
||||
|
||||
expect(dismissed).toContain('t1');
|
||||
expect(installUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders no release notes inline, however long they are', () => {
|
||||
const { container } = render(withI18n(<UpdateToastBody id="t1" version="0.4.2" />));
|
||||
// The 42-bullet changelog is what made the old dialog unusable. The toast
|
||||
// takes no notes prop at all — this asserts the shape stays that way.
|
||||
expect(container.textContent).not.toMatch(/Highlights|### |^- /m);
|
||||
expect(container.textContent.length).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* `isAppBusy` decides whether it is safe to throw away the running process.
|
||||
*
|
||||
* The bug it replaces: the updater asked `dubStep === 'generating'` and nothing
|
||||
* else, so installing an update relaunched the app — discarding the work —
|
||||
* during a dub upload, a transcription, a translation, an export, or a
|
||||
* standalone synth. Two copies of that check existed and could drift.
|
||||
*
|
||||
* These cases are the contract. Adding a long-running operation to the app
|
||||
* means adding it here too.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAppBusy } from '../utils/appBusy';
|
||||
|
||||
const idle = { dubStep: 'idle', stage: 'idle', ttsInflight: 0 };
|
||||
|
||||
describe('isAppBusy', () => {
|
||||
it.each([
|
||||
['dub upload', { dubStep: 'uploading' }],
|
||||
['dub transcription', { dubStep: 'transcribing' }],
|
||||
['dub synth', { dubStep: 'generating' }],
|
||||
['dub being stopped', { dubStep: 'stopping' }],
|
||||
['ASR model load', { stage: 'loading-model' }],
|
||||
['dictation capture', { stage: 'recording' }],
|
||||
['transcription', { stage: 'transcribing' }],
|
||||
['translation', { stage: 'translating' }],
|
||||
['pill-tracked synth', { stage: 'generating' }],
|
||||
['export encode', { stage: 'exporting' }],
|
||||
['refine pass', { stage: 'refining' }],
|
||||
['standalone TTS synth', { ttsInflight: 1 }],
|
||||
['overlapping synths', { ttsInflight: 3 }],
|
||||
])('is busy during %s', (_label, state) => {
|
||||
expect(isAppBusy({ ...idle, ...state })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['nothing running', {}],
|
||||
// Waiting on the user is not an operation. Counting it would block updates
|
||||
// for as long as a transcript tab stays open.
|
||||
['an open transcript', { dubStep: 'editing' }],
|
||||
['a finished dub', { dubStep: 'done' }],
|
||||
['a finished pill', { stage: 'done' }],
|
||||
['a failed pill', { stage: 'error' }],
|
||||
])('is not busy with %s', (_label, state) => {
|
||||
expect(isAppBusy({ ...idle, ...state })).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a missing state as not busy rather than throwing', () => {
|
||||
expect(isAppBusy(undefined)).toBe(false);
|
||||
expect(isAppBusy(null)).toBe(false);
|
||||
expect(isAppBusy({})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -61,3 +61,67 @@ describe('toastErrorWithReport user-fixable marker mapping (#1188)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #1276: a shutdown is not a fault and must not offer a bug report. Matched on
|
||||
// the [shutting_down] MARKER, never on the bare 503 status — 503 is also how a
|
||||
// real engine-load timeout and an unavailable engine are reported (#1246,
|
||||
// #1260, #1277), and those are genuine bugs users need to be able to file.
|
||||
describe('toastErrorWithReport shutdown handling (#1276)', () => {
|
||||
beforeEach(() => {
|
||||
toastErrorMock.mockClear();
|
||||
});
|
||||
|
||||
const withStatus = (message, status) => {
|
||||
const e = new Error(message);
|
||||
e.name = 'ApiError';
|
||||
e.status = status;
|
||||
return e;
|
||||
};
|
||||
|
||||
const SHUTDOWN =
|
||||
"[shutting_down] OmniVoice is shutting down, so it didn't start loading " +
|
||||
'the model. Reopen the app and try again.';
|
||||
|
||||
it('shows localized guidance for the shutdown marker, with no Report action', () => {
|
||||
const err = withStatus(SHUTDOWN, 503);
|
||||
toastErrorWithReport(err.message, err);
|
||||
|
||||
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('t:errors.backend_shutting_down', {
|
||||
duration: 8000,
|
||||
});
|
||||
});
|
||||
|
||||
// The regression this exists to prevent: an earlier version of the fix keyed
|
||||
// off the 503 status alone, which would have removed the Report button from
|
||||
// the compute-timeout class — the exact bug #1277 was filed for.
|
||||
it('still offers Report for a 503 that is a real failure', () => {
|
||||
const err = withStatus(
|
||||
'503 Service Unavailable: TTS generate ran for more than 300s of actual ' +
|
||||
'compute time and was abandoned',
|
||||
503,
|
||||
);
|
||||
toastErrorWithReport(err.message, err);
|
||||
|
||||
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
||||
// A render function, not a string — the reportable path.
|
||||
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
|
||||
});
|
||||
|
||||
it('still offers Report for an engine-unavailable 503', () => {
|
||||
const err = withStatus("503 Service Unavailable: TTS engine 'xtts' is unavailable", 503);
|
||||
toastErrorWithReport(err.message, err);
|
||||
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
|
||||
});
|
||||
|
||||
it('still offers Report for a genuine 500', () => {
|
||||
const err = withStatus('500 Internal Server Error: something actually broke', 500);
|
||||
toastErrorWithReport(err.message, err);
|
||||
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
|
||||
});
|
||||
|
||||
it('still offers Report when there is no status at all', () => {
|
||||
toastErrorWithReport('Something broke', new Error('Something broke'));
|
||||
expect(typeof toastErrorMock.mock.calls[0][0]).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* #1223 — "Backend died (exit code 1)" when port 3900 was already taken.
|
||||
*
|
||||
* The backend log said:
|
||||
* ERROR: [Errno 10048] error while attempting to bind on address
|
||||
* ('127.0.0.1', 3900): обычно разрешается только одно использование адреса…
|
||||
*
|
||||
* Two reasons the user got nothing useful:
|
||||
*
|
||||
* - `detectHints` only matched /port.*in use|address.*in use/. Windows'
|
||||
* WSAEADDRINUSE wording ("only one usage of each socket address is normally
|
||||
* permitted") contains NEITHER phrase — and Windows translates it into the
|
||||
* user's locale, so no English phrase can be relied on at all. The correct
|
||||
* hint string existed in en.json and was simply unreachable on Windows.
|
||||
* - `crashCauseHint` had no branch for it, so a port conflict was described
|
||||
* with the small-GPU VRAM guidance.
|
||||
*
|
||||
* Both are pinned here on the locale-independent signals: the errno and the
|
||||
* backend's dedicated exit code.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectHints } from '../components/BootstrapSplash';
|
||||
import { crashCauseHint } from '../utils/backendCrash';
|
||||
|
||||
const line = (s) => [{ line: s }];
|
||||
|
||||
describe('detectHints — port-in-use (#1223)', () => {
|
||||
it('matches the Windows errno even when the message is localised', () => {
|
||||
// The reporter's actual log line, Russian text and all.
|
||||
const russian =
|
||||
"ERROR: [Errno 10048] error while attempting to bind on address ('127.0.0.1', 3900): " +
|
||||
'обычно разрешается только одно использование адреса сокета';
|
||||
expect(detectHints('', line(russian))).toContain('bootstrap.hint_port');
|
||||
});
|
||||
|
||||
it('matches the English Windows wording', () => {
|
||||
const english =
|
||||
"[Errno 10048] error while attempting to bind on address ('127.0.0.1', 3900): " +
|
||||
'only one usage of each socket address (protocol/network address/port) is normally permitted';
|
||||
expect(detectHints('', line(english))).toContain('bootstrap.hint_port');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['macOS/BSD', "[Errno 48] error while attempting to bind on address ('127.0.0.1', 3900)"],
|
||||
['Linux', "[Errno 98] error while attempting to bind on address ('127.0.0.1', 3900)"],
|
||||
])('matches the %s errno', (_os, msg) => {
|
||||
expect(detectHints('', line(msg))).toContain('bootstrap.hint_port');
|
||||
});
|
||||
|
||||
it("matches the backend's dedicated exit code with no log at all", () => {
|
||||
// The crash path where stderr was never captured — the exit code is the
|
||||
// only signal left.
|
||||
expect(detectHints('Backend process exited (exit code 78)')).toContain('bootstrap.hint_port');
|
||||
});
|
||||
|
||||
it('still matches the pre-existing English phrasings', () => {
|
||||
expect(detectHints('', line('address already in use'))).toContain('bootstrap.hint_port');
|
||||
expect(detectHints('', line('Port 3900 is in use'))).toContain('bootstrap.hint_port');
|
||||
});
|
||||
|
||||
it('does not fire on unrelated failures', () => {
|
||||
expect(detectHints('', line('uv sync failed'))).not.toContain('bootstrap.hint_port');
|
||||
// The generic fallback must still be the only hint here.
|
||||
expect(detectHints('something else entirely')).toEqual(['bootstrap.hint_default']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('crashCauseHint — port conflict is not a memory problem (#1223)', () => {
|
||||
it('explains the port conflict for the dedicated exit code', () => {
|
||||
const hint = crashCauseHint({ exit_code: 78, signal: null });
|
||||
expect(hint).toMatch(/port 3900 is already in use/i);
|
||||
expect(hint).not.toMatch(/VRAM|RAM/);
|
||||
});
|
||||
|
||||
it('leaves the OS-OOM branch alone', () => {
|
||||
expect(crashCauseHint({ exit_code: null, signal: 9 })).toMatch(/ran out of/i);
|
||||
});
|
||||
|
||||
it('leaves the default VRAM branch alone', () => {
|
||||
expect(crashCauseHint({ exit_code: 1, signal: null })).toMatch(/VRAM/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Rust failure messages reach the localised hint (#1223)', () => {
|
||||
// These are the two BootstrapStage::Failed messages bootstrap.rs emits when
|
||||
// it cannot free the port. They are English (as every Rust-side failure
|
||||
// message is), but they only need to be MATCHABLE: detectHints turns them
|
||||
// into `bootstrap.hint_port`, which IS localised. If either message is
|
||||
// reworded so the matcher misses it, the user loses the translated guidance
|
||||
// — which is exactly the #1223 failure mode, one layer up.
|
||||
it.each([
|
||||
[
|
||||
'take-ownership',
|
||||
'Port 3900 is already in use by another application, and OmniVoice could not free it. ' +
|
||||
'Quit whatever is using that port (another copy of OmniVoice, or an app that claimed it) ' +
|
||||
'and try again.',
|
||||
],
|
||||
[
|
||||
'respawn',
|
||||
'Port 3900 is still in use by another application and OmniVoice could not free it, so the ' +
|
||||
"backend can't restart. Quit whatever is using that port and relaunch.",
|
||||
],
|
||||
[
|
||||
'early-exit',
|
||||
'Port 3900 is already in use, so the backend could not start. Another copy of OmniVoice — ' +
|
||||
'or an app that claimed that port — is holding it.',
|
||||
],
|
||||
])('%s message maps to the localised port hint', (_which, message) => {
|
||||
expect(detectHints(message)).toContain('bootstrap.hint_port');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Every synth path must register as "in flight" for as long as it runs, so the
|
||||
* updater can't relaunch the process and discard it.
|
||||
*
|
||||
* Two ways the earlier version of this got it wrong:
|
||||
*
|
||||
* 1. It was tracked in useTTS, so the Generate tab counted but voice
|
||||
* previews, the compare modal, the stories editor and profile previews —
|
||||
* all of which call generateSpeech directly — did not.
|
||||
* 2. It was a boolean, so two overlapping syntheses cleared each other:
|
||||
* whichever settled first said "nothing is running" while the other was
|
||||
* still going.
|
||||
*
|
||||
* Both are fixed by counting, at the one call they all share.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { apiFetch, state } = vi.hoisted(() => ({
|
||||
apiFetch: vi.fn(),
|
||||
state: { ttsInflight: 0 },
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
API: '',
|
||||
apiUrl: (p) => p,
|
||||
apiFetch: (...a) => apiFetch(...a),
|
||||
apiJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
...state,
|
||||
addTtsInflight: (d) => {
|
||||
state.ttsInflight = Math.max(0, state.ttsInflight + d);
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { generateSpeech } from '../api/generate';
|
||||
import { isAppBusy } from '../utils/appBusy';
|
||||
import { createGenerateSlice } from '../store/generateSlice';
|
||||
|
||||
beforeEach(() => {
|
||||
state.ttsInflight = 0;
|
||||
apiFetch.mockReset();
|
||||
});
|
||||
|
||||
describe('synth in-flight tracking', () => {
|
||||
it('counts a synth as busy for as long as the request runs', async () => {
|
||||
let release;
|
||||
apiFetch.mockReturnValue(
|
||||
new Promise((res) => {
|
||||
release = () => res({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
const p = generateSpeech(new FormData());
|
||||
expect(isAppBusy(state)).toBe(true);
|
||||
|
||||
release();
|
||||
await p;
|
||||
expect(isAppBusy(state)).toBe(false);
|
||||
});
|
||||
|
||||
it('survives overlapping syntheses — the first to finish does not clear the rest', async () => {
|
||||
const releases = [];
|
||||
apiFetch.mockImplementation(() => new Promise((res) => releases.push(() => res({ ok: true }))));
|
||||
|
||||
const a = generateSpeech(new FormData()); // Generate tab
|
||||
const b = generateSpeech(new FormData()); // a voice preview alongside it
|
||||
expect(state.ttsInflight).toBe(2);
|
||||
|
||||
releases[0]();
|
||||
await a;
|
||||
// b is still synthesising — a relaunch here would discard it.
|
||||
expect(isAppBusy(state)).toBe(true);
|
||||
|
||||
releases[1]();
|
||||
await b;
|
||||
expect(isAppBusy(state)).toBe(false);
|
||||
});
|
||||
|
||||
it('releases on failure and on abort, not just on success', async () => {
|
||||
apiFetch.mockRejectedValueOnce(new Error('network died'));
|
||||
await expect(generateSpeech(new FormData())).rejects.toThrow('network died');
|
||||
expect(isAppBusy(state)).toBe(false);
|
||||
|
||||
const abort = new DOMException('aborted', 'AbortError');
|
||||
apiFetch.mockRejectedValueOnce(abort);
|
||||
await expect(generateSpeech(new FormData())).rejects.toThrow();
|
||||
expect(isAppBusy(state)).toBe(false);
|
||||
});
|
||||
|
||||
// Against the real slice, not a stand-in: an unbalanced release must not
|
||||
// drive the count below zero, or a later synth would start already "idle".
|
||||
it('never goes negative', () => {
|
||||
let s = { ttsInflight: 0 };
|
||||
const set = (fn) => {
|
||||
s = { ...s, ...fn(s) };
|
||||
};
|
||||
const slice = createGenerateSlice(set, () => s, {});
|
||||
|
||||
slice.addTtsInflight(-1);
|
||||
slice.addTtsInflight(-5);
|
||||
expect(s.ttsInflight).toBe(0);
|
||||
|
||||
slice.addTtsInflight(1);
|
||||
expect(s.ttsInflight).toBe(1);
|
||||
expect(isAppBusy(s)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* The Install button in Settings → Updates must be greyed out while work is
|
||||
* running — and, just as importantly, must NOT be greyed out otherwise.
|
||||
*
|
||||
* Installing relaunches the process, so a click during a synth or a dub throws
|
||||
* that work away. The click handler is the safety check (work can start
|
||||
* between the last render and the click), but the disabled state is what tells
|
||||
* the user why the button won't do anything.
|
||||
*
|
||||
* The failure mode this pins is the expensive one: a busy predicate that is
|
||||
* accidentally always true makes the app permanently un-updatable, which is
|
||||
* far worse than the bug it was added to fix.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { storeState, installUpdate } = vi.hoisted(() => ({
|
||||
storeState: {},
|
||||
installUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
const BASE = {
|
||||
updateStatus: 'available',
|
||||
updateVersion: '0.4.2',
|
||||
updateNotes: null,
|
||||
updateError: null,
|
||||
updateProgress: 0,
|
||||
appVersion: '0.4.1',
|
||||
updateChannel: 'stable',
|
||||
releases: [],
|
||||
releasesStatus: 'idle',
|
||||
loadReleases: vi.fn(),
|
||||
dismissUpdate: vi.fn(),
|
||||
setWhatsNewSeenVersion: vi.fn(),
|
||||
dubStep: 'idle',
|
||||
stage: 'idle',
|
||||
ttsInflight: 0,
|
||||
};
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useAppStore: Object.assign((sel) => sel(storeState), { getState: () => storeState }),
|
||||
}));
|
||||
vi.mock('../utils/updater', () => ({
|
||||
installUpdate: (...a) => installUpdate(...a),
|
||||
checkForUpdate: vi.fn(),
|
||||
}));
|
||||
vi.mock('../utils/updatesApi', () => ({
|
||||
fetchChangelog: () => Promise.resolve([]),
|
||||
fetchBackupState: () => Promise.resolve(null),
|
||||
}));
|
||||
vi.mock('../utils/channelControl', () => ({ setChannel: vi.fn() }));
|
||||
vi.mock('../utils/updatePresentation', () => ({ prepareReleases: () => [] }));
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (k) => k }) }));
|
||||
vi.mock('react-hot-toast', () => ({ default: Object.assign(vi.fn(), { error: vi.fn() }) }));
|
||||
|
||||
import UpdatesPanel from '../components/UpdatesPanel';
|
||||
|
||||
const renderWith = (over) => {
|
||||
Object.keys(storeState).forEach((k) => delete storeState[k]);
|
||||
Object.assign(storeState, BASE, over);
|
||||
return render(<UpdatesPanel />);
|
||||
};
|
||||
|
||||
beforeEach(() => installUpdate.mockClear());
|
||||
|
||||
describe('UpdatesPanel install button', () => {
|
||||
it('is enabled when nothing is running', () => {
|
||||
renderWith({});
|
||||
expect(screen.getByRole('button', { name: /update\.install/ })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a dub upload', { dubStep: 'uploading' }],
|
||||
['a translation', { stage: 'translating' }],
|
||||
['a synth', { ttsInflight: 1 }],
|
||||
['overlapping synths', { ttsInflight: 2 }],
|
||||
])('is disabled during %s', (_label, over) => {
|
||||
renderWith(over);
|
||||
expect(screen.getByRole('button', { name: /update\.install/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the restart button too, not just install', () => {
|
||||
renderWith({ updateStatus: 'ready', ttsInflight: 1 });
|
||||
expect(screen.getByRole('button', { name: /update\.restart/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('an open transcript does not block updating', () => {
|
||||
// 'editing' waits on the user; treating it as busy would make the app
|
||||
// un-updatable for as long as a dub tab stays open.
|
||||
renderWith({ dubStep: 'editing' });
|
||||
expect(screen.getByRole('button', { name: /update\.install/ })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* #1245: the app was dead on arrival on macOS 12 (Monterey).
|
||||
*
|
||||
* The reporter's whole session was one line — `view:launchpad` — and
|
||||
* "Last backend response: none this session — it may never have started".
|
||||
* The stack is a React render throwing:
|
||||
*
|
||||
* AbortSignal.timeout is not a function.
|
||||
* (In 'AbortSignal.timeout(2e3)', 'AbortSignal.timeout' is undefined)
|
||||
*
|
||||
* `useRealtimeEvents` polls backend health with `AbortSignal.timeout(2000)`
|
||||
* on mount. That method arrived in Safari 16.0; `tauri.conf.json` declares
|
||||
* `minimumSystemVersion: "12.0"` and `docs/install/macos.md` promises macOS 12,
|
||||
* which ships WKWebView **15.6**. So this was not an exotic environment — it
|
||||
* was the floor we advertise.
|
||||
*
|
||||
* Two halves are pinned:
|
||||
* 1. the polyfill behaves like the real thing (aborts, and with TimeoutError);
|
||||
* 2. no app module reaches for a post-floor API that nothing fills in — the
|
||||
* whole class, not just the one method that got reported.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { installAbortSignalTimeout } from '../utils/webCompat.js';
|
||||
|
||||
const SRC = path.resolve(__dirname, '..');
|
||||
|
||||
describe('AbortSignal.timeout polyfill', () => {
|
||||
let original;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
original = AbortSignal.timeout;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
AbortSignal.timeout = original;
|
||||
});
|
||||
|
||||
it('fills the method in when the WebView lacks it', () => {
|
||||
// Reproduce Monterey: the method is simply absent.
|
||||
delete AbortSignal.timeout;
|
||||
expect(AbortSignal.timeout).toBeUndefined();
|
||||
|
||||
installAbortSignalTimeout();
|
||||
expect(typeof AbortSignal.timeout).toBe('function');
|
||||
});
|
||||
|
||||
it('aborts after the delay, with a TimeoutError reason', () => {
|
||||
delete AbortSignal.timeout;
|
||||
installAbortSignalTimeout();
|
||||
|
||||
const signal = AbortSignal.timeout(2000);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(signal.aborted).toBe(true);
|
||||
// `reason` is how a caller tells a timeout apart from a user cancel.
|
||||
expect(signal.reason?.name).toBe('TimeoutError');
|
||||
});
|
||||
|
||||
it('does not clobber a native implementation', () => {
|
||||
const native = vi.fn(() => new AbortController().signal);
|
||||
AbortSignal.timeout = native;
|
||||
|
||||
installAbortSignalTimeout();
|
||||
|
||||
expect(AbortSignal.timeout).toBe(native);
|
||||
});
|
||||
|
||||
it('is installed before the app boots', () => {
|
||||
// The gap fill is worthless if it loads after the chunk that throws.
|
||||
const main = fs.readFileSync(path.join(SRC, 'main.jsx'), 'utf8');
|
||||
const compat = main.indexOf('utils/webCompat');
|
||||
const app = main.indexOf('main-app.jsx');
|
||||
expect(compat).toBeGreaterThan(-1);
|
||||
expect(app).toBeGreaterThan(-1);
|
||||
expect(compat).toBeLessThan(app);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The recurrence guard. `AbortSignal.timeout` was not special — it was
|
||||
* whichever post-floor API we happened to reach for first. Each entry is an
|
||||
* API that does NOT exist in Safari 15.6 (the WebView shipped with the macOS
|
||||
* version `tauri.conf.json` declares) and that `webCompat.js` does not fill
|
||||
* in, so using one would break Monterey exactly the way #1245 did.
|
||||
*
|
||||
* To use one of these: either fill it in inside `webCompat.js` and delete the
|
||||
* entry, or guard the call site with a runtime check and a fallback.
|
||||
*
|
||||
* WHAT THIS DOES NOT COVER — deliberately stated so it is not over-trusted:
|
||||
*
|
||||
* - **Syntax.** A post-floor *grammar* feature (RegExp lookbehind, Safari
|
||||
* 16.4) is a parse-time SyntaxError that kills its whole chunk before a
|
||||
* line runs, and no polyfill can help. Text patterns here cannot see that.
|
||||
* - **Dependencies.** Only `frontend/src` is scanned. A bundled library using
|
||||
* a post-floor API or grammar is invisible here and ships anyway.
|
||||
* - **CSS.** Tailwind v4's own floor is Safari 16.4, and `index.css` uses
|
||||
* `color-mix()` (16.2) throughout, so the *rendering* floor is already
|
||||
* above macOS 12 regardless of what JavaScript does. See #1268.
|
||||
* - **Computed access.** `AbortSignal["timeout"]` or a destructured
|
||||
* reference evades every pattern below.
|
||||
*
|
||||
* This guard closes the class it can see — first-party source. The rest is
|
||||
* tracked in #1268 rather than pretended away.
|
||||
*/
|
||||
const POST_FLOOR_APIS = [
|
||||
// Safari 18.0
|
||||
{ pattern: /\bURL\s*\.\s*parse\s*\(/, name: 'URL.parse', since: 'Safari 18.0' },
|
||||
// Safari 17.4
|
||||
{ pattern: /\bAbortSignal\s*\.\s*any\b/, name: 'AbortSignal.any', since: 'Safari 17.4' },
|
||||
{ pattern: /\bObject\s*\.\s*groupBy\b/, name: 'Object.groupBy', since: 'Safari 17.4' },
|
||||
{ pattern: /\bMap\s*\.\s*groupBy\b/, name: 'Map.groupBy', since: 'Safari 17.4' },
|
||||
{
|
||||
pattern: /\bPromise\s*\.\s*withResolvers\b/,
|
||||
name: 'Promise.withResolvers',
|
||||
since: 'Safari 17.4',
|
||||
},
|
||||
{ pattern: /\.checkVisibility\s*\(/, name: 'Element#checkVisibility', since: 'Safari 17.4' },
|
||||
// Safari 17.0
|
||||
{ pattern: /\bURL\s*\.\s*canParse\b/, name: 'URL.canParse', since: 'Safari 17.0' },
|
||||
// Safari 16.4
|
||||
{ pattern: /\.isWellFormed\s*\(/, name: 'String#isWellFormed', since: 'Safari 16.4' },
|
||||
{ pattern: /\.toWellFormed\s*\(/, name: 'String#toWellFormed', since: 'Safari 16.4' },
|
||||
{ pattern: /\bArray\s*\.\s*fromAsync\b/, name: 'Array.fromAsync', since: 'Safari 16.4' },
|
||||
// Safari 16.0 — the change-by-copy family. `with` is the one most likely to
|
||||
// be reached for in React state code (`arr.with(i, v)`), and it was missing
|
||||
// from this list until review caught it.
|
||||
{ pattern: /\.toSorted\s*\(/, name: 'Array#toSorted', since: 'Safari 16.0' },
|
||||
{ pattern: /\.toReversed\s*\(/, name: 'Array#toReversed', since: 'Safari 16.0' },
|
||||
{ pattern: /\.toSpliced\s*\(/, name: 'Array#toSpliced', since: 'Safari 16.0' },
|
||||
// `arr.with(i, v)` is the idiomatic immutable-state form in React code, so
|
||||
// it is the sibling most likely to be reached for. It was missing from this
|
||||
// list until review caught it.
|
||||
{ pattern: /\.with\s*\(/, name: 'Array#with', since: 'Safari 16.0' },
|
||||
{ pattern: /\bAbortSignal\s*\.\s*timeout\b/, name: 'AbortSignal.timeout', since: 'Safari 16.0' },
|
||||
];
|
||||
|
||||
/** Entries `webCompat.js` fills in, so app code may use them freely. */
|
||||
const POLYFILLED = new Set(['AbortSignal.timeout']);
|
||||
|
||||
const walk = (dir, out = []) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'test' || entry.name === 'node_modules') continue;
|
||||
walk(full, out);
|
||||
} else if (/\.(js|jsx|ts|tsx)$/.test(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('no app code depends on an API newer than the macOS floor', () => {
|
||||
it('is written against the macOS version tauri.conf.json declares', () => {
|
||||
const conf = JSON.parse(
|
||||
fs.readFileSync(path.resolve(SRC, '..', 'src-tauri', 'tauri.conf.json'), 'utf8'),
|
||||
);
|
||||
// The list above is derived from the WebView that ships with this macOS
|
||||
// version (12.0 → WKWebView 15.6). If the floor moves, re-derive it — the
|
||||
// test is only as correct as the version it is written against.
|
||||
//
|
||||
// Note this asserts what we DECLARE, not what we deliver: the CSS layer
|
||||
// (Tailwind v4, color-mix) needs Safari 16.4, so 12.0 is currently a
|
||||
// promise the frontend stack does not keep. Tracked in #1268; asserted
|
||||
// here so raising the floor forces this list to be revisited.
|
||||
expect(conf.bundle?.macOS?.minimumSystemVersion).toBe('12.0');
|
||||
});
|
||||
|
||||
it('uses no unfilled post-Safari-15.6 API', () => {
|
||||
const offenders = [];
|
||||
for (const file of walk(SRC)) {
|
||||
if (file.endsWith(path.join('utils', 'webCompat.js'))) continue;
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
for (const api of POST_FLOOR_APIS) {
|
||||
if (POLYFILLED.has(api.name)) continue;
|
||||
if (api.pattern.test(src)) {
|
||||
offenders.push(`${path.relative(SRC, file)} uses ${api.name} (${api.since})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('still guards the API that broke Monterey', () => {
|
||||
// Sanity: AbortSignal.timeout IS used by app code, so the exemption above
|
||||
// is load-bearing — if the polyfill is deleted the exemption must go too.
|
||||
const users = walk(SRC).filter(
|
||||
(f) =>
|
||||
!f.endsWith(path.join('utils', 'webCompat.js')) &&
|
||||
/\bAbortSignal\s*\.\s*timeout\b/.test(fs.readFileSync(f, 'utf8')),
|
||||
);
|
||||
expect(users.length).toBeGreaterThan(0);
|
||||
|
||||
const compat = fs.readFileSync(path.join(SRC, 'utils', 'webCompat.js'), 'utf8');
|
||||
expect(compat).toMatch(/AbortSignal\.timeout\s*=/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* "Is long-running work in flight?" — the single answer, for anything that
|
||||
* would destroy that work.
|
||||
*
|
||||
* Today the only caller that matters is the updater: installing an update
|
||||
* relaunches the process, so anything mid-flight is lost. That check used to
|
||||
* be written inline as `dubStep === 'generating'`, which covered exactly one
|
||||
* of the app's long operations and silently permitted a relaunch during a dub
|
||||
* upload, a transcription, a translation, a standalone TTS synth, or an
|
||||
* export. It was also duplicated in two places, so the two could drift.
|
||||
*
|
||||
* The signals unioned here are the ones the store actually knows about:
|
||||
*
|
||||
* - `dubStep` — the dub workflow's own state machine
|
||||
* - `stage` — the floating status pill, which every long background
|
||||
* operation already pushes to (ASR model load, dictation
|
||||
* capture, transcribe, translate, export, refine)
|
||||
* - `ttsInflight` — a COUNT of synth requests in flight, maintained by
|
||||
* `api/generate.ts` around the one `/generate` call every
|
||||
* synth path shares (Generate tab, voice previews, the
|
||||
* compare modal, the stories editor, profile previews).
|
||||
* A count because those overlap: a boolean would be
|
||||
* cleared by whichever finished first while the rest were
|
||||
* still running.
|
||||
*
|
||||
* Deliberately NOT busy:
|
||||
*
|
||||
* - `dubStep === 'editing'` — the transcript is open and waiting on the
|
||||
* user, which is not an operation. Counting it would block updates for as
|
||||
* long as a tab stays open, which is its own bug.
|
||||
* - `'done'` / `'error'` — terminal; the work already survived or didn't.
|
||||
*/
|
||||
|
||||
/** Dub steps where work is actually running (see `DubStep` in dubSlice). */
|
||||
const BUSY_DUB_STEPS = new Set(['uploading', 'transcribing', 'generating', 'stopping']);
|
||||
|
||||
/** Pill stages that represent an operation in progress (see `PillStage`). */
|
||||
const BUSY_PILL_STAGES = new Set([
|
||||
'loading-model',
|
||||
'recording',
|
||||
'transcribing',
|
||||
'translating',
|
||||
'generating',
|
||||
'exporting',
|
||||
'refining',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when interrupting the app right now would lose work the user cares
|
||||
* about. Takes a store snapshot (`useAppStore.getState()`) rather than reading
|
||||
* the store itself, so it stays usable from event handlers and from tests.
|
||||
*/
|
||||
export function isAppBusy(state) {
|
||||
if (!state) return false;
|
||||
return (
|
||||
BUSY_DUB_STEPS.has(state.dubStep) ||
|
||||
BUSY_PILL_STAGES.has(state.stage) ||
|
||||
(state.ttsInflight ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export default isAppBusy;
|
||||
@@ -196,6 +196,18 @@ export function describeCrashExit(
|
||||
* the dominant cause for real GPU aborts.
|
||||
*/
|
||||
export function crashCauseHint(marker: Pick<BackendCrashMarker, 'exit_code' | 'signal'>): string {
|
||||
// #1223: the backend exits 78 (EX_CONFIG) when it could not bind its port.
|
||||
// That is not a crash and has nothing to do with memory — the old message
|
||||
// sent a user whose real problem was a leftover process off to shrink their
|
||||
// ASR model. Keep in sync with _EXIT_PORT_IN_USE in backend/main.py.
|
||||
if (marker.exit_code === 78) {
|
||||
return (
|
||||
'The backend could not start because port 3900 is already in use — another copy of ' +
|
||||
'OmniVoice (or an app that claimed that port) is holding it. Quit the other instance and ' +
|
||||
'relaunch; if nothing is visibly running, an orphaned backend from a previous session is ' +
|
||||
'still holding the port.'
|
||||
);
|
||||
}
|
||||
if (marker.signal === 9) {
|
||||
return (
|
||||
'It was force-killed (signal 9), which usually means the operating system ran out of ' +
|
||||
|
||||
@@ -14,10 +14,21 @@ import { buildBugReportUrl } from './bugReport';
|
||||
// #1188: backend errors that carry a machine-readable "[code]" marker are
|
||||
// user-fixable input problems, not bugs — show localized guidance (what
|
||||
// happened + the concrete fix) instead of the raw English detail, and skip
|
||||
// the "Report" action. Markers are emitted by the backend (the
|
||||
// [clone_ref_unusable] one in omnivoice/utils/audio.py) — keep in sync.
|
||||
// the "Report" action.
|
||||
//
|
||||
// #1276 adds [shutting_down]: the backend is on its way out, so nothing
|
||||
// failed and there is nothing to report.
|
||||
//
|
||||
// Matched on the MARKER, never on the bare 503 status. 503 is also how a real
|
||||
// engine-load timeout and an unavailable engine are reported (#1246, #1260,
|
||||
// #1277) — those are genuine bugs users need to file, and keying off the
|
||||
// status alone would silence exactly that class.
|
||||
//
|
||||
// Markers are emitted by the backend ([clone_ref_unusable] in
|
||||
// omnivoice/utils/audio.py, [shutting_down] in main.py) — keep in sync.
|
||||
const USER_FIXABLE_MARKERS = {
|
||||
'[clone_ref_unusable]': 'tts_errors.ref_audio_unusable',
|
||||
'[shutting_down]': 'errors.backend_shutting_down',
|
||||
};
|
||||
|
||||
export function toastErrorWithReport(message, error) {
|
||||
|
||||
@@ -50,8 +50,25 @@ export async function checkForUpdate(store) {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const channel = await currentChannel();
|
||||
const update = await invoke('check_update', { channel });
|
||||
if (update) store.setUpdateAvailable(update.version, update.notes || null);
|
||||
else store.setUpdateIdle();
|
||||
if (update) {
|
||||
store.setUpdateAvailable(update.version, update.notes || null);
|
||||
// Announce it where the user is looking. The footer's version dot stays
|
||||
// as the persistent, non-intrusive marker; this is the one-time nudge.
|
||||
// Lazy so the toast never loads in a browser/dev build that can't update.
|
||||
//
|
||||
// Caught separately: a failed chunk load must not fall through to the
|
||||
// outer catch, which calls setUpdateIdle() and would erase the update we
|
||||
// just found. The announcement is the optional part — the available
|
||||
// state (footer dot, Settings → Updates) is what has to survive.
|
||||
try {
|
||||
const { showUpdateToast } = await import('../components/UpdateToast');
|
||||
showUpdateToast(update.version);
|
||||
} catch (e) {
|
||||
console.debug('Update toast failed to load (non-fatal):', e);
|
||||
}
|
||||
} else {
|
||||
store.setUpdateIdle();
|
||||
}
|
||||
} catch (e) {
|
||||
// Endpoint 404s until the first signed release on a channel — non-fatal.
|
||||
console.debug('Update check failed (non-fatal):', e);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Web-platform gap fills for the OLDEST WebView we claim to support.
|
||||
*
|
||||
* `tauri.conf.json` declares `minimumSystemVersion: "12.0"` and the install
|
||||
* docs promise macOS 12 (Monterey) — which ships Safari/WKWebView **15.6**.
|
||||
* Anything newer than that is not present at runtime on a supported machine,
|
||||
* and because our entry chunk touches these during the first React render, a
|
||||
* single missing method is not a degraded feature: it throws mid-render, the
|
||||
* tree unmounts, and the user gets a dead window with no backend ever started
|
||||
* (#1245).
|
||||
*
|
||||
* This module must be imported for side effects as the FIRST thing in
|
||||
* `main.jsx`, before any app chunk loads. `test/webCompat.test.js` keeps the
|
||||
* list honest: it fails CI if app code reaches for a post-15.6 API that is not
|
||||
* filled in here.
|
||||
*
|
||||
* Windows (evergreen WebView2) and Linux (WebKitGTK ≥ 2.44 on Ubuntu 24.04+)
|
||||
* both clear the floor comfortably — macOS 12 is the binding constraint.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `AbortSignal.timeout(ms)` — Safari 16.0. Used by the backend health poll on
|
||||
* the very first render, so its absence took the whole app down on Monterey.
|
||||
*/
|
||||
export function installAbortSignalTimeout() {
|
||||
if (typeof AbortSignal === 'undefined' || typeof AbortController === 'undefined') return;
|
||||
if (typeof AbortSignal.timeout === 'function') return;
|
||||
|
||||
AbortSignal.timeout = function timeout(ms) {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => {
|
||||
// The spec aborts with a DOMException named TimeoutError, which is how
|
||||
// callers tell "we gave up" apart from "the user cancelled". Pass it:
|
||||
// `abort(reason)` has been supported since Safari 15.4, so it IS
|
||||
// honoured on our 15.6 floor. Do not "simplify" this to a bare
|
||||
// `controller.abort()` — that would silently turn every TimeoutError
|
||||
// into an AbortError and break exactly the distinction it exists for.
|
||||
let reason;
|
||||
try {
|
||||
reason = new DOMException('signal timed out', 'TimeoutError');
|
||||
} catch {
|
||||
reason = new Error('signal timed out');
|
||||
reason.name = 'TimeoutError';
|
||||
}
|
||||
controller.abort(reason);
|
||||
}, ms);
|
||||
return controller.signal;
|
||||
};
|
||||
}
|
||||
|
||||
export function installWebCompat() {
|
||||
installAbortSignalTimeout();
|
||||
}
|
||||
|
||||
installWebCompat();
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "omnivoice"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
description = "OmniVoice: Towards Omnilingual Zero-Shot Text-to-Speech with Diffusion Language Models"
|
||||
readme = "README.md"
|
||||
# Free and open-source under the GNU Affero General Public License v3 (see
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""#1262: a non-Latin voice-profile name 500'd every download endpoint.
|
||||
|
||||
500 Internal Server Error: 'latin-1' codec can't encode characters in
|
||||
position 22-25: ordinal not in range(256)
|
||||
|
||||
``attachment; filename="`` is exactly 22 characters long, so positions 22-25
|
||||
were the first four characters of the reporter's own profile name. HTTP header
|
||||
values are latin-1 by definition; every download endpoint interpolated the
|
||||
filename straight into the header.
|
||||
|
||||
The sanitisers in front of those f-strings looked like they covered it — but
|
||||
they all filtered with ``str.isalnum()``, which is ``True`` for *every*
|
||||
alphabetic script. They stripped punctuation and let through exactly the
|
||||
characters that break the header.
|
||||
|
||||
This was never one endpoint: the same ``isalnum()`` idiom was copy-pasted
|
||||
across persona export, marketplace, stories, the OpenAI-compatible speech
|
||||
route, and eight dub-export routes. All of them now go through one RFC 6266
|
||||
builder, and a guard below keeps the next one from being written by hand.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from core.http_headers import ascii_filename, content_disposition
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
# ── the header is always encodable ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"我的声音.ovsvoice", # Chinese — the reported shape
|
||||
"私の声.ovsvoice", # Japanese
|
||||
"내 목소리.ovsvoice", # Korean
|
||||
"Моя речь.ovsvoice", # Cyrillic
|
||||
"φωνή.ovsvoice", # Greek
|
||||
"קול.ovsvoice", # Hebrew
|
||||
"🎙️ voice.ovsvoice", # emoji
|
||||
"Sébastien’s voix.ovsvoice", # accented Latin + smart quote
|
||||
],
|
||||
)
|
||||
def test_the_header_survives_any_script(name):
|
||||
header = content_disposition(name)
|
||||
# The actual failure: Starlette encodes header values as latin-1.
|
||||
header.encode("latin-1")
|
||||
|
||||
|
||||
def test_the_exact_reported_failure():
|
||||
"""A four-character CJK name — the one that produced 'position 22-25'."""
|
||||
header = content_disposition("我的声音.ovsvoice")
|
||||
header.encode("latin-1")
|
||||
assert 'filename="' in header
|
||||
assert "filename*=UTF-8''" in header
|
||||
|
||||
|
||||
def test_the_real_name_is_preserved_for_modern_clients():
|
||||
header = content_disposition("我的声音.ovsvoice")
|
||||
# RFC 5987 percent-encoded UTF-8 — browsers prefer this over `filename=`.
|
||||
assert "%E6%88%91%E7%9A%84%E5%A3%B0%E9%9F%B3" in header
|
||||
|
||||
|
||||
def test_accented_latin_is_folded_not_deleted():
|
||||
assert ascii_filename("Sébastien.ovsvoice") == "Sebastien.ovsvoice"
|
||||
|
||||
|
||||
def test_an_entirely_non_ascii_name_still_yields_a_usable_filename():
|
||||
"""Stripping CJK leaves only ".ovsvoice", which is not a filename."""
|
||||
safe = ascii_filename("我的声音.ovsvoice")
|
||||
assert safe.endswith(".ovsvoice")
|
||||
stem = safe[: -len(".ovsvoice")]
|
||||
assert stem and stem.strip("_ "), f"no usable stem in {safe!r}"
|
||||
|
||||
|
||||
def test_ascii_names_are_left_alone():
|
||||
assert ascii_filename("dubbed_output_en.mp4") == "dubbed_output_en.mp4"
|
||||
header = content_disposition("dubbed_output_en.mp4")
|
||||
assert 'filename="dubbed_output_en.mp4"' in header
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hostile", ['a"b.mp4', "a\\b.mp4", "a\r\nX-Evil: 1.mp4", "a/b/c.mp4"])
|
||||
def test_quoting_and_header_injection_are_neutralised(hostile):
|
||||
"""A dub filename comes from a video title, i.e. from the internet. A bare
|
||||
quote would end the quoted-string; a CRLF would split the header."""
|
||||
header = content_disposition(hostile)
|
||||
header.encode("latin-1")
|
||||
assert "\r" not in header and "\n" not in header
|
||||
# Exactly the two parameters we intend, no smuggled third.
|
||||
assert header.count("filename=") == 1
|
||||
assert header.count("filename*=") == 1
|
||||
|
||||
|
||||
def test_inline_disposition_is_supported():
|
||||
"""The OpenAI-compatible speech route streams inline, not as a download."""
|
||||
assert content_disposition("speech.mp3", disposition="inline").startswith("inline;")
|
||||
|
||||
|
||||
# ── and no endpoint builds the header by hand again ──────────────────────
|
||||
|
||||
|
||||
def test_no_router_interpolates_a_filename_into_the_header():
|
||||
"""The recurrence guard. This bug shipped in ten places because the header
|
||||
was written by f-string ten times; the eleventh must not compile."""
|
||||
offenders = []
|
||||
pattern = re.compile(r'"Content-Disposition"\s*:\s*f[\'"]')
|
||||
for path in (REPO / "backend").rglob("*.py"):
|
||||
if "test" in path.parts:
|
||||
continue
|
||||
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if pattern.search(line):
|
||||
offenders.append(f"{path.relative_to(REPO)}:{i}")
|
||||
assert offenders == [], (
|
||||
"build the value with core.http_headers.content_disposition() — an "
|
||||
"f-string here 500s on any non-latin-1 filename (#1262)"
|
||||
)
|
||||
|
||||
|
||||
def test_every_download_endpoint_actually_uses_the_builder():
|
||||
"""Complements the guard above: proves the call sites were converted, not
|
||||
merely reworded into something the regex misses."""
|
||||
routers = REPO / "backend" / "api" / "routers"
|
||||
users = {
|
||||
path.name
|
||||
for path in routers.rglob("*.py")
|
||||
if "content_disposition(" in path.read_text(encoding="utf-8")
|
||||
}
|
||||
for expected in (
|
||||
"personas.py",
|
||||
"marketplace.py",
|
||||
"stories.py",
|
||||
"dub_export.py",
|
||||
"openai_compat.py",
|
||||
):
|
||||
assert expected in users, f"{expected} still builds the header itself"
|
||||
|
||||
|
||||
def test_a_hostile_custom_fallback_cannot_reach_the_header():
|
||||
"""Review finding (#1262): `fallback` landed in `filename=` verbatim
|
||||
whenever the real name folded away entirely, so a non-ASCII or CRLF
|
||||
fallback walked past every guard the real name goes through."""
|
||||
header = content_disposition("我的声音.ovsvoice", fallback='ev"il\r\nX-Evil: 1')
|
||||
header.encode("latin-1")
|
||||
assert "\r" not in header and "\n" not in header
|
||||
assert header.count("filename=") == 1
|
||||
assert header.count("filename*=") == 1
|
||||
|
||||
# A fallback that is ENTIRELY non-ASCII must still leave a usable name.
|
||||
header = content_disposition("我的声音.ovsvoice", fallback="声音")
|
||||
header.encode("latin-1")
|
||||
assert 'filename=""' not in header
|
||||
@@ -0,0 +1,540 @@
|
||||
"""#1252/#1253: a dub ingest failed with the toast ``ingest: 'mgw39lx3'``.
|
||||
|
||||
Two reports, same reporter, same session, four `dub:upload` actions in a row.
|
||||
The entire user-facing error was eight characters of their own job id:
|
||||
|
||||
ingest: 'mgw39lx3'
|
||||
Error: ingest: 'mgw39lx3'
|
||||
|
||||
That is ``str(KeyError("mgw39lx3"))`` — the repr of a dict key. ``KeyError``
|
||||
does not put "a lookup failed" in its message, so `build_failure` faithfully
|
||||
reported a value with no explanation attached to it.
|
||||
|
||||
The lookup that failed: ``ingest_pipeline`` finished with a bare
|
||||
``_dub_jobs[job_id].update(...)``. Everything before it — demucs, scene
|
||||
detection, thumbnailing — takes minutes, and ``DELETE /dub/history/{id}`` pops
|
||||
the entry. Deleting an in-flight dub therefore raised ``KeyError`` from a
|
||||
pipeline that was, by then, doing exactly what it was told.
|
||||
|
||||
Both halves are covered: the crash no longer happens, and no exception whose
|
||||
``str()`` is a bare value can present itself to a user that way again.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import time
|
||||
|
||||
from core.failure import build_failure, describe_exception
|
||||
from services import dub_pipeline
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_jobs():
|
||||
dub_pipeline._dub_jobs.clear()
|
||||
yield
|
||||
dub_pipeline._dub_jobs.clear()
|
||||
|
||||
|
||||
# ── the crash ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_merging_into_a_live_job_updates_it():
|
||||
dub_pipeline.put_job("job1", {"filename": "a.mp4", "scene_cuts": []})
|
||||
|
||||
assert dub_pipeline.merge_job("job1", {"scene_cuts": [1.0, 2.0]}) is True
|
||||
assert dub_pipeline._dub_jobs["job1"]["scene_cuts"] == [1.0, 2.0]
|
||||
assert dub_pipeline._dub_jobs["job1"]["filename"] == "a.mp4", "a merge, not a replace"
|
||||
|
||||
|
||||
def test_merging_into_a_deleted_job_reports_it_instead_of_raising():
|
||||
"""The #1252 moment: the user deleted the dub while it was still ingesting."""
|
||||
dub_pipeline.put_job("mgw39lx3", {"filename": "a.mp4"})
|
||||
dub_pipeline._dub_jobs.pop("mgw39lx3") # DELETE /dub/history/{id}
|
||||
|
||||
assert dub_pipeline.merge_job("mgw39lx3", {"scene_cuts": []}) is False
|
||||
|
||||
|
||||
def test_a_deleted_job_is_not_resurrected():
|
||||
"""`False` must mean "stop", not "insert it back" — the user deleted this
|
||||
on purpose, and re-adding it would put a phantom row back in history."""
|
||||
assert dub_pipeline.merge_job("gone", {"scene_cuts": []}) is False
|
||||
assert "gone" not in dub_pipeline._dub_jobs
|
||||
|
||||
|
||||
def test_the_ingest_pipeline_no_longer_blind_subscripts_the_job():
|
||||
"""The call site itself. A direct `_dub_jobs[job_id].update(` anywhere in
|
||||
the pipeline reintroduces exactly this KeyError."""
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(dub_pipeline.ingest_pipeline)
|
||||
assert "_dub_jobs[job_id].update(" not in src
|
||||
assert "merge_and_save_job(" in src
|
||||
|
||||
|
||||
# ── the message ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_keyerror_no_longer_presents_as_a_bare_key():
|
||||
"""The exact reason string the reporter saw."""
|
||||
failure = build_failure(KeyError("mgw39lx3"), stage="ingest")
|
||||
|
||||
assert failure["reason"] != "'mgw39lx3'"
|
||||
assert failure["error_class"] == "KeyError"
|
||||
assert "KeyError" in failure["reason"], "name what happened, not just the value"
|
||||
assert "mgw39lx3" in failure["reason"], "but keep the value — it is the only clue"
|
||||
|
||||
|
||||
def test_an_exception_with_no_message_at_all_still_names_itself():
|
||||
assert describe_exception(RuntimeError()) == "RuntimeError"
|
||||
assert describe_exception(ValueError(" ")) == "ValueError"
|
||||
|
||||
|
||||
def test_a_real_message_is_left_exactly_alone():
|
||||
"""The fix must not prefix class names onto errors that already read fine —
|
||||
every existing hint and classification matches on message text."""
|
||||
assert describe_exception(RuntimeError("CUDA out of memory")) == "CUDA out of memory"
|
||||
assert describe_exception(
|
||||
OSError("[Errno 28] No space left on device")
|
||||
) == "[Errno 28] No space left on device"
|
||||
|
||||
|
||||
def test_classification_still_works_through_the_wrapper():
|
||||
"""`build_failure` classifies on the raw text; adding a class prefix must
|
||||
not break the hint lookup for messages that do classify."""
|
||||
failure = build_failure(RuntimeError("No module named 'omnivoice'"), stage="startup")
|
||||
assert failure["docs_topic"] == "BROKEN_VENV"
|
||||
assert failure["hint"]
|
||||
|
||||
|
||||
# ── the delete race the first fix left open ──────────────────────────────
|
||||
|
||||
|
||||
def test_merge_and_save_are_one_step(monkeypatch):
|
||||
"""Review finding (#1252): merging and persisting as two steps leaves a
|
||||
window where the user deletes the dub in between — and the pending save
|
||||
then UPSERTs the row straight back, so a dub they deleted reappears."""
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job",
|
||||
lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
|
||||
|
||||
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
|
||||
assert saved == ["job1"]
|
||||
assert dub_pipeline._dub_jobs["job1"]["scene_cuts"] == [1.0]
|
||||
|
||||
|
||||
def test_a_deleted_job_is_never_persisted(monkeypatch):
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job",
|
||||
lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
|
||||
assert dub_pipeline.merge_and_save_job("gone", {"scene_cuts": []}) is False
|
||||
assert saved == [], "a withdrawn job must not reach the database"
|
||||
|
||||
|
||||
def test_a_delete_landing_mid_save_cannot_be_overtaken(monkeypatch):
|
||||
"""The race itself — asserted on ORDER, which is the only thing that
|
||||
distinguishes it.
|
||||
|
||||
Two earlier versions of this test were not tests. The first deleted the job
|
||||
before calling `merge_and_save_job`, so it merely re-checked the absent-job
|
||||
case. The second interleaved a real thread but asserted only *what*
|
||||
happened, not *when* — so splitting merge from save (the exact bug) still
|
||||
passed, because a save landing AFTER the delete looks identical to one
|
||||
landing before if you only check that both occurred.
|
||||
|
||||
The resurrection is precisely `save` completing after `delete`. So record
|
||||
the order and assert on it: with merge+save atomic under the lock, the
|
||||
purge cannot start until the save has finished, and the sequence is always
|
||||
save-then-delete. Split them and the purge is free to run first.
|
||||
"""
|
||||
import threading
|
||||
|
||||
order = []
|
||||
order_lock = threading.Lock()
|
||||
entered_save = threading.Event()
|
||||
purge_attempted = threading.Event()
|
||||
|
||||
def _slow_save(job_id, job, *a, **kw):
|
||||
entered_save.set()
|
||||
# Wait until the purge thread has actually reached its purge call, so
|
||||
# the two are genuinely in flight together, then hold a moment.
|
||||
purge_attempted.wait(timeout=2.0)
|
||||
time.sleep(0.05)
|
||||
with order_lock:
|
||||
order.append("save")
|
||||
|
||||
monkeypatch.setattr(dub_pipeline, "save_job", _slow_save)
|
||||
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
|
||||
|
||||
def _delete_rows():
|
||||
with order_lock:
|
||||
order.append("delete")
|
||||
|
||||
def _purge():
|
||||
entered_save.wait(timeout=2.0)
|
||||
purge_attempted.set()
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=_delete_rows)
|
||||
|
||||
purger = threading.Thread(target=_purge)
|
||||
purger.start()
|
||||
merged = dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]})
|
||||
purge_attempted.set() # release the save if the purge never got that far
|
||||
purger.join(timeout=5.0)
|
||||
|
||||
assert merged is True, "the save started first, so it must complete"
|
||||
assert order == ["save", "delete"], (
|
||||
f"the write must never land after the delete — got {order}. "
|
||||
"'delete' first means the row was resurrected."
|
||||
)
|
||||
assert "job1" not in dub_pipeline._dub_jobs
|
||||
|
||||
|
||||
def test_a_purge_that_wins_the_race_stops_the_save_entirely(monkeypatch):
|
||||
"""The other ordering: purge first, then the pipeline reaches its save."""
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
dub_pipeline.put_job("job1", {"filename": "a.mp4"})
|
||||
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
|
||||
|
||||
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": []}) is False
|
||||
assert saved == [], "a withdrawn job must never reach the database"
|
||||
|
||||
|
||||
def test_the_create_checkpoints_are_atomic_too(monkeypatch):
|
||||
"""Review finding (#1252): the mid-pipeline put_job + save_job pairs were
|
||||
still unlocked, so a clear-history landing between them left a ghost row
|
||||
behind the purge."""
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(dub_pipeline.ingest_pipeline)
|
||||
assert "put_and_save_job(" in src
|
||||
assert "put_job(job_id," not in src, "the unlocked pair is the race"
|
||||
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
dub_pipeline.put_and_save_job("job2", {"filename": "b.mp4"})
|
||||
assert saved == ["job2"]
|
||||
assert dub_pipeline._dub_jobs["job2"]["filename"] == "b.mp4"
|
||||
|
||||
|
||||
def test_clear_history_also_evicts_in_memory_jobs(monkeypatch):
|
||||
"""`DELETE /dub/history` deleted every row but evicted nothing from memory,
|
||||
so an in-flight job survived "clear history" outright and re-saved itself
|
||||
on completion."""
|
||||
import inspect
|
||||
|
||||
from api.routers import dub_core
|
||||
|
||||
src = inspect.getsource(dub_core.clear_dub_history)
|
||||
assert "purge_jobs" in src, "clear-all must evict memory too, not just rows"
|
||||
|
||||
|
||||
def test_the_ingest_pipeline_persists_atomically():
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(dub_pipeline.ingest_pipeline)
|
||||
assert "merge_and_save_job(" in src
|
||||
# The two-step form is what the race lived in.
|
||||
assert "save_job(job_id, get_job(" not in src
|
||||
|
||||
|
||||
# ── withdrawal survives a job's FIRST write ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_tombstones():
|
||||
dub_pipeline._inflight_jobs.clear()
|
||||
dub_pipeline._withdrawn_jobs.clear()
|
||||
yield
|
||||
dub_pipeline._inflight_jobs.clear()
|
||||
dub_pipeline._withdrawn_jobs.clear()
|
||||
|
||||
|
||||
def test_clearing_history_mid_ingest_is_not_undone_by_the_next_checkpoint(monkeypatch):
|
||||
"""Review finding (#1252): dict membership can't express "withdrawn".
|
||||
|
||||
An ingest's FIRST persistence CREATES the entry, so an absent key means
|
||||
"not written yet" for a new job and "deleted" for an established one — two
|
||||
opposite instructions from one signal. A clear-history landing before that
|
||||
first checkpoint was therefore silently undone by the checkpoint recreating
|
||||
the row, and the run went on to persist its result into history the user
|
||||
had just cleared.
|
||||
"""
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
# Clear history BEFORE the job has ever been written. It appears in no row,
|
||||
# so `job_ids` is empty — only the in-flight sweep can catch it.
|
||||
dub_pipeline.purge_jobs([], delete_rows=lambda: None, include_inflight=True)
|
||||
|
||||
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is False
|
||||
assert saved == [], "the checkpoint must not recreate a cleared job"
|
||||
# ...and the terminal write stays refused too.
|
||||
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": []}) is False
|
||||
|
||||
|
||||
def test_a_single_delete_also_withdraws_an_inflight_job(monkeypatch):
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"})
|
||||
saved.clear()
|
||||
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
|
||||
|
||||
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is False
|
||||
assert saved == []
|
||||
|
||||
|
||||
def test_a_normal_ingest_is_unaffected(monkeypatch):
|
||||
"""The gate must be invisible when nobody deletes anything — this runs on
|
||||
every import."""
|
||||
saved = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "save_job", lambda job_id, job, *a, **kw: saved.append(job_id),
|
||||
)
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
|
||||
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
|
||||
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
|
||||
assert saved == ["job1", "job1"]
|
||||
|
||||
|
||||
def test_a_delete_during_a_RENDER_is_honoured(monkeypatch):
|
||||
"""The case Greptile actually reported, and the one an earlier version of
|
||||
this fix did not close.
|
||||
|
||||
A dub is imported ONCE and rendered many times, so the realistic delete
|
||||
lands during a render — long after its ingest ended. Scoping the tombstone
|
||||
to in-flight ingests looked right and protected almost nothing: the render's
|
||||
own `save_job` wrote the row straight back.
|
||||
"""
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
|
||||
# An ordinary saved dub whose import finished long ago.
|
||||
dub_pipeline.begin_ingest("old1")
|
||||
dub_pipeline.put_and_save_job("old1", {"filename": "a.mp4"})
|
||||
dub_pipeline.end_ingest("old1")
|
||||
written.clear()
|
||||
|
||||
# Deleted from history while a render is still running.
|
||||
dub_pipeline.purge_jobs(["old1"], delete_rows=lambda: None)
|
||||
# The render completes and persists, exactly as dub_generate.py does.
|
||||
dub_pipeline.save_job("old1", {"filename": "a.mp4", "dubbed_tracks": {"en": {}}})
|
||||
|
||||
assert written == [], "a render finishing after the delete must not revive it"
|
||||
|
||||
|
||||
def test_the_ingest_ending_is_not_an_un_delete(monkeypatch):
|
||||
"""`end_ingest` used to clear the tombstone, which is what opened the gap
|
||||
above — the ingest finishing is not the user un-deleting anything."""
|
||||
monkeypatch.setattr(dub_pipeline, "_persist_job", lambda *a, **kw: None)
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
|
||||
|
||||
dub_pipeline.end_ingest("job1")
|
||||
|
||||
assert "job1" in dub_pipeline._withdrawn_jobs
|
||||
assert dub_pipeline._inflight_jobs == set()
|
||||
|
||||
|
||||
def test_re_importing_the_same_id_revives_it(monkeypatch):
|
||||
"""The one thing that legitimately un-deletes a job."""
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
|
||||
assert dub_pipeline.save_job("job1", {"filename": "a.mp4"}) is None
|
||||
assert written == []
|
||||
|
||||
dub_pipeline.begin_ingest("job1") # a deliberate re-import
|
||||
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
|
||||
assert written == ["job1"]
|
||||
|
||||
|
||||
def test_clearing_a_large_history_mid_render_does_not_evict_the_running_job(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Review finding (#1252, Greptile P1): a count-bounded LRU is evictable by
|
||||
ordinary use.
|
||||
|
||||
`DELETE /dub/history` selects every row with no limit, so a user with a
|
||||
large history clearing it while a render is running would push that very
|
||||
job's marker out — and the render would then write it straight back. Age is
|
||||
the honest policy: what matters is how long ago the delete happened, not how
|
||||
many others happened to follow it.
|
||||
"""
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
|
||||
# One dub is mid-render; the user clears a history far larger than any
|
||||
# count cap would keep.
|
||||
# Deliberately larger than the size cap, so the cap is forced to choose —
|
||||
# which is exactly the situation that evicted a live marker before.
|
||||
everything = ["rendering"] + [
|
||||
f"old{i}" for i in range(dub_pipeline._WITHDRAWN_MAX + 500)
|
||||
]
|
||||
dub_pipeline.purge_jobs(everything, delete_rows=lambda: None)
|
||||
|
||||
dub_pipeline.save_job("rendering", {"filename": "a.mp4"})
|
||||
assert written == [], "the running job's withdrawal must survive the sweep"
|
||||
|
||||
|
||||
def test_markers_expire_by_age(monkeypatch):
|
||||
"""They are held for the life of the process otherwise, so something has to
|
||||
let them go — but it must be time, not volume."""
|
||||
import time as _time
|
||||
|
||||
base = _time.monotonic()
|
||||
monkeypatch.setattr(dub_pipeline.time, "monotonic", lambda: base)
|
||||
dub_pipeline.purge_jobs(["old"], delete_rows=lambda: None)
|
||||
assert "old" in dub_pipeline._withdrawn_jobs
|
||||
|
||||
# Well past the TTL, a later delete sweeps the stale one out.
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline.time, "monotonic",
|
||||
lambda: base + dub_pipeline._WITHDRAWN_TTL_S + 1,
|
||||
)
|
||||
dub_pipeline.purge_jobs(["fresh"], delete_rows=lambda: None)
|
||||
|
||||
assert "old" not in dub_pipeline._withdrawn_jobs
|
||||
assert "fresh" in dub_pipeline._withdrawn_jobs
|
||||
|
||||
|
||||
def test_the_marker_map_cannot_grow_without_bound():
|
||||
"""The count cap is a memory backstop, not the policy."""
|
||||
for i in range(dub_pipeline._WITHDRAWN_MAX + 100):
|
||||
dub_pipeline.purge_jobs([f"job{i}"], delete_rows=lambda: None)
|
||||
|
||||
assert len(dub_pipeline._withdrawn_jobs) <= dub_pipeline._WITHDRAWN_MAX
|
||||
|
||||
|
||||
def test_the_pipeline_registers_and_releases_its_run():
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(dub_pipeline.ingest_pipeline)
|
||||
assert "begin_ingest(job_id)" in src
|
||||
assert "end_ingest(job_id)" in src, "a leaked tombstone would block a later run"
|
||||
# Released in `finally`, so a crash or cancel can't leak it.
|
||||
finally_block = src[src.rindex("finally:"):]
|
||||
assert "end_ingest(job_id)" in finally_block
|
||||
|
||||
|
||||
def test_clear_history_sweeps_inflight_jobs():
|
||||
import inspect
|
||||
|
||||
from api.routers import dub_core
|
||||
|
||||
src = inspect.getsource(dub_core.clear_dub_history)
|
||||
assert "include_inflight=True" in src, (
|
||||
"an ingest with no row yet appears in no id list — only the in-flight "
|
||||
"sweep can clear it"
|
||||
)
|
||||
|
||||
|
||||
# ── the gate is at the choke point, not in the callers ───────────────────
|
||||
|
||||
|
||||
def test_every_direct_save_path_honours_a_withdrawal(monkeypatch):
|
||||
"""Review finding (#1252, Greptile P1): gating only the ingest helpers left
|
||||
eight direct `save_job` call sites — across dub generate, translate, export
|
||||
and core — able to resurrect a dub the user deleted *mid-render*. Deleting
|
||||
during generation is at least as likely as deleting during import.
|
||||
|
||||
The gate now lives in `save_job` itself, so every caller inherits it and
|
||||
the ninth one cannot forget."""
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
dub_pipeline.purge_jobs(["job1"], delete_rows=lambda: None)
|
||||
|
||||
# The shape every router uses: a bare save_job, no lock, no helper.
|
||||
dub_pipeline.save_job("job1", {"filename": "a.mp4"})
|
||||
|
||||
assert written == [], "a post-ingest save must not resurrect a deleted dub"
|
||||
|
||||
|
||||
def test_a_normal_save_still_writes(monkeypatch):
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
dub_pipeline.save_job("job1", {"filename": "a.mp4"})
|
||||
assert written == ["job1"]
|
||||
|
||||
|
||||
def test_the_lock_is_reentrant():
|
||||
"""`save_job` acquires the lock, and the atomic helpers call it while
|
||||
already holding it — a plain Lock would deadlock the backend here."""
|
||||
import threading
|
||||
|
||||
assert isinstance(dub_pipeline._dub_jobs_lock, type(threading.RLock()))
|
||||
|
||||
|
||||
def test_the_atomic_helpers_still_work_through_the_reentrant_path(monkeypatch):
|
||||
"""Guards the deadlock directly: if this hangs, the RLock regressed."""
|
||||
written = []
|
||||
monkeypatch.setattr(
|
||||
dub_pipeline, "_persist_job", lambda *a, **kw: written.append(a[0]),
|
||||
)
|
||||
dub_pipeline.begin_ingest("job1")
|
||||
assert dub_pipeline.put_and_save_job("job1", {"filename": "a.mp4"}) is True
|
||||
assert dub_pipeline.merge_and_save_job("job1", {"scene_cuts": [1.0]}) is True
|
||||
assert written == ["job1", "job1"]
|
||||
|
||||
|
||||
def test_eviction_order_does_not_depend_on_hash_seed():
|
||||
"""A `set` of target ids made eviction order depend on PYTHONHASHSEED — so
|
||||
which markers survived a cap-forced trim was luck. The test for the
|
||||
behaviour above passed locally and reddened main for that reason alone.
|
||||
|
||||
Same input, same surviving markers, every time.
|
||||
"""
|
||||
ids = [f"j{i}" for i in range(50)]
|
||||
|
||||
dub_pipeline._withdrawn_jobs.clear()
|
||||
dub_pipeline.purge_jobs(ids, delete_rows=lambda: None)
|
||||
first = list(dub_pipeline._withdrawn_jobs)
|
||||
|
||||
dub_pipeline._withdrawn_jobs.clear()
|
||||
dub_pipeline.purge_jobs(ids, delete_rows=lambda: None)
|
||||
assert list(dub_pipeline._withdrawn_jobs) == first == ids
|
||||
|
||||
|
||||
def test_a_purge_larger_than_the_cap_keeps_all_of_its_own_markers():
|
||||
"""The cap must never discard a marker the current purge just recorded:
|
||||
those are the newest and the likeliest to still be held. The bound is
|
||||
therefore `cap + one purge`, which is the honest guarantee."""
|
||||
dub_pipeline._withdrawn_jobs.clear()
|
||||
oversized = [f"x{i}" for i in range(dub_pipeline._WITHDRAWN_MAX + 750)]
|
||||
|
||||
dub_pipeline.purge_jobs(oversized, delete_rows=lambda: None)
|
||||
|
||||
assert len(dub_pipeline._withdrawn_jobs) == len(oversized)
|
||||
for job_id in oversized:
|
||||
assert job_id in dub_pipeline._withdrawn_jobs
|
||||
@@ -195,7 +195,14 @@ class TestSubtitleSaveResponseShape:
|
||||
assert res.status_code == 200
|
||||
assert res.text.startswith("1\n")
|
||||
disposition = res.headers.get("content-disposition", "")
|
||||
assert disposition.endswith('.srt"')
|
||||
# Asserted on content, not on position: since #1262 the header also
|
||||
# carries an RFC 5987 `filename*=` so non-latin-1 names don't 500, and
|
||||
# that parameter comes last. Both parameters are asserted — checking
|
||||
# only `filename=` would pass against the pre-#1262 header too.
|
||||
assert disposition.startswith("attachment;")
|
||||
assert '.srt"' in disposition
|
||||
assert "filename*=UTF-8''" in disposition
|
||||
assert disposition.endswith(".srt")
|
||||
|
||||
def test_plain_srt_get_still_returns_text_body(self, client, translated_job):
|
||||
job_id, _ = translated_job
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""#1257: `400 Bad Request: Invalid language code. Supported languages: ar
|
||||
(Arabic), da (Danish), de (German), …`
|
||||
|
||||
The reporter was on mlx-audio and got a bare recitation of 23 language codes.
|
||||
Nothing in it says which engine is refusing, that OmniVoice offers 646
|
||||
languages because a *different* engine supports them, or that switching engines
|
||||
is the fix. Three generate attempts in their action log, all identical.
|
||||
|
||||
The cause is stated outright in `MLXAudioBackend.supported_languages`:
|
||||
|
||||
# Per-model; Kokoro supports 8, Qwen3 ~4, Kugel 24. Return "multi"
|
||||
# so the language picker doesn't gate by engine — each engine
|
||||
# silently ignores languages it doesn't know.
|
||||
|
||||
The last clause is not true. The engine's library raises, so the picker offers
|
||||
languages the active engine will reject, and the rejection arrives with no
|
||||
context. Enumerating every model's real language list would be a brittle map
|
||||
that goes stale on each engine update; naming the engine and the way out is
|
||||
both accurate and durable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from api.routers.generation import _language_rejection_or
|
||||
|
||||
|
||||
class _Engine:
|
||||
id = "mlx-audio"
|
||||
display_name = "MLX Audio"
|
||||
|
||||
|
||||
REPORTED = (
|
||||
"Invalid language code. Supported languages: ar (Arabic), da (Danish), "
|
||||
"de (German), el (Greek), en (English), es (Spanish)"
|
||||
)
|
||||
|
||||
|
||||
def test_the_reported_error_names_the_engine_and_the_way_out():
|
||||
rewritten = _language_rejection_or(ValueError(REPORTED), _Engine(), "bn")
|
||||
|
||||
assert isinstance(rewritten, ValueError)
|
||||
message = str(rewritten)
|
||||
assert "MLX Audio" in message, "the user must learn WHICH engine refused"
|
||||
assert "'bn'" in message, "...and which language it refused"
|
||||
assert "Settings → Engines" in message, "...and where to fix it"
|
||||
# The engine's own list is still useful — keep it rather than hide it.
|
||||
assert "ar (Arabic)" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
[
|
||||
"Invalid language code. Supported languages: en, es",
|
||||
"Unsupported language: bn",
|
||||
"RuntimeError: language not supported by this checkpoint",
|
||||
"ValueError: This language is not supported",
|
||||
],
|
||||
)
|
||||
def test_every_wording_of_a_language_rejection_is_caught(reason):
|
||||
"""Each engine multiplexes a different third-party library, so the class
|
||||
and the wording both vary — match on meaning."""
|
||||
rewritten = _language_rejection_or(RuntimeError(reason), _Engine(), "bn")
|
||||
assert "Settings → Engines" in str(rewritten)
|
||||
|
||||
|
||||
def test_a_language_rejection_becomes_a_ValueError():
|
||||
"""The route maps ValueError → 400 and everything else → 500. A user
|
||||
picking an unsupported language is a validation problem, not a crash."""
|
||||
rewritten = _language_rejection_or(RuntimeError(REPORTED), _Engine(), "bn")
|
||||
assert isinstance(rewritten, ValueError)
|
||||
|
||||
|
||||
def test_an_unrelated_failure_is_returned_untouched():
|
||||
"""This wraps every exception on the path, so it must be inert for the
|
||||
rest — an OOM rewritten as a language problem would be far worse than the
|
||||
bug being fixed."""
|
||||
oom = RuntimeError("CUDA out of memory. Tried to allocate 2.00 GiB")
|
||||
assert _language_rejection_or(oom, _Engine(), "en") is oom
|
||||
|
||||
disk = OSError("[Errno 28] No space left on device")
|
||||
assert _language_rejection_or(disk, _Engine(), "en") is disk
|
||||
|
||||
|
||||
def _run(backend, exc, language="bn"):
|
||||
"""Drive the real `_run_backend_inference` with a backend that raises."""
|
||||
from api.routers.generation import _run_backend_inference
|
||||
|
||||
class _Raiser:
|
||||
id = getattr(backend, "id", "x")
|
||||
display_name = getattr(backend, "display_name", None)
|
||||
sample_rate = 24000
|
||||
applies_own_mastering = True
|
||||
|
||||
def generate(self, *a, **kw):
|
||||
raise exc
|
||||
|
||||
return _run_backend_inference(
|
||||
_Raiser(), "hello", language, None, None, None, None,
|
||||
None, None, 1.0, False, False, None,
|
||||
)
|
||||
|
||||
|
||||
def test_the_wiring_rewrites_a_language_rejection_end_to_end():
|
||||
"""Exercised through the real call path, not by reading its source: an
|
||||
assertion on source text passes even when the call is unreachable or its
|
||||
result discarded (#1224 taught this lesson on this same codebase)."""
|
||||
with pytest.raises(ValueError) as caught:
|
||||
_run(_Engine(), RuntimeError(REPORTED))
|
||||
|
||||
assert "Settings → Engines" in str(caught.value)
|
||||
assert "MLX Audio" in str(caught.value)
|
||||
|
||||
|
||||
def test_an_oom_still_reaches_the_oom_path():
|
||||
"""The rewrite wraps every failure on the path, so the OOM handling that
|
||||
was already there must survive it."""
|
||||
with pytest.raises(Exception) as caught:
|
||||
_run(_Engine(), RuntimeError("CUDA out of memory. Tried to allocate 2.00 GiB"))
|
||||
|
||||
message = str(caught.value)
|
||||
assert "Settings → Engines" not in message, "an OOM is not a language problem"
|
||||
# _oom_friendly_reraise rewrites it into its own guidance.
|
||||
assert "memory" in message.lower()
|
||||
|
||||
|
||||
def test_a_nameless_backend_still_names_itself():
|
||||
"""`or "engine" in message` would always pass — the production template
|
||||
contains the word — so it proved nothing about the fallback (#1257
|
||||
review). Assert the class name the fallback actually resolves to."""
|
||||
class _Bare:
|
||||
pass
|
||||
|
||||
message = str(_language_rejection_or(ValueError(REPORTED), _Bare(), None))
|
||||
assert "_Bare" in message
|
||||
assert "Settings → Engines" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
[
|
||||
"Unsupported language model configuration: gpt2-medium",
|
||||
"unsupported language modeling head",
|
||||
],
|
||||
)
|
||||
def test_a_model_failure_that_merely_says_unsupported_language_is_untouched(reason):
|
||||
"""Review finding (#1257): a bare "unsupported language" prefix also
|
||||
matches "Unsupported language model ...", which is a model/config problem
|
||||
that would then be handed engine-switch advice it has no use for."""
|
||||
exc = RuntimeError(reason)
|
||||
assert _language_rejection_or(exc, _Engine(), "en") is exc
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
["Unsupported language: bn", "Unsupported language 'bn'", "Unsupported language"],
|
||||
)
|
||||
def test_a_genuine_unsupported_language_still_matches(reason):
|
||||
assert "Settings → Engines" in str(
|
||||
_language_rejection_or(RuntimeError(reason), _Engine(), "bn")
|
||||
)
|
||||
@@ -504,3 +504,45 @@ def test_classify_repair_messages():
|
||||
def test_classify_unrelated_errors_not_cache_corrupt():
|
||||
assert failure.classify("disk full") != "MODEL_CACHE_CORRUPT"
|
||||
assert failure.classify("") != "MODEL_CACHE_CORRUPT"
|
||||
|
||||
|
||||
# ── the second transformers wording (#1273) ────────────────────────────────
|
||||
#
|
||||
# transformers has two unrelated ways of saying "this snapshot has no weight
|
||||
# shard". The hub-load wording is _SIGNATURE above; loading a *subfolder* of
|
||||
# an already-cached snapshot raises this one instead, with no words in common.
|
||||
# Matching only the first meant a half-written repo produced neither the
|
||||
# automatic repair nor an actionable message — just a raw 500 (#1273, on a
|
||||
# host with 10 GB free, i.e. a download that ran out of room).
|
||||
_SIGNATURE_LOCAL_DIR = (
|
||||
"Error no file named model.safetensors, or pytorch_model.bin, found in "
|
||||
r"directory C:\Users\u\AppData\Local\OmniVoice\hf_cache\models--k2-fsa--"
|
||||
r"OmniVoice\snapshots\c5fdb5ccb189668d56333f77ba2629f4cd7535f4\audio_tokenizer."
|
||||
)
|
||||
|
||||
|
||||
def test_classify_local_directory_missing_weights_signature():
|
||||
assert failure.classify(_SIGNATURE_LOCAL_DIR) == "MODEL_CACHE_CORRUPT"
|
||||
evt = failure.build_failure(_SIGNATURE_LOCAL_DIR, stage="model-load",
|
||||
include_diagnostic=False)
|
||||
assert evt["docs_topic"] == "MODEL_CACHE_CORRUPT"
|
||||
assert "repairs this automatically" in evt["hint"]
|
||||
|
||||
|
||||
def test_self_heal_recognises_both_wordings():
|
||||
"""The repair ladder keys off the same predicate as the classifier — if it
|
||||
misses a wording, the user gets a hint about an automatic repair that
|
||||
never ran."""
|
||||
from services import model_manager as mm
|
||||
|
||||
assert mm._is_incomplete_cache_error(OSError(_SIGNATURE))
|
||||
assert mm._is_incomplete_cache_error(OSError(_SIGNATURE_LOCAL_DIR))
|
||||
|
||||
|
||||
def test_incomplete_cache_predicate_does_not_overmatch():
|
||||
# "found in directory" and "no file named" are common English; both
|
||||
# fragments together are what identifies the class.
|
||||
assert not failure.is_incomplete_cache_message("no file named foo.wav")
|
||||
assert not failure.is_incomplete_cache_message("nothing found in directory /tmp")
|
||||
assert not failure.is_incomplete_cache_message("")
|
||||
assert failure.classify("no file named foo.wav") != "MODEL_CACHE_CORRUPT"
|
||||
|
||||
@@ -103,3 +103,17 @@ def test_decode_ref_audio_rejects_garbage_without_raising():
|
||||
def test_sniff_audio_ext_matches_magic_bytes(raw, ext):
|
||||
from mcp_server import _sniff_audio_ext
|
||||
assert _sniff_audio_ext(raw) == ext
|
||||
|
||||
|
||||
def test_mcp_allowed_hosts_env_extends_allowlist(monkeypatch):
|
||||
"""OMNIVOICE_MCP_ALLOWED_HOSTS must extend the transport-security allowlist."""
|
||||
from mcp_server import create_mcp_server
|
||||
|
||||
monkeypatch.setenv("OMNIVOICE_MCP_ALLOWED_HOSTS", "host.containers.internal:*,10.0.0.1:*")
|
||||
server = create_mcp_server()
|
||||
allowed = server.settings.transport_security.allowed_hosts
|
||||
assert "host.containers.internal:*" in allowed
|
||||
assert "10.0.0.1:*" in allowed
|
||||
origins = server.settings.transport_security.allowed_origins
|
||||
assert "http://host.containers.internal:*" in origins
|
||||
assert "https://host.containers.internal:*" in origins
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""#1256: a synth failed with a raw `FileNotFoundError: … 'ffprobe'`.
|
||||
|
||||
The reporter's toast, on a Mac with the app's own ffprobe sitting on disk:
|
||||
|
||||
Couldn't synthesize audio. … Underlying error: RuntimeError: TTS engine
|
||||
stopped mid-generation with an error OmniVoice doesn't recognize. Retry
|
||||
once; if it keeps failing, please report it with the full trace.
|
||||
Underlying error: FileNotFoundError: [Errno 2] No such file or directory:
|
||||
'ffprobe'
|
||||
|
||||
Two separate defects, fixed here as two halves:
|
||||
|
||||
1. **It happened at all.** Every OmniVoice call site resolves ffmpeg/ffprobe
|
||||
explicitly (`find_ffprobe()`), which is why a bundled sidecar that was never
|
||||
on ``PATH`` works for us. Our dependencies get no such courtesy — one that
|
||||
shells out to ``ffprobe`` by bare name finds nothing. Publishing the
|
||||
resolved directories on ``PATH`` fixes every such dependency at once.
|
||||
|
||||
2. **It was illegible.** "an error OmniVoice doesn't recognize" is what the
|
||||
generic engine wrapper says when `classify()` returns "" — and it did,
|
||||
because nothing matched a missing media binary. It now names the class and
|
||||
points at Settings → Audio tools.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from core.failure import build_failure, classify
|
||||
from services import ffmpeg_utils
|
||||
|
||||
|
||||
# ── the failure is now classified ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_reporters_error_is_classified():
|
||||
assert classify("[Errno 2] No such file or directory: 'ffprobe'") == "MEDIA_TOOL_MISSING"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
[
|
||||
# As it arrives wrapped by the engine's generic handler.
|
||||
"FileNotFoundError: [Errno 2] No such file or directory: 'ffprobe'",
|
||||
"[Errno 2] No such file or directory: 'ffmpeg'",
|
||||
# subprocess on Windows words it differently.
|
||||
"[WinError 2] The system cannot find the file specified: 'ffmpeg'",
|
||||
'[Errno 2] No such file or directory: "ffprobe"',
|
||||
],
|
||||
)
|
||||
def test_every_wording_of_the_missing_binary_is_classified(reason):
|
||||
assert classify(reason) == "MEDIA_TOOL_MISSING"
|
||||
|
||||
|
||||
def test_the_hint_names_the_repair_and_is_actionable():
|
||||
failure = build_failure(
|
||||
FileNotFoundError(2, "No such file or directory", "ffprobe"),
|
||||
stage="generate",
|
||||
)
|
||||
assert failure["error_class"] == "FileNotFoundError"
|
||||
hint = failure.get("hint") or ""
|
||||
assert "Audio tools" in hint, "the user needs the panel that fixes it"
|
||||
assert "ffmpeg" in hint.lower()
|
||||
|
||||
|
||||
def test_a_missing_INPUT_file_is_not_handed_the_media_engine_remedy():
|
||||
"""The narrow half of the match. ffmpeg reporting that its *input* is
|
||||
missing is an entirely different problem — telling that user to reinstall
|
||||
the media engine sends them the wrong way."""
|
||||
assert classify(
|
||||
"ffmpeg failed: [Errno 2] No such file or directory: '/tmp/chunk_7.wav'"
|
||||
) != "MEDIA_TOOL_MISSING"
|
||||
assert classify(
|
||||
"No such file or directory: '/Users/x/Movies/my-ffmpeg-export.mp4'"
|
||||
) != "MEDIA_TOOL_MISSING"
|
||||
|
||||
|
||||
def test_unrelated_errno_2_failures_keep_their_own_class():
|
||||
"""The rule runs before the generic errno-2 handling, so it must not
|
||||
swallow the classes that were already correct."""
|
||||
assert classify("No module named 'omnivoice'") == "BROKEN_VENV"
|
||||
assert classify(
|
||||
"does not appear to have a file named model.safetensors"
|
||||
) == "MODEL_CACHE_CORRUPT"
|
||||
|
||||
|
||||
# ── and it is prevented in the first place ───────────────────────────────
|
||||
|
||||
|
||||
def test_the_resolved_binaries_are_published_on_PATH(monkeypatch, tmp_path):
|
||||
"""The half that stops the failure happening: a dependency that shells out
|
||||
by bare name must find what `find_ffprobe()` already resolved."""
|
||||
bin_dir = tmp_path / "media"
|
||||
bin_dir.mkdir()
|
||||
ffmpeg = bin_dir / "ffmpeg"
|
||||
ffprobe = bin_dir / "ffprobe"
|
||||
ffmpeg.write_text("")
|
||||
ffprobe.write_text("")
|
||||
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", lambda: str(ffmpeg))
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", lambda: str(ffprobe))
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
|
||||
added = ffmpeg_utils.ensure_media_tools_on_path()
|
||||
|
||||
assert added == [str(bin_dir)]
|
||||
assert os.environ["PATH"].split(os.pathsep)[0] == str(bin_dir), (
|
||||
"must be prepended — the copy we validated should win over a broken "
|
||||
"system one"
|
||||
)
|
||||
|
||||
|
||||
def test_publishing_is_idempotent(monkeypatch, tmp_path):
|
||||
"""It runs at import time; a reload or a second call must not grow PATH
|
||||
without bound."""
|
||||
bin_dir = tmp_path / "media"
|
||||
bin_dir.mkdir()
|
||||
(bin_dir / "ffmpeg").write_text("")
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", lambda: str(bin_dir / "ffmpeg"))
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", lambda: None)
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
|
||||
ffmpeg_utils.ensure_media_tools_on_path()
|
||||
first = os.environ["PATH"]
|
||||
assert ffmpeg_utils.ensure_media_tools_on_path() == []
|
||||
assert os.environ["PATH"] == first
|
||||
|
||||
|
||||
def test_separate_directories_are_both_added(monkeypatch, tmp_path):
|
||||
"""A system ffmpeg plus a bundled ffprobe is a real configuration — the
|
||||
resolver already supports it, so PATH must reflect both."""
|
||||
a, b = tmp_path / "a", tmp_path / "b"
|
||||
a.mkdir()
|
||||
b.mkdir()
|
||||
(a / "ffmpeg").write_text("")
|
||||
(b / "ffprobe").write_text("")
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", lambda: str(a / "ffmpeg"))
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", lambda: str(b / "ffprobe"))
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
|
||||
assert sorted(ffmpeg_utils.ensure_media_tools_on_path()) == sorted([str(a), str(b)])
|
||||
|
||||
|
||||
def test_nothing_resolvable_is_a_no_op(monkeypatch):
|
||||
"""A machine with no media engine at all must still boot — the classified
|
||||
error above is what that user gets, not a startup crash."""
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", lambda: None)
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", lambda: None)
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
|
||||
assert ffmpeg_utils.ensure_media_tools_on_path() == []
|
||||
assert os.environ["PATH"] == "/usr/bin"
|
||||
|
||||
|
||||
def test_a_raising_resolver_never_breaks_startup(monkeypatch):
|
||||
def boom():
|
||||
raise RuntimeError("media_tools registry unreadable")
|
||||
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", boom)
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", boom)
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
|
||||
assert ffmpeg_utils.ensure_media_tools_on_path() == []
|
||||
|
||||
|
||||
def test_a_path_that_merely_ends_in_the_tool_name_is_not_a_missing_tool(tmp_path):
|
||||
"""Review finding (#1256): the match accepted any message ending in
|
||||
'ffmpeg'/'ffprobe', so a missing FILE whose name happens to be the tool's
|
||||
was handed the "repair your media engine" remedy.
|
||||
|
||||
Paths are built from `tmp_path` rather than written as literals — they are
|
||||
only error-message data here, but a hardcoded /tmp trips Ruff S108."""
|
||||
paths = [
|
||||
str(tmp_path / "ffmpeg"),
|
||||
str(tmp_path / "sub" / "ffprobe"),
|
||||
"C:\\work\\ffmpeg",
|
||||
]
|
||||
for path in paths:
|
||||
assert classify(f"[Errno 2] No such file or directory: {path}") != (
|
||||
"MEDIA_TOOL_MISSING"
|
||||
), path
|
||||
|
||||
|
||||
def test_the_published_paths_are_not_logged(monkeypatch, tmp_path, caplog):
|
||||
"""Review finding (#1256): a user-set FFMPEG_PATH resolves under their home
|
||||
directory, and absolute home paths must not reach the log."""
|
||||
import logging
|
||||
|
||||
bin_dir = tmp_path / "Users" / "alice" / "media"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "ffmpeg").write_text("")
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffmpeg", lambda: str(bin_dir / "ffmpeg"))
|
||||
monkeypatch.setattr(ffmpeg_utils, "find_ffprobe", lambda: None)
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="omnivoice.api"):
|
||||
ffmpeg_utils.ensure_media_tools_on_path()
|
||||
|
||||
assert str(bin_dir) not in caplog.text
|
||||
assert "alice" not in caplog.text
|
||||
@@ -56,6 +56,7 @@ _ALLOWED_FILES = {
|
||||
"backend/services/segmentation.py",
|
||||
"backend/services/sentence_chunker.py", # streaming-TTS terminator tables (Patter port, Wave 1.4)
|
||||
"backend/services/subtitle_segmenter.py",
|
||||
"backend/core/http_headers.py", # docstring quotes the CJK filename that 500'd the header (#1262)
|
||||
"frontend/src/components/DubSegmentRow.jsx",
|
||||
"frontend/src/components/StoriesEditor.jsx",
|
||||
"frontend/src/utils/voiceInstruct.js",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""#1223: a port conflict must exit with a code the shell can recognise.
|
||||
|
||||
The reporter's backend died with `[Errno 10048] error while attempting to bind
|
||||
on address ('127.0.0.1', 3900)` — port already taken, almost certainly by an
|
||||
orphan from a previous session. uvicorn re-raised the bare OSError, Python
|
||||
exited 1, and the desktop shell reported "Backend died (exit code 1)" with no
|
||||
cause: the Windows wording is OS-translated (the report was in Russian), so no
|
||||
English phrase in the log could be matched.
|
||||
|
||||
The fix is to make the signal locale-independent — a dedicated exit code that
|
||||
`frontend/src-tauri/src/backend.rs` and `frontend/src/utils/backendCrash.ts`
|
||||
both key off. This test pins the code and its cross-language agreement; the
|
||||
matcher side is pinned in frontend/src/test/portInUseHint.test.js.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_EXPECTED_EXIT = 78 # EX_CONFIG
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(_REPO, *parts), encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def test_backend_declares_the_exit_code():
|
||||
src = _read("backend", "main.py")
|
||||
assert f"_EXIT_PORT_IN_USE = {_EXPECTED_EXIT}" in src
|
||||
|
||||
|
||||
def test_rust_shell_agrees_on_the_exit_code():
|
||||
"""The Rust side reads this code to distinguish a conflict from a crash —
|
||||
a silent divergence would restore the unexplained "exit code 1"."""
|
||||
src = _read("frontend", "src-tauri", "src", "backend.rs")
|
||||
match = re.search(r"pub const EXIT_PORT_IN_USE: i32 = (\d+);", src)
|
||||
assert match, "EXIT_PORT_IN_USE missing from backend.rs"
|
||||
assert int(match.group(1)) == _EXPECTED_EXIT
|
||||
|
||||
|
||||
def test_frontend_crash_hint_agrees_on_the_exit_code():
|
||||
src = _read("frontend", "src", "utils", "backendCrash.ts")
|
||||
assert f"marker.exit_code === {_EXPECTED_EXIT}" in src
|
||||
|
||||
|
||||
@pytest.mark.parametrize("errno", [48, 98, 10048])
|
||||
def test_every_platforms_eaddrinuse_is_recognised(errno):
|
||||
"""EADDRINUSE is 48 on macOS/BSD, 98 on Linux, 10048 on Windows. Matching
|
||||
the errno rather than the message is the whole point — the message is
|
||||
translated by the OS."""
|
||||
src = _read("backend", "main.py")
|
||||
match = re.search(r"errno in \(([\d, ]+)\)", src)
|
||||
assert match, "errno guard missing from main.py"
|
||||
assert str(errno) in {p.strip() for p in match.group(1).split(",")}
|
||||
|
||||
|
||||
def test_uvicorn_swallows_the_bind_error_into_systemexit(tmp_path):
|
||||
"""The assumption the first version of this fix got wrong.
|
||||
|
||||
`except OSError` around `uvicorn.run()` looks obviously right and is
|
||||
inert: uvicorn catches the bind failure inside its own startup, logs the
|
||||
raw errno, and raises `SystemExit(1)`. Nothing propagates. This test
|
||||
documents that behaviour against the real installed uvicorn, so a future
|
||||
refactor back to the "obvious" shape fails here instead of silently
|
||||
restoring "Backend died (exit code 1)".
|
||||
"""
|
||||
holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
holder.bind(("127.0.0.1", 0))
|
||||
holder.listen(1)
|
||||
port = holder.getsockname()[1]
|
||||
try:
|
||||
script = tmp_path / "naive.py"
|
||||
script.write_text(
|
||||
"import sys\n"
|
||||
"import uvicorn\n"
|
||||
"from fastapi import FastAPI\n"
|
||||
"try:\n"
|
||||
f" uvicorn.run(FastAPI(), host='127.0.0.1', port={port}, "
|
||||
"log_level='critical')\n"
|
||||
"except OSError:\n"
|
||||
" print('OSERROR', file=sys.stderr); sys.exit(78)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(script)], capture_output=True, text=True
|
||||
)
|
||||
assert "OSERROR" not in proc.stderr, (
|
||||
"uvicorn now propagates the bind OSError — the pre-probe in "
|
||||
"main.py can be simplified, but verify before doing so"
|
||||
)
|
||||
assert proc.returncode == 1
|
||||
finally:
|
||||
holder.close()
|
||||
|
||||
|
||||
def test_real_bind_conflict_exits_with_the_dedicated_code(tmp_path):
|
||||
"""End-to-end against the REAL uvicorn: hold a port, run main.py's guard
|
||||
shape against it, and confirm the process exits 78 with an actionable
|
||||
message — not uvicorn's bare exit 1.
|
||||
|
||||
Reproduces the guard rather than booting the whole backend (a real boot
|
||||
downloads models), but drives genuine `uvicorn.run` so the swallowed-
|
||||
SystemExit trap above cannot silently reappear.
|
||||
"""
|
||||
holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
holder.bind(("127.0.0.1", 0))
|
||||
holder.listen(1)
|
||||
port = holder.getsockname()[1]
|
||||
try:
|
||||
guard = _read("backend", "main.py")
|
||||
start = guard.index(" def _port_taken(")
|
||||
end = guard.index(" # #1223: uvicorn does NOT")
|
||||
body = "\n".join(line[4:] for line in guard[start:end].splitlines())
|
||||
|
||||
script = tmp_path / "guarded.py"
|
||||
script.write_text(
|
||||
"import socket, sys\n"
|
||||
"import uvicorn\n"
|
||||
"from fastapi import FastAPI\n"
|
||||
f"_EXIT_PORT_IN_USE = {_EXPECTED_EXIT}\n"
|
||||
f"_port = {port}\n"
|
||||
"app = FastAPI()\n"
|
||||
+ body
|
||||
+ "\n"
|
||||
"if (_e := _port_taken('127.0.0.1', _port)) is not None:\n"
|
||||
" _fail_port_in_use(_e)\n"
|
||||
"try:\n"
|
||||
" uvicorn.run(app, host='127.0.0.1', port=_port, log_level='critical')\n"
|
||||
"except SystemExit:\n"
|
||||
" if _port_taken('127.0.0.1', _port) is not None:\n"
|
||||
" _fail_port_in_use(None)\n"
|
||||
" raise\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(script)], capture_output=True, text=True
|
||||
)
|
||||
assert proc.returncode == _EXPECTED_EXIT, (
|
||||
f"expected exit {_EXPECTED_EXIT}, got {proc.returncode}\n{proc.stderr}"
|
||||
)
|
||||
assert "already in use" in proc.stderr
|
||||
finally:
|
||||
holder.close()
|
||||
|
||||
|
||||
def test_the_probe_does_not_false_positive_on_a_free_port(tmp_path):
|
||||
"""A free port must start normally. The probe uses uvicorn's own socket
|
||||
options (SO_REUSEADDR off Windows) precisely so a TIME_WAIT socket uvicorn
|
||||
could bind isn't reported as taken."""
|
||||
guard = _read("backend", "main.py")
|
||||
start = guard.index(" def _port_taken(")
|
||||
end = guard.index(" def _fail_port_in_use(")
|
||||
body = "\n".join(line[4:] for line in guard[start:end].splitlines())
|
||||
|
||||
free = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
free.bind(("127.0.0.1", 0))
|
||||
port = free.getsockname()[1]
|
||||
free.close() # now free (possibly TIME_WAIT)
|
||||
|
||||
script = tmp_path / "probe.py"
|
||||
script.write_text(
|
||||
"import socket, sys\n" + body + "\n"
|
||||
f"print('TAKEN' if _port_taken('127.0.0.1', {port}) is not None else 'FREE')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run([sys.executable, str(script)], capture_output=True, text=True)
|
||||
assert proc.stdout.strip() == "FREE", proc.stderr
|
||||
@@ -0,0 +1,340 @@
|
||||
"""#1224: a truncated model download aborted the install instead of retrying.
|
||||
|
||||
The reporter's log tail, captured just before the backend was SIGKILLed:
|
||||
|
||||
httpx.RemoteProtocolError: peer closed connection without sending complete
|
||||
message body (received 4084175097 bytes, expected 4580080592)
|
||||
|
||||
That is a 4.6 GB model dying at 4.0 GB — the single most retry-worthy failure
|
||||
in the whole download path, and it was retried nowhere:
|
||||
|
||||
* the installer's retry loop caught ``(HfHubHTTPError, LocalEntryNotFoundError,
|
||||
OSError)``. ``httpx.RemoteProtocolError`` inherits ``Exception``, NOT
|
||||
``OSError``, so it escaped all five attempts;
|
||||
* ``is_hf_connectivity_error`` — the single source of truth for "transient
|
||||
download failure" — had no truncation signature, so even a widened catch
|
||||
would have classified it as permanent;
|
||||
* the engine load path (``VoxCPM.from_pretrained``) had no retry at all.
|
||||
|
||||
The HF cache is resumable (correctly-sized blobs are skipped by hash), so a
|
||||
retry continues rather than restarting — which is what makes retrying correct
|
||||
here and not merely hopeful.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.failure import is_hf_connectivity_error
|
||||
from services import tts_backend
|
||||
|
||||
|
||||
# ── classification ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_reporters_error_is_recognised_as_transient():
|
||||
assert is_hf_connectivity_error(
|
||||
"peer closed connection without sending complete message body "
|
||||
"(received 4084175097 bytes, expected 4580080592)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
[
|
||||
# urllib3 / http.client wording for the same truncation.
|
||||
"IncompleteRead(4084175097 bytes read, 495905495 more expected)",
|
||||
"ProtocolError('Connection broken: IncompleteRead(…)')",
|
||||
"http.client.IncompleteRead: incomplete read",
|
||||
"Response ended prematurely",
|
||||
],
|
||||
)
|
||||
def test_other_truncation_wordings_are_recognised(reason):
|
||||
assert is_hf_connectivity_error(reason)
|
||||
|
||||
|
||||
def test_a_real_failure_is_still_permanent():
|
||||
"""Widening the net must not make genuine errors retry forever."""
|
||||
assert not is_hf_connectivity_error("401 Unauthorized: invalid token")
|
||||
assert not is_hf_connectivity_error("No such file or directory: config.json")
|
||||
assert not is_hf_connectivity_error("CUDA out of memory")
|
||||
|
||||
|
||||
# ── the engine load path retries ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_backoff(monkeypatch):
|
||||
monkeypatch.setenv("OMNIVOICE_MODEL_LOAD_BACKOFF_S", "0")
|
||||
|
||||
|
||||
def test_truncated_download_is_retried_and_succeeds(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def loader():
|
||||
calls.append(1)
|
||||
if len(calls) < 3:
|
||||
raise RuntimeError(
|
||||
"peer closed connection without sending complete message body"
|
||||
)
|
||||
return "model"
|
||||
|
||||
assert tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2") == "model"
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
def test_retries_are_bounded(monkeypatch):
|
||||
monkeypatch.setenv("OMNIVOICE_MODEL_LOAD_RETRIES", "2")
|
||||
calls = []
|
||||
|
||||
def loader():
|
||||
calls.append(1)
|
||||
raise RuntimeError("peer closed connection without sending complete message body")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2")
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_a_non_transient_failure_is_not_retried():
|
||||
calls = []
|
||||
|
||||
def loader():
|
||||
calls.append(1)
|
||||
raise ValueError("checkpoint has no config.json")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2")
|
||||
assert len(calls) == 1, "a permanent failure must fail fast, not retry"
|
||||
|
||||
|
||||
def test_the_closed_client_path_still_resets_the_session(monkeypatch):
|
||||
"""#880's behaviour must survive the widening."""
|
||||
reset = []
|
||||
import huggingface_hub.utils as hub_utils
|
||||
|
||||
monkeypatch.setattr(hub_utils, "close_session", lambda: reset.append(1), raising=False)
|
||||
|
||||
calls = []
|
||||
|
||||
def loader():
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise RuntimeError("Cannot send a request, as the client has been closed.")
|
||||
return "model"
|
||||
|
||||
assert tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2") == "model"
|
||||
assert reset, "the HF session must be reset before retrying a closed client"
|
||||
|
||||
|
||||
def test_the_closed_client_path_stays_single_shot(monkeypatch):
|
||||
"""The two failure shapes get deliberately different budgets. A closed
|
||||
client is a client-state bug, not a network condition: if a FRESH session
|
||||
hits it again, repeating won't help, and #880 chose to surface it. Only
|
||||
the transient-download path gets the multi-attempt budget."""
|
||||
monkeypatch.setenv("OMNIVOICE_MODEL_LOAD_RETRIES", "5")
|
||||
calls = []
|
||||
|
||||
def loader():
|
||||
calls.append(1)
|
||||
raise RuntimeError("Cannot send a request, as the client has been closed.")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2")
|
||||
assert len(calls) == 2, "closed-client must stay single-shot regardless of the budget"
|
||||
|
||||
|
||||
def test_voxcpm2_load_actually_retries_a_truncated_download(monkeypatch):
|
||||
"""The #1224 call site itself, exercised — not grepped.
|
||||
|
||||
An earlier version of this test asserted the wrapper's NAME appeared in the
|
||||
method source, which would have passed even if the wrapper were called with
|
||||
the wrong argument or its result discarded (#1224 review)."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeVoxCPM:
|
||||
@staticmethod
|
||||
def from_pretrained(checkpoint, **kw):
|
||||
calls.append(checkpoint)
|
||||
if len(calls) < 3:
|
||||
raise RuntimeError(
|
||||
"peer closed connection without sending complete message body"
|
||||
)
|
||||
return f"model:{checkpoint}"
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "voxcpm", types.SimpleNamespace(VoxCPM=_FakeVoxCPM)
|
||||
)
|
||||
monkeypatch.setenv("OMNIVOICE_VOXCPM_MODEL", "openbmb/VoxCPM2")
|
||||
monkeypatch.setattr(
|
||||
tts_backend.VoxCPM2Backend, "is_available", classmethod(lambda cls: (True, ""))
|
||||
)
|
||||
monkeypatch.setattr(tts_backend, "_voxcpm_upgrade_hint", lambda: None)
|
||||
|
||||
backend = tts_backend.VoxCPM2Backend()
|
||||
backend._ensure_loaded()
|
||||
|
||||
assert backend._model == "model:openbmb/VoxCPM2"
|
||||
assert len(calls) == 3, "the load must retry, not fail on the first truncation"
|
||||
|
||||
|
||||
def test_voxcpm2_load_still_fails_fast_on_a_real_error(monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeVoxCPM:
|
||||
@staticmethod
|
||||
def from_pretrained(checkpoint, **kw):
|
||||
calls.append(1)
|
||||
raise ValueError("checkpoint has no config.json")
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "voxcpm", types.SimpleNamespace(VoxCPM=_FakeVoxCPM)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tts_backend.VoxCPM2Backend, "is_available", classmethod(lambda cls: (True, ""))
|
||||
)
|
||||
monkeypatch.setattr(tts_backend, "_voxcpm_upgrade_hint", lambda: None)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
tts_backend.VoxCPM2Backend()._ensure_loaded()
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
# ── the installer's retry loop catches it ────────────────────────────────
|
||||
|
||||
|
||||
def test_installer_retries_a_real_remoteprotocolerror():
|
||||
"""The #1224 root cause, against a REAL httpx exception instance.
|
||||
|
||||
An earlier version asserted `"is_hf_connectivity_error" in getsource(...)`,
|
||||
which passed merely because the module imports it — it would not have
|
||||
noticed the loop ignoring the classifier entirely (#1224 review)."""
|
||||
import httpx
|
||||
|
||||
from api.routers.setup.download import _is_retryable_download_error
|
||||
|
||||
truncated = httpx.RemoteProtocolError(
|
||||
"peer closed connection without sending complete message body "
|
||||
"(received 4084175097 bytes, expected 4580080592)"
|
||||
)
|
||||
assert not isinstance(truncated, OSError), (
|
||||
"if this ever becomes an OSError the original bug is gone, but the "
|
||||
"classification path must still hold"
|
||||
)
|
||||
assert _is_retryable_download_error(truncated)
|
||||
|
||||
|
||||
def test_installer_does_not_retry_a_cancel_or_a_real_error():
|
||||
from api.routers.setup.download import (
|
||||
_InstallCancelled,
|
||||
_is_retryable_download_error,
|
||||
)
|
||||
|
||||
assert not _is_retryable_download_error(_InstallCancelled())
|
||||
assert not _is_retryable_download_error(ValueError("no such repo"))
|
||||
assert not _is_retryable_download_error(RuntimeError("401 Unauthorized"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403, 404, 410])
|
||||
def test_installer_does_not_retry_a_settled_hub_verdict(status):
|
||||
"""Review finding (#1224): every HfHubHTTPError was retried, so a wrong
|
||||
token or a gated repo burned all five attempts with backoff before showing
|
||||
the user the same message. The verdict is settled — fail fast."""
|
||||
import httpx
|
||||
from huggingface_hub.utils import HfHubHTTPError
|
||||
|
||||
from api.routers.setup.download import _is_retryable_download_error
|
||||
|
||||
response = httpx.Response(status, request=httpx.Request("GET", "https://hf.co/x"))
|
||||
assert not _is_retryable_download_error(
|
||||
HfHubHTTPError(f"{status} Client Error", response=response)
|
||||
)
|
||||
|
||||
|
||||
def test_installer_still_retries_a_server_side_hub_error():
|
||||
"""A 5xx (or a rate-limit) is transient and must keep retrying."""
|
||||
import httpx
|
||||
from huggingface_hub.utils import HfHubHTTPError
|
||||
|
||||
from api.routers.setup.download import _is_retryable_download_error
|
||||
|
||||
for status in (429, 500, 503):
|
||||
response = httpx.Response(
|
||||
status, request=httpx.Request("GET", "https://hf.co/x")
|
||||
)
|
||||
assert _is_retryable_download_error(
|
||||
HfHubHTTPError(f"{status} Error", response=response)
|
||||
), status
|
||||
|
||||
|
||||
def test_installer_still_retries_the_original_type_based_cases():
|
||||
"""Widening to classification must not drop what the old tuple caught."""
|
||||
from huggingface_hub.utils import LocalEntryNotFoundError
|
||||
|
||||
from api.routers.setup.download import _is_retryable_download_error
|
||||
|
||||
assert _is_retryable_download_error(OSError("connection reset by peer"))
|
||||
assert _is_retryable_download_error(LocalEntryNotFoundError("offline"))
|
||||
|
||||
|
||||
# ── the streaming path leaves an OOM breadcrumb ──────────────────────────
|
||||
|
||||
|
||||
def test_stream_path_checks_memory_before_loading():
|
||||
"""The reporter was SIGKILLed on a 16 GB Mac. /generate has logged a
|
||||
low-memory advisory since the earlier reports of that class, but the
|
||||
STREAMING path — which the desktop UI tries first — did not, so the load
|
||||
most likely to tip the machine over left no trail in the captured stderr
|
||||
tail a SIGKILL report has to go on.
|
||||
|
||||
Structural rather than behavioural: reaching the call needs a live
|
||||
WebSocket session. Kept honest by also resolving the symbol it names, so a
|
||||
rename or removal on either side fails here (#1224 review).
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from api.routers import tts_stream
|
||||
from services.memory_budget import log_if_low
|
||||
|
||||
assert callable(log_if_low)
|
||||
src = inspect.getsource(tts_stream)
|
||||
assert "from services.memory_budget import log_if_low" in src
|
||||
assert "log_if_low(f\"TTS stream load" in src
|
||||
|
||||
|
||||
def test_the_two_budgets_do_not_share_a_counter(monkeypatch):
|
||||
"""Review finding (#1224): the closed-client reset incremented the same
|
||||
counter as the download retries, so a session reset followed by transient
|
||||
failures left a resumable multi-GB download one attempt short of its
|
||||
configured budget."""
|
||||
monkeypatch.setenv("OMNIVOICE_MODEL_LOAD_RETRIES", "3")
|
||||
import huggingface_hub.utils as hub_utils
|
||||
|
||||
monkeypatch.setattr(hub_utils, "close_session", lambda: None, raising=False)
|
||||
|
||||
calls = []
|
||||
|
||||
def loader():
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise RuntimeError("Cannot send a request, as the client has been closed.")
|
||||
raise RuntimeError("peer closed connection without sending complete message body")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
tts_backend._retry_once_with_fresh_hf_client(loader, "VoxCPM2")
|
||||
# 1 closed-client + a full budget of 3 download attempts.
|
||||
assert len(calls) == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["inf", "-inf", "nan"])
|
||||
def test_a_non_finite_backoff_falls_back_to_the_default(monkeypatch, bad):
|
||||
"""Review finding (#1224): `float("inf")` parses fine and then makes
|
||||
`sleep(inf)` raise OverflowError, replacing a retryable download failure
|
||||
with an unrelated crash that hides the original error."""
|
||||
monkeypatch.setenv("OMNIVOICE_MODEL_LOAD_BACKOFF_S", bad)
|
||||
assert tts_backend._float_env("OMNIVOICE_MODEL_LOAD_BACKOFF_S", 2.0) == 2.0
|
||||
@@ -0,0 +1,156 @@
|
||||
"""#1247: `400 Bad Request: Unknown model id: engine:kittentts`.
|
||||
|
||||
Reported straight from `EngineCompatibilityMatrix.jsx` — the user opened
|
||||
Settings and pressed Unload on a resident engine.
|
||||
|
||||
`list_loaded()` enumerates in-process engines as `engine:<id>` and marks
|
||||
each ``"unloadable": True``. `unload()` handled `tts`, `diarization`,
|
||||
`sidecars` and `sidecar:<id>` — and nothing else. The panel was offering a
|
||||
button for an id the dispatcher rejected. The engines themselves have
|
||||
implemented `unload()` the whole time; only the routing was missing.
|
||||
|
||||
The guard at the bottom is the point: the two functions are a *contract*, and
|
||||
the bug was them disagreeing. Any future id the lister advertises as unloadable
|
||||
must be one the dispatcher accepts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from services import model_lifecycle
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
"""Stands in for an in-process backend (mlx-audio, kittentts, …)."""
|
||||
|
||||
id = "kittentts"
|
||||
_MODEL_ATTRS = ("_model",)
|
||||
|
||||
def __init__(self, loaded=True):
|
||||
self._model = object() if loaded else None
|
||||
self.unload_calls = 0
|
||||
|
||||
def unload(self):
|
||||
self.unload_calls += 1
|
||||
self._model = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_engines(monkeypatch):
|
||||
import api.routers.engines as engines_router
|
||||
|
||||
instances = {}
|
||||
monkeypatch.setattr(engines_router, "_ENGINE_INSTANCES", instances, raising=False)
|
||||
return instances
|
||||
|
||||
|
||||
# ── the reported failure ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unloading_a_resident_engine_no_longer_400s(fake_engines):
|
||||
engine = _FakeEngine(loaded=True)
|
||||
fake_engines[type(engine)] = engine
|
||||
|
||||
result = await model_lifecycle.unload("engine:kittentts")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["unloaded"] == "engine:kittentts"
|
||||
assert engine.unload_calls == 1
|
||||
assert engine._model is None, "the memory must actually be handed back"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_engine_that_holds_nothing_reports_not_loaded(fake_engines):
|
||||
"""Same shape as the `tts` / `diarization` branches — a no-op, not an error."""
|
||||
engine = _FakeEngine(loaded=False)
|
||||
fake_engines[type(engine)] = engine
|
||||
|
||||
result = await model_lifecycle.unload("engine:kittentts")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["reason"] == "not loaded"
|
||||
assert engine.unload_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_engine_that_is_not_instantiated_reports_not_loaded(fake_engines):
|
||||
"""A stale panel row (the engine was already evicted) must not 400 either —
|
||||
the user pressing a button that raced a background eviction did nothing
|
||||
wrong."""
|
||||
result = await model_lifecycle.unload("engine:neverloaded")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["reason"] == "not loaded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_warm_dictation_asr_can_be_unloaded(monkeypatch):
|
||||
"""A SECOND instance of the same defect, found by the contract test below
|
||||
rather than by a user: `capture-asr` was listed as unloadable and the
|
||||
dispatcher had no branch for it either."""
|
||||
import services.asr_backend as ab
|
||||
|
||||
released = []
|
||||
monkeypatch.setattr(ab, "_capture_backend", object(), raising=False)
|
||||
monkeypatch.setattr(
|
||||
ab, "release_idle_capture_backend",
|
||||
lambda idle_s, **kw: (released.append(idle_s), True)[1],
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = await model_lifecycle.unload("capture-asr")
|
||||
|
||||
assert result["success"] is True
|
||||
assert released == [0.0], "an explicit Unload releases now, not after a timeout"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dictation_in_progress_declines_rather_than_yanking_the_model(monkeypatch):
|
||||
import services.asr_backend as ab
|
||||
|
||||
monkeypatch.setattr(ab, "_capture_backend", object(), raising=False)
|
||||
monkeypatch.setattr(
|
||||
ab, "release_idle_capture_backend", lambda idle_s, **kw: False, raising=False
|
||||
)
|
||||
|
||||
result = await model_lifecycle.unload("capture-asr")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "dictation" in result["reason"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_genuinely_unknown_id_still_raises(fake_engines):
|
||||
"""The 400 is correct for an id nothing advertises — don't lose it."""
|
||||
with pytest.raises(ValueError, match="Unknown model id"):
|
||||
await model_lifecycle.unload("banana")
|
||||
|
||||
|
||||
# ── the contract that was broken ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_advertised_unloadable_id_is_accepted(fake_engines, monkeypatch):
|
||||
"""The recurrence guard.
|
||||
|
||||
The panel renders an Unload button for every row with ``unloadable: True``.
|
||||
Advertising an id the dispatcher rejects is precisely #1247, and it will
|
||||
keep happening as new model kinds are added unless the two sides are
|
||||
checked against each other.
|
||||
"""
|
||||
engine = _FakeEngine(loaded=True)
|
||||
fake_engines[type(engine)] = engine
|
||||
|
||||
listing = model_lifecycle.list_loaded()
|
||||
advertised = [m["id"] for m in listing["models"] if m.get("unloadable")]
|
||||
assert "engine:kittentts" in advertised, "fixture engine should be listed"
|
||||
|
||||
for model_id in advertised:
|
||||
try:
|
||||
await model_lifecycle.unload(model_id)
|
||||
except ValueError as e: # pragma: no cover — this is the failure mode
|
||||
pytest.fail(
|
||||
f"{model_id} is advertised as unloadable but the dispatcher "
|
||||
f"rejects it: {e}"
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""#1251: "The paging file is too small for this operation to complete."
|
||||
|
||||
The reporter's toast was the raw OS string and nothing else:
|
||||
|
||||
500 Internal Server Error: The paging file is too small for this operation
|
||||
to complete. (os error 1455)
|
||||
|
||||
`classify()` had no class for it, so `build_failure` attached no hint. The
|
||||
generate path *did* already count the phrase as an out-of-memory condition
|
||||
(`_OOM_MSG_SIGNATURES` in api/routers/generation.py), but that lumps it in with
|
||||
"your RAM is full" — and the resulting advice, close other apps and pick a
|
||||
lighter engine, is the wrong remedy on a 32 GB machine. Windows is not short of
|
||||
RAM here; it is refusing to back a large memory mapping because the paging file
|
||||
is capped too low. The fix is a Windows setting, and the hint now says which.
|
||||
|
||||
Note the spelling: `os error 1455`, not `[WinError 1455]`. It reaches Python
|
||||
through the Rust safetensors mmap, so matching only Python's wording missed it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.failure import build_failure, classify
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
[
|
||||
# Exactly as reported — Rust/safetensors spelling.
|
||||
"The paging file is too small for this operation to complete. (os error 1455)",
|
||||
# Python's spelling of the same condition.
|
||||
"[WinError 1455] The paging file is too small for this operation to complete",
|
||||
# Localised Windows: the code survives, the sentence does not.
|
||||
"OSError: [WinError 1455] Le fichier de pagination est insuffisant",
|
||||
],
|
||||
)
|
||||
def test_the_paging_file_failure_is_classified(reason):
|
||||
assert classify(reason) == "WINDOWS_PAGING_FILE_TOO_SMALL"
|
||||
|
||||
|
||||
def test_the_hint_gives_the_windows_setting_that_actually_fixes_it():
|
||||
failure = build_failure(
|
||||
OSError("The paging file is too small for this operation to complete. (os error 1455)"),
|
||||
stage="generate",
|
||||
)
|
||||
hint = failure["hint"]
|
||||
assert hint, "an unclassified 500 with no next step is the bug"
|
||||
assert "Virtual memory" in hint
|
||||
# The wrong remedy must not be the headline: this is not a RAM shortage.
|
||||
assert "paging file" in hint.lower()
|
||||
|
||||
|
||||
def test_a_genuine_out_of_memory_is_not_relabelled():
|
||||
"""The two conditions want different remedies — don't collapse them."""
|
||||
assert classify("CUDA out of memory. Tried to allocate 2.00 GiB") != (
|
||||
"WINDOWS_PAGING_FILE_TOO_SMALL"
|
||||
)
|
||||
assert classify("DefaultCPUAllocator: not enough memory") != (
|
||||
"WINDOWS_PAGING_FILE_TOO_SMALL"
|
||||
)
|
||||
|
||||
|
||||
def test_an_unrelated_number_1455_does_not_match():
|
||||
"""The numeric match is deliberately paired with an OS-error marker so a
|
||||
model dimension, a sample offset or a byte count can't trip it."""
|
||||
assert classify("Expected 1455 tokens, got 1200") == ""
|
||||
assert classify("wrote 1455 bytes") == ""
|
||||
|
||||
|
||||
def test_the_generate_path_still_treats_it_as_resource_exhaustion():
|
||||
"""It IS a resource failure — the retry/queue behaviour keyed off
|
||||
`_is_oom_failure` must keep working; only the user-facing advice changed."""
|
||||
from api.routers.generation import _is_oom_failure
|
||||
|
||||
assert _is_oom_failure(
|
||||
OSError("The paging file is too small for this operation to complete. (os error 1455)")
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""#1254: `download: ERROR: [youtube] DZpjig7qxM8: This video is DRM protected`
|
||||
|
||||
The reporter's own words:
|
||||
|
||||
Importing a YouTube URL intermittently failed with a DRM protection error,
|
||||
even though the same URL had previously worked and succeeded when retried
|
||||
shortly afterwards. The failure appears to be transient rather than
|
||||
consistently reproducible.
|
||||
|
||||
A genuinely DRM-protected video does not become downloadable thirty seconds
|
||||
later. What varies is the **player client**: YouTube serves a DRM-only format
|
||||
set to some clients for videos that are not actually protected. OmniVoice
|
||||
already had machinery for exactly this shape — `_is_forbidden_download_error`
|
||||
escalates through `_YT_PLAYER_CLIENTS` on a 403, because "extraction worked but
|
||||
this client can't have the media" is the same situation. DRM simply wasn't
|
||||
routed into it, so the user's only recovery was to retry by hand and hope the
|
||||
next attempt drew a different client.
|
||||
|
||||
If every client still reports DRM, the video really is unfetchable — and that
|
||||
now arrives classified, instead of as a raw yt-dlp line.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.failure import build_failure, classify
|
||||
from services.dub_pipeline import (
|
||||
_is_forbidden_download_error,
|
||||
_is_transient_download_error,
|
||||
)
|
||||
|
||||
|
||||
REPORTED = "ERROR: [youtube] DZpjig7qxM8: This video is DRM protected"
|
||||
|
||||
|
||||
# ── it escalates the player client ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_reported_error_triggers_client_escalation():
|
||||
assert _is_forbidden_download_error(RuntimeError(REPORTED))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reason",
|
||||
[
|
||||
"This video is DRM protected",
|
||||
"Requested format is DRM-protected",
|
||||
"ERROR: [youtube] abc: This video is DRM protected",
|
||||
],
|
||||
)
|
||||
def test_every_drm_wording_escalates(reason):
|
||||
assert _is_forbidden_download_error(RuntimeError(reason))
|
||||
|
||||
|
||||
def test_the_403_case_it_shares_the_path_with_still_works():
|
||||
"""#625's behaviour must survive the widening."""
|
||||
assert _is_forbidden_download_error(RuntimeError("HTTP Error 403: Forbidden"))
|
||||
|
||||
|
||||
def test_drm_does_not_burn_the_transient_retry_budget():
|
||||
"""Escalation and blind retry are different budgets. Counting DRM as
|
||||
transient would spend the retries on the same client that just refused —
|
||||
which is what the reporter was doing by hand."""
|
||||
assert not _is_transient_download_error(RuntimeError(REPORTED))
|
||||
|
||||
|
||||
def test_a_real_network_drop_is_still_transient_and_not_escalated():
|
||||
"""The two paths must not bleed into each other."""
|
||||
broken = RuntimeError("Unable to download video: Broken pipe")
|
||||
assert _is_transient_download_error(broken)
|
||||
assert not _is_forbidden_download_error(broken)
|
||||
|
||||
|
||||
# ── and if every client fails, the user is told why ──────────────────────
|
||||
|
||||
|
||||
def test_an_exhausted_drm_failure_is_classified():
|
||||
assert classify(REPORTED) == "VIDEO_DRM_PROTECTED"
|
||||
|
||||
|
||||
def test_the_hint_gives_a_way_forward_rather_than_another_retry():
|
||||
failure = build_failure(RuntimeError(REPORTED), stage="download")
|
||||
hint = failure["hint"]
|
||||
assert hint, "a raw yt-dlp line with no next step is the bug"
|
||||
# The two things the user can actually do.
|
||||
assert "drop the file" in hint.lower()
|
||||
assert "retried" in hint.lower(), "say that retrying was already tried"
|
||||
|
||||
|
||||
def test_an_unrelated_download_failure_keeps_its_own_class():
|
||||
assert classify("Unable to download video: Broken pipe") == "VIDEO_DOWNLOAD_NETWORK"
|
||||
assert classify("ERROR: Unsupported URL: https://example.com/profile") == (
|
||||
"UNSUPPORTED_VIDEO_URL"
|
||||
)
|
||||
Reference in New Issue
Block a user