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