1use crate::services::ai::{AIProvider, AnalysisRequest, ContentSample, MatchResult};
16use std::path::PathBuf;
17
18use crate::Result;
19use crate::core::input::CollectedFiles;
20use crate::core::language::LanguageDetector;
21use crate::core::matcher::cache::{CacheData, OpItem, SnapshotItem};
22use crate::core::matcher::discovery::generate_file_id;
23use crate::core::matcher::journal::{
24 JournalData, JournalEntry, JournalEntryStatus, JournalOperationType, journal_path,
25};
26use crate::core::matcher::{FileDiscovery, MediaFile, MediaFileType};
27use crate::core::parallel::{FileProcessingTask, ProcessingOperation, Task, TaskResult};
28use crate::core::report::ProgressEvent;
29use crate::core::uuidv7::Uuidv7Generator;
30use crate::error::SubXError;
31use dirs;
32use serde_json;
33
34pub(crate) const CURRENT_CACHE_VERSION: &str = "2.0";
41
42pub(crate) fn is_cache_version_current(version: &str) -> bool {
47 version == CURRENT_CACHE_VERSION
48}
49
50pub(crate) fn sanitize_suffix(raw: &str) -> Option<String> {
68 if raw.is_empty() || raw.len() > 16 {
69 return None;
70 }
71 if raw
72 .chars()
73 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
74 {
75 Some(raw.to_string())
76 } else {
77 None
78 }
79}
80
81pub(crate) fn normalize_ai_language(detector: &LanguageDetector, raw: &str) -> Option<String> {
102 let trimmed = raw.trim();
103 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("und") {
104 return None;
105 }
106 let lowered: String = trimmed
107 .chars()
108 .map(|c| {
109 if c.is_ascii_uppercase() {
110 c.to_ascii_lowercase()
111 } else {
112 c
113 }
114 })
115 .collect();
116 let resolved = detector.normalize(&lowered)?;
117 sanitize_suffix(&resolved)
118}
119
120pub fn apply_unique_target_paths(operations: &mut [MatchOperation]) {
140 use std::collections::HashSet;
141
142 fn final_target(op: &MatchOperation) -> PathBuf {
143 if let Some(p) = &op.relocation_target_path {
144 p.clone()
145 } else {
146 let parent = op
147 .subtitle_file
148 .path
149 .parent()
150 .unwrap_or_else(|| std::path::Path::new("."));
151 parent.join(&op.new_subtitle_name)
152 }
153 }
154
155 fn split_filename(name: &str) -> (String, String) {
156 if let Some(idx) = name.rfind('.') {
158 if idx > 0 {
159 return (name[..idx].to_string(), name[idx..].to_string());
160 }
161 }
162 (name.to_string(), String::new())
163 }
164
165 fn split_numeric_tail(stem: &str) -> (String, Option<u32>) {
170 if let Some(idx) = stem.rfind('.') {
171 let (head, tail) = (&stem[..idx], &stem[idx + 1..]);
172 if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
173 if let Ok(n) = tail.parse::<u32>() {
174 return (head.to_string(), Some(n));
175 }
176 }
177 }
178 (stem.to_string(), None)
179 }
180
181 let mut indices: Vec<usize> = (0..operations.len()).collect();
184 indices.sort_by(|&a, &b| {
185 let pa = final_target(&operations[a]);
186 let pb = final_target(&operations[b]);
187 let dir_a = pa.parent().map(|p| p.to_path_buf()).unwrap_or_default();
188 let dir_b = pb.parent().map(|p| p.to_path_buf()).unwrap_or_default();
189 dir_a.cmp(&dir_b).then_with(|| {
190 operations[a]
191 .subtitle_file
192 .relative_path
193 .cmp(&operations[b].subtitle_file.relative_path)
194 })
195 });
196
197 let reserved: HashSet<PathBuf> = (0..operations.len())
204 .map(|i| final_target(&operations[i]))
205 .collect();
206
207 let mut claimed: HashSet<PathBuf> = HashSet::new();
208
209 for idx in indices {
210 let candidate = final_target(&operations[idx]);
211 let parent = candidate
212 .parent()
213 .map(|p| p.to_path_buf())
214 .unwrap_or_default();
215 let filename = candidate
216 .file_name()
217 .map(|f| f.to_string_lossy().to_string())
218 .unwrap_or_default();
219 let (stem, ext) = split_filename(&filename);
220 let (base_stem, existing_counter) = split_numeric_tail(&stem);
221 let source_path = operations[idx].subtitle_file.path.clone();
222
223 let is_taken = |p: &PathBuf| -> bool {
231 claimed.contains(p)
232 || (p != &candidate && reserved.contains(p))
233 || (p != &source_path && p.exists())
234 };
235
236 let mut resolved = candidate.clone();
237 let mut resolved_name = filename.clone();
238 if is_taken(&resolved) {
239 let mut counter = existing_counter.map(|n| n + 1).unwrap_or(2).max(2);
240 loop {
241 let new_name = format!("{}.{}{}", base_stem, counter, ext);
242 let probe = parent.join(&new_name);
243 if !is_taken(&probe) {
244 resolved = probe;
245 resolved_name = new_name;
246 break;
247 }
248 counter = counter.saturating_add(1);
249 if counter > 9999 {
250 break;
251 }
252 }
253 }
254
255 let op = &mut operations[idx];
256 op.new_subtitle_name = resolved_name;
257 if op.relocation_target_path.is_some() {
258 op.relocation_target_path = Some(resolved.clone());
259 }
260 claimed.insert(resolved);
261 }
262}
263
264pub fn apply_archive_origin_relocation(
317 operations: &mut [MatchOperation],
318 collected: &CollectedFiles,
319) {
320 for op in operations {
321 if collected.archive_origin(&op.subtitle_file.path).is_some() && !op.requires_relocation {
322 if let Some(video_dir) = op.video_file.path.parent() {
323 op.relocation_target_path = Some(video_dir.join(&op.new_subtitle_name));
324 op.requires_relocation = true;
325 op.relocation_mode = FileRelocationMode::Copy;
326 }
327 }
328 }
329}
330
331#[derive(Debug, Clone, PartialEq)]
333pub enum FileRelocationMode {
334 None,
336 Copy,
338 Move,
340}
341
342#[derive(Debug, Clone)]
344pub enum ConflictResolution {
345 Skip,
347 AutoRename,
349 Prompt,
351}
352
353#[derive(Debug, Clone)]
368pub struct MatchConfig {
369 pub confidence_threshold: f32,
371 pub max_sample_length: usize,
373 pub enable_content_analysis: bool,
375 pub backup_enabled: bool,
377 pub relocation_mode: FileRelocationMode,
379 pub conflict_resolution: ConflictResolution,
381 pub ai_model: String,
383 pub max_subtitle_bytes: u64,
386}
387
388#[cfg(test)]
389mod language_name_tests {
390 use super::*;
391 use crate::core::matcher::discovery::{MediaFile, MediaFileType};
392 use crate::services::ai::{
393 AIProvider, AnalysisRequest, ConfidenceScore, FileMatch, MatchResult, VerificationRequest,
394 };
395 use async_trait::async_trait;
396 use std::path::PathBuf;
397
398 fn legacy_match() -> FileMatch {
399 FileMatch {
400 video_file_id: "v".into(),
401 subtitle_file_id: "s".into(),
402 confidence: 1.0,
403 match_factors: vec![],
404 language: None,
405 target_filename_suffix: None,
406 }
407 }
408
409 fn match_with_language(language: Option<&str>, suffix: Option<&str>) -> FileMatch {
410 FileMatch {
411 video_file_id: "v".into(),
412 subtitle_file_id: "s".into(),
413 confidence: 1.0,
414 match_factors: vec![],
415 language: language.map(|s| s.to_string()),
416 target_filename_suffix: suffix.map(|s| s.to_string()),
417 }
418 }
419
420 struct DummyAI;
421 #[async_trait]
422 impl AIProvider for DummyAI {
423 async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
424 unimplemented!()
425 }
426 async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
427 unimplemented!()
428 }
429 }
430
431 #[test]
432 fn test_generate_subtitle_name_with_directory_language() {
433 let engine = MatchEngine::new(
434 Box::new(DummyAI),
435 MatchConfig {
436 confidence_threshold: 0.0,
437 max_sample_length: 0,
438 enable_content_analysis: false,
439 backup_enabled: false,
440 relocation_mode: FileRelocationMode::None,
441 conflict_resolution: ConflictResolution::Skip,
442 ai_model: "test-model".to_string(),
443 max_subtitle_bytes: 52_428_800,
444 },
445 );
446 let video = MediaFile {
447 id: "".to_string(),
448 relative_path: "".to_string(),
449 path: PathBuf::from("movie01.mp4"),
450 file_type: MediaFileType::Video,
451 size: 0,
452 name: "movie01".to_string(),
453 extension: "mp4".to_string(),
454 };
455 let subtitle = MediaFile {
456 id: "".to_string(),
457 relative_path: "".to_string(),
458 path: PathBuf::from("tc/subtitle01.ass"),
459 file_type: MediaFileType::Subtitle,
460 size: 0,
461 name: "subtitle01".to_string(),
462 extension: "ass".to_string(),
463 };
464 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
465 assert_eq!(new_name, "movie01.tc.ass");
466 }
467
468 #[test]
469 fn test_generate_subtitle_name_with_filename_language() {
470 let engine = MatchEngine::new(
471 Box::new(DummyAI),
472 MatchConfig {
473 confidence_threshold: 0.0,
474 max_sample_length: 0,
475 enable_content_analysis: false,
476 backup_enabled: false,
477 relocation_mode: FileRelocationMode::None,
478 conflict_resolution: ConflictResolution::Skip,
479 ai_model: "test-model".to_string(),
480 max_subtitle_bytes: 52_428_800,
481 },
482 );
483 let video = MediaFile {
484 id: "".to_string(),
485 relative_path: "".to_string(),
486 path: PathBuf::from("movie02.mp4"),
487 file_type: MediaFileType::Video,
488 size: 0,
489 name: "movie02".to_string(),
490 extension: "mp4".to_string(),
491 };
492 let subtitle = MediaFile {
493 id: "".to_string(),
494 relative_path: "".to_string(),
495 path: PathBuf::from("subtitle02.sc.ass"),
496 file_type: MediaFileType::Subtitle,
497 size: 0,
498 name: "subtitle02".to_string(),
499 extension: "ass".to_string(),
500 };
501 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
502 assert_eq!(new_name, "movie02.sc.ass");
503 }
504
505 #[test]
506 fn test_generate_subtitle_name_without_language() {
507 let engine = MatchEngine::new(
508 Box::new(DummyAI),
509 MatchConfig {
510 confidence_threshold: 0.0,
511 max_sample_length: 0,
512 enable_content_analysis: false,
513 backup_enabled: false,
514 relocation_mode: FileRelocationMode::None,
515 conflict_resolution: ConflictResolution::Skip,
516 ai_model: "test-model".to_string(),
517 max_subtitle_bytes: 52_428_800,
518 },
519 );
520 let video = MediaFile {
521 id: "".to_string(),
522 relative_path: "".to_string(),
523 path: PathBuf::from("movie03.mp4"),
524 file_type: MediaFileType::Video,
525 size: 0,
526 name: "movie03".to_string(),
527 extension: "mp4".to_string(),
528 };
529 let subtitle = MediaFile {
530 id: "".to_string(),
531 relative_path: "".to_string(),
532 path: PathBuf::from("subtitle03.ass"),
533 file_type: MediaFileType::Subtitle,
534 size: 0,
535 name: "subtitle03".to_string(),
536 extension: "ass".to_string(),
537 };
538 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
539 assert_eq!(new_name, "movie03.ass");
540 }
541 #[test]
542 fn test_generate_subtitle_name_removes_video_extension() {
543 let engine = MatchEngine::new(
544 Box::new(DummyAI),
545 MatchConfig {
546 confidence_threshold: 0.0,
547 max_sample_length: 0,
548 enable_content_analysis: false,
549 backup_enabled: false,
550 relocation_mode: FileRelocationMode::None,
551 conflict_resolution: ConflictResolution::Skip,
552 ai_model: "test-model".to_string(),
553 max_subtitle_bytes: 52_428_800,
554 },
555 );
556 let video = MediaFile {
557 id: "".to_string(),
558 relative_path: "".to_string(),
559 path: PathBuf::from("movie.mkv"),
560 file_type: MediaFileType::Video,
561 size: 0,
562 name: "movie.mkv".to_string(),
563 extension: "mkv".to_string(),
564 };
565 let subtitle = MediaFile {
566 id: "".to_string(),
567 relative_path: "".to_string(),
568 path: PathBuf::from("subtitle.srt"),
569 file_type: MediaFileType::Subtitle,
570 size: 0,
571 name: "subtitle".to_string(),
572 extension: "srt".to_string(),
573 };
574 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
575 assert_eq!(new_name, "movie.srt");
576 }
577
578 #[test]
579 fn test_generate_subtitle_name_with_language_removes_video_extension() {
580 let engine = MatchEngine::new(
581 Box::new(DummyAI),
582 MatchConfig {
583 confidence_threshold: 0.0,
584 max_sample_length: 0,
585 enable_content_analysis: false,
586 backup_enabled: false,
587 relocation_mode: FileRelocationMode::None,
588 conflict_resolution: ConflictResolution::Skip,
589 ai_model: "test-model".to_string(),
590 max_subtitle_bytes: 52_428_800,
591 },
592 );
593 let video = MediaFile {
594 id: "".to_string(),
595 relative_path: "".to_string(),
596 path: PathBuf::from("movie.mkv"),
597 file_type: MediaFileType::Video,
598 size: 0,
599 name: "movie.mkv".to_string(),
600 extension: "mkv".to_string(),
601 };
602 let subtitle = MediaFile {
603 id: "".to_string(),
604 relative_path: "".to_string(),
605 path: PathBuf::from("tc/subtitle.srt"),
606 file_type: MediaFileType::Subtitle,
607 size: 0,
608 name: "subtitle".to_string(),
609 extension: "srt".to_string(),
610 };
611 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
612 assert_eq!(new_name, "movie.tc.srt");
613 }
614
615 #[test]
616 fn test_generate_subtitle_name_edge_cases() {
617 let engine = MatchEngine::new(
618 Box::new(DummyAI),
619 MatchConfig {
620 confidence_threshold: 0.0,
621 max_sample_length: 0,
622 enable_content_analysis: false,
623 backup_enabled: false,
624 relocation_mode: FileRelocationMode::None,
625 conflict_resolution: ConflictResolution::Skip,
626 ai_model: "test-model".to_string(),
627 max_subtitle_bytes: 52_428_800,
628 },
629 );
630 let video = MediaFile {
632 id: "".to_string(),
633 relative_path: "".to_string(),
634 path: PathBuf::from("a.b.c"),
635 file_type: MediaFileType::Video,
636 size: 0,
637 name: "a.b.c".to_string(),
638 extension: "".to_string(),
639 };
640 let subtitle = MediaFile {
641 id: "".to_string(),
642 relative_path: "".to_string(),
643 path: PathBuf::from("sub.srt"),
644 file_type: MediaFileType::Subtitle,
645 size: 0,
646 name: "sub".to_string(),
647 extension: "srt".to_string(),
648 };
649 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
650 assert_eq!(new_name, "a.b.c.srt");
651 }
652
653 fn make_engine() -> MatchEngine {
654 MatchEngine::new(
655 Box::new(DummyAI),
656 MatchConfig {
657 confidence_threshold: 0.0,
658 max_sample_length: 0,
659 enable_content_analysis: false,
660 backup_enabled: false,
661 relocation_mode: FileRelocationMode::None,
662 conflict_resolution: ConflictResolution::Skip,
663 ai_model: "test-model".to_string(),
664 max_subtitle_bytes: 52_428_800,
665 },
666 )
667 }
668
669 fn media(path: &str, name: &str, ext: &str, ty: MediaFileType) -> MediaFile {
670 MediaFile {
671 id: "".into(),
672 relative_path: path.into(),
673 path: PathBuf::from(path),
674 file_type: ty,
675 size: 0,
676 name: name.into(),
677 extension: ext.into(),
678 }
679 }
680
681 #[test]
682 fn test_generate_subtitle_name_ai_suffix_wins() {
683 let engine = make_engine();
684 let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
685 let subtitle = media("tc/subs.srt", "subs", "srt", MediaFileType::Subtitle);
686 let m = match_with_language(Some("en"), Some("tc"));
687 assert_eq!(
688 engine.generate_subtitle_name(&video, &subtitle, &m),
689 "movie.tc.srt"
690 );
691 }
692
693 #[test]
694 fn test_generate_subtitle_name_ai_language_used() {
695 let engine = make_engine();
696 let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
697 let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
698 let m = match_with_language(Some("ja"), None);
699 assert_eq!(
700 engine.generate_subtitle_name(&video, &subtitle, &m),
701 "movie.ja.srt"
702 );
703 }
704
705 #[test]
706 fn test_generate_subtitle_name_language_synonym_normalized() {
707 let engine = make_engine();
708 let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
709 let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
710 for variant in &["english", "eng", "EN"] {
711 let m = match_with_language(Some(variant), None);
712 assert_eq!(
713 engine.generate_subtitle_name(&video, &subtitle, &m),
714 "movie.en.srt",
715 "variant {variant} should normalize to en"
716 );
717 }
718 }
719
720 #[test]
721 fn test_generate_subtitle_name_und_collapses_to_no_tag() {
722 let engine = make_engine();
723 let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
724 let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
725 let m = match_with_language(Some("und"), None);
726 assert_eq!(
727 engine.generate_subtitle_name(&video, &subtitle, &m),
728 "movie.srt"
729 );
730 }
731
732 #[test]
733 fn test_generate_subtitle_name_sanitization_drops_path_traversal() {
734 let engine = make_engine();
735 let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
736 let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
737 let m = match_with_language(None, Some("../etc"));
741 assert_eq!(
742 engine.generate_subtitle_name(&video, &subtitle, &m),
743 "movie.srt"
744 );
745 }
746
747 #[test]
748 fn test_sanitize_suffix_helper() {
749 assert_eq!(super::sanitize_suffix(""), None);
750 assert_eq!(super::sanitize_suffix("../"), None);
751 assert_eq!(super::sanitize_suffix("../etc"), None);
753 assert_eq!(super::sanitize_suffix("a-b_c"), Some("a-b_c".into()));
754 assert_eq!(super::sanitize_suffix("繁中"), None);
755 assert_eq!(super::sanitize_suffix("0123456789abcdefGHIJKL"), None);
758 assert_eq!(
759 super::sanitize_suffix("0123456789abcdef"),
760 Some("0123456789abcdef".into())
761 );
762 }
763
764 #[test]
765 fn test_normalize_ai_language_helper() {
766 let det = LanguageDetector::new();
767 assert_eq!(
768 super::normalize_ai_language(&det, "english"),
769 Some("en".into())
770 );
771 assert_eq!(super::normalize_ai_language(&det, "ENG"), Some("en".into()));
772 assert_eq!(super::normalize_ai_language(&det, "EN"), Some("en".into()));
773 assert_eq!(super::normalize_ai_language(&det, "und"), None);
774 assert_eq!(super::normalize_ai_language(&det, "UND"), None);
775 assert_eq!(super::normalize_ai_language(&det, ""), None);
776 assert_eq!(super::normalize_ai_language(&det, "cht"), Some("tc".into()));
777 assert_eq!(super::normalize_ai_language(&det, "chs"), Some("sc".into()));
778 assert_eq!(
780 super::normalize_ai_language(&det, "繁中"),
781 Some("tc".into())
782 );
783 assert_eq!(
784 super::normalize_ai_language(&det, "简中"),
785 Some("sc".into())
786 );
787 assert_eq!(
789 super::normalize_ai_language(&det, "traditional-chinese"),
790 Some("tc".into())
791 );
792 assert_eq!(
793 super::normalize_ai_language(&det, "Traditional_Chinese"),
794 Some("tc".into())
795 );
796 assert_eq!(
797 super::normalize_ai_language(&det, "zh-Hant"),
798 Some("tc".into())
799 );
800 assert_eq!(
801 super::normalize_ai_language(&det, "zh_hans"),
802 Some("sc".into())
803 );
804 assert_eq!(super::normalize_ai_language(&det, "vi"), Some("vi".into()));
806 assert_eq!(super::normalize_ai_language(&det, "ID"), Some("id".into()));
807 }
808
809 fn op(parent: &str, name: &str, sub_relpath: &str, relocate: bool) -> MatchOperation {
810 let video_path = PathBuf::from(parent).join("movie.mkv");
811 let subtitle_path = PathBuf::from(sub_relpath);
812 let relocation_target_path = if relocate {
813 Some(PathBuf::from(parent).join(name))
814 } else {
815 None
816 };
817 MatchOperation {
818 video_file: MediaFile {
819 id: "v".into(),
820 relative_path: video_path.to_string_lossy().to_string(),
821 path: video_path,
822 file_type: MediaFileType::Video,
823 size: 0,
824 name: "movie.mkv".into(),
825 extension: "mkv".into(),
826 },
827 subtitle_file: MediaFile {
828 id: "s".into(),
829 relative_path: sub_relpath.into(),
830 path: subtitle_path,
831 file_type: MediaFileType::Subtitle,
832 size: 0,
833 name: name.into(),
834 extension: "srt".into(),
835 },
836 new_subtitle_name: name.into(),
837 confidence: 1.0,
838 reasoning: vec![],
839 relocation_mode: if relocate {
840 FileRelocationMode::Copy
841 } else {
842 FileRelocationMode::None
843 },
844 relocation_target_path,
845 requires_relocation: relocate,
846 }
847 }
848
849 #[test]
850 fn test_unique_target_paths_two_duplicates_same_dir() {
851 let mut ops = vec![
852 op("/d", "movie.srt", "/d/a.srt", false),
853 op("/d", "movie.srt", "/d/b.srt", false),
854 ];
855 super::apply_unique_target_paths(&mut ops);
856 assert_eq!(ops[0].new_subtitle_name, "movie.srt");
857 assert_eq!(ops[1].new_subtitle_name, "movie.2.srt");
858 }
859
860 #[test]
861 fn test_unique_target_paths_three_way_with_existing_two() {
862 let mut ops = vec![
869 op("/d", "movie.srt", "/d/a.srt", false),
870 op("/d", "movie.srt", "/d/b.srt", false),
871 op("/d", "movie.2.srt", "/d/c.srt", false),
872 ];
873 super::apply_unique_target_paths(&mut ops);
874 assert_eq!(ops[0].new_subtitle_name, "movie.srt");
875 assert_eq!(ops[1].new_subtitle_name, "movie.3.srt");
876 assert_eq!(ops[2].new_subtitle_name, "movie.2.srt");
877 }
878
879 #[test]
880 fn test_unique_target_paths_idempotent() {
881 let mut ops = vec![
885 op("/d", "movie.srt", "/d/a.srt", false),
886 op("/d", "movie.srt", "/d/b.srt", false),
887 op("/d", "movie.2.srt", "/d/c.srt", false),
888 ];
889 super::apply_unique_target_paths(&mut ops);
890 let snapshot: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
891 super::apply_unique_target_paths(&mut ops);
892 let after: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
893 assert_eq!(snapshot, after);
894 }
895
896 #[test]
897 fn test_unique_target_paths_two_languages_preserved() {
898 let mut ops = vec![
899 op("/d", "movie.tc.srt", "/d/a.srt", false),
900 op("/d", "movie.sc.srt", "/d/b.srt", false),
901 ];
902 super::apply_unique_target_paths(&mut ops);
903 assert_eq!(ops[0].new_subtitle_name, "movie.tc.srt");
904 assert_eq!(ops[1].new_subtitle_name, "movie.sc.srt");
905 }
906
907 #[test]
908 fn test_unique_target_paths_cross_video_collision_under_copy() {
909 let mut ops = vec![
912 op("/shared", "subs.srt", "/src1/subs.srt", true),
913 op("/shared", "subs.srt", "/src2/subs.srt", true),
914 ];
915 super::apply_unique_target_paths(&mut ops);
916 assert_eq!(ops[0].new_subtitle_name, "subs.srt");
917 assert_eq!(ops[1].new_subtitle_name, "subs.2.srt");
918 assert_eq!(
920 ops[0].relocation_target_path.as_ref().unwrap(),
921 &PathBuf::from("/shared/subs.srt")
922 );
923 assert_eq!(
924 ops[1].relocation_target_path.as_ref().unwrap(),
925 &PathBuf::from("/shared/subs.2.srt")
926 );
927 }
928
929 #[test]
930 fn test_unique_target_paths_archive_origin_relocation_unique() {
931 let mut ops = vec![
934 op("/videos", "movie.srt", "/tmp/a.srt", true),
935 op("/videos", "movie.srt", "/tmp/b.srt", true),
936 ];
937 super::apply_unique_target_paths(&mut ops);
938 assert_eq!(ops[0].new_subtitle_name, "movie.srt");
939 assert_eq!(ops[1].new_subtitle_name, "movie.2.srt");
940 }
941
942 fn collected_with_origin(temp_root: &str, archive: &str) -> crate::core::input::CollectedFiles {
943 let mut origins = std::collections::HashMap::new();
944 origins.insert(PathBuf::from(temp_root), PathBuf::from(archive));
945 crate::core::input::CollectedFiles::with_archives(Vec::new(), Vec::new(), origins)
946 }
947
948 #[test]
949 fn test_archive_origin_relocation_forces_copy_into_video_dir() {
950 let mut ops = vec![op(
954 "/videos",
955 "movie.srt",
956 "/tmp/subx-XXXX/movie.srt",
957 false,
958 )];
959 let collected = collected_with_origin("/tmp/subx-XXXX", "/data/subs.zip");
960 super::apply_archive_origin_relocation(&mut ops, &collected);
961 assert!(ops[0].requires_relocation);
962 assert_eq!(ops[0].relocation_mode, FileRelocationMode::Copy);
963 assert_eq!(
964 ops[0].relocation_target_path.as_ref().unwrap(),
965 &PathBuf::from("/videos/movie.srt")
966 );
967 }
968
969 #[test]
970 fn test_archive_origin_relocation_leaves_direct_subtitle_untouched() {
971 let mut ops = vec![op("/videos", "movie.srt", "/videos/movie.srt", false)];
972 let before = (
973 ops[0].requires_relocation,
974 ops[0].relocation_mode.clone(),
975 ops[0].relocation_target_path.clone(),
976 );
977 let collected = collected_with_origin("/tmp/subx-XXXX", "/data/subs.zip");
978 super::apply_archive_origin_relocation(&mut ops, &collected);
979 assert_eq!(
980 (
981 ops[0].requires_relocation,
982 ops[0].relocation_mode.clone(),
983 ops[0].relocation_target_path.clone()
984 ),
985 before
986 );
987 }
988
989 #[test]
990 fn test_archive_origin_relocation_respects_existing_relocation() {
991 let mut ops = vec![op("/videos", "movie.srt", "/tmp/subx-XXXX/movie.srt", true)];
994 ops[0].relocation_mode = FileRelocationMode::Move;
995 ops[0].relocation_target_path = Some(PathBuf::from("/elsewhere/movie.srt"));
996 let collected = collected_with_origin("/tmp/subx-XXXX", "/data/subs.zip");
997 super::apply_archive_origin_relocation(&mut ops, &collected);
998 assert_eq!(ops[0].relocation_mode, FileRelocationMode::Move);
999 assert_eq!(
1000 ops[0].relocation_target_path.as_ref().unwrap(),
1001 &PathBuf::from("/elsewhere/movie.srt")
1002 );
1003 }
1004
1005 #[test]
1006 fn test_archive_origin_relocation_leaves_parentless_video_untouched() {
1007 let mut archive_subtitle = op("/videos", "movie.srt", "/tmp/subx-XXXX/movie.srt", false);
1011 archive_subtitle.video_file.path = PathBuf::new();
1012 let mut ops = vec![archive_subtitle];
1013 let collected = collected_with_origin("/tmp/subx-XXXX", "/data/subs.zip");
1014 super::apply_archive_origin_relocation(&mut ops, &collected);
1015 assert!(!ops[0].requires_relocation);
1016 assert!(ops[0].relocation_target_path.is_none());
1017 assert_eq!(ops[0].relocation_mode, FileRelocationMode::None);
1018 }
1019
1020 #[test]
1021 fn test_archive_origin_relocation_before_allocator_yields_unique_targets() {
1022 let mut ops = vec![
1027 op("/videos", "movie.srt", "/tmp/subx-XXXX/one.srt", false),
1028 op("/videos", "movie.srt", "/tmp/subx-XXXX/two.srt", false),
1029 ];
1030 let collected = collected_with_origin("/tmp/subx-XXXX", "/data/subs.zip");
1031 super::apply_archive_origin_relocation(&mut ops, &collected);
1032 super::apply_unique_target_paths(&mut ops);
1033 assert_eq!(ops[0].new_subtitle_name, "movie.srt");
1034 assert_eq!(ops[1].new_subtitle_name, "movie.2.srt");
1035 assert_eq!(
1036 ops[1].relocation_target_path.as_ref().unwrap(),
1037 &PathBuf::from("/videos/movie.2.srt")
1038 );
1039 }
1040
1041 #[test]
1042 fn test_unique_target_paths_skip_existing_on_disk() {
1043 let dir = tempfile::tempdir().unwrap();
1050 let dir_path = dir.path().to_path_buf();
1051 std::fs::write(dir_path.join("movie.srt"), b"existing").unwrap();
1052 std::fs::write(dir_path.join("movie.1.srt"), b"existing").unwrap();
1053 let dir_str = dir_path.to_string_lossy().to_string();
1054 let src1 = dir_path.parent().unwrap().join("sub1.srt");
1055 let src2 = dir_path.parent().unwrap().join("sub2.srt");
1056 let mut ops = vec![
1057 op(&dir_str, "movie.srt", src1.to_str().unwrap(), true),
1058 op(&dir_str, "movie.srt", src2.to_str().unwrap(), true),
1059 ];
1060 super::apply_unique_target_paths(&mut ops);
1061 let mut got: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
1062 got.sort();
1063 assert_eq!(
1064 got,
1065 vec!["movie.2.srt".to_string(), "movie.3.srt".to_string()]
1066 );
1067 }
1068
1069 #[test]
1070 fn test_legacy_v1_cache_rejected() {
1071 use crate::core::matcher::cache::CacheData;
1081 use std::time::{SystemTime, UNIX_EPOCH};
1082 use tempfile::TempDir;
1083
1084 assert_eq!(CURRENT_CACHE_VERSION, "2.0");
1085 assert!(super::is_cache_version_current("2.0"));
1086 assert!(!super::is_cache_version_current("1.0"));
1087 assert!(!super::is_cache_version_current(""));
1088
1089 let temp = TempDir::new().unwrap();
1090 let cache_path = temp.path().join("legacy_v1_cache.json");
1091 let now = SystemTime::now()
1092 .duration_since(UNIX_EPOCH)
1093 .unwrap()
1094 .as_secs();
1095 let legacy = serde_json::json!({
1096 "cache_version": "1.0",
1097 "directory": "filelist_deadbeef",
1098 "file_snapshot": [],
1099 "match_operations": [],
1100 "created_at": now,
1101 "ai_model_used": "test-model",
1102 "config_hash": "0000000000000000",
1103 "original_relocation_mode": "None",
1104 "original_backup_enabled": false,
1105 });
1106 std::fs::write(&cache_path, serde_json::to_string(&legacy).unwrap()).unwrap();
1107
1108 let loaded = CacheData::load(&cache_path).expect("legacy cache should parse");
1109 assert_eq!(loaded.cache_version, "1.0");
1110 assert!(
1111 !super::is_cache_version_current(&loaded.cache_version),
1112 "loaded v1 cache must be rejected by the version gate"
1113 );
1114 }
1115
1116 #[tokio::test]
1117 async fn test_rename_file_displays_success_check_mark() {
1118 use std::fs;
1119 use tempfile::TempDir;
1120
1121 let temp_dir = TempDir::new().unwrap();
1122 let temp_path = temp_dir.path();
1123
1124 let original_file = temp_path.join("original.srt");
1126 fs::write(
1127 &original_file,
1128 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
1129 )
1130 .unwrap();
1131
1132 let engine = MatchEngine::new(
1134 Box::new(DummyAI),
1135 MatchConfig {
1136 confidence_threshold: 0.0,
1137 max_sample_length: 0,
1138 enable_content_analysis: false,
1139 backup_enabled: false,
1140 relocation_mode: FileRelocationMode::None,
1141 conflict_resolution: ConflictResolution::Skip,
1142 ai_model: "test-model".to_string(),
1143 max_subtitle_bytes: 52_428_800,
1144 },
1145 );
1146
1147 let subtitle_file = MediaFile {
1149 id: "test_id".to_string(),
1150 relative_path: "original.srt".to_string(),
1151 path: original_file.clone(),
1152 file_type: MediaFileType::Subtitle,
1153 size: 40,
1154 name: "original".to_string(),
1155 extension: "srt".to_string(),
1156 };
1157
1158 let match_op = MatchOperation {
1159 video_file: MediaFile {
1160 id: "video_id".to_string(),
1161 relative_path: "test.mp4".to_string(),
1162 path: temp_path.join("test.mp4"),
1163 file_type: MediaFileType::Video,
1164 size: 1000,
1165 name: "test".to_string(),
1166 extension: "mp4".to_string(),
1167 },
1168 subtitle_file,
1169 new_subtitle_name: "renamed.srt".to_string(),
1170 confidence: 95.0,
1171 reasoning: vec!["Test match".to_string()],
1172 requires_relocation: false,
1173 relocation_target_path: None,
1174 relocation_mode: FileRelocationMode::None,
1175 };
1176
1177 let result = engine.rename_file(&match_op).await;
1179
1180 assert!(result.is_ok());
1182
1183 let renamed_file = temp_path.join("renamed.srt");
1185 assert!(renamed_file.exists(), "The renamed file should exist");
1186 assert!(
1187 !original_file.exists(),
1188 "The original file should have been renamed"
1189 );
1190
1191 let content = fs::read_to_string(&renamed_file).unwrap();
1193 assert!(content.contains("Test subtitle"));
1194 }
1195
1196 #[tokio::test]
1197 async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
1198 use std::fs;
1199 use tempfile::TempDir;
1200
1201 let temp_dir = TempDir::new().unwrap();
1202 let temp_path = temp_dir.path();
1203
1204 let original_file = temp_path.join("original.srt");
1206 fs::write(
1207 &original_file,
1208 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
1209 )
1210 .unwrap();
1211
1212 let engine = MatchEngine::new(
1214 Box::new(DummyAI),
1215 MatchConfig {
1216 confidence_threshold: 0.0,
1217 max_sample_length: 0,
1218 enable_content_analysis: false,
1219 backup_enabled: false,
1220 relocation_mode: FileRelocationMode::None,
1221 conflict_resolution: ConflictResolution::Skip,
1222 ai_model: "test-model".to_string(),
1223 max_subtitle_bytes: 52_428_800,
1224 },
1225 );
1226
1227 let subtitle_file = MediaFile {
1229 id: "test_id".to_string(),
1230 relative_path: "original.srt".to_string(),
1231 path: original_file.clone(),
1232 file_type: MediaFileType::Subtitle,
1233 size: 40,
1234 name: "original".to_string(),
1235 extension: "srt".to_string(),
1236 };
1237
1238 let match_op = MatchOperation {
1239 video_file: MediaFile {
1240 id: "video_id".to_string(),
1241 relative_path: "test.mp4".to_string(),
1242 path: temp_path.join("test.mp4"),
1243 file_type: MediaFileType::Video,
1244 size: 1000,
1245 name: "test".to_string(),
1246 extension: "mp4".to_string(),
1247 },
1248 subtitle_file,
1249 new_subtitle_name: "renamed.srt".to_string(),
1250 confidence: 95.0,
1251 reasoning: vec!["Test match".to_string()],
1252 requires_relocation: false,
1253 relocation_target_path: None,
1254 relocation_mode: FileRelocationMode::None,
1255 };
1256
1257 let result = engine.rename_file(&match_op).await;
1260 assert!(result.is_ok());
1261
1262 let renamed_file = temp_path.join("renamed.srt");
1264 if renamed_file.exists() {
1265 fs::remove_file(&renamed_file).unwrap();
1266 }
1267
1268 fs::write(
1270 &original_file,
1271 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
1272 )
1273 .unwrap();
1274
1275 let result = engine.rename_file(&match_op).await;
1279 assert!(result.is_ok());
1280
1281 let renamed_file = temp_path.join("renamed.srt");
1283 if renamed_file.exists() {
1284 fs::remove_file(&renamed_file).unwrap();
1285 }
1286
1287 }
1290
1291 #[test]
1292 fn test_file_operation_message_format() {
1293 let source_name = "test.srt";
1295 let target_name = "renamed.srt";
1296
1297 let success_msg = format!(" ✓ Renamed: {} -> {}", source_name, target_name);
1299 assert!(success_msg.contains("✓"));
1300 assert!(success_msg.contains("Renamed:"));
1301 assert!(success_msg.contains(source_name));
1302 assert!(success_msg.contains(target_name));
1303
1304 let error_msg = format!(
1306 " ✗ Rename failed: {} -> {} (target file does not exist after operation)",
1307 source_name, target_name
1308 );
1309 assert!(error_msg.contains("✗"));
1310 assert!(error_msg.contains("Rename failed:"));
1311 assert!(error_msg.contains("target file does not exist"));
1312 assert!(error_msg.contains(source_name));
1313 assert!(error_msg.contains(target_name));
1314 }
1315
1316 #[test]
1317 fn test_copy_operation_message_format() {
1318 let source_name = "subtitle.srt";
1320 let target_name = "video.srt";
1321
1322 let success_msg = format!(" ✓ Copied: {} -> {}", source_name, target_name);
1324 assert!(success_msg.contains("✓"));
1325 assert!(success_msg.contains("Copied:"));
1326
1327 let error_msg = format!(
1329 " ✗ Copy failed: {} -> {} (target file does not exist after operation)",
1330 source_name, target_name
1331 );
1332 assert!(error_msg.contains("✗"));
1333 assert!(error_msg.contains("Copy failed:"));
1334 assert!(error_msg.contains("target file does not exist"));
1335 }
1336
1337 #[test]
1338 fn test_move_operation_message_format() {
1339 let source_name = "subtitle.srt";
1341 let target_name = "video.srt";
1342
1343 let success_msg = format!(" ✓ Moved: {} -> {}", source_name, target_name);
1345 assert!(success_msg.contains("✓"));
1346 assert!(success_msg.contains("Moved:"));
1347
1348 let error_msg = format!(
1350 " ✗ Move failed: {} -> {} (target file does not exist after operation)",
1351 source_name, target_name
1352 );
1353 assert!(error_msg.contains("✗"));
1354 assert!(error_msg.contains("Move failed:"));
1355 assert!(error_msg.contains("target file does not exist"));
1356 }
1357}
1358
1359#[derive(Debug)]
1364pub struct MatchOperation {
1365 pub video_file: MediaFile,
1367 pub subtitle_file: MediaFile,
1369 pub new_subtitle_name: String,
1371 pub confidence: f32,
1373 pub reasoning: Vec<String>,
1375 pub relocation_mode: FileRelocationMode,
1377 pub relocation_target_path: Option<std::path::PathBuf>,
1379 pub requires_relocation: bool,
1381}
1382
1383#[derive(Debug, Clone)]
1389pub struct RejectedCandidate {
1390 pub video_path: String,
1392 pub subtitle_path: String,
1394 pub confidence: f32,
1396 pub reason: &'static str,
1398}
1399
1400#[derive(Debug)]
1403pub struct MatchAudit {
1404 pub operations: Vec<MatchOperation>,
1406 pub rejected: Vec<RejectedCandidate>,
1408}
1409
1410#[derive(Debug)]
1416pub struct OperationOutcome {
1417 pub applied: bool,
1419 pub error: Option<OperationError>,
1421}
1422
1423#[derive(Debug, Clone)]
1425pub struct OperationError {
1426 pub category: &'static str,
1428 pub code: &'static str,
1430 pub message: String,
1432}
1433
1434fn operation_error_from(err: &SubXError) -> OperationError {
1451 OperationError {
1452 category: err.category(),
1453 code: err.machine_code(),
1454 message: err.to_string(),
1455 }
1456}
1457
1458pub struct MatchEngine {
1460 ai_client: Box<dyn AIProvider>,
1461 discovery: FileDiscovery,
1462 config: MatchConfig,
1463 reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
1464}
1465
1466impl MatchEngine {
1467 pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
1472 Self {
1473 ai_client,
1474 discovery: FileDiscovery::new(),
1475 config,
1476 reporter: crate::core::report::noop(),
1477 }
1478 }
1479
1480 pub fn with_reporter(
1488 mut self,
1489 reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
1490 ) -> Self {
1491 self.reporter = reporter;
1492 self
1493 }
1494
1495 pub async fn match_file_list(&self, file_paths: &[PathBuf]) -> Result<Vec<MatchOperation>> {
1509 Ok(self
1510 .match_file_list_with_audit(file_paths)
1511 .await?
1512 .operations)
1513 }
1514
1515 pub async fn match_file_list_with_audit(&self, file_paths: &[PathBuf]) -> Result<MatchAudit> {
1521 let files = self.discovery.scan_file_list(file_paths)?;
1523
1524 let videos: Vec<_> = files
1525 .iter()
1526 .filter(|f| matches!(f.file_type, MediaFileType::Video))
1527 .collect();
1528 let subtitles: Vec<_> = files
1529 .iter()
1530 .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
1531 .collect();
1532
1533 if videos.is_empty() || subtitles.is_empty() {
1534 return Ok(MatchAudit {
1535 operations: Vec::new(),
1536 rejected: Vec::new(),
1537 });
1538 }
1539
1540 let cache_key = self.calculate_file_list_cache_key(file_paths)?;
1542 if let Some(ops) = self.check_file_list_cache(&cache_key).await? {
1543 return Ok(MatchAudit {
1544 operations: ops,
1545 rejected: Vec::new(),
1546 });
1547 }
1548
1549 let content_samples = if self.config.enable_content_analysis {
1551 self.extract_content_samples(&subtitles).await?
1552 } else {
1553 Vec::new()
1554 };
1555
1556 let video_files: Vec<String> = videos
1558 .iter()
1559 .map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
1560 .collect();
1561 let subtitle_files: Vec<String> = subtitles
1562 .iter()
1563 .map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
1564 .collect();
1565
1566 let analysis_request = AnalysisRequest {
1567 video_files,
1568 subtitle_files,
1569 content_samples,
1570 };
1571
1572 let match_result = self.ai_client.analyze_content(analysis_request).await?;
1574
1575 let mut analysis_block = String::from("🔍 AI Analysis Results:");
1579 analysis_block.push_str(&format!(
1580 "\n - Total matches: {}\n - Confidence threshold: {:.2}",
1581 match_result.matches.len(),
1582 self.config.confidence_threshold
1583 ));
1584 for ai_match in &match_result.matches {
1585 analysis_block.push_str(&format!(
1586 "\n - {} -> {} (confidence: {:.2})",
1587 ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
1588 ));
1589 }
1590 self.reporter.diagnostic(&analysis_block);
1591
1592 let mut operations = Vec::new();
1594 let mut rejected = Vec::new();
1595
1596 for ai_match in match_result.matches {
1597 let video_match =
1598 Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
1599 let subtitle_match =
1600 Self::find_media_file_by_id_or_path(&subtitles, &ai_match.subtitle_file_id, None);
1601
1602 if ai_match.confidence < self.config.confidence_threshold {
1603 rejected.push(RejectedCandidate {
1604 video_path: video_match
1605 .map(|v| v.path.display().to_string())
1606 .unwrap_or_default(),
1607 subtitle_path: subtitle_match
1608 .map(|s| s.path.display().to_string())
1609 .unwrap_or_default(),
1610 confidence: ai_match.confidence,
1611 reason: "below_threshold",
1612 });
1613 continue;
1614 }
1615
1616 match (video_match, subtitle_match) {
1617 (Some(video), Some(subtitle)) => {
1618 let new_name = self.generate_subtitle_name(video, subtitle, &ai_match);
1619
1620 let requires_relocation = self.config.relocation_mode
1621 != FileRelocationMode::None
1622 && subtitle.path.parent() != video.path.parent();
1623
1624 let relocation_target_path = if requires_relocation {
1625 let video_dir = video.path.parent().unwrap();
1626 Some(video_dir.join(&new_name))
1627 } else {
1628 None
1629 };
1630
1631 operations.push(MatchOperation {
1632 video_file: (*video).clone(),
1633 subtitle_file: (*subtitle).clone(),
1634 new_subtitle_name: new_name,
1635 confidence: ai_match.confidence,
1636 reasoning: ai_match.match_factors,
1637 relocation_mode: self.config.relocation_mode.clone(),
1638 relocation_target_path,
1639 requires_relocation,
1640 });
1641 }
1642 _ => {
1643 self.reporter.warn(&format!(
1644 "⚠️ Cannot find AI-suggested file pair:\n Video ID: '{}'\n Subtitle ID: '{}'",
1645 ai_match.video_file_id, ai_match.subtitle_file_id
1646 ));
1647 rejected.push(RejectedCandidate {
1648 video_path: video_match
1649 .map(|v| v.path.display().to_string())
1650 .unwrap_or_default(),
1651 subtitle_path: subtitle_match
1652 .map(|s| s.path.display().to_string())
1653 .unwrap_or_default(),
1654 confidence: ai_match.confidence,
1655 reason: "id_not_found",
1656 });
1657 }
1658 }
1659 }
1660
1661 apply_unique_target_paths(&mut operations);
1667
1668 self.save_file_list_cache(&cache_key, &operations).await?;
1670
1671 Ok(MatchAudit {
1672 operations,
1673 rejected,
1674 })
1675 }
1676
1677 async fn extract_content_samples(
1678 &self,
1679 subtitles: &[&MediaFile],
1680 ) -> Result<Vec<ContentSample>> {
1681 let mut samples = Vec::new();
1682
1683 for subtitle in subtitles {
1684 let path = subtitle.path.clone();
1685 crate::core::fs_util::check_file_size(
1686 &path,
1687 self.config.max_subtitle_bytes,
1688 "Subtitle",
1689 )
1690 .map_err(SubXError::Io)?;
1691 let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
1692 .await
1693 .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
1694 let preview = self.create_content_preview(&content);
1695
1696 samples.push(ContentSample {
1697 filename: subtitle.name.clone(),
1698 subtitle_file_id: subtitle.id.clone(),
1699 content_preview: preview,
1700 file_size: subtitle.size,
1701 });
1702 }
1703
1704 Ok(samples)
1705 }
1706
1707 fn create_content_preview(&self, content: &str) -> String {
1708 let lines: Vec<&str> = content.lines().take(20).collect();
1709 let preview = lines.join("\n");
1710
1711 if preview.len() > self.config.max_sample_length {
1712 format!("{}...", &preview[..self.config.max_sample_length])
1713 } else {
1714 preview
1715 }
1716 }
1717
1718 fn generate_subtitle_name(
1719 &self,
1720 video: &MediaFile,
1721 subtitle: &MediaFile,
1722 ai_match: &crate::services::ai::FileMatch,
1723 ) -> String {
1724 let detector = LanguageDetector::new();
1725
1726 let video_base_name = if !video.extension.is_empty() {
1728 video
1729 .name
1730 .strip_suffix(&format!(".{}", video.extension))
1731 .unwrap_or(&video.name)
1732 } else {
1733 &video.name
1734 };
1735
1736 let ai_tag = ai_match
1742 .target_filename_suffix
1743 .as_deref()
1744 .and_then(sanitize_suffix)
1745 .or_else(|| {
1746 ai_match
1747 .language
1748 .as_deref()
1749 .and_then(|s| normalize_ai_language(&detector, s))
1750 });
1751
1752 let code = ai_tag.or_else(|| detector.get_primary_language(&subtitle.path));
1753
1754 if let Some(code) = code {
1755 format!("{}.{}.{}", video_base_name, code, subtitle.extension)
1756 } else {
1757 format!("{}.{}", video_base_name, subtitle.extension)
1758 }
1759 }
1760
1761 pub async fn execute_operations(
1770 &self,
1771 operations: &[MatchOperation],
1772 dry_run: bool,
1773 ) -> Result<()> {
1774 if dry_run {
1775 for op in operations {
1788 self.reporter.diagnostic(&format!(
1789 "Preview: {} -> {}",
1790 op.subtitle_file.name, op.new_subtitle_name
1791 ));
1792 if op.requires_relocation {
1793 if let Some(target_path) = &op.relocation_target_path {
1794 let operation_verb = match op.relocation_mode {
1795 FileRelocationMode::Copy => "Copy",
1796 FileRelocationMode::Move => "Move",
1797 _ => "",
1798 };
1799 self.reporter.diagnostic(&format!(
1800 "Preview: {} {} to {}",
1801 operation_verb,
1802 op.subtitle_file.path.display(),
1803 target_path.display()
1804 ));
1805 }
1806 }
1807 }
1808 return Ok(());
1809 }
1810
1811 let created_at = std::time::SystemTime::now()
1816 .duration_since(std::time::UNIX_EPOCH)
1817 .map(|d| d.as_secs())
1818 .unwrap_or(0);
1819 let batch_id = {
1820 use std::collections::hash_map::DefaultHasher;
1821 use std::hash::{Hash, Hasher};
1822 let mut hasher = DefaultHasher::new();
1823 created_at.hash(&mut hasher);
1824 operations.len().hash(&mut hasher);
1825 for op in operations {
1826 op.subtitle_file.path.hash(&mut hasher);
1827 op.new_subtitle_name.hash(&mut hasher);
1828 }
1829 format!("{:016x}", hasher.finish())
1830 };
1831 let mut journal = JournalData {
1832 batch_id,
1833 created_at,
1834 entries: Vec::new(),
1835 };
1836 let journal_file = journal_path().ok();
1837
1838 let total = operations.len() as u64;
1846 self.reporter.progress(&ProgressEvent::Started { total });
1847 let mut completed: u64 = 0;
1848
1849 let mut first_error: Option<SubXError> = None;
1850
1851 for op in operations {
1852 let mut backup_path: Option<PathBuf> = None;
1855
1856 if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
1857 let backup_task =
1858 self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
1859 if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
1860 backup_path = Some(backup.clone());
1861 }
1862 if let TaskResult::Failed(err) = backup_task.execute().await {
1863 first_error = Some(SubXError::FileOperationFailed(err));
1864 break;
1865 }
1866 }
1867
1868 let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
1872 self.create_copy_task(op)
1873 } else {
1874 self.create_rename_task(op)
1875 };
1876
1877 let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
1878 {
1879 ProcessingOperation::CopyWithRename { source, target }
1880 | ProcessingOperation::CopyToVideoFolder { source, target } => {
1881 (source.clone(), target.clone(), JournalOperationType::Copied)
1882 }
1883 ProcessingOperation::MoveToVideoFolder { source, target } => {
1884 (source.clone(), target.clone(), JournalOperationType::Moved)
1885 }
1886 ProcessingOperation::RenameFile { source, target } => {
1887 let kind = match op.relocation_mode {
1888 FileRelocationMode::Move => JournalOperationType::Moved,
1889 _ => JournalOperationType::Renamed,
1890 };
1891 (source.clone(), target.clone(), kind)
1892 }
1893 _ => (
1894 op.subtitle_file.path.clone(),
1895 op.relocation_target_path.clone().unwrap_or_else(|| {
1896 op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
1897 }),
1898 JournalOperationType::Renamed,
1899 ),
1900 };
1901
1902 let (pre_file_size, pre_file_mtime) = journal_source
1905 .metadata()
1906 .ok()
1907 .map(|m| {
1908 let mtime = m
1909 .modified()
1910 .ok()
1911 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1912 .map(|d| d.as_secs())
1913 .unwrap_or(0);
1914 (m.len(), mtime)
1915 })
1916 .unwrap_or((0, 0));
1917
1918 if let TaskResult::Failed(err) = primary_task.execute().await {
1919 first_error = Some(SubXError::FileOperationFailed(err));
1920 break;
1921 }
1922
1923 let (file_size, file_mtime) = journal_destination
1926 .metadata()
1927 .ok()
1928 .map(|m| {
1929 let mtime = m
1930 .modified()
1931 .ok()
1932 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1933 .map(|d| d.as_secs())
1934 .unwrap_or(0);
1935 (m.len(), mtime)
1936 })
1937 .unwrap_or((pre_file_size, pre_file_mtime));
1938
1939 journal.entries.push(JournalEntry {
1940 operation_type: journal_kind,
1941 source: journal_source,
1942 destination: journal_destination,
1943 backup_path: backup_path.clone(),
1944 status: JournalEntryStatus::Completed,
1945 file_size,
1946 file_mtime,
1947 });
1948
1949 if let Some(path) = journal_file.as_ref() {
1950 if let Err(e) = journal.save(path).await {
1953 self.reporter.progress(&ProgressEvent::Finished {
1958 done: completed,
1959 total,
1960 });
1961 return Err(e);
1962 }
1963 }
1964
1965 completed += 1;
1966 self.reporter.progress(&ProgressEvent::Advanced {
1967 done: completed,
1968 total,
1969 item: None,
1970 });
1971 }
1972
1973 self.reporter.progress(&ProgressEvent::Finished {
1974 done: completed,
1975 total,
1976 });
1977
1978 if let Some(err) = first_error {
1979 return Err(err);
1980 }
1981 Ok(())
1982 }
1983
1984 pub async fn execute_operations_audit(
1991 &self,
1992 operations: &[MatchOperation],
1993 dry_run: bool,
1994 ) -> Result<Vec<OperationOutcome>> {
1995 if dry_run {
1996 return Ok(operations
1997 .iter()
1998 .map(|_| OperationOutcome {
1999 applied: false,
2000 error: None,
2001 })
2002 .collect());
2003 }
2004
2005 let created_at = std::time::SystemTime::now()
2006 .duration_since(std::time::UNIX_EPOCH)
2007 .map(|d| d.as_secs())
2008 .unwrap_or(0);
2009 let batch_id = {
2010 use std::collections::hash_map::DefaultHasher;
2011 use std::hash::{Hash, Hasher};
2012 let mut hasher = DefaultHasher::new();
2013 created_at.hash(&mut hasher);
2014 operations.len().hash(&mut hasher);
2015 for op in operations {
2016 op.subtitle_file.path.hash(&mut hasher);
2017 op.new_subtitle_name.hash(&mut hasher);
2018 }
2019 format!("{:016x}", hasher.finish())
2020 };
2021 let mut journal = JournalData {
2022 batch_id,
2023 created_at,
2024 entries: Vec::new(),
2025 };
2026 let journal_file = journal_path().ok();
2027
2028 let mut outcomes = Vec::with_capacity(operations.len());
2029
2030 let total = operations.len() as u64;
2047 self.reporter.progress(&ProgressEvent::Started { total });
2048
2049 for op in operations {
2050 if self.reporter.cancelled() {
2051 let done = outcomes.len() as u64;
2052 outcomes.resize_with(operations.len(), || OperationOutcome {
2053 applied: false,
2054 error: None,
2055 });
2056 self.reporter
2057 .progress(&ProgressEvent::Finished { done, total });
2058 return Ok(outcomes);
2059 }
2060
2061 let mut backup_path: Option<PathBuf> = None;
2062
2063 if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
2064 let backup_task =
2065 self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
2066 if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
2067 backup_path = Some(backup.clone());
2068 }
2069 if let TaskResult::Failed(err) = backup_task.execute().await {
2070 let err = SubXError::FileOperationFailed(err);
2071 outcomes.push(OperationOutcome {
2072 applied: false,
2073 error: Some(operation_error_from(&err)),
2074 });
2075 self.reporter.progress(&ProgressEvent::Advanced {
2076 done: outcomes.len() as u64,
2077 total,
2078 item: Some(&op.subtitle_file.name),
2079 });
2080 continue;
2081 }
2082 }
2083
2084 let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
2085 self.create_copy_task(op)
2086 } else {
2087 self.create_rename_task(op)
2088 };
2089
2090 let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
2091 {
2092 ProcessingOperation::CopyWithRename { source, target }
2093 | ProcessingOperation::CopyToVideoFolder { source, target } => {
2094 (source.clone(), target.clone(), JournalOperationType::Copied)
2095 }
2096 ProcessingOperation::MoveToVideoFolder { source, target } => {
2097 (source.clone(), target.clone(), JournalOperationType::Moved)
2098 }
2099 ProcessingOperation::RenameFile { source, target } => {
2100 let kind = match op.relocation_mode {
2101 FileRelocationMode::Move => JournalOperationType::Moved,
2102 _ => JournalOperationType::Renamed,
2103 };
2104 (source.clone(), target.clone(), kind)
2105 }
2106 _ => (
2107 op.subtitle_file.path.clone(),
2108 op.relocation_target_path.clone().unwrap_or_else(|| {
2109 op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
2110 }),
2111 JournalOperationType::Renamed,
2112 ),
2113 };
2114
2115 let (pre_file_size, pre_file_mtime) = journal_source
2116 .metadata()
2117 .ok()
2118 .map(|m| {
2119 let mtime = m
2120 .modified()
2121 .ok()
2122 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2123 .map(|d| d.as_secs())
2124 .unwrap_or(0);
2125 (m.len(), mtime)
2126 })
2127 .unwrap_or((0, 0));
2128
2129 if let TaskResult::Failed(err) = primary_task.execute().await {
2130 let err = SubXError::FileOperationFailed(err);
2131 outcomes.push(OperationOutcome {
2132 applied: false,
2133 error: Some(operation_error_from(&err)),
2134 });
2135 self.reporter.progress(&ProgressEvent::Advanced {
2136 done: outcomes.len() as u64,
2137 total,
2138 item: Some(&op.subtitle_file.name),
2139 });
2140 continue;
2141 }
2142
2143 let (file_size, file_mtime) = journal_destination
2144 .metadata()
2145 .ok()
2146 .map(|m| {
2147 let mtime = m
2148 .modified()
2149 .ok()
2150 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2151 .map(|d| d.as_secs())
2152 .unwrap_or(0);
2153 (m.len(), mtime)
2154 })
2155 .unwrap_or((pre_file_size, pre_file_mtime));
2156
2157 journal.entries.push(JournalEntry {
2158 operation_type: journal_kind,
2159 source: journal_source,
2160 destination: journal_destination,
2161 backup_path: backup_path.clone(),
2162 status: JournalEntryStatus::Completed,
2163 file_size,
2164 file_mtime,
2165 });
2166
2167 if let Some(path) = journal_file.as_ref() {
2168 if let Err(e) = journal.save(path).await {
2169 self.reporter.progress(&ProgressEvent::Finished {
2173 done: outcomes.len() as u64,
2174 total,
2175 });
2176 return Err(e);
2177 }
2178 }
2179
2180 outcomes.push(OperationOutcome {
2181 applied: true,
2182 error: None,
2183 });
2184 self.reporter.progress(&ProgressEvent::Advanced {
2185 done: outcomes.len() as u64,
2186 total,
2187 item: Some(&op.subtitle_file.name),
2188 });
2189 }
2190
2191 self.reporter.progress(&ProgressEvent::Finished {
2192 done: outcomes.len() as u64,
2193 total,
2194 });
2195
2196 Ok(outcomes)
2197 }
2198
2199 async fn rename_file(&self, op: &MatchOperation) -> Result<()> {
2201 let task = self.create_rename_task(op);
2202 match task.execute().await {
2203 TaskResult::Success(_) => Ok(()),
2204 TaskResult::Failed(err) => Err(SubXError::FileOperationFailed(err)),
2205 other => Err(SubXError::FileOperationFailed(format!(
2206 "Unexpected rename result: {:?}",
2207 other
2208 ))),
2209 }
2210 }
2211
2212 fn resolve_filename_conflict(&self, target: std::path::PathBuf) -> Result<std::path::PathBuf> {
2214 if !target.exists() {
2215 return Ok(target);
2216 }
2217 match self.config.conflict_resolution {
2218 ConflictResolution::Skip => {
2219 self.reporter.warn(&format!(
2220 "Warning: Skipping relocation due to existing file: {}",
2221 target.display()
2222 ));
2223 Ok(target)
2224 }
2225 ConflictResolution::AutoRename => {
2226 let file_stem = target
2227 .file_stem()
2228 .and_then(|s| s.to_str())
2229 .unwrap_or("file");
2230 let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
2231 let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
2232 match crate::core::fs_util::atomic_create_file(&target) {
2236 Ok(_f) => {
2237 let _ = std::fs::remove_file(&target);
2239 return Ok(target);
2240 }
2241 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
2242 Err(e) => return Err(SubXError::from(e)),
2243 }
2244 for i in 1..1000 {
2245 let new_name = if extension.is_empty() {
2246 format!("{}.{}", file_stem, i)
2247 } else {
2248 format!("{}.{}.{}", file_stem, i, extension)
2249 };
2250 let new_path = parent.join(new_name);
2251 match crate::core::fs_util::atomic_create_file(&new_path) {
2252 Ok(_f) => {
2253 let _ = std::fs::remove_file(&new_path);
2254 return Ok(new_path);
2255 }
2256 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
2257 Err(e) => return Err(SubXError::from(e)),
2258 }
2259 }
2260 Err(SubXError::FileOperationFailed(
2261 "Could not resolve filename conflict".to_string(),
2262 ))
2263 }
2264 ConflictResolution::Prompt => {
2265 self.reporter
2266 .warn("Warning: Conflict resolution prompt not implemented, using auto-rename");
2267 self.resolve_filename_conflict(target)
2268 }
2269 }
2270 }
2271
2272 fn create_copy_task(&self, op: &MatchOperation) -> FileProcessingTask {
2274 let source = op.subtitle_file.path.clone();
2276 let target_base = op.relocation_target_path.clone().unwrap();
2277 let final_target = self.resolve_filename_conflict(target_base).unwrap();
2278 FileProcessingTask::new(
2279 source.clone(),
2280 Some(final_target.clone()),
2281 ProcessingOperation::CopyWithRename {
2282 source,
2283 target: final_target,
2284 },
2285 )
2286 }
2287
2288 fn create_backup_task(&self, source: &std::path::Path, ext: &str) -> FileProcessingTask {
2290 let backup_path = source.with_extension(format!("{}.backup", ext));
2291 FileProcessingTask::new(
2292 source.to_path_buf(),
2293 Some(backup_path.clone()),
2294 ProcessingOperation::CreateBackup {
2295 source: source.to_path_buf(),
2296 backup: backup_path,
2297 },
2298 )
2299 }
2300
2301 fn create_rename_task(&self, op: &MatchOperation) -> FileProcessingTask {
2303 let old = op.subtitle_file.path.clone();
2304 let new_path = if op.requires_relocation && op.relocation_target_path.is_some() {
2306 let target_base = op.relocation_target_path.clone().unwrap();
2307 self.resolve_filename_conflict(target_base).unwrap()
2308 } else {
2309 old.with_file_name(&op.new_subtitle_name)
2310 };
2311
2312 FileProcessingTask::new(
2313 old.clone(),
2314 Some(new_path.clone()),
2315 ProcessingOperation::RenameFile {
2316 source: old,
2317 target: new_path,
2318 },
2319 )
2320 }
2321
2322 fn calculate_file_list_cache_key(&self, file_paths: &[PathBuf]) -> Result<String> {
2324 use std::collections::BTreeMap;
2325 use std::collections::hash_map::DefaultHasher;
2326 use std::hash::{Hash, Hasher};
2327
2328 let mut path_metadata = BTreeMap::new();
2330 for path in file_paths {
2331 if let Ok(metadata) = path.metadata() {
2332 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
2333 path_metadata.insert(
2334 canonical.to_string_lossy().to_string(),
2335 (metadata.len(), metadata.modified().ok()),
2336 );
2337 }
2338 }
2339
2340 let config_hash = self.calculate_config_hash()?;
2342
2343 let mut hasher = DefaultHasher::new();
2344 path_metadata.hash(&mut hasher);
2345 config_hash.hash(&mut hasher);
2346
2347 Ok(format!("filelist_{:016x}", hasher.finish()))
2348 }
2349
2350 async fn check_file_list_cache(&self, cache_key: &str) -> Result<Option<Vec<MatchOperation>>> {
2352 let cache_file_path = self.get_cache_file_path()?;
2353 let cache_data = CacheData::load(&cache_file_path).ok();
2354
2355 if let Some(cache_data) = cache_data {
2356 if !is_cache_version_current(&cache_data.cache_version) {
2357 return Ok(None);
2358 }
2359 if cache_data.directory == cache_key {
2360 let mut ops = Vec::new();
2362 let mut id_gen = Uuidv7Generator::new();
2363 for item in cache_data.match_operations {
2364 let video_path = PathBuf::from(&item.video_file);
2366 let subtitle_path = PathBuf::from(&item.subtitle_file);
2367
2368 if video_path.exists() && subtitle_path.exists() {
2369 let video_meta = video_path.metadata()?;
2371 let subtitle_meta = subtitle_path.metadata()?;
2372
2373 let video_file = MediaFile {
2374 id: generate_file_id(&mut id_gen),
2375 path: video_path.clone(),
2376 file_type: MediaFileType::Video,
2377 size: video_meta.len(),
2378 name: video_path
2379 .file_name()
2380 .unwrap()
2381 .to_string_lossy()
2382 .to_string(),
2383 extension: video_path
2384 .extension()
2385 .unwrap_or_default()
2386 .to_string_lossy()
2387 .to_lowercase(),
2388 relative_path: video_path
2389 .file_name()
2390 .unwrap()
2391 .to_string_lossy()
2392 .to_string(),
2393 };
2394
2395 let subtitle_file = MediaFile {
2396 id: generate_file_id(&mut id_gen),
2397 path: subtitle_path.clone(),
2398 file_type: MediaFileType::Subtitle,
2399 size: subtitle_meta.len(),
2400 name: subtitle_path
2401 .file_name()
2402 .unwrap()
2403 .to_string_lossy()
2404 .to_string(),
2405 extension: subtitle_path
2406 .extension()
2407 .unwrap_or_default()
2408 .to_string_lossy()
2409 .to_lowercase(),
2410 relative_path: subtitle_path
2411 .file_name()
2412 .unwrap()
2413 .to_string_lossy()
2414 .to_string(),
2415 };
2416
2417 let requires_relocation = self.config.relocation_mode
2419 != FileRelocationMode::None
2420 && subtitle_file.path.parent() != video_file.path.parent();
2421
2422 let relocation_target_path = if requires_relocation {
2423 let video_dir = video_file.path.parent().unwrap();
2424 Some(video_dir.join(&item.new_subtitle_name))
2425 } else {
2426 None
2427 };
2428
2429 ops.push(MatchOperation {
2430 video_file,
2431 subtitle_file,
2432 new_subtitle_name: item.new_subtitle_name,
2433 confidence: item.confidence,
2434 reasoning: item.reasoning,
2435 relocation_mode: self.config.relocation_mode.clone(),
2436 relocation_target_path,
2437 requires_relocation,
2438 });
2439 }
2440 }
2441 return Ok(Some(ops));
2442 }
2443 }
2444 Ok(None)
2445 }
2446
2447 async fn save_file_list_cache(
2449 &self,
2450 cache_key: &str,
2451 operations: &[MatchOperation],
2452 ) -> Result<()> {
2453 let cache_file_path = self.get_cache_file_path()?;
2454 let config_hash = self.calculate_config_hash()?;
2455
2456 let mut cache_items = Vec::new();
2457 for op in operations {
2458 cache_items.push(OpItem {
2459 video_file: op.video_file.path.to_string_lossy().to_string(),
2460 subtitle_file: op.subtitle_file.path.to_string_lossy().to_string(),
2461 new_subtitle_name: op.new_subtitle_name.clone(),
2462 confidence: op.confidence,
2463 reasoning: op.reasoning.clone(),
2464 });
2465 }
2466
2467 let mut snapshot_items = Vec::new();
2469 let mut seen_paths = std::collections::HashSet::new();
2470 for op in operations {
2471 for path in [&op.video_file.path, &op.subtitle_file.path] {
2472 let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
2473 let key = canonical.to_string_lossy().to_string();
2474 if seen_paths.insert(key.clone()) {
2475 if let Ok(meta) = std::fs::metadata(&canonical) {
2476 let mtime = meta
2477 .modified()
2478 .ok()
2479 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2480 .map(|d| d.as_secs())
2481 .unwrap_or(0);
2482 snapshot_items.push(SnapshotItem {
2483 path: key,
2484 name: canonical
2485 .file_name()
2486 .unwrap_or_default()
2487 .to_string_lossy()
2488 .to_string(),
2489 size: meta.len(),
2490 mtime,
2491 file_type: if canonical.extension().is_some_and(|e| {
2492 ["srt", "ass", "ssa", "vtt", "sub"]
2493 .contains(&e.to_string_lossy().to_lowercase().as_str())
2494 }) {
2495 "subtitle".to_string()
2496 } else {
2497 "video".to_string()
2498 },
2499 });
2500 }
2501 }
2502 }
2503 }
2504
2505 let cache_data = CacheData {
2506 cache_version: CURRENT_CACHE_VERSION.to_string(),
2507 directory: cache_key.to_string(),
2508 file_snapshot: snapshot_items,
2509 match_operations: cache_items,
2510 created_at: std::time::SystemTime::now()
2511 .duration_since(std::time::UNIX_EPOCH)
2512 .unwrap()
2513 .as_secs(),
2514 ai_model_used: self.config.ai_model.clone(),
2515 config_hash,
2516 original_relocation_mode: format!("{:?}", self.config.relocation_mode),
2517 original_backup_enabled: self.config.backup_enabled,
2518 };
2519
2520 let cache_dir = cache_file_path.parent().unwrap().to_path_buf();
2522 let cache_json = serde_json::to_string_pretty(&cache_data)?;
2523 let cache_file_path_clone = cache_file_path.clone();
2524 tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2525 std::fs::create_dir_all(&cache_dir)?;
2526 std::fs::write(&cache_file_path_clone, cache_json)?;
2527 Ok(())
2528 })
2529 .await
2530 .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
2531
2532 Ok(())
2533 }
2534
2535 fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
2537 let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
2539 std::path::PathBuf::from(xdg_config)
2540 } else {
2541 dirs::config_dir()
2542 .ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
2543 };
2544 Ok(dir.join("subx").join("match_cache.json"))
2545 }
2546
2547 fn calculate_config_hash(&self) -> Result<String> {
2549 use std::collections::hash_map::DefaultHasher;
2550 use std::hash::{Hash, Hasher};
2551
2552 let mut hasher = DefaultHasher::new();
2553 format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
2555 self.config.backup_enabled.hash(&mut hasher);
2556 "prompt_v2".hash(&mut hasher);
2559 Ok(format!("{:016x}", hasher.finish()))
2562 }
2563
2564 fn find_media_file_by_id_or_path<'a>(
2566 files: &'a [&MediaFile],
2567 file_id: &str,
2568 fallback_path: Option<&str>,
2569 ) -> Option<&'a MediaFile> {
2570 if let Some(file) = files.iter().find(|f| f.id == file_id) {
2571 return Some(*file);
2572 }
2573 if let Some(path) = fallback_path {
2574 if let Some(file) = files.iter().find(|f| f.relative_path == path) {
2575 return Some(*file);
2576 }
2577 files.iter().find(|f| f.name == path).copied()
2578 } else {
2579 None
2580 }
2581 }
2582
2583 fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
2585 let mut block = format!(" Available {file_type} files:");
2586 for f in files {
2587 block.push_str(&format!(
2588 "\n - ID: {} | Name: {} | Path: {}",
2589 f.id, f.name, f.relative_path
2590 ));
2591 }
2592 self.reporter.diagnostic(&block);
2593 }
2594
2595 fn log_no_matches_found(
2597 &self,
2598 match_result: &MatchResult,
2599 videos: &[MediaFile],
2600 subtitles: &[MediaFile],
2601 ) {
2602 let mut block = String::from(
2605 "\n❌ No matching files found that meet the criteria\n🔍 AI analysis results:",
2606 );
2607 block.push_str(&format!(
2608 "\n - Total matches: {}\n - Confidence threshold: {:.2}\n - Matches meeting threshold: {}",
2609 match_result.matches.len(),
2610 self.config.confidence_threshold,
2611 match_result
2612 .matches
2613 .iter()
2614 .filter(|m| m.confidence >= self.config.confidence_threshold)
2615 .count()
2616 ));
2617 block.push_str(&format!(
2618 "\n\n📂 Scanned files:\n Video files ({} files):",
2619 videos.len()
2620 ));
2621 for v in videos {
2622 block.push_str(&format!("\n - ID: {} | {}", v.id, v.relative_path));
2623 }
2624 block.push_str(&format!("\n Subtitle files ({} files):", subtitles.len()));
2625 for s in subtitles {
2626 block.push_str(&format!("\n - ID: {} | {}", s.id, s.relative_path));
2627 }
2628 self.reporter.diagnostic(&block);
2629 }
2630}
2631
2632pub async fn apply_cached_operations_with_reporter(
2650 cache: &CacheData,
2651 config: &MatchConfig,
2652 reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
2653) -> Result<()> {
2654 let operations = reconstruct_operations_from_cache(cache, config)?;
2655 let engine = MatchEngine::new(Box::new(NoOpAIProvider), config.clone()).with_reporter(reporter);
2656 engine.execute_operations(&operations, false).await
2657}
2658
2659pub async fn apply_cached_operations(cache: &CacheData, config: &MatchConfig) -> Result<()> {
2664 apply_cached_operations_with_reporter(cache, config, crate::core::report::noop()).await
2665}
2666
2667fn reconstruct_operations_from_cache(
2673 cache: &CacheData,
2674 config: &MatchConfig,
2675) -> Result<Vec<MatchOperation>> {
2676 let mut ops = Vec::new();
2677 let mut id_gen = Uuidv7Generator::new();
2678 for item in &cache.match_operations {
2679 let video_path = PathBuf::from(&item.video_file);
2680 let subtitle_path = PathBuf::from(&item.subtitle_file);
2681
2682 if !video_path.exists() || !subtitle_path.exists() {
2683 continue;
2684 }
2685
2686 let video_meta = video_path.metadata()?;
2687 let subtitle_meta = subtitle_path.metadata()?;
2688
2689 let video_file = MediaFile {
2690 id: generate_file_id(&mut id_gen),
2691 path: video_path.clone(),
2692 file_type: MediaFileType::Video,
2693 size: video_meta.len(),
2694 name: video_path
2695 .file_name()
2696 .unwrap_or_default()
2697 .to_string_lossy()
2698 .to_string(),
2699 extension: video_path
2700 .extension()
2701 .unwrap_or_default()
2702 .to_string_lossy()
2703 .to_lowercase(),
2704 relative_path: video_path
2705 .file_name()
2706 .unwrap_or_default()
2707 .to_string_lossy()
2708 .to_string(),
2709 };
2710
2711 let subtitle_file = MediaFile {
2712 id: generate_file_id(&mut id_gen),
2713 path: subtitle_path.clone(),
2714 file_type: MediaFileType::Subtitle,
2715 size: subtitle_meta.len(),
2716 name: subtitle_path
2717 .file_name()
2718 .unwrap_or_default()
2719 .to_string_lossy()
2720 .to_string(),
2721 extension: subtitle_path
2722 .extension()
2723 .unwrap_or_default()
2724 .to_string_lossy()
2725 .to_lowercase(),
2726 relative_path: subtitle_path
2727 .file_name()
2728 .unwrap_or_default()
2729 .to_string_lossy()
2730 .to_string(),
2731 };
2732
2733 let requires_relocation = config.relocation_mode != FileRelocationMode::None
2734 && subtitle_file.path.parent() != video_file.path.parent();
2735 let relocation_target_path = if requires_relocation {
2736 video_file
2737 .path
2738 .parent()
2739 .map(|p| p.join(&item.new_subtitle_name))
2740 } else {
2741 None
2742 };
2743
2744 ops.push(MatchOperation {
2745 video_file,
2746 subtitle_file,
2747 new_subtitle_name: item.new_subtitle_name.clone(),
2748 confidence: item.confidence,
2749 reasoning: item.reasoning.clone(),
2750 relocation_mode: config.relocation_mode.clone(),
2751 relocation_target_path,
2752 requires_relocation,
2753 });
2754 }
2755 Ok(ops)
2756}
2757
2758struct NoOpAIProvider;
2764
2765#[async_trait::async_trait]
2766impl AIProvider for NoOpAIProvider {
2767 async fn analyze_content(&self, _request: AnalysisRequest) -> crate::Result<MatchResult> {
2768 Err(SubXError::config(
2769 "AI analysis is not available while replaying cached operations",
2770 ))
2771 }
2772
2773 async fn verify_match(
2774 &self,
2775 _verification: crate::services::ai::VerificationRequest,
2776 ) -> crate::Result<crate::services::ai::ConfidenceScore> {
2777 Err(SubXError::config(
2778 "AI verification is not available while replaying cached operations",
2779 ))
2780 }
2781}