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::core::uuidv7::Uuidv7Generator;
28use crate::error::SubXError;
29use dirs;
30use serde_json;
31
32#[derive(Debug, Clone, PartialEq)]
34pub enum FileRelocationMode {
35 None,
37 Copy,
39 Move,
41}
42
43#[derive(Debug, Clone)]
45pub enum ConflictResolution {
46 Skip,
48 AutoRename,
50 Prompt,
52}
53
54#[derive(Debug, Clone)]
59pub struct MatchConfig {
60 pub confidence_threshold: f32,
62 pub max_sample_length: usize,
64 pub enable_content_analysis: bool,
66 pub backup_enabled: bool,
68 pub relocation_mode: FileRelocationMode,
70 pub conflict_resolution: ConflictResolution,
72 pub ai_model: String,
74 pub max_subtitle_bytes: u64,
77}
78
79#[cfg(test)]
80mod language_name_tests {
81 use super::*;
82 use crate::core::matcher::discovery::{MediaFile, MediaFileType};
83 use crate::services::ai::{
84 AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
85 };
86 use async_trait::async_trait;
87 use std::path::PathBuf;
88
89 struct DummyAI;
90 #[async_trait]
91 impl AIProvider for DummyAI {
92 async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
93 unimplemented!()
94 }
95 async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
96 unimplemented!()
97 }
98 }
99
100 #[test]
101 fn test_generate_subtitle_name_with_directory_language() {
102 let engine = MatchEngine::new(
103 Box::new(DummyAI),
104 MatchConfig {
105 confidence_threshold: 0.0,
106 max_sample_length: 0,
107 enable_content_analysis: false,
108 backup_enabled: false,
109 relocation_mode: FileRelocationMode::None,
110 conflict_resolution: ConflictResolution::Skip,
111 ai_model: "test-model".to_string(),
112 max_subtitle_bytes: 52_428_800,
113 },
114 );
115 let video = MediaFile {
116 id: "".to_string(),
117 relative_path: "".to_string(),
118 path: PathBuf::from("movie01.mp4"),
119 file_type: MediaFileType::Video,
120 size: 0,
121 name: "movie01".to_string(),
122 extension: "mp4".to_string(),
123 };
124 let subtitle = MediaFile {
125 id: "".to_string(),
126 relative_path: "".to_string(),
127 path: PathBuf::from("tc/subtitle01.ass"),
128 file_type: MediaFileType::Subtitle,
129 size: 0,
130 name: "subtitle01".to_string(),
131 extension: "ass".to_string(),
132 };
133 let new_name = engine.generate_subtitle_name(&video, &subtitle);
134 assert_eq!(new_name, "movie01.tc.ass");
135 }
136
137 #[test]
138 fn test_generate_subtitle_name_with_filename_language() {
139 let engine = MatchEngine::new(
140 Box::new(DummyAI),
141 MatchConfig {
142 confidence_threshold: 0.0,
143 max_sample_length: 0,
144 enable_content_analysis: false,
145 backup_enabled: false,
146 relocation_mode: FileRelocationMode::None,
147 conflict_resolution: ConflictResolution::Skip,
148 ai_model: "test-model".to_string(),
149 max_subtitle_bytes: 52_428_800,
150 },
151 );
152 let video = MediaFile {
153 id: "".to_string(),
154 relative_path: "".to_string(),
155 path: PathBuf::from("movie02.mp4"),
156 file_type: MediaFileType::Video,
157 size: 0,
158 name: "movie02".to_string(),
159 extension: "mp4".to_string(),
160 };
161 let subtitle = MediaFile {
162 id: "".to_string(),
163 relative_path: "".to_string(),
164 path: PathBuf::from("subtitle02.sc.ass"),
165 file_type: MediaFileType::Subtitle,
166 size: 0,
167 name: "subtitle02".to_string(),
168 extension: "ass".to_string(),
169 };
170 let new_name = engine.generate_subtitle_name(&video, &subtitle);
171 assert_eq!(new_name, "movie02.sc.ass");
172 }
173
174 #[test]
175 fn test_generate_subtitle_name_without_language() {
176 let engine = MatchEngine::new(
177 Box::new(DummyAI),
178 MatchConfig {
179 confidence_threshold: 0.0,
180 max_sample_length: 0,
181 enable_content_analysis: false,
182 backup_enabled: false,
183 relocation_mode: FileRelocationMode::None,
184 conflict_resolution: ConflictResolution::Skip,
185 ai_model: "test-model".to_string(),
186 max_subtitle_bytes: 52_428_800,
187 },
188 );
189 let video = MediaFile {
190 id: "".to_string(),
191 relative_path: "".to_string(),
192 path: PathBuf::from("movie03.mp4"),
193 file_type: MediaFileType::Video,
194 size: 0,
195 name: "movie03".to_string(),
196 extension: "mp4".to_string(),
197 };
198 let subtitle = MediaFile {
199 id: "".to_string(),
200 relative_path: "".to_string(),
201 path: PathBuf::from("subtitle03.ass"),
202 file_type: MediaFileType::Subtitle,
203 size: 0,
204 name: "subtitle03".to_string(),
205 extension: "ass".to_string(),
206 };
207 let new_name = engine.generate_subtitle_name(&video, &subtitle);
208 assert_eq!(new_name, "movie03.ass");
209 }
210 #[test]
211 fn test_generate_subtitle_name_removes_video_extension() {
212 let engine = MatchEngine::new(
213 Box::new(DummyAI),
214 MatchConfig {
215 confidence_threshold: 0.0,
216 max_sample_length: 0,
217 enable_content_analysis: false,
218 backup_enabled: false,
219 relocation_mode: FileRelocationMode::None,
220 conflict_resolution: ConflictResolution::Skip,
221 ai_model: "test-model".to_string(),
222 max_subtitle_bytes: 52_428_800,
223 },
224 );
225 let video = MediaFile {
226 id: "".to_string(),
227 relative_path: "".to_string(),
228 path: PathBuf::from("movie.mkv"),
229 file_type: MediaFileType::Video,
230 size: 0,
231 name: "movie.mkv".to_string(),
232 extension: "mkv".to_string(),
233 };
234 let subtitle = MediaFile {
235 id: "".to_string(),
236 relative_path: "".to_string(),
237 path: PathBuf::from("subtitle.srt"),
238 file_type: MediaFileType::Subtitle,
239 size: 0,
240 name: "subtitle".to_string(),
241 extension: "srt".to_string(),
242 };
243 let new_name = engine.generate_subtitle_name(&video, &subtitle);
244 assert_eq!(new_name, "movie.srt");
245 }
246
247 #[test]
248 fn test_generate_subtitle_name_with_language_removes_video_extension() {
249 let engine = MatchEngine::new(
250 Box::new(DummyAI),
251 MatchConfig {
252 confidence_threshold: 0.0,
253 max_sample_length: 0,
254 enable_content_analysis: false,
255 backup_enabled: false,
256 relocation_mode: FileRelocationMode::None,
257 conflict_resolution: ConflictResolution::Skip,
258 ai_model: "test-model".to_string(),
259 max_subtitle_bytes: 52_428_800,
260 },
261 );
262 let video = MediaFile {
263 id: "".to_string(),
264 relative_path: "".to_string(),
265 path: PathBuf::from("movie.mkv"),
266 file_type: MediaFileType::Video,
267 size: 0,
268 name: "movie.mkv".to_string(),
269 extension: "mkv".to_string(),
270 };
271 let subtitle = MediaFile {
272 id: "".to_string(),
273 relative_path: "".to_string(),
274 path: PathBuf::from("tc/subtitle.srt"),
275 file_type: MediaFileType::Subtitle,
276 size: 0,
277 name: "subtitle".to_string(),
278 extension: "srt".to_string(),
279 };
280 let new_name = engine.generate_subtitle_name(&video, &subtitle);
281 assert_eq!(new_name, "movie.tc.srt");
282 }
283
284 #[test]
285 fn test_generate_subtitle_name_edge_cases() {
286 let engine = MatchEngine::new(
287 Box::new(DummyAI),
288 MatchConfig {
289 confidence_threshold: 0.0,
290 max_sample_length: 0,
291 enable_content_analysis: false,
292 backup_enabled: false,
293 relocation_mode: FileRelocationMode::None,
294 conflict_resolution: ConflictResolution::Skip,
295 ai_model: "test-model".to_string(),
296 max_subtitle_bytes: 52_428_800,
297 },
298 );
299 let video = MediaFile {
301 id: "".to_string(),
302 relative_path: "".to_string(),
303 path: PathBuf::from("a.b.c"),
304 file_type: MediaFileType::Video,
305 size: 0,
306 name: "a.b.c".to_string(),
307 extension: "".to_string(),
308 };
309 let subtitle = MediaFile {
310 id: "".to_string(),
311 relative_path: "".to_string(),
312 path: PathBuf::from("sub.srt"),
313 file_type: MediaFileType::Subtitle,
314 size: 0,
315 name: "sub".to_string(),
316 extension: "srt".to_string(),
317 };
318 let new_name = engine.generate_subtitle_name(&video, &subtitle);
319 assert_eq!(new_name, "a.b.c.srt");
320 }
321
322 #[tokio::test]
323 async fn test_rename_file_displays_success_check_mark() {
324 use std::fs;
325 use tempfile::TempDir;
326
327 let temp_dir = TempDir::new().unwrap();
328 let temp_path = temp_dir.path();
329
330 let original_file = temp_path.join("original.srt");
332 fs::write(
333 &original_file,
334 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
335 )
336 .unwrap();
337
338 let engine = MatchEngine::new(
340 Box::new(DummyAI),
341 MatchConfig {
342 confidence_threshold: 0.0,
343 max_sample_length: 0,
344 enable_content_analysis: false,
345 backup_enabled: false,
346 relocation_mode: FileRelocationMode::None,
347 conflict_resolution: ConflictResolution::Skip,
348 ai_model: "test-model".to_string(),
349 max_subtitle_bytes: 52_428_800,
350 },
351 );
352
353 let subtitle_file = MediaFile {
355 id: "test_id".to_string(),
356 relative_path: "original.srt".to_string(),
357 path: original_file.clone(),
358 file_type: MediaFileType::Subtitle,
359 size: 40,
360 name: "original".to_string(),
361 extension: "srt".to_string(),
362 };
363
364 let match_op = MatchOperation {
365 video_file: MediaFile {
366 id: "video_id".to_string(),
367 relative_path: "test.mp4".to_string(),
368 path: temp_path.join("test.mp4"),
369 file_type: MediaFileType::Video,
370 size: 1000,
371 name: "test".to_string(),
372 extension: "mp4".to_string(),
373 },
374 subtitle_file,
375 new_subtitle_name: "renamed.srt".to_string(),
376 confidence: 95.0,
377 reasoning: vec!["Test match".to_string()],
378 requires_relocation: false,
379 relocation_target_path: None,
380 relocation_mode: FileRelocationMode::None,
381 };
382
383 let result = engine.rename_file(&match_op).await;
385
386 assert!(result.is_ok());
388
389 let renamed_file = temp_path.join("renamed.srt");
391 assert!(renamed_file.exists(), "The renamed file should exist");
392 assert!(
393 !original_file.exists(),
394 "The original file should have been renamed"
395 );
396
397 let content = fs::read_to_string(&renamed_file).unwrap();
399 assert!(content.contains("Test subtitle"));
400 }
401
402 #[tokio::test]
403 async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
404 use std::fs;
405 use tempfile::TempDir;
406
407 let temp_dir = TempDir::new().unwrap();
408 let temp_path = temp_dir.path();
409
410 let original_file = temp_path.join("original.srt");
412 fs::write(
413 &original_file,
414 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
415 )
416 .unwrap();
417
418 let engine = MatchEngine::new(
420 Box::new(DummyAI),
421 MatchConfig {
422 confidence_threshold: 0.0,
423 max_sample_length: 0,
424 enable_content_analysis: false,
425 backup_enabled: false,
426 relocation_mode: FileRelocationMode::None,
427 conflict_resolution: ConflictResolution::Skip,
428 ai_model: "test-model".to_string(),
429 max_subtitle_bytes: 52_428_800,
430 },
431 );
432
433 let subtitle_file = MediaFile {
435 id: "test_id".to_string(),
436 relative_path: "original.srt".to_string(),
437 path: original_file.clone(),
438 file_type: MediaFileType::Subtitle,
439 size: 40,
440 name: "original".to_string(),
441 extension: "srt".to_string(),
442 };
443
444 let match_op = MatchOperation {
445 video_file: MediaFile {
446 id: "video_id".to_string(),
447 relative_path: "test.mp4".to_string(),
448 path: temp_path.join("test.mp4"),
449 file_type: MediaFileType::Video,
450 size: 1000,
451 name: "test".to_string(),
452 extension: "mp4".to_string(),
453 },
454 subtitle_file,
455 new_subtitle_name: "renamed.srt".to_string(),
456 confidence: 95.0,
457 reasoning: vec!["Test match".to_string()],
458 requires_relocation: false,
459 relocation_target_path: None,
460 relocation_mode: FileRelocationMode::None,
461 };
462
463 let result = engine.rename_file(&match_op).await;
466 assert!(result.is_ok());
467
468 let renamed_file = temp_path.join("renamed.srt");
470 if renamed_file.exists() {
471 fs::remove_file(&renamed_file).unwrap();
472 }
473
474 fs::write(
476 &original_file,
477 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
478 )
479 .unwrap();
480
481 let result = engine.rename_file(&match_op).await;
485 assert!(result.is_ok());
486
487 let renamed_file = temp_path.join("renamed.srt");
489 if renamed_file.exists() {
490 fs::remove_file(&renamed_file).unwrap();
491 }
492
493 }
496
497 #[test]
498 fn test_file_operation_message_format() {
499 let source_name = "test.srt";
501 let target_name = "renamed.srt";
502
503 let success_msg = format!(" ā Renamed: {} -> {}", source_name, target_name);
505 assert!(success_msg.contains("ā"));
506 assert!(success_msg.contains("Renamed:"));
507 assert!(success_msg.contains(source_name));
508 assert!(success_msg.contains(target_name));
509
510 let error_msg = format!(
512 " ā Rename failed: {} -> {} (target file does not exist after operation)",
513 source_name, target_name
514 );
515 assert!(error_msg.contains("ā"));
516 assert!(error_msg.contains("Rename failed:"));
517 assert!(error_msg.contains("target file does not exist"));
518 assert!(error_msg.contains(source_name));
519 assert!(error_msg.contains(target_name));
520 }
521
522 #[test]
523 fn test_copy_operation_message_format() {
524 let source_name = "subtitle.srt";
526 let target_name = "video.srt";
527
528 let success_msg = format!(" ā Copied: {} -> {}", source_name, target_name);
530 assert!(success_msg.contains("ā"));
531 assert!(success_msg.contains("Copied:"));
532
533 let error_msg = format!(
535 " ā Copy failed: {} -> {} (target file does not exist after operation)",
536 source_name, target_name
537 );
538 assert!(error_msg.contains("ā"));
539 assert!(error_msg.contains("Copy failed:"));
540 assert!(error_msg.contains("target file does not exist"));
541 }
542
543 #[test]
544 fn test_move_operation_message_format() {
545 let source_name = "subtitle.srt";
547 let target_name = "video.srt";
548
549 let success_msg = format!(" ā Moved: {} -> {}", source_name, target_name);
551 assert!(success_msg.contains("ā"));
552 assert!(success_msg.contains("Moved:"));
553
554 let error_msg = format!(
556 " ā Move failed: {} -> {} (target file does not exist after operation)",
557 source_name, target_name
558 );
559 assert!(error_msg.contains("ā"));
560 assert!(error_msg.contains("Move failed:"));
561 assert!(error_msg.contains("target file does not exist"));
562 }
563}
564
565#[derive(Debug)]
570pub struct MatchOperation {
571 pub video_file: MediaFile,
573 pub subtitle_file: MediaFile,
575 pub new_subtitle_name: String,
577 pub confidence: f32,
579 pub reasoning: Vec<String>,
581 pub relocation_mode: FileRelocationMode,
583 pub relocation_target_path: Option<std::path::PathBuf>,
585 pub requires_relocation: bool,
587}
588
589#[derive(Debug, Clone)]
595pub struct RejectedCandidate {
596 pub video_path: String,
598 pub subtitle_path: String,
600 pub confidence: f32,
602 pub reason: &'static str,
604}
605
606#[derive(Debug)]
609pub struct MatchAudit {
610 pub operations: Vec<MatchOperation>,
612 pub rejected: Vec<RejectedCandidate>,
614}
615
616#[derive(Debug)]
622pub struct OperationOutcome {
623 pub applied: bool,
625 pub error: Option<OperationError>,
627}
628
629#[derive(Debug, Clone)]
631pub struct OperationError {
632 pub category: &'static str,
634 pub code: &'static str,
636 pub message: String,
638}
639
640fn operation_error_from(err: &SubXError) -> OperationError {
643 OperationError {
644 category: err.category(),
645 code: err.machine_code(),
646 message: err.user_friendly_message(),
647 }
648}
649
650pub struct MatchEngine {
652 ai_client: Box<dyn AIProvider>,
653 discovery: FileDiscovery,
654 config: MatchConfig,
655}
656
657impl MatchEngine {
658 pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
660 Self {
661 ai_client,
662 discovery: FileDiscovery::new(),
663 config,
664 }
665 }
666
667 pub async fn match_file_list(&self, file_paths: &[PathBuf]) -> Result<Vec<MatchOperation>> {
681 Ok(self
682 .match_file_list_with_audit(file_paths)
683 .await?
684 .operations)
685 }
686
687 pub async fn match_file_list_with_audit(&self, file_paths: &[PathBuf]) -> Result<MatchAudit> {
693 let json_mode = crate::cli::output::active_mode().is_json();
694 let files = self.discovery.scan_file_list(file_paths)?;
696
697 let videos: Vec<_> = files
698 .iter()
699 .filter(|f| matches!(f.file_type, MediaFileType::Video))
700 .collect();
701 let subtitles: Vec<_> = files
702 .iter()
703 .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
704 .collect();
705
706 if videos.is_empty() || subtitles.is_empty() {
707 return Ok(MatchAudit {
708 operations: Vec::new(),
709 rejected: Vec::new(),
710 });
711 }
712
713 let cache_key = self.calculate_file_list_cache_key(file_paths)?;
715 if let Some(ops) = self.check_file_list_cache(&cache_key).await? {
716 return Ok(MatchAudit {
717 operations: ops,
718 rejected: Vec::new(),
719 });
720 }
721
722 let content_samples = if self.config.enable_content_analysis {
724 self.extract_content_samples(&subtitles).await?
725 } else {
726 Vec::new()
727 };
728
729 let video_files: Vec<String> = videos
731 .iter()
732 .map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
733 .collect();
734 let subtitle_files: Vec<String> = subtitles
735 .iter()
736 .map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
737 .collect();
738
739 let analysis_request = AnalysisRequest {
740 video_files,
741 subtitle_files,
742 content_samples,
743 };
744
745 let match_result = self.ai_client.analyze_content(analysis_request).await?;
747
748 if !json_mode {
751 eprintln!("š AI Analysis Results:");
752 eprintln!(" - Total matches: {}", match_result.matches.len());
753 eprintln!(
754 " - Confidence threshold: {:.2}",
755 self.config.confidence_threshold
756 );
757 for ai_match in &match_result.matches {
758 eprintln!(
759 " - {} -> {} (confidence: {:.2})",
760 ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
761 );
762 }
763 }
764
765 let mut operations = Vec::new();
767 let mut rejected = Vec::new();
768
769 for ai_match in match_result.matches {
770 let video_match =
771 Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
772 let subtitle_match =
773 Self::find_media_file_by_id_or_path(&subtitles, &ai_match.subtitle_file_id, None);
774
775 if ai_match.confidence < self.config.confidence_threshold {
776 rejected.push(RejectedCandidate {
777 video_path: video_match
778 .map(|v| v.path.display().to_string())
779 .unwrap_or_default(),
780 subtitle_path: subtitle_match
781 .map(|s| s.path.display().to_string())
782 .unwrap_or_default(),
783 confidence: ai_match.confidence,
784 reason: "below_threshold",
785 });
786 continue;
787 }
788
789 match (video_match, subtitle_match) {
790 (Some(video), Some(subtitle)) => {
791 let new_name = self.generate_subtitle_name(video, subtitle);
792
793 let requires_relocation = self.config.relocation_mode
794 != FileRelocationMode::None
795 && subtitle.path.parent() != video.path.parent();
796
797 let relocation_target_path = if requires_relocation {
798 let video_dir = video.path.parent().unwrap();
799 Some(video_dir.join(&new_name))
800 } else {
801 None
802 };
803
804 operations.push(MatchOperation {
805 video_file: (*video).clone(),
806 subtitle_file: (*subtitle).clone(),
807 new_subtitle_name: new_name,
808 confidence: ai_match.confidence,
809 reasoning: ai_match.match_factors,
810 relocation_mode: self.config.relocation_mode.clone(),
811 relocation_target_path,
812 requires_relocation,
813 });
814 }
815 _ => {
816 if !json_mode {
817 eprintln!(
818 "ā ļø Cannot find AI-suggested file pair:\n Video ID: '{}'\n Subtitle ID: '{}'",
819 ai_match.video_file_id, ai_match.subtitle_file_id
820 );
821 }
822 rejected.push(RejectedCandidate {
823 video_path: video_match
824 .map(|v| v.path.display().to_string())
825 .unwrap_or_default(),
826 subtitle_path: subtitle_match
827 .map(|s| s.path.display().to_string())
828 .unwrap_or_default(),
829 confidence: ai_match.confidence,
830 reason: "id_not_found",
831 });
832 }
833 }
834 }
835
836 self.save_file_list_cache(&cache_key, &operations).await?;
838
839 Ok(MatchAudit {
840 operations,
841 rejected,
842 })
843 }
844
845 async fn extract_content_samples(
846 &self,
847 subtitles: &[&MediaFile],
848 ) -> Result<Vec<ContentSample>> {
849 let mut samples = Vec::new();
850
851 for subtitle in subtitles {
852 let path = subtitle.path.clone();
853 crate::core::fs_util::check_file_size(
854 &path,
855 self.config.max_subtitle_bytes,
856 "Subtitle",
857 )
858 .map_err(SubXError::Io)?;
859 let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
860 .await
861 .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
862 let preview = self.create_content_preview(&content);
863
864 samples.push(ContentSample {
865 filename: subtitle.name.clone(),
866 content_preview: preview,
867 file_size: subtitle.size,
868 });
869 }
870
871 Ok(samples)
872 }
873
874 fn create_content_preview(&self, content: &str) -> String {
875 let lines: Vec<&str> = content.lines().take(20).collect();
876 let preview = lines.join("\n");
877
878 if preview.len() > self.config.max_sample_length {
879 format!("{}...", &preview[..self.config.max_sample_length])
880 } else {
881 preview
882 }
883 }
884
885 fn generate_subtitle_name(&self, video: &MediaFile, subtitle: &MediaFile) -> String {
886 let detector = LanguageDetector::new();
887
888 let video_base_name = if !video.extension.is_empty() {
890 video
891 .name
892 .strip_suffix(&format!(".{}", video.extension))
893 .unwrap_or(&video.name)
894 } else {
895 &video.name
896 };
897
898 if let Some(code) = detector.get_primary_language(&subtitle.path) {
899 format!("{}.{}.{}", video_base_name, code, subtitle.extension)
900 } else {
901 format!("{}.{}", video_base_name, subtitle.extension)
902 }
903 }
904
905 pub async fn execute_operations(
914 &self,
915 operations: &[MatchOperation],
916 dry_run: bool,
917 ) -> Result<()> {
918 if dry_run {
919 let json_mode = crate::cli::output::active_mode().is_json();
925 for op in operations {
926 if !json_mode {
927 println!(
928 "Preview: {} -> {}",
929 op.subtitle_file.name, op.new_subtitle_name
930 );
931 }
932 if op.requires_relocation {
933 if let Some(target_path) = &op.relocation_target_path {
934 let operation_verb = match op.relocation_mode {
935 FileRelocationMode::Copy => "Copy",
936 FileRelocationMode::Move => "Move",
937 _ => "",
938 };
939 if !json_mode {
940 println!(
941 "Preview: {} {} to {}",
942 operation_verb,
943 op.subtitle_file.path.display(),
944 target_path.display()
945 );
946 }
947 }
948 }
949 }
950 return Ok(());
951 }
952
953 let created_at = std::time::SystemTime::now()
958 .duration_since(std::time::UNIX_EPOCH)
959 .map(|d| d.as_secs())
960 .unwrap_or(0);
961 let batch_id = {
962 use std::collections::hash_map::DefaultHasher;
963 use std::hash::{Hash, Hasher};
964 let mut hasher = DefaultHasher::new();
965 created_at.hash(&mut hasher);
966 operations.len().hash(&mut hasher);
967 for op in operations {
968 op.subtitle_file.path.hash(&mut hasher);
969 op.new_subtitle_name.hash(&mut hasher);
970 }
971 format!("{:016x}", hasher.finish())
972 };
973 let mut journal = JournalData {
974 batch_id,
975 created_at,
976 entries: Vec::new(),
977 };
978 let journal_file = journal_path().ok();
979
980 let mut first_error: Option<SubXError> = None;
981
982 for op in operations {
983 let mut backup_path: Option<PathBuf> = None;
986
987 if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
988 let backup_task =
989 self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
990 if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
991 backup_path = Some(backup.clone());
992 }
993 if let TaskResult::Failed(err) = backup_task.execute().await {
994 first_error = Some(SubXError::FileOperationFailed(err));
995 break;
996 }
997 }
998
999 let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
1003 self.create_copy_task(op)
1004 } else {
1005 self.create_rename_task(op)
1006 };
1007
1008 let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
1009 {
1010 ProcessingOperation::CopyWithRename { source, target }
1011 | ProcessingOperation::CopyToVideoFolder { source, target } => {
1012 (source.clone(), target.clone(), JournalOperationType::Copied)
1013 }
1014 ProcessingOperation::MoveToVideoFolder { source, target } => {
1015 (source.clone(), target.clone(), JournalOperationType::Moved)
1016 }
1017 ProcessingOperation::RenameFile { source, target } => {
1018 let kind = match op.relocation_mode {
1019 FileRelocationMode::Move => JournalOperationType::Moved,
1020 _ => JournalOperationType::Renamed,
1021 };
1022 (source.clone(), target.clone(), kind)
1023 }
1024 _ => (
1025 op.subtitle_file.path.clone(),
1026 op.relocation_target_path.clone().unwrap_or_else(|| {
1027 op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
1028 }),
1029 JournalOperationType::Renamed,
1030 ),
1031 };
1032
1033 let (pre_file_size, pre_file_mtime) = journal_source
1036 .metadata()
1037 .ok()
1038 .map(|m| {
1039 let mtime = m
1040 .modified()
1041 .ok()
1042 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1043 .map(|d| d.as_secs())
1044 .unwrap_or(0);
1045 (m.len(), mtime)
1046 })
1047 .unwrap_or((0, 0));
1048
1049 if let TaskResult::Failed(err) = primary_task.execute().await {
1050 first_error = Some(SubXError::FileOperationFailed(err));
1051 break;
1052 }
1053
1054 let (file_size, file_mtime) = journal_destination
1057 .metadata()
1058 .ok()
1059 .map(|m| {
1060 let mtime = m
1061 .modified()
1062 .ok()
1063 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1064 .map(|d| d.as_secs())
1065 .unwrap_or(0);
1066 (m.len(), mtime)
1067 })
1068 .unwrap_or((pre_file_size, pre_file_mtime));
1069
1070 journal.entries.push(JournalEntry {
1071 operation_type: journal_kind,
1072 source: journal_source,
1073 destination: journal_destination,
1074 backup_path: backup_path.clone(),
1075 status: JournalEntryStatus::Completed,
1076 file_size,
1077 file_mtime,
1078 });
1079
1080 if let Some(path) = journal_file.as_ref() {
1081 journal.save(path).await?;
1084 }
1085 }
1086
1087 if let Some(err) = first_error {
1088 return Err(err);
1089 }
1090 Ok(())
1091 }
1092
1093 pub async fn execute_operations_audit(
1100 &self,
1101 operations: &[MatchOperation],
1102 dry_run: bool,
1103 ) -> Result<Vec<OperationOutcome>> {
1104 if dry_run {
1105 return Ok(operations
1106 .iter()
1107 .map(|_| OperationOutcome {
1108 applied: false,
1109 error: None,
1110 })
1111 .collect());
1112 }
1113
1114 let created_at = std::time::SystemTime::now()
1115 .duration_since(std::time::UNIX_EPOCH)
1116 .map(|d| d.as_secs())
1117 .unwrap_or(0);
1118 let batch_id = {
1119 use std::collections::hash_map::DefaultHasher;
1120 use std::hash::{Hash, Hasher};
1121 let mut hasher = DefaultHasher::new();
1122 created_at.hash(&mut hasher);
1123 operations.len().hash(&mut hasher);
1124 for op in operations {
1125 op.subtitle_file.path.hash(&mut hasher);
1126 op.new_subtitle_name.hash(&mut hasher);
1127 }
1128 format!("{:016x}", hasher.finish())
1129 };
1130 let mut journal = JournalData {
1131 batch_id,
1132 created_at,
1133 entries: Vec::new(),
1134 };
1135 let journal_file = journal_path().ok();
1136
1137 let mut outcomes = Vec::with_capacity(operations.len());
1138
1139 for op in operations {
1140 let mut backup_path: Option<PathBuf> = None;
1141
1142 if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
1143 let backup_task =
1144 self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
1145 if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
1146 backup_path = Some(backup.clone());
1147 }
1148 if let TaskResult::Failed(err) = backup_task.execute().await {
1149 let err = SubXError::FileOperationFailed(err);
1150 outcomes.push(OperationOutcome {
1151 applied: false,
1152 error: Some(operation_error_from(&err)),
1153 });
1154 continue;
1155 }
1156 }
1157
1158 let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
1159 self.create_copy_task(op)
1160 } else {
1161 self.create_rename_task(op)
1162 };
1163
1164 let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
1165 {
1166 ProcessingOperation::CopyWithRename { source, target }
1167 | ProcessingOperation::CopyToVideoFolder { source, target } => {
1168 (source.clone(), target.clone(), JournalOperationType::Copied)
1169 }
1170 ProcessingOperation::MoveToVideoFolder { source, target } => {
1171 (source.clone(), target.clone(), JournalOperationType::Moved)
1172 }
1173 ProcessingOperation::RenameFile { source, target } => {
1174 let kind = match op.relocation_mode {
1175 FileRelocationMode::Move => JournalOperationType::Moved,
1176 _ => JournalOperationType::Renamed,
1177 };
1178 (source.clone(), target.clone(), kind)
1179 }
1180 _ => (
1181 op.subtitle_file.path.clone(),
1182 op.relocation_target_path.clone().unwrap_or_else(|| {
1183 op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
1184 }),
1185 JournalOperationType::Renamed,
1186 ),
1187 };
1188
1189 let (pre_file_size, pre_file_mtime) = journal_source
1190 .metadata()
1191 .ok()
1192 .map(|m| {
1193 let mtime = m
1194 .modified()
1195 .ok()
1196 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1197 .map(|d| d.as_secs())
1198 .unwrap_or(0);
1199 (m.len(), mtime)
1200 })
1201 .unwrap_or((0, 0));
1202
1203 if let TaskResult::Failed(err) = primary_task.execute().await {
1204 let err = SubXError::FileOperationFailed(err);
1205 outcomes.push(OperationOutcome {
1206 applied: false,
1207 error: Some(operation_error_from(&err)),
1208 });
1209 continue;
1210 }
1211
1212 let (file_size, file_mtime) = journal_destination
1213 .metadata()
1214 .ok()
1215 .map(|m| {
1216 let mtime = m
1217 .modified()
1218 .ok()
1219 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1220 .map(|d| d.as_secs())
1221 .unwrap_or(0);
1222 (m.len(), mtime)
1223 })
1224 .unwrap_or((pre_file_size, pre_file_mtime));
1225
1226 journal.entries.push(JournalEntry {
1227 operation_type: journal_kind,
1228 source: journal_source,
1229 destination: journal_destination,
1230 backup_path: backup_path.clone(),
1231 status: JournalEntryStatus::Completed,
1232 file_size,
1233 file_mtime,
1234 });
1235
1236 if let Some(path) = journal_file.as_ref() {
1237 journal.save(path).await?;
1238 }
1239
1240 outcomes.push(OperationOutcome {
1241 applied: true,
1242 error: None,
1243 });
1244 }
1245
1246 Ok(outcomes)
1247 }
1248
1249 async fn rename_file(&self, op: &MatchOperation) -> Result<()> {
1251 let task = self.create_rename_task(op);
1252 match task.execute().await {
1253 TaskResult::Success(_) => Ok(()),
1254 TaskResult::Failed(err) => Err(SubXError::FileOperationFailed(err)),
1255 other => Err(SubXError::FileOperationFailed(format!(
1256 "Unexpected rename result: {:?}",
1257 other
1258 ))),
1259 }
1260 }
1261
1262 fn resolve_filename_conflict(&self, target: std::path::PathBuf) -> Result<std::path::PathBuf> {
1264 if !target.exists() {
1265 return Ok(target);
1266 }
1267 let json_mode = crate::cli::output::active_mode().is_json();
1268 match self.config.conflict_resolution {
1269 ConflictResolution::Skip => {
1270 if !json_mode {
1271 eprintln!(
1272 "Warning: Skipping relocation due to existing file: {}",
1273 target.display()
1274 );
1275 }
1276 Ok(target)
1277 }
1278 ConflictResolution::AutoRename => {
1279 let file_stem = target
1280 .file_stem()
1281 .and_then(|s| s.to_str())
1282 .unwrap_or("file");
1283 let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
1284 let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
1285 match crate::core::fs_util::atomic_create_file(&target) {
1289 Ok(_f) => {
1290 let _ = std::fs::remove_file(&target);
1292 return Ok(target);
1293 }
1294 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
1295 Err(e) => return Err(SubXError::from(e)),
1296 }
1297 for i in 1..1000 {
1298 let new_name = if extension.is_empty() {
1299 format!("{}.{}", file_stem, i)
1300 } else {
1301 format!("{}.{}.{}", file_stem, i, extension)
1302 };
1303 let new_path = parent.join(new_name);
1304 match crate::core::fs_util::atomic_create_file(&new_path) {
1305 Ok(_f) => {
1306 let _ = std::fs::remove_file(&new_path);
1307 return Ok(new_path);
1308 }
1309 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
1310 Err(e) => return Err(SubXError::from(e)),
1311 }
1312 }
1313 Err(SubXError::FileOperationFailed(
1314 "Could not resolve filename conflict".to_string(),
1315 ))
1316 }
1317 ConflictResolution::Prompt => {
1318 if !json_mode {
1319 eprintln!(
1320 "Warning: Conflict resolution prompt not implemented, using auto-rename"
1321 );
1322 }
1323 self.resolve_filename_conflict(target)
1324 }
1325 }
1326 }
1327
1328 fn create_copy_task(&self, op: &MatchOperation) -> FileProcessingTask {
1330 let source = op.subtitle_file.path.clone();
1332 let target_base = op.relocation_target_path.clone().unwrap();
1333 let final_target = self.resolve_filename_conflict(target_base).unwrap();
1334 FileProcessingTask::new(
1335 source.clone(),
1336 Some(final_target.clone()),
1337 ProcessingOperation::CopyWithRename {
1338 source,
1339 target: final_target,
1340 },
1341 )
1342 }
1343
1344 fn create_backup_task(&self, source: &std::path::Path, ext: &str) -> FileProcessingTask {
1346 let backup_path = source.with_extension(format!("{}.backup", ext));
1347 FileProcessingTask::new(
1348 source.to_path_buf(),
1349 Some(backup_path.clone()),
1350 ProcessingOperation::CreateBackup {
1351 source: source.to_path_buf(),
1352 backup: backup_path,
1353 },
1354 )
1355 }
1356
1357 fn create_rename_task(&self, op: &MatchOperation) -> FileProcessingTask {
1359 let old = op.subtitle_file.path.clone();
1360 let new_path = if op.requires_relocation && op.relocation_target_path.is_some() {
1362 let target_base = op.relocation_target_path.clone().unwrap();
1363 self.resolve_filename_conflict(target_base).unwrap()
1364 } else {
1365 old.with_file_name(&op.new_subtitle_name)
1366 };
1367
1368 FileProcessingTask::new(
1369 old.clone(),
1370 Some(new_path.clone()),
1371 ProcessingOperation::RenameFile {
1372 source: old,
1373 target: new_path,
1374 },
1375 )
1376 }
1377
1378 fn calculate_file_list_cache_key(&self, file_paths: &[PathBuf]) -> Result<String> {
1380 use std::collections::BTreeMap;
1381 use std::collections::hash_map::DefaultHasher;
1382 use std::hash::{Hash, Hasher};
1383
1384 let mut path_metadata = BTreeMap::new();
1386 for path in file_paths {
1387 if let Ok(metadata) = path.metadata() {
1388 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1389 path_metadata.insert(
1390 canonical.to_string_lossy().to_string(),
1391 (metadata.len(), metadata.modified().ok()),
1392 );
1393 }
1394 }
1395
1396 let config_hash = self.calculate_config_hash()?;
1398
1399 let mut hasher = DefaultHasher::new();
1400 path_metadata.hash(&mut hasher);
1401 config_hash.hash(&mut hasher);
1402
1403 Ok(format!("filelist_{:016x}", hasher.finish()))
1404 }
1405
1406 async fn check_file_list_cache(&self, cache_key: &str) -> Result<Option<Vec<MatchOperation>>> {
1408 let cache_file_path = self.get_cache_file_path()?;
1409 let cache_data = CacheData::load(&cache_file_path).ok();
1410
1411 if let Some(cache_data) = cache_data {
1412 if cache_data.directory == cache_key {
1413 let mut ops = Vec::new();
1415 let mut id_gen = Uuidv7Generator::new();
1416 for item in cache_data.match_operations {
1417 let video_path = PathBuf::from(&item.video_file);
1419 let subtitle_path = PathBuf::from(&item.subtitle_file);
1420
1421 if video_path.exists() && subtitle_path.exists() {
1422 let video_meta = video_path.metadata()?;
1424 let subtitle_meta = subtitle_path.metadata()?;
1425
1426 let video_file = MediaFile {
1427 id: generate_file_id(&mut id_gen),
1428 path: video_path.clone(),
1429 file_type: MediaFileType::Video,
1430 size: video_meta.len(),
1431 name: video_path
1432 .file_name()
1433 .unwrap()
1434 .to_string_lossy()
1435 .to_string(),
1436 extension: video_path
1437 .extension()
1438 .unwrap_or_default()
1439 .to_string_lossy()
1440 .to_lowercase(),
1441 relative_path: video_path
1442 .file_name()
1443 .unwrap()
1444 .to_string_lossy()
1445 .to_string(),
1446 };
1447
1448 let subtitle_file = MediaFile {
1449 id: generate_file_id(&mut id_gen),
1450 path: subtitle_path.clone(),
1451 file_type: MediaFileType::Subtitle,
1452 size: subtitle_meta.len(),
1453 name: subtitle_path
1454 .file_name()
1455 .unwrap()
1456 .to_string_lossy()
1457 .to_string(),
1458 extension: subtitle_path
1459 .extension()
1460 .unwrap_or_default()
1461 .to_string_lossy()
1462 .to_lowercase(),
1463 relative_path: subtitle_path
1464 .file_name()
1465 .unwrap()
1466 .to_string_lossy()
1467 .to_string(),
1468 };
1469
1470 let requires_relocation = self.config.relocation_mode
1472 != FileRelocationMode::None
1473 && subtitle_file.path.parent() != video_file.path.parent();
1474
1475 let relocation_target_path = if requires_relocation {
1476 let video_dir = video_file.path.parent().unwrap();
1477 Some(video_dir.join(&item.new_subtitle_name))
1478 } else {
1479 None
1480 };
1481
1482 ops.push(MatchOperation {
1483 video_file,
1484 subtitle_file,
1485 new_subtitle_name: item.new_subtitle_name,
1486 confidence: item.confidence,
1487 reasoning: item.reasoning,
1488 relocation_mode: self.config.relocation_mode.clone(),
1489 relocation_target_path,
1490 requires_relocation,
1491 });
1492 }
1493 }
1494 return Ok(Some(ops));
1495 }
1496 }
1497 Ok(None)
1498 }
1499
1500 async fn save_file_list_cache(
1502 &self,
1503 cache_key: &str,
1504 operations: &[MatchOperation],
1505 ) -> Result<()> {
1506 let cache_file_path = self.get_cache_file_path()?;
1507 let config_hash = self.calculate_config_hash()?;
1508
1509 let mut cache_items = Vec::new();
1510 for op in operations {
1511 cache_items.push(OpItem {
1512 video_file: op.video_file.path.to_string_lossy().to_string(),
1513 subtitle_file: op.subtitle_file.path.to_string_lossy().to_string(),
1514 new_subtitle_name: op.new_subtitle_name.clone(),
1515 confidence: op.confidence,
1516 reasoning: op.reasoning.clone(),
1517 });
1518 }
1519
1520 let mut snapshot_items = Vec::new();
1522 let mut seen_paths = std::collections::HashSet::new();
1523 for op in operations {
1524 for path in [&op.video_file.path, &op.subtitle_file.path] {
1525 let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
1526 let key = canonical.to_string_lossy().to_string();
1527 if seen_paths.insert(key.clone()) {
1528 if let Ok(meta) = std::fs::metadata(&canonical) {
1529 let mtime = meta
1530 .modified()
1531 .ok()
1532 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1533 .map(|d| d.as_secs())
1534 .unwrap_or(0);
1535 snapshot_items.push(SnapshotItem {
1536 path: key,
1537 name: canonical
1538 .file_name()
1539 .unwrap_or_default()
1540 .to_string_lossy()
1541 .to_string(),
1542 size: meta.len(),
1543 mtime,
1544 file_type: if canonical.extension().is_some_and(|e| {
1545 ["srt", "ass", "ssa", "vtt", "sub"]
1546 .contains(&e.to_string_lossy().to_lowercase().as_str())
1547 }) {
1548 "subtitle".to_string()
1549 } else {
1550 "video".to_string()
1551 },
1552 });
1553 }
1554 }
1555 }
1556 }
1557
1558 let cache_data = CacheData {
1559 cache_version: "1.0".to_string(),
1560 directory: cache_key.to_string(),
1561 file_snapshot: snapshot_items,
1562 match_operations: cache_items,
1563 created_at: std::time::SystemTime::now()
1564 .duration_since(std::time::UNIX_EPOCH)
1565 .unwrap()
1566 .as_secs(),
1567 ai_model_used: self.config.ai_model.clone(),
1568 config_hash,
1569 original_relocation_mode: format!("{:?}", self.config.relocation_mode),
1570 original_backup_enabled: self.config.backup_enabled,
1571 };
1572
1573 let cache_dir = cache_file_path.parent().unwrap().to_path_buf();
1575 let cache_json = serde_json::to_string_pretty(&cache_data)?;
1576 let cache_file_path_clone = cache_file_path.clone();
1577 tokio::task::spawn_blocking(move || -> std::io::Result<()> {
1578 std::fs::create_dir_all(&cache_dir)?;
1579 std::fs::write(&cache_file_path_clone, cache_json)?;
1580 Ok(())
1581 })
1582 .await
1583 .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
1584
1585 Ok(())
1586 }
1587
1588 fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
1590 let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
1592 std::path::PathBuf::from(xdg_config)
1593 } else {
1594 dirs::config_dir()
1595 .ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
1596 };
1597 Ok(dir.join("subx").join("match_cache.json"))
1598 }
1599
1600 fn calculate_config_hash(&self) -> Result<String> {
1602 use std::collections::hash_map::DefaultHasher;
1603 use std::hash::{Hash, Hasher};
1604
1605 let mut hasher = DefaultHasher::new();
1606 format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
1608 self.config.backup_enabled.hash(&mut hasher);
1609 Ok(format!("{:016x}", hasher.finish()))
1612 }
1613
1614 fn find_media_file_by_id_or_path<'a>(
1616 files: &'a [&MediaFile],
1617 file_id: &str,
1618 fallback_path: Option<&str>,
1619 ) -> Option<&'a MediaFile> {
1620 if let Some(file) = files.iter().find(|f| f.id == file_id) {
1621 return Some(*file);
1622 }
1623 if let Some(path) = fallback_path {
1624 if let Some(file) = files.iter().find(|f| f.relative_path == path) {
1625 return Some(*file);
1626 }
1627 files.iter().find(|f| f.name == path).copied()
1628 } else {
1629 None
1630 }
1631 }
1632
1633 fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
1635 if crate::cli::output::active_mode().is_json() {
1636 return;
1637 }
1638 eprintln!(" Available {} files:", file_type);
1639 for f in files {
1640 eprintln!(
1641 " - ID: {} | Name: {} | Path: {}",
1642 f.id, f.name, f.relative_path
1643 );
1644 }
1645 }
1646
1647 fn log_no_matches_found(
1649 &self,
1650 match_result: &MatchResult,
1651 videos: &[MediaFile],
1652 subtitles: &[MediaFile],
1653 ) {
1654 if crate::cli::output::active_mode().is_json() {
1655 return;
1656 }
1657 eprintln!("\nā No matching files found that meet the criteria");
1658 eprintln!("š AI analysis results:");
1659 eprintln!(" - Total matches: {}", match_result.matches.len());
1660 eprintln!(
1661 " - Confidence threshold: {:.2}",
1662 self.config.confidence_threshold
1663 );
1664 eprintln!(
1665 " - Matches meeting threshold: {}",
1666 match_result
1667 .matches
1668 .iter()
1669 .filter(|m| m.confidence >= self.config.confidence_threshold)
1670 .count()
1671 );
1672 eprintln!("\nš Scanned files:");
1673 eprintln!(" Video files ({} files):", videos.len());
1674 for v in videos {
1675 eprintln!(" - ID: {} | {}", v.id, v.relative_path);
1676 }
1677 eprintln!(" Subtitle files ({} files):", subtitles.len());
1678 for s in subtitles {
1679 eprintln!(" - ID: {} | {}", s.id, s.relative_path);
1680 }
1681 }
1682}
1683
1684pub async fn apply_cached_operations(cache: &CacheData, config: &MatchConfig) -> Result<()> {
1702 let operations = reconstruct_operations_from_cache(cache, config)?;
1703 let engine = MatchEngine::new(Box::new(NoOpAIProvider), config.clone());
1704 engine.execute_operations(&operations, false).await
1705}
1706
1707fn reconstruct_operations_from_cache(
1713 cache: &CacheData,
1714 config: &MatchConfig,
1715) -> Result<Vec<MatchOperation>> {
1716 let mut ops = Vec::new();
1717 let mut id_gen = Uuidv7Generator::new();
1718 for item in &cache.match_operations {
1719 let video_path = PathBuf::from(&item.video_file);
1720 let subtitle_path = PathBuf::from(&item.subtitle_file);
1721
1722 if !video_path.exists() || !subtitle_path.exists() {
1723 continue;
1724 }
1725
1726 let video_meta = video_path.metadata()?;
1727 let subtitle_meta = subtitle_path.metadata()?;
1728
1729 let video_file = MediaFile {
1730 id: generate_file_id(&mut id_gen),
1731 path: video_path.clone(),
1732 file_type: MediaFileType::Video,
1733 size: video_meta.len(),
1734 name: video_path
1735 .file_name()
1736 .unwrap_or_default()
1737 .to_string_lossy()
1738 .to_string(),
1739 extension: video_path
1740 .extension()
1741 .unwrap_or_default()
1742 .to_string_lossy()
1743 .to_lowercase(),
1744 relative_path: video_path
1745 .file_name()
1746 .unwrap_or_default()
1747 .to_string_lossy()
1748 .to_string(),
1749 };
1750
1751 let subtitle_file = MediaFile {
1752 id: generate_file_id(&mut id_gen),
1753 path: subtitle_path.clone(),
1754 file_type: MediaFileType::Subtitle,
1755 size: subtitle_meta.len(),
1756 name: subtitle_path
1757 .file_name()
1758 .unwrap_or_default()
1759 .to_string_lossy()
1760 .to_string(),
1761 extension: subtitle_path
1762 .extension()
1763 .unwrap_or_default()
1764 .to_string_lossy()
1765 .to_lowercase(),
1766 relative_path: subtitle_path
1767 .file_name()
1768 .unwrap_or_default()
1769 .to_string_lossy()
1770 .to_string(),
1771 };
1772
1773 let requires_relocation = config.relocation_mode != FileRelocationMode::None
1774 && subtitle_file.path.parent() != video_file.path.parent();
1775 let relocation_target_path = if requires_relocation {
1776 video_file
1777 .path
1778 .parent()
1779 .map(|p| p.join(&item.new_subtitle_name))
1780 } else {
1781 None
1782 };
1783
1784 ops.push(MatchOperation {
1785 video_file,
1786 subtitle_file,
1787 new_subtitle_name: item.new_subtitle_name.clone(),
1788 confidence: item.confidence,
1789 reasoning: item.reasoning.clone(),
1790 relocation_mode: config.relocation_mode.clone(),
1791 relocation_target_path,
1792 requires_relocation,
1793 });
1794 }
1795 Ok(ops)
1796}
1797
1798struct NoOpAIProvider;
1804
1805#[async_trait::async_trait]
1806impl AIProvider for NoOpAIProvider {
1807 async fn analyze_content(&self, _request: AnalysisRequest) -> crate::Result<MatchResult> {
1808 Err(SubXError::config(
1809 "AI analysis is not available while replaying cached operations",
1810 ))
1811 }
1812
1813 async fn verify_match(
1814 &self,
1815 _verification: crate::services::ai::VerificationRequest,
1816 ) -> crate::Result<crate::services::ai::ConfidenceScore> {
1817 Err(SubXError::config(
1818 "AI verification is not available while replaying cached operations",
1819 ))
1820 }
1821}