Skip to main content

subx_cli/commands/
sync_command.rs

1//! Refactored sync command supporting new multi-method sync engine.
2//!
3//! This module provides the synchronization command functionality, supporting
4//! multiple synchronization methods including local VAD (Voice Activity Detection),
5//! automatic method selection, and manual offset adjustment.
6
7use 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
18/// Approximate VAD chunk duration in milliseconds.
19///
20/// Silero VAD operates on 512 samples at 16 kHz (≈32 ms) and 256 samples
21/// at 8 kHz (≈32 ms). The exact chunk duration depends on the audio
22/// sample rate; 32 ms is the value used for both supported rates.
23const VAD_CHUNK_MS: u32 = 32;
24
25/// JSON payload emitted under `data` of the top-level envelope by
26/// `subx-cli --output json sync`.
27///
28/// The shape is uniform across single-pair and batch invocations: an
29/// `inputs` array describing each subtitle that was analyzed, an
30/// `operations` array describing each file that was written or
31/// planned, and a top-level `method` string identifying the active
32/// synchronization strategy.
33#[derive(Debug, Serialize)]
34pub struct SyncPayload {
35    /// Active sync method: `"vad"`, `"manual"`, or `"auto"`.
36    pub method: String,
37    /// Per-subtitle analysis results, one entry per processed input.
38    pub inputs: Vec<SyncInput>,
39    /// Per-subtitle write operations, one entry per planned/applied write.
40    pub operations: Vec<SyncOperation>,
41}
42
43/// One entry in [`SyncPayload::inputs`] — describes the analysis stage
44/// for a single subtitle file.
45#[derive(Debug, Serialize)]
46pub struct SyncInput {
47    /// Subtitle file path that was analyzed.
48    pub subtitle_path: String,
49    /// Audio/video source used for analysis (absent for manual offsets
50    /// and skipped items).
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub audio_path: Option<String>,
53    /// Detected offset in milliseconds (positive = subtitles delayed).
54    /// For manual sync this equals the user-supplied offset.
55    pub detected_offset_ms: i64,
56    /// Detection confidence (0.0–1.0); absent for manual and skipped.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub confidence: Option<f32>,
59    /// VAD-specific metadata (only populated when `method == "vad"`).
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub vad: Option<VadInfoPayload>,
62    /// Either `"ok"` or `"error"`.
63    pub status: &'static str,
64    /// Error metadata when `status == "error"`.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub error: Option<SyncItemError>,
67}
68
69/// One entry in [`SyncPayload::operations`] — describes a planned or
70/// applied write to disk.
71#[derive(Debug, Serialize)]
72pub struct SyncOperation {
73    /// Subtitle source path the operation derives from.
74    pub subtitle_path: String,
75    /// Path the synchronized subtitle was (or would have been) written to.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub output_path: Option<String>,
78    /// True when the synchronized subtitle was actually written to disk.
79    pub applied: bool,
80    /// True when `--dry-run` was supplied.
81    pub dry_run: bool,
82    /// Either `"ok"` or `"error"`.
83    pub status: &'static str,
84    /// Error metadata when `status == "error"`.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub error: Option<SyncItemError>,
87}
88
89/// VAD-specific metadata included in [`SyncInput::vad`].
90#[derive(Debug, Serialize)]
91pub struct VadInfoPayload {
92    /// VAD sensitivity threshold in effect (0.0–1.0).
93    pub sensitivity: f32,
94    /// Padding around detected speech, expressed in milliseconds.
95    pub padding_ms: u32,
96    /// Detected speech segments (`{start,end,duration}` objects).
97    pub segments: Vec<serde_json::Value>,
98}
99
100/// Stable per-item error payload (mirrors the top-level error envelope
101/// minus `exit_code`).
102#[derive(Debug, Serialize, Clone)]
103pub struct SyncItemError {
104    /// Stable machine code (e.g. `E_SUBTITLE_FORMAT`).
105    pub code: String,
106    /// Stable category (e.g. `subtitle_format`).
107    pub category: String,
108    /// Human-readable message.
109    pub message: String,
110}
111
112/// Internal aggregate produced by [`run_single`] — pairs the input
113/// analysis record with its companion write operation.
114struct 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
179/// Resolve the active sync method as a stable string for the
180/// top-level [`SyncPayload::method`] field. Mirrors the dispatch
181/// rules in [`determine_sync_method`] but operates without an audio
182/// source so it can be evaluated even when no items succeed.
183fn 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
232/// Internal helper to perform a single video-subtitle synchronization.
233///
234/// Returns a [`SyncSingleResult`] describing the operation. Prose stdout
235/// chatter is suppressed when JSON mode is active; the caller decides
236/// whether to wrap the pair in a single-pair [`SyncPayload`] or stitch
237/// it into a batch result.
238async 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        // Automatic sync requires video file
288        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        // Check if video path is empty (manual mode case)
295        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
382/// Execute the sync command with the provided arguments.
383///
384/// This function handles both manual offset synchronization and automatic
385/// synchronization using various detection methods.
386///
387/// # Arguments
388///
389/// * `args` - The sync command arguments containing input files and options
390/// * `config_service` - Service for accessing configuration settings
391///
392/// # Returns
393///
394/// Returns `Ok(())` on successful synchronization, or an error if the operation fails
395///
396/// # Errors
397///
398/// This function returns an error if:
399/// - Arguments validation fails
400/// - Subtitle file cannot be loaded
401/// - Video file is required but not provided for automatic sync
402/// - Output file already exists and force flag is not set
403/// - Synchronization detection fails
404///
405/// Execute the sync command with the provided arguments.
406///
407/// Handles both single and batch synchronization modes.
408pub async fn execute(args: SyncArgs, config_service: &dyn ConfigService) -> Result<()> {
409    // Validate arguments and prepare resources
410    if let Err(msg) = args.validate() {
411        return Err(SubXError::CommandExecution(msg));
412    }
413    let config = config_service.get_config()?;
414
415    // Validate manual offset against max_offset_seconds configuration
416    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, // Recommended value slightly below limit
427                manual_offset
428                    .abs()
429                    .max(config.sync.max_offset_seconds * 1.5) // Recommend increasing to appropriate value
430            )));
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    // Batch mode: multiple video-subtitle pairs
440    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        // Separate video and subtitle files
446        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        // Case 1: No video files - skip all subtitles
471        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                // Forward progress is impossible without a video — emit a
492                // top-level error envelope instead of a success envelope
493                // wrapping all-error items.
494                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        // Case 2: Exactly one video and one subtitle - sync regardless of name match
503        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 subtitle came from an archive, redirect output beside the archive
511            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        // Case 3: Multiple videos/subtitles - match by prefix and handle unmatched
537        let mut processed_videos = std::collections::HashSet::new();
538        let mut processed_subtitles = std::collections::HashSet::new();
539
540        // Process subtitle files with matching videos
541        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                // Check if they are in the same directory
553                if sub_dir != video_dir {
554                    return false;
555                }
556
557                // If in the same directory, check if it's a 1-to-1 pair
558                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                    // 1-to-1 in same directory - always match
569                    return true;
570                }
571
572                // Otherwise use starts_with logic
573                !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 subtitle came from an archive, redirect output beside the archive
584                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                // Per-file isolation: capture errors as per-item failures so
595                // the top-level batch envelope can stay `status == "ok"`
596                // when at least one item makes forward progress.
597                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                            // Preserve text-mode contract: stop on first error.
607                            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        // Display skip messages for unmatched videos
642        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        // Display skip messages for unmatched subtitles
652        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    // Single mode or error
687    match args.get_sync_mode() {
688        Ok(SyncMode::Single { video, subtitle }) => {
689            // Update args with the resolved paths from SyncMode
690            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            // For subtitle-only sync without offset, default to zero manual offset
696            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        // Prepare test configuration
731        let config_service = Arc::new(TestConfigService::with_sync_settings(0.5, 30.0));
732
733        // Create temporary directory with video and subtitle files
734        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        // Test single file sync instead of batch to avoid audio processing issues
741        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), // Use manual offset to avoid audio processing
748            method: Some(crate::cli::SyncMethodArg::Manual),
749            window: 30,
750            vad_sensitivity: None,
751            output: None,
752            verbose: false,
753            dry_run: true, // Use dry run to avoid file creation
754            force: true,
755            batch: None, // Disable batch mode,
756            no_extract: false,
757        };
758
759        execute(args, config_service.as_ref()).await?;
760
761        // In dry run mode, files are not actually created, so we just verify the command executed successfully
762        Ok(())
763    }
764}
765
766/// Maintain consistency with other commands
767pub 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
774/// Determine the sync method to use based on CLI arguments and configuration.
775///
776/// # Arguments
777///
778/// * `args` - CLI arguments which may specify a sync method
779/// * `default_method` - Default method from configuration
780///
781/// # Returns
782///
783/// The determined sync method to use
784fn determine_sync_method(args: &SyncArgs, default_method: &str) -> Result<SyncMethod> {
785    // If CLI specifies a method, use it
786    if let Some(ref method_arg) = args.method {
787        return Ok(method_arg.clone().into());
788    }
789    // If VAD sensitivity specified, default to VAD method
790    if args.vad_sensitivity.is_some() {
791        return Ok(SyncMethod::LocalVad);
792    }
793    // Otherwise use the default method from configuration
794    match default_method {
795        "vad" => Ok(SyncMethod::LocalVad),
796        "auto" => Ok(SyncMethod::Auto),
797        _ => Ok(SyncMethod::Auto),
798    }
799}
800
801/// Apply CLI argument overrides to the sync configuration.
802///
803/// # Arguments
804///
805/// * `config` - Sync configuration to modify
806/// * `args` - CLI arguments containing overrides
807fn apply_cli_overrides(config: &mut crate::config::SyncConfig, args: &SyncArgs) -> Result<()> {
808    // Apply VAD-specific overrides
809    if let Some(sensitivity) = args.vad_sensitivity {
810        config.vad.sensitivity = sensitivity;
811    }
812
813    Ok(())
814}
815
816/// Display sync result information to the user.
817///
818/// # Arguments
819///
820/// * `result` - The sync result to display
821/// * `verbose` - Whether to show detailed information
822fn 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}