#[derive(Debug, Clone, ValueEnum, PartialEq)]
pub enum SyncMethodArg {
Vad,
Manual,
}
impl From<SyncMethodArg> for crate::core::sync::SyncMethod {
fn from(arg: SyncMethodArg) -> Self {
match arg {
SyncMethodArg::Vad => Self::LocalVad,
SyncMethodArg::Manual => Self::Manual,
}
}
}
use crate::cli::InputPathHandler;
use crate::error::{SubXError, SubXResult};
use clap::{Args, ValueEnum};
use std::path::{Path, PathBuf};
#[derive(Args, Debug, Clone)]
pub struct SyncArgs {
#[arg(value_name = "PATH", num_args = 0..)]
pub positional_paths: Vec<PathBuf>,
#[arg(
short = 'v',
long = "video",
value_name = "VIDEO",
help = "Video file path (optional if using positional or manual offset)"
)]
pub video: Option<PathBuf>,
#[arg(
short = 's',
long = "subtitle",
value_name = "SUBTITLE",
help = "Subtitle file path (optional if using positional or manual offset)"
)]
pub subtitle: Option<PathBuf>,
#[arg(short = 'i', long = "input", value_name = "PATH")]
pub input_paths: Vec<PathBuf>,
#[arg(short, long)]
pub recursive: bool,
#[arg(
long,
value_name = "SECONDS",
help = "Manual offset in seconds (positive delays subtitles, negative advances them)"
)]
pub offset: Option<f32>,
#[arg(short, long, value_enum, help = "Synchronization method")]
pub method: Option<SyncMethodArg>,
#[arg(
short = 'w',
long,
value_name = "SECONDS",
default_value = "30",
help = "Time window around first subtitle for analysis (seconds)"
)]
pub window: u32,
#[arg(
long,
value_name = "SENSITIVITY",
help = "VAD sensitivity threshold (0.0-1.0)"
)]
pub vad_sensitivity: Option<f32>,
#[arg(
short = 'o',
long,
value_name = "PATH",
help = "Output file path (default: input_synced.ext)"
)]
pub output: Option<PathBuf>,
#[arg(
long,
help = "Enable verbose output with detailed progress information"
)]
pub verbose: bool,
#[arg(long, help = "Analyze and display results but don't save output file")]
pub dry_run: bool,
#[arg(long, help = "Overwrite existing output file without confirmation")]
pub force: bool,
#[arg(
short = 'b',
long = "batch",
value_name = "DIRECTORY",
help = "Enable batch processing mode. Can optionally specify a directory path.",
num_args = 0..=1,
require_equals = false
)]
pub batch: Option<Option<PathBuf>>,
#[arg(long, default_value_t = false)]
pub no_extract: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SyncMethod {
Auto,
Manual,
}
impl SyncArgs {
pub fn validate(&self) -> Result<(), String> {
if let Some(SyncMethodArg::Manual) = &self.method {
if self.offset.is_none() {
return Err("Manual method requires --offset parameter.".to_string());
}
}
if self.batch.is_some() {
let has_input_paths = !self.input_paths.is_empty();
let has_positional = !self.positional_paths.is_empty();
let has_video_or_subtitle = self.video.is_some() || self.subtitle.is_some();
let has_batch_directory = matches!(&self.batch, Some(Some(_)));
if has_input_paths || has_positional || has_video_or_subtitle || has_batch_directory {
return Ok(());
}
return Err("Batch mode requires at least one input source.\n\n\
Usage:\n\
• Batch with directory: subx sync -b <directory>\n\
• Batch with input paths: subx sync -b -i <path>\n\
• Batch with positional: subx sync -b <path>\n\n\
Need help? Run: subx sync --help"
.to_string());
}
let has_video = self.video.is_some();
let has_subtitle = self.subtitle.is_some();
let has_positional = !self.positional_paths.is_empty();
let is_manual = self.offset.is_some();
if is_manual {
if has_subtitle || has_positional {
return Ok(());
}
return Err("Manual sync mode requires subtitle file.\n\n\
Usage:\n\
• Manual sync: subx sync --offset <seconds> <subtitle>\n\
• Manual sync: subx sync --offset <seconds> -s <subtitle>\n\n\
Need help? Run: subx sync --help"
.to_string());
}
if has_video || has_positional {
if self.vad_sensitivity.is_some() {
if let Some(SyncMethodArg::Manual) = &self.method {
return Err("VAD options can only be used with --method vad.".to_string());
}
}
return Ok(());
}
Err("Auto sync mode requires video file or positional path.\n\n\
Usage:\n\
• Auto sync: subx sync <video> <subtitle> or subx sync <video_path>\n\
• Auto sync: subx sync -v <video> -s <subtitle>\n\
• Manual sync: subx sync --offset <seconds> <subtitle>\n\
• Batch mode: subx sync -b [directory]\n\n\
Need help? Run: subx sync --help"
.to_string())
}
pub fn get_output_path(&self) -> Option<PathBuf> {
if let Some(ref output) = self.output {
Some(output.clone())
} else {
self.subtitle
.as_ref()
.map(|subtitle| create_default_output_path(subtitle))
}
}
pub fn is_manual_mode(&self) -> bool {
self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual))
}
pub fn sync_method(&self) -> SyncMethod {
if self.offset.is_some() {
SyncMethod::Manual
} else {
SyncMethod::Auto
}
}
pub fn validate_compat(&self) -> SubXResult<()> {
if self.offset.is_none() && self.video.is_none() && !self.positional_paths.is_empty() {
return Ok(());
}
match (self.offset.is_some(), self.video.is_some()) {
(true, _) => Ok(()),
(false, true) => Ok(()),
(false, false) => Err(SubXError::CommandExecution(
"Auto sync mode requires video file.\n\n\
Usage:\n\
• Auto sync: subx sync <video> <subtitle>\n\
• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
Need help? Run: subx sync --help"
.to_string(),
)),
}
}
#[allow(dead_code)]
pub fn requires_video(&self) -> bool {
self.offset.is_none()
}
pub fn get_input_handler(&self) -> Result<InputPathHandler, SubXError> {
let optional_paths = vec![self.video.clone(), self.subtitle.clone()];
let string_paths: Vec<String> = self
.positional_paths
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
let merged_paths = InputPathHandler::merge_paths_from_multiple_sources(
&optional_paths,
&self.input_paths,
&string_paths,
)?;
Ok(InputPathHandler::from_args(&merged_paths, self.recursive)?
.with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"])
.with_no_extract(self.no_extract))
}
pub fn get_sync_mode(&self) -> Result<SyncMode, SubXError> {
if self.batch.is_some()
|| !self.input_paths.is_empty()
|| self
.positional_paths
.iter()
.any(|p| p.extension().is_none())
{
let mut paths = Vec::new();
if let Some(Some(batch_dir)) = &self.batch {
paths.push(batch_dir.clone());
}
paths.extend(self.input_paths.clone());
paths.extend(self.positional_paths.clone());
if paths.is_empty() {
paths.push(PathBuf::from("."));
}
let handler = InputPathHandler::from_args(&paths, self.recursive)?
.with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"])
.with_no_extract(self.no_extract);
return Ok(SyncMode::Batch(handler));
}
if !self.positional_paths.is_empty() {
if self.positional_paths.len() == 1 {
let path = &self.positional_paths[0];
let ext = path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
let mut video = None;
let mut subtitle = None;
match ext.as_str() {
"mp4" | "mkv" | "avi" | "mov" => {
video = Some(path.clone());
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
for sub_ext in &["srt", "ass", "vtt", "sub"] {
let cand = dir.join(format!("{stem}.{sub_ext}"));
if cand.exists() {
subtitle = Some(cand);
break;
}
}
}
}
"srt" | "ass" | "vtt" | "sub" => {
subtitle = Some(path.clone());
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
for vid_ext in &["mp4", "mkv", "avi", "mov"] {
let cand = dir.join(format!("{stem}.{vid_ext}"));
if cand.exists() {
video = Some(cand);
break;
}
}
}
}
_ => {}
}
if self.is_manual_mode() {
if let Some(subtitle_path) = subtitle {
return Ok(SyncMode::Single {
video: PathBuf::new(), subtitle: subtitle_path,
});
}
}
if let (Some(v), Some(s)) = (video, subtitle) {
return Ok(SyncMode::Single {
video: v,
subtitle: s,
});
}
return Err(SubXError::InvalidSyncConfiguration);
} else if self.positional_paths.len() == 2 {
let mut video = None;
let mut subtitle = None;
for p in &self.positional_paths {
if let Some(ext) = p
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase())
{
if ["mp4", "mkv", "avi", "mov"].contains(&ext.as_str()) {
video = Some(p.clone());
}
if ["srt", "ass", "vtt", "sub"].contains(&ext.as_str()) {
subtitle = Some(p.clone());
}
}
}
if let (Some(v), Some(s)) = (video, subtitle) {
return Ok(SyncMode::Single {
video: v,
subtitle: s,
});
}
return Err(SubXError::InvalidSyncConfiguration);
}
}
if let (Some(video), Some(subtitle)) = (self.video.as_ref(), self.subtitle.as_ref()) {
Ok(SyncMode::Single {
video: video.clone(),
subtitle: subtitle.clone(),
})
} else if self.is_manual_mode() {
if let Some(subtitle) = self.subtitle.as_ref() {
Ok(SyncMode::Single {
video: PathBuf::new(), subtitle: subtitle.clone(),
})
} else {
Err(SubXError::InvalidSyncConfiguration)
}
} else {
Err(SubXError::InvalidSyncConfiguration)
}
}
}
#[derive(Debug)]
pub enum SyncMode {
Single {
video: PathBuf,
subtitle: PathBuf,
},
Batch(InputPathHandler),
}
pub fn create_default_output_path(input: &Path) -> PathBuf {
let mut output = input.to_path_buf();
if let Some(stem) = input.file_stem().and_then(|s| s.to_str()) {
if let Some(extension) = input.extension().and_then(|s| s.to_str()) {
let new_filename = format!("{stem}_synced.{extension}");
output.set_file_name(new_filename);
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{Cli, Commands};
use clap::Parser;
use std::path::PathBuf;
#[test]
fn test_sync_method_selection_manual() {
let args = SyncArgs {
positional_paths: Vec::new(),
video: Some(PathBuf::from("video.mp4")),
subtitle: Some(PathBuf::from("subtitle.srt")),
input_paths: Vec::new(),
recursive: false,
offset: Some(2.5),
method: None,
window: 30,
vad_sensitivity: None,
output: None,
verbose: false,
dry_run: false,
force: false,
batch: None,
no_extract: false,
};
assert_eq!(args.sync_method(), SyncMethod::Manual);
}
#[test]
fn test_sync_args_batch_input() {
let cli = Cli::try_parse_from([
"subx-cli",
"sync",
"-i",
"dir",
"--batch",
"--recursive",
"--video",
"video.mp4",
])
.unwrap();
let args = match cli.command {
Commands::Sync(a) => a,
_ => panic!("Expected Sync command"),
};
assert_eq!(args.input_paths, vec![PathBuf::from("dir")]);
assert!(args.batch.is_some());
assert!(args.recursive);
assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
}
#[test]
fn test_sync_args_invalid_combinations() {
let cli = Cli::try_parse_from(["subx-cli", "sync", "--batch", "-i", "dir"]).unwrap();
let args = match cli.command {
Commands::Sync(a) => a,
_ => panic!("Expected Sync command"),
};
assert!(args.validate().is_ok());
let args_invalid = SyncArgs {
positional_paths: Vec::new(),
video: None,
subtitle: None,
input_paths: Vec::new(),
recursive: false,
offset: None,
method: None,
window: 30,
vad_sensitivity: None,
output: None,
verbose: false,
dry_run: false,
force: false,
batch: Some(None), no_extract: false,
};
assert!(args_invalid.validate().is_err());
}
#[test]
fn test_sync_method_selection_auto() {
let args = SyncArgs {
positional_paths: Vec::new(),
video: Some(PathBuf::from("video.mp4")),
subtitle: Some(PathBuf::from("subtitle.srt")),
input_paths: Vec::new(),
recursive: false,
offset: None,
method: None,
window: 30,
vad_sensitivity: None,
output: None,
verbose: false,
dry_run: false,
force: false,
batch: None,
no_extract: false,
};
assert_eq!(args.sync_method(), SyncMethod::Auto);
}
#[test]
fn test_method_arg_conversion() {
assert_eq!(
crate::core::sync::SyncMethod::from(SyncMethodArg::Vad),
crate::core::sync::SyncMethod::LocalVad
);
assert_eq!(
crate::core::sync::SyncMethod::from(SyncMethodArg::Manual),
crate::core::sync::SyncMethod::Manual
);
}
#[test]
fn test_create_default_output_path() {
let input = PathBuf::from("test.srt");
let output = create_default_output_path(&input);
assert_eq!(output.file_name().unwrap(), "test_synced.srt");
let input = PathBuf::from("/path/to/movie.ass");
let output = create_default_output_path(&input);
assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
}
}