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::config::Config;
10use crate::config::ConfigService;
11use crate::core::formats::manager::FormatManager;
12use crate::core::sync::{SyncEngine, SyncMethod, SyncResult};
13use crate::{Result, error::SubXError};
14
15/// Internal helper to perform a single video-subtitle synchronization.
16async fn run_single(
17    args: &SyncArgs,
18    config: &Config,
19    sync_engine: &SyncEngine,
20    format_manager: &FormatManager,
21) -> Result<()> {
22    let subtitle_path = args.subtitle.as_ref().ok_or_else(|| {
23        SubXError::CommandExecution(
24            "Subtitle file path is required for single file sync".to_string(),
25        )
26    })?;
27
28    if args.verbose {
29        println!("🎬 Loading subtitle file: {}", subtitle_path.display());
30        println!("📄 Subtitle entries count: {}", {
31            let s = format_manager.load_subtitle(subtitle_path)?;
32            s.entries.len()
33        });
34    }
35    let mut subtitle = format_manager.load_subtitle(subtitle_path)?;
36    let sync_result = if let Some(offset) = args.offset {
37        if args.verbose {
38            println!("⚙️  Using manual offset: {:.3}s", offset);
39        }
40        sync_engine.apply_manual_offset(&mut subtitle, offset)?;
41        SyncResult {
42            offset_seconds: offset,
43            confidence: 1.0,
44            method_used: crate::core::sync::SyncMethod::Manual,
45            correlation_peak: 0.0,
46            processing_duration: std::time::Duration::ZERO,
47            warnings: Vec::new(),
48            additional_info: None,
49        }
50    } else {
51        // Automatic sync requires video file
52        let video_path = args.video.as_ref().ok_or_else(|| {
53            SubXError::CommandExecution(
54                "Video file path is required for automatic sync".to_string(),
55            )
56        })?;
57        let method = determine_sync_method(args, &config.sync.default_method)?;
58        if args.verbose {
59            println!("🔍 Starting sync analysis...");
60            println!("   Method: {:?}", method);
61            println!("   Analysis window: {}s", args.window);
62            println!("   Video file: {}", video_path.display());
63        }
64        let mut sync_cfg = config.sync.clone();
65        apply_cli_overrides(&mut sync_cfg, args)?;
66        let result = sync_engine
67            .detect_sync_offset(video_path.as_path(), &subtitle, Some(method))
68            .await?;
69        if args.verbose {
70            println!("✅ Analysis completed:");
71            println!("   Detected offset: {:.3}s", result.offset_seconds);
72            println!("   Confidence: {:.1}%", result.confidence * 100.0);
73            println!("   Processing time: {:?}", result.processing_duration);
74        }
75        if !args.dry_run {
76            sync_engine.apply_manual_offset(&mut subtitle, result.offset_seconds)?;
77        }
78        result
79    };
80    display_sync_result(&sync_result, args.verbose);
81    if !args.dry_run {
82        if let Some(out) = args.get_output_path() {
83            if out.exists() && !args.force {
84                return Err(SubXError::CommandExecution(format!(
85                    "Output file already exists: {}. Use --force to overwrite.",
86                    out.display()
87                )));
88            }
89            format_manager.save_subtitle(&subtitle, &out)?;
90            if args.verbose {
91                println!("💾 Synchronized subtitle saved to: {}", out.display());
92            } else {
93                println!("Synchronized subtitle saved to: {}", out.display());
94            }
95        } else {
96            return Err(SubXError::CommandExecution(
97                "No output path specified".to_string(),
98            ));
99        }
100    } else {
101        println!("🔍 Dry run mode - file not saved");
102    }
103    Ok(())
104}
105
106/// Execute the sync command with the provided arguments.
107///
108/// This function handles both manual offset synchronization and automatic
109/// synchronization using various detection methods.
110///
111/// # Arguments
112///
113/// * `args` - The sync command arguments containing input files and options
114/// * `config_service` - Service for accessing configuration settings
115///
116/// # Returns
117///
118/// Returns `Ok(())` on successful synchronization, or an error if the operation fails
119///
120/// # Errors
121///
122/// This function returns an error if:
123/// - Arguments validation fails
124/// - Subtitle file cannot be loaded
125/// - Video file is required but not provided for automatic sync
126/// - Output file already exists and force flag is not set
127/// - Synchronization detection fails
128///
129/// Execute the sync command with the provided arguments.
130///
131/// Handles both single and batch synchronization modes.
132pub async fn execute(args: SyncArgs, config_service: &dyn ConfigService) -> Result<()> {
133    // Validate arguments and prepare resources
134    if let Err(msg) = args.validate() {
135        return Err(SubXError::CommandExecution(msg));
136    }
137    let config = config_service.get_config()?;
138
139    // Validate manual offset against max_offset_seconds configuration
140    if let Some(manual_offset) = args.offset {
141        if manual_offset.abs() > config.sync.max_offset_seconds {
142            return Err(SubXError::config(format!(
143                "The specified offset {:.2}s exceeds the configured maximum allowed value {:.2}s.\n\n\
144                Please use one of the following methods to resolve this issue:\n\
145                1. Use a smaller offset: --offset {:.2}\n\
146                2. Adjust configuration: subx-cli config set sync.max_offset_seconds {:.2}\n\
147                3. Use automatic detection: remove the --offset parameter",
148                manual_offset,
149                config.sync.max_offset_seconds,
150                config.sync.max_offset_seconds * 0.9, // Recommended value slightly below limit
151                manual_offset
152                    .abs()
153                    .max(config.sync.max_offset_seconds * 1.5) // Recommend increasing to appropriate value
154            )));
155        }
156    }
157
158    let sync_engine = SyncEngine::new(config.sync.clone())?;
159    let format_manager = FormatManager::new();
160
161    // Batch mode: multiple video-subtitle pairs
162    if let Ok(SyncMode::Batch(handler)) = args.get_sync_mode() {
163        let paths = handler
164            .collect_files()
165            .map_err(|e| SubXError::CommandExecution(e.to_string()))?;
166        for path in &paths {
167            if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
168                let ext = ext.to_lowercase();
169                if ["mp4", "mkv", "avi", "mov"].contains(&ext.as_str()) {
170                    let stem = path
171                        .file_stem()
172                        .map(|s| s.to_string_lossy().to_string())
173                        .unwrap_or_default();
174                    if let Some(sub_path) = paths.iter().find(|p| {
175                        p.file_stem()
176                            .map(|s| s.to_string_lossy() == stem)
177                            .unwrap_or(false)
178                            && p.extension()
179                                .and_then(|s| s.to_str())
180                                .map(|e| {
181                                    matches!(
182                                        e.to_lowercase().as_str(),
183                                        "srt" | "ass" | "vtt" | "sub"
184                                    )
185                                })
186                                .unwrap_or(false)
187                    }) {
188                        let mut single_args = args.clone();
189                        single_args.input_paths.clear();
190                        single_args.batch = false;
191                        single_args.recursive = false;
192                        single_args.video = Some(path.clone());
193                        single_args.subtitle = Some(sub_path.clone());
194                        run_single(&single_args, &config, &sync_engine, &format_manager).await?;
195                    } else {
196                        eprintln!("✗ Skip sync for {}: no matching subtitle", path.display());
197                    }
198                }
199            }
200        }
201        return Ok(());
202    }
203
204    // Single mode or error
205    match args.get_sync_mode() {
206        Ok(SyncMode::Single { .. }) => {
207            run_single(&args, &config, &sync_engine, &format_manager).await?;
208            Ok(())
209        }
210        Err(err) => Err(err),
211        _ => unreachable!(),
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::config::TestConfigService;
219    use std::fs;
220    use std::sync::Arc;
221    use tempfile::TempDir;
222
223    #[tokio::test]
224    async fn test_sync_batch_processing() -> Result<()> {
225        // Prepare test configuration
226        let config_service = Arc::new(TestConfigService::with_sync_settings(0.5, 30.0));
227
228        // Create temporary directory with video and subtitle files
229        let tmp = TempDir::new().unwrap();
230        let video1 = tmp.path().join("movie1.mp4");
231        let sub1 = tmp.path().join("movie1.srt");
232        fs::write(&video1, b"").unwrap();
233        fs::write(&sub1, b"1\n00:00:01,000 --> 00:00:02,000\nTest1\n\n").unwrap();
234
235        // Test single file sync instead of batch to avoid audio processing issues
236        let args = SyncArgs {
237            video: Some(video1.clone()),
238            subtitle: Some(sub1.clone()),
239            input_paths: vec![],
240            recursive: false,
241            offset: Some(1.0), // Use manual offset to avoid audio processing
242            method: Some(crate::cli::SyncMethodArg::Manual),
243            window: 30,
244            vad_sensitivity: None,
245            output: None,
246            verbose: false,
247            dry_run: true, // Use dry run to avoid file creation
248            force: true,
249            batch: false, // Disable batch mode
250            #[allow(deprecated)]
251            range: None,
252            #[allow(deprecated)]
253            threshold: None,
254        };
255
256        execute(args, config_service.as_ref()).await?;
257
258        // In dry run mode, files are not actually created, so we just verify the command executed successfully
259        Ok(())
260    }
261}
262
263/// Maintain consistency with other commands
264pub async fn execute_with_config(
265    args: SyncArgs,
266    config_service: std::sync::Arc<dyn ConfigService>,
267) -> Result<()> {
268    execute(args, config_service.as_ref()).await
269}
270
271/// Determine the sync method to use based on CLI arguments and configuration.
272///
273/// # Arguments
274///
275/// * `args` - CLI arguments which may specify a sync method
276/// * `default_method` - Default method from configuration
277///
278/// # Returns
279///
280/// The determined sync method to use
281fn determine_sync_method(args: &SyncArgs, default_method: &str) -> Result<SyncMethod> {
282    // If CLI specifies a method, use it
283    if let Some(ref method_arg) = args.method {
284        return Ok(method_arg.clone().into());
285    }
286
287    // Otherwise use the default method from configuration
288    match default_method {
289        "vad" => Ok(SyncMethod::LocalVad),
290        "auto" => Ok(SyncMethod::Auto),
291        _ => Ok(SyncMethod::Auto),
292    }
293}
294
295/// Apply CLI argument overrides to the sync configuration.
296///
297/// # Arguments
298///
299/// * `config` - Sync configuration to modify
300/// * `args` - CLI arguments containing overrides
301fn apply_cli_overrides(config: &mut crate::config::SyncConfig, args: &SyncArgs) -> Result<()> {
302    // Apply VAD-specific overrides
303    if let Some(sensitivity) = args.vad_sensitivity {
304        config.vad.sensitivity = sensitivity;
305    }
306
307    Ok(())
308}
309
310/// Display sync result information to the user.
311///
312/// # Arguments
313///
314/// * `result` - The sync result to display
315/// * `verbose` - Whether to show detailed information
316fn display_sync_result(result: &SyncResult, verbose: bool) {
317    if verbose {
318        println!("\n=== Sync Results ===");
319        println!("Method used: {:?}", result.method_used);
320        println!("Detected offset: {:.3} seconds", result.offset_seconds);
321        println!("Confidence: {:.1}%", result.confidence * 100.0);
322        println!("Processing time: {:?}", result.processing_duration);
323
324        if !result.warnings.is_empty() {
325            println!("\nWarnings:");
326            for warning in &result.warnings {
327                println!("  ⚠️  {}", warning);
328            }
329        }
330
331        if let Some(info) = &result.additional_info {
332            if let Ok(pretty_info) = serde_json::to_string_pretty(info) {
333                println!("\nAdditional information:");
334                println!("{}", pretty_info);
335            }
336        }
337    } else {
338        println!(
339            "✅ Sync completed: offset {:.3}s (confidence: {:.1}%)",
340            result.offset_seconds,
341            result.confidence * 100.0
342        );
343    }
344}