use crate::services::ai::{AIProvider, AnalysisRequest, ContentSample, MatchResult};
use std::path::PathBuf;
use crate::Result;
use crate::core::language::LanguageDetector;
use crate::core::matcher::cache::{CacheData, OpItem, SnapshotItem};
use crate::core::matcher::discovery::generate_file_id;
use crate::core::matcher::journal::{
JournalData, JournalEntry, JournalEntryStatus, JournalOperationType, journal_path,
};
use crate::core::matcher::{FileDiscovery, MediaFile, MediaFileType};
use crate::core::parallel::{FileProcessingTask, ProcessingOperation, Task, TaskResult};
use crate::core::uuidv7::Uuidv7Generator;
use crate::error::SubXError;
use dirs;
use serde_json;
pub(crate) const CURRENT_CACHE_VERSION: &str = "2.0";
pub(crate) fn is_cache_version_current(version: &str) -> bool {
version == CURRENT_CACHE_VERSION
}
pub(crate) fn sanitize_suffix(raw: &str) -> Option<String> {
if raw.is_empty() || raw.len() > 16 {
return None;
}
if raw
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
Some(raw.to_string())
} else {
None
}
}
pub(crate) fn normalize_ai_language(detector: &LanguageDetector, raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("und") {
return None;
}
let lowered: String = trimmed
.chars()
.map(|c| {
if c.is_ascii_uppercase() {
c.to_ascii_lowercase()
} else {
c
}
})
.collect();
let resolved = detector.normalize(&lowered)?;
sanitize_suffix(&resolved)
}
pub fn apply_unique_target_paths(operations: &mut [MatchOperation]) {
use std::collections::HashSet;
fn final_target(op: &MatchOperation) -> PathBuf {
if let Some(p) = &op.relocation_target_path {
p.clone()
} else {
let parent = op
.subtitle_file
.path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
parent.join(&op.new_subtitle_name)
}
}
fn split_filename(name: &str) -> (String, String) {
if let Some(idx) = name.rfind('.') {
if idx > 0 {
return (name[..idx].to_string(), name[idx..].to_string());
}
}
(name.to_string(), String::new())
}
fn split_numeric_tail(stem: &str) -> (String, Option<u32>) {
if let Some(idx) = stem.rfind('.') {
let (head, tail) = (&stem[..idx], &stem[idx + 1..]);
if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
if let Ok(n) = tail.parse::<u32>() {
return (head.to_string(), Some(n));
}
}
}
(stem.to_string(), None)
}
let mut indices: Vec<usize> = (0..operations.len()).collect();
indices.sort_by(|&a, &b| {
let pa = final_target(&operations[a]);
let pb = final_target(&operations[b]);
let dir_a = pa.parent().map(|p| p.to_path_buf()).unwrap_or_default();
let dir_b = pb.parent().map(|p| p.to_path_buf()).unwrap_or_default();
dir_a.cmp(&dir_b).then_with(|| {
operations[a]
.subtitle_file
.relative_path
.cmp(&operations[b].subtitle_file.relative_path)
})
});
let reserved: HashSet<PathBuf> = (0..operations.len())
.map(|i| final_target(&operations[i]))
.collect();
let mut claimed: HashSet<PathBuf> = HashSet::new();
for idx in indices {
let candidate = final_target(&operations[idx]);
let parent = candidate
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
let filename = candidate
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let (stem, ext) = split_filename(&filename);
let (base_stem, existing_counter) = split_numeric_tail(&stem);
let source_path = operations[idx].subtitle_file.path.clone();
let is_taken = |p: &PathBuf| -> bool {
claimed.contains(p)
|| (p != &candidate && reserved.contains(p))
|| (p != &source_path && p.exists())
};
let mut resolved = candidate.clone();
let mut resolved_name = filename.clone();
if is_taken(&resolved) {
let mut counter = existing_counter.map(|n| n + 1).unwrap_or(2).max(2);
loop {
let new_name = format!("{}.{}{}", base_stem, counter, ext);
let probe = parent.join(&new_name);
if !is_taken(&probe) {
resolved = probe;
resolved_name = new_name;
break;
}
counter = counter.saturating_add(1);
if counter > 9999 {
break;
}
}
}
let op = &mut operations[idx];
op.new_subtitle_name = resolved_name;
if op.relocation_target_path.is_some() {
op.relocation_target_path = Some(resolved.clone());
}
claimed.insert(resolved);
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum FileRelocationMode {
None,
Copy,
Move,
}
#[derive(Debug, Clone)]
pub enum ConflictResolution {
Skip,
AutoRename,
Prompt,
}
#[derive(Debug, Clone)]
pub struct MatchConfig {
pub confidence_threshold: f32,
pub max_sample_length: usize,
pub enable_content_analysis: bool,
pub backup_enabled: bool,
pub relocation_mode: FileRelocationMode,
pub conflict_resolution: ConflictResolution,
pub ai_model: String,
pub max_subtitle_bytes: u64,
}
#[cfg(test)]
mod language_name_tests {
use super::*;
use crate::core::matcher::discovery::{MediaFile, MediaFileType};
use crate::services::ai::{
AIProvider, AnalysisRequest, ConfidenceScore, FileMatch, MatchResult, VerificationRequest,
};
use async_trait::async_trait;
use std::path::PathBuf;
fn legacy_match() -> FileMatch {
FileMatch {
video_file_id: "v".into(),
subtitle_file_id: "s".into(),
confidence: 1.0,
match_factors: vec![],
language: None,
target_filename_suffix: None,
}
}
fn match_with_language(language: Option<&str>, suffix: Option<&str>) -> FileMatch {
FileMatch {
video_file_id: "v".into(),
subtitle_file_id: "s".into(),
confidence: 1.0,
match_factors: vec![],
language: language.map(|s| s.to_string()),
target_filename_suffix: suffix.map(|s| s.to_string()),
}
}
struct DummyAI;
#[async_trait]
impl AIProvider for DummyAI {
async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
unimplemented!()
}
async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
unimplemented!()
}
}
#[test]
fn test_generate_subtitle_name_with_directory_language() {
let engine = MatchEngine::new(
Box::new(DummyAI),
MatchConfig {
confidence_threshold: 0.0,
max_sample_length: 0,
enable_content_analysis: false,
backup_enabled: false,
relocation_mode: FileRelocationMode::None,
conflict_resolution: ConflictResolution::Skip,
ai_model: "test-model".to_string(),
max_subtitle_bytes: 52_428_800,
},
);
let video = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("movie01.mp4"),
file_type: MediaFileType::Video,
size: 0,
name: "movie01".to_string(),
extension: "mp4".to_string(),
};
let subtitle = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("tc/subtitle01.ass"),
file_type: MediaFileType::Subtitle,
size: 0,
name: "subtitle01".to_string(),
extension: "ass".to_string(),
};
let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
assert_eq!(new_name, "movie01.tc.ass");
}
#[test]
fn test_generate_subtitle_name_with_filename_language() {
let engine = MatchEngine::new(
Box::new(DummyAI),
MatchConfig {
confidence_threshold: 0.0,
max_sample_length: 0,
enable_content_analysis: false,
backup_enabled: false,
relocation_mode: FileRelocationMode::None,
conflict_resolution: ConflictResolution::Skip,
ai_model: "test-model".to_string(),
max_subtitle_bytes: 52_428_800,
},
);
let video = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("movie02.mp4"),
file_type: MediaFileType::Video,
size: 0,
name: "movie02".to_string(),
extension: "mp4".to_string(),
};
let subtitle = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("subtitle02.sc.ass"),
file_type: MediaFileType::Subtitle,
size: 0,
name: "subtitle02".to_string(),
extension: "ass".to_string(),
};
let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
assert_eq!(new_name, "movie02.sc.ass");
}
#[test]
fn test_generate_subtitle_name_without_language() {
let engine = MatchEngine::new(
Box::new(DummyAI),
MatchConfig {
confidence_threshold: 0.0,
max_sample_length: 0,
enable_content_analysis: false,
backup_enabled: false,
relocation_mode: FileRelocationMode::None,
conflict_resolution: ConflictResolution::Skip,
ai_model: "test-model".to_string(),
max_subtitle_bytes: 52_428_800,
},
);
let video = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("movie03.mp4"),
file_type: MediaFileType::Video,
size: 0,
name: "movie03".to_string(),
extension: "mp4".to_string(),
};
let subtitle = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("subtitle03.ass"),
file_type: MediaFileType::Subtitle,
size: 0,
name: "subtitle03".to_string(),
extension: "ass".to_string(),
};
let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
assert_eq!(new_name, "movie03.ass");
}
#[test]
fn test_generate_subtitle_name_removes_video_extension() {
let engine = MatchEngine::new(
Box::new(DummyAI),
MatchConfig {
confidence_threshold: 0.0,
max_sample_length: 0,
enable_content_analysis: false,
backup_enabled: false,
relocation_mode: FileRelocationMode::None,
conflict_resolution: ConflictResolution::Skip,
ai_model: "test-model".to_string(),
max_subtitle_bytes: 52_428_800,
},
);
let video = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("movie.mkv"),
file_type: MediaFileType::Video,
size: 0,
name: "movie.mkv".to_string(),
extension: "mkv".to_string(),
};
let subtitle = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("subtitle.srt"),
file_type: MediaFileType::Subtitle,
size: 0,
name: "subtitle".to_string(),
extension: "srt".to_string(),
};
let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
assert_eq!(new_name, "movie.srt");
}
#[test]
fn test_generate_subtitle_name_with_language_removes_video_extension() {
let engine = MatchEngine::new(
Box::new(DummyAI),
MatchConfig {
confidence_threshold: 0.0,
max_sample_length: 0,
enable_content_analysis: false,
backup_enabled: false,
relocation_mode: FileRelocationMode::None,
conflict_resolution: ConflictResolution::Skip,
ai_model: "test-model".to_string(),
max_subtitle_bytes: 52_428_800,
},
);
let video = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("movie.mkv"),
file_type: MediaFileType::Video,
size: 0,
name: "movie.mkv".to_string(),
extension: "mkv".to_string(),
};
let subtitle = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("tc/subtitle.srt"),
file_type: MediaFileType::Subtitle,
size: 0,
name: "subtitle".to_string(),
extension: "srt".to_string(),
};
let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
assert_eq!(new_name, "movie.tc.srt");
}
#[test]
fn test_generate_subtitle_name_edge_cases() {
let engine = MatchEngine::new(
Box::new(DummyAI),
MatchConfig {
confidence_threshold: 0.0,
max_sample_length: 0,
enable_content_analysis: false,
backup_enabled: false,
relocation_mode: FileRelocationMode::None,
conflict_resolution: ConflictResolution::Skip,
ai_model: "test-model".to_string(),
max_subtitle_bytes: 52_428_800,
},
);
let video = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("a.b.c"),
file_type: MediaFileType::Video,
size: 0,
name: "a.b.c".to_string(),
extension: "".to_string(),
};
let subtitle = MediaFile {
id: "".to_string(),
relative_path: "".to_string(),
path: PathBuf::from("sub.srt"),
file_type: MediaFileType::Subtitle,
size: 0,
name: "sub".to_string(),
extension: "srt".to_string(),
};
let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
assert_eq!(new_name, "a.b.c.srt");
}
fn make_engine() -> MatchEngine {
MatchEngine::new(
Box::new(DummyAI),
MatchConfig {
confidence_threshold: 0.0,
max_sample_length: 0,
enable_content_analysis: false,
backup_enabled: false,
relocation_mode: FileRelocationMode::None,
conflict_resolution: ConflictResolution::Skip,
ai_model: "test-model".to_string(),
max_subtitle_bytes: 52_428_800,
},
)
}
fn media(path: &str, name: &str, ext: &str, ty: MediaFileType) -> MediaFile {
MediaFile {
id: "".into(),
relative_path: path.into(),
path: PathBuf::from(path),
file_type: ty,
size: 0,
name: name.into(),
extension: ext.into(),
}
}
#[test]
fn test_generate_subtitle_name_ai_suffix_wins() {
let engine = make_engine();
let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
let subtitle = media("tc/subs.srt", "subs", "srt", MediaFileType::Subtitle);
let m = match_with_language(Some("en"), Some("tc"));
assert_eq!(
engine.generate_subtitle_name(&video, &subtitle, &m),
"movie.tc.srt"
);
}
#[test]
fn test_generate_subtitle_name_ai_language_used() {
let engine = make_engine();
let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
let m = match_with_language(Some("ja"), None);
assert_eq!(
engine.generate_subtitle_name(&video, &subtitle, &m),
"movie.ja.srt"
);
}
#[test]
fn test_generate_subtitle_name_language_synonym_normalized() {
let engine = make_engine();
let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
for variant in &["english", "eng", "EN"] {
let m = match_with_language(Some(variant), None);
assert_eq!(
engine.generate_subtitle_name(&video, &subtitle, &m),
"movie.en.srt",
"variant {variant} should normalize to en"
);
}
}
#[test]
fn test_generate_subtitle_name_und_collapses_to_no_tag() {
let engine = make_engine();
let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
let m = match_with_language(Some("und"), None);
assert_eq!(
engine.generate_subtitle_name(&video, &subtitle, &m),
"movie.srt"
);
}
#[test]
fn test_generate_subtitle_name_sanitization_drops_path_traversal() {
let engine = make_engine();
let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
let m = match_with_language(None, Some("../etc"));
assert_eq!(
engine.generate_subtitle_name(&video, &subtitle, &m),
"movie.srt"
);
}
#[test]
fn test_sanitize_suffix_helper() {
assert_eq!(super::sanitize_suffix(""), None);
assert_eq!(super::sanitize_suffix("../"), None);
assert_eq!(super::sanitize_suffix("../etc"), None);
assert_eq!(super::sanitize_suffix("a-b_c"), Some("a-b_c".into()));
assert_eq!(super::sanitize_suffix("繁中"), None);
assert_eq!(super::sanitize_suffix("0123456789abcdefGHIJKL"), None);
assert_eq!(
super::sanitize_suffix("0123456789abcdef"),
Some("0123456789abcdef".into())
);
}
#[test]
fn test_normalize_ai_language_helper() {
let det = LanguageDetector::new();
assert_eq!(
super::normalize_ai_language(&det, "english"),
Some("en".into())
);
assert_eq!(super::normalize_ai_language(&det, "ENG"), Some("en".into()));
assert_eq!(super::normalize_ai_language(&det, "EN"), Some("en".into()));
assert_eq!(super::normalize_ai_language(&det, "und"), None);
assert_eq!(super::normalize_ai_language(&det, "UND"), None);
assert_eq!(super::normalize_ai_language(&det, ""), None);
assert_eq!(super::normalize_ai_language(&det, "cht"), Some("tc".into()));
assert_eq!(super::normalize_ai_language(&det, "chs"), Some("sc".into()));
assert_eq!(
super::normalize_ai_language(&det, "繁中"),
Some("tc".into())
);
assert_eq!(
super::normalize_ai_language(&det, "简中"),
Some("sc".into())
);
assert_eq!(
super::normalize_ai_language(&det, "traditional-chinese"),
Some("tc".into())
);
assert_eq!(
super::normalize_ai_language(&det, "Traditional_Chinese"),
Some("tc".into())
);
assert_eq!(
super::normalize_ai_language(&det, "zh-Hant"),
Some("tc".into())
);
assert_eq!(
super::normalize_ai_language(&det, "zh_hans"),
Some("sc".into())
);
assert_eq!(super::normalize_ai_language(&det, "vi"), Some("vi".into()));
assert_eq!(super::normalize_ai_language(&det, "ID"), Some("id".into()));
}
fn op(parent: &str, name: &str, sub_relpath: &str, relocate: bool) -> MatchOperation {
let video_path = PathBuf::from(parent).join("movie.mkv");
let subtitle_path = PathBuf::from(sub_relpath);
let relocation_target_path = if relocate {
Some(PathBuf::from(parent).join(name))
} else {
None
};
MatchOperation {
video_file: MediaFile {
id: "v".into(),
relative_path: video_path.to_string_lossy().to_string(),
path: video_path,
file_type: MediaFileType::Video,
size: 0,
name: "movie.mkv".into(),
extension: "mkv".into(),
},
subtitle_file: MediaFile {
id: "s".into(),
relative_path: sub_relpath.into(),
path: subtitle_path,
file_type: MediaFileType::Subtitle,
size: 0,
name: name.into(),
extension: "srt".into(),
},
new_subtitle_name: name.into(),
confidence: 1.0,
reasoning: vec![],
relocation_mode: if relocate {
FileRelocationMode::Copy
} else {
FileRelocationMode::None
},
relocation_target_path,
requires_relocation: relocate,
}
}
#[test]
fn test_unique_target_paths_two_duplicates_same_dir() {
let mut ops = vec![
op("/d", "movie.srt", "/d/a.srt", false),
op("/d", "movie.srt", "/d/b.srt", false),
];
super::apply_unique_target_paths(&mut ops);
assert_eq!(ops[0].new_subtitle_name, "movie.srt");
assert_eq!(ops[1].new_subtitle_name, "movie.2.srt");
}
#[test]
fn test_unique_target_paths_three_way_with_existing_two() {
let mut ops = vec![
op("/d", "movie.srt", "/d/a.srt", false),
op("/d", "movie.srt", "/d/b.srt", false),
op("/d", "movie.2.srt", "/d/c.srt", false),
];
super::apply_unique_target_paths(&mut ops);
assert_eq!(ops[0].new_subtitle_name, "movie.srt");
assert_eq!(ops[1].new_subtitle_name, "movie.3.srt");
assert_eq!(ops[2].new_subtitle_name, "movie.2.srt");
}
#[test]
fn test_unique_target_paths_idempotent() {
let mut ops = vec![
op("/d", "movie.srt", "/d/a.srt", false),
op("/d", "movie.srt", "/d/b.srt", false),
op("/d", "movie.2.srt", "/d/c.srt", false),
];
super::apply_unique_target_paths(&mut ops);
let snapshot: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
super::apply_unique_target_paths(&mut ops);
let after: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
assert_eq!(snapshot, after);
}
#[test]
fn test_unique_target_paths_two_languages_preserved() {
let mut ops = vec![
op("/d", "movie.tc.srt", "/d/a.srt", false),
op("/d", "movie.sc.srt", "/d/b.srt", false),
];
super::apply_unique_target_paths(&mut ops);
assert_eq!(ops[0].new_subtitle_name, "movie.tc.srt");
assert_eq!(ops[1].new_subtitle_name, "movie.sc.srt");
}
#[test]
fn test_unique_target_paths_cross_video_collision_under_copy() {
let mut ops = vec![
op("/shared", "subs.srt", "/src1/subs.srt", true),
op("/shared", "subs.srt", "/src2/subs.srt", true),
];
super::apply_unique_target_paths(&mut ops);
assert_eq!(ops[0].new_subtitle_name, "subs.srt");
assert_eq!(ops[1].new_subtitle_name, "subs.2.srt");
assert_eq!(
ops[0].relocation_target_path.as_ref().unwrap(),
&PathBuf::from("/shared/subs.srt")
);
assert_eq!(
ops[1].relocation_target_path.as_ref().unwrap(),
&PathBuf::from("/shared/subs.2.srt")
);
}
#[test]
fn test_unique_target_paths_archive_origin_relocation_unique() {
let mut ops = vec![
op("/videos", "movie.srt", "/tmp/a.srt", true),
op("/videos", "movie.srt", "/tmp/b.srt", true),
];
super::apply_unique_target_paths(&mut ops);
assert_eq!(ops[0].new_subtitle_name, "movie.srt");
assert_eq!(ops[1].new_subtitle_name, "movie.2.srt");
}
#[test]
fn test_unique_target_paths_skip_existing_on_disk() {
let dir = tempfile::tempdir().unwrap();
let dir_path = dir.path().to_path_buf();
std::fs::write(dir_path.join("movie.srt"), b"existing").unwrap();
std::fs::write(dir_path.join("movie.1.srt"), b"existing").unwrap();
let dir_str = dir_path.to_string_lossy().to_string();
let src1 = dir_path.parent().unwrap().join("sub1.srt");
let src2 = dir_path.parent().unwrap().join("sub2.srt");
let mut ops = vec![
op(&dir_str, "movie.srt", src1.to_str().unwrap(), true),
op(&dir_str, "movie.srt", src2.to_str().unwrap(), true),
];
super::apply_unique_target_paths(&mut ops);
let mut got: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
got.sort();
assert_eq!(
got,
vec!["movie.2.srt".to_string(), "movie.3.srt".to_string()]
);
}
#[test]
fn test_legacy_v1_cache_rejected() {
use crate::core::matcher::cache::CacheData;
use std::time::{SystemTime, UNIX_EPOCH};
use tempfile::TempDir;
assert_eq!(CURRENT_CACHE_VERSION, "2.0");
assert!(super::is_cache_version_current("2.0"));
assert!(!super::is_cache_version_current("1.0"));
assert!(!super::is_cache_version_current(""));
let temp = TempDir::new().unwrap();
let cache_path = temp.path().join("legacy_v1_cache.json");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let legacy = serde_json::json!({
"cache_version": "1.0",
"directory": "filelist_deadbeef",
"file_snapshot": [],
"match_operations": [],
"created_at": now,
"ai_model_used": "test-model",
"config_hash": "0000000000000000",
"original_relocation_mode": "None",
"original_backup_enabled": false,
});
std::fs::write(&cache_path, serde_json::to_string(&legacy).unwrap()).unwrap();
let loaded = CacheData::load(&cache_path).expect("legacy cache should parse");
assert_eq!(loaded.cache_version, "1.0");
assert!(
!super::is_cache_version_current(&loaded.cache_version),
"loaded v1 cache must be rejected by the version gate"
);
}
#[tokio::test]
async fn test_rename_file_displays_success_check_mark() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let original_file = temp_path.join("original.srt");
fs::write(
&original_file,
"1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
)
.unwrap();
let engine = MatchEngine::new(
Box::new(DummyAI),
MatchConfig {
confidence_threshold: 0.0,
max_sample_length: 0,
enable_content_analysis: false,
backup_enabled: false,
relocation_mode: FileRelocationMode::None,
conflict_resolution: ConflictResolution::Skip,
ai_model: "test-model".to_string(),
max_subtitle_bytes: 52_428_800,
},
);
let subtitle_file = MediaFile {
id: "test_id".to_string(),
relative_path: "original.srt".to_string(),
path: original_file.clone(),
file_type: MediaFileType::Subtitle,
size: 40,
name: "original".to_string(),
extension: "srt".to_string(),
};
let match_op = MatchOperation {
video_file: MediaFile {
id: "video_id".to_string(),
relative_path: "test.mp4".to_string(),
path: temp_path.join("test.mp4"),
file_type: MediaFileType::Video,
size: 1000,
name: "test".to_string(),
extension: "mp4".to_string(),
},
subtitle_file,
new_subtitle_name: "renamed.srt".to_string(),
confidence: 95.0,
reasoning: vec!["Test match".to_string()],
requires_relocation: false,
relocation_target_path: None,
relocation_mode: FileRelocationMode::None,
};
let result = engine.rename_file(&match_op).await;
assert!(result.is_ok());
let renamed_file = temp_path.join("renamed.srt");
assert!(renamed_file.exists(), "The renamed file should exist");
assert!(
!original_file.exists(),
"The original file should have been renamed"
);
let content = fs::read_to_string(&renamed_file).unwrap();
assert!(content.contains("Test subtitle"));
}
#[tokio::test]
async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let original_file = temp_path.join("original.srt");
fs::write(
&original_file,
"1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
)
.unwrap();
let engine = MatchEngine::new(
Box::new(DummyAI),
MatchConfig {
confidence_threshold: 0.0,
max_sample_length: 0,
enable_content_analysis: false,
backup_enabled: false,
relocation_mode: FileRelocationMode::None,
conflict_resolution: ConflictResolution::Skip,
ai_model: "test-model".to_string(),
max_subtitle_bytes: 52_428_800,
},
);
let subtitle_file = MediaFile {
id: "test_id".to_string(),
relative_path: "original.srt".to_string(),
path: original_file.clone(),
file_type: MediaFileType::Subtitle,
size: 40,
name: "original".to_string(),
extension: "srt".to_string(),
};
let match_op = MatchOperation {
video_file: MediaFile {
id: "video_id".to_string(),
relative_path: "test.mp4".to_string(),
path: temp_path.join("test.mp4"),
file_type: MediaFileType::Video,
size: 1000,
name: "test".to_string(),
extension: "mp4".to_string(),
},
subtitle_file,
new_subtitle_name: "renamed.srt".to_string(),
confidence: 95.0,
reasoning: vec!["Test match".to_string()],
requires_relocation: false,
relocation_target_path: None,
relocation_mode: FileRelocationMode::None,
};
let result = engine.rename_file(&match_op).await;
assert!(result.is_ok());
let renamed_file = temp_path.join("renamed.srt");
if renamed_file.exists() {
fs::remove_file(&renamed_file).unwrap();
}
fs::write(
&original_file,
"1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
)
.unwrap();
let result = engine.rename_file(&match_op).await;
assert!(result.is_ok());
let renamed_file = temp_path.join("renamed.srt");
if renamed_file.exists() {
fs::remove_file(&renamed_file).unwrap();
}
}
#[test]
fn test_file_operation_message_format() {
let source_name = "test.srt";
let target_name = "renamed.srt";
let success_msg = format!(" ✓ Renamed: {} -> {}", source_name, target_name);
assert!(success_msg.contains("✓"));
assert!(success_msg.contains("Renamed:"));
assert!(success_msg.contains(source_name));
assert!(success_msg.contains(target_name));
let error_msg = format!(
" ✗ Rename failed: {} -> {} (target file does not exist after operation)",
source_name, target_name
);
assert!(error_msg.contains("✗"));
assert!(error_msg.contains("Rename failed:"));
assert!(error_msg.contains("target file does not exist"));
assert!(error_msg.contains(source_name));
assert!(error_msg.contains(target_name));
}
#[test]
fn test_copy_operation_message_format() {
let source_name = "subtitle.srt";
let target_name = "video.srt";
let success_msg = format!(" ✓ Copied: {} -> {}", source_name, target_name);
assert!(success_msg.contains("✓"));
assert!(success_msg.contains("Copied:"));
let error_msg = format!(
" ✗ Copy failed: {} -> {} (target file does not exist after operation)",
source_name, target_name
);
assert!(error_msg.contains("✗"));
assert!(error_msg.contains("Copy failed:"));
assert!(error_msg.contains("target file does not exist"));
}
#[test]
fn test_move_operation_message_format() {
let source_name = "subtitle.srt";
let target_name = "video.srt";
let success_msg = format!(" ✓ Moved: {} -> {}", source_name, target_name);
assert!(success_msg.contains("✓"));
assert!(success_msg.contains("Moved:"));
let error_msg = format!(
" ✗ Move failed: {} -> {} (target file does not exist after operation)",
source_name, target_name
);
assert!(error_msg.contains("✗"));
assert!(error_msg.contains("Move failed:"));
assert!(error_msg.contains("target file does not exist"));
}
}
#[derive(Debug)]
pub struct MatchOperation {
pub video_file: MediaFile,
pub subtitle_file: MediaFile,
pub new_subtitle_name: String,
pub confidence: f32,
pub reasoning: Vec<String>,
pub relocation_mode: FileRelocationMode,
pub relocation_target_path: Option<std::path::PathBuf>,
pub requires_relocation: bool,
}
#[derive(Debug, Clone)]
pub struct RejectedCandidate {
pub video_path: String,
pub subtitle_path: String,
pub confidence: f32,
pub reason: &'static str,
}
#[derive(Debug)]
pub struct MatchAudit {
pub operations: Vec<MatchOperation>,
pub rejected: Vec<RejectedCandidate>,
}
#[derive(Debug)]
pub struct OperationOutcome {
pub applied: bool,
pub error: Option<OperationError>,
}
#[derive(Debug, Clone)]
pub struct OperationError {
pub category: &'static str,
pub code: &'static str,
pub message: String,
}
fn operation_error_from(err: &SubXError) -> OperationError {
OperationError {
category: err.category(),
code: err.machine_code(),
message: err.user_friendly_message(),
}
}
pub struct MatchEngine {
ai_client: Box<dyn AIProvider>,
discovery: FileDiscovery,
config: MatchConfig,
}
impl MatchEngine {
pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
Self {
ai_client,
discovery: FileDiscovery::new(),
config,
}
}
pub async fn match_file_list(&self, file_paths: &[PathBuf]) -> Result<Vec<MatchOperation>> {
Ok(self
.match_file_list_with_audit(file_paths)
.await?
.operations)
}
pub async fn match_file_list_with_audit(&self, file_paths: &[PathBuf]) -> Result<MatchAudit> {
let json_mode = crate::cli::output::active_mode().is_json();
let files = self.discovery.scan_file_list(file_paths)?;
let videos: Vec<_> = files
.iter()
.filter(|f| matches!(f.file_type, MediaFileType::Video))
.collect();
let subtitles: Vec<_> = files
.iter()
.filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
.collect();
if videos.is_empty() || subtitles.is_empty() {
return Ok(MatchAudit {
operations: Vec::new(),
rejected: Vec::new(),
});
}
let cache_key = self.calculate_file_list_cache_key(file_paths)?;
if let Some(ops) = self.check_file_list_cache(&cache_key).await? {
return Ok(MatchAudit {
operations: ops,
rejected: Vec::new(),
});
}
let content_samples = if self.config.enable_content_analysis {
self.extract_content_samples(&subtitles).await?
} else {
Vec::new()
};
let video_files: Vec<String> = videos
.iter()
.map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
.collect();
let subtitle_files: Vec<String> = subtitles
.iter()
.map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
.collect();
let analysis_request = AnalysisRequest {
video_files,
subtitle_files,
content_samples,
};
let match_result = self.ai_client.analyze_content(analysis_request).await?;
if !json_mode {
eprintln!("🔍 AI Analysis Results:");
eprintln!(" - Total matches: {}", match_result.matches.len());
eprintln!(
" - Confidence threshold: {:.2}",
self.config.confidence_threshold
);
for ai_match in &match_result.matches {
eprintln!(
" - {} -> {} (confidence: {:.2})",
ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
);
}
}
let mut operations = Vec::new();
let mut rejected = Vec::new();
for ai_match in match_result.matches {
let video_match =
Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
let subtitle_match =
Self::find_media_file_by_id_or_path(&subtitles, &ai_match.subtitle_file_id, None);
if ai_match.confidence < self.config.confidence_threshold {
rejected.push(RejectedCandidate {
video_path: video_match
.map(|v| v.path.display().to_string())
.unwrap_or_default(),
subtitle_path: subtitle_match
.map(|s| s.path.display().to_string())
.unwrap_or_default(),
confidence: ai_match.confidence,
reason: "below_threshold",
});
continue;
}
match (video_match, subtitle_match) {
(Some(video), Some(subtitle)) => {
let new_name = self.generate_subtitle_name(video, subtitle, &ai_match);
let requires_relocation = self.config.relocation_mode
!= FileRelocationMode::None
&& subtitle.path.parent() != video.path.parent();
let relocation_target_path = if requires_relocation {
let video_dir = video.path.parent().unwrap();
Some(video_dir.join(&new_name))
} else {
None
};
operations.push(MatchOperation {
video_file: (*video).clone(),
subtitle_file: (*subtitle).clone(),
new_subtitle_name: new_name,
confidence: ai_match.confidence,
reasoning: ai_match.match_factors,
relocation_mode: self.config.relocation_mode.clone(),
relocation_target_path,
requires_relocation,
});
}
_ => {
if !json_mode {
eprintln!(
"⚠️ Cannot find AI-suggested file pair:\n Video ID: '{}'\n Subtitle ID: '{}'",
ai_match.video_file_id, ai_match.subtitle_file_id
);
}
rejected.push(RejectedCandidate {
video_path: video_match
.map(|v| v.path.display().to_string())
.unwrap_or_default(),
subtitle_path: subtitle_match
.map(|s| s.path.display().to_string())
.unwrap_or_default(),
confidence: ai_match.confidence,
reason: "id_not_found",
});
}
}
}
apply_unique_target_paths(&mut operations);
self.save_file_list_cache(&cache_key, &operations).await?;
Ok(MatchAudit {
operations,
rejected,
})
}
async fn extract_content_samples(
&self,
subtitles: &[&MediaFile],
) -> Result<Vec<ContentSample>> {
let mut samples = Vec::new();
for subtitle in subtitles {
let path = subtitle.path.clone();
crate::core::fs_util::check_file_size(
&path,
self.config.max_subtitle_bytes,
"Subtitle",
)
.map_err(SubXError::Io)?;
let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
.await
.map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
let preview = self.create_content_preview(&content);
samples.push(ContentSample {
filename: subtitle.name.clone(),
subtitle_file_id: subtitle.id.clone(),
content_preview: preview,
file_size: subtitle.size,
});
}
Ok(samples)
}
fn create_content_preview(&self, content: &str) -> String {
let lines: Vec<&str> = content.lines().take(20).collect();
let preview = lines.join("\n");
if preview.len() > self.config.max_sample_length {
format!("{}...", &preview[..self.config.max_sample_length])
} else {
preview
}
}
fn generate_subtitle_name(
&self,
video: &MediaFile,
subtitle: &MediaFile,
ai_match: &crate::services::ai::FileMatch,
) -> String {
let detector = LanguageDetector::new();
let video_base_name = if !video.extension.is_empty() {
video
.name
.strip_suffix(&format!(".{}", video.extension))
.unwrap_or(&video.name)
} else {
&video.name
};
let ai_tag = ai_match
.target_filename_suffix
.as_deref()
.and_then(sanitize_suffix)
.or_else(|| {
ai_match
.language
.as_deref()
.and_then(|s| normalize_ai_language(&detector, s))
});
let code = ai_tag.or_else(|| detector.get_primary_language(&subtitle.path));
if let Some(code) = code {
format!("{}.{}.{}", video_base_name, code, subtitle.extension)
} else {
format!("{}.{}", video_base_name, subtitle.extension)
}
}
pub async fn execute_operations(
&self,
operations: &[MatchOperation],
dry_run: bool,
) -> Result<()> {
if dry_run {
let json_mode = crate::cli::output::active_mode().is_json();
for op in operations {
if !json_mode {
println!(
"Preview: {} -> {}",
op.subtitle_file.name, op.new_subtitle_name
);
}
if op.requires_relocation {
if let Some(target_path) = &op.relocation_target_path {
let operation_verb = match op.relocation_mode {
FileRelocationMode::Copy => "Copy",
FileRelocationMode::Move => "Move",
_ => "",
};
if !json_mode {
println!(
"Preview: {} {} to {}",
operation_verb,
op.subtitle_file.path.display(),
target_path.display()
);
}
}
}
}
return Ok(());
}
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let batch_id = {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
created_at.hash(&mut hasher);
operations.len().hash(&mut hasher);
for op in operations {
op.subtitle_file.path.hash(&mut hasher);
op.new_subtitle_name.hash(&mut hasher);
}
format!("{:016x}", hasher.finish())
};
let mut journal = JournalData {
batch_id,
created_at,
entries: Vec::new(),
};
let journal_file = journal_path().ok();
let mut first_error: Option<SubXError> = None;
for op in operations {
let mut backup_path: Option<PathBuf> = None;
if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
let backup_task =
self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
backup_path = Some(backup.clone());
}
if let TaskResult::Failed(err) = backup_task.execute().await {
first_error = Some(SubXError::FileOperationFailed(err));
break;
}
}
let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
self.create_copy_task(op)
} else {
self.create_rename_task(op)
};
let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
{
ProcessingOperation::CopyWithRename { source, target }
| ProcessingOperation::CopyToVideoFolder { source, target } => {
(source.clone(), target.clone(), JournalOperationType::Copied)
}
ProcessingOperation::MoveToVideoFolder { source, target } => {
(source.clone(), target.clone(), JournalOperationType::Moved)
}
ProcessingOperation::RenameFile { source, target } => {
let kind = match op.relocation_mode {
FileRelocationMode::Move => JournalOperationType::Moved,
_ => JournalOperationType::Renamed,
};
(source.clone(), target.clone(), kind)
}
_ => (
op.subtitle_file.path.clone(),
op.relocation_target_path.clone().unwrap_or_else(|| {
op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
}),
JournalOperationType::Renamed,
),
};
let (pre_file_size, pre_file_mtime) = journal_source
.metadata()
.ok()
.map(|m| {
let mtime = m
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
(m.len(), mtime)
})
.unwrap_or((0, 0));
if let TaskResult::Failed(err) = primary_task.execute().await {
first_error = Some(SubXError::FileOperationFailed(err));
break;
}
let (file_size, file_mtime) = journal_destination
.metadata()
.ok()
.map(|m| {
let mtime = m
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
(m.len(), mtime)
})
.unwrap_or((pre_file_size, pre_file_mtime));
journal.entries.push(JournalEntry {
operation_type: journal_kind,
source: journal_source,
destination: journal_destination,
backup_path: backup_path.clone(),
status: JournalEntryStatus::Completed,
file_size,
file_mtime,
});
if let Some(path) = journal_file.as_ref() {
journal.save(path).await?;
}
}
if let Some(err) = first_error {
return Err(err);
}
Ok(())
}
pub async fn execute_operations_audit(
&self,
operations: &[MatchOperation],
dry_run: bool,
) -> Result<Vec<OperationOutcome>> {
if dry_run {
return Ok(operations
.iter()
.map(|_| OperationOutcome {
applied: false,
error: None,
})
.collect());
}
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let batch_id = {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
created_at.hash(&mut hasher);
operations.len().hash(&mut hasher);
for op in operations {
op.subtitle_file.path.hash(&mut hasher);
op.new_subtitle_name.hash(&mut hasher);
}
format!("{:016x}", hasher.finish())
};
let mut journal = JournalData {
batch_id,
created_at,
entries: Vec::new(),
};
let journal_file = journal_path().ok();
let mut outcomes = Vec::with_capacity(operations.len());
for op in operations {
let mut backup_path: Option<PathBuf> = None;
if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
let backup_task =
self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
backup_path = Some(backup.clone());
}
if let TaskResult::Failed(err) = backup_task.execute().await {
let err = SubXError::FileOperationFailed(err);
outcomes.push(OperationOutcome {
applied: false,
error: Some(operation_error_from(&err)),
});
continue;
}
}
let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
self.create_copy_task(op)
} else {
self.create_rename_task(op)
};
let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
{
ProcessingOperation::CopyWithRename { source, target }
| ProcessingOperation::CopyToVideoFolder { source, target } => {
(source.clone(), target.clone(), JournalOperationType::Copied)
}
ProcessingOperation::MoveToVideoFolder { source, target } => {
(source.clone(), target.clone(), JournalOperationType::Moved)
}
ProcessingOperation::RenameFile { source, target } => {
let kind = match op.relocation_mode {
FileRelocationMode::Move => JournalOperationType::Moved,
_ => JournalOperationType::Renamed,
};
(source.clone(), target.clone(), kind)
}
_ => (
op.subtitle_file.path.clone(),
op.relocation_target_path.clone().unwrap_or_else(|| {
op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
}),
JournalOperationType::Renamed,
),
};
let (pre_file_size, pre_file_mtime) = journal_source
.metadata()
.ok()
.map(|m| {
let mtime = m
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
(m.len(), mtime)
})
.unwrap_or((0, 0));
if let TaskResult::Failed(err) = primary_task.execute().await {
let err = SubXError::FileOperationFailed(err);
outcomes.push(OperationOutcome {
applied: false,
error: Some(operation_error_from(&err)),
});
continue;
}
let (file_size, file_mtime) = journal_destination
.metadata()
.ok()
.map(|m| {
let mtime = m
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
(m.len(), mtime)
})
.unwrap_or((pre_file_size, pre_file_mtime));
journal.entries.push(JournalEntry {
operation_type: journal_kind,
source: journal_source,
destination: journal_destination,
backup_path: backup_path.clone(),
status: JournalEntryStatus::Completed,
file_size,
file_mtime,
});
if let Some(path) = journal_file.as_ref() {
journal.save(path).await?;
}
outcomes.push(OperationOutcome {
applied: true,
error: None,
});
}
Ok(outcomes)
}
async fn rename_file(&self, op: &MatchOperation) -> Result<()> {
let task = self.create_rename_task(op);
match task.execute().await {
TaskResult::Success(_) => Ok(()),
TaskResult::Failed(err) => Err(SubXError::FileOperationFailed(err)),
other => Err(SubXError::FileOperationFailed(format!(
"Unexpected rename result: {:?}",
other
))),
}
}
fn resolve_filename_conflict(&self, target: std::path::PathBuf) -> Result<std::path::PathBuf> {
if !target.exists() {
return Ok(target);
}
let json_mode = crate::cli::output::active_mode().is_json();
match self.config.conflict_resolution {
ConflictResolution::Skip => {
if !json_mode {
eprintln!(
"Warning: Skipping relocation due to existing file: {}",
target.display()
);
}
Ok(target)
}
ConflictResolution::AutoRename => {
let file_stem = target
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("file");
let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
match crate::core::fs_util::atomic_create_file(&target) {
Ok(_f) => {
let _ = std::fs::remove_file(&target);
return Ok(target);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(SubXError::from(e)),
}
for i in 1..1000 {
let new_name = if extension.is_empty() {
format!("{}.{}", file_stem, i)
} else {
format!("{}.{}.{}", file_stem, i, extension)
};
let new_path = parent.join(new_name);
match crate::core::fs_util::atomic_create_file(&new_path) {
Ok(_f) => {
let _ = std::fs::remove_file(&new_path);
return Ok(new_path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(SubXError::from(e)),
}
}
Err(SubXError::FileOperationFailed(
"Could not resolve filename conflict".to_string(),
))
}
ConflictResolution::Prompt => {
if !json_mode {
eprintln!(
"Warning: Conflict resolution prompt not implemented, using auto-rename"
);
}
self.resolve_filename_conflict(target)
}
}
}
fn create_copy_task(&self, op: &MatchOperation) -> FileProcessingTask {
let source = op.subtitle_file.path.clone();
let target_base = op.relocation_target_path.clone().unwrap();
let final_target = self.resolve_filename_conflict(target_base).unwrap();
FileProcessingTask::new(
source.clone(),
Some(final_target.clone()),
ProcessingOperation::CopyWithRename {
source,
target: final_target,
},
)
}
fn create_backup_task(&self, source: &std::path::Path, ext: &str) -> FileProcessingTask {
let backup_path = source.with_extension(format!("{}.backup", ext));
FileProcessingTask::new(
source.to_path_buf(),
Some(backup_path.clone()),
ProcessingOperation::CreateBackup {
source: source.to_path_buf(),
backup: backup_path,
},
)
}
fn create_rename_task(&self, op: &MatchOperation) -> FileProcessingTask {
let old = op.subtitle_file.path.clone();
let new_path = if op.requires_relocation && op.relocation_target_path.is_some() {
let target_base = op.relocation_target_path.clone().unwrap();
self.resolve_filename_conflict(target_base).unwrap()
} else {
old.with_file_name(&op.new_subtitle_name)
};
FileProcessingTask::new(
old.clone(),
Some(new_path.clone()),
ProcessingOperation::RenameFile {
source: old,
target: new_path,
},
)
}
fn calculate_file_list_cache_key(&self, file_paths: &[PathBuf]) -> Result<String> {
use std::collections::BTreeMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut path_metadata = BTreeMap::new();
for path in file_paths {
if let Ok(metadata) = path.metadata() {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
path_metadata.insert(
canonical.to_string_lossy().to_string(),
(metadata.len(), metadata.modified().ok()),
);
}
}
let config_hash = self.calculate_config_hash()?;
let mut hasher = DefaultHasher::new();
path_metadata.hash(&mut hasher);
config_hash.hash(&mut hasher);
Ok(format!("filelist_{:016x}", hasher.finish()))
}
async fn check_file_list_cache(&self, cache_key: &str) -> Result<Option<Vec<MatchOperation>>> {
let cache_file_path = self.get_cache_file_path()?;
let cache_data = CacheData::load(&cache_file_path).ok();
if let Some(cache_data) = cache_data {
if !is_cache_version_current(&cache_data.cache_version) {
return Ok(None);
}
if cache_data.directory == cache_key {
let mut ops = Vec::new();
let mut id_gen = Uuidv7Generator::new();
for item in cache_data.match_operations {
let video_path = PathBuf::from(&item.video_file);
let subtitle_path = PathBuf::from(&item.subtitle_file);
if video_path.exists() && subtitle_path.exists() {
let video_meta = video_path.metadata()?;
let subtitle_meta = subtitle_path.metadata()?;
let video_file = MediaFile {
id: generate_file_id(&mut id_gen),
path: video_path.clone(),
file_type: MediaFileType::Video,
size: video_meta.len(),
name: video_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string(),
extension: video_path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase(),
relative_path: video_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string(),
};
let subtitle_file = MediaFile {
id: generate_file_id(&mut id_gen),
path: subtitle_path.clone(),
file_type: MediaFileType::Subtitle,
size: subtitle_meta.len(),
name: subtitle_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string(),
extension: subtitle_path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase(),
relative_path: subtitle_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string(),
};
let requires_relocation = self.config.relocation_mode
!= FileRelocationMode::None
&& subtitle_file.path.parent() != video_file.path.parent();
let relocation_target_path = if requires_relocation {
let video_dir = video_file.path.parent().unwrap();
Some(video_dir.join(&item.new_subtitle_name))
} else {
None
};
ops.push(MatchOperation {
video_file,
subtitle_file,
new_subtitle_name: item.new_subtitle_name,
confidence: item.confidence,
reasoning: item.reasoning,
relocation_mode: self.config.relocation_mode.clone(),
relocation_target_path,
requires_relocation,
});
}
}
return Ok(Some(ops));
}
}
Ok(None)
}
async fn save_file_list_cache(
&self,
cache_key: &str,
operations: &[MatchOperation],
) -> Result<()> {
let cache_file_path = self.get_cache_file_path()?;
let config_hash = self.calculate_config_hash()?;
let mut cache_items = Vec::new();
for op in operations {
cache_items.push(OpItem {
video_file: op.video_file.path.to_string_lossy().to_string(),
subtitle_file: op.subtitle_file.path.to_string_lossy().to_string(),
new_subtitle_name: op.new_subtitle_name.clone(),
confidence: op.confidence,
reasoning: op.reasoning.clone(),
});
}
let mut snapshot_items = Vec::new();
let mut seen_paths = std::collections::HashSet::new();
for op in operations {
for path in [&op.video_file.path, &op.subtitle_file.path] {
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
let key = canonical.to_string_lossy().to_string();
if seen_paths.insert(key.clone()) {
if let Ok(meta) = std::fs::metadata(&canonical) {
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
snapshot_items.push(SnapshotItem {
path: key,
name: canonical
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
size: meta.len(),
mtime,
file_type: if canonical.extension().is_some_and(|e| {
["srt", "ass", "ssa", "vtt", "sub"]
.contains(&e.to_string_lossy().to_lowercase().as_str())
}) {
"subtitle".to_string()
} else {
"video".to_string()
},
});
}
}
}
}
let cache_data = CacheData {
cache_version: CURRENT_CACHE_VERSION.to_string(),
directory: cache_key.to_string(),
file_snapshot: snapshot_items,
match_operations: cache_items,
created_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
ai_model_used: self.config.ai_model.clone(),
config_hash,
original_relocation_mode: format!("{:?}", self.config.relocation_mode),
original_backup_enabled: self.config.backup_enabled,
};
let cache_dir = cache_file_path.parent().unwrap().to_path_buf();
let cache_json = serde_json::to_string_pretty(&cache_data)?;
let cache_file_path_clone = cache_file_path.clone();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
std::fs::create_dir_all(&cache_dir)?;
std::fs::write(&cache_file_path_clone, cache_json)?;
Ok(())
})
.await
.map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
Ok(())
}
fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
std::path::PathBuf::from(xdg_config)
} else {
dirs::config_dir()
.ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
};
Ok(dir.join("subx").join("match_cache.json"))
}
fn calculate_config_hash(&self) -> Result<String> {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
self.config.backup_enabled.hash(&mut hasher);
"prompt_v2".hash(&mut hasher);
Ok(format!("{:016x}", hasher.finish()))
}
fn find_media_file_by_id_or_path<'a>(
files: &'a [&MediaFile],
file_id: &str,
fallback_path: Option<&str>,
) -> Option<&'a MediaFile> {
if let Some(file) = files.iter().find(|f| f.id == file_id) {
return Some(*file);
}
if let Some(path) = fallback_path {
if let Some(file) = files.iter().find(|f| f.relative_path == path) {
return Some(*file);
}
files.iter().find(|f| f.name == path).copied()
} else {
None
}
}
fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
if crate::cli::output::active_mode().is_json() {
return;
}
eprintln!(" Available {} files:", file_type);
for f in files {
eprintln!(
" - ID: {} | Name: {} | Path: {}",
f.id, f.name, f.relative_path
);
}
}
fn log_no_matches_found(
&self,
match_result: &MatchResult,
videos: &[MediaFile],
subtitles: &[MediaFile],
) {
if crate::cli::output::active_mode().is_json() {
return;
}
eprintln!("\n❌ No matching files found that meet the criteria");
eprintln!("🔍 AI analysis results:");
eprintln!(" - Total matches: {}", match_result.matches.len());
eprintln!(
" - Confidence threshold: {:.2}",
self.config.confidence_threshold
);
eprintln!(
" - Matches meeting threshold: {}",
match_result
.matches
.iter()
.filter(|m| m.confidence >= self.config.confidence_threshold)
.count()
);
eprintln!("\n📂 Scanned files:");
eprintln!(" Video files ({} files):", videos.len());
for v in videos {
eprintln!(" - ID: {} | {}", v.id, v.relative_path);
}
eprintln!(" Subtitle files ({} files):", subtitles.len());
for s in subtitles {
eprintln!(" - ID: {} | {}", s.id, s.relative_path);
}
}
}
pub async fn apply_cached_operations(cache: &CacheData, config: &MatchConfig) -> Result<()> {
let operations = reconstruct_operations_from_cache(cache, config)?;
let engine = MatchEngine::new(Box::new(NoOpAIProvider), config.clone());
engine.execute_operations(&operations, false).await
}
fn reconstruct_operations_from_cache(
cache: &CacheData,
config: &MatchConfig,
) -> Result<Vec<MatchOperation>> {
let mut ops = Vec::new();
let mut id_gen = Uuidv7Generator::new();
for item in &cache.match_operations {
let video_path = PathBuf::from(&item.video_file);
let subtitle_path = PathBuf::from(&item.subtitle_file);
if !video_path.exists() || !subtitle_path.exists() {
continue;
}
let video_meta = video_path.metadata()?;
let subtitle_meta = subtitle_path.metadata()?;
let video_file = MediaFile {
id: generate_file_id(&mut id_gen),
path: video_path.clone(),
file_type: MediaFileType::Video,
size: video_meta.len(),
name: video_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
extension: video_path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase(),
relative_path: video_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
};
let subtitle_file = MediaFile {
id: generate_file_id(&mut id_gen),
path: subtitle_path.clone(),
file_type: MediaFileType::Subtitle,
size: subtitle_meta.len(),
name: subtitle_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
extension: subtitle_path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase(),
relative_path: subtitle_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
};
let requires_relocation = config.relocation_mode != FileRelocationMode::None
&& subtitle_file.path.parent() != video_file.path.parent();
let relocation_target_path = if requires_relocation {
video_file
.path
.parent()
.map(|p| p.join(&item.new_subtitle_name))
} else {
None
};
ops.push(MatchOperation {
video_file,
subtitle_file,
new_subtitle_name: item.new_subtitle_name.clone(),
confidence: item.confidence,
reasoning: item.reasoning.clone(),
relocation_mode: config.relocation_mode.clone(),
relocation_target_path,
requires_relocation,
});
}
Ok(ops)
}
struct NoOpAIProvider;
#[async_trait::async_trait]
impl AIProvider for NoOpAIProvider {
async fn analyze_content(&self, _request: AnalysisRequest) -> crate::Result<MatchResult> {
Err(SubXError::config(
"AI analysis is not available while replaying cached operations",
))
}
async fn verify_match(
&self,
_verification: crate::services::ai::VerificationRequest,
) -> crate::Result<crate::services::ai::ConfidenceScore> {
Err(SubXError::config(
"AI verification is not available while replaying cached operations",
))
}
}