1use crate::services::ai::{AIProvider, AnalysisRequest, ContentSample, MatchResult};
16use std::path::PathBuf;
17
18use crate::Result;
19use crate::core::language::LanguageDetector;
20use crate::core::matcher::cache::{CacheData, OpItem, SnapshotItem};
21use crate::core::matcher::discovery::generate_file_id;
22use crate::core::matcher::journal::{
23 JournalData, JournalEntry, JournalEntryStatus, JournalOperationType, journal_path,
24};
25use crate::core::matcher::{FileDiscovery, MediaFile, MediaFileType};
26use crate::core::parallel::{FileProcessingTask, ProcessingOperation, Task, TaskResult};
27use crate::error::SubXError;
28use dirs;
29use serde_json;
30
31#[derive(Debug, Clone, PartialEq)]
33pub enum FileRelocationMode {
34 None,
36 Copy,
38 Move,
40}
41
42#[derive(Debug, Clone)]
44pub enum ConflictResolution {
45 Skip,
47 AutoRename,
49 Prompt,
51}
52
53#[derive(Debug, Clone)]
58pub struct MatchConfig {
59 pub confidence_threshold: f32,
61 pub max_sample_length: usize,
63 pub enable_content_analysis: bool,
65 pub backup_enabled: bool,
67 pub relocation_mode: FileRelocationMode,
69 pub conflict_resolution: ConflictResolution,
71 pub ai_model: String,
73 pub max_subtitle_bytes: u64,
76}
77
78#[cfg(test)]
79mod language_name_tests {
80 use super::*;
81 use crate::core::matcher::discovery::{MediaFile, MediaFileType};
82 use crate::services::ai::{
83 AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
84 };
85 use async_trait::async_trait;
86 use std::path::PathBuf;
87
88 struct DummyAI;
89 #[async_trait]
90 impl AIProvider for DummyAI {
91 async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
92 unimplemented!()
93 }
94 async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
95 unimplemented!()
96 }
97 }
98
99 #[test]
100 fn test_generate_subtitle_name_with_directory_language() {
101 let engine = MatchEngine::new(
102 Box::new(DummyAI),
103 MatchConfig {
104 confidence_threshold: 0.0,
105 max_sample_length: 0,
106 enable_content_analysis: false,
107 backup_enabled: false,
108 relocation_mode: FileRelocationMode::None,
109 conflict_resolution: ConflictResolution::Skip,
110 ai_model: "test-model".to_string(),
111 max_subtitle_bytes: 52_428_800,
112 },
113 );
114 let video = MediaFile {
115 id: "".to_string(),
116 relative_path: "".to_string(),
117 path: PathBuf::from("movie01.mp4"),
118 file_type: MediaFileType::Video,
119 size: 0,
120 name: "movie01".to_string(),
121 extension: "mp4".to_string(),
122 };
123 let subtitle = MediaFile {
124 id: "".to_string(),
125 relative_path: "".to_string(),
126 path: PathBuf::from("tc/subtitle01.ass"),
127 file_type: MediaFileType::Subtitle,
128 size: 0,
129 name: "subtitle01".to_string(),
130 extension: "ass".to_string(),
131 };
132 let new_name = engine.generate_subtitle_name(&video, &subtitle);
133 assert_eq!(new_name, "movie01.tc.ass");
134 }
135
136 #[test]
137 fn test_generate_subtitle_name_with_filename_language() {
138 let engine = MatchEngine::new(
139 Box::new(DummyAI),
140 MatchConfig {
141 confidence_threshold: 0.0,
142 max_sample_length: 0,
143 enable_content_analysis: false,
144 backup_enabled: false,
145 relocation_mode: FileRelocationMode::None,
146 conflict_resolution: ConflictResolution::Skip,
147 ai_model: "test-model".to_string(),
148 max_subtitle_bytes: 52_428_800,
149 },
150 );
151 let video = MediaFile {
152 id: "".to_string(),
153 relative_path: "".to_string(),
154 path: PathBuf::from("movie02.mp4"),
155 file_type: MediaFileType::Video,
156 size: 0,
157 name: "movie02".to_string(),
158 extension: "mp4".to_string(),
159 };
160 let subtitle = MediaFile {
161 id: "".to_string(),
162 relative_path: "".to_string(),
163 path: PathBuf::from("subtitle02.sc.ass"),
164 file_type: MediaFileType::Subtitle,
165 size: 0,
166 name: "subtitle02".to_string(),
167 extension: "ass".to_string(),
168 };
169 let new_name = engine.generate_subtitle_name(&video, &subtitle);
170 assert_eq!(new_name, "movie02.sc.ass");
171 }
172
173 #[test]
174 fn test_generate_subtitle_name_without_language() {
175 let engine = MatchEngine::new(
176 Box::new(DummyAI),
177 MatchConfig {
178 confidence_threshold: 0.0,
179 max_sample_length: 0,
180 enable_content_analysis: false,
181 backup_enabled: false,
182 relocation_mode: FileRelocationMode::None,
183 conflict_resolution: ConflictResolution::Skip,
184 ai_model: "test-model".to_string(),
185 max_subtitle_bytes: 52_428_800,
186 },
187 );
188 let video = MediaFile {
189 id: "".to_string(),
190 relative_path: "".to_string(),
191 path: PathBuf::from("movie03.mp4"),
192 file_type: MediaFileType::Video,
193 size: 0,
194 name: "movie03".to_string(),
195 extension: "mp4".to_string(),
196 };
197 let subtitle = MediaFile {
198 id: "".to_string(),
199 relative_path: "".to_string(),
200 path: PathBuf::from("subtitle03.ass"),
201 file_type: MediaFileType::Subtitle,
202 size: 0,
203 name: "subtitle03".to_string(),
204 extension: "ass".to_string(),
205 };
206 let new_name = engine.generate_subtitle_name(&video, &subtitle);
207 assert_eq!(new_name, "movie03.ass");
208 }
209 #[test]
210 fn test_generate_subtitle_name_removes_video_extension() {
211 let engine = MatchEngine::new(
212 Box::new(DummyAI),
213 MatchConfig {
214 confidence_threshold: 0.0,
215 max_sample_length: 0,
216 enable_content_analysis: false,
217 backup_enabled: false,
218 relocation_mode: FileRelocationMode::None,
219 conflict_resolution: ConflictResolution::Skip,
220 ai_model: "test-model".to_string(),
221 max_subtitle_bytes: 52_428_800,
222 },
223 );
224 let video = MediaFile {
225 id: "".to_string(),
226 relative_path: "".to_string(),
227 path: PathBuf::from("movie.mkv"),
228 file_type: MediaFileType::Video,
229 size: 0,
230 name: "movie.mkv".to_string(),
231 extension: "mkv".to_string(),
232 };
233 let subtitle = MediaFile {
234 id: "".to_string(),
235 relative_path: "".to_string(),
236 path: PathBuf::from("subtitle.srt"),
237 file_type: MediaFileType::Subtitle,
238 size: 0,
239 name: "subtitle".to_string(),
240 extension: "srt".to_string(),
241 };
242 let new_name = engine.generate_subtitle_name(&video, &subtitle);
243 assert_eq!(new_name, "movie.srt");
244 }
245
246 #[test]
247 fn test_generate_subtitle_name_with_language_removes_video_extension() {
248 let engine = MatchEngine::new(
249 Box::new(DummyAI),
250 MatchConfig {
251 confidence_threshold: 0.0,
252 max_sample_length: 0,
253 enable_content_analysis: false,
254 backup_enabled: false,
255 relocation_mode: FileRelocationMode::None,
256 conflict_resolution: ConflictResolution::Skip,
257 ai_model: "test-model".to_string(),
258 max_subtitle_bytes: 52_428_800,
259 },
260 );
261 let video = MediaFile {
262 id: "".to_string(),
263 relative_path: "".to_string(),
264 path: PathBuf::from("movie.mkv"),
265 file_type: MediaFileType::Video,
266 size: 0,
267 name: "movie.mkv".to_string(),
268 extension: "mkv".to_string(),
269 };
270 let subtitle = MediaFile {
271 id: "".to_string(),
272 relative_path: "".to_string(),
273 path: PathBuf::from("tc/subtitle.srt"),
274 file_type: MediaFileType::Subtitle,
275 size: 0,
276 name: "subtitle".to_string(),
277 extension: "srt".to_string(),
278 };
279 let new_name = engine.generate_subtitle_name(&video, &subtitle);
280 assert_eq!(new_name, "movie.tc.srt");
281 }
282
283 #[test]
284 fn test_generate_subtitle_name_edge_cases() {
285 let engine = MatchEngine::new(
286 Box::new(DummyAI),
287 MatchConfig {
288 confidence_threshold: 0.0,
289 max_sample_length: 0,
290 enable_content_analysis: false,
291 backup_enabled: false,
292 relocation_mode: FileRelocationMode::None,
293 conflict_resolution: ConflictResolution::Skip,
294 ai_model: "test-model".to_string(),
295 max_subtitle_bytes: 52_428_800,
296 },
297 );
298 let video = MediaFile {
300 id: "".to_string(),
301 relative_path: "".to_string(),
302 path: PathBuf::from("a.b.c"),
303 file_type: MediaFileType::Video,
304 size: 0,
305 name: "a.b.c".to_string(),
306 extension: "".to_string(),
307 };
308 let subtitle = MediaFile {
309 id: "".to_string(),
310 relative_path: "".to_string(),
311 path: PathBuf::from("sub.srt"),
312 file_type: MediaFileType::Subtitle,
313 size: 0,
314 name: "sub".to_string(),
315 extension: "srt".to_string(),
316 };
317 let new_name = engine.generate_subtitle_name(&video, &subtitle);
318 assert_eq!(new_name, "a.b.c.srt");
319 }
320
321 #[tokio::test]
322 async fn test_rename_file_displays_success_check_mark() {
323 use std::fs;
324 use tempfile::TempDir;
325
326 let temp_dir = TempDir::new().unwrap();
327 let temp_path = temp_dir.path();
328
329 let original_file = temp_path.join("original.srt");
331 fs::write(
332 &original_file,
333 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
334 )
335 .unwrap();
336
337 let engine = MatchEngine::new(
339 Box::new(DummyAI),
340 MatchConfig {
341 confidence_threshold: 0.0,
342 max_sample_length: 0,
343 enable_content_analysis: false,
344 backup_enabled: false,
345 relocation_mode: FileRelocationMode::None,
346 conflict_resolution: ConflictResolution::Skip,
347 ai_model: "test-model".to_string(),
348 max_subtitle_bytes: 52_428_800,
349 },
350 );
351
352 let subtitle_file = MediaFile {
354 id: "test_id".to_string(),
355 relative_path: "original.srt".to_string(),
356 path: original_file.clone(),
357 file_type: MediaFileType::Subtitle,
358 size: 40,
359 name: "original".to_string(),
360 extension: "srt".to_string(),
361 };
362
363 let match_op = MatchOperation {
364 video_file: MediaFile {
365 id: "video_id".to_string(),
366 relative_path: "test.mp4".to_string(),
367 path: temp_path.join("test.mp4"),
368 file_type: MediaFileType::Video,
369 size: 1000,
370 name: "test".to_string(),
371 extension: "mp4".to_string(),
372 },
373 subtitle_file,
374 new_subtitle_name: "renamed.srt".to_string(),
375 confidence: 95.0,
376 reasoning: vec!["Test match".to_string()],
377 requires_relocation: false,
378 relocation_target_path: None,
379 relocation_mode: FileRelocationMode::None,
380 };
381
382 let result = engine.rename_file(&match_op).await;
384
385 assert!(result.is_ok());
387
388 let renamed_file = temp_path.join("renamed.srt");
390 assert!(renamed_file.exists(), "The renamed file should exist");
391 assert!(
392 !original_file.exists(),
393 "The original file should have been renamed"
394 );
395
396 let content = fs::read_to_string(&renamed_file).unwrap();
398 assert!(content.contains("Test subtitle"));
399 }
400
401 #[tokio::test]
402 async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
403 use std::fs;
404 use tempfile::TempDir;
405
406 let temp_dir = TempDir::new().unwrap();
407 let temp_path = temp_dir.path();
408
409 let original_file = temp_path.join("original.srt");
411 fs::write(
412 &original_file,
413 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
414 )
415 .unwrap();
416
417 let engine = MatchEngine::new(
419 Box::new(DummyAI),
420 MatchConfig {
421 confidence_threshold: 0.0,
422 max_sample_length: 0,
423 enable_content_analysis: false,
424 backup_enabled: false,
425 relocation_mode: FileRelocationMode::None,
426 conflict_resolution: ConflictResolution::Skip,
427 ai_model: "test-model".to_string(),
428 max_subtitle_bytes: 52_428_800,
429 },
430 );
431
432 let subtitle_file = MediaFile {
434 id: "test_id".to_string(),
435 relative_path: "original.srt".to_string(),
436 path: original_file.clone(),
437 file_type: MediaFileType::Subtitle,
438 size: 40,
439 name: "original".to_string(),
440 extension: "srt".to_string(),
441 };
442
443 let match_op = MatchOperation {
444 video_file: MediaFile {
445 id: "video_id".to_string(),
446 relative_path: "test.mp4".to_string(),
447 path: temp_path.join("test.mp4"),
448 file_type: MediaFileType::Video,
449 size: 1000,
450 name: "test".to_string(),
451 extension: "mp4".to_string(),
452 },
453 subtitle_file,
454 new_subtitle_name: "renamed.srt".to_string(),
455 confidence: 95.0,
456 reasoning: vec!["Test match".to_string()],
457 requires_relocation: false,
458 relocation_target_path: None,
459 relocation_mode: FileRelocationMode::None,
460 };
461
462 let result = engine.rename_file(&match_op).await;
465 assert!(result.is_ok());
466
467 let renamed_file = temp_path.join("renamed.srt");
469 if renamed_file.exists() {
470 fs::remove_file(&renamed_file).unwrap();
471 }
472
473 fs::write(
475 &original_file,
476 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
477 )
478 .unwrap();
479
480 let result = engine.rename_file(&match_op).await;
484 assert!(result.is_ok());
485
486 let renamed_file = temp_path.join("renamed.srt");
488 if renamed_file.exists() {
489 fs::remove_file(&renamed_file).unwrap();
490 }
491
492 }
495
496 #[test]
497 fn test_file_operation_message_format() {
498 let source_name = "test.srt";
500 let target_name = "renamed.srt";
501
502 let success_msg = format!(" ā Renamed: {} -> {}", source_name, target_name);
504 assert!(success_msg.contains("ā"));
505 assert!(success_msg.contains("Renamed:"));
506 assert!(success_msg.contains(source_name));
507 assert!(success_msg.contains(target_name));
508
509 let error_msg = format!(
511 " ā Rename failed: {} -> {} (target file does not exist after operation)",
512 source_name, target_name
513 );
514 assert!(error_msg.contains("ā"));
515 assert!(error_msg.contains("Rename failed:"));
516 assert!(error_msg.contains("target file does not exist"));
517 assert!(error_msg.contains(source_name));
518 assert!(error_msg.contains(target_name));
519 }
520
521 #[test]
522 fn test_copy_operation_message_format() {
523 let source_name = "subtitle.srt";
525 let target_name = "video.srt";
526
527 let success_msg = format!(" ā Copied: {} -> {}", source_name, target_name);
529 assert!(success_msg.contains("ā"));
530 assert!(success_msg.contains("Copied:"));
531
532 let error_msg = format!(
534 " ā Copy failed: {} -> {} (target file does not exist after operation)",
535 source_name, target_name
536 );
537 assert!(error_msg.contains("ā"));
538 assert!(error_msg.contains("Copy failed:"));
539 assert!(error_msg.contains("target file does not exist"));
540 }
541
542 #[test]
543 fn test_move_operation_message_format() {
544 let source_name = "subtitle.srt";
546 let target_name = "video.srt";
547
548 let success_msg = format!(" ā Moved: {} -> {}", source_name, target_name);
550 assert!(success_msg.contains("ā"));
551 assert!(success_msg.contains("Moved:"));
552
553 let error_msg = format!(
555 " ā Move failed: {} -> {} (target file does not exist after operation)",
556 source_name, target_name
557 );
558 assert!(error_msg.contains("ā"));
559 assert!(error_msg.contains("Move failed:"));
560 assert!(error_msg.contains("target file does not exist"));
561 }
562}
563
564#[derive(Debug)]
569pub struct MatchOperation {
570 pub video_file: MediaFile,
572 pub subtitle_file: MediaFile,
574 pub new_subtitle_name: String,
576 pub confidence: f32,
578 pub reasoning: Vec<String>,
580 pub relocation_mode: FileRelocationMode,
582 pub relocation_target_path: Option<std::path::PathBuf>,
584 pub requires_relocation: bool,
586}
587
588pub struct MatchEngine {
590 ai_client: Box<dyn AIProvider>,
591 discovery: FileDiscovery,
592 config: MatchConfig,
593}
594
595impl MatchEngine {
596 pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
598 Self {
599 ai_client,
600 discovery: FileDiscovery::new(),
601 config,
602 }
603 }
604
605 pub async fn match_file_list(&self, file_paths: &[PathBuf]) -> Result<Vec<MatchOperation>> {
619 let files = self.discovery.scan_file_list(file_paths)?;
621
622 let videos: Vec<_> = files
623 .iter()
624 .filter(|f| matches!(f.file_type, MediaFileType::Video))
625 .collect();
626 let subtitles: Vec<_> = files
627 .iter()
628 .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
629 .collect();
630
631 if videos.is_empty() || subtitles.is_empty() {
632 return Ok(Vec::new());
633 }
634
635 let cache_key = self.calculate_file_list_cache_key(file_paths)?;
638 if let Some(ops) = self.check_file_list_cache(&cache_key).await? {
639 return Ok(ops);
640 }
641
642 let content_samples = if self.config.enable_content_analysis {
644 self.extract_content_samples(&subtitles).await?
645 } else {
646 Vec::new()
647 };
648
649 let video_files: Vec<String> = videos
652 .iter()
653 .map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
654 .collect();
655 let subtitle_files: Vec<String> = subtitles
656 .iter()
657 .map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
658 .collect();
659
660 let analysis_request = AnalysisRequest {
661 video_files,
662 subtitle_files,
663 content_samples,
664 };
665
666 let match_result = self.ai_client.analyze_content(analysis_request).await?;
668
669 eprintln!("š AI Analysis Results:");
671 eprintln!(" - Total matches: {}", match_result.matches.len());
672 eprintln!(
673 " - Confidence threshold: {:.2}",
674 self.config.confidence_threshold
675 );
676 for ai_match in &match_result.matches {
677 eprintln!(
678 " - {} -> {} (confidence: {:.2})",
679 ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
680 );
681 }
682
683 let mut operations = Vec::new();
685
686 for ai_match in match_result.matches {
687 if ai_match.confidence >= self.config.confidence_threshold {
688 let video_match =
689 Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
690 let subtitle_match = Self::find_media_file_by_id_or_path(
691 &subtitles,
692 &ai_match.subtitle_file_id,
693 None,
694 );
695 match (video_match, subtitle_match) {
696 (Some(video), Some(subtitle)) => {
697 let new_name = self.generate_subtitle_name(video, subtitle);
698
699 let requires_relocation = self.config.relocation_mode
701 != FileRelocationMode::None
702 && subtitle.path.parent() != video.path.parent();
703
704 let relocation_target_path = if requires_relocation {
705 let video_dir = video.path.parent().unwrap();
706 Some(video_dir.join(&new_name))
707 } else {
708 None
709 };
710
711 operations.push(MatchOperation {
712 video_file: (*video).clone(),
713 subtitle_file: (*subtitle).clone(),
714 new_subtitle_name: new_name,
715 confidence: ai_match.confidence,
716 reasoning: ai_match.match_factors,
717 relocation_mode: self.config.relocation_mode.clone(),
718 relocation_target_path,
719 requires_relocation,
720 });
721 }
722 _ => {
723 eprintln!(
724 "ā ļø Cannot find AI-suggested file pair:\n Video ID: '{}'\n Subtitle ID: '{}'",
725 ai_match.video_file_id, ai_match.subtitle_file_id
726 );
727 eprintln!("ā No matching files found that meet the criteria");
728 eprintln!("š Available file statistics:");
729 eprintln!(" Video files ({} files):", videos.len());
730 for video in &videos {
731 eprintln!(" - ID: {} | {}", video.id, video.name);
732 }
733 eprintln!(" Subtitle files ({} files):", subtitles.len());
734 for subtitle in &subtitles {
735 eprintln!(" - ID: {} | {}", subtitle.id, subtitle.name);
736 }
737 }
738 }
739 }
740 }
741
742 self.save_file_list_cache(&cache_key, &operations).await?;
744
745 Ok(operations)
746 }
747
748 async fn extract_content_samples(
749 &self,
750 subtitles: &[&MediaFile],
751 ) -> Result<Vec<ContentSample>> {
752 let mut samples = Vec::new();
753
754 for subtitle in subtitles {
755 let path = subtitle.path.clone();
756 crate::core::fs_util::check_file_size(
757 &path,
758 self.config.max_subtitle_bytes,
759 "Subtitle",
760 )
761 .map_err(SubXError::Io)?;
762 let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
763 .await
764 .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
765 let preview = self.create_content_preview(&content);
766
767 samples.push(ContentSample {
768 filename: subtitle.name.clone(),
769 content_preview: preview,
770 file_size: subtitle.size,
771 });
772 }
773
774 Ok(samples)
775 }
776
777 fn create_content_preview(&self, content: &str) -> String {
778 let lines: Vec<&str> = content.lines().take(20).collect();
779 let preview = lines.join("\n");
780
781 if preview.len() > self.config.max_sample_length {
782 format!("{}...", &preview[..self.config.max_sample_length])
783 } else {
784 preview
785 }
786 }
787
788 fn generate_subtitle_name(&self, video: &MediaFile, subtitle: &MediaFile) -> String {
789 let detector = LanguageDetector::new();
790
791 let video_base_name = if !video.extension.is_empty() {
793 video
794 .name
795 .strip_suffix(&format!(".{}", video.extension))
796 .unwrap_or(&video.name)
797 } else {
798 &video.name
799 };
800
801 if let Some(code) = detector.get_primary_language(&subtitle.path) {
802 format!("{}.{}.{}", video_base_name, code, subtitle.extension)
803 } else {
804 format!("{}.{}", video_base_name, subtitle.extension)
805 }
806 }
807
808 pub async fn execute_operations(
817 &self,
818 operations: &[MatchOperation],
819 dry_run: bool,
820 ) -> Result<()> {
821 if dry_run {
822 for op in operations {
823 println!(
824 "Preview: {} -> {}",
825 op.subtitle_file.name, op.new_subtitle_name
826 );
827 if op.requires_relocation {
828 if let Some(target_path) = &op.relocation_target_path {
829 let operation_verb = match op.relocation_mode {
830 FileRelocationMode::Copy => "Copy",
831 FileRelocationMode::Move => "Move",
832 _ => "",
833 };
834 println!(
835 "Preview: {} {} to {}",
836 operation_verb,
837 op.subtitle_file.path.display(),
838 target_path.display()
839 );
840 }
841 }
842 }
843 return Ok(());
844 }
845
846 let created_at = std::time::SystemTime::now()
851 .duration_since(std::time::UNIX_EPOCH)
852 .map(|d| d.as_secs())
853 .unwrap_or(0);
854 let batch_id = {
855 use std::collections::hash_map::DefaultHasher;
856 use std::hash::{Hash, Hasher};
857 let mut hasher = DefaultHasher::new();
858 created_at.hash(&mut hasher);
859 operations.len().hash(&mut hasher);
860 for op in operations {
861 op.subtitle_file.path.hash(&mut hasher);
862 op.new_subtitle_name.hash(&mut hasher);
863 }
864 format!("{:016x}", hasher.finish())
865 };
866 let mut journal = JournalData {
867 batch_id,
868 created_at,
869 entries: Vec::new(),
870 };
871 let journal_file = journal_path().ok();
872
873 let mut first_error: Option<SubXError> = None;
874
875 for op in operations {
876 let mut backup_path: Option<PathBuf> = None;
879
880 if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
881 let backup_task =
882 self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
883 if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
884 backup_path = Some(backup.clone());
885 }
886 if let TaskResult::Failed(err) = backup_task.execute().await {
887 first_error = Some(SubXError::FileOperationFailed(err));
888 break;
889 }
890 }
891
892 let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
896 self.create_copy_task(op)
897 } else {
898 self.create_rename_task(op)
899 };
900
901 let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
902 {
903 ProcessingOperation::CopyWithRename { source, target }
904 | ProcessingOperation::CopyToVideoFolder { source, target } => {
905 (source.clone(), target.clone(), JournalOperationType::Copied)
906 }
907 ProcessingOperation::MoveToVideoFolder { source, target } => {
908 (source.clone(), target.clone(), JournalOperationType::Moved)
909 }
910 ProcessingOperation::RenameFile { source, target } => {
911 let kind = match op.relocation_mode {
912 FileRelocationMode::Move => JournalOperationType::Moved,
913 _ => JournalOperationType::Renamed,
914 };
915 (source.clone(), target.clone(), kind)
916 }
917 _ => (
918 op.subtitle_file.path.clone(),
919 op.relocation_target_path.clone().unwrap_or_else(|| {
920 op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
921 }),
922 JournalOperationType::Renamed,
923 ),
924 };
925
926 let (pre_file_size, pre_file_mtime) = journal_source
929 .metadata()
930 .ok()
931 .map(|m| {
932 let mtime = m
933 .modified()
934 .ok()
935 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
936 .map(|d| d.as_secs())
937 .unwrap_or(0);
938 (m.len(), mtime)
939 })
940 .unwrap_or((0, 0));
941
942 if let TaskResult::Failed(err) = primary_task.execute().await {
943 first_error = Some(SubXError::FileOperationFailed(err));
944 break;
945 }
946
947 let (file_size, file_mtime) = journal_destination
950 .metadata()
951 .ok()
952 .map(|m| {
953 let mtime = m
954 .modified()
955 .ok()
956 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
957 .map(|d| d.as_secs())
958 .unwrap_or(0);
959 (m.len(), mtime)
960 })
961 .unwrap_or((pre_file_size, pre_file_mtime));
962
963 journal.entries.push(JournalEntry {
964 operation_type: journal_kind,
965 source: journal_source,
966 destination: journal_destination,
967 backup_path: backup_path.clone(),
968 status: JournalEntryStatus::Completed,
969 file_size,
970 file_mtime,
971 });
972
973 if let Some(path) = journal_file.as_ref() {
974 journal.save(path).await?;
977 }
978 }
979
980 if let Some(err) = first_error {
981 return Err(err);
982 }
983 Ok(())
984 }
985
986 async fn rename_file(&self, op: &MatchOperation) -> Result<()> {
988 let task = self.create_rename_task(op);
989 match task.execute().await {
990 TaskResult::Success(_) => Ok(()),
991 TaskResult::Failed(err) => Err(SubXError::FileOperationFailed(err)),
992 other => Err(SubXError::FileOperationFailed(format!(
993 "Unexpected rename result: {:?}",
994 other
995 ))),
996 }
997 }
998
999 fn resolve_filename_conflict(&self, target: std::path::PathBuf) -> Result<std::path::PathBuf> {
1001 if !target.exists() {
1002 return Ok(target);
1003 }
1004 match self.config.conflict_resolution {
1005 ConflictResolution::Skip => {
1006 eprintln!(
1007 "Warning: Skipping relocation due to existing file: {}",
1008 target.display()
1009 );
1010 Ok(target)
1011 }
1012 ConflictResolution::AutoRename => {
1013 let file_stem = target
1014 .file_stem()
1015 .and_then(|s| s.to_str())
1016 .unwrap_or("file");
1017 let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
1018 let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
1019 match crate::core::fs_util::atomic_create_file(&target) {
1023 Ok(_f) => {
1024 let _ = std::fs::remove_file(&target);
1026 return Ok(target);
1027 }
1028 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
1029 Err(e) => return Err(SubXError::from(e)),
1030 }
1031 for i in 1..1000 {
1032 let new_name = if extension.is_empty() {
1033 format!("{}.{}", file_stem, i)
1034 } else {
1035 format!("{}.{}.{}", file_stem, i, extension)
1036 };
1037 let new_path = parent.join(new_name);
1038 match crate::core::fs_util::atomic_create_file(&new_path) {
1039 Ok(_f) => {
1040 let _ = std::fs::remove_file(&new_path);
1041 return Ok(new_path);
1042 }
1043 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
1044 Err(e) => return Err(SubXError::from(e)),
1045 }
1046 }
1047 Err(SubXError::FileOperationFailed(
1048 "Could not resolve filename conflict".to_string(),
1049 ))
1050 }
1051 ConflictResolution::Prompt => {
1052 eprintln!("Warning: Conflict resolution prompt not implemented, using auto-rename");
1053 self.resolve_filename_conflict(target)
1054 }
1055 }
1056 }
1057
1058 fn create_copy_task(&self, op: &MatchOperation) -> FileProcessingTask {
1060 let source = op.subtitle_file.path.clone();
1062 let target_base = op.relocation_target_path.clone().unwrap();
1063 let final_target = self.resolve_filename_conflict(target_base).unwrap();
1064 FileProcessingTask::new(
1065 source.clone(),
1066 Some(final_target.clone()),
1067 ProcessingOperation::CopyWithRename {
1068 source,
1069 target: final_target,
1070 },
1071 )
1072 }
1073
1074 fn create_backup_task(&self, source: &std::path::Path, ext: &str) -> FileProcessingTask {
1076 let backup_path = source.with_extension(format!("{}.backup", ext));
1077 FileProcessingTask::new(
1078 source.to_path_buf(),
1079 Some(backup_path.clone()),
1080 ProcessingOperation::CreateBackup {
1081 source: source.to_path_buf(),
1082 backup: backup_path,
1083 },
1084 )
1085 }
1086
1087 fn create_rename_task(&self, op: &MatchOperation) -> FileProcessingTask {
1089 let old = op.subtitle_file.path.clone();
1090 let new_path = if op.requires_relocation && op.relocation_target_path.is_some() {
1092 let target_base = op.relocation_target_path.clone().unwrap();
1093 self.resolve_filename_conflict(target_base).unwrap()
1094 } else {
1095 old.with_file_name(&op.new_subtitle_name)
1096 };
1097
1098 FileProcessingTask::new(
1099 old.clone(),
1100 Some(new_path.clone()),
1101 ProcessingOperation::RenameFile {
1102 source: old,
1103 target: new_path,
1104 },
1105 )
1106 }
1107
1108 fn calculate_file_list_cache_key(&self, file_paths: &[PathBuf]) -> Result<String> {
1110 use std::collections::BTreeMap;
1111 use std::collections::hash_map::DefaultHasher;
1112 use std::hash::{Hash, Hasher};
1113
1114 let mut path_metadata = BTreeMap::new();
1116 for path in file_paths {
1117 if let Ok(metadata) = path.metadata() {
1118 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1119 path_metadata.insert(
1120 canonical.to_string_lossy().to_string(),
1121 (metadata.len(), metadata.modified().ok()),
1122 );
1123 }
1124 }
1125
1126 let config_hash = self.calculate_config_hash()?;
1128
1129 let mut hasher = DefaultHasher::new();
1130 path_metadata.hash(&mut hasher);
1131 config_hash.hash(&mut hasher);
1132
1133 Ok(format!("filelist_{:016x}", hasher.finish()))
1134 }
1135
1136 async fn check_file_list_cache(&self, cache_key: &str) -> Result<Option<Vec<MatchOperation>>> {
1138 let cache_file_path = self.get_cache_file_path()?;
1139 let cache_data = CacheData::load(&cache_file_path).ok();
1140
1141 if let Some(cache_data) = cache_data {
1142 if cache_data.directory == cache_key {
1143 let mut ops = Vec::new();
1145 for item in cache_data.match_operations {
1146 let video_path = PathBuf::from(&item.video_file);
1148 let subtitle_path = PathBuf::from(&item.subtitle_file);
1149
1150 if video_path.exists() && subtitle_path.exists() {
1151 let video_meta = video_path.metadata()?;
1153 let subtitle_meta = subtitle_path.metadata()?;
1154
1155 let video_file = MediaFile {
1156 id: generate_file_id(&video_path, video_meta.len()),
1157 path: video_path.clone(),
1158 file_type: MediaFileType::Video,
1159 size: video_meta.len(),
1160 name: video_path
1161 .file_name()
1162 .unwrap()
1163 .to_string_lossy()
1164 .to_string(),
1165 extension: video_path
1166 .extension()
1167 .unwrap_or_default()
1168 .to_string_lossy()
1169 .to_lowercase(),
1170 relative_path: video_path
1171 .file_name()
1172 .unwrap()
1173 .to_string_lossy()
1174 .to_string(),
1175 };
1176
1177 let subtitle_file = MediaFile {
1178 id: generate_file_id(&subtitle_path, subtitle_meta.len()),
1179 path: subtitle_path.clone(),
1180 file_type: MediaFileType::Subtitle,
1181 size: subtitle_meta.len(),
1182 name: subtitle_path
1183 .file_name()
1184 .unwrap()
1185 .to_string_lossy()
1186 .to_string(),
1187 extension: subtitle_path
1188 .extension()
1189 .unwrap_or_default()
1190 .to_string_lossy()
1191 .to_lowercase(),
1192 relative_path: subtitle_path
1193 .file_name()
1194 .unwrap()
1195 .to_string_lossy()
1196 .to_string(),
1197 };
1198
1199 let requires_relocation = self.config.relocation_mode
1201 != FileRelocationMode::None
1202 && subtitle_file.path.parent() != video_file.path.parent();
1203
1204 let relocation_target_path = if requires_relocation {
1205 let video_dir = video_file.path.parent().unwrap();
1206 Some(video_dir.join(&item.new_subtitle_name))
1207 } else {
1208 None
1209 };
1210
1211 ops.push(MatchOperation {
1212 video_file,
1213 subtitle_file,
1214 new_subtitle_name: item.new_subtitle_name,
1215 confidence: item.confidence,
1216 reasoning: item.reasoning,
1217 relocation_mode: self.config.relocation_mode.clone(),
1218 relocation_target_path,
1219 requires_relocation,
1220 });
1221 }
1222 }
1223 return Ok(Some(ops));
1224 }
1225 }
1226 Ok(None)
1227 }
1228
1229 async fn save_file_list_cache(
1231 &self,
1232 cache_key: &str,
1233 operations: &[MatchOperation],
1234 ) -> Result<()> {
1235 let cache_file_path = self.get_cache_file_path()?;
1236 let config_hash = self.calculate_config_hash()?;
1237
1238 let mut cache_items = Vec::new();
1239 for op in operations {
1240 cache_items.push(OpItem {
1241 video_file: op.video_file.path.to_string_lossy().to_string(),
1242 subtitle_file: op.subtitle_file.path.to_string_lossy().to_string(),
1243 new_subtitle_name: op.new_subtitle_name.clone(),
1244 confidence: op.confidence,
1245 reasoning: op.reasoning.clone(),
1246 });
1247 }
1248
1249 let mut snapshot_items = Vec::new();
1251 let mut seen_paths = std::collections::HashSet::new();
1252 for op in operations {
1253 for path in [&op.video_file.path, &op.subtitle_file.path] {
1254 let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
1255 let key = canonical.to_string_lossy().to_string();
1256 if seen_paths.insert(key.clone()) {
1257 if let Ok(meta) = std::fs::metadata(&canonical) {
1258 let mtime = meta
1259 .modified()
1260 .ok()
1261 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1262 .map(|d| d.as_secs())
1263 .unwrap_or(0);
1264 snapshot_items.push(SnapshotItem {
1265 path: key,
1266 name: canonical
1267 .file_name()
1268 .unwrap_or_default()
1269 .to_string_lossy()
1270 .to_string(),
1271 size: meta.len(),
1272 mtime,
1273 file_type: if canonical.extension().is_some_and(|e| {
1274 ["srt", "ass", "ssa", "vtt", "sub"]
1275 .contains(&e.to_string_lossy().to_lowercase().as_str())
1276 }) {
1277 "subtitle".to_string()
1278 } else {
1279 "video".to_string()
1280 },
1281 });
1282 }
1283 }
1284 }
1285 }
1286
1287 let cache_data = CacheData {
1288 cache_version: "1.0".to_string(),
1289 directory: cache_key.to_string(),
1290 file_snapshot: snapshot_items,
1291 match_operations: cache_items,
1292 created_at: std::time::SystemTime::now()
1293 .duration_since(std::time::UNIX_EPOCH)
1294 .unwrap()
1295 .as_secs(),
1296 ai_model_used: self.config.ai_model.clone(),
1297 config_hash,
1298 original_relocation_mode: format!("{:?}", self.config.relocation_mode),
1299 original_backup_enabled: self.config.backup_enabled,
1300 };
1301
1302 let cache_dir = cache_file_path.parent().unwrap().to_path_buf();
1304 let cache_json = serde_json::to_string_pretty(&cache_data)?;
1305 let cache_file_path_clone = cache_file_path.clone();
1306 tokio::task::spawn_blocking(move || -> std::io::Result<()> {
1307 std::fs::create_dir_all(&cache_dir)?;
1308 std::fs::write(&cache_file_path_clone, cache_json)?;
1309 Ok(())
1310 })
1311 .await
1312 .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
1313
1314 Ok(())
1315 }
1316
1317 fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
1319 let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
1321 std::path::PathBuf::from(xdg_config)
1322 } else {
1323 dirs::config_dir()
1324 .ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
1325 };
1326 Ok(dir.join("subx").join("match_cache.json"))
1327 }
1328
1329 fn calculate_config_hash(&self) -> Result<String> {
1331 use std::collections::hash_map::DefaultHasher;
1332 use std::hash::{Hash, Hasher};
1333
1334 let mut hasher = DefaultHasher::new();
1335 format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
1337 self.config.backup_enabled.hash(&mut hasher);
1338 Ok(format!("{:016x}", hasher.finish()))
1341 }
1342
1343 fn find_media_file_by_id_or_path<'a>(
1345 files: &'a [&MediaFile],
1346 file_id: &str,
1347 fallback_path: Option<&str>,
1348 ) -> Option<&'a MediaFile> {
1349 if let Some(file) = files.iter().find(|f| f.id == file_id) {
1350 return Some(*file);
1351 }
1352 if let Some(path) = fallback_path {
1353 if let Some(file) = files.iter().find(|f| f.relative_path == path) {
1354 return Some(*file);
1355 }
1356 files.iter().find(|f| f.name == path).copied()
1357 } else {
1358 None
1359 }
1360 }
1361
1362 fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
1364 eprintln!(" Available {} files:", file_type);
1365 for f in files {
1366 eprintln!(
1367 " - ID: {} | Name: {} | Path: {}",
1368 f.id, f.name, f.relative_path
1369 );
1370 }
1371 }
1372
1373 fn log_no_matches_found(
1375 &self,
1376 match_result: &MatchResult,
1377 videos: &[MediaFile],
1378 subtitles: &[MediaFile],
1379 ) {
1380 eprintln!("\nā No matching files found that meet the criteria");
1381 eprintln!("š AI analysis results:");
1382 eprintln!(" - Total matches: {}", match_result.matches.len());
1383 eprintln!(
1384 " - Confidence threshold: {:.2}",
1385 self.config.confidence_threshold
1386 );
1387 eprintln!(
1388 " - Matches meeting threshold: {}",
1389 match_result
1390 .matches
1391 .iter()
1392 .filter(|m| m.confidence >= self.config.confidence_threshold)
1393 .count()
1394 );
1395 eprintln!("\nš Scanned files:");
1396 eprintln!(" Video files ({} files):", videos.len());
1397 for v in videos {
1398 eprintln!(" - ID: {} | {}", v.id, v.relative_path);
1399 }
1400 eprintln!(" Subtitle files ({} files):", subtitles.len());
1401 for s in subtitles {
1402 eprintln!(" - ID: {} | {}", s.id, s.relative_path);
1403 }
1404 }
1405}
1406
1407pub async fn apply_cached_operations(cache: &CacheData, config: &MatchConfig) -> Result<()> {
1425 let operations = reconstruct_operations_from_cache(cache, config)?;
1426 let engine = MatchEngine::new(Box::new(NoOpAIProvider), config.clone());
1427 engine.execute_operations(&operations, false).await
1428}
1429
1430fn reconstruct_operations_from_cache(
1436 cache: &CacheData,
1437 config: &MatchConfig,
1438) -> Result<Vec<MatchOperation>> {
1439 let mut ops = Vec::new();
1440 for item in &cache.match_operations {
1441 let video_path = PathBuf::from(&item.video_file);
1442 let subtitle_path = PathBuf::from(&item.subtitle_file);
1443
1444 if !video_path.exists() || !subtitle_path.exists() {
1445 continue;
1446 }
1447
1448 let video_meta = video_path.metadata()?;
1449 let subtitle_meta = subtitle_path.metadata()?;
1450
1451 let video_file = MediaFile {
1452 id: generate_file_id(&video_path, video_meta.len()),
1453 path: video_path.clone(),
1454 file_type: MediaFileType::Video,
1455 size: video_meta.len(),
1456 name: video_path
1457 .file_name()
1458 .unwrap_or_default()
1459 .to_string_lossy()
1460 .to_string(),
1461 extension: video_path
1462 .extension()
1463 .unwrap_or_default()
1464 .to_string_lossy()
1465 .to_lowercase(),
1466 relative_path: video_path
1467 .file_name()
1468 .unwrap_or_default()
1469 .to_string_lossy()
1470 .to_string(),
1471 };
1472
1473 let subtitle_file = MediaFile {
1474 id: generate_file_id(&subtitle_path, subtitle_meta.len()),
1475 path: subtitle_path.clone(),
1476 file_type: MediaFileType::Subtitle,
1477 size: subtitle_meta.len(),
1478 name: subtitle_path
1479 .file_name()
1480 .unwrap_or_default()
1481 .to_string_lossy()
1482 .to_string(),
1483 extension: subtitle_path
1484 .extension()
1485 .unwrap_or_default()
1486 .to_string_lossy()
1487 .to_lowercase(),
1488 relative_path: subtitle_path
1489 .file_name()
1490 .unwrap_or_default()
1491 .to_string_lossy()
1492 .to_string(),
1493 };
1494
1495 let requires_relocation = config.relocation_mode != FileRelocationMode::None
1496 && subtitle_file.path.parent() != video_file.path.parent();
1497 let relocation_target_path = if requires_relocation {
1498 video_file
1499 .path
1500 .parent()
1501 .map(|p| p.join(&item.new_subtitle_name))
1502 } else {
1503 None
1504 };
1505
1506 ops.push(MatchOperation {
1507 video_file,
1508 subtitle_file,
1509 new_subtitle_name: item.new_subtitle_name.clone(),
1510 confidence: item.confidence,
1511 reasoning: item.reasoning.clone(),
1512 relocation_mode: config.relocation_mode.clone(),
1513 relocation_target_path,
1514 requires_relocation,
1515 });
1516 }
1517 Ok(ops)
1518}
1519
1520struct NoOpAIProvider;
1526
1527#[async_trait::async_trait]
1528impl AIProvider for NoOpAIProvider {
1529 async fn analyze_content(&self, _request: AnalysisRequest) -> crate::Result<MatchResult> {
1530 Err(SubXError::config(
1531 "AI analysis is not available while replaying cached operations",
1532 ))
1533 }
1534
1535 async fn verify_match(
1536 &self,
1537 _verification: crate::services::ai::VerificationRequest,
1538 ) -> crate::Result<crate::services::ai::ConfidenceScore> {
1539 Err(SubXError::config(
1540 "AI verification is not available while replaying cached operations",
1541 ))
1542 }
1543}