feat(batch): watch-folder auto-ingest (#1768)
Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with the last Add-to-queue settings, with pause/stop controls and copy-in-progress protection. Files stream to the loopback backend as bytes; paths never leave the app. Also gives the batch queue a reachable UI entry point and streams multipart uploads to disk. Maintainer fix: the watched directory handle is opened with full share mode on Windows so users can rename or delete the folder while it is watched, matching macOS/Linux behaviour, with a cross-platform regression test. Thanks @mvanhorn!
This commit is contained in:
@@ -29,6 +29,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Hear a dub line as you type it — an opt-in live preview streams TTS for the edited segment (#1769) — thanks @mvanhorn!
|
||||
- Studio gains a Convert method: re-say any clip in one of your saved voices, speech to speech, fully local (#1765) — thanks @mvanhorn!
|
||||
- Hardsub video export gains an opt-in karaoke word-highlight caption style (#1764) — thanks @mvanhorn!
|
||||
- The batch queue can now watch a folder: new videos dropped into it are dubbed automatically (#1768) — thanks @mvanhorn!
|
||||
- The audiobook player now shows the chapter text and highlights the word being narrated (#1766) — thanks @mvanhorn!
|
||||
- The dub editor gains a casting board: drag voice chips onto speakers, dropdowns stay in sync (#1767) — thanks @mvanhorn!
|
||||
|
||||
@@ -39,6 +40,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- The audiobook result is now a synced-lyrics player: chapter text follows playback with the current word highlighted and click-to-seek, timed from the render's own chapter durations with a karaoke-style even split — no ASR pass, fully local (#1766) — thanks @mvanhorn!
|
||||
- The dub CAST strip expands into a project-level casting board: drag voice chips (clone profiles, design presets, Default) onto speaker rows — or pick from a keyboard listbox — writing the same per-speaker cast fields as the existing dropdowns (#1767) — thanks @mvanhorn!
|
||||
- Studio's new Convert method turns a dropped or recorded clip into an existing voice profile's voice, with optional source-duration matching (#1765) — thanks @mvanhorn!
|
||||
- Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with your last Add-to-queue settings, with pause/stop controls and copy-in-progress protection — files upload as bytes, paths never leave the app (#1768) — thanks @mvanhorn!
|
||||
- Hardsub export can now burn karaoke word-highlight captions: an opt-in Line | Karaoke control renders a word-timed ASS sweep from timings persisted at transcription, with an even-split fallback for older jobs and translated tracks, plus a `GET /dub/ass/{job_id}` sidecar (#1764) — thanks @mvanhorn!
|
||||
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
|
||||
- Dub segments can now stream live TTS while you edit a translated line — opt-in toggle, existing `/ws/tts` socket, shared generation admission, exports still render at full quality (#1769) — thanks @mvanhorn!
|
||||
@@ -53,6 +55,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The batch dubbing queue now has a UI entry point — a quiet link on the Dub landing (it was previously unreachable: the app switched on a mode nothing ever set) (#1768) — thanks @mvanhorn!
|
||||
- OpenAI-compatible ASR now requires HTTPS outside loopback and refuses redirects so audio stays on the configured origin (#1736)
|
||||
- Windows isolated engines now retain direct Job ownership without an extra Python supervisor process that can deadlock the child loader (#1734)
|
||||
- The setup splash now waits through the backend's full startup budget instead of reporting slow Windows CUDA initialization as stuck after two minutes (#1749)
|
||||
|
||||
@@ -134,7 +134,7 @@ The desktop launcher configures Python dependencies on first run via `uv` automa
|
||||
| **[Dictation Widget](docs/features/dictation.md)** | System-wide shortcut, live transcription, optional local-LLM cleanup |
|
||||
| **Vocal Isolation** | Demucs speech/background separation |
|
||||
| **Speaker Diarization** | Pyannote and WhisperX speaker assignment ([guide](docs/features/diarization.md)) |
|
||||
| **Batch Queue** | Queue large sets of audio and video jobs with per-job progress |
|
||||
| **Batch Queue** | Queue large sets of audio and video jobs with per-job progress, or watch a local folder for new videos |
|
||||
| **Model Catalogue** | Install, remove, select, and route TTS, ASR, and LLM models ([catalogue](docs/engines/README.md)) |
|
||||
| **Remote Model Downloads** | Install models on enrolled remote workers with live progress ([guide](docs/downloading-models.md)) |
|
||||
| **GPU Auto-Detect** | CUDA, MPS, ROCm, and CPU routing with per-engine checks ([performance](docs/performance.md)) |
|
||||
|
||||
@@ -111,6 +111,24 @@ BATCH_WIDTH_ENV = "OMNIVOICE_DUB_BATCH_WIDTH"
|
||||
#: that costs more than the saving.
|
||||
_MAX_BATCH_WIDTH = 16
|
||||
|
||||
# Bound each allocation while persisting multipart uploads. Video inputs can
|
||||
# be many gigabytes; `await UploadFile.read()` with no size used to mirror the
|
||||
# entire file in process memory before writing it back out.
|
||||
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
async def _save_upload(upload: UploadFile, destination: str) -> None:
|
||||
try:
|
||||
with open(destination, "wb") as output:
|
||||
while chunk := await upload.read(_UPLOAD_CHUNK_BYTES):
|
||||
output.write(chunk)
|
||||
except BaseException:
|
||||
try:
|
||||
unlink_if_present(destination)
|
||||
except FileCleanupError:
|
||||
logger.warning("Could not remove incomplete batch upload", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def _native_batch_width(backend) -> int:
|
||||
"""How many segments to render in one native batch on THIS host.
|
||||
@@ -690,9 +708,7 @@ async def enqueue_batch_job(
|
||||
ext = os.path.splitext(video.filename or "video.mp4")[1] or ".mp4"
|
||||
video_path = os.path.join(batch_dir, f"{job_id}{ext}")
|
||||
|
||||
with open(video_path, "wb") as f:
|
||||
content = await video.read()
|
||||
f.write(content)
|
||||
await _save_upload(video, video_path)
|
||||
|
||||
job = {
|
||||
"id": job_id,
|
||||
|
||||
@@ -112,6 +112,42 @@ class TestEnqueue:
|
||||
job = client.get(f"/batch/jobs/{job_id}").json()
|
||||
assert job["filename"] == "test.mp4"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_is_persisted_in_bounded_chunks(self, batch, tmp_path):
|
||||
class RecordingUpload:
|
||||
def __init__(self):
|
||||
self.read_sizes = []
|
||||
self.remaining = b"video"
|
||||
|
||||
async def read(self, size):
|
||||
self.read_sizes.append(size)
|
||||
chunk, self.remaining = self.remaining[:size], self.remaining[size:]
|
||||
return chunk
|
||||
|
||||
upload = RecordingUpload()
|
||||
destination = tmp_path / "video.mp4"
|
||||
await batch._save_upload(upload, str(destination))
|
||||
|
||||
assert destination.read_bytes() == b"video"
|
||||
assert upload.read_sizes == [batch._UPLOAD_CHUNK_BYTES, batch._UPLOAD_CHUNK_BYTES]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_upload_removes_partial_file(self, batch, tmp_path):
|
||||
class FailingUpload:
|
||||
calls = 0
|
||||
|
||||
async def read(self, _size):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return b"partial"
|
||||
raise OSError("upload interrupted")
|
||||
|
||||
destination = tmp_path / "video.mp4"
|
||||
with pytest.raises(OSError, match="upload interrupted"):
|
||||
await batch._save_upload(FailingUpload(), str(destination))
|
||||
|
||||
assert not destination.exists()
|
||||
|
||||
|
||||
class TestListJobs:
|
||||
def test_empty(self, client):
|
||||
|
||||
Generated
+110
-1
@@ -43,6 +43,12 @@ dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ambient-authority"
|
||||
version = "0.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
|
||||
|
||||
[[package]]
|
||||
name = "android_log-sys"
|
||||
version = "0.3.2"
|
||||
@@ -548,6 +554,36 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cap-primitives"
|
||||
version = "3.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e0bf07d379916947be6c4a07f43684153d710a2896c31f9e97781362895596c"
|
||||
dependencies = [
|
||||
"ambient-authority",
|
||||
"fs-set-times",
|
||||
"io-extras",
|
||||
"io-lifetimes",
|
||||
"ipnet",
|
||||
"maybe-owned",
|
||||
"rustix",
|
||||
"rustix-linux-procfs",
|
||||
"windows-sys 0.59.0",
|
||||
"winx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cap-std"
|
||||
version = "3.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a59e59fa26472d29680ece6a9f8ee8b0551a719a33df2f5240bde065ecbddfd7"
|
||||
dependencies = [
|
||||
"cap-primitives",
|
||||
"io-extras",
|
||||
"io-lifetimes",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo-platform"
|
||||
version = "0.1.9"
|
||||
@@ -1364,6 +1400,17 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-set-times"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
|
||||
dependencies = [
|
||||
"io-lifetimes",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "0.13.1"
|
||||
@@ -1393,6 +1440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2184,6 +2232,22 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-extras"
|
||||
version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65"
|
||||
dependencies = [
|
||||
"io-lifetimes",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-lifetimes"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -2477,6 +2541,12 @@ dependencies = [
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-owned"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
@@ -2507,6 +2577,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
@@ -2967,6 +3047,7 @@ name = "omnivoice-studio"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"cap-std",
|
||||
"dirs-next",
|
||||
"enigo",
|
||||
"fs4",
|
||||
@@ -3051,7 +3132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3672,6 +3753,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
@@ -3684,6 +3766,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -3818,6 +3901,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix-linux-procfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
@@ -5423,6 +5516,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -6512,6 +6611,16 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winx"
|
||||
version = "0.36.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -23,6 +23,10 @@ tauri-build = { version = "2.6.0", features = [] }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
getrandom = "0.3"
|
||||
# Capability-scoped fs access for the batch watch folder: scans/reads resolve
|
||||
# against a directory HANDLE captured at pick time. The selected pathname is
|
||||
# re-resolved only for liveness/identity checks (#1768).
|
||||
cap-std = "3"
|
||||
log = "0.4"
|
||||
tauri = { version = "2.11.0", features = ["macos-private-api", "protocol-asset", "tray-icon", "image-png"] }
|
||||
tauri-plugin-log = "2"
|
||||
@@ -47,8 +51,9 @@ enigo = { version = "0.3", features = ["serde"] }
|
||||
# bundled pyproject.toml — which installs torch, whisperx, etc.
|
||||
# ureq is used for HTTP health checks and ffmpeg downloads.
|
||||
ureq = "2"
|
||||
# reqwest is used for GitHub Releases API calls (list_releases command).
|
||||
reqwest = { version = "0.13", features = ["json"] }
|
||||
# reqwest is used for GitHub Releases API calls and bounded-memory native
|
||||
# watch-folder uploads to the loopback backend.
|
||||
reqwest = { version = "0.13", features = ["json", "blocking", "multipart"] }
|
||||
# semver is used by the channel-aware updater (updater_channel.rs) to rank
|
||||
# builds across the stable/preview channels (#326); same major as the
|
||||
# `Version` type tauri-plugin-updater re-exports (already in the lockfile).
|
||||
@@ -90,7 +95,7 @@ windows-core = "0.61"
|
||||
# `HWND` type — no second copy of the crate enters the dependency graph.
|
||||
# Win32_System_Registry: check_microphone reads the CapabilityAccessManager
|
||||
# ConsentStore mic toggle (RegGetValueW) for the permissions UX.
|
||||
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_Diagnostics_ToolHelp", "Win32_Security"] }
|
||||
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_UI_WindowsAndMessaging", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_Diagnostics_ToolHelp", "Win32_Security"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod speech_sidecar;
|
||||
pub mod tools;
|
||||
pub mod uninstall;
|
||||
pub mod updater_channel;
|
||||
pub mod watch_folder;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod wayland_shortcut;
|
||||
|
||||
@@ -739,6 +740,10 @@ pub fn run() {
|
||||
reset::reset_purge,
|
||||
blank_guard::report_render_state,
|
||||
blank_guard::recover_main_window,
|
||||
watch_folder::watch_folder_pick,
|
||||
watch_folder::watch_folder_scan,
|
||||
watch_folder::watch_folder_enqueue,
|
||||
watch_folder::watch_folder_forget,
|
||||
])
|
||||
.setup(move |app| {
|
||||
// Blank-window guard: watch the main window and, if nothing ever
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
//! Batch watch-folder IPC: native folder pick, polling scan, and upload.
|
||||
//!
|
||||
//! The watcher lives entirely on the client side of the app: the webview asks
|
||||
//! this module (over Tauri IPC) for directory listings and asks Rust to stream
|
||||
//! a settled file through the existing `POST /batch/enqueue` multipart route.
|
||||
//! The Python backend only ever sees uploaded bytes — filesystem paths never
|
||||
//! ride an HTTP request (same posture as `commands::authorize_host_path`).
|
||||
//!
|
||||
//! Access model: the folder is picked in a native dialog inside this process
|
||||
//! and registered under a random session token, together with a `cap_std`
|
||||
//! directory HANDLE opened at pick time. Scan/read commands resolve entries
|
||||
//! relative to that handle — the pathname is never re-resolved for *access*,
|
||||
//! so swapping the directory (or any component of its path) for a
|
||||
//! symlink/junction later cannot redirect the watcher, on any OS. The stored
|
||||
//! pathname is re-resolved only by the liveness/identity check, which stops
|
||||
//! the watcher loudly when the folder is deleted, moved, or replaced. The
|
||||
//! webview cannot point the commands at an arbitrary path; reads are confined
|
||||
//! to files sitting directly in the folder the user explicitly picked this
|
||||
//! session (non-recursive by design).
|
||||
//!
|
||||
//! Holding the handle must not lock the user's folder: `cap_std` opens
|
||||
//! directories on Windows WITHOUT `FILE_SHARE_DELETE` (it pins the pathname
|
||||
//! for its own path-based helpers), which would make Explorer refuse to
|
||||
//! rename or delete a watched folder until the watch is stopped — a
|
||||
//! Windows-only behaviour the other two platforms don't have. The handle is
|
||||
//! therefore opened here with the full share mode (`open_dir_handle`), so
|
||||
//! replacing the folder behaves identically everywhere: the OS allows it, the
|
||||
//! next poll's identity check fails, and the UI stops the watcher.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
use cap_std::ambient_authority;
|
||||
use cap_std::fs::Dir;
|
||||
use serde::Serialize;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
/// An authorized watch folder: the directory handle everything resolves
|
||||
/// against, plus the identity the folder had when the user picked it. The
|
||||
/// handle is the security boundary (operations can never leave it); the
|
||||
/// identity check is the LIVENESS signal — when the folder is deleted, moved,
|
||||
/// or replaced, token resolution fails loudly and the UI stops the watcher
|
||||
/// instead of polling silently forever.
|
||||
struct WatchedDir {
|
||||
/// Fully-resolved directory path captured at pick time.
|
||||
canonical: PathBuf,
|
||||
/// Filesystem identity (device, inode) captured at pick time.
|
||||
#[cfg(unix)]
|
||||
identity: (u64, u64),
|
||||
/// Filesystem identity (volume serial, file index) captured at pick time.
|
||||
#[cfg(windows)]
|
||||
identity: (u32, u64),
|
||||
/// Directory handle captured at pick time — all scans/reads go through it.
|
||||
handle: Dir,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn dir_identity(meta: &fs::Metadata) -> (u64, u64) {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
(meta.dev(), meta.ino())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn dir_identity(dir: &Dir) -> Result<(u32, u64), String> {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
|
||||
};
|
||||
|
||||
let mut info = BY_HANDLE_FILE_INFORMATION::default();
|
||||
// SAFETY: `dir` owns a live directory handle for the duration of this
|
||||
// call, and `info` is a valid writable output buffer.
|
||||
unsafe { GetFileInformationByHandle(HANDLE(dir.as_raw_handle()), &mut info) }
|
||||
.map_err(|_| "Selected watch folder identity could not be read".to_string())?;
|
||||
Ok((
|
||||
info.dwVolumeSerialNumber,
|
||||
((info.nFileIndexHigh as u64) << 32) | info.nFileIndexLow as u64,
|
||||
))
|
||||
}
|
||||
|
||||
/// Open a directory handle for capability-scoped access.
|
||||
///
|
||||
/// Unix: `cap_std`'s own ambient open. Windows: the same
|
||||
/// `FILE_FLAG_BACKUP_SEMANTICS` directory open `cap_std` performs, but with
|
||||
/// `FILE_SHARE_DELETE` included so the user can still rename/delete the folder
|
||||
/// while it is watched (see the module docs). Child opens stay handle-relative
|
||||
/// (`CreateFileAtW` / `NtCreateFile` with a root directory) so confinement is
|
||||
/// unaffected; only the liveness check observes the rename, which is the
|
||||
/// intended signal.
|
||||
#[cfg(not(windows))]
|
||||
fn open_dir_handle(dir: &Path) -> io::Result<Dir> {
|
||||
Dir::open_ambient_dir(dir, ambient_authority())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open_dir_handle(dir: &Path) -> io::Result<Dir> {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
};
|
||||
|
||||
let file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0)
|
||||
.share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0)
|
||||
.open(dir)?;
|
||||
if !file.metadata()?.is_dir() {
|
||||
return Err(io::Error::other("not a directory"));
|
||||
}
|
||||
Ok(Dir::from_std_file(file))
|
||||
}
|
||||
|
||||
fn authorize_watched_dir(dir: &Path) -> Result<WatchedDir, String> {
|
||||
let canonical = fs::canonicalize(dir)
|
||||
.map_err(|e| format!("Selected watch folder could not be resolved: {e}"))?;
|
||||
if !canonical.is_dir() {
|
||||
return Err("Selected watch folder is not a directory".into());
|
||||
}
|
||||
let handle = open_dir_handle(&canonical)
|
||||
.map_err(|e| format!("Selected watch folder could not be opened: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
let identity = dir_identity(
|
||||
&fs::metadata(&canonical)
|
||||
.map_err(|e| format!("Selected watch folder could not be inspected: {e}"))?,
|
||||
);
|
||||
#[cfg(windows)]
|
||||
let identity = dir_identity(&handle)?;
|
||||
Ok(WatchedDir {
|
||||
canonical,
|
||||
#[cfg(unix)]
|
||||
identity,
|
||||
#[cfg(windows)]
|
||||
identity,
|
||||
handle,
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-verify a watched folder's identity: the stored path must still resolve
|
||||
/// to the same canonical target (and, on unix, the same device+inode). A
|
||||
/// deleted, moved, replaced, or recreated directory fails here, which is what
|
||||
/// stops the watcher loudly in the UI. Reads never depend on this check for
|
||||
/// confinement — they go through the pinned handle regardless.
|
||||
fn verify_watched_dir(watched: &WatchedDir) -> Result<(), String> {
|
||||
let canonical_now = fs::canonicalize(&watched.canonical)
|
||||
.map_err(|_| "Watched folder is no longer accessible".to_string())?;
|
||||
if canonical_now != watched.canonical {
|
||||
return Err("Watched folder changed identity".into());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let meta = fs::metadata(&canonical_now)
|
||||
.map_err(|_| "Watched folder is no longer accessible".to_string())?;
|
||||
if dir_identity(&meta) != watched.identity {
|
||||
return Err("Watched folder changed identity".into());
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let current = open_dir_handle(&canonical_now)
|
||||
.map_err(|_| "Watched folder is no longer accessible".to_string())?;
|
||||
if dir_identity(¤t)? != watched.identity {
|
||||
return Err("Watched folder changed identity".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn registry() -> &'static Mutex<HashMap<String, WatchedDir>> {
|
||||
static WATCHED: OnceLock<Mutex<HashMap<String, WatchedDir>>> = OnceLock::new();
|
||||
WATCHED.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WatchFolderSelection {
|
||||
token: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WatchEntry {
|
||||
name: String,
|
||||
size: u64,
|
||||
/// Modification time in ms since the Unix epoch (0 when unavailable).
|
||||
mtime: u64,
|
||||
}
|
||||
|
||||
fn new_token() -> Result<String, String> {
|
||||
let mut random = [0_u8; 32];
|
||||
getrandom::fill(&mut random).map_err(|e| format!("Secure randomness unavailable: {e}"))?;
|
||||
Ok(random.iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
/// Resolve a session token to a clone of its pinned directory handle,
|
||||
/// re-verifying the folder's liveness/identity on every access.
|
||||
fn registered_dir(token: &str) -> Result<Dir, String> {
|
||||
let map = registry()
|
||||
.lock()
|
||||
.map_err(|_| "Watch-folder registry poisoned".to_string())?;
|
||||
let watched = map
|
||||
.get(token)
|
||||
.ok_or_else(|| "Watch folder is not authorized".to_string())?;
|
||||
verify_watched_dir(watched)?;
|
||||
watched
|
||||
.handle
|
||||
.try_clone()
|
||||
.map_err(|e| format!("Watched folder handle could not be reused: {e}"))
|
||||
}
|
||||
|
||||
/// A directory entry name must be a single plain path component — anything
|
||||
/// that could climb out of the watched folder is rejected. (The `cap_std`
|
||||
/// handle would also refuse an escape; this keeps the error crisp and the
|
||||
/// contract explicit.)
|
||||
fn validate_entry_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty()
|
||||
|| name == "."
|
||||
|| name == ".."
|
||||
|| name.contains('/')
|
||||
|| name.contains('\\')
|
||||
|| name.chars().any(|c| c.is_control())
|
||||
{
|
||||
return Err("Invalid watch-folder entry name".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mtime_ms(meta: &fs::Metadata) -> u64 {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn cap_mtime_ms(meta: &cap_std::fs::Metadata) -> u64 {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.into_std().duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Non-recursive listing of the regular files in the watched folder (name,
|
||||
/// size, mtime), resolved through the pinned handle. Symlinks are skipped
|
||||
/// outright — the read path cannot follow them out of the folder anyway, so
|
||||
/// listing them would only produce entries that can never be ingested.
|
||||
fn scan_dir(dir: &Dir) -> Result<Vec<WatchEntry>, String> {
|
||||
let mut entries = Vec::new();
|
||||
let read = dir
|
||||
.entries()
|
||||
.map_err(|e| format!("Watched folder is unreadable: {e}"))?;
|
||||
for item in read.flatten() {
|
||||
let Ok(file_type) = item.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = item.metadata() else { continue };
|
||||
let Ok(name) = item.file_name().into_string() else {
|
||||
continue; // non-UTF-8 names can't round-trip through IPC; skip
|
||||
};
|
||||
entries.push(WatchEntry {
|
||||
name,
|
||||
size: meta.len(),
|
||||
mtime: cap_mtime_ms(&meta),
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Open a settled watched file through the pinned directory handle. A symlink
|
||||
/// outside the folder cannot be opened, and the returned reader revalidates
|
||||
/// size+mtime around every network read so a mutation aborts the upload.
|
||||
fn open_watched_reader(
|
||||
dir: &Dir,
|
||||
name: &str,
|
||||
expected_size: u64,
|
||||
expected_mtime: u64,
|
||||
) -> Result<SnapshotReader, String> {
|
||||
validate_entry_name(name)?;
|
||||
let file = dir
|
||||
.open(name)
|
||||
.map_err(|e| format!("Watched file could not be opened: {e}"))?
|
||||
.into_std();
|
||||
let meta = file
|
||||
.metadata()
|
||||
.map_err(|e| format!("Watched file could not be inspected: {e}"))?;
|
||||
if !meta.is_file() {
|
||||
return Err("Watched entry is not a regular file".into());
|
||||
}
|
||||
if meta.len() != expected_size || mtime_ms(&meta) != expected_mtime {
|
||||
return Err("Watched file changed after it was scanned".into());
|
||||
}
|
||||
Ok(SnapshotReader {
|
||||
file,
|
||||
expected_size,
|
||||
expected_mtime,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SnapshotReader {
|
||||
file: fs::File,
|
||||
expected_size: u64,
|
||||
expected_mtime: u64,
|
||||
}
|
||||
|
||||
impl SnapshotReader {
|
||||
fn validate(&self) -> io::Result<()> {
|
||||
let meta = self.file.metadata()?;
|
||||
if !meta.is_file()
|
||||
|| meta.len() != self.expected_size
|
||||
|| mtime_ms(&meta) != self.expected_mtime
|
||||
{
|
||||
return Err(io::Error::other("watched file changed during upload"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for SnapshotReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
self.validate()?;
|
||||
let read = self.file.read(buf)?;
|
||||
self.validate()?;
|
||||
Ok(read)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the native folder picker and register the chosen directory for this
|
||||
/// session. Returns `None` when the user cancels.
|
||||
#[tauri::command]
|
||||
pub async fn watch_folder_pick(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<Option<WatchFolderSelection>, String> {
|
||||
let picked = app
|
||||
.dialog()
|
||||
.file()
|
||||
.blocking_pick_folder()
|
||||
.and_then(|value| value.into_path().ok());
|
||||
let Some(dir) = picked else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !dir.is_absolute() || !dir.is_dir() {
|
||||
return Err("Selected watch folder is not a directory".into());
|
||||
}
|
||||
let watched = authorize_watched_dir(&dir)?;
|
||||
let display = watched.canonical.to_string_lossy().into_owned();
|
||||
let token = new_token()?;
|
||||
registry()
|
||||
.lock()
|
||||
.map_err(|_| "Watch-folder registry poisoned".to_string())?
|
||||
.insert(token.clone(), watched);
|
||||
Ok(Some(WatchFolderSelection {
|
||||
token,
|
||||
path: display,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List the files currently sitting in the watched folder (non-recursive).
|
||||
#[tauri::command]
|
||||
pub fn watch_folder_scan(token: String) -> Result<Vec<WatchEntry>, String> {
|
||||
scan_dir(®istered_dir(&token)?)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WatchFolderUploadReply {
|
||||
status: u16,
|
||||
body: serde_json::Value,
|
||||
}
|
||||
|
||||
fn video_mime(name: &str) -> &'static str {
|
||||
match Path::new(name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"mp4" | "m4v" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"mkv" => "video/x-matroska",
|
||||
"webm" => "video/webm",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mpg" | "mpeg" => "video/mpeg",
|
||||
"wmv" => "video/x-ms-wmv",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream one settled watched file directly from its pinned OS handle to the
|
||||
/// loopback backend. Keeping bytes out of WebView IPC avoids an O(file size)
|
||||
/// renderer allocation for multi-gigabyte videos.
|
||||
#[tauri::command]
|
||||
pub async fn watch_folder_enqueue(
|
||||
token: String,
|
||||
name: String,
|
||||
expected_size: u64,
|
||||
expected_mtime: u64,
|
||||
langs: Vec<String>,
|
||||
voice_id: Option<String>,
|
||||
preserve_bg: bool,
|
||||
) -> Result<WatchFolderUploadReply, String> {
|
||||
let dir = registered_dir(&token)?;
|
||||
let reader = open_watched_reader(&dir, &name, expected_size, expected_mtime)?;
|
||||
let mime = video_mime(&name);
|
||||
let url = format!("http://127.0.0.1:{}/batch/enqueue", crate::backend_port());
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let part = reqwest::blocking::multipart::Part::reader_with_length(reader, expected_size)
|
||||
.file_name(name)
|
||||
.mime_str(mime)
|
||||
.map_err(|_| "Watched file type could not be prepared".to_string())?;
|
||||
let mut form = reqwest::blocking::multipart::Form::new()
|
||||
.part("video", part)
|
||||
.text("langs", langs.join(","))
|
||||
.text("preserve_bg", preserve_bg.to_string());
|
||||
if let Some(voice_id) = voice_id.filter(|value| !value.is_empty()) {
|
||||
form = form.text("voice_id", voice_id);
|
||||
}
|
||||
let response = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|_| "Watch-folder upload client could not start".to_string())?
|
||||
.post(url)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.map_err(|_| "Watch-folder upload failed".to_string())?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.json::<serde_json::Value>()
|
||||
.map_err(|_| "Watch-folder backend returned an invalid response".to_string())?;
|
||||
Ok(WatchFolderUploadReply { status, body })
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Watch-folder upload task failed".to_string())?
|
||||
}
|
||||
|
||||
/// Drop a watch-folder authorization (watcher stopped or component unmounted).
|
||||
#[tauri::command]
|
||||
pub fn watch_folder_forget(token: String) {
|
||||
if let Ok(mut map) = registry().lock() {
|
||||
map.remove(&token);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
authorize_watched_dir, mtime_ms, open_dir_handle, open_watched_reader, scan_dir,
|
||||
validate_entry_name, verify_watched_dir, Dir,
|
||||
};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn temp_watch_dir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("vs-watch-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn open_handle(dir: &std::path::Path) -> Dir {
|
||||
open_dir_handle(dir).unwrap()
|
||||
}
|
||||
|
||||
fn snapshot(path: &std::path::Path) -> (u64, u64) {
|
||||
let meta = fs::metadata(path).unwrap();
|
||||
(meta.len(), mtime_ms(&meta))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_names_must_be_single_components() {
|
||||
assert!(validate_entry_name("clip.mp4").is_ok());
|
||||
assert!(validate_entry_name("weird name (1).MOV").is_ok());
|
||||
for bad in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"a/b.mp4",
|
||||
"a\\b.mp4",
|
||||
"..\\up.mp4",
|
||||
"x\n.mp4",
|
||||
] {
|
||||
assert!(validate_entry_name(bad).is_err(), "accepted {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_lists_regular_files_with_size_and_mtime_and_skips_dirs() {
|
||||
let dir = temp_watch_dir("scan");
|
||||
fs::create_dir_all(dir.join("nested")).unwrap();
|
||||
fs::write(dir.join("a.mp4"), b"12345").unwrap();
|
||||
fs::write(dir.join("notes.txt"), b"x").unwrap();
|
||||
|
||||
let mut entries = scan_dir(&open_handle(&dir)).unwrap();
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
|
||||
// Directories are skipped; filtering to *videos* is the frontend's job.
|
||||
assert_eq!(names, ["a.mp4", "notes.txt"]);
|
||||
assert_eq!(entries[0].size, 5);
|
||||
assert!(entries[0].mtime > 0);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_reader_streams_the_exact_bytes() {
|
||||
let dir = temp_watch_dir("stream");
|
||||
fs::write(dir.join("clip.mp4"), b"0123456789").unwrap();
|
||||
let (size, mtime) = snapshot(&dir.join("clip.mp4"));
|
||||
let handle = open_handle(&dir);
|
||||
|
||||
let mut reader = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap();
|
||||
let mut whole = Vec::new();
|
||||
reader.read_to_end(&mut whole).unwrap();
|
||||
assert_eq!(whole, b"0123456789");
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_are_bound_to_the_settled_snapshot() {
|
||||
let dir = temp_watch_dir("snapshot");
|
||||
fs::write(dir.join("clip.mp4"), b"settled bytes").unwrap();
|
||||
let (size, mtime) = snapshot(&dir.join("clip.mp4"));
|
||||
let handle = open_handle(&dir);
|
||||
|
||||
// The file is replaced after the scan settled → the read must refuse
|
||||
// rather than upload bytes the tracker never saw stabilize.
|
||||
fs::write(dir.join("clip.mp4"), b"replaced with something longer").unwrap();
|
||||
let err = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap_err();
|
||||
assert!(err.contains("changed"), "unexpected error: {err}");
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_reader_aborts_when_file_changes_during_stream() {
|
||||
let dir = temp_watch_dir("mid-stream-change");
|
||||
fs::write(dir.join("clip.mp4"), b"settled bytes").unwrap();
|
||||
let (size, mtime) = snapshot(&dir.join("clip.mp4"));
|
||||
let handle = open_handle(&dir);
|
||||
let mut reader = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap();
|
||||
|
||||
fs::write(dir.join("clip.mp4"), b"different-length bytes").unwrap();
|
||||
let mut byte = [0_u8; 1];
|
||||
assert!(reader.read(&mut byte).is_err());
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_pins_the_directory_identity() {
|
||||
let dir = temp_watch_dir("identity");
|
||||
let watched = authorize_watched_dir(&dir).unwrap();
|
||||
// Untouched directory verifies fine…
|
||||
assert!(verify_watched_dir(&watched).is_ok());
|
||||
// …and a directory that disappears after authorization is refused.
|
||||
// The removal itself must succeed WHILE the handle is held: a watch
|
||||
// that locked the user's folder against deletion (Windows sharing
|
||||
// violation, OS error 32) would be a Windows-only behaviour.
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(verify_watched_dir(&watched).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_watched_folder_can_be_renamed_by_the_user_while_watched() {
|
||||
// Cross-platform contract: holding the pinned handle never blocks the
|
||||
// user from moving the folder (Explorer/Finder/mv). The liveness
|
||||
// check is what notices — it must refuse, not the OS.
|
||||
let dir = temp_watch_dir("rename-while-watched");
|
||||
let moved = dir.with_extension("moved");
|
||||
let _ = fs::remove_dir_all(&moved);
|
||||
let watched = authorize_watched_dir(&dir).unwrap();
|
||||
fs::rename(&dir, &moved).unwrap();
|
||||
assert!(verify_watched_dir(&watched).is_err());
|
||||
// The pinned handle still points at the ORIGINAL directory object.
|
||||
fs::write(moved.join("clip.mp4"), b"x").unwrap();
|
||||
let names: Vec<String> = scan_dir(&watched.handle)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.collect();
|
||||
assert_eq!(names, ["clip.mp4"]);
|
||||
drop(watched);
|
||||
let _ = fs::remove_dir_all(&moved);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_directory_swapped_for_a_symlink_is_refused_and_never_followed() {
|
||||
let dir = temp_watch_dir("dir-swap");
|
||||
let elsewhere = temp_watch_dir("dir-swap-target");
|
||||
fs::write(elsewhere.join("clip.mp4"), b"outside").unwrap();
|
||||
let (size, mtime) = snapshot(&elsewhere.join("clip.mp4"));
|
||||
|
||||
let watched = authorize_watched_dir(&dir).unwrap();
|
||||
assert!(verify_watched_dir(&watched).is_ok());
|
||||
|
||||
// Replace the authorized directory itself with a symlink pointing
|
||||
// somewhere else. Token resolution refuses (identity check)…
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
std::os::unix::fs::symlink(&elsewhere, &dir).unwrap();
|
||||
let err = verify_watched_dir(&watched).unwrap_err();
|
||||
assert!(err.contains("identity"), "unexpected error: {err}");
|
||||
// …and even the pinned handle cannot reach the swap target: it still
|
||||
// points at the ORIGINAL (now unlinked) directory, which is empty.
|
||||
assert!(scan_dir(&watched.handle).unwrap().is_empty());
|
||||
assert!(open_watched_reader(&watched.handle, "clip.mp4", size, mtime).is_err());
|
||||
|
||||
let _ = fs::remove_file(&dir);
|
||||
let _ = fs::remove_dir_all(&elsewhere);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_recreated_directory_at_the_same_path_is_refused() {
|
||||
let dir = temp_watch_dir("dir-recreate");
|
||||
let watched = authorize_watched_dir(&dir).unwrap();
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
fs::create_dir_all(&dir).unwrap(); // same path, different inode
|
||||
assert!(verify_watched_dir(&watched).is_err());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_replaced_directory_at_the_same_windows_path_is_refused() {
|
||||
// Same pathname, different directory object (volume serial + file
|
||||
// index): the pathname check alone would pass, the identity must not.
|
||||
let dir = temp_watch_dir("windows-dir-replace");
|
||||
let moved = dir.with_extension("moved");
|
||||
let _ = fs::remove_dir_all(&moved);
|
||||
let watched = authorize_watched_dir(&dir).unwrap();
|
||||
fs::rename(&dir, &moved).unwrap();
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let err = verify_watched_dir(&watched).unwrap_err();
|
||||
assert!(err.contains("identity"), "unexpected error: {err}");
|
||||
drop(watched);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let _ = fs::remove_dir_all(&moved);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_are_never_followed_out_of_the_folder() {
|
||||
let dir = temp_watch_dir("symlink");
|
||||
let secret = std::env::temp_dir().join(format!("vs-secret-{}", std::process::id()));
|
||||
fs::write(&secret, b"outside the folder").unwrap();
|
||||
std::os::unix::fs::symlink(&secret, dir.join("evil.mp4")).unwrap();
|
||||
let meta = fs::metadata(dir.join("evil.mp4")).unwrap();
|
||||
let handle = open_handle(&dir);
|
||||
|
||||
// Even with a "correct" snapshot of the symlink target, opening it
|
||||
// through the capability handle refuses: resolution may not escape
|
||||
// the watched folder.
|
||||
let err =
|
||||
open_watched_reader(&handle, "evil.mp4", meta.len(), mtime_ms(&meta)).unwrap_err();
|
||||
assert!(
|
||||
err.contains("could not be opened"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
// And the scanner never lists it in the first place.
|
||||
assert!(scan_dir(&handle).unwrap().is_empty());
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let _ = fs::remove_file(&secret);
|
||||
}
|
||||
}
|
||||
@@ -300,7 +300,7 @@ function App() {
|
||||
mode === 'settings' ||
|
||||
mode === 'voice' ||
|
||||
mode === 'donate' ||
|
||||
mode === 'queue' ||
|
||||
mode === 'batch' ||
|
||||
mode === 'tools' ||
|
||||
mode === 'projects' ||
|
||||
mode === 'gallery' ||
|
||||
@@ -1474,7 +1474,7 @@ function App() {
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'queue' ? (
|
||||
) : mode === 'batch' ? (
|
||||
<ErrorBoundary name="batch-queue">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<BatchQueue onBack={() => setMode('launchpad')} />
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Contract test for the batch API client: /batch/enqueue receives uploaded
|
||||
* File BYTES only. Filesystem paths must never appear in the request — the
|
||||
* watch-folder feature (and everything else) rides the same guarantee.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args) => invoke(...args) }));
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(message, init = {}) {
|
||||
super(message);
|
||||
Object.assign(this, init);
|
||||
}
|
||||
},
|
||||
apiJson: vi.fn(),
|
||||
apiPost: vi.fn(async () => ({ job_id: 'j1', status: 'queued', queue_position: 0 })),
|
||||
apiDelete: vi.fn(),
|
||||
API: 'http://127.0.0.1:3900',
|
||||
}));
|
||||
|
||||
import { apiPost } from './client';
|
||||
import { enqueueBatchJob } from './batch';
|
||||
|
||||
describe('enqueueBatchJob', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('uploads the File itself — no filesystem path in any form field', async () => {
|
||||
const file = new File(['bytes'], 'clip.mp4', { type: 'video/mp4' });
|
||||
// Desktop-flavored File objects sometimes carry a path property; even if
|
||||
// one sneaks in, the client must not serialize it.
|
||||
Object.defineProperty(file, 'path', { value: '/Users/me/Watched/clip.mp4' });
|
||||
|
||||
await enqueueBatchJob(file, ['es', 'fr'], 'voice-1', false);
|
||||
|
||||
expect(apiPost).toHaveBeenCalledTimes(1);
|
||||
const [url, form] = apiPost.mock.calls[0];
|
||||
expect(url).toBe('/batch/enqueue');
|
||||
expect(form).toBeInstanceOf(FormData);
|
||||
|
||||
expect([...form.keys()].sort()).toEqual(['langs', 'preserve_bg', 'video', 'voice_id']);
|
||||
expect(form.get('video')).toBeInstanceOf(File);
|
||||
expect(form.get('video').name).toBe('clip.mp4');
|
||||
expect(form.get('langs')).toBe('es,fr');
|
||||
expect(form.get('voice_id')).toBe('voice-1');
|
||||
expect(form.get('preserve_bg')).toBe('false');
|
||||
|
||||
for (const [, value] of form.entries()) {
|
||||
if (typeof value === 'string') {
|
||||
expect(value).not.toContain('/Users/me');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('streams native watch entries without constructing FormData in the renderer', async () => {
|
||||
const watched = {
|
||||
__voiceStudioNativeWatchUpload: /** @type {const} */ (true),
|
||||
token: 'a'.repeat(64),
|
||||
name: 'large.mkv',
|
||||
size: 8 * 1024 * 1024 * 1024,
|
||||
mtime: 1234,
|
||||
};
|
||||
invoke.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
body: { job_id: 'native-1', status: 'queued', queue_position: 1 },
|
||||
});
|
||||
|
||||
await expect(enqueueBatchJob(watched, ['es'], '', true)).resolves.toMatchObject({
|
||||
job_id: 'native-1',
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('watch_folder_enqueue', {
|
||||
token: watched.token,
|
||||
name: 'large.mkv',
|
||||
expectedSize: watched.size,
|
||||
expectedMtime: watched.mtime,
|
||||
langs: ['es'],
|
||||
voiceId: '',
|
||||
preserveBg: true,
|
||||
});
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves structured backend errors from native watch uploads', async () => {
|
||||
const detail = { error: 'asr_model_missing', recommended: { repo_id: 'model/repo' } };
|
||||
invoke.mockResolvedValueOnce({ status: 409, body: { detail } });
|
||||
const watched = {
|
||||
__voiceStudioNativeWatchUpload: /** @type {const} */ (true),
|
||||
token: 'b'.repeat(64),
|
||||
name: 'clip.mp4',
|
||||
size: 4,
|
||||
mtime: 1,
|
||||
};
|
||||
|
||||
await expect(enqueueBatchJob(watched, ['es'])).rejects.toMatchObject({ status: 409, detail });
|
||||
});
|
||||
|
||||
it('the batch client source never touches a path at all', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), 'batch.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
// No `path` identifier anywhere: not a form field, not a query param, not
|
||||
// a property read. (Regression guard for the watch-folder rule that the
|
||||
// backend only ever receives uploaded bytes.)
|
||||
expect(source).not.toMatch(/[^A-Za-z]path/i);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* Used by BatchQueue and BatchAddDialog to enqueue, monitor, and
|
||||
* manage batch dub jobs.
|
||||
*/
|
||||
import { apiJson, apiPost, apiDelete, API } from './client';
|
||||
import { apiJson, apiPost, apiDelete, ApiError } from './client';
|
||||
|
||||
export interface BatchJob {
|
||||
id: string;
|
||||
@@ -28,6 +28,18 @@ export interface BatchJob {
|
||||
outputs?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface NativeWatchUpload {
|
||||
__voiceStudioNativeWatchUpload: true;
|
||||
token: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
function isNativeWatchUpload(file: File | NativeWatchUpload): file is NativeWatchUpload {
|
||||
return !(file instanceof File) && file.__voiceStudioNativeWatchUpload === true;
|
||||
}
|
||||
|
||||
/** List batch jobs, optionally filtered by status. */
|
||||
export async function listBatchJobs(status?: string, limit = 50): Promise<BatchJob[]> {
|
||||
const qs = new URLSearchParams();
|
||||
@@ -43,11 +55,31 @@ export async function getBatchJob(id: string): Promise<BatchJob> {
|
||||
|
||||
/** Enqueue a video for batch dubbing. */
|
||||
export async function enqueueBatchJob(
|
||||
file: File,
|
||||
file: File | NativeWatchUpload,
|
||||
langs: string[],
|
||||
voiceId?: string,
|
||||
preserveBg = true,
|
||||
): Promise<{ job_id: string; status: string; queue_position: number }> {
|
||||
if (isNativeWatchUpload(file)) {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const reply = await invoke<{ status: number; body: unknown }>('watch_folder_enqueue', {
|
||||
token: file.token,
|
||||
name: file.name,
|
||||
expectedSize: file.size,
|
||||
expectedMtime: file.mtime,
|
||||
langs,
|
||||
voiceId,
|
||||
preserveBg,
|
||||
});
|
||||
if (reply.status < 200 || reply.status >= 300) {
|
||||
const detail = (reply.body as { detail?: unknown })?.detail ?? reply.body;
|
||||
throw new ApiError(`Watch-folder upload failed (${reply.status})`, {
|
||||
status: reply.status,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
return reply.body as { job_id: string; status: string; queue_position: number };
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('video', file);
|
||||
form.append('langs', langs.join(','));
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FolderOpen, Pause, Play, Square } from 'lucide-react';
|
||||
import { Badge, Button } from '../ui';
|
||||
import toast from 'react-hot-toast';
|
||||
import { createIngestTracker, openWatchSource, WATCH_POLL_MS } from '../utils/watchFolder';
|
||||
|
||||
/**
|
||||
* WatchFolderBar — opt-in "watch a folder" controls for the batch queue
|
||||
* (extracted from BatchQueue so the page stays focused on the job list).
|
||||
*
|
||||
* Pick a directory once; every ~5s the source is rescanned and new, settled
|
||||
* video files are handed to `onIngest(files)` as browser Files or native
|
||||
* capability descriptors. Dedup (name+size+mtime),
|
||||
* pause/resume, and stop-on-unmount live here; the actual enqueue (and its
|
||||
* language/voice settings) stays with the parent.
|
||||
*/
|
||||
export default function WatchFolderBar({ onIngest }) {
|
||||
const { t } = useTranslation();
|
||||
const [source, setSource] = useState(null);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [added, setAdded] = useState(0);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const sourceRef = useRef(null);
|
||||
const trackerRef = useRef(null);
|
||||
const pausedRef = useRef(false);
|
||||
const tickingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const startingRef = useRef(false);
|
||||
const startSequenceRef = useRef(0);
|
||||
useEffect(() => {
|
||||
pausedRef.current = paused;
|
||||
}, [paused]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
sourceRef.current?.close();
|
||||
sourceRef.current = null;
|
||||
trackerRef.current = null;
|
||||
setSource(null);
|
||||
setPaused(false);
|
||||
setAdded(0);
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (startingRef.current) return;
|
||||
startingRef.current = true;
|
||||
setStarting(true);
|
||||
const sequence = ++startSequenceRef.current;
|
||||
let next = null;
|
||||
try {
|
||||
next = await openWatchSource();
|
||||
if (!next) return; // picker cancelled
|
||||
const tracker = createIngestTracker();
|
||||
// Files already sitting in the folder are not "new" — only arrivals
|
||||
// after this point get enqueued.
|
||||
tracker.prime(await next.listEntries());
|
||||
if (!mountedRef.current || startSequenceRef.current !== sequence) {
|
||||
next.close();
|
||||
return;
|
||||
}
|
||||
sourceRef.current = next;
|
||||
trackerRef.current = tracker;
|
||||
setAdded(0);
|
||||
setPaused(false);
|
||||
setSource(next);
|
||||
toast.success(t('batch.watch_started', { folder: next.label }));
|
||||
} catch (e) {
|
||||
next?.close();
|
||||
if (mountedRef.current && startSequenceRef.current === sequence) {
|
||||
if (e?.code === 'watch-unsupported') {
|
||||
toast.error(t('batch.watch_unsupported'));
|
||||
} else {
|
||||
// Actionable message for the user; a sanitized code (never the raw
|
||||
// error — fs messages can carry filesystem paths) to the console.
|
||||
console.warn('watch folder start failed:', e?.code || e?.name || 'unknown error');
|
||||
toast.error(t('batch.watch_start_failed'));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (startSequenceRef.current === sequence) startingRef.current = false;
|
||||
if (mountedRef.current && startSequenceRef.current === sequence) setStarting(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const tick = useCallback(async () => {
|
||||
const src = sourceRef.current;
|
||||
const tracker = trackerRef.current;
|
||||
if (!src || !tracker || pausedRef.current || tickingRef.current) return;
|
||||
tickingRef.current = true;
|
||||
// Still the poll that started this pass, in a component that is still
|
||||
// mounted? Checked after every await — pause, Stop, and unmount can all
|
||||
// land while a scan/read/upload is in flight, and no work may continue
|
||||
// (and nothing may enter the queue) once they have.
|
||||
const live = () => mountedRef.current && sourceRef.current === src;
|
||||
try {
|
||||
const fresh = tracker.next(await src.listEntries());
|
||||
// Strictly one file at a time — read → enqueue → release — so at most
|
||||
// one video's bytes are ever resident in the webview. Uploads stream
|
||||
// from the File parts; holding N settled videos before the first
|
||||
// enqueue is how a big drop would exhaust renderer memory.
|
||||
for (const entry of fresh) {
|
||||
if (!live() || pausedRef.current) {
|
||||
// Deferred, not failed: hand the known-stable entry back so it
|
||||
// ingests on the next unpaused poll.
|
||||
tracker.unsee(entry);
|
||||
continue;
|
||||
}
|
||||
let file;
|
||||
try {
|
||||
file = await src.readFile(entry);
|
||||
} catch {
|
||||
// A transient read failure (vanished, changed after settling,
|
||||
// locked) must not permanently consume the entry. If it vanished,
|
||||
// later scans simply clear it from pending.
|
||||
tracker.retry(entry);
|
||||
continue;
|
||||
}
|
||||
if (!live() || pausedRef.current) {
|
||||
tracker.unsee(entry);
|
||||
continue;
|
||||
}
|
||||
let accepted;
|
||||
try {
|
||||
accepted = await onIngest([file]);
|
||||
} catch {
|
||||
tracker.retry(entry);
|
||||
continue;
|
||||
}
|
||||
// BatchQueue returns the exact upload candidates it accepted. Keep
|
||||
// undefined/true/positive-count compatible with simpler consumers.
|
||||
const wasAccepted =
|
||||
accepted instanceof Set ? accepted.has(file) : accepted !== false && accepted !== 0;
|
||||
// (A pause landing while the enqueue itself was in flight is fine:
|
||||
// the backend already accepted the job — pausing only stops future
|
||||
// ingests, so the file stays counted.)
|
||||
if (wasAccepted) {
|
||||
if (live()) setAdded((n) => n + 1);
|
||||
} else {
|
||||
tracker.retry(entry);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// The folder itself became unreadable (unmounted, deleted, permission
|
||||
// revoked) — stop loudly rather than failing silently every 5s. Log a
|
||||
// sanitized code only: fs errors can carry filesystem paths.
|
||||
if (live()) {
|
||||
console.warn('watch folder scan failed:', e?.code || e?.name || 'unknown error');
|
||||
stop();
|
||||
toast.error(t('batch.watch_failed'));
|
||||
}
|
||||
} finally {
|
||||
tickingRef.current = false;
|
||||
}
|
||||
}, [onIngest, stop, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) return undefined;
|
||||
const iv = setInterval(tick, WATCH_POLL_MS);
|
||||
return () => clearInterval(iv);
|
||||
}, [source, tick]);
|
||||
|
||||
// Stop on unmount: release the native authorization and the poll timer,
|
||||
// and clear the refs so an in-flight tick can never treat its stale source
|
||||
// as current and enqueue (or toast) after the component is gone.
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false;
|
||||
startSequenceRef.current += 1;
|
||||
sourceRef.current?.close();
|
||||
sourceRef.current = null;
|
||||
trackerRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (!source) {
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={start}
|
||||
disabled={starting}
|
||||
loading={starting}
|
||||
leading={!starting && <FolderOpen size={11} />}
|
||||
>
|
||||
{t('batch.watch_folder')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-[var(--space-2)]"
|
||||
title={source.path}
|
||||
data-testid="watch-folder-active"
|
||||
>
|
||||
<Badge tone={paused ? 'warn' : 'brand'} dot>
|
||||
<FolderOpen size={10} />
|
||||
{paused
|
||||
? t('batch.watch_paused', { folder: source.label })
|
||||
: t('batch.watching', { folder: source.label })}
|
||||
</Badge>
|
||||
<span className="text-[var(--text-xs)] text-fg-subtle [font-variant-numeric:tabular-nums]">
|
||||
{t('batch.watch_added', { count: added })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setPaused((p) => !p)}
|
||||
leading={paused ? <Play size={10} /> : <Pause size={10} />}
|
||||
>
|
||||
{paused ? t('batch.watch_resume') : t('batch.watch_pause')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="xs" onClick={stop} leading={<Square size={10} />}>
|
||||
{t('batch.watch_stop')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const openWatchSource = vi.fn();
|
||||
vi.mock('../utils/watchFolder', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
// Fast poll so tests exercise the real interval loop without fake timers.
|
||||
WATCH_POLL_MS: 20,
|
||||
openWatchSource: (...args) => openWatchSource(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const toastSuccess = vi.fn();
|
||||
const toastError = vi.fn();
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { success: (...a) => toastSuccess(...a), error: (...a) => toastError(...a) },
|
||||
}));
|
||||
|
||||
import WatchFolderBar from './WatchFolderBar';
|
||||
|
||||
/** In-memory watch source standing in for the Tauri/FS-Access backends. */
|
||||
function makeSource(label = 'Drop') {
|
||||
const files = new Map(); // name → {size, mtime, bytes}
|
||||
return {
|
||||
label,
|
||||
path: `/watched/${label}`,
|
||||
files,
|
||||
closed: false,
|
||||
listEntries: vi.fn(async () =>
|
||||
[...files.entries()].map(([name, f]) => ({ name, size: f.size, mtime: f.mtime })),
|
||||
),
|
||||
readFile: vi.fn(
|
||||
async (entry) => new File([files.get(entry.name).bytes], entry.name, { type: 'video/mp4' }),
|
||||
),
|
||||
close() {
|
||||
this.closed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 70)); // > 2 polls at 20ms
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('WatchFolderBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('auto-ingests a new video as File objects after it settles, exactly once', async () => {
|
||||
const source = makeSource();
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
const onIngest = vi.fn().mockResolvedValue(undefined);
|
||||
render(<WatchFolderBar onIngest={onIngest} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await screen.findByTestId('watch-folder-active');
|
||||
|
||||
// Drop a new mp4 into the "folder" — no Add-to-queue interaction at all.
|
||||
source.files.set('new clip.mp4', { size: 7, mtime: 42, bytes: 'content' });
|
||||
await waitFor(() => expect(onIngest).toHaveBeenCalledTimes(1));
|
||||
|
||||
const files = onIngest.mock.calls[0][0];
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toBeInstanceOf(File);
|
||||
expect(files[0].name).toBe('new clip.mp4');
|
||||
expect(files[0].size).toBe(7);
|
||||
|
||||
// Unchanged on later polls → deduped, never enqueued twice.
|
||||
await settle();
|
||||
expect(onIngest).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('1 auto-added')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not enqueue files that were already in the folder when watching started', async () => {
|
||||
const source = makeSource();
|
||||
source.files.set('existing.mp4', { size: 3, mtime: 1, bytes: 'old' });
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
const onIngest = vi.fn();
|
||||
render(<WatchFolderBar onIngest={onIngest} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await screen.findByTestId('watch-folder-active');
|
||||
await settle();
|
||||
expect(onIngest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores non-video files', async () => {
|
||||
const source = makeSource();
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
const onIngest = vi.fn();
|
||||
render(<WatchFolderBar onIngest={onIngest} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await screen.findByTestId('watch-folder-active');
|
||||
source.files.set('notes.txt', { size: 2, mtime: 5, bytes: 'hi' });
|
||||
await settle();
|
||||
expect(onIngest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pause stops ingesting; resume picks new files back up', async () => {
|
||||
const source = makeSource();
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
const onIngest = vi.fn().mockResolvedValue(undefined);
|
||||
render(<WatchFolderBar onIngest={onIngest} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await screen.findByTestId('watch-folder-active');
|
||||
|
||||
fireEvent.click(screen.getByText('Pause'));
|
||||
source.files.set('while-paused.mp4', { size: 9, mtime: 9, bytes: 'x' });
|
||||
await settle();
|
||||
expect(onIngest).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByText('Resume'));
|
||||
await waitFor(() => expect(onIngest).toHaveBeenCalledTimes(1));
|
||||
expect(onIngest.mock.calls[0][0][0].name).toBe('while-paused.mp4');
|
||||
});
|
||||
|
||||
it('Stop releases the source and returns to the idle button', async () => {
|
||||
const source = makeSource();
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
render(<WatchFolderBar onIngest={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await screen.findByTestId('watch-folder-active');
|
||||
|
||||
fireEvent.click(screen.getByText('Stop'));
|
||||
expect(source.closed).toBe(true);
|
||||
expect(screen.getByText('Watch folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('unmount stops the watcher: source closed, polling ends', async () => {
|
||||
const source = makeSource();
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
const { unmount } = render(<WatchFolderBar onIngest={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await screen.findByTestId('watch-folder-active');
|
||||
|
||||
unmount();
|
||||
expect(source.closed).toBe(true);
|
||||
const calls = source.listEntries.mock.calls.length;
|
||||
await settle();
|
||||
expect(source.listEntries.mock.calls.length).toBe(calls);
|
||||
});
|
||||
|
||||
it('shows an actionable message where folder watching is unsupported (web-only)', async () => {
|
||||
const err = new Error('Folder watching is unavailable in this browser');
|
||||
err.code = 'watch-unsupported';
|
||||
openWatchSource.mockRejectedValue(err);
|
||||
render(<WatchFolderBar onIngest={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await waitFor(() =>
|
||||
expect(toastError).toHaveBeenCalledWith(
|
||||
'Folder watching needs the desktop app or a Chromium-based browser — use Add Videos here instead.',
|
||||
),
|
||||
);
|
||||
// Still idle — nothing started.
|
||||
expect(screen.queryByTestId('watch-folder-active')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('a cancelled picker changes nothing', async () => {
|
||||
openWatchSource.mockResolvedValue(null);
|
||||
render(<WatchFolderBar onIngest={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await settle();
|
||||
expect(screen.queryByTestId('watch-folder-active')).not.toBeInTheDocument();
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes a native source when its initial directory scan fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const source = makeSource();
|
||||
source.listEntries.mockRejectedValue(new Error('cannot scan'));
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
render(<WatchFolderBar onIngest={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await waitFor(() => expect(source.closed).toBe(true));
|
||||
// Stable actionable message; the raw error goes to the console only.
|
||||
expect(toastError.mock.calls[0][0]).toMatch(/pick it again to retry/);
|
||||
expect(toastError.mock.calls[0][0]).not.toContain('cannot scan');
|
||||
expect(screen.queryByTestId('watch-folder-active')).not.toBeInTheDocument();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('closes a source returned after the component unmounts', async () => {
|
||||
const pending = deferred();
|
||||
const source = makeSource();
|
||||
openWatchSource.mockReturnValue(pending.promise);
|
||||
const { unmount } = render(<WatchFolderBar onIngest={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
unmount();
|
||||
pending.resolve(source);
|
||||
await waitFor(() => expect(source.closed).toBe(true));
|
||||
expect(toastSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows only one folder picker while startup is pending', async () => {
|
||||
const pending = deferred();
|
||||
openWatchSource.mockReturnValue(pending.promise);
|
||||
render(<WatchFolderBar onIngest={vi.fn()} />);
|
||||
|
||||
const button = screen.getByText('Watch folder').closest('button');
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
expect(openWatchSource).toHaveBeenCalledTimes(1);
|
||||
pending.resolve(null);
|
||||
await waitFor(() => expect(button).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it('retries a stable entry after a transient read failure', async () => {
|
||||
const source = makeSource();
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
const onIngest = vi.fn().mockResolvedValue(1);
|
||||
render(<WatchFolderBar onIngest={onIngest} />);
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await screen.findByTestId('watch-folder-active');
|
||||
|
||||
source.files.set('retry.mp4', { size: 5, mtime: 7, bytes: 'video' });
|
||||
source.readFile.mockRejectedValueOnce(new Error('temporarily locked'));
|
||||
await waitFor(() => expect(source.readFile).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(onIngest).toHaveBeenCalledTimes(1));
|
||||
expect(screen.getByText('1 auto-added')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('retries when the queue rejects an otherwise readable entry', async () => {
|
||||
const source = makeSource();
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
const onIngest = vi.fn().mockResolvedValueOnce(0).mockResolvedValueOnce(1);
|
||||
render(<WatchFolderBar onIngest={onIngest} />);
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await screen.findByTestId('watch-folder-active');
|
||||
|
||||
source.files.set('retry.mp4', { size: 5, mtime: 7, bytes: 'video' });
|
||||
await waitFor(() => expect(onIngest).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByText('1 auto-added')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stops loudly with an actionable message when the watched folder becomes unreadable', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const source = makeSource();
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
render(<WatchFolderBar onIngest={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await screen.findByTestId('watch-folder-active');
|
||||
|
||||
source.listEntries.mockRejectedValue(new Error('EACCES: permission denied /watched/Drop'));
|
||||
await waitFor(() => expect(toastError).toHaveBeenCalled());
|
||||
// The toast is a stable actionable string, and the console warning is a
|
||||
// sanitized code — neither may carry technical detail or filesystem paths.
|
||||
expect(toastError.mock.calls[0][0]).toMatch(/Pick it again to resume/);
|
||||
expect(toastError.mock.calls[0][0]).not.toContain('EACCES');
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain('/watched/Drop');
|
||||
expect(source.closed).toBe(true);
|
||||
expect(screen.getByText('Watch folder')).toBeInTheDocument();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('a poll that straddles unmount neither enqueues nor toasts', async () => {
|
||||
const source = makeSource();
|
||||
const realList = source.listEntries.getMockImplementation();
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
const onIngest = vi.fn().mockResolvedValue(undefined);
|
||||
const { unmount } = render(<WatchFolderBar onIngest={onIngest} />);
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await screen.findByTestId('watch-folder-active');
|
||||
|
||||
// A file settles across two normal scans' worth of state, then the scan
|
||||
// that would enqueue it hangs across the unmount.
|
||||
const gates = [];
|
||||
source.listEntries.mockImplementation(
|
||||
() => new Promise((resolve) => gates.push(() => resolve(realList()))),
|
||||
);
|
||||
source.files.set('late.mp4', { size: 5, mtime: 3, bytes: 'late' });
|
||||
await waitFor(() => expect(gates.length).toBe(1));
|
||||
gates[0](); // pending
|
||||
await waitFor(() => expect(gates.length).toBe(2));
|
||||
unmount(); // …and the in-flight releasing scan resolves afterwards
|
||||
gates[1]();
|
||||
await settle();
|
||||
expect(onIngest).not.toHaveBeenCalled();
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
expect(source.closed).toBe(true);
|
||||
});
|
||||
|
||||
it('a file arriving during a poll that straddles Pause is NOT enqueued until Resume', async () => {
|
||||
const source = makeSource();
|
||||
const realList = source.listEntries.getMockImplementation();
|
||||
openWatchSource.mockResolvedValue(source);
|
||||
const onIngest = vi.fn().mockResolvedValue(undefined);
|
||||
render(<WatchFolderBar onIngest={onIngest} />);
|
||||
fireEvent.click(screen.getByText('Watch folder'));
|
||||
await screen.findByTestId('watch-folder-active');
|
||||
|
||||
// Gate every scan after the prime so each poll is released explicitly.
|
||||
const gates = [];
|
||||
source.listEntries.mockImplementation(
|
||||
() => new Promise((resolve) => gates.push(() => resolve(realList()))),
|
||||
);
|
||||
source.files.set('raced.mp4', { size: 5, mtime: 3, bytes: 'raced' });
|
||||
|
||||
// Scan 1: the new file becomes a pending (settling) candidate.
|
||||
await waitFor(() => expect(gates.length).toBe(1));
|
||||
gates[0]();
|
||||
// Scan 2 is the one that would release + enqueue it. While it is still
|
||||
// in flight, the user pauses — then the scan completes.
|
||||
await waitFor(() => expect(gates.length).toBe(2));
|
||||
fireEvent.click(screen.getByText('Pause'));
|
||||
gates[1]();
|
||||
await settle();
|
||||
expect(onIngest).not.toHaveBeenCalled(); // nothing enters the queue while paused
|
||||
|
||||
// Resume: the handed-back file is ingested on the next poll, exactly once.
|
||||
source.listEntries.mockImplementation(realList);
|
||||
fireEvent.click(screen.getByText('Resume'));
|
||||
await waitFor(() => expect(onIngest).toHaveBeenCalledTimes(1));
|
||||
expect(onIngest.mock.calls[0][0][0].name).toBe('raced.mp4');
|
||||
await settle();
|
||||
expect(onIngest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Trash2,
|
||||
Play,
|
||||
Download,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { Button, Progress } from '../../ui';
|
||||
import { useEffect, useRef } from 'react';
|
||||
@@ -104,6 +105,7 @@ export default function IdleSkeleton({
|
||||
setLandingAdvOpen,
|
||||
dubInstruct,
|
||||
setDubInstruct,
|
||||
onOpenQueue,
|
||||
}) {
|
||||
const youtubeCookieInputRef = useRef(null);
|
||||
useEffect(() => {
|
||||
@@ -487,6 +489,22 @@ export default function IdleSkeleton({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Quiet path into the batch dubbing queue — the queue page
|
||||
itself (many videos × many languages, plus the watch
|
||||
folder) had no UI entry point anywhere in the app. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dub-open-batch-queue"
|
||||
className="mt-[8px] inline-flex cursor-pointer items-center gap-[6px] rounded-[4px] border-0 bg-[rgba(255,255,255,0.02)] px-[8px] py-[4px] text-[0.62rem] text-fg-muted hover:bg-[rgba(255,255,255,0.05)] hover:text-fg"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenQueue?.();
|
||||
}}
|
||||
>
|
||||
<Activity size={11} aria-hidden="true" />
|
||||
<span>{t('dub.batch_queue_link')}</span>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
{/* One decision up front: the target language. Everything else
|
||||
|
||||
@@ -248,6 +248,11 @@ export default function useAppData() {
|
||||
} else if (saved.mode === 'design') {
|
||||
setMode('studio');
|
||||
setDefineMethod('design');
|
||||
} else if (saved.mode === 'queue') {
|
||||
// Legacy shim: the batch queue's mode id is 'batch' (the store's Mode
|
||||
// union); 'queue' was an App.jsx-only id that nothing could set, but
|
||||
// normalize any persisted copy of it rather than strand the restore.
|
||||
setMode('batch');
|
||||
} else if (saved.mode) setMode(saved.mode);
|
||||
if (saved.defineMethod) setDefineMethod(saved.defineMethod);
|
||||
// #983: legacy localStorage state had no shape validation at all — a
|
||||
|
||||
@@ -186,6 +186,17 @@ describe('useAppData omni_ui persistence', () => {
|
||||
expect(persistenceProbe.queuedProviders[0].key).toBe('omni_ui');
|
||||
});
|
||||
|
||||
it("normalizes the legacy persisted 'queue' mode to 'batch' on restore", () => {
|
||||
// Regression: App.jsx used to switch on mode === 'queue' (an id nothing
|
||||
// could set, since the store's Mode union says 'batch'); any persisted
|
||||
// 'queue' would strand the restore on an unrenderable mode.
|
||||
seedOmniUi({ mode: 'queue' });
|
||||
|
||||
renderHook(() => useAppData());
|
||||
|
||||
expect(useAppStore.getState().mode).toBe('batch');
|
||||
});
|
||||
|
||||
it('keeps serialization and physical writes out of a burst and flushes only the latest value', () => {
|
||||
const setItemSpy = watchStorageWrites();
|
||||
renderHook(() => useAppData());
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "... أو الصق عنوان URL للفيديو/YouTube",
|
||||
"ingest": "استيعاب",
|
||||
"pull_captions": "سحب التسميات التوضيحية على YouTube + الترجمات التلقائية",
|
||||
"batch_queue_link": "هل تدبلج مقاطع فيديو كثيرة؟ افتح قائمة انتظار الدُفعات",
|
||||
"youtube_auth": "تسجيل الدخول إلى YouTube (اختياري)",
|
||||
"youtube_cookie_file": "اختيار ملف cookies.txt مُصدَّر",
|
||||
"remove_cookie_file": "إزالة ملف تعريف الارتباط",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "الحفاظ على صوت الخلفية (الموسيقى/FX)",
|
||||
"estimate": "{{videos}} مقاطع الفيديو × {{langs}} اللغة (اللغات) = {{jobs}} الوظيفة (الوظائف)",
|
||||
"select_files_langs": "حدد الملفات واللغات",
|
||||
"add_to_queue": "إضافة إلى قائمة الانتظار"
|
||||
"add_to_queue": "إضافة إلى قائمة الانتظار",
|
||||
"watch_folder": "مراقبة مجلد",
|
||||
"watching": "مراقبة {{folder}}",
|
||||
"watch_paused": "متوقف مؤقتًا — {{folder}}",
|
||||
"watch_added": "تمت إضافة {{count}} تلقائيًا",
|
||||
"watch_pause": "إيقاف مؤقت",
|
||||
"watch_resume": "استئناف",
|
||||
"watch_stop": "إيقاف",
|
||||
"watch_started": "تتم مراقبة {{folder}} — تُضاف مقاطع الفيديو الجديدة إلى قائمة الانتظار تلقائيًا",
|
||||
"watch_unsupported": "تتطلب مراقبة المجلدات تطبيق سطح المكتب أو متصفحًا يعتمد على Chromium — استخدم «إضافة مقاطع فيديو» بدلًا من ذلك.",
|
||||
"watch_failed": "توقفت المراقبة — لم يعد بالإمكان قراءة المجلد (نُقل أو حُذف أو تغيّرت الأذونات). اختره مجددًا للمتابعة.",
|
||||
"watch_start_failed": "تعذّر بدء مراقبة هذا المجلد — اختره مجددًا لإعادة المحاولة."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio معرض",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…oder fügen Sie die YouTube-/Video-URL ein",
|
||||
"ingest": "Verschlucken",
|
||||
"pull_captions": "Rufen Sie YouTube-Untertitel und automatische Übersetzungen ab",
|
||||
"batch_queue_link": "Viele Videos zu synchronisieren? Warteschlange öffnen",
|
||||
"youtube_auth": "YouTube-Anmeldung (optional)",
|
||||
"youtube_cookie_file": "cookies.txt-Export auswählen",
|
||||
"remove_cookie_file": "Cookie-Datei entfernen",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Hintergrundaudio (Musik/FX) beibehalten",
|
||||
"estimate": "{{videos}} Video(s) × {{langs}} Sprache(n) = {{jobs}} Job(s)",
|
||||
"select_files_langs": "Wählen Sie Dateien und Sprachen aus",
|
||||
"add_to_queue": "Zur Warteschlange hinzufügen"
|
||||
"add_to_queue": "Zur Warteschlange hinzufügen",
|
||||
"watch_folder": "Ordner überwachen",
|
||||
"watching": "Überwache {{folder}}",
|
||||
"watch_paused": "Pausiert — {{folder}}",
|
||||
"watch_added": "{{count}} automatisch hinzugefügt",
|
||||
"watch_pause": "Pausieren",
|
||||
"watch_resume": "Fortsetzen",
|
||||
"watch_stop": "Stoppen",
|
||||
"watch_started": "{{folder}} wird überwacht — neue Videos landen automatisch in der Warteschlange",
|
||||
"watch_unsupported": "Ordnerüberwachung erfordert die Desktop-App oder einen Chromium-basierten Browser — nutze stattdessen „Videos hinzufügen“.",
|
||||
"watch_failed": "Ordnerüberwachung gestoppt — der Ordner ist nicht mehr lesbar (verschoben, gelöscht oder Berechtigungen geändert). Wähle ihn erneut aus, um fortzufahren.",
|
||||
"watch_start_failed": "Die Überwachung dieses Ordners konnte nicht gestartet werden — wähle ihn erneut aus, um es noch einmal zu versuchen."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Galerie",
|
||||
|
||||
@@ -1199,6 +1199,7 @@
|
||||
"paste_url": "…or paste YouTube / video URL",
|
||||
"ingest": "Ingest",
|
||||
"pull_captions": "Pull YouTube captions + auto-translations",
|
||||
"batch_queue_link": "Dubbing many videos? Open the batch queue",
|
||||
"youtube_auth": "YouTube sign-in (optional)",
|
||||
"youtube_cookie_file": "Choose a cookies.txt export",
|
||||
"remove_cookie_file": "Remove cookie file",
|
||||
@@ -1636,7 +1637,18 @@
|
||||
"preserve_bg": "Preserve background audio (music/FX)",
|
||||
"estimate": "{{videos}} video(s) × {{langs}} lang(s) = {{jobs}} job(s)",
|
||||
"select_files_langs": "Select files and languages",
|
||||
"add_to_queue": "Add to Queue"
|
||||
"add_to_queue": "Add to Queue",
|
||||
"watch_folder": "Watch folder",
|
||||
"watching": "Watching {{folder}}",
|
||||
"watch_paused": "Paused — {{folder}}",
|
||||
"watch_added": "{{count}} auto-added",
|
||||
"watch_pause": "Pause",
|
||||
"watch_resume": "Resume",
|
||||
"watch_stop": "Stop",
|
||||
"watch_started": "Watching {{folder}} — new videos are added to the queue automatically",
|
||||
"watch_unsupported": "Folder watching needs the desktop app or a Chromium-based browser — use Add Videos here instead.",
|
||||
"watch_failed": "Folder watching stopped — the folder is no longer readable (moved, deleted, or permissions changed). Pick it again to resume.",
|
||||
"watch_start_failed": "Couldn't start watching that folder — pick it again to retry."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Gallery",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…o pegue la URL de YouTube/vídeo",
|
||||
"ingest": "Ingerir",
|
||||
"pull_captions": "Extraiga subtítulos de YouTube + traducciones automáticas",
|
||||
"batch_queue_link": "¿Doblas muchos vídeos? Abre la cola por lotes",
|
||||
"youtube_auth": "Inicio de sesión en YouTube (opcional)",
|
||||
"youtube_cookie_file": "Elegir una exportación cookies.txt",
|
||||
"remove_cookie_file": "Quitar archivo de cookies",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Conservar audio de fondo (música/FX)",
|
||||
"estimate": "{{videos}} vídeo(s) × {{langs}} idioma(s) = {{jobs}} trabajo(s)",
|
||||
"select_files_langs": "Seleccionar archivos e idiomas",
|
||||
"add_to_queue": "Agregar a la cola"
|
||||
"add_to_queue": "Agregar a la cola",
|
||||
"watch_folder": "Vigilar carpeta",
|
||||
"watching": "Vigilando {{folder}}",
|
||||
"watch_paused": "En pausa — {{folder}}",
|
||||
"watch_added": "{{count}} añadidos automáticamente",
|
||||
"watch_pause": "Pausar",
|
||||
"watch_resume": "Reanudar",
|
||||
"watch_stop": "Detener",
|
||||
"watch_started": "Vigilando {{folder}} — los vídeos nuevos se añaden a la cola automáticamente",
|
||||
"watch_unsupported": "La vigilancia de carpetas requiere la aplicación de escritorio o un navegador basado en Chromium — usa Añadir vídeos.",
|
||||
"watch_failed": "La vigilancia se detuvo: la carpeta ya no se puede leer (movida, eliminada o con permisos cambiados). Selecciónala de nuevo para continuar.",
|
||||
"watch_start_failed": "No se pudo empezar a vigilar esa carpeta — selecciónala de nuevo para reintentar."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Galería",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…ou collez l’URL YouTube/vidéo",
|
||||
"ingest": "Ingérer",
|
||||
"pull_captions": "Extrayez les sous-titres YouTube + les traductions automatiques",
|
||||
"batch_queue_link": "Beaucoup de vidéos à doubler ? Ouvrir la file par lots",
|
||||
"youtube_auth": "Connexion YouTube (facultative)",
|
||||
"youtube_cookie_file": "Choisir un export cookies.txt",
|
||||
"remove_cookie_file": "Supprimer le fichier de cookies",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Préserver l'audio de fond (musique/FX)",
|
||||
"estimate": "{{videos}} vidéo(s) × {{langs}} langue(s) = {{jobs}} travail(s)",
|
||||
"select_files_langs": "Sélectionnez les fichiers et les langues",
|
||||
"add_to_queue": "Ajouter à la file d'attente"
|
||||
"add_to_queue": "Ajouter à la file d'attente",
|
||||
"watch_folder": "Surveiller un dossier",
|
||||
"watching": "Surveillance de {{folder}}",
|
||||
"watch_paused": "En pause — {{folder}}",
|
||||
"watch_added": "{{count}} ajoutés automatiquement",
|
||||
"watch_pause": "Pause",
|
||||
"watch_resume": "Reprendre",
|
||||
"watch_stop": "Arrêter",
|
||||
"watch_started": "Surveillance de {{folder}} — les nouvelles vidéos sont ajoutées à la file automatiquement",
|
||||
"watch_unsupported": "La surveillance de dossier nécessite l'application de bureau ou un navigateur basé sur Chromium — utilisez plutôt Ajouter des vidéos.",
|
||||
"watch_failed": "Surveillance arrêtée — le dossier n'est plus lisible (déplacé, supprimé ou permissions modifiées). Sélectionnez-le à nouveau pour reprendre.",
|
||||
"watch_start_failed": "Impossible de commencer à surveiller ce dossier — sélectionnez-le à nouveau pour réessayer."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Galerie",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "...या यूट्यूब/वीडियो यूआरएल पेस्ट करें",
|
||||
"ingest": "निगलना",
|
||||
"pull_captions": "YouTube कैप्शन + ऑटो-अनुवाद खींचें",
|
||||
"batch_queue_link": "कई वीडियो डब कर रहे हैं? बैच कतार खोलें",
|
||||
"youtube_auth": "YouTube साइन-इन (वैकल्पिक)",
|
||||
"youtube_cookie_file": "cookies.txt निर्यात चुनें",
|
||||
"remove_cookie_file": "कुकी फ़ाइल हटाएँ",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "पृष्ठभूमि ऑडियो सुरक्षित रखें (संगीत/एफएक्स)",
|
||||
"estimate": "{{videos}} वीडियो × {{langs}} लैंग्वेज = {{jobs}} कार्य",
|
||||
"select_files_langs": "फ़ाइलें और भाषाएँ चुनें",
|
||||
"add_to_queue": "कतार में जोड़ें"
|
||||
"add_to_queue": "कतार में जोड़ें",
|
||||
"watch_folder": "फ़ोल्डर पर नज़र रखें",
|
||||
"watching": "{{folder}} पर नज़र रखी जा रही है",
|
||||
"watch_paused": "रोका गया — {{folder}}",
|
||||
"watch_added": "{{count}} अपने आप जोड़े गए",
|
||||
"watch_pause": "रोकें",
|
||||
"watch_resume": "फिर शुरू करें",
|
||||
"watch_stop": "बंद करें",
|
||||
"watch_started": "{{folder}} पर नज़र रखी जा रही है — नए वीडियो अपने आप कतार में जुड़ जाते हैं",
|
||||
"watch_unsupported": "फ़ोल्डर पर नज़र रखने के लिए डेस्कटॉप ऐप या Chromium-आधारित ब्राउज़र चाहिए — इसके बजाय वीडियो जोड़ें का उपयोग करें।",
|
||||
"watch_failed": "निगरानी रुक गई — फ़ोल्डर अब पढ़ा नहीं जा सकता (हटाया गया, स्थानांतरित हुआ या अनुमतियाँ बदल गईं)। जारी रखने के लिए उसे फिर से चुनें।",
|
||||
"watch_start_failed": "उस फ़ोल्डर की निगरानी शुरू नहीं हो सकी — फिर से कोशिश करने के लिए उसे दोबारा चुनें।"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio गैलरी",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…atau tempel URL YouTube/video",
|
||||
"ingest": "Menelan",
|
||||
"pull_captions": "Tarik teks YouTube + terjemahan otomatis",
|
||||
"batch_queue_link": "Men-dubbing banyak video? Buka antrean batch",
|
||||
"youtube_auth": "Masuk YouTube (opsional)",
|
||||
"youtube_cookie_file": "Pilih ekspor cookies.txt",
|
||||
"remove_cookie_file": "Hapus berkas kuki",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Pertahankan audio latar belakang (musik/FX)",
|
||||
"estimate": "{{videos}} video × {{langs}} bahasa = {{jobs}} pekerjaan",
|
||||
"select_files_langs": "Pilih file dan bahasa",
|
||||
"add_to_queue": "Tambahkan ke Antrean"
|
||||
"add_to_queue": "Tambahkan ke Antrean",
|
||||
"watch_folder": "Pantau folder",
|
||||
"watching": "Memantau {{folder}}",
|
||||
"watch_paused": "Dijeda — {{folder}}",
|
||||
"watch_added": "{{count}} ditambahkan otomatis",
|
||||
"watch_pause": "Jeda",
|
||||
"watch_resume": "Lanjutkan",
|
||||
"watch_stop": "Hentikan",
|
||||
"watch_started": "Memantau {{folder}} — video baru otomatis masuk ke antrean",
|
||||
"watch_unsupported": "Pemantauan folder memerlukan aplikasi desktop atau peramban berbasis Chromium — gunakan Tambah Video sebagai gantinya.",
|
||||
"watch_failed": "Pemantauan berhenti — folder tidak lagi dapat dibaca (dipindahkan, dihapus, atau izin berubah). Pilih lagi foldernya untuk melanjutkan.",
|
||||
"watch_start_failed": "Tidak dapat mulai memantau folder itu — pilih lagi untuk mencoba ulang."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Galeri",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "...o incolla l'URL di YouTube/video",
|
||||
"ingest": "Ingerire",
|
||||
"pull_captions": "Estrai sottotitoli YouTube + traduzioni automatiche",
|
||||
"batch_queue_link": "Molti video da doppiare? Apri la coda batch",
|
||||
"youtube_auth": "Accesso a YouTube (facoltativo)",
|
||||
"youtube_cookie_file": "Scegli un'esportazione cookies.txt",
|
||||
"remove_cookie_file": "Rimuovi file dei cookie",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Conserva l'audio di sottofondo (musica/effetti)",
|
||||
"estimate": "{{videos}} video/i × {{langs}} lingua/e = {{jobs}} lavoro/i",
|
||||
"select_files_langs": "Seleziona file e lingue",
|
||||
"add_to_queue": "Aggiungi alla coda"
|
||||
"add_to_queue": "Aggiungi alla coda",
|
||||
"watch_folder": "Sorveglia cartella",
|
||||
"watching": "Sorveglianza di {{folder}}",
|
||||
"watch_paused": "In pausa — {{folder}}",
|
||||
"watch_added": "{{count}} aggiunti automaticamente",
|
||||
"watch_pause": "Pausa",
|
||||
"watch_resume": "Riprendi",
|
||||
"watch_stop": "Interrompi",
|
||||
"watch_started": "Sorveglianza di {{folder}} — i nuovi video vengono aggiunti automaticamente alla coda",
|
||||
"watch_unsupported": "La sorveglianza delle cartelle richiede l'app desktop o un browser basato su Chromium — usa invece Aggiungi video.",
|
||||
"watch_failed": "Sorveglianza interrotta — la cartella non è più leggibile (spostata, eliminata o permessi cambiati). Selezionala di nuovo per riprendere.",
|
||||
"watch_start_failed": "Impossibile avviare la sorveglianza di quella cartella — selezionala di nuovo per riprovare."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Galleria",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…または YouTube / ビデオの URL を貼り付けます",
|
||||
"ingest": "摂取する",
|
||||
"pull_captions": "YouTube のキャプションと自動翻訳を取得します",
|
||||
"batch_queue_link": "多くの動画を吹き替えますか?バッチキューを開く",
|
||||
"youtube_auth": "YouTube ログイン(任意)",
|
||||
"youtube_cookie_file": "cookies.txt のエクスポートを選択",
|
||||
"remove_cookie_file": "Cookie ファイルを削除",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "バックグラウンドオーディオの保存 (音楽/FX)",
|
||||
"estimate": "{{videos}} ビデオ × {{langs}} 言語 = {{jobs}} ジョブ",
|
||||
"select_files_langs": "ファイルと言語を選択してください",
|
||||
"add_to_queue": "キューに追加"
|
||||
"add_to_queue": "キューに追加",
|
||||
"watch_folder": "フォルダーを監視",
|
||||
"watching": "{{folder}} を監視中",
|
||||
"watch_paused": "一時停止中 — {{folder}}",
|
||||
"watch_added": "{{count}} 件を自動追加",
|
||||
"watch_pause": "一時停止",
|
||||
"watch_resume": "再開",
|
||||
"watch_stop": "停止",
|
||||
"watch_started": "{{folder}} を監視中 — 新しい動画は自動的にキューへ追加されます",
|
||||
"watch_unsupported": "フォルダー監視にはデスクトップアプリまたはChromiumベースのブラウザーが必要です。代わりに「動画を追加」をご利用ください。",
|
||||
"watch_failed": "フォルダー監視を停止しました — フォルダーを読み取れません(移動・削除・権限変更の可能性)。再開するにはフォルダーを選び直してください。",
|
||||
"watch_start_failed": "そのフォルダーの監視を開始できませんでした。フォルダーを選び直して再試行してください。"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio ギャラリー",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "...또는 YouTube/동영상 URL을 붙여넣으세요.",
|
||||
"ingest": "섭취",
|
||||
"pull_captions": "YouTube 캡션 및 자동 번역 가져오기",
|
||||
"batch_queue_link": "많은 동영상을 더빙하나요? 배치 대기열 열기",
|
||||
"youtube_auth": "YouTube 로그인(선택 사항)",
|
||||
"youtube_cookie_file": "cookies.txt 내보내기 선택",
|
||||
"remove_cookie_file": "쿠키 파일 제거",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "배경 오디오 보존(음악/FX)",
|
||||
"estimate": "{{videos}} 동영상 × {{langs}} 언어 = {{jobs}} 작업",
|
||||
"select_files_langs": "파일 및 언어 선택",
|
||||
"add_to_queue": "대기열에 추가"
|
||||
"add_to_queue": "대기열에 추가",
|
||||
"watch_folder": "폴더 감시",
|
||||
"watching": "{{folder}} 감시 중",
|
||||
"watch_paused": "일시정지됨 — {{folder}}",
|
||||
"watch_added": "{{count}}개 자동 추가됨",
|
||||
"watch_pause": "일시정지",
|
||||
"watch_resume": "재개",
|
||||
"watch_stop": "중지",
|
||||
"watch_started": "{{folder}} 감시 중 — 새 동영상이 자동으로 대기열에 추가됩니다",
|
||||
"watch_unsupported": "폴더 감시에는 데스크톱 앱 또는 Chromium 기반 브라우저가 필요합니다. 대신 동영상 추가를 사용하세요.",
|
||||
"watch_failed": "감시가 중지되었습니다 — 폴더를 더 이상 읽을 수 없습니다(이동, 삭제 또는 권한 변경). 다시 시작하려면 폴더를 다시 선택하세요.",
|
||||
"watch_start_failed": "해당 폴더 감시를 시작할 수 없습니다 — 다시 시도하려면 폴더를 다시 선택하세요."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio 갤러리",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…of plak de YouTube-/video-URL",
|
||||
"ingest": "Innemen",
|
||||
"pull_captions": "Haal YouTube-ondertitels en automatische vertalingen op",
|
||||
"batch_queue_link": "Veel video's te dubben? Open de batchwachtrij",
|
||||
"youtube_auth": "YouTube-aanmelding (optioneel)",
|
||||
"youtube_cookie_file": "Een cookies.txt-export kiezen",
|
||||
"remove_cookie_file": "Cookiebestand verwijderen",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Achtergrondaudio behouden (muziek/FX)",
|
||||
"estimate": "{{videos}} video(s) × {{langs}} taal(s) = {{jobs}} vacature(s)",
|
||||
"select_files_langs": "Selecteer bestanden en talen",
|
||||
"add_to_queue": "Toevoegen aan wachtrij"
|
||||
"add_to_queue": "Toevoegen aan wachtrij",
|
||||
"watch_folder": "Map volgen",
|
||||
"watching": "{{folder}} wordt gevolgd",
|
||||
"watch_paused": "Gepauzeerd — {{folder}}",
|
||||
"watch_added": "{{count}} automatisch toegevoegd",
|
||||
"watch_pause": "Pauzeren",
|
||||
"watch_resume": "Hervatten",
|
||||
"watch_stop": "Stoppen",
|
||||
"watch_started": "{{folder}} wordt gevolgd — nieuwe video's komen automatisch in de wachtrij",
|
||||
"watch_unsupported": "Mappen volgen vereist de desktop-app of een op Chromium gebaseerde browser — gebruik anders Video's toevoegen.",
|
||||
"watch_failed": "Volgen gestopt — de map is niet meer leesbaar (verplaatst, verwijderd of rechten gewijzigd). Kies de map opnieuw om verder te gaan.",
|
||||
"watch_start_failed": "Kon deze map niet volgen — kies de map opnieuw om het nogmaals te proberen."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Galerij",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…lub wklej adres URL YouTube/wideo",
|
||||
"ingest": "Połknąć",
|
||||
"pull_captions": "Pobieraj napisy z YouTube + automatyczne tłumaczenia",
|
||||
"batch_queue_link": "Dubbingujesz wiele filmów? Otwórz kolejkę wsadową",
|
||||
"youtube_auth": "Logowanie do YouTube (opcjonalne)",
|
||||
"youtube_cookie_file": "Wybierz eksport cookies.txt",
|
||||
"remove_cookie_file": "Usuń plik cookie",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Zachowaj dźwięk w tle (muzyka/FX)",
|
||||
"estimate": "{{videos}} filmy × {{langs}} języki = {{jobs}} zadania",
|
||||
"select_files_langs": "Wybierz pliki i języki",
|
||||
"add_to_queue": "Dodaj do kolejki"
|
||||
"add_to_queue": "Dodaj do kolejki",
|
||||
"watch_folder": "Obserwuj folder",
|
||||
"watching": "Obserwowanie {{folder}}",
|
||||
"watch_paused": "Wstrzymano — {{folder}}",
|
||||
"watch_added": "{{count}} dodano automatycznie",
|
||||
"watch_pause": "Wstrzymaj",
|
||||
"watch_resume": "Wznów",
|
||||
"watch_stop": "Zatrzymaj",
|
||||
"watch_started": "Obserwowanie {{folder}} — nowe filmy trafiają do kolejki automatycznie",
|
||||
"watch_unsupported": "Obserwowanie folderów wymaga aplikacji desktopowej lub przeglądarki opartej na Chromium — użyj opcji Dodaj filmy.",
|
||||
"watch_failed": "Obserwowanie zatrzymane — folder nie jest już czytelny (przeniesiony, usunięty lub zmieniono uprawnienia). Wybierz go ponownie, aby wznowić.",
|
||||
"watch_start_failed": "Nie udało się rozpocząć obserwowania tego folderu — wybierz go ponownie, aby spróbować jeszcze raz."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Galeria",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…ou cole o URL do YouTube/vídeo",
|
||||
"ingest": "Ingerir",
|
||||
"pull_captions": "Obtenha legendas + traduções automáticas do YouTube",
|
||||
"batch_queue_link": "Dublando muitos vídeos? Abra a fila em lote",
|
||||
"youtube_auth": "Login no YouTube (opcional)",
|
||||
"youtube_cookie_file": "Escolher uma exportação cookies.txt",
|
||||
"remove_cookie_file": "Remover arquivo de cookies",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Preservar o áudio de fundo (música/FX)",
|
||||
"estimate": "{{videos}} vídeo(s) × {{langs}} idioma(s) = {{jobs}} trabalho(s)",
|
||||
"select_files_langs": "Selecione arquivos e idiomas",
|
||||
"add_to_queue": "Adicionar à fila"
|
||||
"add_to_queue": "Adicionar à fila",
|
||||
"watch_folder": "Monitorar pasta",
|
||||
"watching": "Monitorando {{folder}}",
|
||||
"watch_paused": "Pausado — {{folder}}",
|
||||
"watch_added": "{{count}} adicionados automaticamente",
|
||||
"watch_pause": "Pausar",
|
||||
"watch_resume": "Retomar",
|
||||
"watch_stop": "Parar",
|
||||
"watch_started": "Monitorando {{folder}} — vídeos novos entram na fila automaticamente",
|
||||
"watch_unsupported": "O monitoramento de pastas requer o app para desktop ou um navegador baseado em Chromium — use Adicionar vídeos.",
|
||||
"watch_failed": "Monitoramento interrompido — a pasta não pode mais ser lida (movida, excluída ou permissões alteradas). Selecione-a novamente para retomar.",
|
||||
"watch_start_failed": "Não foi possível começar a monitorar essa pasta — selecione-a novamente para tentar de novo."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Galeria",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…или вставьте URL-адрес YouTube/видео",
|
||||
"ingest": "Заглотить",
|
||||
"pull_captions": "Получение титров YouTube + автопереводы",
|
||||
"batch_queue_link": "Дублируете много видео? Откройте пакетную очередь",
|
||||
"youtube_auth": "Вход в YouTube (необязательно)",
|
||||
"youtube_cookie_file": "Выбрать экспорт cookies.txt",
|
||||
"remove_cookie_file": "Удалить файл cookie",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Сохранять фоновый звук (музыка/эффекты)",
|
||||
"estimate": "{{videos}} видео × {{langs}} языков = {{jobs}} заданий",
|
||||
"select_files_langs": "Выбор файлов и языков",
|
||||
"add_to_queue": "Добавить в очередь"
|
||||
"add_to_queue": "Добавить в очередь",
|
||||
"watch_folder": "Следить за папкой",
|
||||
"watching": "Слежение за {{folder}}",
|
||||
"watch_paused": "Пауза — {{folder}}",
|
||||
"watch_added": "Автоматически добавлено: {{count}}",
|
||||
"watch_pause": "Пауза",
|
||||
"watch_resume": "Возобновить",
|
||||
"watch_stop": "Остановить",
|
||||
"watch_started": "Слежение за {{folder}} — новые видео добавляются в очередь автоматически",
|
||||
"watch_unsupported": "Для слежения за папкой нужны настольное приложение или браузер на базе Chromium — используйте «Добавить видео».",
|
||||
"watch_failed": "Слежение остановлено — папка больше недоступна для чтения (перемещена, удалена или изменены права). Выберите её снова, чтобы продолжить.",
|
||||
"watch_start_failed": "Не удалось начать слежение за этой папкой — выберите её снова, чтобы повторить попытку."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Галерея",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…eller klistra in YouTube/videons URL",
|
||||
"ingest": "Inta",
|
||||
"pull_captions": "Dra YouTube-textning + automatiska översättningar",
|
||||
"batch_queue_link": "Dubbar du många videor? Öppna batchkön",
|
||||
"youtube_auth": "YouTube-inloggning (valfritt)",
|
||||
"youtube_cookie_file": "Välj en cookies.txt-export",
|
||||
"remove_cookie_file": "Ta bort cookie-filen",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Bevara bakgrundsljud (musik/FX)",
|
||||
"estimate": "{{videos}} video(ar) × {{langs}} lång(ar) = {{jobs}} jobb(ar)",
|
||||
"select_files_langs": "Välj filer och språk",
|
||||
"add_to_queue": "Lägg till i kö"
|
||||
"add_to_queue": "Lägg till i kö",
|
||||
"watch_folder": "Bevaka mapp",
|
||||
"watching": "Bevakar {{folder}}",
|
||||
"watch_paused": "Pausad — {{folder}}",
|
||||
"watch_added": "{{count}} automatiskt tillagda",
|
||||
"watch_pause": "Pausa",
|
||||
"watch_resume": "Återuppta",
|
||||
"watch_stop": "Stoppa",
|
||||
"watch_started": "Bevakar {{folder}} — nya videor läggs automatiskt i kön",
|
||||
"watch_unsupported": "Mappbevakning kräver skrivbordsappen eller en Chromium-baserad webbläsare — använd Lägg till videor i stället.",
|
||||
"watch_failed": "Bevakningen stoppades — mappen kan inte längre läsas (flyttad, borttagen eller ändrade behörigheter). Välj den igen för att fortsätta.",
|
||||
"watch_start_failed": "Kunde inte börja bevaka mappen — välj den igen för att försöka på nytt."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Galleri",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…หรือวาง URL ของ YouTube / วิดีโอ",
|
||||
"ingest": "นำเข้า",
|
||||
"pull_captions": "ดึงคำบรรยาย YouTube + การแปลอัตโนมัติ",
|
||||
"batch_queue_link": "พากย์วิดีโอหลายรายการ? เปิดคิวแบบกลุ่ม",
|
||||
"youtube_auth": "ลงชื่อเข้าใช้ YouTube (ไม่บังคับ)",
|
||||
"youtube_cookie_file": "เลือกไฟล์ส่งออก cookies.txt",
|
||||
"remove_cookie_file": "ลบไฟล์คุกกี้",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "รักษาเสียงพื้นหลัง (เพลง/FX)",
|
||||
"estimate": "{{videos}} วิดีโอ × {{langs}} lang(s) = {{jobs}} งาน",
|
||||
"select_files_langs": "เลือกไฟล์และภาษา",
|
||||
"add_to_queue": "เพิ่มเข้าคิว"
|
||||
"add_to_queue": "เพิ่มเข้าคิว",
|
||||
"watch_folder": "เฝ้าดูโฟลเดอร์",
|
||||
"watching": "กำลังเฝ้าดู {{folder}}",
|
||||
"watch_paused": "หยุดชั่วคราว — {{folder}}",
|
||||
"watch_added": "เพิ่มอัตโนมัติแล้ว {{count}} รายการ",
|
||||
"watch_pause": "หยุดชั่วคราว",
|
||||
"watch_resume": "ทำต่อ",
|
||||
"watch_stop": "หยุด",
|
||||
"watch_started": "กำลังเฝ้าดู {{folder}} — วิดีโอใหม่จะถูกเพิ่มเข้าคิวโดยอัตโนมัติ",
|
||||
"watch_unsupported": "การเฝ้าดูโฟลเดอร์ต้องใช้แอปเดสก์ท็อปหรือเบราว์เซอร์ที่ใช้ Chromium — โปรดใช้เพิ่มวิดีโอแทน",
|
||||
"watch_failed": "หยุดการเฝ้าดูแล้ว — ไม่สามารถอ่านโฟลเดอร์ได้อีกต่อไป (ถูกย้าย ถูกลบ หรือสิทธิ์เปลี่ยนไป) เลือกโฟลเดอร์อีกครั้งเพื่อทำต่อ",
|
||||
"watch_start_failed": "เริ่มเฝ้าดูโฟลเดอร์นั้นไม่ได้ — เลือกโฟลเดอร์อีกครั้งเพื่อลองใหม่"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio แกลเลอรี่",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…veya YouTube / video URL'sini yapıştırın",
|
||||
"ingest": "Al",
|
||||
"pull_captions": "YouTube altyazılarını + otomatik çevirileri çekin",
|
||||
"batch_queue_link": "Çok sayıda video mu dubluyorsunuz? Toplu kuyruğu açın",
|
||||
"youtube_auth": "YouTube oturumu (isteğe bağlı)",
|
||||
"youtube_cookie_file": "Bir cookies.txt dışa aktarımı seçin",
|
||||
"remove_cookie_file": "Çerez dosyasını kaldır",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Arka plan sesini koru (müzik/FX)",
|
||||
"estimate": "{{videos}} video(lar) × {{langs}} dil(ler) = {{jobs}} iş(ler)",
|
||||
"select_files_langs": "Dosyaları ve dilleri seçin",
|
||||
"add_to_queue": "Kuyruğa Ekle"
|
||||
"add_to_queue": "Kuyruğa Ekle",
|
||||
"watch_folder": "Klasörü izle",
|
||||
"watching": "{{folder}} izleniyor",
|
||||
"watch_paused": "Duraklatıldı — {{folder}}",
|
||||
"watch_added": "{{count}} otomatik eklendi",
|
||||
"watch_pause": "Duraklat",
|
||||
"watch_resume": "Devam et",
|
||||
"watch_stop": "Durdur",
|
||||
"watch_started": "{{folder}} izleniyor — yeni videolar kuyruğa otomatik olarak eklenir",
|
||||
"watch_unsupported": "Klasör izleme, masaüstü uygulaması veya Chromium tabanlı bir tarayıcı gerektirir — bunun yerine Video Ekle'yi kullanın.",
|
||||
"watch_failed": "İzleme durdu — klasör artık okunamıyor (taşındı, silindi veya izinler değişti). Devam etmek için klasörü yeniden seçin.",
|
||||
"watch_start_failed": "Bu klasör izlenemedi — yeniden denemek için klasörü tekrar seçin."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Galeri",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…або вставте URL-адресу YouTube/відео",
|
||||
"ingest": "Проковтнути",
|
||||
"pull_captions": "Витягніть субтитри YouTube + автоматичний переклад",
|
||||
"batch_queue_link": "Дублюєте багато відео? Відкрийте пакетну чергу",
|
||||
"youtube_auth": "Вхід у YouTube (необов’язково)",
|
||||
"youtube_cookie_file": "Вибрати експорт cookies.txt",
|
||||
"remove_cookie_file": "Видалити файл cookie",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Збереження фонового звуку (музика/FX)",
|
||||
"estimate": "{{videos}} відео × {{langs}} мов = {{jobs}} вакансій",
|
||||
"select_files_langs": "Виберіть файли та мови",
|
||||
"add_to_queue": "Додати в чергу"
|
||||
"add_to_queue": "Додати в чергу",
|
||||
"watch_folder": "Стежити за текою",
|
||||
"watching": "Стеження за {{folder}}",
|
||||
"watch_paused": "Призупинено — {{folder}}",
|
||||
"watch_added": "Автоматично додано: {{count}}",
|
||||
"watch_pause": "Призупинити",
|
||||
"watch_resume": "Відновити",
|
||||
"watch_stop": "Зупинити",
|
||||
"watch_started": "Стеження за {{folder}} — нові відео автоматично додаються до черги",
|
||||
"watch_unsupported": "Для стеження за текою потрібні настільний застосунок або браузер на основі Chromium — скористайтеся «Додати відео».",
|
||||
"watch_failed": "Стеження зупинено — теку більше не можна прочитати (переміщено, видалено або змінено права). Виберіть її знову, щоб продовжити.",
|
||||
"watch_start_failed": "Не вдалося почати стеження за цією текою — виберіть її знову, щоб повторити спробу."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Галерея",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…hoặc dán URL YouTube/video",
|
||||
"ingest": "Nhập",
|
||||
"pull_captions": "Kéo phụ đề YouTube + bản dịch tự động",
|
||||
"batch_queue_link": "Lồng tiếng nhiều video? Mở hàng đợi hàng loạt",
|
||||
"youtube_auth": "Đăng nhập YouTube (tùy chọn)",
|
||||
"youtube_cookie_file": "Chọn bản xuất cookies.txt",
|
||||
"remove_cookie_file": "Xóa tệp cookie",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "Giữ nguyên âm thanh nền (âm nhạc/FX)",
|
||||
"estimate": "{{videos}} video × {{langs}} lang = {{jobs}} công việc",
|
||||
"select_files_langs": "Chọn tập tin và ngôn ngữ",
|
||||
"add_to_queue": "Thêm vào hàng đợi"
|
||||
"add_to_queue": "Thêm vào hàng đợi",
|
||||
"watch_folder": "Theo dõi thư mục",
|
||||
"watching": "Đang theo dõi {{folder}}",
|
||||
"watch_paused": "Đã tạm dừng — {{folder}}",
|
||||
"watch_added": "Đã tự động thêm {{count}}",
|
||||
"watch_pause": "Tạm dừng",
|
||||
"watch_resume": "Tiếp tục",
|
||||
"watch_stop": "Dừng",
|
||||
"watch_started": "Đang theo dõi {{folder}} — video mới sẽ tự động được thêm vào hàng đợi",
|
||||
"watch_unsupported": "Theo dõi thư mục cần ứng dụng máy tính hoặc trình duyệt dựa trên Chromium — hãy dùng Thêm video thay thế.",
|
||||
"watch_failed": "Đã dừng theo dõi — không thể đọc thư mục nữa (đã di chuyển, bị xóa hoặc quyền đã thay đổi). Chọn lại thư mục để tiếp tục.",
|
||||
"watch_start_failed": "Không thể bắt đầu theo dõi thư mục đó — hãy chọn lại để thử lại."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio Thư viện ảnh",
|
||||
|
||||
@@ -915,6 +915,7 @@
|
||||
"paste_url": "…或粘贴 YouTube / 视频链接",
|
||||
"ingest": "导入",
|
||||
"pull_captions": "拉取 YouTube 字幕 + 自动翻译",
|
||||
"batch_queue_link": "要给很多视频配音?打开批量队列",
|
||||
"youtube_auth": "YouTube 登录(可选)",
|
||||
"youtube_cookie_file": "选择导出的 cookies.txt",
|
||||
"remove_cookie_file": "移除 Cookie 文件",
|
||||
@@ -1336,7 +1337,18 @@
|
||||
"preserve_bg": "保留背景音频(音乐/FX)",
|
||||
"estimate": "{{videos}} 视频 × {{langs}} 语言 = {{jobs}} 作业",
|
||||
"select_files_langs": "选择文件和语言",
|
||||
"add_to_queue": "添加到队列"
|
||||
"add_to_queue": "添加到队列",
|
||||
"watch_folder": "监视文件夹",
|
||||
"watching": "正在监视 {{folder}}",
|
||||
"watch_paused": "已暂停 — {{folder}}",
|
||||
"watch_added": "已自动添加 {{count}} 个",
|
||||
"watch_pause": "暂停",
|
||||
"watch_resume": "继续",
|
||||
"watch_stop": "停止",
|
||||
"watch_started": "正在监视 {{folder}} — 新视频会自动加入队列",
|
||||
"watch_unsupported": "文件夹监视需要桌面应用或基于 Chromium 的浏览器 — 请改用“添加视频”。",
|
||||
"watch_failed": "监视已停止 — 文件夹无法再读取(已移动、删除或权限更改)。请重新选择该文件夹以继续。",
|
||||
"watch_start_failed": "无法开始监视该文件夹 — 请重新选择以重试。"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio 音色库",
|
||||
|
||||
@@ -956,6 +956,7 @@
|
||||
"paste_url": "…或貼上 YouTube/影片 URL",
|
||||
"ingest": "攝取",
|
||||
"pull_captions": "擷取 YouTube 字幕 + 自動翻譯",
|
||||
"batch_queue_link": "要為許多影片配音?開啟批次佇列",
|
||||
"youtube_auth": "YouTube 登入(選用)",
|
||||
"youtube_cookie_file": "選擇匯出的 cookies.txt",
|
||||
"remove_cookie_file": "移除 Cookie 檔案",
|
||||
@@ -1377,7 +1378,18 @@
|
||||
"preserve_bg": "保留背景音訊(音樂/FX)",
|
||||
"estimate": "{{videos}} 影片 × {{langs}} 語言 = {{jobs}} 作業",
|
||||
"select_files_langs": "選擇文件和語言",
|
||||
"add_to_queue": "添加到隊列"
|
||||
"add_to_queue": "添加到隊列",
|
||||
"watch_folder": "監看資料夾",
|
||||
"watching": "正在監看 {{folder}}",
|
||||
"watch_paused": "已暫停 — {{folder}}",
|
||||
"watch_added": "已自動加入 {{count}} 部",
|
||||
"watch_pause": "暫停",
|
||||
"watch_resume": "繼續",
|
||||
"watch_stop": "停止",
|
||||
"watch_started": "正在監看 {{folder}} — 新影片會自動加入佇列",
|
||||
"watch_unsupported": "資料夾監看需要桌面應用程式或以 Chromium 為基礎的瀏覽器 — 請改用「新增影片」。",
|
||||
"watch_failed": "監看已停止 — 資料夾已無法讀取(已移動、刪除或權限變更)。請重新選擇資料夾以繼續。",
|
||||
"watch_start_failed": "無法開始監看該資料夾 — 請重新選擇以重試。"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "VoiceStudio 畫廊",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../api/batch';
|
||||
import { API } from '../api/client';
|
||||
import BatchAddDialog from '../components/BatchAddDialog';
|
||||
import WatchFolderBar from '../components/WatchFolderBar';
|
||||
import toast from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { asrMissingPayload, toastAsrModelMissing } from '../utils/asrModelMissing';
|
||||
@@ -74,6 +75,14 @@ export default function BatchQueue({ onBack }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
// Settings the watch-folder ingest reuses: the last Add-to-queue submission,
|
||||
// or the dialog's own defaults before any manual enqueue this session.
|
||||
const lastSettingsRef = useRef({
|
||||
langs: [{ lang: 'Spanish', code: 'es' }],
|
||||
voiceId: '',
|
||||
preserveBg: true,
|
||||
});
|
||||
|
||||
// Ids last seen queued/running. The 'active' filter excludes finished jobs
|
||||
// server-side, so a job VANISHING from the active list is the completion
|
||||
// signal — resolve its final status to tell done apart from failed/cancelled.
|
||||
@@ -123,8 +132,10 @@ export default function BatchQueue({ onBack }) {
|
||||
|
||||
const handleEnqueue = useCallback(
|
||||
async (files, settings) => {
|
||||
lastSettingsRef.current = settings;
|
||||
const langCodes = settings.langs.map((l) => l.code);
|
||||
let success = 0;
|
||||
const successfulFiles = new Set();
|
||||
for (const file of files) {
|
||||
try {
|
||||
await enqueueBatchJob(
|
||||
@@ -134,6 +145,7 @@ export default function BatchQueue({ onBack }) {
|
||||
settings.preserveBg,
|
||||
);
|
||||
success++;
|
||||
successfulFiles.add(file);
|
||||
} catch (e) {
|
||||
const missing = asrMissingPayload(e);
|
||||
if (missing) {
|
||||
@@ -153,10 +165,18 @@ export default function BatchQueue({ onBack }) {
|
||||
setTab('active');
|
||||
reload();
|
||||
}
|
||||
return successfulFiles;
|
||||
},
|
||||
[t, reload],
|
||||
);
|
||||
|
||||
// Watch-folder arrivals go through the exact same enqueue path (File
|
||||
// uploads to POST /batch/enqueue) with the last-used / default settings.
|
||||
const handleWatchIngest = useCallback(
|
||||
(files) => handleEnqueue(files, lastSettingsRef.current),
|
||||
[handleEnqueue],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(
|
||||
async (id) => {
|
||||
try {
|
||||
@@ -199,6 +219,7 @@ export default function BatchQueue({ onBack }) {
|
||||
<Activity size={15} /> {t('batch.title')}
|
||||
</div>
|
||||
<div className="batch-queue__bar-spacer flex-1" />
|
||||
<WatchFolderBar onIngest={handleWatchIngest} />
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
|
||||
@@ -705,6 +705,7 @@ export default function DubTab(props) {
|
||||
setLandingAdvOpen={setLandingAdvOpen}
|
||||
dubInstruct={dubInstruct}
|
||||
setDubInstruct={setDubInstruct}
|
||||
onOpenQueue={() => useAppStore.getState().setMode?.('batch')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -209,4 +209,16 @@ describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => {
|
||||
expect(container.querySelector('.dub-idle-drop')).toBeNull();
|
||||
expect(screen.queryByPlaceholderText(URL_PLACEHOLDER)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the batch-queue entry point from the idle landing without triggering the file picker', () => {
|
||||
// Regression: BatchQueue (and its watch folder) had NO entry point
|
||||
// anywhere in the UI — App.jsx switched on a mode nothing ever set.
|
||||
const onOpenQueue = vi.fn();
|
||||
const setDubVideoFile = vi.fn();
|
||||
renderIdle({ dubStep: 'idle', dubJobId: null, onOpenQueue, setDubVideoFile });
|
||||
|
||||
fireEvent.click(screen.getByTestId('dub-open-batch-queue'));
|
||||
expect(onOpenQueue).toHaveBeenCalledOnce();
|
||||
expect(setDubVideoFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Watch-folder ingest for the batch dubbing queue (opt-in, local-only).
|
||||
*
|
||||
* A watch source hands back directory listings and upload candidates; the UI
|
||||
* (WatchFolderBar) polls it every ~5s and pushes NEW videos through the same
|
||||
* `enqueueBatchJob` API as the Add-to-queue dialog. Two backends:
|
||||
*
|
||||
* - Tauri (desktop, all three OSes): a native folder pick registers the
|
||||
* directory under a session token in the Rust process
|
||||
* (`src-tauri/src/watch_folder.rs`); scans and native streamed uploads
|
||||
* resolve the token. Filesystem paths NEVER ride an HTTP request — the
|
||||
* backend only receives multipart bytes.
|
||||
* - Browsers with the File System Access API (Chromium): a directory
|
||||
* handle from `showDirectoryPicker()` is polled directly.
|
||||
*
|
||||
* Everything else (Firefox/Safari web builds) throws `watch-unsupported`,
|
||||
* which the UI turns into an actionable message.
|
||||
*/
|
||||
import { isTauriContext } from './apiBase';
|
||||
import { API } from '../api/client';
|
||||
|
||||
/** Poll cadence — no native fs-watch plugin ships in this repo, so both
|
||||
* backends rescan on a timer. 5s keeps ingest snappy without disk churn. */
|
||||
export const WATCH_POLL_MS = 5000;
|
||||
|
||||
// Container formats the dub pipeline's ffmpeg extract stage accepts. Watch
|
||||
// entries carry no MIME type, so filtering is by extension (lowercased).
|
||||
const VIDEO_EXTENSIONS = new Set(['mp4', 'm4v', 'mov', 'mkv', 'webm', 'avi', 'mpg', 'mpeg', 'wmv']);
|
||||
|
||||
const MIME_BY_EXTENSION = {
|
||||
mp4: 'video/mp4',
|
||||
m4v: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
mkv: 'video/x-matroska',
|
||||
webm: 'video/webm',
|
||||
avi: 'video/x-msvideo',
|
||||
mpg: 'video/mpeg',
|
||||
mpeg: 'video/mpeg',
|
||||
wmv: 'video/x-ms-wmv',
|
||||
};
|
||||
|
||||
function extensionOf(name) {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
/** True when a bare filename looks like a video the batch pipeline can dub. */
|
||||
export function isVideoFile(name) {
|
||||
return VIDEO_EXTENSIONS.has(extensionOf(typeof name === 'string' ? name : ''));
|
||||
}
|
||||
|
||||
export function videoMimeFor(name) {
|
||||
return MIME_BY_EXTENSION[extensionOf(name)] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/** Dedup identity: name + size + mtime. A re-listed unchanged file is the
|
||||
* same key (skipped); a rewritten/renamed file is a new key (re-ingested). */
|
||||
export function entryKey(entry) {
|
||||
return `${entry.name}\u0000${entry.size}\u0000${entry.mtime}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which directory entries have already been ingested (or predate the
|
||||
* watch) and which are still settling.
|
||||
*
|
||||
* - `prime(entries)` marks everything currently in the folder as seen —
|
||||
* starting a watch must not enqueue the folder's existing contents.
|
||||
* - `next(entries)` returns the video entries that are new AND stable: a
|
||||
* candidate is only released once two consecutive scans agree on its
|
||||
* name+size+mtime, so a large file still being copied in (size/mtime moving
|
||||
* between polls) is never uploaded half-written.
|
||||
*/
|
||||
export function createIngestTracker() {
|
||||
const seen = new Set();
|
||||
const pending = new Map(); // name → key awaiting a confirming rescan
|
||||
|
||||
return {
|
||||
prime(entries) {
|
||||
for (const entry of entries) seen.add(entryKey(entry));
|
||||
},
|
||||
next(entries) {
|
||||
const ready = [];
|
||||
const present = new Set();
|
||||
for (const entry of entries) {
|
||||
if (!isVideoFile(entry.name)) continue;
|
||||
present.add(entry.name);
|
||||
const key = entryKey(entry);
|
||||
if (seen.has(key)) {
|
||||
pending.delete(entry.name);
|
||||
continue;
|
||||
}
|
||||
if (pending.get(entry.name) === key) {
|
||||
seen.add(key);
|
||||
pending.delete(entry.name);
|
||||
ready.push(entry);
|
||||
} else {
|
||||
pending.set(entry.name, key); // new or still changing — wait a poll
|
||||
}
|
||||
}
|
||||
for (const name of pending.keys()) {
|
||||
if (!present.has(name)) pending.delete(name); // vanished mid-copy
|
||||
}
|
||||
return ready;
|
||||
},
|
||||
/** Hand a released entry back untouched (the watcher was paused or torn
|
||||
* down mid-poll before it could enqueue): the file is known-stable, so
|
||||
* it re-releases on the very next unpaused scan. */
|
||||
unsee(entry) {
|
||||
const key = entryKey(entry);
|
||||
seen.delete(key);
|
||||
pending.set(entry.name, key);
|
||||
},
|
||||
retry(entry) {
|
||||
// `next` reserves a stable entry before the asynchronous read/upload.
|
||||
// Release that reservation after a transient failure so a later poll
|
||||
// can settle and try the same file again (from scratch — unlike
|
||||
// `unsee`, the failure may mean the file is changing again).
|
||||
seen.delete(entryKey(entry));
|
||||
pending.delete(entry.name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openTauriSource() {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const selection = await invoke('watch_folder_pick');
|
||||
if (!selection) return null; // user cancelled the native picker
|
||||
const { token, path } = selection;
|
||||
// Display label only — the path never leaves the app over HTTP.
|
||||
const label = path.split(/[\\/]/).filter(Boolean).pop() || path;
|
||||
return {
|
||||
label,
|
||||
path,
|
||||
listEntries: () => invoke('watch_folder_scan', { token }),
|
||||
// A lightweight capability descriptor, not file bytes. The batch API
|
||||
// client hands this back to Rust, which streams the pinned file handle
|
||||
// directly to the loopback backend without retaining it in renderer RAM.
|
||||
async readFile(entry) {
|
||||
return {
|
||||
__voiceStudioNativeWatchUpload: true,
|
||||
token,
|
||||
name: entry.name,
|
||||
size: entry.size,
|
||||
mtime: entry.mtime,
|
||||
type: videoMimeFor(entry.name),
|
||||
lastModified: entry.mtime,
|
||||
};
|
||||
},
|
||||
close() {
|
||||
invoke('watch_folder_forget', { token }).catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openBrowserSource() {
|
||||
let handle;
|
||||
try {
|
||||
handle = await window.showDirectoryPicker({ mode: 'read' });
|
||||
} catch (e) {
|
||||
if (e?.name === 'AbortError') return null; // user cancelled the picker
|
||||
throw e;
|
||||
}
|
||||
return {
|
||||
label: handle.name,
|
||||
path: handle.name,
|
||||
async listEntries() {
|
||||
const entries = [];
|
||||
for await (const item of handle.values()) {
|
||||
if (item.kind !== 'file') continue;
|
||||
const file = await item.getFile();
|
||||
entries.push({ name: file.name, size: file.size, mtime: file.lastModified });
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
async readFile(entry) {
|
||||
const fileHandle = await handle.getFileHandle(entry.name);
|
||||
const file = await fileHandle.getFile();
|
||||
// Bind the read to the settled scan snapshot — a file replaced between
|
||||
// settling and reading is refused; its new content re-settles on later
|
||||
// scans and is ingested then.
|
||||
if (file.size !== entry.size || file.lastModified !== entry.mtime) {
|
||||
throw new Error(`Watched file ${entry.name} changed after it was scanned`);
|
||||
}
|
||||
return file;
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user for a folder and return a watch source, or `null` on cancel.
|
||||
* Throws an Error with `code === 'watch-unsupported'` where no local folder
|
||||
* access exists (web build outside Chromium).
|
||||
*/
|
||||
export async function openWatchSource() {
|
||||
if (isTauriContext()) {
|
||||
let localApi = false;
|
||||
try {
|
||||
const endpoint = new URL(API);
|
||||
localApi =
|
||||
endpoint.protocol === 'http:' &&
|
||||
['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname) &&
|
||||
(endpoint.pathname === '/' || endpoint.pathname === '');
|
||||
} catch {
|
||||
/* invalid configured endpoint is not a safe native upload target */
|
||||
}
|
||||
if (!localApi) {
|
||||
const err = new Error('Native folder watching requires the local backend');
|
||||
err.code = 'watch-unsupported';
|
||||
throw err;
|
||||
}
|
||||
return openTauriSource();
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function') {
|
||||
return openBrowserSource();
|
||||
}
|
||||
const err = new Error('Folder watching is unavailable in this browser');
|
||||
err.code = 'watch-unsupported';
|
||||
throw err;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args) => invoke(...args) }));
|
||||
|
||||
import {
|
||||
createIngestTracker,
|
||||
entryKey,
|
||||
isVideoFile,
|
||||
openWatchSource,
|
||||
videoMimeFor,
|
||||
} from './watchFolder';
|
||||
|
||||
const entry = (name, size = 100, mtime = 1000) => ({ name, size, mtime });
|
||||
|
||||
describe('isVideoFile', () => {
|
||||
it('accepts common video containers, case-insensitively', () => {
|
||||
for (const name of ['a.mp4', 'B.MOV', 'c.mkv', 'd.WebM', 'e.avi', 'f.m4v']) {
|
||||
expect(isVideoFile(name), name).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-video and extension-less names', () => {
|
||||
for (const name of ['notes.txt', 'audio.wav', 'subs.srt', 'noext', '.mp4', '']) {
|
||||
expect(isVideoFile(name), name).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('entryKey — dedup identity is name+size+mtime', () => {
|
||||
it('is stable for identical entries and distinct when any part changes', () => {
|
||||
expect(entryKey(entry('a.mp4', 10, 1))).toBe(entryKey(entry('a.mp4', 10, 1)));
|
||||
const base = entryKey(entry('a.mp4', 10, 1));
|
||||
expect(entryKey(entry('b.mp4', 10, 1))).not.toBe(base);
|
||||
expect(entryKey(entry('a.mp4', 11, 1))).not.toBe(base);
|
||||
expect(entryKey(entry('a.mp4', 10, 2))).not.toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createIngestTracker', () => {
|
||||
it('never re-ingests primed (pre-existing) files', () => {
|
||||
const tracker = createIngestTracker();
|
||||
tracker.prime([entry('old.mp4')]);
|
||||
expect(tracker.next([entry('old.mp4')])).toEqual([]);
|
||||
expect(tracker.next([entry('old.mp4')])).toEqual([]);
|
||||
});
|
||||
|
||||
it('releases a new file only after two scans agree (copy-in-progress guard)', () => {
|
||||
const tracker = createIngestTracker();
|
||||
tracker.prime([]);
|
||||
expect(tracker.next([entry('new.mp4', 10, 1)])).toEqual([]); // first sighting
|
||||
expect(tracker.next([entry('new.mp4', 10, 1)])).toEqual([entry('new.mp4', 10, 1)]);
|
||||
// …and exactly once: later identical scans are deduped.
|
||||
expect(tracker.next([entry('new.mp4', 10, 1)])).toEqual([]);
|
||||
expect(tracker.next([entry('new.mp4', 10, 1)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps waiting while a file is still growing', () => {
|
||||
const tracker = createIngestTracker();
|
||||
expect(tracker.next([entry('big.mp4', 10, 1)])).toEqual([]);
|
||||
expect(tracker.next([entry('big.mp4', 20, 2)])).toEqual([]); // changed → not stable
|
||||
expect(tracker.next([entry('big.mp4', 30, 3)])).toEqual([]);
|
||||
expect(tracker.next([entry('big.mp4', 30, 3)])).toEqual([entry('big.mp4', 30, 3)]);
|
||||
});
|
||||
|
||||
it('ignores non-video files entirely', () => {
|
||||
const tracker = createIngestTracker();
|
||||
expect(tracker.next([entry('readme.txt')])).toEqual([]);
|
||||
expect(tracker.next([entry('readme.txt')])).toEqual([]);
|
||||
});
|
||||
|
||||
it('a rewritten file (same name, new size/mtime) is ingested again', () => {
|
||||
const tracker = createIngestTracker();
|
||||
tracker.prime([entry('take.mp4', 10, 1)]);
|
||||
expect(tracker.next([entry('take.mp4', 99, 2)])).toEqual([]);
|
||||
expect(tracker.next([entry('take.mp4', 99, 2)])).toEqual([entry('take.mp4', 99, 2)]);
|
||||
});
|
||||
|
||||
it('forgets a pending file that vanishes before settling', () => {
|
||||
const tracker = createIngestTracker();
|
||||
expect(tracker.next([entry('gone.mp4', 10, 1)])).toEqual([]);
|
||||
expect(tracker.next([])).toEqual([]); // vanished mid-copy
|
||||
// Reappears: must settle across two fresh scans again.
|
||||
expect(tracker.next([entry('gone.mp4', 10, 1)])).toEqual([]);
|
||||
expect(tracker.next([entry('gone.mp4', 10, 1)])).toEqual([entry('gone.mp4', 10, 1)]);
|
||||
});
|
||||
|
||||
it('unsee() hands a released entry back so it re-releases on the next scan', () => {
|
||||
const tracker = createIngestTracker();
|
||||
tracker.next([entry('raced.mp4', 10, 1)]);
|
||||
expect(tracker.next([entry('raced.mp4', 10, 1)])).toEqual([entry('raced.mp4', 10, 1)]);
|
||||
// Released but never enqueued (e.g. paused mid-poll) → given back…
|
||||
tracker.unsee(entry('raced.mp4', 10, 1));
|
||||
// …and released again on the very next scan, exactly once.
|
||||
expect(tracker.next([entry('raced.mp4', 10, 1)])).toEqual([entry('raced.mp4', 10, 1)]);
|
||||
expect(tracker.next([entry('raced.mp4', 10, 1)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('retries a stable file after its asynchronous ingest fails (full re-settle)', () => {
|
||||
const tracker = createIngestTracker();
|
||||
const clip = entry('retry.mp4', 10, 1);
|
||||
expect(tracker.next([clip])).toEqual([]);
|
||||
expect(tracker.next([clip])).toEqual([clip]);
|
||||
tracker.retry(clip);
|
||||
expect(tracker.next([clip])).toEqual([]);
|
||||
expect(tracker.next([clip])).toEqual([clip]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openWatchSource — Tauri backend', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
});
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
});
|
||||
|
||||
it('returns null when the native picker is cancelled', async () => {
|
||||
invoke.mockResolvedValueOnce(null);
|
||||
expect(await openWatchSource()).toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('watch_folder_pick');
|
||||
});
|
||||
|
||||
it('returns a capability descriptor without sending file bytes through IPC', async () => {
|
||||
const token = 'f'.repeat(64);
|
||||
invoke.mockImplementation(async (cmd) => {
|
||||
if (cmd === 'watch_folder_pick') return { token, path: '/Users/me/Watched Drop' };
|
||||
if (cmd === 'watch_folder_scan') return [{ name: 'clip.mp4', size: 4, mtime: 1234 }];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const source = await openWatchSource();
|
||||
expect(source.label).toBe('Watched Drop');
|
||||
|
||||
const entries = await source.listEntries();
|
||||
expect(entries).toEqual([{ name: 'clip.mp4', size: 4, mtime: 1234 }]);
|
||||
|
||||
const file = await source.readFile(entries[0]);
|
||||
expect(file.__voiceStudioNativeWatchUpload).toBe(true);
|
||||
expect(file.token).toBe(token);
|
||||
expect(file.name).toBe('clip.mp4');
|
||||
expect(file.size).toBe(4);
|
||||
expect(file.type).toBe('video/mp4');
|
||||
expect(file.mtime).toBe(1234);
|
||||
|
||||
source.close();
|
||||
expect(invoke).toHaveBeenCalledWith('watch_folder_forget', { token });
|
||||
|
||||
// Scan/forget IPC calls carry only the opaque token. File bytes are never
|
||||
// copied into the renderer; enqueueBatchJob gives the descriptor to the
|
||||
// native streaming command later.
|
||||
for (const [cmd, args] of invoke.mock.calls) {
|
||||
if (cmd === 'watch_folder_pick') continue;
|
||||
expect(JSON.stringify(args)).not.toContain('/Users/me');
|
||||
expect(JSON.stringify(args)).not.toContain('Watched Drop');
|
||||
}
|
||||
expect(invoke.mock.calls.some(([cmd]) => cmd === 'watch_folder_read')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps descriptor memory constant for multi-gigabyte watched files', async () => {
|
||||
const token = 'a'.repeat(64);
|
||||
invoke.mockImplementation(async (cmd) => {
|
||||
if (cmd === 'watch_folder_pick') return { token, path: '/w/Drop' };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const source = await openWatchSource();
|
||||
const size = 8 * 1024 * 1024 * 1024;
|
||||
const file = await source.readFile({ name: 'big.mp4', size, mtime: 7 });
|
||||
expect(file).toMatchObject({
|
||||
__voiceStudioNativeWatchUpload: true,
|
||||
token,
|
||||
name: 'big.mp4',
|
||||
size,
|
||||
mtime: 7,
|
||||
});
|
||||
expect(invoke.mock.calls.some(([cmd]) => cmd === 'watch_folder_read')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openWatchSource — File System Access backend', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
});
|
||||
afterEach(() => {
|
||||
delete window.showDirectoryPicker;
|
||||
});
|
||||
|
||||
function fakeDirectoryHandle(files) {
|
||||
return {
|
||||
name: 'Drop',
|
||||
kind: 'directory',
|
||||
async *values() {
|
||||
for (const file of files) yield { kind: 'file', getFile: async () => file };
|
||||
},
|
||||
async getFileHandle(name) {
|
||||
const file = files.find((f) => f.name === name);
|
||||
if (!file) throw new Error('NotFound');
|
||||
return { getFile: async () => file };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('refuses a file whose bytes changed after the scan settled', async () => {
|
||||
const settled = new File(['settled'], 'clip.mp4', { type: 'video/mp4', lastModified: 111 });
|
||||
window.showDirectoryPicker = vi.fn(async () => fakeDirectoryHandle([settled]));
|
||||
|
||||
const source = await openWatchSource();
|
||||
const [entry] = await source.listEntries();
|
||||
expect(entry).toEqual({ name: 'clip.mp4', size: settled.size, mtime: 111 });
|
||||
|
||||
// Read against the settled snapshot works…
|
||||
expect(await source.readFile(entry)).toBe(settled);
|
||||
// …but a stale snapshot (the file was replaced after settling) is refused.
|
||||
await expect(source.readFile({ ...entry, size: entry.size + 5 })).rejects.toThrow(/changed/);
|
||||
await expect(source.readFile({ ...entry, mtime: 999 })).rejects.toThrow(/changed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openWatchSource — unsupported web context', () => {
|
||||
it('throws an identifiable watch-unsupported error where no folder access exists', async () => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
delete window.showDirectoryPicker;
|
||||
await expect(openWatchSource()).rejects.toMatchObject({ code: 'watch-unsupported' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('videoMimeFor', () => {
|
||||
it('maps known containers and falls back to octet-stream', () => {
|
||||
expect(videoMimeFor('a.mp4')).toBe('video/mp4');
|
||||
expect(videoMimeFor('a.mov')).toBe('video/quicktime');
|
||||
expect(videoMimeFor('a.unknown')).toBe('application/octet-stream');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user