1use 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
15async 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).map_err(|e| {
32 eprintln!("[DEBUG] Failed to load subtitle: {e}");
33 e
34 })?;
35 s.entries.len()
36 });
37 }
38 let mut subtitle = format_manager.load_subtitle(subtitle_path).map_err(|e| {
39 eprintln!("[DEBUG] Failed to load subtitle: {e}");
40 e
41 })?;
42 let sync_result = if let Some(offset) = args.offset {
43 if args.verbose {
44 println!("⚙️ Using manual offset: {offset:.3}s");
45 }
46 sync_engine
47 .apply_manual_offset(&mut subtitle, offset)
48 .map_err(|e| {
49 eprintln!("[DEBUG] Failed to apply manual offset: {e}");
50 e
51 })?;
52 SyncResult {
53 offset_seconds: offset,
54 confidence: 1.0,
55 method_used: crate::core::sync::SyncMethod::Manual,
56 correlation_peak: 0.0,
57 processing_duration: std::time::Duration::ZERO,
58 warnings: Vec::new(),
59 additional_info: None,
60 }
61 } else {
62 let video_path = args.video.as_ref().ok_or_else(|| {
64 SubXError::CommandExecution(
65 "Video file path is required for automatic sync".to_string(),
66 )
67 })?;
68
69 if video_path.as_os_str().is_empty() {
71 return Err(SubXError::CommandExecution(
72 "Video file path is required for automatic sync".to_string(),
73 ));
74 }
75
76 let method = determine_sync_method(args, &config.sync.default_method)?;
77 if args.verbose {
78 println!("🔍 Starting sync analysis...");
79 println!(" Method: {method:?}");
80 println!(" Analysis window: {}s", args.window);
81 println!(" Video file: {}", video_path.display());
82 }
83 let mut sync_cfg = config.sync.clone();
84 apply_cli_overrides(&mut sync_cfg, args)?;
85 let result = sync_engine
86 .detect_sync_offset(video_path.as_path(), &subtitle, Some(method))
87 .await
88 .map_err(|e| {
89 eprintln!("[DEBUG] Failed to detect sync offset: {e}");
90 e
91 })?;
92 if args.verbose {
93 println!("✅ Analysis completed:");
94 println!(" Detected offset: {:.3}s", result.offset_seconds);
95 println!(" Confidence: {:.1}%", result.confidence * 100.0);
96 println!(" Processing time: {:?}", result.processing_duration);
97 }
98 if !args.dry_run {
99 sync_engine
100 .apply_manual_offset(&mut subtitle, result.offset_seconds)
101 .map_err(|e| {
102 eprintln!("[DEBUG] Failed to apply detected offset: {e}");
103 e
104 })?;
105 }
106 result
107 };
108 display_sync_result(&sync_result, args.verbose);
109 if !args.dry_run {
110 if let Some(out) = args.get_output_path() {
111 if out.exists() && !args.force {
112 eprintln!(
113 "[DEBUG] Output file exists and --force not set: {}",
114 out.display()
115 );
116 return Err(SubXError::CommandExecution(format!(
117 "Output file already exists: {}. Use --force to overwrite.",
118 out.display()
119 )));
120 }
121 format_manager.save_subtitle(&subtitle, &out).map_err(|e| {
122 eprintln!("[DEBUG] Failed to save subtitle: {e}");
123 e
124 })?;
125 if args.verbose {
126 println!("💾 Synchronized subtitle saved to: {}", out.display());
127 } else {
128 println!("Synchronized subtitle saved to: {}", out.display());
129 }
130 } else {
131 eprintln!("[DEBUG] No output path specified");
132 return Err(SubXError::CommandExecution(
133 "No output path specified".to_string(),
134 ));
135 }
136 } else {
137 println!("🔍 Dry run mode - file not saved");
138 }
139 Ok(())
140}
141
142pub async fn execute(args: SyncArgs, config_service: &dyn ConfigService) -> Result<()> {
169 if let Err(msg) = args.validate() {
171 return Err(SubXError::CommandExecution(msg));
172 }
173 let config = config_service.get_config()?;
174
175 if let Some(manual_offset) = args.offset {
177 if manual_offset.abs() > config.sync.max_offset_seconds {
178 return Err(SubXError::config(format!(
179 "The specified offset {:.2}s exceeds the configured maximum allowed value {:.2}s.\n\n\
180 Please use one of the following methods to resolve this issue:\n\
181 1. Use a smaller offset: --offset {:.2}\n\
182 2. Adjust configuration: subx-cli config set sync.max_offset_seconds {:.2}\n\
183 3. Use automatic detection: remove the --offset parameter",
184 manual_offset,
185 config.sync.max_offset_seconds,
186 config.sync.max_offset_seconds * 0.9, manual_offset
188 .abs()
189 .max(config.sync.max_offset_seconds * 1.5) )));
191 }
192 }
193
194 let sync_engine = SyncEngine::new(config.sync.clone())?;
195 let format_manager = FormatManager::new();
196
197 if let Ok(SyncMode::Batch(handler)) = args.get_sync_mode() {
199 let paths = handler
200 .collect_files()
201 .map_err(|e| SubXError::CommandExecution(e.to_string()))?;
202
203 let video_files: Vec<_> = paths
205 .iter()
206 .filter(|p| {
207 p.extension()
208 .and_then(|s| s.to_str())
209 .map(|e| ["mp4", "mkv", "avi", "mov"].contains(&e.to_lowercase().as_str()))
210 .unwrap_or(false)
211 })
212 .collect();
213
214 let subtitle_files: Vec<_> = paths
215 .iter()
216 .filter(|p| {
217 p.extension()
218 .and_then(|s| s.to_str())
219 .map(|e| ["srt", "ass", "vtt", "sub"].contains(&e.to_lowercase().as_str()))
220 .unwrap_or(false)
221 })
222 .collect();
223
224 if video_files.is_empty() {
226 for sub_path in &subtitle_files {
227 println!(
228 "✗ Skip sync for {}: no video files found in directory",
229 sub_path.display()
230 );
231 }
232 return Ok(());
233 }
234
235 if video_files.len() == 1 && subtitle_files.len() == 1 {
237 let mut single_args = args.clone();
238 single_args.input_paths.clear();
239 single_args.batch = None;
240 single_args.recursive = false;
241 single_args.video = Some(video_files[0].clone());
242 single_args.subtitle = Some(subtitle_files[0].clone());
243 run_single(&single_args, &config, &sync_engine, &format_manager).await?;
244 return Ok(());
245 }
246
247 let mut processed_videos = std::collections::HashSet::new();
249 let mut processed_subtitles = std::collections::HashSet::new();
250
251 for sub_path in &subtitle_files {
253 let sub_name = sub_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
254 let sub_dir = sub_path.parent();
255
256 let matching_video = video_files.iter().find(|&video_path| {
257 let video_name = video_path
258 .file_stem()
259 .and_then(|s| s.to_str())
260 .unwrap_or("");
261 let video_dir = video_path.parent();
262
263 if sub_dir != video_dir {
265 return false;
266 }
267
268 let dir_videos: Vec<_> = video_files
270 .iter()
271 .filter(|v| v.parent() == video_dir)
272 .collect();
273 let dir_subtitles: Vec<_> = subtitle_files
274 .iter()
275 .filter(|s| s.parent() == sub_dir)
276 .collect();
277
278 if dir_videos.len() == 1 && dir_subtitles.len() == 1 {
279 return true;
281 }
282
283 !video_name.is_empty() && sub_name.starts_with(video_name)
285 });
286
287 if let Some(video_path) = matching_video {
288 let mut single_args = args.clone();
289 single_args.input_paths.clear();
290 single_args.batch = None;
291 single_args.recursive = false;
292 single_args.video = Some((*video_path).clone());
293 single_args.subtitle = Some((*sub_path).clone());
294 run_single(&single_args, &config, &sync_engine, &format_manager).await?;
295
296 processed_videos.insert(video_path.as_path());
297 processed_subtitles.insert(sub_path.as_path());
298 }
299 }
300
301 for video_path in &video_files {
303 if !processed_videos.contains(video_path.as_path()) {
304 println!(
305 "✗ Skip sync for {}: no matching subtitle",
306 video_path.display()
307 );
308 }
309 }
310
311 for sub_path in &subtitle_files {
313 if !processed_subtitles.contains(sub_path.as_path()) {
314 println!("✗ Skip sync for {}: no matching video", sub_path.display());
315 }
316 }
317
318 return Ok(());
319 }
320
321 match args.get_sync_mode() {
323 Ok(SyncMode::Single { video, subtitle }) => {
324 let mut resolved_args = args.clone();
326 if !video.as_os_str().is_empty() {
327 resolved_args.video = Some(video.clone());
328 }
329 resolved_args.subtitle = Some(subtitle.clone());
330 if resolved_args.video.is_none() && resolved_args.offset.is_none() {
332 resolved_args.offset = Some(0.0);
333 resolved_args.method = Some(crate::cli::SyncMethodArg::Manual);
334 }
335 run_single(&resolved_args, &config, &sync_engine, &format_manager).await?;
336 Ok(())
337 }
338 Err(err) => Err(err),
339 _ => unreachable!(),
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::config::TestConfigService;
347 use std::fs;
348 use std::sync::Arc;
349 use tempfile::TempDir;
350
351 #[tokio::test]
352 async fn test_sync_batch_processing() -> Result<()> {
353 let config_service = Arc::new(TestConfigService::with_sync_settings(0.5, 30.0));
355
356 let tmp = TempDir::new().unwrap();
358 let video1 = tmp.path().join("movie1.mp4");
359 let sub1 = tmp.path().join("movie1.srt");
360 fs::write(&video1, b"").unwrap();
361 fs::write(&sub1, b"1\n00:00:01,000 --> 00:00:02,000\nTest1\n\n").unwrap();
362
363 let args = SyncArgs {
365 positional_paths: Vec::new(),
366 video: Some(video1.clone()),
367 subtitle: Some(sub1.clone()),
368 input_paths: vec![],
369 recursive: false,
370 offset: Some(1.0), method: Some(crate::cli::SyncMethodArg::Manual),
372 window: 30,
373 vad_sensitivity: None,
374 output: None,
375 verbose: false,
376 dry_run: true, force: true,
378 batch: None, };
380
381 execute(args, config_service.as_ref()).await?;
382
383 Ok(())
385 }
386}
387
388pub async fn execute_with_config(
390 args: SyncArgs,
391 config_service: std::sync::Arc<dyn ConfigService>,
392) -> Result<()> {
393 execute(args, config_service.as_ref()).await
394}
395
396fn determine_sync_method(args: &SyncArgs, default_method: &str) -> Result<SyncMethod> {
407 if let Some(ref method_arg) = args.method {
409 return Ok(method_arg.clone().into());
410 }
411 if args.vad_sensitivity.is_some() {
413 return Ok(SyncMethod::LocalVad);
414 }
415 match default_method {
417 "vad" => Ok(SyncMethod::LocalVad),
418 "auto" => Ok(SyncMethod::Auto),
419 _ => Ok(SyncMethod::Auto),
420 }
421}
422
423fn apply_cli_overrides(config: &mut crate::config::SyncConfig, args: &SyncArgs) -> Result<()> {
430 if let Some(sensitivity) = args.vad_sensitivity {
432 config.vad.sensitivity = sensitivity;
433 }
434
435 Ok(())
436}
437
438fn display_sync_result(result: &SyncResult, verbose: bool) {
445 if verbose {
446 println!("\n=== Sync Results ===");
447 println!("Method used: {:?}", result.method_used);
448 println!("Detected offset: {:.3} seconds", result.offset_seconds);
449 println!("Confidence: {:.1}%", result.confidence * 100.0);
450 println!("Processing time: {:?}", result.processing_duration);
451
452 if !result.warnings.is_empty() {
453 println!("\nWarnings:");
454 for warning in &result.warnings {
455 println!(" ⚠️ {warning}");
456 }
457 }
458
459 if let Some(info) = &result.additional_info {
460 if let Ok(pretty_info) = serde_json::to_string_pretty(info) {
461 println!("\nAdditional information:");
462 println!("{pretty_info}");
463 }
464 }
465 } else {
466 println!(
467 "✅ Sync completed: offset {:.3}s (confidence: {:.1}%)",
468 result.offset_seconds,
469 result.confidence * 100.0
470 );
471 }
472}