653 lines
22 KiB
Rust
653 lines
22 KiB
Rust
//! Subtitle handling: SRT-to-VTT conversion, sidecar discovery, storage,
|
|
//! and embedded subtitle extraction via ffmpeg.
|
|
|
|
use once_cell::sync::Lazy;
|
|
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Supported subtitle file extensions.
|
|
pub const SUB_EXTS: &[&str] = &[".srt", ".vtt"];
|
|
|
|
/// Languages considered "English" for sidecar priority.
|
|
const ENGLISH_LANGS: &[&str] = &["en", "eng", "english"];
|
|
|
|
/// All language suffixes to strip when normalizing subtitle basenames.
|
|
const ALL_LANG_SUFFIXES: &[&str] = &[
|
|
"en", "eng", "english", "fr", "de", "es", "it", "pt", "ru", "ja", "ko", "zh",
|
|
];
|
|
|
|
/// Windows CREATE_NO_WINDOW flag for subprocess creation.
|
|
#[cfg(target_os = "windows")]
|
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Structs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Result of storing a subtitle file for a video.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SubtitleStored {
|
|
/// Relative path like `"subtitles/{fid}_{name}.vtt"`.
|
|
pub vtt: String,
|
|
/// Display label (source filename).
|
|
pub label: String,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compiled regex patterns
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Matches a line that is only digits (SRT cue index).
|
|
static CUE_INDEX_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d+$").unwrap());
|
|
|
|
/// Matches characters that are NOT alphanumeric, dot, underscore, or hyphen.
|
|
static SANITIZE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^a-zA-Z0-9._\-]").unwrap());
|
|
|
|
/// Collapses runs of whitespace and dash/underscore into a single space for
|
|
/// normalized comparison of subtitle stems.
|
|
static NORMALIZE_SEP_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[-_\s]+").unwrap());
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. srt_to_vtt
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Convert SRT subtitle text to WebVTT format string.
|
|
///
|
|
/// - Removes BOM (`\u{FEFF}`) if present.
|
|
/// - Adds the `WEBVTT` header.
|
|
/// - Skips cue index numbers (lines that are just digits).
|
|
/// - Converts timestamp separators: comma -> dot.
|
|
/// - Collects subtitle text between timestamp lines and empty lines.
|
|
pub fn srt_to_vtt(srt_text: &str) -> String {
|
|
let text = srt_text.replace('\u{FEFF}', "");
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
let mut out: Vec<String> = vec!["WEBVTT".to_string(), String::new()];
|
|
|
|
let mut i = 0;
|
|
while i < lines.len() {
|
|
let line = lines[i].trim_end_matches('\r');
|
|
|
|
// Empty line -> blank line in output
|
|
if line.trim().is_empty() {
|
|
out.push(String::new());
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
// Skip cue index (pure digit line)
|
|
if CUE_INDEX_RE.is_match(line.trim()) {
|
|
i += 1;
|
|
if i >= lines.len() {
|
|
break;
|
|
}
|
|
// Re-read the next line as a potential timestamp
|
|
let line = lines[i].trim_end_matches('\r');
|
|
|
|
if line.contains("-->") {
|
|
let ts_line = line.replace(',', ".");
|
|
out.push(ts_line);
|
|
i += 1;
|
|
// Collect subtitle text until blank line
|
|
while i < lines.len() {
|
|
let t = lines[i].trim_end_matches('\r');
|
|
if t.trim().is_empty() {
|
|
out.push(String::new());
|
|
i += 1;
|
|
break;
|
|
}
|
|
out.push(t.to_string());
|
|
i += 1;
|
|
}
|
|
} else {
|
|
i += 1;
|
|
}
|
|
} else if line.contains("-->") {
|
|
// Timestamp line without preceding cue index
|
|
let ts_line = line.replace(',', ".");
|
|
out.push(ts_line);
|
|
i += 1;
|
|
while i < lines.len() {
|
|
let t = lines[i].trim_end_matches('\r');
|
|
if t.trim().is_empty() {
|
|
out.push(String::new());
|
|
i += 1;
|
|
break;
|
|
}
|
|
out.push(t.to_string());
|
|
i += 1;
|
|
}
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
|
|
let joined = out.join("\n");
|
|
format!("{}\n", joined.trim())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. auto_subtitle_sidecar (helpers)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Normalize a string for fuzzy subtitle matching: lowercase, replace `-` and
|
|
/// `_` with space, collapse whitespace.
|
|
fn normalize_stem(s: &str) -> String {
|
|
let lower = s.to_lowercase();
|
|
let replaced = NORMALIZE_SEP_RE.replace_all(&lower, " ");
|
|
replaced.trim().to_string()
|
|
}
|
|
|
|
/// Strip a trailing language suffix from a subtitle stem.
|
|
///
|
|
/// For example, `"video.en"` -> `Some(("video", "en"))`.
|
|
/// Returns `None` if no known language suffix is found.
|
|
fn strip_lang_suffix(stem: &str) -> Option<(String, String)> {
|
|
if let Some(dot_pos) = stem.rfind('.') {
|
|
let base = &stem[..dot_pos];
|
|
let suffix = &stem[dot_pos + 1..];
|
|
let suffix_lower = suffix.to_lowercase();
|
|
if ALL_LANG_SUFFIXES.contains(&suffix_lower.as_str()) {
|
|
return Some((base.to_string(), suffix_lower));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Find a subtitle sidecar file matching the given video path.
|
|
///
|
|
/// Returns the best matching subtitle file path, or `None`.
|
|
///
|
|
/// Priority (lower is better):
|
|
/// - 0: Exact stem match (case-insensitive)
|
|
/// - 1: Normalized exact match
|
|
/// - 2: English language suffix with exact base
|
|
/// - 3: English language suffix with normalized base
|
|
/// - 4: Other language suffix with exact base
|
|
/// - 5: Other/no language with normalized base
|
|
pub fn auto_subtitle_sidecar(video_path: &Path) -> Option<PathBuf> {
|
|
let parent = video_path.parent()?;
|
|
let video_stem = video_path.file_stem()?.to_string_lossy().to_string();
|
|
let video_stem_lower = video_stem.to_lowercase();
|
|
let video_stem_norm = normalize_stem(&video_stem);
|
|
|
|
// Collect all subtitle files in the same directory.
|
|
let entries = fs::read_dir(parent).ok()?;
|
|
|
|
let mut best: Option<(u8, PathBuf)> = None;
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
|
|
let fname = match path.file_name() {
|
|
Some(n) => n.to_string_lossy().to_string(),
|
|
None => continue,
|
|
};
|
|
let fname_lower = fname.to_lowercase();
|
|
|
|
// Must end with a supported subtitle extension.
|
|
let is_sub = SUB_EXTS
|
|
.iter()
|
|
.any(|ext| fname_lower.ends_with(ext));
|
|
if !is_sub {
|
|
continue;
|
|
}
|
|
|
|
// Extract the stem (without the subtitle extension).
|
|
let sub_stem = match path.file_stem() {
|
|
Some(s) => s.to_string_lossy().to_string(),
|
|
None => continue,
|
|
};
|
|
let sub_stem_lower = sub_stem.to_lowercase();
|
|
|
|
// Priority 0: exact stem match (case-insensitive).
|
|
if sub_stem_lower == video_stem_lower {
|
|
let priority = 0u8;
|
|
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
|
|
best = Some((priority, path.clone()));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Check for language suffix.
|
|
if let Some((base, lang)) = strip_lang_suffix(&sub_stem) {
|
|
let base_lower = base.to_lowercase();
|
|
let base_norm = normalize_stem(&base);
|
|
let is_english = ENGLISH_LANGS.contains(&lang.as_str());
|
|
|
|
if is_english {
|
|
// Priority 2: English suffix, exact base.
|
|
if base_lower == video_stem_lower {
|
|
let priority = 2u8;
|
|
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
|
|
best = Some((priority, path.clone()));
|
|
}
|
|
continue;
|
|
}
|
|
// Priority 3: English suffix, normalized base.
|
|
if base_norm == video_stem_norm {
|
|
let priority = 3u8;
|
|
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
|
|
best = Some((priority, path.clone()));
|
|
}
|
|
continue;
|
|
}
|
|
} else {
|
|
// Priority 4: Other language suffix, exact base.
|
|
if base_lower == video_stem_lower {
|
|
let priority = 4u8;
|
|
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
|
|
best = Some((priority, path.clone()));
|
|
}
|
|
continue;
|
|
}
|
|
// Priority 5: Other language suffix, normalized base.
|
|
if base_norm == video_stem_norm {
|
|
let priority = 5u8;
|
|
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
|
|
best = Some((priority, path.clone()));
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 1: Normalized match (no language suffix).
|
|
let sub_stem_norm = normalize_stem(&sub_stem);
|
|
if sub_stem_norm == video_stem_norm {
|
|
let priority = 1u8;
|
|
if best.as_ref().map_or(true, |(bp, _)| priority < *bp) {
|
|
best = Some((priority, path.clone()));
|
|
}
|
|
}
|
|
|
|
// Priority 5 fallback: normalized match for subtitle files whose
|
|
// language suffix was not recognised above (handled by the
|
|
// strip_lang_suffix branch already for known languages).
|
|
}
|
|
|
|
best.map(|(_, p)| p)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. store_subtitle_for_fid
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Sanitize a filename component: replace non-alphanumeric chars (except
|
|
/// `._-`) with `_`, then truncate to 60 characters.
|
|
fn sanitize_name(name: &str) -> String {
|
|
let sanitized = SANITIZE_RE.replace_all(name, "_");
|
|
let s = sanitized.as_ref();
|
|
if s.len() > 60 {
|
|
s[..60].to_string()
|
|
} else {
|
|
s.to_string()
|
|
}
|
|
}
|
|
|
|
/// Store a subtitle file for a given fid. Converts SRT->VTT if needed.
|
|
///
|
|
/// The output file is written as `{fid}_{sanitized_name}.vtt` inside
|
|
/// `subs_dir`. Returns `SubtitleStored` with the relative path (from the
|
|
/// parent of `subs_dir`) and a display label.
|
|
///
|
|
/// Returns `None` if the source file extension is not supported or reading
|
|
/// the source fails.
|
|
pub fn store_subtitle_for_fid(
|
|
fid: &str,
|
|
src_path: &Path,
|
|
subs_dir: &Path,
|
|
) -> Option<SubtitleStored> {
|
|
let ext_lower = src_path
|
|
.extension()
|
|
.map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))?;
|
|
|
|
if !SUB_EXTS.contains(&ext_lower.as_str()) {
|
|
return None;
|
|
}
|
|
|
|
let src_filename = src_path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
|
|
let src_stem = src_path
|
|
.file_stem()
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "subtitle".to_string());
|
|
|
|
let sanitized = sanitize_name(&src_stem);
|
|
let out_name = format!("{}_{}.vtt", fid, sanitized);
|
|
|
|
// Ensure subs_dir exists.
|
|
let _ = fs::create_dir_all(subs_dir);
|
|
|
|
let out_path = subs_dir.join(&out_name);
|
|
|
|
let content = fs::read_to_string(src_path).ok()?;
|
|
|
|
let vtt_content = if ext_lower == ".srt" {
|
|
srt_to_vtt(&content)
|
|
} else {
|
|
// Already VTT - use as-is.
|
|
content
|
|
};
|
|
|
|
fs::write(&out_path, vtt_content.as_bytes()).ok()?;
|
|
|
|
// Build relative path: "subtitles/{out_name}".
|
|
let subs_dir_name = subs_dir
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "subtitles".to_string());
|
|
|
|
let vtt_rel = format!("{}/{}", subs_dir_name, out_name);
|
|
|
|
Some(SubtitleStored {
|
|
vtt: vtt_rel,
|
|
label: src_filename,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. extract_embedded_subtitle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Extract an embedded subtitle track from a video using ffmpeg.
|
|
///
|
|
/// Runs: `ffmpeg -y -i {video_path} -map 0:{track_index} -c:s webvtt {output_path}`
|
|
///
|
|
/// The output file is `{fid}_embedded_{track_index}.vtt` inside `subs_dir`.
|
|
/// On Windows, the process is created with `CREATE_NO_WINDOW`.
|
|
///
|
|
/// Returns `SubtitleStored` on success, or an error message string.
|
|
pub fn extract_embedded_subtitle(
|
|
video_path: &Path,
|
|
track_index: u32,
|
|
ffmpeg_path: &Path,
|
|
subs_dir: &Path,
|
|
fid: &str,
|
|
) -> Result<SubtitleStored, String> {
|
|
let _ = fs::create_dir_all(subs_dir);
|
|
|
|
let out_name = format!("{}_embedded_{}.vtt", fid, track_index);
|
|
let out_path = subs_dir.join(&out_name);
|
|
|
|
let mut cmd = Command::new(ffmpeg_path);
|
|
cmd.args([
|
|
"-y",
|
|
"-i",
|
|
&video_path.to_string_lossy(),
|
|
"-map",
|
|
&format!("0:{}", track_index),
|
|
"-c:s",
|
|
"webvtt",
|
|
&out_path.to_string_lossy(),
|
|
]);
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
|
|
let output = cmd
|
|
.output()
|
|
.map_err(|e| format!("Failed to run ffmpeg: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!(
|
|
"ffmpeg exited with status {}: {}",
|
|
output.status, stderr
|
|
));
|
|
}
|
|
|
|
if !out_path.exists() {
|
|
return Err("ffmpeg did not produce an output file".to_string());
|
|
}
|
|
|
|
let subs_dir_name = subs_dir
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "subtitles".to_string());
|
|
|
|
let vtt_rel = format!("{}/{}", subs_dir_name, out_name);
|
|
|
|
Ok(SubtitleStored {
|
|
vtt: vtt_rel,
|
|
label: format!("Embedded track {}", track_index),
|
|
})
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Tests
|
|
// ===========================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
|
|
// -- srt_to_vtt ----------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_srt_to_vtt_basic() {
|
|
let srt = "\
|
|
1
|
|
00:00:01,000 --> 00:00:04,000
|
|
Hello, world!
|
|
|
|
2
|
|
00:00:05,000 --> 00:00:08,000
|
|
This is a test.
|
|
";
|
|
let vtt = srt_to_vtt(srt);
|
|
assert!(vtt.starts_with("WEBVTT\n"));
|
|
assert!(vtt.contains("00:00:01.000 --> 00:00:04.000"));
|
|
assert!(vtt.contains("Hello, world!"));
|
|
assert!(vtt.contains("00:00:05.000 --> 00:00:08.000"));
|
|
assert!(vtt.contains("This is a test."));
|
|
// Timestamp commas must be converted to dots.
|
|
assert!(!vtt.contains("00:00:01,000"));
|
|
assert!(!vtt.contains("00:00:04,000"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_srt_to_vtt_bom() {
|
|
let srt = "\u{FEFF}1\n00:00:01,000 --> 00:00:02,000\nHello\n";
|
|
let vtt = srt_to_vtt(srt);
|
|
assert!(vtt.starts_with("WEBVTT"));
|
|
// BOM must be removed.
|
|
assert!(!vtt.contains('\u{FEFF}'));
|
|
assert!(vtt.contains("Hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_srt_to_vtt_empty() {
|
|
let vtt = srt_to_vtt("");
|
|
assert!(vtt.starts_with("WEBVTT"));
|
|
// Should be just the header.
|
|
assert_eq!(vtt.trim(), "WEBVTT");
|
|
}
|
|
|
|
#[test]
|
|
fn test_srt_to_vtt_windows_line_endings() {
|
|
let srt = "1\r\n00:00:01,000 --> 00:00:02,000\r\nHello\r\n\r\n\
|
|
2\r\n00:00:03,000 --> 00:00:04,000\r\nWorld\r\n";
|
|
let vtt = srt_to_vtt(srt);
|
|
assert!(vtt.starts_with("WEBVTT"));
|
|
assert!(vtt.contains("00:00:01.000 --> 00:00:02.000"));
|
|
assert!(vtt.contains("Hello"));
|
|
assert!(vtt.contains("00:00:03.000 --> 00:00:04.000"));
|
|
assert!(vtt.contains("World"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_srt_to_vtt_no_cue_indices() {
|
|
// Some SRT files omit cue numbers entirely.
|
|
let srt = "\
|
|
00:00:01,500 --> 00:00:03,500
|
|
First line
|
|
|
|
00:00:04,000 --> 00:00:06,000
|
|
Second line
|
|
";
|
|
let vtt = srt_to_vtt(srt);
|
|
assert!(vtt.starts_with("WEBVTT"));
|
|
assert!(vtt.contains("00:00:01.500 --> 00:00:03.500"));
|
|
assert!(vtt.contains("First line"));
|
|
assert!(vtt.contains("00:00:04.000 --> 00:00:06.000"));
|
|
assert!(vtt.contains("Second line"));
|
|
}
|
|
|
|
// -- auto_subtitle_sidecar -----------------------------------------------
|
|
|
|
#[test]
|
|
fn test_auto_subtitle_sidecar_exact_match() {
|
|
let dir = TempDir::new().unwrap();
|
|
let video = dir.path().join("lecture.mp4");
|
|
let sub = dir.path().join("lecture.srt");
|
|
fs::write(&video, b"video").unwrap();
|
|
fs::write(&sub, b"1\n00:00:00,000 --> 00:00:01,000\nhi\n").unwrap();
|
|
|
|
let result = auto_subtitle_sidecar(&video);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap(), sub);
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_subtitle_sidecar_english_suffix() {
|
|
let dir = TempDir::new().unwrap();
|
|
let video = dir.path().join("lecture.mp4");
|
|
let sub = dir.path().join("lecture.en.srt");
|
|
fs::write(&video, b"video").unwrap();
|
|
fs::write(&sub, b"sub content").unwrap();
|
|
|
|
let result = auto_subtitle_sidecar(&video);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap(), sub);
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_subtitle_sidecar_no_match() {
|
|
let dir = TempDir::new().unwrap();
|
|
let video = dir.path().join("lecture.mp4");
|
|
fs::write(&video, b"video").unwrap();
|
|
// No subtitle files at all.
|
|
let result = auto_subtitle_sidecar(&video);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_subtitle_sidecar_priority_order() {
|
|
let dir = TempDir::new().unwrap();
|
|
let video = dir.path().join("lecture.mp4");
|
|
fs::write(&video, b"video").unwrap();
|
|
|
|
// Priority 0: exact stem match.
|
|
let exact = dir.path().join("lecture.srt");
|
|
// Priority 2: English suffix with exact base.
|
|
let en_suffix = dir.path().join("lecture.en.srt");
|
|
// Priority 4: Other language suffix with exact base.
|
|
let fr_suffix = dir.path().join("lecture.fr.srt");
|
|
|
|
fs::write(&exact, b"exact").unwrap();
|
|
fs::write(&en_suffix, b"english").unwrap();
|
|
fs::write(&fr_suffix, b"french").unwrap();
|
|
|
|
let result = auto_subtitle_sidecar(&video);
|
|
assert!(result.is_some());
|
|
// Should pick priority 0 (exact match) over others.
|
|
assert_eq!(result.unwrap(), exact);
|
|
|
|
// Remove exact match -> should pick English suffix (priority 2).
|
|
fs::remove_file(&exact).unwrap();
|
|
let result = auto_subtitle_sidecar(&video);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap(), en_suffix);
|
|
|
|
// Remove English suffix -> should pick French suffix (priority 4).
|
|
fs::remove_file(&en_suffix).unwrap();
|
|
let result = auto_subtitle_sidecar(&video);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap(), fr_suffix);
|
|
}
|
|
|
|
// -- store_subtitle_for_fid ----------------------------------------------
|
|
|
|
#[test]
|
|
fn test_store_subtitle_srt_converts_to_vtt() {
|
|
let dir = TempDir::new().unwrap();
|
|
let subs_dir = dir.path().join("subtitles");
|
|
let src = dir.path().join("my_sub.srt");
|
|
|
|
let srt_content = "1\n00:00:01,000 --> 00:00:02,000\nHello\n";
|
|
fs::write(&src, srt_content).unwrap();
|
|
|
|
let result = store_subtitle_for_fid("abc123", &src, &subs_dir);
|
|
assert!(result.is_some());
|
|
|
|
let stored = result.unwrap();
|
|
assert!(stored.vtt.ends_with(".vtt"));
|
|
assert!(stored.vtt.starts_with("subtitles/"));
|
|
assert_eq!(stored.label, "my_sub.srt");
|
|
|
|
// Verify the VTT output file was actually created and converted.
|
|
let out_path = subs_dir.join(format!("abc123_{}.vtt", "my_sub"));
|
|
assert!(out_path.exists());
|
|
|
|
let vtt_content = fs::read_to_string(&out_path).unwrap();
|
|
assert!(vtt_content.starts_with("WEBVTT"));
|
|
assert!(vtt_content.contains("00:00:01.000 --> 00:00:02.000"));
|
|
assert!(vtt_content.contains("Hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_store_subtitle_vtt_copies() {
|
|
let dir = TempDir::new().unwrap();
|
|
let subs_dir = dir.path().join("subtitles");
|
|
let src = dir.path().join("my_sub.vtt");
|
|
|
|
let vtt_content = "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello\n";
|
|
fs::write(&src, vtt_content).unwrap();
|
|
|
|
let result = store_subtitle_for_fid("def456", &src, &subs_dir);
|
|
assert!(result.is_some());
|
|
|
|
let stored = result.unwrap();
|
|
assert!(stored.vtt.ends_with(".vtt"));
|
|
assert_eq!(stored.label, "my_sub.vtt");
|
|
|
|
// Verify the output file has the same content (not SRT-converted).
|
|
let out_path = subs_dir.join("def456_my_sub.vtt");
|
|
assert!(out_path.exists());
|
|
|
|
let content = fs::read_to_string(&out_path).unwrap();
|
|
assert_eq!(content, vtt_content);
|
|
}
|
|
|
|
#[test]
|
|
fn test_store_subtitle_unsupported_ext() {
|
|
let dir = TempDir::new().unwrap();
|
|
let subs_dir = dir.path().join("subtitles");
|
|
let src = dir.path().join("notes.txt");
|
|
fs::write(&src, "Some notes").unwrap();
|
|
|
|
let result = store_subtitle_for_fid("xyz789", &src, &subs_dir);
|
|
assert!(result.is_none());
|
|
}
|
|
}
|