1pub mod engine;
32
33pub use engine::{MethodSelectionStrategy, SyncEngine, SyncMethod, SyncResult};
35
36use crate::core::formats::Subtitle;
37use crate::core::input::InputPathHandler;
38use crate::error::SubXError;
39use log::debug;
40use serde_json::json;
41use std::path::{Path, PathBuf};
42use std::time::{Duration, Instant};
43
44pub const SYNC_VIDEO_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov"];
50
51pub const SYNC_SUBTITLE_EXTENSIONS: &[&str] = &["srt", "ass", "vtt", "sub"];
57
58#[derive(Debug, Clone, PartialEq, Eq, Default)]
64pub enum BatchRequest {
65 #[default]
67 Off,
68 Auto,
70 Directory(PathBuf),
72}
73
74#[derive(Debug, Clone, Default)]
81pub struct SyncPairingRequest {
82 pub positional_paths: Vec<PathBuf>,
84 pub input_paths: Vec<PathBuf>,
86 pub video: Option<PathBuf>,
88 pub subtitle: Option<PathBuf>,
90 pub batch: BatchRequest,
92 pub recursive: bool,
94 pub no_extract: bool,
96 pub manual: bool,
98}
99
100#[derive(Debug)]
102pub enum SyncMode {
103 Single {
105 video: PathBuf,
107 subtitle: PathBuf,
109 },
110 Batch(InputPathHandler),
112}
113
114pub fn resolve_sync_pairing(request: &SyncPairingRequest) -> Result<SyncMode, SubXError> {
131 if request.batch != BatchRequest::Off
133 || !request.input_paths.is_empty()
134 || request
135 .positional_paths
136 .iter()
137 .any(|p| p.extension().is_none())
138 {
139 let mut paths = Vec::new();
140
141 if let BatchRequest::Directory(batch_dir) = &request.batch {
143 paths.push(batch_dir.clone());
144 }
145
146 paths.extend(request.input_paths.clone());
148 paths.extend(request.positional_paths.clone());
149
150 if paths.is_empty() {
152 paths.push(PathBuf::from("."));
153 }
154
155 let handler = InputPathHandler::from_args(&paths, request.recursive)?
156 .with_extensions(&[SYNC_VIDEO_EXTENSIONS, SYNC_SUBTITLE_EXTENSIONS].concat())
157 .with_no_extract(request.no_extract);
158
159 return Ok(SyncMode::Batch(handler));
160 }
161
162 if !request.positional_paths.is_empty() {
164 if request.positional_paths.len() == 1 {
165 let path = &request.positional_paths[0];
166 let ext = path
167 .extension()
168 .and_then(|s| s.to_str())
169 .unwrap_or("")
170 .to_lowercase();
171 let mut video = None;
172 let mut subtitle = None;
173 if SYNC_VIDEO_EXTENSIONS.contains(&ext.as_str()) {
174 video = Some(path.clone());
175 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
176 let dir = path.parent().unwrap_or_else(|| Path::new("."));
177 for sub_ext in SYNC_SUBTITLE_EXTENSIONS {
178 let cand = dir.join(format!("{stem}.{sub_ext}"));
179 if cand.exists() {
180 subtitle = Some(cand);
181 break;
182 }
183 }
184 }
185 } else if SYNC_SUBTITLE_EXTENSIONS.contains(&ext.as_str()) {
186 subtitle = Some(path.clone());
187 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
188 let dir = path.parent().unwrap_or_else(|| Path::new("."));
189 for vid_ext in SYNC_VIDEO_EXTENSIONS {
190 let cand = dir.join(format!("{stem}.{vid_ext}"));
191 if cand.exists() {
192 video = Some(cand);
193 break;
194 }
195 }
196 }
197 }
198 if request.manual {
200 if let Some(subtitle_path) = subtitle {
201 return Ok(SyncMode::Single {
202 video: PathBuf::new(), subtitle: subtitle_path,
204 });
205 }
206 }
207 if let (Some(v), Some(s)) = (video, subtitle) {
208 return Ok(SyncMode::Single {
209 video: v,
210 subtitle: s,
211 });
212 }
213 return Err(SubXError::InvalidSyncConfiguration);
214 } else if request.positional_paths.len() == 2 {
215 let mut video = None;
216 let mut subtitle = None;
217 for p in &request.positional_paths {
218 if let Some(ext) = p
219 .extension()
220 .and_then(|s| s.to_str())
221 .map(|s| s.to_lowercase())
222 {
223 if SYNC_VIDEO_EXTENSIONS.contains(&ext.as_str()) {
224 video = Some(p.clone());
225 }
226 if SYNC_SUBTITLE_EXTENSIONS.contains(&ext.as_str()) {
227 subtitle = Some(p.clone());
228 }
229 }
230 }
231 if let (Some(v), Some(s)) = (video, subtitle) {
232 return Ok(SyncMode::Single {
233 video: v,
234 subtitle: s,
235 });
236 }
237 return Err(SubXError::InvalidSyncConfiguration);
238 }
239 }
240
241 if let (Some(video), Some(subtitle)) = (request.video.as_ref(), request.subtitle.as_ref()) {
243 Ok(SyncMode::Single {
244 video: video.clone(),
245 subtitle: subtitle.clone(),
246 })
247 } else if request.manual {
248 if let Some(subtitle) = request.subtitle.as_ref() {
249 Ok(SyncMode::Single {
251 video: PathBuf::new(), subtitle: subtitle.clone(),
253 })
254 } else {
255 Err(SubXError::InvalidSyncConfiguration)
256 }
257 } else {
258 Err(SubXError::InvalidSyncConfiguration)
259 }
260}
261
262pub fn create_default_output_path(input: &Path) -> PathBuf {
289 let mut output = input.to_path_buf();
290
291 if let Some(stem) = input.file_stem().and_then(|s| s.to_str()) {
292 if let Some(extension) = input.extension().and_then(|s| s.to_str()) {
293 let new_filename = format!("{stem}_synced.{extension}");
294 output.set_file_name(new_filename);
295 }
296 }
297
298 output
299}
300
301pub fn shift_subtitle_timing(
362 subtitle: &mut Subtitle,
363 offset_seconds: f32,
364) -> crate::Result<SyncResult> {
365 let start = Instant::now();
366 for entry in &mut subtitle.entries {
367 let offset_dur = Duration::from_secs_f32(offset_seconds.abs());
368 if offset_seconds >= 0.0 {
369 entry.start_time = entry.start_time.checked_add(offset_dur).ok_or_else(|| {
370 SubXError::audio_processing("Invalid offset results in negative time")
371 })?;
372 entry.end_time = entry.end_time.checked_add(offset_dur).ok_or_else(|| {
373 SubXError::audio_processing("Invalid offset results in negative time")
374 })?;
375 } else {
376 entry.start_time = if entry.start_time > offset_dur {
378 entry.start_time - offset_dur
379 } else {
380 Duration::ZERO
381 };
382 entry.end_time = if entry.end_time > offset_dur {
383 entry.end_time - offset_dur
384 } else {
385 Duration::ZERO
386 };
387 }
388 }
389 debug!(
390 "[SyncEngine] Manual offset applied to all entries | offset_seconds: {:.3}",
391 offset_seconds
392 );
393 Ok(SyncResult {
394 offset_seconds,
395 confidence: 1.0,
396 method_used: SyncMethod::Manual,
397 correlation_peak: 1.0,
398 additional_info: Some(json!({
399 "applied_offset": offset_seconds,
400 "entries_modified": subtitle.entries.len(),
401 })),
402 processing_duration: start.elapsed(),
403 warnings: Vec::new(),
404 })
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::core::formats::{SubtitleEntry, SubtitleFormatType, SubtitleMetadata};
411
412 fn shift_test_subtitle(entries: Vec<(std::time::Duration, std::time::Duration)>) -> Subtitle {
415 let mut subtitle = Subtitle::new(SubtitleFormatType::Srt, SubtitleMetadata::default());
416 subtitle.entries = entries
417 .into_iter()
418 .enumerate()
419 .map(|(i, (start, end))| {
420 SubtitleEntry::new(i + 1, start, end, format!("line {}", i + 1))
421 })
422 .collect();
423 subtitle
424 }
425
426 #[test]
427 fn test_shift_subtitle_timing_positive_shifts_both_times() {
428 let mut subtitle =
429 shift_test_subtitle(vec![(Duration::from_secs(10), Duration::from_secs(12))]);
430 let result = shift_subtitle_timing(&mut subtitle, 2.5).unwrap();
431 assert_eq!(
432 subtitle.entries[0].start_time,
433 Duration::from_secs_f32(12.5)
434 );
435 assert_eq!(subtitle.entries[0].end_time, Duration::from_secs_f32(14.5));
436 assert_eq!(result.offset_seconds, 2.5);
437 assert_eq!(result.method_used, SyncMethod::Manual);
438 assert_eq!(result.confidence, 1.0);
439 }
440
441 #[test]
442 fn test_shift_subtitle_timing_negative_clamps_at_zero() {
443 let mut subtitle =
445 shift_test_subtitle(vec![(Duration::from_secs(1), Duration::from_secs(20))]);
446 shift_subtitle_timing(&mut subtitle, -5.0).unwrap();
447 assert_eq!(subtitle.entries[0].start_time, Duration::ZERO);
448 assert_eq!(subtitle.entries[0].end_time, Duration::from_secs_f32(15.0));
449 }
450
451 #[test]
452 fn test_shift_subtitle_timing_negative_boundary_equal_offset_clamps_to_zero() {
453 let mut subtitle =
457 shift_test_subtitle(vec![(Duration::from_secs(2), Duration::from_secs(5))]);
458 shift_subtitle_timing(&mut subtitle, -5.0).unwrap();
459 assert_eq!(subtitle.entries[0].start_time, Duration::ZERO);
460 assert_eq!(subtitle.entries[0].end_time, Duration::ZERO);
461 }
462
463 #[test]
464 fn test_shift_subtitle_timing_positive_overflow_errors() {
465 let mut subtitle = shift_test_subtitle(vec![(
469 Duration::MAX - Duration::from_secs(1),
470 Duration::MAX,
471 )]);
472 let err = shift_subtitle_timing(&mut subtitle, 1.0).unwrap_err();
473 assert!(matches!(err, SubXError::AudioProcessing { .. }));
474 }
475
476 #[test]
477 fn test_shift_subtitle_timing_empty_subtitle_succeeds() {
478 let mut subtitle = shift_test_subtitle(vec![]);
479 let result = shift_subtitle_timing(&mut subtitle, 3.0).unwrap();
480 assert_eq!(
481 result.additional_info.unwrap()["entries_modified"]
482 .as_u64()
483 .unwrap(),
484 0
485 );
486 }
487
488 #[test]
489 fn test_shift_subtitle_timing_ignores_max_offset_guard() {
490 let mut subtitle =
494 shift_test_subtitle(vec![(Duration::from_secs(1), Duration::from_secs(2))]);
495 let result = shift_subtitle_timing(&mut subtitle, 120.0).unwrap();
496 assert_eq!(result.offset_seconds, 120.0);
497 assert_eq!(
498 subtitle.entries[0].start_time,
499 Duration::from_secs_f32(121.0)
500 );
501 }
502
503 #[test]
504 fn test_apply_manual_offset_delegates_identically_to_shift_subtitle_timing() {
505 use crate::config::TestConfigBuilder;
506
507 let config = TestConfigBuilder::new()
508 .with_vad_enabled(true)
509 .build_config();
510 let engine = SyncEngine::new(config.sync).unwrap();
511
512 let fixture = shift_test_subtitle(vec![
513 (Duration::from_secs(1), Duration::from_secs(3)),
514 (Duration::from_secs(10), Duration::from_secs(14)),
515 (Duration::from_secs(100), Duration::from_secs(120)),
516 ]);
517 let mut via_engine = fixture.clone();
518 let mut via_free_fn = fixture.clone();
519
520 let offset = 2.5f32; let r_engine = engine.apply_manual_offset(&mut via_engine, offset).unwrap();
522 let r_free = shift_subtitle_timing(&mut via_free_fn, offset).unwrap();
523
524 assert_eq!(via_engine.entries.len(), via_free_fn.entries.len());
526 for (a, b) in via_engine.entries.iter().zip(&via_free_fn.entries) {
527 assert_eq!(a.start_time, b.start_time);
528 assert_eq!(a.end_time, b.end_time);
529 }
530 assert_eq!(r_engine.offset_seconds, r_free.offset_seconds);
533 assert_eq!(r_engine.confidence, r_free.confidence);
534 assert_eq!(r_engine.method_used, r_free.method_used);
535 assert_eq!(r_engine.correlation_peak, r_free.correlation_peak);
536 assert_eq!(r_engine.additional_info, r_free.additional_info);
537 assert_eq!(r_engine.warnings, r_free.warnings);
538 }
539
540 #[test]
543 fn test_create_default_output_path_srt() {
544 let input = PathBuf::from("test.srt");
545 let output = create_default_output_path(&input);
546 assert_eq!(output.file_name().unwrap(), "test_synced.srt");
547 }
548
549 #[test]
550 fn test_create_default_output_path_with_prefix() {
551 let input = PathBuf::from("/path/to/movie.ass");
552 let output = create_default_output_path(&input);
553 assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
554 assert_eq!(output.parent().unwrap(), std::path::Path::new("/path/to"));
555 }
556
557 #[test]
558 fn test_create_default_output_path_vtt() {
559 let input = PathBuf::from("episode.vtt");
560 let output = create_default_output_path(&input);
561 assert_eq!(output.file_name().unwrap(), "episode_synced.vtt");
562 }
563
564 #[test]
565 fn test_create_default_output_path_no_extension() {
566 let input = PathBuf::from("noextension");
568 let output = create_default_output_path(&input);
569 assert_eq!(output, PathBuf::from("noextension"));
570 }
571
572 #[test]
576 fn test_resolve_pairing_single_positional_video_probes_subtitle() {
577 let tmp = tempfile::TempDir::new().unwrap();
578 let video = tmp.path().join("movie.mp4");
579 let sub = tmp.path().join("movie.srt");
580 std::fs::write(&video, b"fake video").unwrap();
581 std::fs::write(&sub, b"1\n00:00:01,000 --> 00:00:02,000\nHi\n").unwrap();
582
583 let request = SyncPairingRequest {
584 positional_paths: vec![video.clone()],
585 ..Default::default()
586 };
587 match resolve_sync_pairing(&request).unwrap() {
588 SyncMode::Single {
589 video: v,
590 subtitle: s,
591 } => {
592 assert_eq!(v, video);
593 assert_eq!(s, sub);
594 }
595 other => panic!("Expected Single mode, got {other:?}"),
596 }
597 }
598
599 #[test]
600 fn test_resolve_pairing_single_positional_subtitle_probes_video() {
601 let tmp = tempfile::TempDir::new().unwrap();
602 let video = tmp.path().join("movie.mp4");
603 let sub = tmp.path().join("movie.srt");
604 std::fs::write(&video, b"fake video").unwrap();
605 std::fs::write(&sub, b"1\n00:00:01,000 --> 00:00:02,000\nHi\n").unwrap();
606
607 let request = SyncPairingRequest {
608 positional_paths: vec![sub.clone()],
609 ..Default::default()
610 };
611 match resolve_sync_pairing(&request).unwrap() {
612 SyncMode::Single {
613 video: v,
614 subtitle: s,
615 } => {
616 assert_eq!(v, video);
617 assert_eq!(s, sub);
618 }
619 other => panic!("Expected Single mode, got {other:?}"),
620 }
621 }
622
623 #[test]
624 fn test_resolve_pairing_probe_order_prefers_srt_over_ass() {
625 let tmp = tempfile::TempDir::new().unwrap();
626 let video = tmp.path().join("movie.mp4");
627 let srt = tmp.path().join("movie.srt");
628 let ass = tmp.path().join("movie.ass");
629 std::fs::write(&video, b"fake video").unwrap();
630 std::fs::write(&srt, b"1\n00:00:01,000 --> 00:00:02,000\nHi\n").unwrap();
631 std::fs::write(&ass, b"[Script Info]\n").unwrap();
632
633 let request = SyncPairingRequest {
634 positional_paths: vec![video],
635 ..Default::default()
636 };
637 match resolve_sync_pairing(&request).unwrap() {
638 SyncMode::Single { subtitle, .. } => assert_eq!(subtitle, srt),
639 other => panic!("Expected Single mode, got {other:?}"),
640 }
641 }
642
643 #[test]
644 fn test_resolve_pairing_manual_mode_subtitle_only_positional_empty_video() {
645 let tmp = tempfile::TempDir::new().unwrap();
646 let sub = tmp.path().join("movie.srt");
647 std::fs::write(&sub, b"1\n00:00:01,000 --> 00:00:02,000\nHi\n").unwrap();
648
649 let request = SyncPairingRequest {
650 positional_paths: vec![sub.clone()],
651 manual: true,
652 ..Default::default()
653 };
654 match resolve_sync_pairing(&request).unwrap() {
655 SyncMode::Single { video, subtitle } => {
656 assert_eq!(video, PathBuf::new());
657 assert_eq!(subtitle, sub);
658 }
659 other => panic!("Expected Single mode, got {other:?}"),
660 }
661 }
662
663 #[test]
664 fn test_resolve_pairing_two_positionals_classified_without_probing() {
665 let request = SyncPairingRequest {
668 positional_paths: vec![
669 PathBuf::from("nowhere/movie.srt"),
670 PathBuf::from("nowhere/movie.mp4"),
671 ],
672 ..Default::default()
673 };
674 match resolve_sync_pairing(&request).unwrap() {
675 SyncMode::Single { video, subtitle } => {
676 assert_eq!(video, PathBuf::from("nowhere/movie.mp4"));
677 assert_eq!(subtitle, PathBuf::from("nowhere/movie.srt"));
678 }
679 other => panic!("Expected Single mode, got {other:?}"),
680 }
681 }
682
683 #[test]
684 fn test_resolve_pairing_unpairable_single_positional_errors() {
685 let tmp = tempfile::TempDir::new().unwrap();
686 let video = tmp.path().join("movie.mp4");
687 std::fs::write(&video, b"fake video").unwrap();
688 let request = SyncPairingRequest {
690 positional_paths: vec![video],
691 ..Default::default()
692 };
693 assert!(matches!(
694 resolve_sync_pairing(&request),
695 Err(SubXError::InvalidSyncConfiguration)
696 ));
697 }
698
699 #[test]
702 fn test_resolve_pairing_batch_directory() {
703 let tmp = tempfile::TempDir::new().unwrap();
704 let dir = tmp.path().to_path_buf();
705 std::fs::write(dir.join("extra"), b"x").unwrap();
707 std::fs::write(dir.join("pos.srt"), b"x").unwrap();
708 let request = SyncPairingRequest {
709 batch: BatchRequest::Directory(dir.clone()),
710 input_paths: vec![dir.join("extra")],
711 positional_paths: vec![dir.join("pos.srt")],
712 recursive: true,
713 no_extract: true,
714 ..Default::default()
715 };
716 match resolve_sync_pairing(&request).unwrap() {
717 SyncMode::Batch(handler) => {
718 assert_eq!(
720 handler.paths,
721 vec![dir.clone(), dir.join("extra"), dir.join("pos.srt")]
722 );
723 assert!(handler.recursive);
724 assert!(handler.no_extract);
725 let mut expected_ext: Vec<String> = SYNC_VIDEO_EXTENSIONS
726 .iter()
727 .chain(SYNC_SUBTITLE_EXTENSIONS)
728 .map(|s| s.to_string())
729 .collect();
730 expected_ext.sort();
731 let mut actual_ext = handler.file_extensions.clone();
732 actual_ext.sort();
733 assert_eq!(actual_ext, expected_ext);
734 }
735 other => panic!("Expected Batch mode, got {other:?}"),
736 }
737 }
738
739 #[test]
740 fn test_resolve_pairing_batch_via_input_paths() {
741 let tmp = tempfile::TempDir::new().unwrap();
742 let dir = tmp.path().to_path_buf();
743 let request = SyncPairingRequest {
744 input_paths: vec![dir],
745 ..Default::default()
746 };
747 assert!(matches!(
748 resolve_sync_pairing(&request).unwrap(),
749 SyncMode::Batch(_)
750 ));
751 }
752
753 #[test]
754 fn test_resolve_pairing_batch_via_extensionless_positional() {
755 let tmp = tempfile::TempDir::new().unwrap();
756 let request = SyncPairingRequest {
757 positional_paths: vec![tmp.path().to_path_buf()],
758 ..Default::default()
759 };
760 assert!(matches!(
761 resolve_sync_pairing(&request).unwrap(),
762 SyncMode::Batch(_)
763 ));
764 }
765
766 #[test]
767 fn test_resolve_pairing_batch_auto_without_paths_defaults_to_cwd() {
768 let request = SyncPairingRequest {
769 batch: BatchRequest::Auto,
770 ..Default::default()
771 };
772 match resolve_sync_pairing(&request).unwrap() {
773 SyncMode::Batch(handler) => {
774 assert_eq!(handler.paths, vec![PathBuf::from(".")]);
775 }
776 other => panic!("Expected Batch mode, got {other:?}"),
777 }
778 }
779}