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(
46 short = 'v',
47 long = "video",
48 value_name = "VIDEO",
49 help = "Video file path (required for auto sync, optional for manual offset)",
50 required_unless_present = "offset"
51 )]
52 pub video: Option<PathBuf>,
53
54 #[arg(
56 short = 's',
57 long = "subtitle",
58 value_name = "SUBTITLE",
59 help = "Subtitle file path (required for single file, optional for batch mode)",
60 required_unless_present_any = ["input_paths", "batch"]
61 )]
62 pub subtitle: Option<PathBuf>,
63 #[arg(short = 'i', long = "input", value_name = "PATH")]
65 pub input_paths: Vec<PathBuf>,
66
67 #[arg(short, long)]
69 pub recursive: bool,
70
71 #[arg(
73 long,
74 value_name = "SECONDS",
75 help = "Manual offset in seconds (positive delays subtitles, negative advances them)",
76 conflicts_with_all = ["method", "window", "vad_sensitivity"]
77 )]
78 pub offset: Option<f32>,
79
80 #[arg(short, long, value_enum, help = "Synchronization method")]
82 pub method: Option<SyncMethodArg>,
83
84 #[arg(
86 short = 'w',
87 long,
88 value_name = "SECONDS",
89 default_value = "30",
90 help = "Time window around first subtitle for analysis (seconds)"
91 )]
92 pub window: u32,
93
94 #[arg(
97 long,
98 value_name = "SENSITIVITY",
99 help = "VAD sensitivity threshold (0.0-1.0)"
100 )]
101 pub vad_sensitivity: Option<f32>,
102
103 #[arg(
105 long,
106 value_name = "SIZE",
107 help = "VAD audio chunk size (number of samples)",
108 value_parser = validate_chunk_size
109 )]
110 pub vad_chunk_size: Option<usize>,
111
112 #[arg(
115 short = 'o',
116 long,
117 value_name = "PATH",
118 help = "Output file path (default: input_synced.ext)"
119 )]
120 pub output: Option<PathBuf>,
121
122 #[arg(
124 long,
125 help = "Enable verbose output with detailed progress information"
126 )]
127 pub verbose: bool,
128
129 #[arg(long, help = "Analyze and display results but don't save output file")]
131 pub dry_run: bool,
132
133 #[arg(long, help = "Overwrite existing output file without confirmation")]
135 pub force: bool,
136
137 #[arg(short, long, help = "Enable batch processing mode")]
139 pub batch: bool,
140
141 #[arg(long, hide = true)]
144 #[deprecated(note = "Use configuration file instead")]
145 pub range: Option<f32>,
146
147 #[arg(long, hide = true)]
149 #[deprecated(note = "Use configuration file instead")]
150 pub threshold: Option<f32>,
151}
152
153#[derive(Debug, Clone, PartialEq)]
155pub enum SyncMethod {
156 Auto,
158 Manual,
160}
161
162impl SyncArgs {
163 pub fn validate(&self) -> Result<(), String> {
165 if let Some(SyncMethodArg::Manual) = &self.method {
167 if self.offset.is_none() {
168 return Err("Manual method requires --offset parameter.".to_string());
169 }
170 }
171
172 if self.offset.is_none() && self.video.is_none() {
174 return Err("Auto sync mode requires video file.\n\n\
175Usage:\n\
176• Auto sync: subx sync <video> <subtitle>\n\
177• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
178Need help? Run: subx sync --help"
179 .to_string());
180 }
181
182 if self.vad_sensitivity.is_some() || self.vad_chunk_size.is_some() {
184 match &self.method {
185 Some(SyncMethodArg::Vad) => {}
186 _ => return Err("VAD options can only be used with --method vad.".to_string()),
187 }
188 }
189
190 Ok(())
191 }
192
193 pub fn get_output_path(&self) -> Option<PathBuf> {
195 if let Some(ref output) = self.output {
196 Some(output.clone())
197 } else {
198 self.subtitle
199 .as_ref()
200 .map(|subtitle| create_default_output_path(subtitle))
201 }
202 }
203
204 pub fn is_manual_mode(&self) -> bool {
206 self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual))
207 }
208
209 pub fn sync_method(&self) -> SyncMethod {
211 if self.offset.is_some() {
212 SyncMethod::Manual
213 } else {
214 SyncMethod::Auto
215 }
216 }
217
218 pub fn validate_compat(&self) -> SubXResult<()> {
220 match (self.offset.is_some(), self.video.is_some()) {
221 (true, _) => Ok(()),
223 (false, true) => Ok(()),
225 (false, false) => Err(SubXError::CommandExecution(
227 "Auto sync mode requires video file.\n\n\
228Usage:\n\
229• Auto sync: subx sync <video> <subtitle>\n\
230• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
231Need help? Run: subx sync --help"
232 .to_string(),
233 )),
234 }
235 }
236
237 #[allow(dead_code)]
239 pub fn requires_video(&self) -> bool {
240 self.offset.is_none()
241 }
242
243 pub fn get_input_handler(&self) -> Result<InputPathHandler, SubXError> {
246 let optional_paths = vec![self.video.clone(), self.subtitle.clone()];
247 let merged_paths = InputPathHandler::merge_paths_from_multiple_sources(
248 &optional_paths,
249 &self.input_paths,
250 &[],
251 )?;
252
253 Ok(InputPathHandler::from_args(&merged_paths, self.recursive)?
254 .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]))
255 }
256
257 pub fn get_sync_mode(&self) -> Result<SyncMode, SubXError> {
259 if !self.input_paths.is_empty() || self.batch {
260 let paths = if !self.input_paths.is_empty() {
261 self.input_paths.clone()
262 } else if let Some(video) = &self.video {
263 vec![video.clone()]
264 } else {
265 return Err(SubXError::NoInputSpecified);
266 };
267
268 let handler = InputPathHandler::from_args(&paths, self.recursive)?
269 .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]);
270
271 Ok(SyncMode::Batch(handler))
272 } else if let (Some(video), Some(subtitle)) = (self.video.as_ref(), self.subtitle.as_ref())
273 {
274 Ok(SyncMode::Single {
275 video: video.clone(),
276 subtitle: subtitle.clone(),
277 })
278 } else if let Some(subtitle) = self.subtitle.as_ref() {
279 if self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual)) {
281 Ok(SyncMode::Single {
283 video: PathBuf::from(""), subtitle: subtitle.clone(),
285 })
286 } else {
287 Err(SubXError::InvalidSyncConfiguration)
288 }
289 } else {
290 Err(SubXError::InvalidSyncConfiguration)
291 }
292 }
293}
294
295#[derive(Debug)]
297pub enum SyncMode {
298 Single {
300 video: PathBuf,
302 subtitle: PathBuf,
304 },
305 Batch(InputPathHandler),
307}
308
309fn validate_chunk_size(s: &str) -> Result<usize, String> {
311 let size: usize = s.parse().map_err(|_| "Invalid chunk size")?;
312
313 if !(256..=2048).contains(&size) {
314 return Err("Chunk size must be between 256 and 2048".to_string());
315 }
316
317 if !size.is_power_of_two() {
318 return Err("Chunk size must be a power of 2".to_string());
319 }
320
321 Ok(size)
322}
323
324fn create_default_output_path(input: &Path) -> PathBuf {
325 let mut output = input.to_path_buf();
326
327 if let Some(stem) = input.file_stem().and_then(|s| s.to_str()) {
328 if let Some(extension) = input.extension().and_then(|s| s.to_str()) {
329 let new_filename = format!("{}_synced.{}", stem, extension);
330 output.set_file_name(new_filename);
331 }
332 }
333
334 output
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::cli::{Cli, Commands};
341 use clap::Parser;
342 use std::path::PathBuf;
343
344 #[test]
345 fn test_sync_method_selection_manual() {
346 let args = SyncArgs {
347 video: Some(PathBuf::from("video.mp4")),
348 subtitle: Some(PathBuf::from("subtitle.srt")),
349 input_paths: Vec::new(),
350 recursive: false,
351 offset: Some(2.5),
352 method: None,
353 window: 30,
354 vad_sensitivity: None,
355 vad_chunk_size: None,
356 output: None,
357 verbose: false,
358 dry_run: false,
359 force: false,
360 batch: false,
361 #[allow(deprecated)]
362 range: None,
363 #[allow(deprecated)]
364 threshold: None,
365 };
366 assert_eq!(args.sync_method(), SyncMethod::Manual);
367 }
368
369 #[test]
370 fn test_sync_args_batch_input() {
371 let cli = Cli::try_parse_from([
372 "subx-cli",
373 "sync",
374 "-i",
375 "dir",
376 "--batch",
377 "--recursive",
378 "--video",
379 "video.mp4",
380 ])
381 .unwrap();
382 let args = match cli.command {
383 Commands::Sync(a) => a,
384 _ => panic!("Expected Sync command"),
385 };
386 assert_eq!(args.input_paths, vec![PathBuf::from("dir")]);
387 assert!(args.batch);
388 assert!(args.recursive);
389 assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
390 }
391
392 #[test]
393 fn test_sync_args_invalid_combinations() {
394 let res = Cli::try_parse_from(["subx-cli", "sync", "--batch", "-i", "dir"]);
396 assert!(res.is_err());
397 }
398
399 #[test]
400 fn test_sync_method_selection_auto() {
401 let args = SyncArgs {
402 video: Some(PathBuf::from("video.mp4")),
403 subtitle: Some(PathBuf::from("subtitle.srt")),
404 input_paths: Vec::new(),
405 recursive: false,
406 offset: None,
407 method: None,
408 window: 30,
409 vad_sensitivity: None,
410 vad_chunk_size: None,
411 output: None,
412 verbose: false,
413 dry_run: false,
414 force: false,
415 batch: false,
416 #[allow(deprecated)]
417 range: None,
418 #[allow(deprecated)]
419 threshold: None,
420 };
421 assert_eq!(args.sync_method(), SyncMethod::Auto);
422 }
423
424 #[test]
425 fn test_method_arg_conversion() {
426 assert_eq!(
427 crate::core::sync::SyncMethod::from(SyncMethodArg::Vad),
428 crate::core::sync::SyncMethod::LocalVad
429 );
430 assert_eq!(
431 crate::core::sync::SyncMethod::from(SyncMethodArg::Manual),
432 crate::core::sync::SyncMethod::Manual
433 );
434 }
435
436 #[test]
437 fn test_validate_chunk_size() {
438 assert!(validate_chunk_size("512").is_ok());
439 assert!(validate_chunk_size("1024").is_ok());
440 assert!(validate_chunk_size("256").is_ok());
441
442 assert!(validate_chunk_size("128").is_err());
444 assert!(validate_chunk_size("4096").is_err());
446 assert!(validate_chunk_size("500").is_err());
448 assert!(validate_chunk_size("abc").is_err());
450 }
451
452 #[test]
453 fn test_create_default_output_path() {
454 let input = PathBuf::from("test.srt");
455 let output = create_default_output_path(&input);
456 assert_eq!(output.file_name().unwrap(), "test_synced.srt");
457
458 let input = PathBuf::from("/path/to/movie.ass");
459 let output = create_default_output_path(&input);
460 assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
461 }
462}