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};
21use crate::core::matcher::discovery::generate_file_id;
22use crate::core::matcher::{FileDiscovery, MediaFile, MediaFileType};
23
24use crate::error::SubXError;
25use dirs;
26use serde_json;
27
28#[derive(Debug, Clone, PartialEq)]
30pub enum FileRelocationMode {
31 None,
33 Copy,
35 Move,
37}
38
39#[derive(Debug, Clone)]
41pub enum ConflictResolution {
42 Skip,
44 AutoRename,
46 Prompt,
48}
49
50#[derive(Debug, Clone)]
55pub struct MatchConfig {
56 pub confidence_threshold: f32,
58 pub max_sample_length: usize,
60 pub enable_content_analysis: bool,
62 pub backup_enabled: bool,
64 pub relocation_mode: FileRelocationMode,
66 pub conflict_resolution: ConflictResolution,
68 pub ai_model: String,
70}
71
72#[cfg(test)]
73mod language_name_tests {
74 use super::*;
75 use crate::core::matcher::discovery::{MediaFile, MediaFileType};
76 use crate::services::ai::{
77 AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
78 };
79 use async_trait::async_trait;
80 use std::path::PathBuf;
81
82 struct DummyAI;
83 #[async_trait]
84 impl AIProvider for DummyAI {
85 async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
86 unimplemented!()
87 }
88 async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
89 unimplemented!()
90 }
91 }
92
93 #[test]
94 fn test_generate_subtitle_name_with_directory_language() {
95 let engine = MatchEngine::new(
96 Box::new(DummyAI),
97 MatchConfig {
98 confidence_threshold: 0.0,
99 max_sample_length: 0,
100 enable_content_analysis: false,
101 backup_enabled: false,
102 relocation_mode: FileRelocationMode::None,
103 conflict_resolution: ConflictResolution::Skip,
104 ai_model: "test-model".to_string(),
105 },
106 );
107 let video = MediaFile {
108 id: "".to_string(),
109 relative_path: "".to_string(),
110 path: PathBuf::from("movie01.mp4"),
111 file_type: MediaFileType::Video,
112 size: 0,
113 name: "movie01".to_string(),
114 extension: "mp4".to_string(),
115 };
116 let subtitle = MediaFile {
117 id: "".to_string(),
118 relative_path: "".to_string(),
119 path: PathBuf::from("tc/subtitle01.ass"),
120 file_type: MediaFileType::Subtitle,
121 size: 0,
122 name: "subtitle01".to_string(),
123 extension: "ass".to_string(),
124 };
125 let new_name = engine.generate_subtitle_name(&video, &subtitle);
126 assert_eq!(new_name, "movie01.tc.ass");
127 }
128
129 #[test]
130 fn test_generate_subtitle_name_with_filename_language() {
131 let engine = MatchEngine::new(
132 Box::new(DummyAI),
133 MatchConfig {
134 confidence_threshold: 0.0,
135 max_sample_length: 0,
136 enable_content_analysis: false,
137 backup_enabled: false,
138 relocation_mode: FileRelocationMode::None,
139 conflict_resolution: ConflictResolution::Skip,
140 ai_model: "test-model".to_string(),
141 },
142 );
143 let video = MediaFile {
144 id: "".to_string(),
145 relative_path: "".to_string(),
146 path: PathBuf::from("movie02.mp4"),
147 file_type: MediaFileType::Video,
148 size: 0,
149 name: "movie02".to_string(),
150 extension: "mp4".to_string(),
151 };
152 let subtitle = MediaFile {
153 id: "".to_string(),
154 relative_path: "".to_string(),
155 path: PathBuf::from("subtitle02.sc.ass"),
156 file_type: MediaFileType::Subtitle,
157 size: 0,
158 name: "subtitle02".to_string(),
159 extension: "ass".to_string(),
160 };
161 let new_name = engine.generate_subtitle_name(&video, &subtitle);
162 assert_eq!(new_name, "movie02.sc.ass");
163 }
164
165 #[test]
166 fn test_generate_subtitle_name_without_language() {
167 let engine = MatchEngine::new(
168 Box::new(DummyAI),
169 MatchConfig {
170 confidence_threshold: 0.0,
171 max_sample_length: 0,
172 enable_content_analysis: false,
173 backup_enabled: false,
174 relocation_mode: FileRelocationMode::None,
175 conflict_resolution: ConflictResolution::Skip,
176 ai_model: "test-model".to_string(),
177 },
178 );
179 let video = MediaFile {
180 id: "".to_string(),
181 relative_path: "".to_string(),
182 path: PathBuf::from("movie03.mp4"),
183 file_type: MediaFileType::Video,
184 size: 0,
185 name: "movie03".to_string(),
186 extension: "mp4".to_string(),
187 };
188 let subtitle = MediaFile {
189 id: "".to_string(),
190 relative_path: "".to_string(),
191 path: PathBuf::from("subtitle03.ass"),
192 file_type: MediaFileType::Subtitle,
193 size: 0,
194 name: "subtitle03".to_string(),
195 extension: "ass".to_string(),
196 };
197 let new_name = engine.generate_subtitle_name(&video, &subtitle);
198 assert_eq!(new_name, "movie03.ass");
199 }
200 #[test]
201 fn test_generate_subtitle_name_removes_video_extension() {
202 let engine = MatchEngine::new(
203 Box::new(DummyAI),
204 MatchConfig {
205 confidence_threshold: 0.0,
206 max_sample_length: 0,
207 enable_content_analysis: false,
208 backup_enabled: false,
209 relocation_mode: FileRelocationMode::None,
210 conflict_resolution: ConflictResolution::Skip,
211 ai_model: "test-model".to_string(),
212 },
213 );
214 let video = MediaFile {
215 id: "".to_string(),
216 relative_path: "".to_string(),
217 path: PathBuf::from("movie.mkv"),
218 file_type: MediaFileType::Video,
219 size: 0,
220 name: "movie.mkv".to_string(),
221 extension: "mkv".to_string(),
222 };
223 let subtitle = MediaFile {
224 id: "".to_string(),
225 relative_path: "".to_string(),
226 path: PathBuf::from("subtitle.srt"),
227 file_type: MediaFileType::Subtitle,
228 size: 0,
229 name: "subtitle".to_string(),
230 extension: "srt".to_string(),
231 };
232 let new_name = engine.generate_subtitle_name(&video, &subtitle);
233 assert_eq!(new_name, "movie.srt");
234 }
235
236 #[test]
237 fn test_generate_subtitle_name_with_language_removes_video_extension() {
238 let engine = MatchEngine::new(
239 Box::new(DummyAI),
240 MatchConfig {
241 confidence_threshold: 0.0,
242 max_sample_length: 0,
243 enable_content_analysis: false,
244 backup_enabled: false,
245 relocation_mode: FileRelocationMode::None,
246 conflict_resolution: ConflictResolution::Skip,
247 ai_model: "test-model".to_string(),
248 },
249 );
250 let video = MediaFile {
251 id: "".to_string(),
252 relative_path: "".to_string(),
253 path: PathBuf::from("movie.mkv"),
254 file_type: MediaFileType::Video,
255 size: 0,
256 name: "movie.mkv".to_string(),
257 extension: "mkv".to_string(),
258 };
259 let subtitle = MediaFile {
260 id: "".to_string(),
261 relative_path: "".to_string(),
262 path: PathBuf::from("tc/subtitle.srt"),
263 file_type: MediaFileType::Subtitle,
264 size: 0,
265 name: "subtitle".to_string(),
266 extension: "srt".to_string(),
267 };
268 let new_name = engine.generate_subtitle_name(&video, &subtitle);
269 assert_eq!(new_name, "movie.tc.srt");
270 }
271
272 #[test]
273 fn test_generate_subtitle_name_edge_cases() {
274 let engine = MatchEngine::new(
275 Box::new(DummyAI),
276 MatchConfig {
277 confidence_threshold: 0.0,
278 max_sample_length: 0,
279 enable_content_analysis: false,
280 backup_enabled: false,
281 relocation_mode: FileRelocationMode::None,
282 conflict_resolution: ConflictResolution::Skip,
283 ai_model: "test-model".to_string(),
284 },
285 );
286 let video = MediaFile {
288 id: "".to_string(),
289 relative_path: "".to_string(),
290 path: PathBuf::from("a.b.c"),
291 file_type: MediaFileType::Video,
292 size: 0,
293 name: "a.b.c".to_string(),
294 extension: "".to_string(),
295 };
296 let subtitle = MediaFile {
297 id: "".to_string(),
298 relative_path: "".to_string(),
299 path: PathBuf::from("sub.srt"),
300 file_type: MediaFileType::Subtitle,
301 size: 0,
302 name: "sub".to_string(),
303 extension: "srt".to_string(),
304 };
305 let new_name = engine.generate_subtitle_name(&video, &subtitle);
306 assert_eq!(new_name, "a.b.c.srt");
307 }
308
309 #[tokio::test]
310 async fn test_rename_file_displays_success_check_mark() {
311 use std::fs;
312 use tempfile::TempDir;
313
314 let temp_dir = TempDir::new().unwrap();
315 let temp_path = temp_dir.path();
316
317 let original_file = temp_path.join("original.srt");
319 fs::write(
320 &original_file,
321 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
322 )
323 .unwrap();
324
325 let engine = MatchEngine::new(
327 Box::new(DummyAI),
328 MatchConfig {
329 confidence_threshold: 0.0,
330 max_sample_length: 0,
331 enable_content_analysis: false,
332 backup_enabled: false,
333 relocation_mode: FileRelocationMode::None,
334 conflict_resolution: ConflictResolution::Skip,
335 ai_model: "test-model".to_string(),
336 },
337 );
338
339 let subtitle_file = MediaFile {
341 id: "test_id".to_string(),
342 relative_path: "original.srt".to_string(),
343 path: original_file.clone(),
344 file_type: MediaFileType::Subtitle,
345 size: 40,
346 name: "original".to_string(),
347 extension: "srt".to_string(),
348 };
349
350 let match_op = MatchOperation {
351 video_file: MediaFile {
352 id: "video_id".to_string(),
353 relative_path: "test.mp4".to_string(),
354 path: temp_path.join("test.mp4"),
355 file_type: MediaFileType::Video,
356 size: 1000,
357 name: "test".to_string(),
358 extension: "mp4".to_string(),
359 },
360 subtitle_file,
361 new_subtitle_name: "renamed.srt".to_string(),
362 confidence: 95.0,
363 reasoning: vec!["Test match".to_string()],
364 requires_relocation: false,
365 relocation_target_path: None,
366 relocation_mode: FileRelocationMode::None,
367 };
368
369 let result = engine.rename_file(&match_op).await;
371
372 assert!(result.is_ok());
374
375 let renamed_file = temp_path.join("renamed.srt");
377 assert!(renamed_file.exists(), "The renamed file should exist");
378 assert!(
379 !original_file.exists(),
380 "The original file should have been renamed"
381 );
382
383 let content = fs::read_to_string(&renamed_file).unwrap();
385 assert!(content.contains("Test subtitle"));
386 }
387
388 #[tokio::test]
389 async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
390 use std::fs;
391 use tempfile::TempDir;
392
393 let temp_dir = TempDir::new().unwrap();
394 let temp_path = temp_dir.path();
395
396 let original_file = temp_path.join("original.srt");
398 fs::write(
399 &original_file,
400 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
401 )
402 .unwrap();
403
404 let engine = MatchEngine::new(
406 Box::new(DummyAI),
407 MatchConfig {
408 confidence_threshold: 0.0,
409 max_sample_length: 0,
410 enable_content_analysis: false,
411 backup_enabled: false,
412 relocation_mode: FileRelocationMode::None,
413 conflict_resolution: ConflictResolution::Skip,
414 ai_model: "test-model".to_string(),
415 },
416 );
417
418 let subtitle_file = MediaFile {
420 id: "test_id".to_string(),
421 relative_path: "original.srt".to_string(),
422 path: original_file.clone(),
423 file_type: MediaFileType::Subtitle,
424 size: 40,
425 name: "original".to_string(),
426 extension: "srt".to_string(),
427 };
428
429 let match_op = MatchOperation {
430 video_file: MediaFile {
431 id: "video_id".to_string(),
432 relative_path: "test.mp4".to_string(),
433 path: temp_path.join("test.mp4"),
434 file_type: MediaFileType::Video,
435 size: 1000,
436 name: "test".to_string(),
437 extension: "mp4".to_string(),
438 },
439 subtitle_file,
440 new_subtitle_name: "renamed.srt".to_string(),
441 confidence: 95.0,
442 reasoning: vec!["Test match".to_string()],
443 requires_relocation: false,
444 relocation_target_path: None,
445 relocation_mode: FileRelocationMode::None,
446 };
447
448 let result = engine.rename_file(&match_op).await;
451 assert!(result.is_ok());
452
453 let renamed_file = temp_path.join("renamed.srt");
455 if renamed_file.exists() {
456 fs::remove_file(&renamed_file).unwrap();
457 }
458
459 fs::write(
461 &original_file,
462 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
463 )
464 .unwrap();
465
466 let result = engine.rename_file(&match_op).await;
470 assert!(result.is_ok());
471
472 let renamed_file = temp_path.join("renamed.srt");
474 if renamed_file.exists() {
475 fs::remove_file(&renamed_file).unwrap();
476 }
477
478 }
481
482 #[test]
483 fn test_file_operation_message_format() {
484 let source_name = "test.srt";
486 let target_name = "renamed.srt";
487
488 let success_msg = format!(" ✓ Renamed: {} -> {}", source_name, target_name);
490 assert!(success_msg.contains("✓"));
491 assert!(success_msg.contains("Renamed:"));
492 assert!(success_msg.contains(source_name));
493 assert!(success_msg.contains(target_name));
494
495 let error_msg = format!(
497 " ✗ Rename failed: {} -> {} (target file does not exist after operation)",
498 source_name, target_name
499 );
500 assert!(error_msg.contains("✗"));
501 assert!(error_msg.contains("Rename failed:"));
502 assert!(error_msg.contains("target file does not exist"));
503 assert!(error_msg.contains(source_name));
504 assert!(error_msg.contains(target_name));
505 }
506
507 #[test]
508 fn test_copy_operation_message_format() {
509 let source_name = "subtitle.srt";
511 let target_name = "video.srt";
512
513 let success_msg = format!(" ✓ Copied: {} -> {}", source_name, target_name);
515 assert!(success_msg.contains("✓"));
516 assert!(success_msg.contains("Copied:"));
517
518 let error_msg = format!(
520 " ✗ Copy failed: {} -> {} (target file does not exist after operation)",
521 source_name, target_name
522 );
523 assert!(error_msg.contains("✗"));
524 assert!(error_msg.contains("Copy failed:"));
525 assert!(error_msg.contains("target file does not exist"));
526 }
527
528 #[test]
529 fn test_move_operation_message_format() {
530 let source_name = "subtitle.srt";
532 let target_name = "video.srt";
533
534 let success_msg = format!(" ✓ Moved: {} -> {}", source_name, target_name);
536 assert!(success_msg.contains("✓"));
537 assert!(success_msg.contains("Moved:"));
538
539 let error_msg = format!(
541 " ✗ Move failed: {} -> {} (target file does not exist after operation)",
542 source_name, target_name
543 );
544 assert!(error_msg.contains("✗"));
545 assert!(error_msg.contains("Move failed:"));
546 assert!(error_msg.contains("target file does not exist"));
547 }
548}
549
550#[derive(Debug)]
555pub struct MatchOperation {
556 pub video_file: MediaFile,
558 pub subtitle_file: MediaFile,
560 pub new_subtitle_name: String,
562 pub confidence: f32,
564 pub reasoning: Vec<String>,
566 pub relocation_mode: FileRelocationMode,
568 pub relocation_target_path: Option<std::path::PathBuf>,
570 pub requires_relocation: bool,
572}
573
574pub struct MatchEngine {
576 ai_client: Box<dyn AIProvider>,
577 discovery: FileDiscovery,
578 config: MatchConfig,
579}
580
581impl MatchEngine {
582 pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
584 Self {
585 ai_client,
586 discovery: FileDiscovery::new(),
587 config,
588 }
589 }
590
591 pub async fn match_file_list(&self, file_paths: &[PathBuf]) -> Result<Vec<MatchOperation>> {
605 let files = self.discovery.scan_file_list(file_paths)?;
607
608 let videos: Vec<_> = files
609 .iter()
610 .filter(|f| matches!(f.file_type, MediaFileType::Video))
611 .collect();
612 let subtitles: Vec<_> = files
613 .iter()
614 .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
615 .collect();
616
617 if videos.is_empty() || subtitles.is_empty() {
618 return Ok(Vec::new());
619 }
620
621 let cache_key = self.calculate_file_list_cache_key(file_paths)?;
624 if let Some(ops) = self.check_file_list_cache(&cache_key).await? {
625 return Ok(ops);
626 }
627
628 let content_samples = if self.config.enable_content_analysis {
630 self.extract_content_samples(&subtitles).await?
631 } else {
632 Vec::new()
633 };
634
635 let video_files: Vec<String> = videos
638 .iter()
639 .map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
640 .collect();
641 let subtitle_files: Vec<String> = subtitles
642 .iter()
643 .map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
644 .collect();
645
646 let analysis_request = AnalysisRequest {
647 video_files,
648 subtitle_files,
649 content_samples,
650 };
651
652 let match_result = self.ai_client.analyze_content(analysis_request).await?;
654
655 eprintln!("🔍 AI Analysis Results:");
657 eprintln!(" - Total matches: {}", match_result.matches.len());
658 eprintln!(
659 " - Confidence threshold: {:.2}",
660 self.config.confidence_threshold
661 );
662 for ai_match in &match_result.matches {
663 eprintln!(
664 " - {} -> {} (confidence: {:.2})",
665 ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
666 );
667 }
668
669 let mut operations = Vec::new();
671
672 for ai_match in match_result.matches {
673 if ai_match.confidence >= self.config.confidence_threshold {
674 let video_match =
675 Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
676 let subtitle_match = Self::find_media_file_by_id_or_path(
677 &subtitles,
678 &ai_match.subtitle_file_id,
679 None,
680 );
681 match (video_match, subtitle_match) {
682 (Some(video), Some(subtitle)) => {
683 let new_name = self.generate_subtitle_name(video, subtitle);
684
685 let requires_relocation = self.config.relocation_mode
687 != FileRelocationMode::None
688 && subtitle.path.parent() != video.path.parent();
689
690 let relocation_target_path = if requires_relocation {
691 let video_dir = video.path.parent().unwrap();
692 Some(video_dir.join(&new_name))
693 } else {
694 None
695 };
696
697 operations.push(MatchOperation {
698 video_file: (*video).clone(),
699 subtitle_file: (*subtitle).clone(),
700 new_subtitle_name: new_name,
701 confidence: ai_match.confidence,
702 reasoning: ai_match.match_factors,
703 relocation_mode: self.config.relocation_mode.clone(),
704 relocation_target_path,
705 requires_relocation,
706 });
707 }
708 _ => {
709 eprintln!(
710 "⚠️ Cannot find AI-suggested file pair:\n Video ID: '{}'\n Subtitle ID: '{}'",
711 ai_match.video_file_id, ai_match.subtitle_file_id
712 );
713 eprintln!("❌ No matching files found that meet the criteria");
714 eprintln!("🔍 Available file statistics:");
715 eprintln!(" Video files ({} files):", videos.len());
716 for video in &videos {
717 eprintln!(" - ID: {} | {}", video.id, video.name);
718 }
719 eprintln!(" Subtitle files ({} files):", subtitles.len());
720 for subtitle in &subtitles {
721 eprintln!(" - ID: {} | {}", subtitle.id, subtitle.name);
722 }
723 }
724 }
725 }
726 }
727
728 self.save_file_list_cache(&cache_key, &operations).await?;
730
731 Ok(operations)
732 }
733
734 async fn extract_content_samples(
735 &self,
736 subtitles: &[&MediaFile],
737 ) -> Result<Vec<ContentSample>> {
738 let mut samples = Vec::new();
739
740 for subtitle in subtitles {
741 let content = std::fs::read_to_string(&subtitle.path)?;
742 let preview = self.create_content_preview(&content);
743
744 samples.push(ContentSample {
745 filename: subtitle.name.clone(),
746 content_preview: preview,
747 file_size: subtitle.size,
748 });
749 }
750
751 Ok(samples)
752 }
753
754 fn create_content_preview(&self, content: &str) -> String {
755 let lines: Vec<&str> = content.lines().take(20).collect();
756 let preview = lines.join("\n");
757
758 if preview.len() > self.config.max_sample_length {
759 format!("{}...", &preview[..self.config.max_sample_length])
760 } else {
761 preview
762 }
763 }
764
765 fn generate_subtitle_name(&self, video: &MediaFile, subtitle: &MediaFile) -> String {
766 let detector = LanguageDetector::new();
767
768 let video_base_name = if !video.extension.is_empty() {
770 video
771 .name
772 .strip_suffix(&format!(".{}", video.extension))
773 .unwrap_or(&video.name)
774 } else {
775 &video.name
776 };
777
778 if let Some(code) = detector.get_primary_language(&subtitle.path) {
779 format!("{}.{}.{}", video_base_name, code, subtitle.extension)
780 } else {
781 format!("{}.{}", video_base_name, subtitle.extension)
782 }
783 }
784
785 pub async fn execute_operations(
787 &self,
788 operations: &[MatchOperation],
789 dry_run: bool,
790 ) -> Result<()> {
791 for op in operations {
792 if dry_run {
793 println!(
794 "Preview: {} -> {}",
795 op.subtitle_file.name, op.new_subtitle_name
796 );
797 if op.requires_relocation {
798 if let Some(target_path) = &op.relocation_target_path {
799 let operation_verb = match op.relocation_mode {
800 FileRelocationMode::Copy => "Copy",
801 FileRelocationMode::Move => "Move",
802 _ => "",
803 };
804 println!(
805 "Preview: {} {} to {}",
806 operation_verb,
807 op.subtitle_file.path.display(),
808 target_path.display()
809 );
810 }
811 }
812 } else {
813 match op.relocation_mode {
814 FileRelocationMode::Copy => {
815 if op.requires_relocation {
816 self.execute_copy_operation(op).await?;
817 } else {
818 self.execute_local_copy(op).await?;
820 }
821 }
822 FileRelocationMode::Move => {
823 self.rename_file(op).await?;
824 if op.requires_relocation {
825 self.execute_relocation_operation(op).await?;
826 }
827 }
828 FileRelocationMode::None => {
829 self.rename_file(op).await?;
830 }
831 }
832 }
833 }
834 Ok(())
835 }
836
837 async fn execute_relocation_operation(&self, op: &MatchOperation) -> Result<()> {
839 if !op.requires_relocation {
840 return Ok(());
841 }
842
843 let source_path = if op.new_subtitle_name == op.subtitle_file.name {
844 op.subtitle_file.path.clone()
846 } else {
847 op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
849 };
850
851 if let Some(target_path) = &op.relocation_target_path {
852 if let Some(parent) = target_path.parent() {
854 std::fs::create_dir_all(parent)?;
855 }
856
857 let final_target = self.resolve_filename_conflict(target_path.clone())?;
859
860 match op.relocation_mode {
861 FileRelocationMode::Copy => {
862 if self.config.backup_enabled && final_target.exists() {
864 let backup_path = final_target.with_extension(format!(
865 "{}.backup",
866 final_target
867 .extension()
868 .and_then(|s| s.to_str())
869 .unwrap_or("")
870 ));
871 std::fs::copy(&final_target, backup_path)?;
872 }
873
874 std::fs::copy(&source_path, &final_target)?;
876
877 if final_target.exists() {
879 println!(
880 " ✓ Copied: {} -> {}",
881 source_path
882 .file_name()
883 .unwrap_or_default()
884 .to_string_lossy(),
885 final_target
886 .file_name()
887 .unwrap_or_default()
888 .to_string_lossy()
889 );
890 } else {
891 eprintln!(
892 " ✗ Copy failed: {} -> {} (target file does not exist after operation)",
893 source_path
894 .file_name()
895 .unwrap_or_default()
896 .to_string_lossy(),
897 final_target
898 .file_name()
899 .unwrap_or_default()
900 .to_string_lossy()
901 );
902 }
903 }
904 FileRelocationMode::Move => {
905 if self.config.backup_enabled {
907 let backup_path = source_path.with_extension(format!(
908 "{}.backup",
909 source_path
910 .extension()
911 .and_then(|s| s.to_str())
912 .unwrap_or("")
913 ));
914 std::fs::copy(&source_path, backup_path)?;
915 }
916
917 if self.config.backup_enabled && final_target.exists() {
919 let backup_path = final_target.with_extension(format!(
920 "{}.backup",
921 final_target
922 .extension()
923 .and_then(|s| s.to_str())
924 .unwrap_or("")
925 ));
926 std::fs::copy(&final_target, backup_path)?;
927 }
928
929 std::fs::rename(&source_path, &final_target)?;
931
932 if final_target.exists() {
934 println!(
935 " ✓ Moved: {} -> {}",
936 source_path
937 .file_name()
938 .unwrap_or_default()
939 .to_string_lossy(),
940 final_target
941 .file_name()
942 .unwrap_or_default()
943 .to_string_lossy()
944 );
945 } else {
946 eprintln!(
947 " ✗ Move failed: {} -> {} (target file does not exist after operation)",
948 source_path
949 .file_name()
950 .unwrap_or_default()
951 .to_string_lossy(),
952 final_target
953 .file_name()
954 .unwrap_or_default()
955 .to_string_lossy()
956 );
957 }
958 }
959 FileRelocationMode::None => {
960 }
962 }
963 }
964
965 Ok(())
966 }
967
968 async fn execute_copy_operation(&self, op: &MatchOperation) -> Result<()> {
971 if let Some(target_path) = &op.relocation_target_path {
972 let final_target = self.resolve_filename_conflict(target_path.clone())?;
974 if let Some(parent) = final_target.parent() {
975 std::fs::create_dir_all(parent)?;
976 }
977 if self.config.backup_enabled && final_target.exists() {
979 let backup_path = final_target.with_extension(format!(
980 "{}.backup",
981 final_target
982 .extension()
983 .and_then(|s| s.to_str())
984 .unwrap_or("")
985 ));
986 std::fs::copy(&final_target, backup_path)?;
987 }
988 std::fs::copy(&op.subtitle_file.path, &final_target)?;
991
992 if final_target.exists() {
994 println!(
995 " ✓ Copied: {} -> {}",
996 op.subtitle_file.name,
997 final_target.file_name().unwrap().to_string_lossy()
998 );
999 }
1000 }
1001 Ok(())
1002 }
1003
1004 async fn execute_local_copy(&self, op: &MatchOperation) -> Result<()> {
1006 if op.new_subtitle_name != op.subtitle_file.name {
1007 let target_path = op.subtitle_file.path.with_file_name(&op.new_subtitle_name);
1008
1009 let final_target = self.resolve_filename_conflict(target_path)?;
1011
1012 if self.config.backup_enabled && final_target.exists() {
1014 let backup_path = final_target.with_extension(format!(
1015 "{}.backup",
1016 final_target
1017 .extension()
1018 .and_then(|s| s.to_str())
1019 .unwrap_or("")
1020 ));
1021 std::fs::copy(&final_target, backup_path)?;
1022 }
1023
1024 std::fs::copy(&op.subtitle_file.path, &final_target)?;
1026
1027 if final_target.exists() {
1029 println!(
1030 " ✓ Copied: {} -> {}",
1031 op.subtitle_file.name,
1032 final_target.file_name().unwrap().to_string_lossy()
1033 );
1034 }
1035 }
1036 Ok(())
1037 }
1038
1039 fn resolve_filename_conflict(&self, target: std::path::PathBuf) -> Result<std::path::PathBuf> {
1041 if !target.exists() {
1042 return Ok(target);
1043 }
1044
1045 match self.config.conflict_resolution {
1047 ConflictResolution::Skip => {
1048 eprintln!(
1049 "Warning: Skipping relocation due to existing file: {}",
1050 target.display()
1051 );
1052 Ok(target) }
1054 ConflictResolution::AutoRename => {
1055 let file_stem = target
1057 .file_stem()
1058 .and_then(|s| s.to_str())
1059 .unwrap_or("file");
1060 let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
1061
1062 let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
1063
1064 for i in 1..1000 {
1066 let new_name = if extension.is_empty() {
1067 format!("{}.{}", file_stem, i)
1068 } else {
1069 format!("{}.{}.{}", file_stem, i, extension)
1070 };
1071 let new_path = parent.join(new_name);
1072 if !new_path.exists() {
1073 return Ok(new_path);
1074 }
1075 }
1076
1077 Err(SubXError::FileOperationFailed(
1078 "Could not resolve filename conflict".to_string(),
1079 ))
1080 }
1081 ConflictResolution::Prompt => {
1082 eprintln!("Warning: Conflict resolution prompt not implemented, using auto-rename");
1085 self.resolve_filename_conflict(target)
1086 }
1087 }
1088 }
1089
1090 async fn rename_file(&self, op: &MatchOperation) -> Result<()> {
1091 let old_path = &op.subtitle_file.path;
1092 let new_path = old_path.with_file_name(&op.new_subtitle_name);
1093
1094 if self.config.backup_enabled {
1096 let backup_path =
1097 old_path.with_extension(format!("{}.backup", op.subtitle_file.extension));
1098 std::fs::copy(old_path, backup_path)?;
1099 }
1100
1101 std::fs::rename(old_path, &new_path)?;
1102
1103 if new_path.exists() {
1105 println!(
1106 " ✓ Renamed: {} -> {}",
1107 old_path.file_name().unwrap_or_default().to_string_lossy(),
1108 op.new_subtitle_name
1109 );
1110 } else {
1111 eprintln!(
1112 " ✗ Rename failed: {} -> {} (target file does not exist after operation)",
1113 old_path.file_name().unwrap_or_default().to_string_lossy(),
1114 op.new_subtitle_name
1115 );
1116 }
1117
1118 Ok(())
1119 }
1120
1121 fn calculate_file_list_cache_key(&self, file_paths: &[PathBuf]) -> Result<String> {
1123 use std::collections::BTreeMap;
1124 use std::collections::hash_map::DefaultHasher;
1125 use std::hash::{Hash, Hasher};
1126
1127 let mut path_metadata = BTreeMap::new();
1129 for path in file_paths {
1130 if let Ok(metadata) = path.metadata() {
1131 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1132 path_metadata.insert(
1133 canonical.to_string_lossy().to_string(),
1134 (metadata.len(), metadata.modified().ok()),
1135 );
1136 }
1137 }
1138
1139 let config_hash = self.calculate_config_hash()?;
1141
1142 let mut hasher = DefaultHasher::new();
1143 path_metadata.hash(&mut hasher);
1144 config_hash.hash(&mut hasher);
1145
1146 Ok(format!("filelist_{:016x}", hasher.finish()))
1147 }
1148
1149 async fn check_file_list_cache(&self, cache_key: &str) -> Result<Option<Vec<MatchOperation>>> {
1151 let cache_file_path = self.get_cache_file_path()?;
1152 let cache_data = CacheData::load(&cache_file_path).ok();
1153
1154 if let Some(cache_data) = cache_data {
1155 if cache_data.directory == cache_key {
1156 let mut ops = Vec::new();
1158 for item in cache_data.match_operations {
1159 let video_path = PathBuf::from(&item.video_file);
1161 let subtitle_path = PathBuf::from(&item.subtitle_file);
1162
1163 if video_path.exists() && subtitle_path.exists() {
1164 let video_meta = video_path.metadata()?;
1166 let subtitle_meta = subtitle_path.metadata()?;
1167
1168 let video_file = MediaFile {
1169 id: generate_file_id(&video_path, video_meta.len()),
1170 path: video_path.clone(),
1171 file_type: MediaFileType::Video,
1172 size: video_meta.len(),
1173 name: video_path
1174 .file_name()
1175 .unwrap()
1176 .to_string_lossy()
1177 .to_string(),
1178 extension: video_path
1179 .extension()
1180 .unwrap_or_default()
1181 .to_string_lossy()
1182 .to_lowercase(),
1183 relative_path: video_path
1184 .file_name()
1185 .unwrap()
1186 .to_string_lossy()
1187 .to_string(),
1188 };
1189
1190 let subtitle_file = MediaFile {
1191 id: generate_file_id(&subtitle_path, subtitle_meta.len()),
1192 path: subtitle_path.clone(),
1193 file_type: MediaFileType::Subtitle,
1194 size: subtitle_meta.len(),
1195 name: subtitle_path
1196 .file_name()
1197 .unwrap()
1198 .to_string_lossy()
1199 .to_string(),
1200 extension: subtitle_path
1201 .extension()
1202 .unwrap_or_default()
1203 .to_string_lossy()
1204 .to_lowercase(),
1205 relative_path: subtitle_path
1206 .file_name()
1207 .unwrap()
1208 .to_string_lossy()
1209 .to_string(),
1210 };
1211
1212 let requires_relocation = self.config.relocation_mode
1214 != FileRelocationMode::None
1215 && subtitle_file.path.parent() != video_file.path.parent();
1216
1217 let relocation_target_path = if requires_relocation {
1218 let video_dir = video_file.path.parent().unwrap();
1219 Some(video_dir.join(&item.new_subtitle_name))
1220 } else {
1221 None
1222 };
1223
1224 ops.push(MatchOperation {
1225 video_file,
1226 subtitle_file,
1227 new_subtitle_name: item.new_subtitle_name,
1228 confidence: item.confidence,
1229 reasoning: item.reasoning,
1230 relocation_mode: self.config.relocation_mode.clone(),
1231 relocation_target_path,
1232 requires_relocation,
1233 });
1234 }
1235 }
1236 return Ok(Some(ops));
1237 }
1238 }
1239 Ok(None)
1240 }
1241
1242 async fn save_file_list_cache(
1244 &self,
1245 cache_key: &str,
1246 operations: &[MatchOperation],
1247 ) -> Result<()> {
1248 let cache_file_path = self.get_cache_file_path()?;
1249 let config_hash = self.calculate_config_hash()?;
1250
1251 let mut cache_items = Vec::new();
1252 for op in operations {
1253 cache_items.push(OpItem {
1254 video_file: op.video_file.path.to_string_lossy().to_string(),
1255 subtitle_file: op.subtitle_file.path.to_string_lossy().to_string(),
1256 new_subtitle_name: op.new_subtitle_name.clone(),
1257 confidence: op.confidence,
1258 reasoning: op.reasoning.clone(),
1259 });
1260 }
1261
1262 let cache_data = CacheData {
1263 cache_version: "1.0".to_string(),
1264 directory: cache_key.to_string(),
1265 file_snapshot: vec![], match_operations: cache_items,
1267 created_at: std::time::SystemTime::now()
1268 .duration_since(std::time::UNIX_EPOCH)
1269 .unwrap()
1270 .as_secs(),
1271 ai_model_used: self.config.ai_model.clone(),
1272 config_hash,
1273 original_relocation_mode: format!("{:?}", self.config.relocation_mode),
1274 original_backup_enabled: self.config.backup_enabled,
1275 };
1276
1277 let cache_dir = cache_file_path.parent().unwrap();
1279 std::fs::create_dir_all(cache_dir)?;
1280 let cache_json = serde_json::to_string_pretty(&cache_data)?;
1281 std::fs::write(&cache_file_path, cache_json)?;
1282
1283 Ok(())
1284 }
1285
1286 fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
1288 let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
1290 std::path::PathBuf::from(xdg_config)
1291 } else {
1292 dirs::config_dir()
1293 .ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
1294 };
1295 Ok(dir.join("subx").join("match_cache.json"))
1296 }
1297
1298 fn calculate_config_hash(&self) -> Result<String> {
1300 use std::collections::hash_map::DefaultHasher;
1301 use std::hash::{Hash, Hasher};
1302
1303 let mut hasher = DefaultHasher::new();
1304 format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
1306 self.config.backup_enabled.hash(&mut hasher);
1307 Ok(format!("{:016x}", hasher.finish()))
1310 }
1311
1312 fn find_media_file_by_id_or_path<'a>(
1314 files: &'a [&MediaFile],
1315 file_id: &str,
1316 fallback_path: Option<&str>,
1317 ) -> Option<&'a MediaFile> {
1318 if let Some(file) = files.iter().find(|f| f.id == file_id) {
1319 return Some(*file);
1320 }
1321 if let Some(path) = fallback_path {
1322 if let Some(file) = files.iter().find(|f| f.relative_path == path) {
1323 return Some(*file);
1324 }
1325 files.iter().find(|f| f.name == path).copied()
1326 } else {
1327 None
1328 }
1329 }
1330
1331 fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
1333 eprintln!(" Available {} files:", file_type);
1334 for f in files {
1335 eprintln!(
1336 " - ID: {} | Name: {} | Path: {}",
1337 f.id, f.name, f.relative_path
1338 );
1339 }
1340 }
1341
1342 fn log_no_matches_found(
1344 &self,
1345 match_result: &MatchResult,
1346 videos: &[MediaFile],
1347 subtitles: &[MediaFile],
1348 ) {
1349 eprintln!("\n❌ No matching files found that meet the criteria");
1350 eprintln!("🔍 AI analysis results:");
1351 eprintln!(" - Total matches: {}", match_result.matches.len());
1352 eprintln!(
1353 " - Confidence threshold: {:.2}",
1354 self.config.confidence_threshold
1355 );
1356 eprintln!(
1357 " - Matches meeting threshold: {}",
1358 match_result
1359 .matches
1360 .iter()
1361 .filter(|m| m.confidence >= self.config.confidence_threshold)
1362 .count()
1363 );
1364 eprintln!("\n📂 Scanned files:");
1365 eprintln!(" Video files ({} files):", videos.len());
1366 for v in videos {
1367 eprintln!(" - ID: {} | {}", v.id, v.relative_path);
1368 }
1369 eprintln!(" Subtitle files ({} files):", subtitles.len());
1370 for s in subtitles {
1371 eprintln!(" - ID: {} | {}", s.id, s.relative_path);
1372 }
1373 }
1374}