use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::models::{ExternalSubtitle, MediaEntry, SubtitleTrack};
const SUBTITLE_EXTENSIONS: &[&str] = &["srt", "ass", "ssa", "sub", "idx", "vtt"];
#[derive(Debug, Serialize, Deserialize)]
struct SubtitleCache {
video_mtime: DateTime<Utc>,
#[serde(default)]
embedded: Vec<SubtitleTrack>,
#[serde(default)]
external: Vec<ExternalSubtitle>,
}
fn subtitle_cache_path(video_path: &Path) -> std::path::PathBuf {
let stem = video_path
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let dir = video_path.parent().unwrap_or(Path::new("."));
dir.join(format!("{stem}.media.subtitles.toml"))
}
fn video_mtime(video_path: &Path) -> Option<DateTime<Utc>> {
std::fs::metadata(video_path)
.ok()?
.modified()
.ok()
.map(|t| t.into())
}
fn load_subtitle_cache(video_path: &Path) -> Option<SubtitleCache> {
let cache_path = subtitle_cache_path(video_path);
let raw = std::fs::read_to_string(&cache_path).ok()?;
let cache: SubtitleCache = toml::from_str(&raw).ok()?;
let current_mtime = video_mtime(video_path)?;
if cache.video_mtime != current_mtime {
return None;
}
Some(cache)
}
fn save_subtitle_cache(
video_path: &Path,
embedded: &[SubtitleTrack],
external: &[ExternalSubtitle],
) {
let Some(mtime) = video_mtime(video_path) else {
return;
};
let cache = SubtitleCache {
video_mtime: mtime,
embedded: embedded.to_vec(),
external: external.to_vec(),
};
let Ok(raw) = toml::to_string_pretty(&cache) else {
return;
};
let content = format!(
"# MediaVault — cached subtitle data\n\
# Auto-generated from video scan. Delete to force re-scan.\n\n\
{raw}"
);
let _ = std::fs::write(subtitle_cache_path(video_path), content);
}
pub fn load_video_subtitles(video_path: &Path) -> (Vec<SubtitleTrack>, Vec<ExternalSubtitle>) {
if let Some(cached) = load_subtitle_cache(video_path) {
return (cached.embedded, cached.external);
}
let embedded = extract_subtitle_tracks(video_path);
let external = find_external_subtitles(video_path);
save_subtitle_cache(video_path, &embedded, &external);
(embedded, external)
}
pub fn load_entry_subtitles(entry: &mut MediaEntry) {
match entry {
MediaEntry::Movie(m) => {
let (embedded, external) = load_video_subtitles(&m.video_path);
m.subtitles = embedded;
m.external_subs = external;
}
MediaEntry::Show(s) => {
for season in &mut s.seasons {
for ep in &mut season.episodes {
let (embedded, external) = load_video_subtitles(&ep.video_path);
ep.subtitles = embedded;
ep.external_subs = external;
}
}
}
}
}
pub fn extract_subtitle_tracks(video_path: &Path) -> Vec<SubtitleTrack> {
let ext = video_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if ext != "mkv" && ext != "webm" {
return Vec::new();
}
let file = match std::fs::File::open(video_path) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let mkv = match matroska::Matroska::open(file) {
Ok(m) => m,
Err(_) => return Vec::new(),
};
mkv.tracks
.into_iter()
.filter(|t| t.tracktype == matroska::Tracktype::Subtitle)
.map(|t| SubtitleTrack {
track_number: t.number,
language: t.language.map(|l| format!("{l}")),
codec_id: t.codec_id,
name: t.name,
default: t.default,
forced: t.forced,
})
.collect()
}
pub fn find_external_subtitles(video_path: &Path) -> Vec<ExternalSubtitle> {
let Some(parent) = video_path.parent() else {
return Vec::new();
};
let video_stem = video_path
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let Ok(entries) = std::fs::read_dir(parent) else {
return Vec::new();
};
let mut subs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if !SUBTITLE_EXTENSIONS.contains(&ext.as_str()) {
continue;
}
let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let sub_stem = path
.file_stem()
.unwrap_or_default()
.to_string_lossy();
if !sub_stem.to_lowercase().starts_with(&video_stem.to_lowercase()) {
continue;
}
let remainder = &sub_stem[video_stem.len()..];
let language = remainder
.strip_prefix('.')
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
subs.push(ExternalSubtitle {
filename,
language,
format: ext,
});
}
subs.sort_by(|a, b| a.filename.cmp(&b.filename));
subs
}