TutorialVault: complete Tauri v2 port with runtime fixes
Rename from TutorialDock to TutorialVault. Remove legacy Python app and scripts. Fix video playback, subtitles, metadata display, window state persistence, and auto-download of ffmpeg/ffprobe on first run. Bundle fonts via npm instead of runtime download.
This commit is contained in:
@@ -519,7 +519,11 @@ impl Library {
|
||||
let folder = self
|
||||
.root
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.map(|p| {
|
||||
let s = p.to_string_lossy().to_string();
|
||||
// Strip Windows extended-length path prefix.
|
||||
s.strip_prefix("\\\\?\\").unwrap_or(&s).to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let count = self.fids.len();
|
||||
@@ -623,6 +627,14 @@ impl Library {
|
||||
|
||||
/// Find the next unfinished video index after the current position.
|
||||
fn _compute_next_up(&self, current_index: Option<usize>) -> Value {
|
||||
let make_result = |i: usize| -> Value {
|
||||
let fid = &self.fids[i];
|
||||
let title = self.fid_to_rel.get(fid)
|
||||
.map(|r| pretty_title_from_filename(r))
|
||||
.unwrap_or_default();
|
||||
json!({"index": i, "title": title})
|
||||
};
|
||||
|
||||
let start = current_index.map(|i| i + 1).unwrap_or(0);
|
||||
for i in start..self.fids.len() {
|
||||
let fid = &self.fids[i];
|
||||
@@ -633,7 +645,7 @@ impl Library {
|
||||
.map(|m| m.finished)
|
||||
.unwrap_or(false);
|
||||
if !finished {
|
||||
return json!(i);
|
||||
return make_result(i);
|
||||
}
|
||||
}
|
||||
// Wrap around from beginning.
|
||||
@@ -647,7 +659,7 @@ impl Library {
|
||||
.map(|m| m.finished)
|
||||
.unwrap_or(false);
|
||||
if !finished {
|
||||
return json!(i);
|
||||
return make_result(i);
|
||||
}
|
||||
}
|
||||
Value::Null
|
||||
@@ -914,7 +926,7 @@ impl Library {
|
||||
None => return json!({"ok": false, "error": "current fid not in library"}),
|
||||
};
|
||||
|
||||
let mut result = self._basic_file_meta(&fid);
|
||||
let basic = self._basic_file_meta(&fid);
|
||||
|
||||
// Probe if not cached.
|
||||
if !self.meta_cache.contains_key(&fid) {
|
||||
@@ -929,6 +941,12 @@ impl Library {
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = json!({
|
||||
"ok": true,
|
||||
"basic": basic,
|
||||
"ffprobe_found": self.ffprobe.is_some(),
|
||||
});
|
||||
|
||||
if let Some(cached) = self.meta_cache.get(&fid) {
|
||||
if let Ok(meta_val) = serde_json::to_value(cached) {
|
||||
result
|
||||
@@ -938,10 +956,6 @@ impl Library {
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("ok".to_string(), json!(true));
|
||||
result
|
||||
}
|
||||
|
||||
@@ -949,6 +963,23 @@ impl Library {
|
||||
// Subtitle methods
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build a protocol URL for a stored subtitle VTT path.
|
||||
/// The `vtt` field is like `"subtitles/{fid}_{name}.vtt"` — extract the filename.
|
||||
fn _sub_url(vtt: &str) -> String {
|
||||
let filename = vtt.rsplit('/').next().unwrap_or(vtt);
|
||||
format!("http://tutdock.localhost/sub/{}", filename)
|
||||
}
|
||||
|
||||
/// Build a successful subtitle JSON response with `has`, `url`, and `label`.
|
||||
fn _sub_response(vtt: &str, label: &str) -> Value {
|
||||
json!({
|
||||
"ok": true,
|
||||
"has": true,
|
||||
"url": Self::_sub_url(vtt),
|
||||
"label": label,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get subtitle for the current video.
|
||||
///
|
||||
/// Priority: stored subtitle -> sidecar -> embedded.
|
||||
@@ -968,12 +999,7 @@ impl Library {
|
||||
if let Some(ref sub_ref) = meta.subtitle {
|
||||
let vtt_path = state_dir.join(&sub_ref.vtt);
|
||||
if vtt_path.exists() {
|
||||
return json!({
|
||||
"ok": true,
|
||||
"source": "stored",
|
||||
"vtt": sub_ref.vtt,
|
||||
"label": sub_ref.label,
|
||||
});
|
||||
return Self::_sub_response(&sub_ref.vtt, &sub_ref.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -993,12 +1019,7 @@ impl Library {
|
||||
});
|
||||
}
|
||||
self.save_state();
|
||||
return json!({
|
||||
"ok": true,
|
||||
"source": "sidecar",
|
||||
"vtt": stored.vtt,
|
||||
"label": stored.label,
|
||||
});
|
||||
return Self::_sub_response(&stored.vtt, &stored.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1026,12 +1047,7 @@ impl Library {
|
||||
});
|
||||
}
|
||||
self.save_state();
|
||||
return json!({
|
||||
"ok": true,
|
||||
"source": "embedded",
|
||||
"vtt": stored.vtt,
|
||||
"label": stored.label,
|
||||
});
|
||||
return Self::_sub_response(&stored.vtt, &stored.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1039,7 +1055,7 @@ impl Library {
|
||||
}
|
||||
}
|
||||
|
||||
json!({"ok": true, "source": "none"})
|
||||
json!({"ok": true, "has": false})
|
||||
}
|
||||
|
||||
/// Store a user-selected subtitle file for the current video.
|
||||
@@ -1064,11 +1080,7 @@ impl Library {
|
||||
});
|
||||
}
|
||||
self.save_state();
|
||||
json!({
|
||||
"ok": true,
|
||||
"vtt": stored.vtt,
|
||||
"label": stored.label,
|
||||
})
|
||||
Self::_sub_response(&stored.vtt, &stored.label)
|
||||
}
|
||||
None => {
|
||||
json!({"ok": false, "error": "unsupported subtitle format"})
|
||||
@@ -1154,11 +1166,7 @@ impl Library {
|
||||
});
|
||||
}
|
||||
self.save_state();
|
||||
json!({
|
||||
"ok": true,
|
||||
"vtt": stored.vtt,
|
||||
"label": stored.label,
|
||||
})
|
||||
Self::_sub_response(&stored.vtt, &stored.label)
|
||||
}
|
||||
Err(e) => {
|
||||
json!({"ok": false, "error": e})
|
||||
@@ -1288,10 +1296,15 @@ impl Library {
|
||||
});
|
||||
|
||||
for (label, path, _) in &found {
|
||||
let ext = std::path::Path::new(path)
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_uppercase())
|
||||
.unwrap_or_default();
|
||||
sidecar_subs.push(json!({
|
||||
"type": "sidecar",
|
||||
"label": label,
|
||||
"path": path,
|
||||
"format": ext,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1358,11 +1371,7 @@ impl Library {
|
||||
});
|
||||
}
|
||||
self.save_state();
|
||||
json!({
|
||||
"ok": true,
|
||||
"vtt": stored.vtt,
|
||||
"label": stored.label,
|
||||
})
|
||||
Self::_sub_response(&stored.vtt, &stored.label)
|
||||
}
|
||||
None => {
|
||||
json!({"ok": false, "error": "unsupported subtitle format or read error"})
|
||||
@@ -1572,7 +1581,10 @@ impl Library {
|
||||
|
||||
let folder = path
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.map(|p| {
|
||||
let s = p.to_string_lossy().to_string();
|
||||
s.strip_prefix("\\\\?\\").unwrap_or(&s).to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
return json!({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
use tutorialdock_lib::{commands, ffmpeg, fonts, library, prefs, video_protocol, AppPaths};
|
||||
use tutorialvault_lib::{commands, ffmpeg, fonts, library, prefs, video_protocol, AppPaths};
|
||||
|
||||
fn main() {
|
||||
// 1. Resolve exe directory for portability.
|
||||
@@ -41,6 +41,7 @@ fn main() {
|
||||
|
||||
// Discover ffmpeg/ffprobe.
|
||||
let ff_paths = ffmpeg::discover(&paths.exe_dir, &paths.state_dir);
|
||||
let needs_ffmpeg_download = ff_paths.ffprobe.is_none() || ff_paths.ffmpeg.is_none();
|
||||
lib.ffprobe = ff_paths.ffprobe;
|
||||
lib.ffmpeg = ff_paths.ffmpeg;
|
||||
|
||||
@@ -91,22 +92,22 @@ fn main() {
|
||||
|
||||
// Configure window from saved prefs and launch.
|
||||
builder
|
||||
.setup(|app| {
|
||||
.setup(move |app| {
|
||||
let prefs_state = app.state::<Mutex<prefs::Prefs>>();
|
||||
let p = prefs_state.lock().unwrap();
|
||||
let win = app.get_webview_window("main").unwrap();
|
||||
|
||||
if let Some(x) = p.window.x {
|
||||
if let Some(y) = p.window.y {
|
||||
// Only restore position/size if values are sane (not minimized/offscreen).
|
||||
let w = p.window.width.max(640) as u32;
|
||||
let h = p.window.height.max(480) as u32;
|
||||
if let (Some(x), Some(y)) = (p.window.x, p.window.y) {
|
||||
if x > -10000 && y > -10000 && x < 10000 && y < 10000 {
|
||||
let _ = win.set_position(tauri::Position::Physical(
|
||||
tauri::PhysicalPosition::new(x, y),
|
||||
));
|
||||
}
|
||||
}
|
||||
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize::new(
|
||||
p.window.width as u32,
|
||||
p.window.height as u32,
|
||||
)));
|
||||
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize::new(w, h)));
|
||||
let _ = win.set_always_on_top(p.always_on_top);
|
||||
drop(p);
|
||||
|
||||
@@ -119,6 +120,32 @@ fn main() {
|
||||
let _ = fonts::ensure_fontawesome_local(&fa_dir).await;
|
||||
});
|
||||
|
||||
// Auto-download ffmpeg/ffprobe if not found locally.
|
||||
if needs_ffmpeg_download {
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let sd = handle.state::<AppPaths>().state_dir.clone();
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(16);
|
||||
match ffmpeg::download_ffmpeg(&sd, tx).await {
|
||||
Ok(paths) => {
|
||||
let lib_state = handle.state::<Mutex<library::Library>>();
|
||||
if let Ok(mut lib) = lib_state.lock() {
|
||||
if lib.ffprobe.is_none() {
|
||||
lib.ffprobe = paths.ffprobe;
|
||||
}
|
||||
if lib.ffmpeg.is_none() {
|
||||
lib.ffmpeg = paths.ffmpeg;
|
||||
}
|
||||
}
|
||||
eprintln!("[ffmpeg] Auto-download complete");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[ffmpeg] Auto-download failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -26,6 +26,9 @@ use crate::AppPaths;
|
||||
/// Maximum chunk size for full-file responses (4 MB).
|
||||
const MAX_CHUNK: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// CORS header name.
|
||||
const CORS: &str = "Access-Control-Allow-Origin";
|
||||
|
||||
/// Register the `tutdock` custom protocol on the builder.
|
||||
pub fn register_protocol(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
builder.register_uri_scheme_protocol("tutdock", move |ctx, request| {
|
||||
@@ -140,6 +143,7 @@ fn serve_full(path: &PathBuf, file_size: u64, mime: &str) -> http::Response<Vec<
|
||||
.header(header::CONTENT_LENGTH, file_size.to_string())
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(CORS, "*")
|
||||
.body(buf)
|
||||
.unwrap_or_else(|_| internal_error("response build error"))
|
||||
}
|
||||
@@ -180,6 +184,7 @@ fn serve_range(
|
||||
.header(header::CONTENT_LENGTH, length.to_string())
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(CORS, "*")
|
||||
.body(buf)
|
||||
.unwrap_or_else(|_| internal_error("response build error"))
|
||||
}
|
||||
@@ -215,33 +220,36 @@ fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_subtitle(app: &tauri::AppHandle, rest: &str) -> http::Response<Vec<u8>> {
|
||||
// Path: sub/{libid}/{fid}
|
||||
let parts: Vec<&str> = rest.splitn(2, '/').collect();
|
||||
if parts.len() != 2 {
|
||||
return not_found();
|
||||
}
|
||||
let fid = parts[1];
|
||||
// rest is a VTT filename like "{fid}_{name}.vtt"
|
||||
let filename = rest;
|
||||
|
||||
if !fid.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
// Validate: no path traversal
|
||||
if filename.contains("..") || filename.contains('/') || filename.contains('\\') || filename.is_empty() {
|
||||
return not_found();
|
||||
}
|
||||
|
||||
let paths = app.state::<AppPaths>();
|
||||
let vtt_path = paths.subs_dir.join(format!("{}.vtt", fid));
|
||||
let vtt_path = paths.subs_dir.join(filename);
|
||||
|
||||
if !vtt_path.exists() {
|
||||
// Ensure resolved path is still inside subs_dir
|
||||
if let (Ok(base), Ok(resolved)) = (paths.subs_dir.canonicalize(), vtt_path.canonicalize()) {
|
||||
if !resolved.starts_with(&base) {
|
||||
return not_found();
|
||||
}
|
||||
} else {
|
||||
return not_found();
|
||||
}
|
||||
|
||||
let data = match fs::read(&vtt_path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return internal_error("read error"),
|
||||
Err(_) => return not_found(),
|
||||
};
|
||||
|
||||
http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/vtt; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(CORS, "*")
|
||||
.body(data)
|
||||
.unwrap_or_else(|_| internal_error("response build error"))
|
||||
}
|
||||
@@ -331,6 +339,7 @@ fn serve_static_file(base_dir: &PathBuf, filename: &str) -> http::Response<Vec<u
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.header(CORS, "*")
|
||||
.body(data)
|
||||
.unwrap_or_else(|_| internal_error("response build error"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user