use crate::cli::SyncArgs;
use crate::cli::SyncMode;
use crate::cli::output::{OutputMode, active_mode, emit_success};
use crate::cli::sync_args::create_default_output_path;
use crate::config::Config;
use crate::config::ConfigService;
use crate::core::formats::manager::FormatManager;
use crate::core::sync::{SyncEngine, SyncMethod, SyncResult};
use crate::{Result, error::SubXError};
use serde::Serialize;
const VAD_CHUNK_MS: u32 = 32;
#[derive(Debug, Serialize)]
pub struct SyncPayload {
pub method: String,
pub inputs: Vec<SyncInput>,
pub operations: Vec<SyncOperation>,
}
#[derive(Debug, Serialize)]
pub struct SyncInput {
pub subtitle_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_path: Option<String>,
pub detected_offset_ms: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vad: Option<VadInfoPayload>,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<SyncItemError>,
}
#[derive(Debug, Serialize)]
pub struct SyncOperation {
pub subtitle_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_path: Option<String>,
pub applied: bool,
pub dry_run: bool,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<SyncItemError>,
}
#[derive(Debug, Serialize)]
pub struct VadInfoPayload {
pub sensitivity: f32,
pub padding_ms: u32,
pub segments: Vec<serde_json::Value>,
}
#[derive(Debug, Serialize, Clone)]
pub struct SyncItemError {
pub code: String,
pub category: String,
pub message: String,
}
struct SyncSingleResult {
input: SyncInput,
operation: SyncOperation,
}
fn method_to_str(m: &SyncMethod) -> &'static str {
match m {
SyncMethod::LocalVad => "vad",
SyncMethod::Manual => "manual",
SyncMethod::Auto => "auto",
}
}
fn build_single_result(
args: &SyncArgs,
sync_result: &SyncResult,
subtitle_path: &std::path::Path,
audio_path: Option<&std::path::Path>,
output_path: Option<&std::path::Path>,
applied: bool,
vad_cfg: &crate::config::VadConfig,
) -> SyncSingleResult {
let offset_ms = (sync_result.offset_seconds as f64 * 1000.0).round() as i64;
let confidence = if matches!(sync_result.method_used, SyncMethod::Manual) {
None
} else {
Some(sync_result.confidence)
};
let vad = if matches!(sync_result.method_used, SyncMethod::LocalVad) {
let segments = sync_result
.additional_info
.as_ref()
.and_then(|v| v.get("detected_segments"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
Some(VadInfoPayload {
sensitivity: vad_cfg.sensitivity,
padding_ms: vad_cfg.padding_chunks.saturating_mul(VAD_CHUNK_MS),
segments,
})
} else {
None
};
let subtitle_str = subtitle_path.display().to_string();
let input = SyncInput {
subtitle_path: subtitle_str.clone(),
audio_path: audio_path.map(|p| p.display().to_string()),
detected_offset_ms: offset_ms,
confidence,
vad,
status: "ok",
error: None,
};
let operation = SyncOperation {
subtitle_path: subtitle_str,
output_path: output_path.map(|p| p.display().to_string()),
applied,
dry_run: args.dry_run,
status: "ok",
error: None,
};
SyncSingleResult { input, operation }
}
fn resolve_method_string(args: &SyncArgs, default_method: &str) -> String {
if args.offset.is_some() {
return "manual".to_string();
}
if let Some(method_arg) = &args.method {
return method_to_str(&method_arg.clone().into()).to_string();
}
if args.vad_sensitivity.is_some() {
return "vad".to_string();
}
match default_method {
"vad" => "vad".to_string(),
"auto" => "auto".to_string(),
_ => "auto".to_string(),
}
}
fn make_skip_input_op(
sub_path: &std::path::Path,
audio_path: Option<&std::path::Path>,
reason: &str,
dry_run: bool,
) -> (SyncInput, SyncOperation) {
let err = SyncItemError {
code: "E_FILE_MATCHING".to_string(),
category: "file_matching".to_string(),
message: format!("Skip sync: {reason}"),
};
let subtitle_str = sub_path.display().to_string();
let input = SyncInput {
subtitle_path: subtitle_str.clone(),
audio_path: audio_path.map(|p| p.display().to_string()),
detected_offset_ms: 0,
confidence: None,
vad: None,
status: "error",
error: Some(err.clone()),
};
let operation = SyncOperation {
subtitle_path: subtitle_str,
output_path: None,
applied: false,
dry_run,
status: "error",
error: Some(err),
};
(input, operation)
}
async fn run_single(
args: &SyncArgs,
config: &Config,
sync_engine: &SyncEngine,
format_manager: &FormatManager,
) -> Result<SyncSingleResult> {
let json = active_mode().is_json();
let subtitle_path = args.subtitle.as_ref().ok_or_else(|| {
SubXError::CommandExecution(
"Subtitle file path is required for single file sync".to_string(),
)
})?;
if args.verbose && !json {
println!("🎬 Loading subtitle file: {}", subtitle_path.display());
println!("📄 Subtitle entries count: {}", {
let s = format_manager.load_subtitle(subtitle_path).map_err(|e| {
log::debug!("Failed to load subtitle: {e}");
e
})?;
s.entries.len()
});
}
let mut subtitle = format_manager.load_subtitle(subtitle_path).map_err(|e| {
log::debug!("Failed to load subtitle: {e}");
e
})?;
let mut effective_vad_cfg = config.sync.vad.clone();
let mut audio_for_payload: Option<std::path::PathBuf> = None;
let sync_result = if let Some(offset) = args.offset {
if args.verbose && !json {
println!("⚙️ Using manual offset: {offset:.3}s");
}
sync_engine
.apply_manual_offset(&mut subtitle, offset)
.map_err(|e| {
log::debug!("Failed to apply manual offset: {e}");
e
})?;
SyncResult {
offset_seconds: offset,
confidence: 1.0,
method_used: crate::core::sync::SyncMethod::Manual,
correlation_peak: 0.0,
processing_duration: std::time::Duration::ZERO,
warnings: Vec::new(),
additional_info: None,
}
} else {
let video_path = args.video.as_ref().ok_or_else(|| {
SubXError::CommandExecution(
"Video file path is required for automatic sync".to_string(),
)
})?;
if video_path.as_os_str().is_empty() {
return Err(SubXError::CommandExecution(
"Video file path is required for automatic sync".to_string(),
));
}
let method = determine_sync_method(args, &config.sync.default_method)?;
if args.verbose && !json {
println!("🔍 Starting sync analysis...");
println!(" Method: {method:?}");
println!(" Analysis window: {}s", args.window);
println!(" Video file: {}", video_path.display());
}
let mut sync_cfg = config.sync.clone();
apply_cli_overrides(&mut sync_cfg, args)?;
effective_vad_cfg = sync_cfg.vad.clone();
audio_for_payload = Some(video_path.clone());
let result = sync_engine
.detect_sync_offset(video_path.as_path(), &subtitle, Some(method))
.await
.map_err(|e| {
log::debug!("Failed to detect sync offset: {e}");
e
})?;
if args.verbose && !json {
println!("✅ Analysis completed:");
println!(" Detected offset: {:.3}s", result.offset_seconds);
println!(" Confidence: {:.1}%", result.confidence * 100.0);
println!(" Processing time: {:?}", result.processing_duration);
}
if !args.dry_run {
sync_engine
.apply_manual_offset(&mut subtitle, result.offset_seconds)
.map_err(|e| {
log::debug!("Failed to apply detected offset: {e}");
e
})?;
}
result
};
if !json {
display_sync_result(&sync_result, args.verbose);
}
let mut applied = false;
let mut output_path_used: Option<std::path::PathBuf> = None;
if !args.dry_run {
if let Some(out) = args.get_output_path() {
if out.exists() && !args.force {
log::debug!("Output file exists and --force not set: {}", out.display());
return Err(SubXError::CommandExecution(format!(
"Output file already exists: {}. Use --force to overwrite.",
out.display()
)));
}
format_manager.save_subtitle(&subtitle, &out).map_err(|e| {
log::debug!("Failed to save subtitle: {e}");
e
})?;
if !json {
if args.verbose {
println!("💾 Synchronized subtitle saved to: {}", out.display());
} else {
println!("Synchronized subtitle saved to: {}", out.display());
}
}
applied = true;
output_path_used = Some(out);
} else {
log::debug!("No output path specified");
return Err(SubXError::CommandExecution(
"No output path specified".to_string(),
));
}
} else if !json {
println!("🔍 Dry run mode - file not saved");
}
Ok(build_single_result(
args,
&sync_result,
subtitle_path,
audio_for_payload.as_deref(),
output_path_used.as_deref(),
applied,
&effective_vad_cfg,
))
}
pub async fn execute(args: SyncArgs, config_service: &dyn ConfigService) -> Result<()> {
if let Err(msg) = args.validate() {
return Err(SubXError::CommandExecution(msg));
}
let config = config_service.get_config()?;
if let Some(manual_offset) = args.offset {
if manual_offset.abs() > config.sync.max_offset_seconds {
return Err(SubXError::config(format!(
"The specified offset {:.2}s exceeds the configured maximum allowed value {:.2}s.\n\n\
Please use one of the following methods to resolve this issue:\n\
1. Use a smaller offset: --offset {:.2}\n\
2. Adjust configuration: subx-cli config set sync.max_offset_seconds {:.2}\n\
3. Use automatic detection: remove the --offset parameter",
manual_offset,
config.sync.max_offset_seconds,
config.sync.max_offset_seconds * 0.9, manual_offset
.abs()
.max(config.sync.max_offset_seconds * 1.5) )));
}
}
let sync_engine = SyncEngine::new(config.sync.clone())?;
let format_manager = FormatManager::new();
let mode = active_mode();
let json = matches!(mode, OutputMode::Json);
if let Ok(SyncMode::Batch(handler)) = args.get_sync_mode() {
let paths = handler
.collect_files()
.map_err(|e| SubXError::CommandExecution(e.to_string()))?;
let video_files: Vec<_> = paths
.iter()
.filter(|p| {
p.extension()
.and_then(|s| s.to_str())
.map(|e| ["mp4", "mkv", "avi", "mov"].contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
})
.collect();
let subtitle_files: Vec<_> = paths
.iter()
.filter(|p| {
p.extension()
.and_then(|s| s.to_str())
.map(|e| ["srt", "ass", "vtt", "sub"].contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
})
.collect();
let mut inputs: Vec<SyncInput> = Vec::new();
let mut operations: Vec<SyncOperation> = Vec::new();
let method_string = resolve_method_string(&args, &config.sync.default_method);
if video_files.is_empty() {
for sub_path in &subtitle_files {
if !json {
println!(
"✗ Skip sync for {}: no video files found in directory",
sub_path.display()
);
}
if json {
let (input, op) = make_skip_input_op(
sub_path,
None,
"no video files found in directory",
args.dry_run,
);
inputs.push(input);
operations.push(op);
}
}
if json {
return Err(SubXError::FileMatching {
message: "No video files found in directory; cannot sync any subtitles"
.to_string(),
});
}
return Ok(());
}
if video_files.len() == 1 && subtitle_files.len() == 1 {
let mut single_args = args.clone();
single_args.input_paths.clear();
single_args.batch = None;
single_args.recursive = false;
single_args.video = Some(video_files[0].clone());
single_args.subtitle = Some(subtitle_files[0].clone());
if single_args.output.is_none() {
if let Some(archive_path) = paths.archive_origin(subtitle_files[0]) {
if let Some(archive_dir) = archive_path.parent() {
let default = create_default_output_path(subtitle_files[0]);
if let Some(filename) = default.file_name() {
single_args.output = Some(archive_dir.join(filename));
}
}
}
}
let pair = run_single(&single_args, &config, &sync_engine, &format_manager).await?;
if json {
emit_success(
mode,
"sync",
SyncPayload {
method: method_string,
inputs: vec![pair.input],
operations: vec![pair.operation],
},
);
}
return Ok(());
}
let mut processed_videos = std::collections::HashSet::new();
let mut processed_subtitles = std::collections::HashSet::new();
for sub_path in &subtitle_files {
let sub_name = sub_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let sub_dir = sub_path.parent();
let matching_video = video_files.iter().find(|&video_path| {
let video_name = video_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let video_dir = video_path.parent();
if sub_dir != video_dir {
return false;
}
let dir_videos: Vec<_> = video_files
.iter()
.filter(|v| v.parent() == video_dir)
.collect();
let dir_subtitles: Vec<_> = subtitle_files
.iter()
.filter(|s| s.parent() == sub_dir)
.collect();
if dir_videos.len() == 1 && dir_subtitles.len() == 1 {
return true;
}
!video_name.is_empty() && sub_name.starts_with(video_name)
});
if let Some(video_path) = matching_video {
let mut single_args = args.clone();
single_args.input_paths.clear();
single_args.batch = None;
single_args.recursive = false;
single_args.video = Some((*video_path).clone());
single_args.subtitle = Some((*sub_path).clone());
if single_args.output.is_none() {
if let Some(archive_path) = paths.archive_origin(sub_path) {
if let Some(archive_dir) = archive_path.parent() {
let default = create_default_output_path(sub_path);
if let Some(filename) = default.file_name() {
single_args.output = Some(archive_dir.join(filename));
}
}
}
}
match run_single(&single_args, &config, &sync_engine, &format_manager).await {
Ok(pair) => {
if json {
inputs.push(pair.input);
operations.push(pair.operation);
}
}
Err(err) => {
if !json {
return Err(err);
}
let item_err = SyncItemError {
code: err.machine_code().to_string(),
category: err.category().to_string(),
message: err.user_friendly_message(),
};
let subtitle_str = sub_path.display().to_string();
let audio_str = (*video_path).display().to_string();
inputs.push(SyncInput {
subtitle_path: subtitle_str.clone(),
audio_path: Some(audio_str),
detected_offset_ms: 0,
confidence: None,
vad: None,
status: "error",
error: Some(item_err.clone()),
});
operations.push(SyncOperation {
subtitle_path: subtitle_str,
output_path: None,
applied: false,
dry_run: args.dry_run,
status: "error",
error: Some(item_err),
});
}
}
processed_videos.insert(video_path.as_path());
processed_subtitles.insert(sub_path.as_path());
}
}
for video_path in &video_files {
if !processed_videos.contains(video_path.as_path()) && !json {
println!(
"✗ Skip sync for {}: no matching subtitle",
video_path.display()
);
}
}
for sub_path in &subtitle_files {
if !processed_subtitles.contains(sub_path.as_path()) {
if !json {
println!("✗ Skip sync for {}: no matching video", sub_path.display());
} else {
let (input, op) =
make_skip_input_op(sub_path, None, "no matching video", args.dry_run);
inputs.push(input);
operations.push(op);
}
}
}
if json {
let forward_progress =
inputs.iter().any(|i| i.status == "ok") || operations.iter().any(|o| o.applied);
if !forward_progress {
return Err(SubXError::FileMatching {
message: "No subtitle/video pairs were synced successfully".to_string(),
});
}
emit_success(
mode,
"sync",
SyncPayload {
method: method_string,
inputs,
operations,
},
);
}
return Ok(());
}
match args.get_sync_mode() {
Ok(SyncMode::Single { video, subtitle }) => {
let mut resolved_args = args.clone();
if !video.as_os_str().is_empty() {
resolved_args.video = Some(video.clone());
}
resolved_args.subtitle = Some(subtitle.clone());
if resolved_args.video.is_none() && resolved_args.offset.is_none() {
resolved_args.offset = Some(0.0);
resolved_args.method = Some(crate::cli::SyncMethodArg::Manual);
}
let method_string = resolve_method_string(&resolved_args, &config.sync.default_method);
let pair = run_single(&resolved_args, &config, &sync_engine, &format_manager).await?;
if json {
emit_success(
mode,
"sync",
SyncPayload {
method: method_string,
inputs: vec![pair.input],
operations: vec![pair.operation],
},
);
}
Ok(())
}
Err(err) => Err(err),
_ => unreachable!(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TestConfigService;
use std::fs;
use std::sync::Arc;
use tempfile::TempDir;
#[tokio::test]
async fn test_sync_batch_processing() -> Result<()> {
let config_service = Arc::new(TestConfigService::with_sync_settings(0.5, 30.0));
let tmp = TempDir::new().unwrap();
let video1 = tmp.path().join("movie1.mp4");
let sub1 = tmp.path().join("movie1.srt");
fs::write(&video1, b"").unwrap();
fs::write(&sub1, b"1\n00:00:01,000 --> 00:00:02,000\nTest1\n\n").unwrap();
let args = SyncArgs {
positional_paths: Vec::new(),
video: Some(video1.clone()),
subtitle: Some(sub1.clone()),
input_paths: vec![],
recursive: false,
offset: Some(1.0), method: Some(crate::cli::SyncMethodArg::Manual),
window: 30,
vad_sensitivity: None,
output: None,
verbose: false,
dry_run: true, force: true,
batch: None, no_extract: false,
};
execute(args, config_service.as_ref()).await?;
Ok(())
}
}
pub async fn execute_with_config(
args: SyncArgs,
config_service: std::sync::Arc<dyn ConfigService>,
) -> Result<()> {
execute(args, config_service.as_ref()).await
}
fn determine_sync_method(args: &SyncArgs, default_method: &str) -> Result<SyncMethod> {
if let Some(ref method_arg) = args.method {
return Ok(method_arg.clone().into());
}
if args.vad_sensitivity.is_some() {
return Ok(SyncMethod::LocalVad);
}
match default_method {
"vad" => Ok(SyncMethod::LocalVad),
"auto" => Ok(SyncMethod::Auto),
_ => Ok(SyncMethod::Auto),
}
}
fn apply_cli_overrides(config: &mut crate::config::SyncConfig, args: &SyncArgs) -> Result<()> {
if let Some(sensitivity) = args.vad_sensitivity {
config.vad.sensitivity = sensitivity;
}
Ok(())
}
fn display_sync_result(result: &SyncResult, verbose: bool) {
if verbose {
println!("\n=== Sync Results ===");
println!("Method used: {:?}", result.method_used);
println!("Detected offset: {:.3} seconds", result.offset_seconds);
println!("Confidence: {:.1}%", result.confidence * 100.0);
println!("Processing time: {:?}", result.processing_duration);
if !result.warnings.is_empty() {
println!("\nWarnings:");
for warning in &result.warnings {
println!(" ⚠️ {warning}");
}
}
if let Some(info) = &result.additional_info {
if let Ok(pretty_info) = serde_json::to_string_pretty(info) {
println!("\nAdditional information:");
println!("{pretty_info}");
}
}
} else {
println!(
"✅ Sync completed: offset {:.3}s (confidence: {:.1}%)",
result.offset_seconds,
result.confidence * 100.0
);
}
}