1use crate::services::ai::{AIProvider, AnalysisRequest, ContentSample, MatchResult};
16use std::path::Path;
17
18use crate::Result;
19use crate::core::language::LanguageDetector;
20use crate::core::matcher::cache::{CacheData, OpItem, SnapshotItem};
21use crate::core::matcher::{FileDiscovery, MediaFile, MediaFileType};
22
23use crate::error::SubXError;
24use dirs;
25use serde_json;
26
27#[derive(Debug, Clone, PartialEq)]
29pub enum FileRelocationMode {
30 None,
32 Copy,
34 Move,
36}
37
38#[derive(Debug, Clone)]
40pub enum ConflictResolution {
41 Skip,
43 AutoRename,
45 Prompt,
47}
48
49#[derive(Debug, Clone)]
54pub struct MatchConfig {
55 pub confidence_threshold: f32,
57 pub max_sample_length: usize,
59 pub enable_content_analysis: bool,
61 pub backup_enabled: bool,
63 pub relocation_mode: FileRelocationMode,
65 pub conflict_resolution: ConflictResolution,
67}
68
69#[cfg(test)]
70mod language_name_tests {
71 use super::*;
72 use crate::core::matcher::discovery::{MediaFile, MediaFileType};
73 use crate::services::ai::{
74 AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
75 };
76 use async_trait::async_trait;
77 use std::path::PathBuf;
78
79 struct DummyAI;
80 #[async_trait]
81 impl AIProvider for DummyAI {
82 async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
83 unimplemented!()
84 }
85 async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
86 unimplemented!()
87 }
88 }
89
90 #[test]
91 fn test_generate_subtitle_name_with_directory_language() {
92 let engine = MatchEngine::new(
93 Box::new(DummyAI),
94 MatchConfig {
95 confidence_threshold: 0.0,
96 max_sample_length: 0,
97 enable_content_analysis: false,
98 backup_enabled: false,
99 relocation_mode: FileRelocationMode::None,
100 conflict_resolution: ConflictResolution::Skip,
101 },
102 );
103 let video = MediaFile {
104 id: "".to_string(),
105 relative_path: "".to_string(),
106 path: PathBuf::from("movie01.mp4"),
107 file_type: MediaFileType::Video,
108 size: 0,
109 name: "movie01".to_string(),
110 extension: "mp4".to_string(),
111 };
112 let subtitle = MediaFile {
113 id: "".to_string(),
114 relative_path: "".to_string(),
115 path: PathBuf::from("tc/subtitle01.ass"),
116 file_type: MediaFileType::Subtitle,
117 size: 0,
118 name: "subtitle01".to_string(),
119 extension: "ass".to_string(),
120 };
121 let new_name = engine.generate_subtitle_name(&video, &subtitle);
122 assert_eq!(new_name, "movie01.tc.ass");
123 }
124
125 #[test]
126 fn test_generate_subtitle_name_with_filename_language() {
127 let engine = MatchEngine::new(
128 Box::new(DummyAI),
129 MatchConfig {
130 confidence_threshold: 0.0,
131 max_sample_length: 0,
132 enable_content_analysis: false,
133 backup_enabled: false,
134 relocation_mode: FileRelocationMode::None,
135 conflict_resolution: ConflictResolution::Skip,
136 },
137 );
138 let video = MediaFile {
139 id: "".to_string(),
140 relative_path: "".to_string(),
141 path: PathBuf::from("movie02.mp4"),
142 file_type: MediaFileType::Video,
143 size: 0,
144 name: "movie02".to_string(),
145 extension: "mp4".to_string(),
146 };
147 let subtitle = MediaFile {
148 id: "".to_string(),
149 relative_path: "".to_string(),
150 path: PathBuf::from("subtitle02.sc.ass"),
151 file_type: MediaFileType::Subtitle,
152 size: 0,
153 name: "subtitle02".to_string(),
154 extension: "ass".to_string(),
155 };
156 let new_name = engine.generate_subtitle_name(&video, &subtitle);
157 assert_eq!(new_name, "movie02.sc.ass");
158 }
159
160 #[test]
161 fn test_generate_subtitle_name_without_language() {
162 let engine = MatchEngine::new(
163 Box::new(DummyAI),
164 MatchConfig {
165 confidence_threshold: 0.0,
166 max_sample_length: 0,
167 enable_content_analysis: false,
168 backup_enabled: false,
169 relocation_mode: FileRelocationMode::None,
170 conflict_resolution: ConflictResolution::Skip,
171 },
172 );
173 let video = MediaFile {
174 id: "".to_string(),
175 relative_path: "".to_string(),
176 path: PathBuf::from("movie03.mp4"),
177 file_type: MediaFileType::Video,
178 size: 0,
179 name: "movie03".to_string(),
180 extension: "mp4".to_string(),
181 };
182 let subtitle = MediaFile {
183 id: "".to_string(),
184 relative_path: "".to_string(),
185 path: PathBuf::from("subtitle03.ass"),
186 file_type: MediaFileType::Subtitle,
187 size: 0,
188 name: "subtitle03".to_string(),
189 extension: "ass".to_string(),
190 };
191 let new_name = engine.generate_subtitle_name(&video, &subtitle);
192 assert_eq!(new_name, "movie03.ass");
193 }
194 #[test]
195 fn test_generate_subtitle_name_removes_video_extension() {
196 let engine = MatchEngine::new(
197 Box::new(DummyAI),
198 MatchConfig {
199 confidence_threshold: 0.0,
200 max_sample_length: 0,
201 enable_content_analysis: false,
202 backup_enabled: false,
203 relocation_mode: FileRelocationMode::None,
204 conflict_resolution: ConflictResolution::Skip,
205 },
206 );
207 let video = MediaFile {
208 id: "".to_string(),
209 relative_path: "".to_string(),
210 path: PathBuf::from("movie.mkv"),
211 file_type: MediaFileType::Video,
212 size: 0,
213 name: "movie.mkv".to_string(),
214 extension: "mkv".to_string(),
215 };
216 let subtitle = MediaFile {
217 id: "".to_string(),
218 relative_path: "".to_string(),
219 path: PathBuf::from("subtitle.srt"),
220 file_type: MediaFileType::Subtitle,
221 size: 0,
222 name: "subtitle".to_string(),
223 extension: "srt".to_string(),
224 };
225 let new_name = engine.generate_subtitle_name(&video, &subtitle);
226 assert_eq!(new_name, "movie.srt");
227 }
228
229 #[test]
230 fn test_generate_subtitle_name_with_language_removes_video_extension() {
231 let engine = MatchEngine::new(
232 Box::new(DummyAI),
233 MatchConfig {
234 confidence_threshold: 0.0,
235 max_sample_length: 0,
236 enable_content_analysis: false,
237 backup_enabled: false,
238 relocation_mode: FileRelocationMode::None,
239 conflict_resolution: ConflictResolution::Skip,
240 },
241 );
242 let video = MediaFile {
243 id: "".to_string(),
244 relative_path: "".to_string(),
245 path: PathBuf::from("movie.mkv"),
246 file_type: MediaFileType::Video,
247 size: 0,
248 name: "movie.mkv".to_string(),
249 extension: "mkv".to_string(),
250 };
251 let subtitle = MediaFile {
252 id: "".to_string(),
253 relative_path: "".to_string(),
254 path: PathBuf::from("tc/subtitle.srt"),
255 file_type: MediaFileType::Subtitle,
256 size: 0,
257 name: "subtitle".to_string(),
258 extension: "srt".to_string(),
259 };
260 let new_name = engine.generate_subtitle_name(&video, &subtitle);
261 assert_eq!(new_name, "movie.tc.srt");
262 }
263
264 #[test]
265 fn test_generate_subtitle_name_edge_cases() {
266 let engine = MatchEngine::new(
267 Box::new(DummyAI),
268 MatchConfig {
269 confidence_threshold: 0.0,
270 max_sample_length: 0,
271 enable_content_analysis: false,
272 backup_enabled: false,
273 relocation_mode: FileRelocationMode::None,
274 conflict_resolution: ConflictResolution::Skip,
275 },
276 );
277 let video = MediaFile {
279 id: "".to_string(),
280 relative_path: "".to_string(),
281 path: PathBuf::from("a.b.c"),
282 file_type: MediaFileType::Video,
283 size: 0,
284 name: "a.b.c".to_string(),
285 extension: "".to_string(),
286 };
287 let subtitle = MediaFile {
288 id: "".to_string(),
289 relative_path: "".to_string(),
290 path: PathBuf::from("sub.srt"),
291 file_type: MediaFileType::Subtitle,
292 size: 0,
293 name: "sub".to_string(),
294 extension: "srt".to_string(),
295 };
296 let new_name = engine.generate_subtitle_name(&video, &subtitle);
297 assert_eq!(new_name, "a.b.c.srt");
298 }
299
300 #[tokio::test]
301 async fn test_rename_file_displays_success_check_mark() {
302 use std::fs;
303 use tempfile::TempDir;
304
305 let temp_dir = TempDir::new().unwrap();
306 let temp_path = temp_dir.path();
307
308 let original_file = temp_path.join("original.srt");
310 fs::write(
311 &original_file,
312 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
313 )
314 .unwrap();
315
316 let engine = MatchEngine::new(
318 Box::new(DummyAI),
319 MatchConfig {
320 confidence_threshold: 0.0,
321 max_sample_length: 0,
322 enable_content_analysis: false,
323 backup_enabled: false,
324 relocation_mode: FileRelocationMode::None,
325 conflict_resolution: ConflictResolution::Skip,
326 },
327 );
328
329 let subtitle_file = MediaFile {
331 id: "test_id".to_string(),
332 relative_path: "original.srt".to_string(),
333 path: original_file.clone(),
334 file_type: MediaFileType::Subtitle,
335 size: 40,
336 name: "original".to_string(),
337 extension: "srt".to_string(),
338 };
339
340 let match_op = MatchOperation {
341 video_file: MediaFile {
342 id: "video_id".to_string(),
343 relative_path: "test.mp4".to_string(),
344 path: temp_path.join("test.mp4"),
345 file_type: MediaFileType::Video,
346 size: 1000,
347 name: "test".to_string(),
348 extension: "mp4".to_string(),
349 },
350 subtitle_file,
351 new_subtitle_name: "renamed.srt".to_string(),
352 confidence: 95.0,
353 reasoning: vec!["Test match".to_string()],
354 requires_relocation: false,
355 relocation_target_path: None,
356 relocation_mode: FileRelocationMode::None,
357 };
358
359 let result = engine.rename_file(&match_op).await;
361
362 assert!(result.is_ok());
364
365 let renamed_file = temp_path.join("renamed.srt");
367 assert!(renamed_file.exists(), "The renamed file should exist");
368 assert!(
369 !original_file.exists(),
370 "The original file should have been renamed"
371 );
372
373 let content = fs::read_to_string(&renamed_file).unwrap();
375 assert!(content.contains("Test subtitle"));
376 }
377
378 #[tokio::test]
379 async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
380 use std::fs;
381 use tempfile::TempDir;
382
383 let temp_dir = TempDir::new().unwrap();
384 let temp_path = temp_dir.path();
385
386 let original_file = temp_path.join("original.srt");
388 fs::write(
389 &original_file,
390 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
391 )
392 .unwrap();
393
394 let engine = MatchEngine::new(
396 Box::new(DummyAI),
397 MatchConfig {
398 confidence_threshold: 0.0,
399 max_sample_length: 0,
400 enable_content_analysis: false,
401 backup_enabled: false,
402 relocation_mode: FileRelocationMode::None,
403 conflict_resolution: ConflictResolution::Skip,
404 },
405 );
406
407 let subtitle_file = MediaFile {
409 id: "test_id".to_string(),
410 relative_path: "original.srt".to_string(),
411 path: original_file.clone(),
412 file_type: MediaFileType::Subtitle,
413 size: 40,
414 name: "original".to_string(),
415 extension: "srt".to_string(),
416 };
417
418 let match_op = MatchOperation {
419 video_file: MediaFile {
420 id: "video_id".to_string(),
421 relative_path: "test.mp4".to_string(),
422 path: temp_path.join("test.mp4"),
423 file_type: MediaFileType::Video,
424 size: 1000,
425 name: "test".to_string(),
426 extension: "mp4".to_string(),
427 },
428 subtitle_file,
429 new_subtitle_name: "renamed.srt".to_string(),
430 confidence: 95.0,
431 reasoning: vec!["Test match".to_string()],
432 requires_relocation: false,
433 relocation_target_path: None,
434 relocation_mode: FileRelocationMode::None,
435 };
436
437 let result = engine.rename_file(&match_op).await;
440 assert!(result.is_ok());
441
442 let renamed_file = temp_path.join("renamed.srt");
444 if renamed_file.exists() {
445 fs::remove_file(&renamed_file).unwrap();
446 }
447
448 fs::write(
450 &original_file,
451 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
452 )
453 .unwrap();
454
455 let result = engine.rename_file(&match_op).await;
459 assert!(result.is_ok());
460
461 let renamed_file = temp_path.join("renamed.srt");
463 if renamed_file.exists() {
464 fs::remove_file(&renamed_file).unwrap();
465 }
466
467 }
470
471 #[test]
472 fn test_file_operation_message_format() {
473 let source_name = "test.srt";
475 let target_name = "renamed.srt";
476
477 let success_msg = format!(" ✓ Renamed: {} -> {}", source_name, target_name);
479 assert!(success_msg.contains("✓"));
480 assert!(success_msg.contains("Renamed:"));
481 assert!(success_msg.contains(source_name));
482 assert!(success_msg.contains(target_name));
483
484 let error_msg = format!(
486 " ✗ Rename failed: {} -> {} (target file does not exist after operation)",
487 source_name, target_name
488 );
489 assert!(error_msg.contains("✗"));
490 assert!(error_msg.contains("Rename failed:"));
491 assert!(error_msg.contains("target file does not exist"));
492 assert!(error_msg.contains(source_name));
493 assert!(error_msg.contains(target_name));
494 }
495
496 #[test]
497 fn test_copy_operation_message_format() {
498 let source_name = "subtitle.srt";
500 let target_name = "video.srt";
501
502 let success_msg = format!(" ✓ Copied: {} -> {}", source_name, target_name);
504 assert!(success_msg.contains("✓"));
505 assert!(success_msg.contains("Copied:"));
506
507 let error_msg = format!(
509 " ✗ Copy failed: {} -> {} (target file does not exist after operation)",
510 source_name, target_name
511 );
512 assert!(error_msg.contains("✗"));
513 assert!(error_msg.contains("Copy failed:"));
514 assert!(error_msg.contains("target file does not exist"));
515 }
516
517 #[test]
518 fn test_move_operation_message_format() {
519 let source_name = "subtitle.srt";
521 let target_name = "video.srt";
522
523 let success_msg = format!(" ✓ Moved: {} -> {}", source_name, target_name);
525 assert!(success_msg.contains("✓"));
526 assert!(success_msg.contains("Moved:"));
527
528 let error_msg = format!(
530 " ✗ Move failed: {} -> {} (target file does not exist after operation)",
531 source_name, target_name
532 );
533 assert!(error_msg.contains("✗"));
534 assert!(error_msg.contains("Move failed:"));
535 assert!(error_msg.contains("target file does not exist"));
536 }
537}
538
539#[derive(Debug)]
544pub struct MatchOperation {
545 pub video_file: MediaFile,
547 pub subtitle_file: MediaFile,
549 pub new_subtitle_name: String,
551 pub confidence: f32,
553 pub reasoning: Vec<String>,
555 pub relocation_mode: FileRelocationMode,
557 pub relocation_target_path: Option<std::path::PathBuf>,
559 pub requires_relocation: bool,
561}
562
563pub struct MatchEngine {
565 ai_client: Box<dyn AIProvider>,
566 discovery: FileDiscovery,
567 config: MatchConfig,
568}
569
570impl MatchEngine {
571 pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
573 Self {
574 ai_client,
575 discovery: FileDiscovery::new(),
576 config,
577 }
578 }
579
580 pub async fn match_files(&self, path: &Path, recursive: bool) -> Result<Vec<MatchOperation>> {
591 let files = self.discovery.scan_directory(path, recursive)?;
593
594 let videos: Vec<_> = files
595 .iter()
596 .filter(|f| matches!(f.file_type, MediaFileType::Video))
597 .collect();
598 let subtitles: Vec<_> = files
599 .iter()
600 .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
601 .collect();
602
603 if videos.is_empty() || subtitles.is_empty() {
604 return Ok(Vec::new());
605 }
606
607 if let Some(ops) = self.check_cache(path, recursive).await? {
609 return Ok(ops);
610 }
611 let content_samples = if self.config.enable_content_analysis {
613 self.extract_content_samples(&subtitles).await?
614 } else {
615 Vec::new()
616 };
617
618 let video_files: Vec<String> = videos
621 .iter()
622 .map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
623 .collect();
624 let subtitle_files: Vec<String> = subtitles
625 .iter()
626 .map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
627 .collect();
628 let analysis_request = AnalysisRequest {
629 video_files,
630 subtitle_files,
631 content_samples,
632 };
633
634 let match_result = self.ai_client.analyze_content(analysis_request).await?;
635
636 eprintln!("🔍 AI Analysis Results:");
638 eprintln!(" - Total matches: {}", match_result.matches.len());
639 eprintln!(
640 " - Confidence threshold: {:.2}",
641 self.config.confidence_threshold
642 );
643 for ai_match in &match_result.matches {
644 eprintln!(
645 " - {} -> {} (confidence: {:.2})",
646 ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
647 );
648 }
649
650 let mut operations = Vec::new();
652
653 for ai_match in match_result.matches {
654 if ai_match.confidence >= self.config.confidence_threshold {
655 let video_match =
656 Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
657 let subtitle_match = Self::find_media_file_by_id_or_path(
658 &subtitles,
659 &ai_match.subtitle_file_id,
660 None,
661 );
662 match (video_match, subtitle_match) {
663 (Some(video), Some(subtitle)) => {
664 let new_name = self.generate_subtitle_name(video, subtitle);
665
666 let requires_relocation = self.config.relocation_mode
668 != FileRelocationMode::None
669 && subtitle.path.parent() != video.path.parent();
670
671 let relocation_target_path = if requires_relocation {
672 let video_dir = video.path.parent().unwrap();
673 Some(video_dir.join(&new_name))
674 } else {
675 None
676 };
677
678 operations.push(MatchOperation {
679 video_file: (*video).clone(),
680 subtitle_file: (*subtitle).clone(),
681 new_subtitle_name: new_name,
682 confidence: ai_match.confidence,
683 reasoning: ai_match.match_factors,
684 relocation_mode: self.config.relocation_mode.clone(),
685 relocation_target_path,
686 requires_relocation,
687 });
688 }
689 (None, Some(_)) => {
690 eprintln!(
691 "⚠️ Cannot find AI-suggested video file ID: '{}'",
692 ai_match.video_file_id
693 );
694 self.log_available_files(&videos, "video");
695 }
696 (Some(_), None) => {
697 eprintln!(
698 "⚠️ Cannot find AI-suggested subtitle file ID: '{}'",
699 ai_match.subtitle_file_id
700 );
701 self.log_available_files(&subtitles, "subtitle");
702 }
703 (None, None) => {
704 eprintln!("⚠️ Cannot find AI-suggested file pair:");
705 eprintln!(" Video ID: '{}'", ai_match.video_file_id);
706 eprintln!(" Subtitle ID: '{}'", ai_match.subtitle_file_id);
707 }
708 }
709 } else {
710 eprintln!(
711 "ℹ️ AI match confidence too low ({:.2}): {} <-> {}",
712 ai_match.confidence, ai_match.video_file_id, ai_match.subtitle_file_id
713 );
714 }
715 }
716
717 if operations.is_empty() {
719 eprintln!("\n❌ No matching files found that meet the criteria");
720 eprintln!("🔍 Available file statistics:");
721 eprintln!(" Video files ({} files):", videos.len());
722 for v in &videos {
723 eprintln!(" - ID: {} | {}", v.id, v.relative_path);
724 }
725 eprintln!(" Subtitle files ({} files):", subtitles.len());
726 for s in &subtitles {
727 eprintln!(" - ID: {} | {}", s.id, s.relative_path);
728 }
729 }
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 fn calculate_file_snapshot(
1122 &self,
1123 directory: &Path,
1124 recursive: bool,
1125 ) -> Result<Vec<SnapshotItem>> {
1126 let files = self.discovery.scan_directory(directory, recursive)?;
1127 let mut snapshot = Vec::new();
1128 for f in files {
1129 let metadata = std::fs::metadata(&f.path)?;
1130 let mtime = metadata
1131 .modified()
1132 .ok()
1133 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1134 .map(|d| d.as_secs())
1135 .unwrap_or(0);
1136 snapshot.push(SnapshotItem {
1137 name: f.name.clone(),
1138 size: f.size,
1139 mtime,
1140 file_type: match f.file_type {
1141 MediaFileType::Video => "video".to_string(),
1142 MediaFileType::Subtitle => "subtitle".to_string(),
1143 },
1144 });
1145 }
1146 Ok(snapshot)
1147 }
1148
1149 pub async fn check_cache(
1151 &self,
1152 directory: &Path,
1153 recursive: bool,
1154 ) -> Result<Option<Vec<MatchOperation>>> {
1155 let current_snapshot = self.calculate_file_snapshot(directory, recursive)?;
1156 let cache_file_path = self.get_cache_file_path()?;
1157
1158 let cache_data = CacheData::load(&cache_file_path).ok();
1159 if let Some(cache_data) = cache_data {
1160 let current_config_hash = self.calculate_config_hash()?;
1161
1162 if cache_data.directory == directory.to_string_lossy()
1163 && cache_data.file_snapshot == current_snapshot
1164 && cache_data.config_hash == current_config_hash
1165 {
1166 let files = self.discovery.scan_directory(directory, recursive)?;
1168 let mut ops = Vec::new();
1169 for item in cache_data.match_operations {
1170 if let (Some(video), Some(subtitle)) = (
1171 files.iter().find(|f| {
1172 f.name == item.video_file && matches!(f.file_type, MediaFileType::Video)
1173 }),
1174 files.iter().find(|f| {
1175 f.name == item.subtitle_file
1176 && matches!(f.file_type, MediaFileType::Subtitle)
1177 }),
1178 ) {
1179 let requires_relocation = self.config.relocation_mode
1181 != FileRelocationMode::None
1182 && subtitle.path.parent() != video.path.parent();
1183 let relocation_target_path = if requires_relocation {
1184 let video_dir = video.path.parent().unwrap();
1185 Some(video_dir.join(&item.new_subtitle_name))
1186 } else {
1187 None
1188 };
1189
1190 ops.push(MatchOperation {
1191 video_file: (*video).clone(),
1192 subtitle_file: (*subtitle).clone(),
1193 new_subtitle_name: item.new_subtitle_name.clone(),
1194 confidence: item.confidence,
1195 reasoning: item.reasoning.clone(),
1196 relocation_mode: self.config.relocation_mode.clone(),
1197 relocation_target_path,
1198 requires_relocation,
1199 });
1200 }
1201 }
1202 return Ok(Some(ops));
1203 }
1204 }
1205 Ok(None)
1206 }
1207
1208 pub async fn save_cache(
1210 &self,
1211 directory: &Path,
1212 recursive: bool,
1213 operations: &[MatchOperation],
1214 ) -> Result<()> {
1215 let cache_data = CacheData {
1216 cache_version: "1.0".to_string(),
1217 directory: directory.to_string_lossy().to_string(),
1218 file_snapshot: self.calculate_file_snapshot(directory, recursive)?,
1219 match_operations: operations
1220 .iter()
1221 .map(|op| OpItem {
1222 video_file: op.video_file.name.clone(),
1223 subtitle_file: op.subtitle_file.name.clone(),
1224 new_subtitle_name: op.new_subtitle_name.clone(),
1225 confidence: op.confidence,
1226 reasoning: op.reasoning.clone(),
1227 })
1228 .collect(),
1229 created_at: std::time::SystemTime::now()
1230 .duration_since(std::time::UNIX_EPOCH)
1231 .map(|d| d.as_secs())
1232 .unwrap_or(0),
1233 ai_model_used: "gpt-4.1-mini".to_string(), original_relocation_mode: format!("{:?}", self.config.relocation_mode),
1236 original_backup_enabled: self.config.backup_enabled,
1237 config_hash: self.calculate_config_hash()?,
1238 };
1239 let path = self.get_cache_file_path()?;
1240 if let Some(parent) = path.parent() {
1241 std::fs::create_dir_all(parent)?;
1242 }
1243 let content =
1244 serde_json::to_string_pretty(&cache_data).map_err(|e| SubXError::Other(e.into()))?;
1245 std::fs::write(path, content)?;
1246 Ok(())
1247 }
1248
1249 fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
1251 let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
1253 std::path::PathBuf::from(xdg_config)
1254 } else {
1255 dirs::config_dir()
1256 .ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
1257 };
1258 Ok(dir.join("subx").join("match_cache.json"))
1259 }
1260
1261 fn calculate_config_hash(&self) -> Result<String> {
1263 use std::collections::hash_map::DefaultHasher;
1264 use std::hash::{Hash, Hasher};
1265
1266 let mut hasher = DefaultHasher::new();
1267 format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
1269 self.config.backup_enabled.hash(&mut hasher);
1270 Ok(format!("{:016x}", hasher.finish()))
1273 }
1274
1275 fn find_media_file_by_id_or_path<'a>(
1277 files: &'a [&MediaFile],
1278 file_id: &str,
1279 fallback_path: Option<&str>,
1280 ) -> Option<&'a MediaFile> {
1281 if let Some(file) = files.iter().find(|f| f.id == file_id) {
1282 return Some(*file);
1283 }
1284 if let Some(path) = fallback_path {
1285 if let Some(file) = files.iter().find(|f| f.relative_path == path) {
1286 return Some(*file);
1287 }
1288 files.iter().find(|f| f.name == path).copied()
1289 } else {
1290 None
1291 }
1292 }
1293
1294 fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
1296 eprintln!(" Available {} files:", file_type);
1297 for f in files {
1298 eprintln!(
1299 " - ID: {} | Name: {} | Path: {}",
1300 f.id, f.name, f.relative_path
1301 );
1302 }
1303 }
1304
1305 fn log_no_matches_found(
1307 &self,
1308 match_result: &MatchResult,
1309 videos: &[MediaFile],
1310 subtitles: &[MediaFile],
1311 ) {
1312 eprintln!("\n❌ No matching files found that meet the criteria");
1313 eprintln!("🔍 AI analysis results:");
1314 eprintln!(" - Total matches: {}", match_result.matches.len());
1315 eprintln!(
1316 " - Confidence threshold: {:.2}",
1317 self.config.confidence_threshold
1318 );
1319 eprintln!(
1320 " - Matches meeting threshold: {}",
1321 match_result
1322 .matches
1323 .iter()
1324 .filter(|m| m.confidence >= self.config.confidence_threshold)
1325 .count()
1326 );
1327 eprintln!("\n📂 Scanned files:");
1328 eprintln!(" Video files ({} files):", videos.len());
1329 for v in videos {
1330 eprintln!(" - ID: {} | {}", v.id, v.relative_path);
1331 }
1332 eprintln!(" Subtitle files ({} files):", subtitles.len());
1333 for s in subtitles {
1334 eprintln!(" - ID: {} | {}", s.id, s.relative_path);
1335 }
1336 }
1337}