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