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 }
141
142#[derive(Debug, Clone, PartialEq)]
144pub enum SyncMethod {
145 Auto,
147 Manual,
149}
150
151impl SyncArgs {
152 pub fn validate(&self) -> Result<(), String> {
154 if let Some(SyncMethodArg::Manual) = &self.method {
156 if self.offset.is_none() {
157 return Err("Manual method requires --offset parameter.".to_string());
158 }
159 }
160
161 if self.batch.is_some() {
163 let has_input_paths = !self.input_paths.is_empty();
164 let has_positional = !self.positional_paths.is_empty();
165 let has_video_or_subtitle = self.video.is_some() || self.subtitle.is_some();
166 let has_batch_directory = matches!(&self.batch, Some(Some(_)));
167
168 if has_input_paths || has_positional || has_video_or_subtitle || has_batch_directory {
170 return Ok(());
171 }
172
173 return Err("Batch mode requires at least one input source.\n\n\
174Usage:\n\
175• Batch with directory: subx sync -b <directory>\n\
176• Batch with input paths: subx sync -b -i <path>\n\
177• Batch with positional: subx sync -b <path>\n\n\
178Need help? Run: subx sync --help"
179 .to_string());
180 }
181
182 let has_video = self.video.is_some();
184 let has_subtitle = self.subtitle.is_some();
185 let has_positional = !self.positional_paths.is_empty();
186 let is_manual = self.offset.is_some();
187
188 if is_manual {
190 if has_subtitle || has_positional {
191 return Ok(());
192 }
193 return Err("Manual sync mode requires subtitle file.\n\n\
194Usage:\n\
195• Manual sync: subx sync --offset <seconds> <subtitle>\n\
196• Manual sync: subx sync --offset <seconds> -s <subtitle>\n\n\
197Need help? Run: subx sync --help"
198 .to_string());
199 }
200
201 if has_video || has_positional {
203 if self.vad_sensitivity.is_some() {
205 if let Some(SyncMethodArg::Manual) = &self.method {
206 return Err("VAD options can only be used with --method vad.".to_string());
207 }
208 }
209 return Ok(());
210 }
211
212 Err("Auto sync mode requires video file or positional path.\n\n\
213Usage:\n\
214• Auto sync: subx sync <video> <subtitle> or subx sync <video_path>\n\
215• Auto sync: subx sync -v <video> -s <subtitle>\n\
216• Manual sync: subx sync --offset <seconds> <subtitle>\n\
217• Batch mode: subx sync -b [directory]\n\n\
218Need help? Run: subx sync --help"
219 .to_string())
220 }
221
222 pub fn get_output_path(&self) -> Option<PathBuf> {
224 if let Some(ref output) = self.output {
225 Some(output.clone())
226 } else {
227 self.subtitle
228 .as_ref()
229 .map(|subtitle| create_default_output_path(subtitle))
230 }
231 }
232
233 pub fn is_manual_mode(&self) -> bool {
235 self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual))
236 }
237
238 pub fn sync_method(&self) -> SyncMethod {
240 if self.offset.is_some() {
241 SyncMethod::Manual
242 } else {
243 SyncMethod::Auto
244 }
245 }
246
247 pub fn validate_compat(&self) -> SubXResult<()> {
249 if self.offset.is_none() && self.video.is_none() && !self.positional_paths.is_empty() {
251 return Ok(());
252 }
253 match (self.offset.is_some(), self.video.is_some()) {
254 (true, _) => Ok(()),
256 (false, true) => Ok(()),
258 (false, false) => Err(SubXError::CommandExecution(
260 "Auto sync mode requires video file.\n\n\
261Usage:\n\
262• Auto sync: subx sync <video> <subtitle>\n\
263• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
264Need help? Run: subx sync --help"
265 .to_string(),
266 )),
267 }
268 }
269
270 #[allow(dead_code)]
272 pub fn requires_video(&self) -> bool {
273 self.offset.is_none()
274 }
275
276 pub fn get_input_handler(&self) -> Result<InputPathHandler, SubXError> {
279 let optional_paths = vec![self.video.clone(), self.subtitle.clone()];
280 let string_paths: Vec<String> = self
281 .positional_paths
282 .iter()
283 .map(|p| p.to_string_lossy().to_string())
284 .collect();
285 let merged_paths = InputPathHandler::merge_paths_from_multiple_sources(
286 &optional_paths,
287 &self.input_paths,
288 &string_paths,
289 )?;
290
291 Ok(InputPathHandler::from_args(&merged_paths, self.recursive)?
292 .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]))
293 }
294
295 pub fn get_sync_mode(&self) -> Result<SyncMode, SubXError> {
297 if self.batch.is_some()
299 || !self.input_paths.is_empty()
300 || self
301 .positional_paths
302 .iter()
303 .any(|p| p.extension().is_none())
304 {
305 let mut paths = Vec::new();
306
307 if let Some(Some(batch_dir)) = &self.batch {
309 paths.push(batch_dir.clone());
310 }
311
312 paths.extend(self.input_paths.clone());
314 paths.extend(self.positional_paths.clone());
315
316 if paths.is_empty() {
318 paths.push(PathBuf::from("."));
319 }
320
321 let handler = InputPathHandler::from_args(&paths, self.recursive)?
322 .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]);
323
324 return Ok(SyncMode::Batch(handler));
325 }
326
327 if !self.positional_paths.is_empty() {
329 if self.positional_paths.len() == 1 {
330 let path = &self.positional_paths[0];
331 let ext = path
332 .extension()
333 .and_then(|s| s.to_str())
334 .unwrap_or("")
335 .to_lowercase();
336 let mut video = None;
337 let mut subtitle = None;
338 match ext.as_str() {
339 "mp4" | "mkv" | "avi" | "mov" => {
340 video = Some(path.clone());
341 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
342 let dir = path.parent().unwrap_or_else(|| Path::new("."));
343 for sub_ext in &["srt", "ass", "vtt", "sub"] {
344 let cand = dir.join(format!("{stem}.{sub_ext}"));
345 if cand.exists() {
346 subtitle = Some(cand);
347 break;
348 }
349 }
350 }
351 }
352 "srt" | "ass" | "vtt" | "sub" => {
353 subtitle = Some(path.clone());
354 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
355 let dir = path.parent().unwrap_or_else(|| Path::new("."));
356 for vid_ext in &["mp4", "mkv", "avi", "mov"] {
357 let cand = dir.join(format!("{stem}.{vid_ext}"));
358 if cand.exists() {
359 video = Some(cand);
360 break;
361 }
362 }
363 }
364 }
365 _ => {}
366 }
367 if self.is_manual_mode() {
369 if let Some(subtitle_path) = subtitle {
370 return Ok(SyncMode::Single {
371 video: PathBuf::new(), subtitle: subtitle_path,
373 });
374 }
375 }
376 if let (Some(v), Some(s)) = (video, subtitle) {
377 return Ok(SyncMode::Single {
378 video: v,
379 subtitle: s,
380 });
381 }
382 return Err(SubXError::InvalidSyncConfiguration);
383 } else if self.positional_paths.len() == 2 {
384 let mut video = None;
385 let mut subtitle = None;
386 for p in &self.positional_paths {
387 if let Some(ext) = p
388 .extension()
389 .and_then(|s| s.to_str())
390 .map(|s| s.to_lowercase())
391 {
392 if ["mp4", "mkv", "avi", "mov"].contains(&ext.as_str()) {
393 video = Some(p.clone());
394 }
395 if ["srt", "ass", "vtt", "sub"].contains(&ext.as_str()) {
396 subtitle = Some(p.clone());
397 }
398 }
399 }
400 if let (Some(v), Some(s)) = (video, subtitle) {
401 return Ok(SyncMode::Single {
402 video: v,
403 subtitle: s,
404 });
405 }
406 return Err(SubXError::InvalidSyncConfiguration);
407 }
408 }
409
410 if let (Some(video), Some(subtitle)) = (self.video.as_ref(), self.subtitle.as_ref()) {
412 Ok(SyncMode::Single {
413 video: video.clone(),
414 subtitle: subtitle.clone(),
415 })
416 } else if self.is_manual_mode() && self.subtitle.is_some() {
417 Ok(SyncMode::Single {
419 video: PathBuf::new(), subtitle: self.subtitle.as_ref().unwrap().clone(),
421 })
422 } else {
423 Err(SubXError::InvalidSyncConfiguration)
424 }
425 }
426}
427
428#[derive(Debug)]
430pub enum SyncMode {
431 Single {
433 video: PathBuf,
435 subtitle: PathBuf,
437 },
438 Batch(InputPathHandler),
440}
441
442fn create_default_output_path(input: &Path) -> PathBuf {
445 let mut output = input.to_path_buf();
446
447 if let Some(stem) = input.file_stem().and_then(|s| s.to_str()) {
448 if let Some(extension) = input.extension().and_then(|s| s.to_str()) {
449 let new_filename = format!("{stem}_synced.{extension}");
450 output.set_file_name(new_filename);
451 }
452 }
453
454 output
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::cli::{Cli, Commands};
461 use clap::Parser;
462 use std::path::PathBuf;
463
464 #[test]
465 fn test_sync_method_selection_manual() {
466 let args = SyncArgs {
467 positional_paths: Vec::new(),
468 video: Some(PathBuf::from("video.mp4")),
469 subtitle: Some(PathBuf::from("subtitle.srt")),
470 input_paths: Vec::new(),
471 recursive: false,
472 offset: Some(2.5),
473 method: None,
474 window: 30,
475 vad_sensitivity: None,
476 output: None,
477 verbose: false,
478 dry_run: false,
479 force: false,
480 batch: None,
481 };
482 assert_eq!(args.sync_method(), SyncMethod::Manual);
483 }
484
485 #[test]
486 fn test_sync_args_batch_input() {
487 let cli = Cli::try_parse_from([
488 "subx-cli",
489 "sync",
490 "-i",
491 "dir",
492 "--batch",
493 "--recursive",
494 "--video",
495 "video.mp4",
496 ])
497 .unwrap();
498 let args = match cli.command {
499 Commands::Sync(a) => a,
500 _ => panic!("Expected Sync command"),
501 };
502 assert_eq!(args.input_paths, vec![PathBuf::from("dir")]);
503 assert!(args.batch.is_some());
504 assert!(args.recursive);
505 assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
506 }
507
508 #[test]
509 fn test_sync_args_invalid_combinations() {
510 let cli = Cli::try_parse_from(["subx-cli", "sync", "--batch", "-i", "dir"]).unwrap();
512 let args = match cli.command {
513 Commands::Sync(a) => a,
514 _ => panic!("Expected Sync command"),
515 };
516
517 assert!(args.validate().is_ok());
519
520 let args_invalid = SyncArgs {
522 positional_paths: Vec::new(),
523 video: None,
524 subtitle: None,
525 input_paths: Vec::new(),
526 recursive: false,
527 offset: None,
528 method: None,
529 window: 30,
530 vad_sensitivity: None,
531 output: None,
532 verbose: false,
533 dry_run: false,
534 force: false,
535 batch: Some(None), };
537
538 assert!(args_invalid.validate().is_err());
539 }
540
541 #[test]
542 fn test_sync_method_selection_auto() {
543 let args = SyncArgs {
544 positional_paths: Vec::new(),
545 video: Some(PathBuf::from("video.mp4")),
546 subtitle: Some(PathBuf::from("subtitle.srt")),
547 input_paths: Vec::new(),
548 recursive: false,
549 offset: None,
550 method: None,
551 window: 30,
552 vad_sensitivity: None,
553 output: None,
554 verbose: false,
555 dry_run: false,
556 force: false,
557 batch: None,
558 };
559 assert_eq!(args.sync_method(), SyncMethod::Auto);
560 }
561
562 #[test]
563 fn test_method_arg_conversion() {
564 assert_eq!(
565 crate::core::sync::SyncMethod::from(SyncMethodArg::Vad),
566 crate::core::sync::SyncMethod::LocalVad
567 );
568 assert_eq!(
569 crate::core::sync::SyncMethod::from(SyncMethodArg::Manual),
570 crate::core::sync::SyncMethod::Manual
571 );
572 }
573
574 #[test]
575 fn test_create_default_output_path() {
576 let input = PathBuf::from("test.srt");
577 let output = create_default_output_path(&input);
578 assert_eq!(output.file_name().unwrap(), "test_synced.srt");
579
580 let input = PathBuf::from("/path/to/movie.ass");
581 let output = create_default_output_path(&input);
582 assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
583 }
584}