fix: server bug fixes from review

- DB: add threading.Lock on all write methods and multi-step reads
- export.py: check audio extraction return code, raise on failure
- routes/export: counter race condition fix with _counter_lock
- routes/export: delete validation accepts EXPORT_DIR_suffix siblings
- routes/export: evict old finished jobs to prevent unbounded growth
- client plan: fix 10 bugs (mpv IPC, encodePath, input_path sep, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 19:53:38 +02:00
parent 409eb82e5c
commit 39f873bec2
4 changed files with 166 additions and 108 deletions
+77 -53
View File
@@ -152,16 +152,21 @@ export function getFiles(root?: string): Promise<VideoFile[]> {
return get(`/api/files${q}`);
}
// For {path:path} routes, encode each segment individually to preserve slashes
function encodePath(p: string): string {
return p.split("/").map(encodeURIComponent).join("/");
}
export function streamUrl(path: string, root: string, quality: string): string {
return `${serverUrl}/api/stream/${encodeURIComponent(path)}?root=${encodeURIComponent(root)}&quality=${quality}`;
return `${serverUrl}/api/stream/${encodePath(path)}?root=${encodeURIComponent(root)}&quality=${quality}`;
}
export function audioUrl(path: string, root: string): string {
return `${serverUrl}/api/audio/${encodeURIComponent(path)}?root=${encodeURIComponent(root)}`;
return `${serverUrl}/api/audio/${encodePath(path)}?root=${encodeURIComponent(root)}`;
}
export function cacheStatus(path: string, root: string): Promise<Record<string, string>> {
return get(`/api/cache/status/${encodeURIComponent(path)}?root=${encodeURIComponent(root)}`);
return get(`/api/cache/status/${encodePath(path)}?root=${encodeURIComponent(root)}`);
}
// --- Markers & Profiles ---
@@ -311,12 +316,10 @@ export const clipSpan = derived(
);
export const visibleFiles = derived(
[files, hiddenFiles, hideExported, showHidden, markers],
([$files, $hidden, $hideExported, $showHidden, $markers]) => {
const exportedNames = new Set($markers.map(m => m.output_path));
[files, hiddenFiles, showHidden],
([$files, $hidden, $showHidden]) => {
return $files.filter(f => {
if (!$showHidden && $hidden.has(f.name)) return false;
// hideExported filtering would need per-file marker lookup
return true;
});
}
@@ -398,19 +401,21 @@ git commit -m "feat: add WebSocket client for export progress"
**Step 1: Create mpv.rs**
This module spawns mpv with `--input-ipc-server`, then sends JSON IPC commands over the Unix socket.
This module spawns mpv with `--input-ipc-server`, then sends JSON IPC commands over the Unix socket. Uses a persistent BufReader and request_id to correctly handle mpv's interleaved events and responses.
```rust
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::process::{Child, Command};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::{json, Value};
pub struct Mpv {
process: Option<Child>,
socket: Option<UnixStream>,
writer: Option<UnixStream>,
reader: Option<BufReader<UnixStream>>,
socket_path: String,
next_id: AtomicU64,
}
impl Mpv {
@@ -418,13 +423,14 @@ impl Mpv {
let socket_path = format!("/tmp/8cut-mpv-{}", std::process::id());
Mpv {
process: None,
socket: None,
writer: None,
reader: None,
socket_path,
next_id: AtomicU64::new(1),
}
}
pub fn start(&mut self) -> Result<(), String> {
// Kill existing
self.stop();
let child = Command::new("mpv")
@@ -445,7 +451,9 @@ impl Mpv {
std::thread::sleep(std::time::Duration::from_millis(100));
if let Ok(stream) = UnixStream::connect(&self.socket_path) {
stream.set_nonblocking(false).ok();
self.socket = Some(stream);
let reader_stream = stream.try_clone().map_err(|e| e.to_string())?;
self.writer = Some(stream);
self.reader = Some(BufReader::new(reader_stream));
return Ok(());
}
}
@@ -458,55 +466,62 @@ impl Mpv {
child.wait().ok();
}
self.process = None;
self.socket = None;
self.writer = None;
self.reader = None;
std::fs::remove_file(&self.socket_path).ok();
}
pub fn command(&mut self, args: &[&str]) -> Result<(), String> {
let socket = self.socket.as_mut().ok_or("mpv not running")?;
let cmd = json!({ "command": args });
let mut msg = serde_json::to_string(&cmd).unwrap();
msg.push('\n');
socket.write_all(msg.as_bytes()).map_err(|e| e.to_string())?;
/// Send a command and wait for the matching response (by request_id).
/// Skips over asynchronous mpv events while waiting.
fn send_and_recv(&mut self, cmd: Value) -> Result<Value, String> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let writer = self.writer.as_mut().ok_or("mpv not running")?;
let reader = self.reader.as_mut().ok_or("mpv not running")?;
// Read response
let mut reader = BufReader::new(socket.try_clone().map_err(|e| e.to_string())?);
let mut msg_val = cmd;
msg_val["request_id"] = json!(id);
let mut msg = serde_json::to_string(&msg_val).unwrap();
msg.push('\n');
writer.write_all(msg.as_bytes()).map_err(|e| e.to_string())?;
// Read lines until we find the response matching our request_id
let mut line = String::new();
reader.read_line(&mut line).map_err(|e| e.to_string())?;
loop {
line.clear();
reader.read_line(&mut line).map_err(|e| e.to_string())?;
let parsed: Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
// mpv events have "event" key, responses have "request_id"
if parsed.get("request_id").and_then(|v| v.as_u64()) == Some(id) {
return Ok(parsed);
}
// Otherwise it's an async event — skip it
}
}
pub fn command(&mut self, args: &[&str]) -> Result<(), String> {
let resp = self.send_and_recv(json!({ "command": args }))?;
if resp.get("error").and_then(|e| e.as_str()) != Some("success") {
return Err(format!("mpv error: {}", resp.get("error").unwrap_or(&Value::Null)));
}
Ok(())
}
pub fn set_property(&mut self, name: &str, value: Value) -> Result<(), String> {
let socket = self.socket.as_mut().ok_or("mpv not running")?;
let cmd = json!({ "command": ["set_property", name, value] });
let mut msg = serde_json::to_string(&cmd).unwrap();
msg.push('\n');
socket.write_all(msg.as_bytes()).map_err(|e| e.to_string())?;
let mut reader = BufReader::new(socket.try_clone().map_err(|e| e.to_string())?);
let mut line = String::new();
reader.read_line(&mut line).map_err(|e| e.to_string())?;
Ok(())
self.command(&["set_property", name, &value.to_string()])
}
pub fn get_property(&mut self, name: &str) -> Result<Value, String> {
let socket = self.socket.as_mut().ok_or("mpv not running")?;
let cmd = json!({ "command": ["get_property", name] });
let mut msg = serde_json::to_string(&cmd).unwrap();
msg.push('\n');
socket.write_all(msg.as_bytes()).map_err(|e| e.to_string())?;
let mut reader = BufReader::new(socket.try_clone().map_err(|e| e.to_string())?);
let mut line = String::new();
reader.read_line(&mut line).map_err(|e| e.to_string())?;
let resp: Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
let resp = self.send_and_recv(json!({ "command": ["get_property", name] }))?;
if resp.get("error").and_then(|e| e.as_str()) != Some("success") {
return Err(format!("mpv error: {}", resp.get("error").unwrap_or(&Value::Null)));
}
Ok(resp.get("data").cloned().unwrap_or(Value::Null))
}
pub fn load_file(&mut self, video_url: &str, audio_url: &str) -> Result<(), String> {
self.command(&["loadfile", video_url])?;
self.set_property("audio-files", json!(audio_url))?;
Ok(())
// Pass audio-file option during load so both streams sync from the start
let options = format!("audio-file={}", audio_url);
self.command(&["loadfile", video_url, "replace", &options])
}
pub fn seek(&mut self, time: f64) -> Result<(), String> {
@@ -651,7 +666,9 @@ pub fn run() {
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
client_lib::run();
// Crate name matches the `name` field in Cargo.toml (with hyphens → underscores).
// The Tauri scaffold sets this — adjust if the package is named differently.
app_lib::run();
}
```
@@ -662,6 +679,8 @@ Add `serde_json` to `client/src-tauri/Cargo.toml` dependencies:
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = [] }
[build-dependencies]
tauri-build = { version = "2", features = [] }
```
@@ -1071,14 +1090,14 @@ git commit -m "feat: add canvas-based timeline component"
const CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"];
const RATIOS = ["Off", "9:16", "4:5", "1:1"];
async function doExport(folderSuffix: string = "") {
export async function doExport(folderSuffix: string = "") {
if (!$currentFile) return;
$exportStatus = "running";
$exportCompleted = 0;
$exportTotal = $clips;
const req = {
input_path: `${$currentFile.root}${$currentFile.path}`,
input_path: `${$currentFile.root}/${$currentFile.path}`,
cursor: $cursor,
name: $clipName || $currentFile.name.replace(/\.[^.]+$/, ""),
clips: $clips,
@@ -1302,8 +1321,9 @@ git commit -m "feat: add profile bar component"
clearInterval(pollInterval);
});
// Load file into mpv when currentFile changes
// Load file into mpv when currentFile OR quality changes
$: if ($currentFile) {
void $quality; // trigger reactivity on quality change too
const vUrl = streamUrl($currentFile.path, $currentFile.root, $quality);
const aUrl = audioUrl($currentFile.path, $currentFile.root);
mpvLoad(vUrl, aUrl).then(async () => {
@@ -1370,6 +1390,7 @@ git commit -m "feat: add profile bar component"
</div>
<Timeline
onCursorChange={handleCursorChange}
onSeek={handleCursorChange}
onMarkerClick={handleMarkerClick}
onMarkerDelete={handleMarkerDelete}
/>
@@ -1392,7 +1413,7 @@ git commit -m "feat: add profile bar component"
<option value="high">Original</option>
</select>
</div>
<ExportPanel />
<ExportPanel bind:this={exportPanelRef} />
</div>
</div>
</main>
@@ -1476,6 +1497,9 @@ git commit -m "feat: wire up main app layout with all components"
Add to the `<script>` in App.svelte:
```typescript
// Export trigger — called from keyboard shortcuts and forwarded to ExportPanel
let exportPanelRef: ExportPanel;
function handleKeydown(e: KeyboardEvent) {
// Ignore when typing in inputs
const tag = (e.target as HTMLElement).tagName;
@@ -1488,7 +1512,7 @@ function handleKeydown(e: KeyboardEvent) {
break;
case "e":
case "E":
doMainExport();
exportPanelRef?.doExport();
break;
case "ArrowLeft":
$cursor = Math.max(0, $cursor - 1);
@@ -1505,7 +1529,7 @@ function handleKeydown(e: KeyboardEvent) {
if (num >= 1 && num <= 9) {
const idx = num - 1;
if (idx < $subprofiles.length) {
doSubprofileExport($subprofiles[idx]);
exportPanelRef?.doExport($subprofiles[idx]);
}
}
}