1#[derive(Debug, Clone, ValueEnum, PartialEq)]
20pub enum SyncMethodArg {
21 Vad,
23 Manual,
25}
26
27impl From<SyncMethodArg> for crate::core::sync::SyncMethod {
28 fn from(arg: SyncMethodArg) -> Self {
29 match arg {
30 SyncMethodArg::Vad => Self::LocalVad,
31 SyncMethodArg::Manual => Self::Manual,
32 }
33 }
34}
35
36use crate::cli::InputPathHandler;
37use crate::error::{SubXError, SubXResult};
38use clap::{Args, ValueEnum};
39use std::path::{Path, PathBuf};
40
41#[derive(Args, Debug, Clone)]
43pub struct SyncArgs {
44 #[arg(value_name = "PATH", num_args = 0..)]
46 pub positional_paths: Vec<PathBuf>,
47
48 #[arg(
50 short = 'v',
51 long = "video",
52 value_name = "VIDEO",
53 help = "Video file path (optional if using positional or manual offset)"
54 )]
55 pub video: Option<PathBuf>,
56
57 #[arg(
59 short = 's',
60 long = "subtitle",
61 value_name = "SUBTITLE",
62 help = "Subtitle file path (optional if using positional or manual offset)"
63 )]
64 pub subtitle: Option<PathBuf>,
65 #[arg(short = 'i', long = "input", value_name = "PATH")]
67 pub input_paths: Vec<PathBuf>,
68
69 #[arg(short, long)]
71 pub recursive: bool,
72
73 #[arg(
75 long,
76 value_name = "SECONDS",
77 help = "Manual offset in seconds (positive delays subtitles, negative advances them)"
78 )]
79 pub offset: Option<f32>,
80
81 #[arg(short, long, value_enum, help = "Synchronization method")]
83 pub method: Option<SyncMethodArg>,
84
85 #[arg(
87 short = 'w',
88 long,
89 value_name = "SECONDS",
90 default_value = "30",
91 help = "Time window around first subtitle for analysis (seconds)"
92 )]
93 pub window: u32,
94
95 #[arg(
98 long,
99 value_name = "SENSITIVITY",
100 help = "VAD sensitivity threshold (0.0-1.0)"
101 )]
102 pub vad_sensitivity: Option<f32>,
103
104 #[arg(
107 short = 'o',
108 long,
109 value_name = "PATH",
110 help = "Output file path (default: input_synced.ext)"
111 )]
112 pub output: Option<PathBuf>,
113
114 #[arg(
116 long,
117 help = "Enable verbose output with detailed progress information"
118 )]
119 pub verbose: bool,
120
121 #[arg(long, help = "Analyze and display results but don't save output file")]
123 pub dry_run: bool,
124
125 #[arg(long, help = "Overwrite existing output file without confirmation")]
127 pub force: bool,
128
129 #[arg(
131 short = 'b',
132 long = "batch",
133 value_name = "DIRECTORY",
134 help = "Enable batch processing mode. Can optionally specify a directory path.",
135 num_args = 0..=1,
136 require_equals = false
137 )]
138 pub batch: Option<Option<PathBuf>>,
139
140 #[arg(long, default_value_t = false)]
142 pub no_extract: bool,
143 }
145
146#[derive(Debug, Clone, PartialEq)]
148pub enum SyncMethod {
149 Auto,
151 Manual,
153}
154
155impl SyncArgs {
156 pub fn validate(&self) -> Result<(), String> {
158 if let Some(SyncMethodArg::Manual) = &self.method {
160 if self.offset.is_none() {
161 return Err("Manual method requires --offset parameter.".to_string());
162 }
163 }
164
165 if self.batch.is_some() {
167 let has_input_paths = !self.input_paths.is_empty();
168 let has_positional = !self.positional_paths.is_empty();
169 let has_video_or_subtitle = self.video.is_some() || self.subtitle.is_some();
170 let has_batch_directory = matches!(&self.batch, Some(Some(_)));
171
172 if has_input_paths || has_positional || has_video_or_subtitle || has_batch_directory {
174 return Ok(());
175 }
176
177 return Err("Batch mode requires at least one input source.\n\n\
178Usage:\n\
179• Batch with directory: subx sync -b <directory>\n\
180• Batch with input paths: subx sync -b -i <path>\n\
181• Batch with positional: subx sync -b <path>\n\n\
182Need help? Run: subx sync --help"
183 .to_string());
184 }
185
186 let has_video = self.video.is_some();
188 let has_subtitle = self.subtitle.is_some();
189 let has_positional = !self.positional_paths.is_empty();
190 let is_manual = self.offset.is_some();
191
192 if is_manual {
194 if has_subtitle || has_positional {
195 return Ok(());
196 }
197 return Err("Manual sync mode requires subtitle file.\n\n\
198Usage:\n\
199• Manual sync: subx sync --offset <seconds> <subtitle>\n\
200• Manual sync: subx sync --offset <seconds> -s <subtitle>\n\n\
201Need help? Run: subx sync --help"
202 .to_string());
203 }
204
205 if has_video || has_positional {
207 if self.vad_sensitivity.is_some() {
209 if let Some(SyncMethodArg::Manual) = &self.method {
210 return Err("VAD options can only be used with --method vad.".to_string());
211 }
212 }
213 return Ok(());
214 }
215
216 Err("Auto sync mode requires video file or positional path.\n\n\
217Usage:\n\
218• Auto sync: subx sync <video> <subtitle> or subx sync <video_path>\n\
219• Auto sync: subx sync -v <video> -s <subtitle>\n\
220• Manual sync: subx sync --offset <seconds> <subtitle>\n\
221• Batch mode: subx sync -b [directory]\n\n\
222Need help? Run: subx sync --help"
223 .to_string())
224 }
225
226 pub fn get_output_path(&self) -> Option<PathBuf> {
228 if let Some(ref output) = self.output {
229 Some(output.clone())
230 } else {
231 self.subtitle
232 .as_ref()
233 .map(|subtitle| create_default_output_path(subtitle))
234 }
235 }
236
237 pub fn is_manual_mode(&self) -> bool {
239 self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual))
240 }
241
242 pub fn sync_method(&self) -> SyncMethod {
244 if self.offset.is_some() {
245 SyncMethod::Manual
246 } else {
247 SyncMethod::Auto
248 }
249 }
250
251 pub fn validate_compat(&self) -> SubXResult<()> {
253 if self.offset.is_none() && self.video.is_none() && !self.positional_paths.is_empty() {
255 return Ok(());
256 }
257 match (self.offset.is_some(), self.video.is_some()) {
258 (true, _) => Ok(()),
260 (false, true) => Ok(()),
262 (false, false) => Err(SubXError::CommandExecution(
264 "Auto sync mode requires video file.\n\n\
265Usage:\n\
266• Auto sync: subx sync <video> <subtitle>\n\
267• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
268Need help? Run: subx sync --help"
269 .to_string(),
270 )),
271 }
272 }
273
274 #[allow(dead_code)]
276 pub fn requires_video(&self) -> bool {
277 self.offset.is_none()
278 }
279
280 pub fn get_input_handler(&self) -> Result<InputPathHandler, SubXError> {
283 let optional_paths = vec![self.video.clone(), self.subtitle.clone()];
284 let string_paths: Vec<String> = self
285 .positional_paths
286 .iter()
287 .map(|p| p.to_string_lossy().to_string())
288 .collect();
289 let merged_paths = InputPathHandler::merge_paths_from_multiple_sources(
290 &optional_paths,
291 &self.input_paths,
292 &string_paths,
293 )?;
294
295 Ok(InputPathHandler::from_args(&merged_paths, self.recursive)?
296 .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"])
297 .with_no_extract(self.no_extract))
298 }
299
300 pub fn get_sync_mode(&self) -> Result<SyncMode, SubXError> {
302 if self.batch.is_some()
304 || !self.input_paths.is_empty()
305 || self
306 .positional_paths
307 .iter()
308 .any(|p| p.extension().is_none())
309 {
310 let mut paths = Vec::new();
311
312 if let Some(Some(batch_dir)) = &self.batch {
314 paths.push(batch_dir.clone());
315 }
316
317 paths.extend(self.input_paths.clone());
319 paths.extend(self.positional_paths.clone());
320
321 if paths.is_empty() {
323 paths.push(PathBuf::from("."));
324 }
325
326 let handler = InputPathHandler::from_args(&paths, self.recursive)?
327 .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"])
328 .with_no_extract(self.no_extract);
329
330 return Ok(SyncMode::Batch(handler));
331 }
332
333 if !self.positional_paths.is_empty() {
335 if self.positional_paths.len() == 1 {
336 let path = &self.positional_paths[0];
337 let ext = path
338 .extension()
339 .and_then(|s| s.to_str())
340 .unwrap_or("")
341 .to_lowercase();
342 let mut video = None;
343 let mut subtitle = None;
344 match ext.as_str() {
345 "mp4" | "mkv" | "avi" | "mov" => {
346 video = Some(path.clone());
347 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
348 let dir = path.parent().unwrap_or_else(|| Path::new("."));
349 for sub_ext in &["srt", "ass", "vtt", "sub"] {
350 let cand = dir.join(format!("{stem}.{sub_ext}"));
351 if cand.exists() {
352 subtitle = Some(cand);
353 break;
354 }
355 }
356 }
357 }
358 "srt" | "ass" | "vtt" | "sub" => {
359 subtitle = Some(path.clone());
360 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
361 let dir = path.parent().unwrap_or_else(|| Path::new("."));
362 for vid_ext in &["mp4", "mkv", "avi", "mov"] {
363 let cand = dir.join(format!("{stem}.{vid_ext}"));
364 if cand.exists() {
365 video = Some(cand);
366 break;
367 }
368 }
369 }
370 }
371 _ => {}
372 }
373 if self.is_manual_mode() {
375 if let Some(subtitle_path) = subtitle {
376 return Ok(SyncMode::Single {
377 video: PathBuf::new(), subtitle: subtitle_path,
379 });
380 }
381 }
382 if let (Some(v), Some(s)) = (video, subtitle) {
383 return Ok(SyncMode::Single {
384 video: v,
385 subtitle: s,
386 });
387 }
388 return Err(SubXError::InvalidSyncConfiguration);
389 } else if self.positional_paths.len() == 2 {
390 let mut video = None;
391 let mut subtitle = None;
392 for p in &self.positional_paths {
393 if let Some(ext) = p
394 .extension()
395 .and_then(|s| s.to_str())
396 .map(|s| s.to_lowercase())
397 {
398 if ["mp4", "mkv", "avi", "mov"].contains(&ext.as_str()) {
399 video = Some(p.clone());
400 }
401 if ["srt", "ass", "vtt", "sub"].contains(&ext.as_str()) {
402 subtitle = Some(p.clone());
403 }
404 }
405 }
406 if let (Some(v), Some(s)) = (video, subtitle) {
407 return Ok(SyncMode::Single {
408 video: v,
409 subtitle: s,
410 });
411 }
412 return Err(SubXError::InvalidSyncConfiguration);
413 }
414 }
415
416 if let (Some(video), Some(subtitle)) = (self.video.as_ref(), self.subtitle.as_ref()) {
418 Ok(SyncMode::Single {
419 video: video.clone(),
420 subtitle: subtitle.clone(),
421 })
422 } else if self.is_manual_mode() {
423 if let Some(subtitle) = self.subtitle.as_ref() {
424 Ok(SyncMode::Single {
426 video: PathBuf::new(), subtitle: subtitle.clone(),
428 })
429 } else {
430 Err(SubXError::InvalidSyncConfiguration)
431 }
432 } else {
433 Err(SubXError::InvalidSyncConfiguration)
434 }
435 }
436}
437
438#[derive(Debug)]
440pub enum SyncMode {
441 Single {
443 video: PathBuf,
445 subtitle: PathBuf,
447 },
448 Batch(InputPathHandler),
450}
451
452pub fn create_default_output_path(input: &Path) -> PathBuf {
456 let mut output = input.to_path_buf();
457
458 if let Some(stem) = input.file_stem().and_then(|s| s.to_str()) {
459 if let Some(extension) = input.extension().and_then(|s| s.to_str()) {
460 let new_filename = format!("{stem}_synced.{extension}");
461 output.set_file_name(new_filename);
462 }
463 }
464
465 output
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::cli::{Cli, Commands};
472 use clap::Parser;
473 use std::path::PathBuf;
474
475 #[test]
476 fn test_sync_method_selection_manual() {
477 let args = SyncArgs {
478 positional_paths: Vec::new(),
479 video: Some(PathBuf::from("video.mp4")),
480 subtitle: Some(PathBuf::from("subtitle.srt")),
481 input_paths: Vec::new(),
482 recursive: false,
483 offset: Some(2.5),
484 method: None,
485 window: 30,
486 vad_sensitivity: None,
487 output: None,
488 verbose: false,
489 dry_run: false,
490 force: false,
491 batch: None,
492 no_extract: false,
493 };
494 assert_eq!(args.sync_method(), SyncMethod::Manual);
495 }
496
497 #[test]
498 fn test_sync_args_batch_input() {
499 let cli = Cli::try_parse_from([
500 "subx-cli",
501 "sync",
502 "-i",
503 "dir",
504 "--batch",
505 "--recursive",
506 "--video",
507 "video.mp4",
508 ])
509 .unwrap();
510 let args = match cli.command {
511 Commands::Sync(a) => a,
512 _ => panic!("Expected Sync command"),
513 };
514 assert_eq!(args.input_paths, vec![PathBuf::from("dir")]);
515 assert!(args.batch.is_some());
516 assert!(args.recursive);
517 assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
518 }
519
520 #[test]
521 fn test_sync_args_invalid_combinations() {
522 let cli = Cli::try_parse_from(["subx-cli", "sync", "--batch", "-i", "dir"]).unwrap();
524 let args = match cli.command {
525 Commands::Sync(a) => a,
526 _ => panic!("Expected Sync command"),
527 };
528
529 assert!(args.validate().is_ok());
531
532 let args_invalid = SyncArgs {
534 positional_paths: Vec::new(),
535 video: None,
536 subtitle: None,
537 input_paths: Vec::new(),
538 recursive: false,
539 offset: None,
540 method: None,
541 window: 30,
542 vad_sensitivity: None,
543 output: None,
544 verbose: false,
545 dry_run: false,
546 force: false,
547 batch: Some(None), no_extract: false,
549 };
550
551 assert!(args_invalid.validate().is_err());
552 }
553
554 #[test]
555 fn test_sync_method_selection_auto() {
556 let args = SyncArgs {
557 positional_paths: Vec::new(),
558 video: Some(PathBuf::from("video.mp4")),
559 subtitle: Some(PathBuf::from("subtitle.srt")),
560 input_paths: Vec::new(),
561 recursive: false,
562 offset: None,
563 method: None,
564 window: 30,
565 vad_sensitivity: None,
566 output: None,
567 verbose: false,
568 dry_run: false,
569 force: false,
570 batch: None,
571 no_extract: false,
572 };
573 assert_eq!(args.sync_method(), SyncMethod::Auto);
574 }
575
576 #[test]
577 fn test_method_arg_conversion() {
578 assert_eq!(
579 crate::core::sync::SyncMethod::from(SyncMethodArg::Vad),
580 crate::core::sync::SyncMethod::LocalVad
581 );
582 assert_eq!(
583 crate::core::sync::SyncMethod::from(SyncMethodArg::Manual),
584 crate::core::sync::SyncMethod::Manual
585 );
586 }
587
588 #[test]
589 fn test_create_default_output_path() {
590 let input = PathBuf::from("test.srt");
591 let output = create_default_output_path(&input);
592 assert_eq!(output.file_name().unwrap(), "test_synced.srt");
593
594 let input = PathBuf::from("/path/to/movie.ass");
595 let output = create_default_output_path(&input);
596 assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
597 }
598}