1use crate::cli::SyncArgs;
8use crate::cli::SyncMode;
9use crate::cli::output::{OutputMode, active_mode, emit_success};
10use crate::cli::sync_args::create_default_output_path;
11use crate::config::Config;
12use crate::config::ConfigService;
13use crate::core::formats::manager::FormatManager;
14use crate::core::sync::{SyncEngine, SyncMethod, SyncResult};
15use crate::{Result, error::SubXError};
16use serde::Serialize;
17
18const VAD_CHUNK_MS: u32 = 32;
24
25#[derive(Debug, Serialize)]
34pub struct SyncPayload {
35 pub method: String,
37 pub inputs: Vec<SyncInput>,
39 pub operations: Vec<SyncOperation>,
41}
42
43#[derive(Debug, Serialize)]
46pub struct SyncInput {
47 pub subtitle_path: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
52 pub audio_path: Option<String>,
53 pub detected_offset_ms: i64,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub confidence: Option<f32>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub vad: Option<VadInfoPayload>,
62 pub status: &'static str,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub error: Option<SyncItemError>,
67}
68
69#[derive(Debug, Serialize)]
72pub struct SyncOperation {
73 pub subtitle_path: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub output_path: Option<String>,
78 pub applied: bool,
80 pub dry_run: bool,
82 pub status: &'static str,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub error: Option<SyncItemError>,
87}
88
89#[derive(Debug, Serialize)]
91pub struct VadInfoPayload {
92 pub sensitivity: f32,
94 pub padding_ms: u32,
96 pub segments: Vec<serde_json::Value>,
98}
99
100#[derive(Debug, Serialize, Clone)]
103pub struct SyncItemError {
104 pub code: String,
106 pub category: String,
108 pub message: String,
110}
111
112struct SyncSingleResult {
115 input: SyncInput,
116 operation: SyncOperation,
117}
118
119fn method_to_str(m: &SyncMethod) -> &'static str {
120 match m {
121 SyncMethod::LocalVad => "vad",
122 SyncMethod::Manual => "manual",
123 SyncMethod::Auto => "auto",
124 }
125}
126
127fn build_single_result(
128 args: &SyncArgs,
129 sync_result: &SyncResult,
130 subtitle_path: &std::path::Path,
131 audio_path: Option<&std::path::Path>,
132 output_path: Option<&std::path::Path>,
133 applied: bool,
134 vad_cfg: &crate::config::VadConfig,
135) -> SyncSingleResult {
136 let offset_ms = (sync_result.offset_seconds as f64 * 1000.0).round() as i64;
137 let confidence = if matches!(sync_result.method_used, SyncMethod::Manual) {
138 None
139 } else {
140 Some(sync_result.confidence)
141 };
142 let vad = if matches!(sync_result.method_used, SyncMethod::LocalVad) {
143 let segments = sync_result
144 .additional_info
145 .as_ref()
146 .and_then(|v| v.get("detected_segments"))
147 .and_then(|v| v.as_array())
148 .cloned()
149 .unwrap_or_default();
150 Some(VadInfoPayload {
151 sensitivity: vad_cfg.sensitivity,
152 padding_ms: vad_cfg.padding_chunks.saturating_mul(VAD_CHUNK_MS),
153 segments,
154 })
155 } else {
156 None
157 };
158 let subtitle_str = subtitle_path.display().to_string();
159 let input = SyncInput {
160 subtitle_path: subtitle_str.clone(),
161 audio_path: audio_path.map(|p| p.display().to_string()),
162 detected_offset_ms: offset_ms,
163 confidence,
164 vad,
165 status: "ok",
166 error: None,
167 };
168 let operation = SyncOperation {
169 subtitle_path: subtitle_str,
170 output_path: output_path.map(|p| p.display().to_string()),
171 applied,
172 dry_run: args.dry_run,
173 status: "ok",
174 error: None,
175 };
176 SyncSingleResult { input, operation }
177}
178
179fn resolve_method_string(args: &SyncArgs, default_method: &str) -> String {
184 if args.offset.is_some() {
185 return "manual".to_string();
186 }
187 if let Some(method_arg) = &args.method {
188 return method_to_str(&method_arg.clone().into()).to_string();
189 }
190 if args.vad_sensitivity.is_some() {
191 return "vad".to_string();
192 }
193 match default_method {
194 "vad" => "vad".to_string(),
195 "auto" => "auto".to_string(),
196 _ => "auto".to_string(),
197 }
198}
199
200fn make_skip_input_op(
201 sub_path: &std::path::Path,
202 audio_path: Option<&std::path::Path>,
203 reason: &str,
204 dry_run: bool,
205) -> (SyncInput, SyncOperation) {
206 let err = SyncItemError {
207 code: "E_FILE_MATCHING".to_string(),
208 category: "file_matching".to_string(),
209 message: format!("Skip sync: {reason}"),
210 };
211 let subtitle_str = sub_path.display().to_string();
212 let input = SyncInput {
213 subtitle_path: subtitle_str.clone(),
214 audio_path: audio_path.map(|p| p.display().to_string()),
215 detected_offset_ms: 0,
216 confidence: None,
217 vad: None,
218 status: "error",
219 error: Some(err.clone()),
220 };
221 let operation = SyncOperation {
222 subtitle_path: subtitle_str,
223 output_path: None,
224 applied: false,
225 dry_run,
226 status: "error",
227 error: Some(err),
228 };
229 (input, operation)
230}
231
232async fn run_single(
239 args: &SyncArgs,
240 config: &Config,
241 sync_engine: &SyncEngine,
242 format_manager: &FormatManager,
243) -> Result<SyncSingleResult> {
244 let json = active_mode().is_json();
245 let subtitle_path = args.subtitle.as_ref().ok_or_else(|| {
246 SubXError::CommandExecution(
247 "Subtitle file path is required for single file sync".to_string(),
248 )
249 })?;
250
251 if args.verbose && !json {
252 println!("🎬 Loading subtitle file: {}", subtitle_path.display());
253 println!("📄 Subtitle entries count: {}", {
254 let s = format_manager.load_subtitle(subtitle_path).map_err(|e| {
255 log::debug!("Failed to load subtitle: {e}");
256 e
257 })?;
258 s.entries.len()
259 });
260 }
261 let mut subtitle = format_manager.load_subtitle(subtitle_path).map_err(|e| {
262 log::debug!("Failed to load subtitle: {e}");
263 e
264 })?;
265 let mut effective_vad_cfg = config.sync.vad.clone();
266 let mut audio_for_payload: Option<std::path::PathBuf> = None;
267 let sync_result = if let Some(offset) = args.offset {
268 if args.verbose && !json {
269 println!("⚙️ Using manual offset: {offset:.3}s");
270 }
271 sync_engine
272 .apply_manual_offset(&mut subtitle, offset)
273 .map_err(|e| {
274 log::debug!("Failed to apply manual offset: {e}");
275 e
276 })?;
277 SyncResult {
278 offset_seconds: offset,
279 confidence: 1.0,
280 method_used: crate::core::sync::SyncMethod::Manual,
281 correlation_peak: 0.0,
282 processing_duration: std::time::Duration::ZERO,
283 warnings: Vec::new(),
284 additional_info: None,
285 }
286 } else {
287 let video_path = args.video.as_ref().ok_or_else(|| {
289 SubXError::CommandExecution(
290 "Video file path is required for automatic sync".to_string(),
291 )
292 })?;
293
294 if video_path.as_os_str().is_empty() {
296 return Err(SubXError::CommandExecution(
297 "Video file path is required for automatic sync".to_string(),
298 ));
299 }
300
301 let method = determine_sync_method(args, &config.sync.default_method)?;
302 if args.verbose && !json {
303 println!("🔍 Starting sync analysis...");
304 println!(" Method: {method:?}");
305 println!(" Analysis window: {}s", args.window);
306 println!(" Video file: {}", video_path.display());
307 }
308 let mut sync_cfg = config.sync.clone();
309 apply_cli_overrides(&mut sync_cfg, args)?;
310 effective_vad_cfg = sync_cfg.vad.clone();
311 audio_for_payload = Some(video_path.clone());
312 let result = sync_engine
313 .detect_sync_offset(video_path.as_path(), &subtitle, Some(method))
314 .await
315 .map_err(|e| {
316 log::debug!("Failed to detect sync offset: {e}");
317 e
318 })?;
319 if args.verbose && !json {
320 println!("✅ Analysis completed:");
321 println!(" Detected offset: {:.3}s", result.offset_seconds);
322 println!(" Confidence: {:.1}%", result.confidence * 100.0);
323 println!(" Processing time: {:?}", result.processing_duration);
324 }
325 if !args.dry_run {
326 sync_engine
327 .apply_manual_offset(&mut subtitle, result.offset_seconds)
328 .map_err(|e| {
329 log::debug!("Failed to apply detected offset: {e}");
330 e
331 })?;
332 }
333 result
334 };
335 if !json {
336 display_sync_result(&sync_result, args.verbose);
337 }
338 let mut applied = false;
339 let mut output_path_used: Option<std::path::PathBuf> = None;
340 if !args.dry_run {
341 if let Some(out) = args.get_output_path() {
342 if out.exists() && !args.force {
343 log::debug!("Output file exists and --force not set: {}", out.display());
344 return Err(SubXError::CommandExecution(format!(
345 "Output file already exists: {}. Use --force to overwrite.",
346 out.display()
347 )));
348 }
349 format_manager.save_subtitle(&subtitle, &out).map_err(|e| {
350 log::debug!("Failed to save subtitle: {e}");
351 e
352 })?;
353 if !json {
354 if args.verbose {
355 println!("💾 Synchronized subtitle saved to: {}", out.display());
356 } else {
357 println!("Synchronized subtitle saved to: {}", out.display());
358 }
359 }
360 applied = true;
361 output_path_used = Some(out);
362 } else {
363 log::debug!("No output path specified");
364 return Err(SubXError::CommandExecution(
365 "No output path specified".to_string(),
366 ));
367 }
368 } else if !json {
369 println!("🔍 Dry run mode - file not saved");
370 }
371 Ok(build_single_result(
372 args,
373 &sync_result,
374 subtitle_path,
375 audio_for_payload.as_deref(),
376 output_path_used.as_deref(),
377 applied,
378 &effective_vad_cfg,
379 ))
380}
381
382pub async fn execute(args: SyncArgs, config_service: &dyn ConfigService) -> Result<()> {
409 if let Err(msg) = args.validate() {
411 return Err(SubXError::CommandExecution(msg));
412 }
413 let config = config_service.get_config()?;
414
415 if let Some(manual_offset) = args.offset {
417 if manual_offset.abs() > config.sync.max_offset_seconds {
418 return Err(SubXError::config(format!(
419 "The specified offset {:.2}s exceeds the configured maximum allowed value {:.2}s.\n\n\
420 Please use one of the following methods to resolve this issue:\n\
421 1. Use a smaller offset: --offset {:.2}\n\
422 2. Adjust configuration: subx-cli config set sync.max_offset_seconds {:.2}\n\
423 3. Use automatic detection: remove the --offset parameter",
424 manual_offset,
425 config.sync.max_offset_seconds,
426 config.sync.max_offset_seconds * 0.9, manual_offset
428 .abs()
429 .max(config.sync.max_offset_seconds * 1.5) )));
431 }
432 }
433
434 let sync_engine = SyncEngine::new(config.sync.clone())?;
435 let format_manager = FormatManager::new();
436 let mode = active_mode();
437 let json = matches!(mode, OutputMode::Json);
438
439 if let Ok(SyncMode::Batch(handler)) = args.get_sync_mode() {
441 let paths = handler
442 .collect_files()
443 .map_err(|e| SubXError::CommandExecution(e.to_string()))?;
444
445 let video_files: Vec<_> = paths
447 .iter()
448 .filter(|p| {
449 p.extension()
450 .and_then(|s| s.to_str())
451 .map(|e| ["mp4", "mkv", "avi", "mov"].contains(&e.to_lowercase().as_str()))
452 .unwrap_or(false)
453 })
454 .collect();
455
456 let subtitle_files: Vec<_> = paths
457 .iter()
458 .filter(|p| {
459 p.extension()
460 .and_then(|s| s.to_str())
461 .map(|e| ["srt", "ass", "vtt", "sub"].contains(&e.to_lowercase().as_str()))
462 .unwrap_or(false)
463 })
464 .collect();
465
466 let mut inputs: Vec<SyncInput> = Vec::new();
467 let mut operations: Vec<SyncOperation> = Vec::new();
468 let method_string = resolve_method_string(&args, &config.sync.default_method);
469
470 if video_files.is_empty() {
472 for sub_path in &subtitle_files {
473 if !json {
474 println!(
475 "✗ Skip sync for {}: no video files found in directory",
476 sub_path.display()
477 );
478 }
479 if json {
480 let (input, op) = make_skip_input_op(
481 sub_path,
482 None,
483 "no video files found in directory",
484 args.dry_run,
485 );
486 inputs.push(input);
487 operations.push(op);
488 }
489 }
490 if json {
491 return Err(SubXError::FileMatching {
495 message: "No video files found in directory; cannot sync any subtitles"
496 .to_string(),
497 });
498 }
499 return Ok(());
500 }
501
502 if video_files.len() == 1 && subtitle_files.len() == 1 {
504 let mut single_args = args.clone();
505 single_args.input_paths.clear();
506 single_args.batch = None;
507 single_args.recursive = false;
508 single_args.video = Some(video_files[0].clone());
509 single_args.subtitle = Some(subtitle_files[0].clone());
510 if single_args.output.is_none() {
512 if let Some(archive_path) = paths.archive_origin(subtitle_files[0]) {
513 if let Some(archive_dir) = archive_path.parent() {
514 let default = create_default_output_path(subtitle_files[0]);
515 if let Some(filename) = default.file_name() {
516 single_args.output = Some(archive_dir.join(filename));
517 }
518 }
519 }
520 }
521 let pair = run_single(&single_args, &config, &sync_engine, &format_manager).await?;
522 if json {
523 emit_success(
524 mode,
525 "sync",
526 SyncPayload {
527 method: method_string,
528 inputs: vec![pair.input],
529 operations: vec![pair.operation],
530 },
531 );
532 }
533 return Ok(());
534 }
535
536 let mut processed_videos = std::collections::HashSet::new();
538 let mut processed_subtitles = std::collections::HashSet::new();
539
540 for sub_path in &subtitle_files {
542 let sub_name = sub_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
543 let sub_dir = sub_path.parent();
544
545 let matching_video = video_files.iter().find(|&video_path| {
546 let video_name = video_path
547 .file_stem()
548 .and_then(|s| s.to_str())
549 .unwrap_or("");
550 let video_dir = video_path.parent();
551
552 if sub_dir != video_dir {
554 return false;
555 }
556
557 let dir_videos: Vec<_> = video_files
559 .iter()
560 .filter(|v| v.parent() == video_dir)
561 .collect();
562 let dir_subtitles: Vec<_> = subtitle_files
563 .iter()
564 .filter(|s| s.parent() == sub_dir)
565 .collect();
566
567 if dir_videos.len() == 1 && dir_subtitles.len() == 1 {
568 return true;
570 }
571
572 !video_name.is_empty() && sub_name.starts_with(video_name)
574 });
575
576 if let Some(video_path) = matching_video {
577 let mut single_args = args.clone();
578 single_args.input_paths.clear();
579 single_args.batch = None;
580 single_args.recursive = false;
581 single_args.video = Some((*video_path).clone());
582 single_args.subtitle = Some((*sub_path).clone());
583 if single_args.output.is_none() {
585 if let Some(archive_path) = paths.archive_origin(sub_path) {
586 if let Some(archive_dir) = archive_path.parent() {
587 let default = create_default_output_path(sub_path);
588 if let Some(filename) = default.file_name() {
589 single_args.output = Some(archive_dir.join(filename));
590 }
591 }
592 }
593 }
594 match run_single(&single_args, &config, &sync_engine, &format_manager).await {
598 Ok(pair) => {
599 if json {
600 inputs.push(pair.input);
601 operations.push(pair.operation);
602 }
603 }
604 Err(err) => {
605 if !json {
606 return Err(err);
608 }
609 let item_err = SyncItemError {
610 code: err.machine_code().to_string(),
611 category: err.category().to_string(),
612 message: err.user_friendly_message(),
613 };
614 let subtitle_str = sub_path.display().to_string();
615 let audio_str = (*video_path).display().to_string();
616 inputs.push(SyncInput {
617 subtitle_path: subtitle_str.clone(),
618 audio_path: Some(audio_str),
619 detected_offset_ms: 0,
620 confidence: None,
621 vad: None,
622 status: "error",
623 error: Some(item_err.clone()),
624 });
625 operations.push(SyncOperation {
626 subtitle_path: subtitle_str,
627 output_path: None,
628 applied: false,
629 dry_run: args.dry_run,
630 status: "error",
631 error: Some(item_err),
632 });
633 }
634 }
635
636 processed_videos.insert(video_path.as_path());
637 processed_subtitles.insert(sub_path.as_path());
638 }
639 }
640
641 for video_path in &video_files {
643 if !processed_videos.contains(video_path.as_path()) && !json {
644 println!(
645 "✗ Skip sync for {}: no matching subtitle",
646 video_path.display()
647 );
648 }
649 }
650
651 for sub_path in &subtitle_files {
653 if !processed_subtitles.contains(sub_path.as_path()) {
654 if !json {
655 println!("✗ Skip sync for {}: no matching video", sub_path.display());
656 } else {
657 let (input, op) =
658 make_skip_input_op(sub_path, None, "no matching video", args.dry_run);
659 inputs.push(input);
660 operations.push(op);
661 }
662 }
663 }
664
665 if json {
666 let forward_progress =
667 inputs.iter().any(|i| i.status == "ok") || operations.iter().any(|o| o.applied);
668 if !forward_progress {
669 return Err(SubXError::FileMatching {
670 message: "No subtitle/video pairs were synced successfully".to_string(),
671 });
672 }
673 emit_success(
674 mode,
675 "sync",
676 SyncPayload {
677 method: method_string,
678 inputs,
679 operations,
680 },
681 );
682 }
683 return Ok(());
684 }
685
686 match args.get_sync_mode() {
688 Ok(SyncMode::Single { video, subtitle }) => {
689 let mut resolved_args = args.clone();
691 if !video.as_os_str().is_empty() {
692 resolved_args.video = Some(video.clone());
693 }
694 resolved_args.subtitle = Some(subtitle.clone());
695 if resolved_args.video.is_none() && resolved_args.offset.is_none() {
697 resolved_args.offset = Some(0.0);
698 resolved_args.method = Some(crate::cli::SyncMethodArg::Manual);
699 }
700 let method_string = resolve_method_string(&resolved_args, &config.sync.default_method);
701 let pair = run_single(&resolved_args, &config, &sync_engine, &format_manager).await?;
702 if json {
703 emit_success(
704 mode,
705 "sync",
706 SyncPayload {
707 method: method_string,
708 inputs: vec![pair.input],
709 operations: vec![pair.operation],
710 },
711 );
712 }
713 Ok(())
714 }
715 Err(err) => Err(err),
716 _ => unreachable!(),
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use crate::config::TestConfigService;
724 use std::fs;
725 use std::sync::Arc;
726 use tempfile::TempDir;
727
728 #[tokio::test]
729 async fn test_sync_batch_processing() -> Result<()> {
730 let config_service = Arc::new(TestConfigService::with_sync_settings(0.5, 30.0));
732
733 let tmp = TempDir::new().unwrap();
735 let video1 = tmp.path().join("movie1.mp4");
736 let sub1 = tmp.path().join("movie1.srt");
737 fs::write(&video1, b"").unwrap();
738 fs::write(&sub1, b"1\n00:00:01,000 --> 00:00:02,000\nTest1\n\n").unwrap();
739
740 let args = SyncArgs {
742 positional_paths: Vec::new(),
743 video: Some(video1.clone()),
744 subtitle: Some(sub1.clone()),
745 input_paths: vec![],
746 recursive: false,
747 offset: Some(1.0), method: Some(crate::cli::SyncMethodArg::Manual),
749 window: 30,
750 vad_sensitivity: None,
751 output: None,
752 verbose: false,
753 dry_run: true, force: true,
755 batch: None, no_extract: false,
757 };
758
759 execute(args, config_service.as_ref()).await?;
760
761 Ok(())
763 }
764}
765
766pub async fn execute_with_config(
768 args: SyncArgs,
769 config_service: std::sync::Arc<dyn ConfigService>,
770) -> Result<()> {
771 execute(args, config_service.as_ref()).await
772}
773
774fn determine_sync_method(args: &SyncArgs, default_method: &str) -> Result<SyncMethod> {
785 if let Some(ref method_arg) = args.method {
787 return Ok(method_arg.clone().into());
788 }
789 if args.vad_sensitivity.is_some() {
791 return Ok(SyncMethod::LocalVad);
792 }
793 match default_method {
795 "vad" => Ok(SyncMethod::LocalVad),
796 "auto" => Ok(SyncMethod::Auto),
797 _ => Ok(SyncMethod::Auto),
798 }
799}
800
801fn apply_cli_overrides(config: &mut crate::config::SyncConfig, args: &SyncArgs) -> Result<()> {
808 if let Some(sensitivity) = args.vad_sensitivity {
810 config.vad.sensitivity = sensitivity;
811 }
812
813 Ok(())
814}
815
816fn display_sync_result(result: &SyncResult, verbose: bool) {
823 if verbose {
824 println!("\n=== Sync Results ===");
825 println!("Method used: {:?}", result.method_used);
826 println!("Detected offset: {:.3} seconds", result.offset_seconds);
827 println!("Confidence: {:.1}%", result.confidence * 100.0);
828 println!("Processing time: {:?}", result.processing_duration);
829
830 if !result.warnings.is_empty() {
831 println!("\nWarnings:");
832 for warning in &result.warnings {
833 println!(" ⚠️ {warning}");
834 }
835 }
836
837 if let Some(info) = &result.additional_info {
838 if let Ok(pretty_info) = serde_json::to_string_pretty(info) {
839 println!("\nAdditional information:");
840 println!("{pretty_info}");
841 }
842 }
843 } else {
844 println!(
845 "✅ Sync completed: offset {:.3}s (confidence: {:.1}%)",
846 result.offset_seconds,
847 result.confidence * 100.0
848 );
849 }
850}