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(
106 short = 'o',
107 long,
108 value_name = "PATH",
109 help = "Output file path (default: input_synced.ext)"
110 )]
111 pub output: Option<PathBuf>,
112
113 #[arg(
115 long,
116 help = "Enable verbose output with detailed progress information"
117 )]
118 pub verbose: bool,
119
120 #[arg(long, help = "Analyze and display results but don't save output file")]
122 pub dry_run: bool,
123
124 #[arg(long, help = "Overwrite existing output file without confirmation")]
126 pub force: bool,
127
128 #[arg(short, long, help = "Enable batch processing mode")]
130 pub batch: bool,
131
132 #[arg(long, hide = true)]
135 #[deprecated(note = "Use configuration file instead")]
136 pub range: Option<f32>,
137
138 #[arg(long, hide = true)]
140 #[deprecated(note = "Use configuration file instead")]
141 pub threshold: Option<f32>,
142}
143
144#[derive(Debug, Clone, PartialEq)]
146pub enum SyncMethod {
147 Auto,
149 Manual,
151}
152
153impl SyncArgs {
154 pub fn validate(&self) -> Result<(), String> {
156 if let Some(SyncMethodArg::Manual) = &self.method {
158 if self.offset.is_none() {
159 return Err("Manual method requires --offset parameter.".to_string());
160 }
161 }
162
163 if self.offset.is_none() && self.video.is_none() {
165 return Err("Auto sync mode requires video file.\n\n\
166Usage:\n\
167• Auto sync: subx sync <video> <subtitle>\n\
168• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
169Need help? Run: subx sync --help"
170 .to_string());
171 }
172
173 if self.vad_sensitivity.is_some() {
175 match &self.method {
176 Some(SyncMethodArg::Vad) => {}
177 _ => return Err("VAD options can only be used with --method vad.".to_string()),
178 }
179 }
180
181 Ok(())
182 }
183
184 pub fn get_output_path(&self) -> Option<PathBuf> {
186 if let Some(ref output) = self.output {
187 Some(output.clone())
188 } else {
189 self.subtitle
190 .as_ref()
191 .map(|subtitle| create_default_output_path(subtitle))
192 }
193 }
194
195 pub fn is_manual_mode(&self) -> bool {
197 self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual))
198 }
199
200 pub fn sync_method(&self) -> SyncMethod {
202 if self.offset.is_some() {
203 SyncMethod::Manual
204 } else {
205 SyncMethod::Auto
206 }
207 }
208
209 pub fn validate_compat(&self) -> SubXResult<()> {
211 match (self.offset.is_some(), self.video.is_some()) {
212 (true, _) => Ok(()),
214 (false, true) => Ok(()),
216 (false, false) => Err(SubXError::CommandExecution(
218 "Auto sync mode requires video file.\n\n\
219Usage:\n\
220• Auto sync: subx sync <video> <subtitle>\n\
221• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
222Need help? Run: subx sync --help"
223 .to_string(),
224 )),
225 }
226 }
227
228 #[allow(dead_code)]
230 pub fn requires_video(&self) -> bool {
231 self.offset.is_none()
232 }
233
234 pub fn get_input_handler(&self) -> Result<InputPathHandler, SubXError> {
237 let optional_paths = vec![self.video.clone(), self.subtitle.clone()];
238 let merged_paths = InputPathHandler::merge_paths_from_multiple_sources(
239 &optional_paths,
240 &self.input_paths,
241 &[],
242 )?;
243
244 Ok(InputPathHandler::from_args(&merged_paths, self.recursive)?
245 .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]))
246 }
247
248 pub fn get_sync_mode(&self) -> Result<SyncMode, SubXError> {
250 if !self.input_paths.is_empty() || self.batch {
251 let paths = if !self.input_paths.is_empty() {
252 self.input_paths.clone()
253 } else if let Some(video) = &self.video {
254 vec![video.clone()]
255 } else {
256 return Err(SubXError::NoInputSpecified);
257 };
258
259 let handler = InputPathHandler::from_args(&paths, self.recursive)?
260 .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]);
261
262 Ok(SyncMode::Batch(handler))
263 } else if let (Some(video), Some(subtitle)) = (self.video.as_ref(), self.subtitle.as_ref())
264 {
265 Ok(SyncMode::Single {
266 video: video.clone(),
267 subtitle: subtitle.clone(),
268 })
269 } else if let Some(subtitle) = self.subtitle.as_ref() {
270 if self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual)) {
272 Ok(SyncMode::Single {
274 video: PathBuf::from(""), subtitle: subtitle.clone(),
276 })
277 } else {
278 Err(SubXError::InvalidSyncConfiguration)
279 }
280 } else {
281 Err(SubXError::InvalidSyncConfiguration)
282 }
283 }
284}
285
286#[derive(Debug)]
288pub enum SyncMode {
289 Single {
291 video: PathBuf,
293 subtitle: PathBuf,
295 },
296 Batch(InputPathHandler),
298}
299
300fn create_default_output_path(input: &Path) -> PathBuf {
303 let mut output = input.to_path_buf();
304
305 if let Some(stem) = input.file_stem().and_then(|s| s.to_str()) {
306 if let Some(extension) = input.extension().and_then(|s| s.to_str()) {
307 let new_filename = format!("{}_synced.{}", stem, extension);
308 output.set_file_name(new_filename);
309 }
310 }
311
312 output
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::cli::{Cli, Commands};
319 use clap::Parser;
320 use std::path::PathBuf;
321
322 #[test]
323 fn test_sync_method_selection_manual() {
324 let args = SyncArgs {
325 video: Some(PathBuf::from("video.mp4")),
326 subtitle: Some(PathBuf::from("subtitle.srt")),
327 input_paths: Vec::new(),
328 recursive: false,
329 offset: Some(2.5),
330 method: None,
331 window: 30,
332 vad_sensitivity: None,
333 output: None,
334 verbose: false,
335 dry_run: false,
336 force: false,
337 batch: false,
338 #[allow(deprecated)]
339 range: None,
340 #[allow(deprecated)]
341 threshold: None,
342 };
343 assert_eq!(args.sync_method(), SyncMethod::Manual);
344 }
345
346 #[test]
347 fn test_sync_args_batch_input() {
348 let cli = Cli::try_parse_from([
349 "subx-cli",
350 "sync",
351 "-i",
352 "dir",
353 "--batch",
354 "--recursive",
355 "--video",
356 "video.mp4",
357 ])
358 .unwrap();
359 let args = match cli.command {
360 Commands::Sync(a) => a,
361 _ => panic!("Expected Sync command"),
362 };
363 assert_eq!(args.input_paths, vec![PathBuf::from("dir")]);
364 assert!(args.batch);
365 assert!(args.recursive);
366 assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
367 }
368
369 #[test]
370 fn test_sync_args_invalid_combinations() {
371 let res = Cli::try_parse_from(["subx-cli", "sync", "--batch", "-i", "dir"]);
373 assert!(res.is_err());
374 }
375
376 #[test]
377 fn test_sync_method_selection_auto() {
378 let args = SyncArgs {
379 video: Some(PathBuf::from("video.mp4")),
380 subtitle: Some(PathBuf::from("subtitle.srt")),
381 input_paths: Vec::new(),
382 recursive: false,
383 offset: None,
384 method: None,
385 window: 30,
386 vad_sensitivity: None,
387 output: None,
388 verbose: false,
389 dry_run: false,
390 force: false,
391 batch: false,
392 #[allow(deprecated)]
393 range: None,
394 #[allow(deprecated)]
395 threshold: None,
396 };
397 assert_eq!(args.sync_method(), SyncMethod::Auto);
398 }
399
400 #[test]
401 fn test_method_arg_conversion() {
402 assert_eq!(
403 crate::core::sync::SyncMethod::from(SyncMethodArg::Vad),
404 crate::core::sync::SyncMethod::LocalVad
405 );
406 assert_eq!(
407 crate::core::sync::SyncMethod::from(SyncMethodArg::Manual),
408 crate::core::sync::SyncMethod::Manual
409 );
410 }
411
412 #[test]
413 fn test_create_default_output_path() {
414 let input = PathBuf::from("test.srt");
415 let output = create_default_output_path(&input);
416 assert_eq!(output.file_name().unwrap(), "test_synced.srt");
417
418 let input = PathBuf::from("/path/to/movie.ass");
419 let output = create_default_output_path(&input);
420 assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
421 }
422}