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