diff --git a/README.md b/README.md
index 7316f57..88c13ee 100644
--- a/README.md
+++ b/README.md
@@ -462,7 +462,7 @@ The `archive` command lets you download episodes with multiple audios and subtit
In the best case, when multiple audio & subtitle tracks are used, there is only one *video* track and all other languages can be stored as audio-only.
But, as said, this is not always the case.
With the `-m` / `--merge` flag you can define the behaviour when an episodes' video tracks differ in length.
- Valid options are `audio` - store one video and all other languages as audio only; `video` - store the video + audio for every language; `auto` - detect if videos differ in length: if so, behave like `video` - otherwise like `audio`.
+ Valid options are `audio` - store one video and all other languages as audio only; `video` - store the video + audio for every language; `auto` - detect if videos differ in length: if so, behave like `video` - otherwise like `audio`; `sync` - detect if videos differ in length: if so, it tries to find the offset of matching audio parts and removes the offset from the beginning, otherwise it behaves like `audio`.
Subtitles will always match the primary audio and video.
```shell
@@ -482,15 +482,12 @@ The `archive` command lets you download episodes with multiple audios and subtit
Default are `200` milliseconds.
-- Sync start
+- Sync tolerance
- If you want that all videos of the same episode should start at the same time and `--merge` doesn't fit your needs (e.g. one video has an intro, all other doesn't), you might consider using the `--sync-start`.
- It tries to sync the timing of all downloaded audios to match one video.
- This is done by downloading the first few segments/frames of all video tracks that differ in length and comparing them frame by frame.
- The flag takes an optional value determines how accurate the syncing is, generally speaking everything over 15 begins to be more inaccurate and everything below 6 is too accurate (and won't succeed).
- When the syncing fails, the command is continued as if `--sync-start` wasn't provided for this episode.
+ Sometimes two video tracks are downloaded with `--merge` set to `sync` because the audio fingerprinting fails to identify matching audio parts (e.g. opening).
+ To prevent this, you can use the "--sync-tolerance" flag to specify the difference by which two fingerprints are considered equal.
- Default is `7.5`.
+ Default is `6`.
- Language tagging
diff --git a/crunchy-cli-core/src/archive/command.rs b/crunchy-cli-core/src/archive/command.rs
index 3525a4a..87c133e 100644
--- a/crunchy-cli-core/src/archive/command.rs
+++ b/crunchy-cli-core/src/archive/command.rs
@@ -90,32 +90,31 @@ pub struct Archive {
pub(crate) resolution: Resolution,
#[arg(
- help = "Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio' and 'video'"
+ help = "Sets the behavior of the stream merging. Valid behaviors are 'auto', 'sync', 'audio' and 'video'"
)]
#[arg(
long_help = "Because of local restrictions (or other reasons) some episodes with different languages does not have the same length (e.g. when some scenes were cut out). \
With this flag you can set the behavior when handling multiple language.
- Valid options are 'audio' (stores one video and all other languages as audio only), 'video' (stores the video + audio for every language) and 'auto' (detects if videos differ in length: if so, behave like 'video' else like 'audio')"
+ Valid options are 'audio' (stores one video and all other languages as audio only), 'video' (stores the video + audio for every language), 'auto' (detects if videos differ in length: if so, behave like 'video' else like 'audio') and 'sync' (detects if videos differ in length: if so, tries to find the offset of matching audio parts and removes it from the beginning, otherwise it behaves like 'audio')"
)]
#[arg(short, long, default_value = "auto")]
#[arg(value_parser = MergeBehavior::parse)]
pub(crate) merge: MergeBehavior,
#[arg(
- help = "If the merge behavior is 'auto', only download multiple video tracks if their length difference is higher than the given milliseconds"
+ help = "If the merge behavior is 'auto' or 'sync', consider videos to be of equal lengths if the difference in length is smaller than the specified milliseconds"
)]
#[arg(long, default_value_t = 200)]
pub(crate) merge_time_tolerance: u32,
- #[arg(help = "Tries to sync the timing of all downloaded audios to match one video")]
#[arg(
- long_help = "Tries to sync the timing of all downloaded audios to match one video. \
- This is done by downloading the first few segments/frames of all video tracks that differ in length and comparing them frame by frame. \
- The value of this flag determines how accurate the syncing is, generally speaking everything over 15 begins to be more inaccurate and everything below 6 is too accurate (and won't succeed). \
- If you want to provide a custom value to this flag, you have to set it with an equals (e.g. `--sync-start=10` instead of `--sync-start 10`). \
- When the syncing fails, the command is continued as if `--sync-start` wasn't provided for this episode
- "
+ help = "If the merge behavior is 'sync', specify the difference by which two fingerprints are considered equal"
)]
- #[arg(long, require_equals = true, num_args = 0..=1, default_missing_value = "7.5")]
- pub(crate) sync_start: Option,
+ #[arg(long, default_value_t = 6)]
+ pub(crate) sync_tolerance: u32,
+ #[arg(
+ help = "If the merge behavior is 'sync', specify the amount of offset determination runs from which the final offset is calculated"
+ )]
+ #[arg(long, default_value_t = 4)]
+ pub(crate) sync_precision: u32,
#[arg(
help = "Specified which language tagging the audio and subtitle tracks and language specific format options should have. \
@@ -229,18 +228,10 @@ impl Execute for Archive {
}
if self.include_chapters
+ && !matches!(self.merge, MergeBehavior::Sync)
&& !matches!(self.merge, MergeBehavior::Audio)
- && self.sync_start.is_none()
{
- bail!("`--include-chapters` can only be used if `--merge` is set to 'audio' or `--sync-start` is set")
- }
-
- if !matches!(self.merge, MergeBehavior::Auto) && self.sync_start.is_some() {
- bail!("`--sync-start` can only be used if `--merge` is set to `auto`")
- }
-
- if self.sync_start.is_some() && self.ffmpeg_preset.is_none() {
- warn!("Using `--sync-start` without `--ffmpeg-preset` might produce worse sync results than with `--ffmpeg-preset` set")
+ bail!("`--include-chapters` can only be used if `--merge` is set to 'audio' or 'sync'")
}
self.audio = all_locale_in_locales(self.audio.clone());
@@ -317,7 +308,14 @@ impl Execute for Archive {
.audio_sort(Some(self.audio.clone()))
.subtitle_sort(Some(self.subtitle.clone()))
.no_closed_caption(self.no_closed_caption)
- .sync_start_value(self.sync_start)
+ .sync_tolerance(match self.merge {
+ MergeBehavior::Sync => Some(self.sync_tolerance),
+ _ => None,
+ })
+ .sync_precision(match self.merge {
+ MergeBehavior::Sync => Some(self.sync_precision),
+ _ => None,
+ })
.threads(self.threads)
.audio_locale_output_map(
zip(self.audio.clone(), self.output_audio_locales.clone()).collect(),
@@ -560,7 +558,7 @@ async fn get_format(
},
},
}),
- MergeBehavior::Auto => {
+ MergeBehavior::Auto | MergeBehavior::Sync => {
let mut d_formats: Vec<(Duration, DownloadFormat)> = vec![];
for (single_format, video, audio, subtitles) in format_pairs {
diff --git a/crunchy-cli-core/src/utils/download.rs b/crunchy-cli-core/src/utils/download.rs
index bd7bf3d..67cb66a 100644
--- a/crunchy-cli-core/src/utils/download.rs
+++ b/crunchy-cli-core/src/utils/download.rs
@@ -2,15 +2,13 @@ use crate::utils::ffmpeg::FFmpegPreset;
use crate::utils::filter::real_dedup_vec;
use crate::utils::fmt::format_time_delta;
use crate::utils::log::progress;
-use crate::utils::os::{
- cache_dir, is_special_file, temp_directory, temp_named_pipe, tempdir, tempfile,
-};
+use crate::utils::os::{cache_dir, is_special_file, temp_directory, temp_named_pipe, tempfile};
use crate::utils::rate_limit::RateLimiterService;
+use crate::utils::sync::{sync_audios, SyncAudio};
use anyhow::{bail, Result};
use chrono::{NaiveTime, TimeDelta};
use crunchyroll_rs::media::{SkipEvents, SkipEventsEvent, StreamData, StreamSegment, Subtitle};
use crunchyroll_rs::Locale;
-use image_hasher::{Hasher, HasherConfig, ImageHash};
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressFinish, ProgressStyle};
use log::{debug, warn, LevelFilter};
use regex::Regex;
@@ -39,6 +37,7 @@ pub enum MergeBehavior {
Video,
Audio,
Auto,
+ Sync,
}
impl MergeBehavior {
@@ -47,6 +46,7 @@ impl MergeBehavior {
"video" => MergeBehavior::Video,
"audio" => MergeBehavior::Audio,
"auto" => MergeBehavior::Auto,
+ "sync" => MergeBehavior::Sync,
_ => return Err(format!("'{}' is not a valid merge behavior", s)),
})
}
@@ -64,7 +64,8 @@ pub struct DownloadBuilder {
force_hardsub: bool,
download_fonts: bool,
no_closed_caption: bool,
- sync_start_value: Option,
+ sync_tolerance: Option,
+ sync_precision: Option,
threads: usize,
ffmpeg_threads: Option,
audio_locale_output_map: HashMap,
@@ -84,7 +85,8 @@ impl DownloadBuilder {
force_hardsub: false,
download_fonts: false,
no_closed_caption: false,
- sync_start_value: None,
+ sync_tolerance: None,
+ sync_precision: None,
threads: num_cpus::get(),
ffmpeg_threads: None,
audio_locale_output_map: HashMap::new(),
@@ -106,7 +108,8 @@ impl DownloadBuilder {
download_fonts: self.download_fonts,
no_closed_caption: self.no_closed_caption,
- sync_start_value: self.sync_start_value,
+ sync_tolerance: self.sync_tolerance,
+ sync_precision: self.sync_precision,
download_threads: self.threads,
ffmpeg_threads: self.ffmpeg_threads,
@@ -165,7 +168,8 @@ pub struct Downloader {
download_fonts: bool,
no_closed_caption: bool,
- sync_start_value: Option,
+ sync_tolerance: Option,
+ sync_precision: Option,
download_threads: usize,
ffmpeg_threads: Option,
@@ -245,6 +249,7 @@ impl Downloader {
let mut video_offset = None;
let mut audio_offsets = HashMap::new();
let mut subtitle_offsets = HashMap::new();
+ let mut raw_audios = vec![];
let mut videos = vec![];
let mut audios = vec![];
let mut subtitles = vec![];
@@ -263,40 +268,32 @@ impl Downloader {
.max()
.unwrap();
- if self.formats.len() > 1 && self.sync_start_value.is_some() {
- let all_segments_count: Vec = self
- .formats
- .iter()
- .map(|f| f.video.0.segments().len())
- .collect();
- let sync_segments = 11.max(
- all_segments_count.iter().max().unwrap() - all_segments_count.iter().min().unwrap(),
- );
- let mut sync_vids = vec![];
- for (i, format) in self.formats.iter().enumerate() {
+ // downloads all audios
+ for (i, format) in self.formats.iter().enumerate() {
+ for (stream_data, locale) in &format.audios {
let path = self
- .download_video(
- &format.video.0,
- format!("Downloading video #{} sync segments", i + 1),
- Some(sync_segments),
+ .download_audio(
+ stream_data,
+ format!("{:<1$}", format!("Downloading {} audio", locale), fmt_space),
)
.await?;
- sync_vids.push(SyncVideo {
+ raw_audios.push(SyncAudio {
+ format_id: i,
path,
- length: len_from_segments(&format.video.0.segments()),
- available_frames: (len_from_segments(
- &format.video.0.segments()[0..sync_segments],
- )
- .num_milliseconds() as f64
- * format.video.0.fps().unwrap()
- / 1000.0) as u64,
- idx: i,
+ locale: locale.clone(),
+ video_idx: i,
})
}
+ }
+ if self.formats.len() > 1 && self.sync_tolerance.is_some() {
let _progress_handler =
progress!("Syncing video start times (this might take some time)");
- let mut offsets = sync_videos(sync_vids, self.sync_start_value.unwrap())?;
+ let mut offsets = sync_audios(
+ &raw_audios,
+ self.sync_tolerance.unwrap(),
+ self.sync_precision.unwrap(),
+ )?;
drop(_progress_handler);
let mut offset_pre_checked = false;
@@ -307,19 +304,14 @@ impl Downloader {
.enumerate()
.map(|(i, f)| {
len_from_segments(&f.video.0.segments())
- - TimeDelta::milliseconds(
- tmp_offsets
- .get(&i)
- .map(|o| (*o as f64 / f.video.0.fps().unwrap() * 1000.0) as i64)
- .unwrap_or_default(),
- )
+ - tmp_offsets.get(&i).map(|o| *o).unwrap_or_default()
})
.collect();
let min = formats_with_offset.iter().min().unwrap();
let max = formats_with_offset.iter().max().unwrap();
if max.num_seconds() - min.num_seconds() > 15 {
- warn!("Found difference of >15 seconds after sync, skipping applying it");
+ warn!("Found difference of >15 seconds after sync, so the application was skipped");
offsets = None;
offset_pre_checked = true
}
@@ -331,7 +323,7 @@ impl Downloader {
let mut audio_count: usize = 0;
let mut subtitle_count: usize = 0;
for (i, format) in self.formats.iter().enumerate() {
- let format_fps = format.video.0.fps().unwrap();
+ let offset = offsets.get(&i).map(|o| *o).unwrap_or_default();
let format_len = format
.video
.0
@@ -339,7 +331,7 @@ impl Downloader {
.iter()
.map(|s| s.length.as_millis())
.sum::() as u64
- - offsets.get(&i).map_or(0, |o| *o);
+ - offset.num_milliseconds() as u64;
if format_len > root_format_length {
root_format_idx = i;
root_format_length = format_len;
@@ -347,23 +339,13 @@ impl Downloader {
for _ in &format.audios {
if let Some(offset) = &offsets.get(&i) {
- audio_offsets.insert(
- audio_count,
- TimeDelta::milliseconds(
- (**offset as f64 / format_fps * 1000.0) as i64,
- ),
- );
+ audio_offsets.insert(audio_count, **offset);
}
audio_count += 1
}
for _ in &format.subtitles {
if let Some(offset) = &offsets.get(&i) {
- subtitle_offsets.insert(
- subtitle_count,
- TimeDelta::milliseconds(
- (**offset as f64 / format_fps * 1000.0) as i64,
- ),
- );
+ subtitle_offsets.insert(subtitle_count, **offset);
}
subtitle_count += 1
}
@@ -390,20 +372,28 @@ impl Downloader {
root_format.subtitles.extend(subtitle_append);
self.formats = vec![root_format];
- video_offset = offsets.get(&root_format_idx).map(|o| {
- TimeDelta::milliseconds(
- (*o as f64 / self.formats[0].video.0.fps().unwrap() * 1000.0) as i64,
- )
- })
+ video_offset = offsets.get(&root_format_idx).map(|o| *o);
+ for raw_audio in raw_audios.iter_mut() {
+ raw_audio.video_idx = root_format_idx;
+ }
} else {
for format in &mut self.formats {
format.metadata.skip_events = None
}
+ if !offset_pre_checked {
+ warn!("Couldn't find reliable sync positions")
+ }
}
+ }
- if !offset_pre_checked {
- warn!("Couldn't find reliable sync positions")
- }
+ // add audio metadata
+ for raw_audio in raw_audios {
+ audios.push(FFmpegAudioMeta {
+ path: raw_audio.path,
+ locale: raw_audio.locale,
+ start_time: audio_offsets.get(&raw_audio.format_id).map(|o| *o),
+ video_idx: raw_audio.video_idx,
+ })
}
// downloads all videos
@@ -435,24 +425,6 @@ impl Downloader {
})
}
- // downloads all audios
- for (i, format) in self.formats.iter().enumerate() {
- for (j, (stream_data, locale)) in format.audios.iter().enumerate() {
- let path = self
- .download_audio(
- stream_data,
- format!("{:<1$}", format!("Downloading {} audio", locale), fmt_space),
- )
- .await?;
- audios.push(FFmpegAudioMeta {
- path,
- locale: locale.clone(),
- start_time: audio_offsets.get(&j).cloned(),
- video_idx: i,
- })
- }
- }
-
for (i, format) in self.formats.iter().enumerate() {
if format.subtitles.is_empty() {
continue;
@@ -1538,134 +1510,6 @@ async fn ffmpeg_progress(
Ok(())
}
-struct SyncVideo {
- path: TempPath,
- length: TimeDelta,
- available_frames: u64,
- idx: usize,
-}
-
-fn sync_videos(mut sync_videos: Vec, value: f64) -> Result