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;
#[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, MatchResult, VerificationRequest,
};
use async_trait::async_trait;
use std::path::PathBuf;
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);
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);
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);
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);
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);
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);
assert_eq!(new_name, "a.b.c.srt");
}
#[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);
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",
});
}
}
}
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(),
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) -> 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
};
if let Some(code) = detector.get_primary_language(&subtitle.path) {
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 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: "1.0".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);
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",
))
}
}