From 1a511e12f95e7e4e76d97a4624306387909c6590 Mon Sep 17 00:00:00 2001 From: bytedream Date: Mon, 8 Apr 2024 13:57:06 +0200 Subject: [PATCH] Add archive start sync flag --- Cargo.lock | 122 +++++ crunchy-cli-core/Cargo.toml | 2 + crunchy-cli-core/src/archive/command.rs | 35 +- crunchy-cli-core/src/download/command.rs | 4 +- crunchy-cli-core/src/lib.rs | 33 +- crunchy-cli-core/src/utils/download.rs | 653 +++++++++++++++++------ crunchy-cli-core/src/utils/os.rs | 20 +- crunchy-cli-core/src/utils/video.rs | 2 +- 8 files changed, 692 insertions(+), 179 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d0b7e1..c1f24f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,18 @@ version = "3.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" +[[package]] +name = "bytemuck" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d68c57235a3a081186990eca2867354726650f42f7516ca50c28d6281fd15" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.6.0" @@ -369,6 +381,8 @@ dependencies = [ "fs2", "futures-util", "http", + "image", + "image_hasher", "indicatif", "lazy_static", "log", @@ -936,6 +950,32 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11" +dependencies = [ + "bytemuck", + "byteorder", + "num-traits", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image_hasher" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481465fe767d92494987319b0b447a5829edf57f09c52bf8639396abaaeaf78" +dependencies = [ + "base64 0.22.0", + "image", + "rustdct", + "serde", + "transpose", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1143,12 +1183,30 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-complex" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23c6602fda94a57c990fe0df199a035d83576b496aa29f4e634a8ac6004e68a6" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.18" @@ -1305,6 +1363,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "primal-check" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df7f93fd637f083201473dab4fee2db4c429d32e55e3299980ab3957ab916a0" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro2" version = "1.0.79" @@ -1469,6 +1536,30 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustdct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b61555105d6a9bf98797c063c362a1d24ed8ab0431655e38f1cf51e52089551" +dependencies = [ + "rustfft", +] + +[[package]] +name = "rustfft" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43806561bc506d0c5d160643ad742e3161049ac01027b5e6d7524091fd401d86" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", + "version_check", +] + [[package]] name = "rustix" version = "0.38.32" @@ -1720,6 +1811,12 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strsim" version = "0.10.0" @@ -1998,6 +2095,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2377,3 +2484,18 @@ name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec866b44a2a1fd6133d363f073ca1b179f438f99e7e5bfb1e33f7181facfe448" +dependencies = [ + "zune-core", +] diff --git a/crunchy-cli-core/Cargo.toml b/crunchy-cli-core/Cargo.toml index 07973e1..bd47aba 100644 --- a/crunchy-cli-core/Cargo.toml +++ b/crunchy-cli-core/Cargo.toml @@ -24,6 +24,8 @@ derive_setters = "0.1" futures-util = { version = "0.3", features = ["io"] } fs2 = "0.4" http = "1.1" +image = { version = "0.25", features = ["jpeg"], default-features = false } +image_hasher = "2.0" indicatif = "0.17" lazy_static = "1.4" log = { version = "0.4", features = ["std"] } diff --git a/crunchy-cli-core/src/archive/command.rs b/crunchy-cli-core/src/archive/command.rs index 0dc0b86..64ad66a 100644 --- a/crunchy-cli-core/src/archive/command.rs +++ b/crunchy-cli-core/src/archive/command.rs @@ -10,7 +10,7 @@ use crate::utils::locale::{all_locale_in_locales, resolve_locales, LanguageTaggi use crate::utils::log::progress; use crate::utils::os::{free_file, has_ffmpeg, is_special_file}; use crate::utils::parse::parse_url; -use crate::utils::video::variant_data_from_stream; +use crate::utils::video::stream_data_from_stream; use crate::Execute; use anyhow::bail; use anyhow::Result; @@ -89,6 +89,17 @@ pub struct Archive { #[arg(value_parser = crate::utils::clap::clap_parse_resolution)] pub(crate) resolution: Resolution, + #[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 + " + )] + #[arg(long, require_equals = true, num_args = 0..=1, default_missing_value = "7.5")] + pub(crate) sync_start: Option, #[arg( help = "Sets the behavior of the stream merging. Valid behaviors are 'auto', 'audio' and 'video'" )] @@ -216,8 +227,19 @@ impl Execute for Archive { } } - if self.include_chapters && !matches!(self.merge, MergeBehavior::Audio) { - bail!("`--include-chapters` can only be used if `--merge` is set to 'audio'") + if self.include_chapters + && !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") } if self.output.contains("{resolution}") @@ -294,6 +316,7 @@ 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) .threads(self.threads) .audio_locale_output_map( zip(self.audio.clone(), self.output_audio_locales.clone()).collect(), @@ -450,7 +473,7 @@ async fn get_format( for single_format in single_formats { let stream = single_format.stream().await?; let Some((video, audio, _)) = - variant_data_from_stream(&stream, &archive.resolution, None).await? + stream_data_from_stream(&stream, &archive.resolution, None).await? else { if single_format.is_episode() { bail!( @@ -569,7 +592,9 @@ async fn get_format( video: (video, single_format.audio.clone()), audios: vec![(audio, single_format.audio.clone())], subtitles, - metadata: DownloadFormatMetadata { skip_events: None }, + metadata: DownloadFormatMetadata { + skip_events: single_format.skip_events().await?, + }, }, )); } diff --git a/crunchy-cli-core/src/download/command.rs b/crunchy-cli-core/src/download/command.rs index 47b29c9..fd29030 100644 --- a/crunchy-cli-core/src/download/command.rs +++ b/crunchy-cli-core/src/download/command.rs @@ -8,7 +8,7 @@ use crate::utils::locale::{resolve_locales, LanguageTagging}; use crate::utils::log::progress; use crate::utils::os::{free_file, has_ffmpeg, is_special_file}; use crate::utils::parse::parse_url; -use crate::utils::video::variant_data_from_stream; +use crate::utils::video::stream_data_from_stream; use crate::Execute; use anyhow::bail; use anyhow::Result; @@ -351,7 +351,7 @@ async fn get_format( try_peer_hardsubs: bool, ) -> Result<(DownloadFormat, Format)> { let stream = single_format.stream().await?; - let Some((video, audio, contains_hardsub)) = variant_data_from_stream( + let Some((video, audio, contains_hardsub)) = stream_data_from_stream( &stream, &download.resolution, if try_peer_hardsubs { diff --git a/crunchy-cli-core/src/lib.rs b/crunchy-cli-core/src/lib.rs index 483cb63..d8d5ec5 100644 --- a/crunchy-cli-core/src/lib.rs +++ b/crunchy-cli-core/src/lib.rs @@ -184,16 +184,29 @@ pub async fn main(args: &[String]) { .unwrap_or_default() .starts_with(".crunchy-cli_") { - let result = fs::remove_file(file.path()); - debug!( - "Ctrl-c removed temporary file {} {}", - file.path().to_string_lossy(), - if result.is_ok() { - "successfully" - } else { - "not successfully" - } - ) + if file.file_type().map_or(true, |ft| ft.is_file()) { + let result = fs::remove_file(file.path()); + debug!( + "Ctrl-c removed temporary file {} {}", + file.path().to_string_lossy(), + if result.is_ok() { + "successfully" + } else { + "not successfully" + } + ) + } else { + let result = fs::remove_dir_all(file.path()); + debug!( + "Ctrl-c removed temporary directory {} {}", + file.path().to_string_lossy(), + if result.is_ok() { + "successfully" + } else { + "not successfully" + } + ) + } } } } diff --git a/crunchy-cli-core/src/utils/download.rs b/crunchy-cli-core/src/utils/download.rs index cee89e2..fe0e84a 100644 --- a/crunchy-cli-core/src/utils/download.rs +++ b/crunchy-cli-core/src/utils/download.rs @@ -1,11 +1,15 @@ use crate::utils::ffmpeg::FFmpegPreset; use crate::utils::filter::real_dedup_vec; -use crate::utils::os::{cache_dir, is_special_file, temp_directory, temp_named_pipe, tempfile}; +use crate::utils::log::progress; +use crate::utils::os::{ + cache_dir, is_special_file, temp_directory, temp_named_pipe, tempdir, tempfile, +}; use crate::utils::rate_limit::RateLimiterService; use anyhow::{bail, Result}; -use chrono::NaiveTime; +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; @@ -59,6 +63,7 @@ pub struct DownloadBuilder { force_hardsub: bool, download_fonts: bool, no_closed_caption: bool, + sync_start_value: Option, threads: usize, ffmpeg_threads: Option, audio_locale_output_map: HashMap, @@ -78,6 +83,7 @@ impl DownloadBuilder { force_hardsub: false, download_fonts: false, no_closed_caption: false, + sync_start_value: None, threads: num_cpus::get(), ffmpeg_threads: None, audio_locale_output_map: HashMap::new(), @@ -99,6 +105,8 @@ impl DownloadBuilder { download_fonts: self.download_fonts, no_closed_caption: self.no_closed_caption, + sync_start_value: self.sync_start_value, + download_threads: self.threads, ffmpeg_threads: self.ffmpeg_threads, @@ -110,10 +118,23 @@ impl DownloadBuilder { } } -struct FFmpegMeta { +struct FFmpegVideoMeta { path: TempPath, - language: Locale, - title: String, + length: TimeDelta, + start_time: Option, +} + +struct FFmpegAudioMeta { + path: TempPath, + locale: Locale, + start_time: Option, +} + +struct FFmpegSubtitleMeta { + path: TempPath, + locale: Locale, + cc: bool, + start_time: Option, } pub struct DownloadFormat { @@ -141,6 +162,8 @@ pub struct Downloader { download_fonts: bool, no_closed_caption: bool, + sync_start_value: Option, + download_threads: usize, ffmpeg_threads: Option, @@ -216,13 +239,16 @@ impl Downloader { } } + let mut video_offset = None; + let mut audio_offsets = HashMap::new(); + let mut subtitle_offsets = HashMap::new(); let mut videos = vec![]; let mut audios = vec![]; let mut subtitles = vec![]; let mut fonts = vec![]; let mut chapters = None; - let mut max_len = NaiveTime::MIN; - let mut max_frames = 0f64; + let mut max_len = TimeDelta::min_value(); + let mut max_frames = 0; let fmt_space = self .formats .iter() @@ -234,115 +260,252 @@ impl Downloader { .max() .unwrap(); - for (i, format) in self.formats.iter().enumerate() { - let video_path = self - .download_video( - &format.video.0, - format!("{:<1$}", format!("Downloading video #{}", i + 1), fmt_space), - ) - .await?; - for (variant_data, locale) in format.audios.iter() { - let audio_path = self - .download_audio( - variant_data, - format!("{:<1$}", format!("Downloading {} audio", locale), fmt_space), + 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() { + let path = self + .download_video( + &format.video.0, + format!("Downloading video #{} sync segments", i + 1), + Some(sync_segments), ) .await?; - audios.push(FFmpegMeta { - path: audio_path, - language: locale.clone(), - title: if i == 0 { - locale.to_human_readable() - } else { - format!("{} [Video: #{}]", locale.to_human_readable(), i + 1) - }, + sync_vids.push(SyncVideo { + 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, }) } - let (len, fps) = get_video_stats(&video_path)?; + 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())?; + drop(_progress_handler); + + let mut offset_pre_checked = false; + if let Some(tmp_offsets) = &offsets { + let formats_with_offset: Vec = self + .formats + .iter() + .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(), + ) + }) + .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"); + offsets = None; + offset_pre_checked = true + } + } + + if let Some(offsets) = offsets { + let mut root_format_idx = 0; + let mut root_format_length = 0; + 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 format_len = format + .video + .0 + .segments() + .iter() + .map(|s| s.length.as_millis()) + .sum::() as u64 + - offsets.get(&i).map_or(0, |o| *o); + if format_len > root_format_length { + root_format_idx = i; + root_format_length = format_len; + } + + 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_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_count += 1 + } + } + + let mut root_format = self.formats.remove(root_format_idx); + + let mut audio_prepend = vec![]; + let mut subtitle_prepend = vec![]; + let mut audio_append = vec![]; + let mut subtitle_append = vec![]; + for (i, format) in self.formats.into_iter().enumerate() { + if i < root_format_idx { + audio_prepend.extend(format.audios); + subtitle_prepend.extend(format.subtitles); + } else { + audio_append.extend(format.audios); + subtitle_append.extend(format.subtitles); + } + } + root_format.audios.splice(0..0, audio_prepend); + root_format.subtitles.splice(0..0, subtitle_prepend); + root_format.audios.extend(audio_append); + 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, + ) + }) + } else if !offset_pre_checked { + warn!("Couldn't find reliable sync positions") + } + } + + // downloads all videos + for (i, format) in self.formats.iter().enumerate() { + let path = self + .download_video( + &format.video.0, + format!("{:<1$}", format!("Downloading video #{}", i + 1), fmt_space), + None, + ) + .await?; + + let (len, fps) = get_video_stats(&path)?; if max_len < len { max_len = len } - let frames = len.signed_duration_since(NaiveTime::MIN).num_seconds() as f64 * fps; - if frames > max_frames { - max_frames = frames; + let frames = ((len.num_milliseconds() as f64 + - video_offset.unwrap_or_default().num_milliseconds() as f64) + / 1000.0 + * fps) as u64; + if max_frames < frames { + max_frames = frames } - if !format.subtitles.is_empty() { - let progress_spinner = if log::max_level() == LevelFilter::Info { - let progress_spinner = ProgressBar::new_spinner() - .with_style( - ProgressStyle::with_template( - format!( - ":: {:<1$} {{msg}} {{spinner}}", - "Downloading subtitles", fmt_space - ) - .as_str(), + videos.push(FFmpegVideoMeta { + path, + length: len, + start_time: video_offset, + }) + } + + // downloads all audios + for format in &self.formats { + 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(), + }) + } + } + + for (i, format) in self.formats.iter().enumerate() { + if format.subtitles.is_empty() { + continue; + } + + let progress_spinner = if log::max_level() == LevelFilter::Info { + let progress_spinner = ProgressBar::new_spinner() + .with_style( + ProgressStyle::with_template( + format!( + ":: {:<1$} {{msg}} {{spinner}}", + "Downloading subtitles", fmt_space ) - .unwrap() - .tick_strings(&["—", "\\", "|", "/", ""]), + .as_str(), ) - .with_finish(ProgressFinish::Abandon); - progress_spinner.enable_steady_tick(Duration::from_millis(100)); - Some(progress_spinner) - } else { - None - }; + .unwrap() + .tick_strings(&["—", "\\", "|", "/", ""]), + ) + .with_finish(ProgressFinish::Abandon); + progress_spinner.enable_steady_tick(Duration::from_millis(100)); + Some(progress_spinner) + } else { + None + }; - for (subtitle, not_cc) in format.subtitles.iter() { - if !not_cc && self.no_closed_caption { - continue; - } - - if let Some(pb) = &progress_spinner { - let mut progress_message = pb.message(); - if !progress_message.is_empty() { - progress_message += ", " - } - progress_message += &subtitle.locale.to_string(); - if !not_cc { - progress_message += " (CC)"; - } - if i != 0 { - progress_message += &format!(" [Video: #{}]", i + 1); - } - pb.set_message(progress_message) - } - - let mut subtitle_title = subtitle.locale.to_human_readable(); - if !not_cc { - subtitle_title += " (CC)" - } - if i != 0 { - subtitle_title += &format!(" [Video: #{}]", i + 1) - } - - let subtitle_path = self.download_subtitle(subtitle.clone(), len).await?; - debug!( - "Downloaded {} subtitles{}{}", - subtitle.locale, - (!not_cc).then_some(" (cc)").unwrap_or_default(), - (i != 0) - .then_some(format!(" for video {}", i)) - .unwrap_or_default() - ); - subtitles.push(FFmpegMeta { - path: subtitle_path, - language: subtitle.locale.clone(), - title: subtitle_title, - }) + for (j, (subtitle, not_cc)) in format.subtitles.iter().enumerate() { + if !not_cc && self.no_closed_caption { + continue; } - } - videos.push(FFmpegMeta { - path: video_path, - language: format.video.1.clone(), - title: if self.formats.len() == 1 { - "Default".to_string() - } else { - format!("#{}", i + 1) - }, - }); + if let Some(pb) = &progress_spinner { + let mut progress_message = pb.message(); + if !progress_message.is_empty() { + progress_message += ", " + } + progress_message += &subtitle.locale.to_string(); + if !not_cc { + progress_message += " (CC)"; + } + if i.min(videos.len() - 1) != 0 { + progress_message += &format!(" [Video: #{}]", i + 1); + } + pb.set_message(progress_message) + } + + let path = self + .download_subtitle(subtitle.clone(), videos[i.min(videos.len() - 1)].length) + .await?; + debug!( + "Downloaded {} subtitles{}", + subtitle.locale, + (!not_cc).then_some(" (cc)").unwrap_or_default(), + ); + subtitles.push(FFmpegSubtitleMeta { + path, + locale: subtitle.locale.clone(), + cc: !not_cc, + start_time: subtitle_offsets.get(&j).cloned(), + }) + } + } + + for format in self.formats.iter() { if let Some(skip_events) = &format.metadata.skip_events { let (file, path) = tempfile(".chapter")?.into_parts(); chapters = Some(( @@ -421,17 +584,30 @@ impl Downloader { let mut metadata = vec![]; for (i, meta) in videos.iter().enumerate() { + if let Some(start_time) = meta.start_time { + input.extend(["-ss".to_string(), format_time_delta(start_time)]) + } input.extend(["-i".to_string(), meta.path.to_string_lossy().to_string()]); maps.extend(["-map".to_string(), i.to_string()]); metadata.extend([ format!("-metadata:s:v:{}", i), - format!("title={}", meta.title), + format!( + "title={}", + if videos.len() == 1 { + "Default".to_string() + } else { + format!("#{}", i + 1) + } + ), ]); // the empty language metadata is created to avoid that metadata from the original track // is copied metadata.extend([format!("-metadata:s:v:{}", i), "language=".to_string()]) } for (i, meta) in audios.iter().enumerate() { + if let Some(start_time) = meta.start_time { + input.extend(["-ss".to_string(), format_time_delta(start_time)]) + } input.extend(["-i".to_string(), meta.path.to_string_lossy().to_string()]); maps.extend(["-map".to_string(), (i + videos.len()).to_string()]); metadata.extend([ @@ -439,13 +615,20 @@ impl Downloader { format!( "language={}", self.audio_locale_output_map - .get(&meta.language) - .unwrap_or(&meta.language.to_string()) + .get(&meta.locale) + .unwrap_or(&meta.locale.to_string()) ), ]); metadata.extend([ format!("-metadata:s:a:{}", i), - format!("title={}", meta.title), + format!( + "title={}", + if videos.len() == 1 { + meta.locale.to_human_readable() + } else { + format!("{} [Video: #{}]", meta.locale.to_human_readable(), i + 1,) + } + ), ]); } @@ -465,6 +648,9 @@ impl Downloader { if container_supports_softsubs { for (i, meta) in subtitles.iter().enumerate() { + if let Some(start_time) = meta.start_time { + input.extend(["-ss".to_string(), format_time_delta(start_time)]) + } input.extend(["-i".to_string(), meta.path.to_string_lossy().to_string()]); maps.extend([ "-map".to_string(), @@ -475,13 +661,22 @@ impl Downloader { format!( "language={}", self.subtitle_locale_output_map - .get(&meta.language) - .unwrap_or(&meta.language.to_string()) + .get(&meta.locale) + .unwrap_or(&meta.locale.to_string()) ), ]); metadata.extend([ format!("-metadata:s:s:{}", i), - format!("title={}", meta.title), + format!("title={}", { + let mut title = meta.locale.to_string(); + if meta.cc { + title += " (CC)" + } + if videos.len() > 1 { + title += &format!(" [Video: #{}]", i + 1) + } + title + }), ]); } } @@ -523,10 +718,7 @@ impl Downloader { // set default subtitle if let Some(default_subtitle) = self.default_subtitle { - if let Some(position) = subtitles - .iter() - .position(|m| m.language == default_subtitle) - { + if let Some(position) = subtitles.iter().position(|m| m.locale == default_subtitle) { if container_supports_softsubs { match dst.extension().unwrap_or_default().to_str().unwrap() { "mov" | "mp4" => output_presets.extend([ @@ -585,7 +777,7 @@ impl Downloader { if container_supports_softsubs { if let Some(position) = subtitles .iter() - .position(|meta| meta.language == default_subtitle) + .position(|meta| meta.locale == default_subtitle) { command_args.extend([ format!("-disposition:s:s:{}", position), @@ -597,9 +789,7 @@ impl Downloader { // set the 'forced' flag to CC subtitles for (i, subtitle) in subtitles.iter().enumerate() { - // well, checking if the title contains '(CC)' might not be the best solutions from a - // performance perspective but easier than adjusting the `FFmpegMeta` struct - if !subtitle.title.contains("(CC)") { + if !subtitle.cc { continue; } @@ -632,7 +822,7 @@ impl Downloader { // create parent directory if it does not exist if let Some(parent) = dst.parent() { if !parent.exists() { - std::fs::create_dir_all(parent)? + fs::create_dir_all(parent)? } } @@ -650,7 +840,7 @@ impl Downloader { let ffmpeg_progress_cancellation_token = ffmpeg_progress_cancel.clone(); let ffmpeg_progress = tokio::spawn(async move { ffmpeg_progress( - max_frames as u64, + max_frames, fifo, format!("{:<1$}", "Generating output file", fmt_space + 1), ffmpeg_progress_cancellation_token, @@ -681,7 +871,7 @@ impl Downloader { let segments = stream_data.segments(); // sum the length of all streams up - estimated_required_space += estimate_variant_file_size(stream_data, &segments); + estimated_required_space += estimate_stream_data_file_size(stream_data, &segments); } let tmp_stat = fs2::statvfs(temp_directory()).unwrap(); @@ -727,11 +917,16 @@ impl Downloader { Ok((tmp_required, dst_required)) } - async fn download_video(&self, stream_data: &StreamData, message: String) -> Result { + async fn download_video( + &self, + stream_data: &StreamData, + message: String, + max_segments: Option, + ) -> Result { let tempfile = tempfile(".mp4")?; let (mut file, path) = tempfile.into_parts(); - self.download_segments(&mut file, message, stream_data) + self.download_segments(&mut file, message, stream_data, max_segments) .await?; Ok(path) @@ -741,7 +936,7 @@ impl Downloader { let tempfile = tempfile(".m4a")?; let (mut file, path) = tempfile.into_parts(); - self.download_segments(&mut file, message, stream_data) + self.download_segments(&mut file, message, stream_data, None) .await?; Ok(path) @@ -750,7 +945,7 @@ impl Downloader { async fn download_subtitle( &self, subtitle: Subtitle, - max_length: NaiveTime, + max_length: TimeDelta, ) -> Result { let tempfile = tempfile(".ass")?; let (mut file, path) = tempfile.into_parts(); @@ -796,14 +991,20 @@ impl Downloader { writer: &mut impl Write, message: String, stream_data: &StreamData, + max_segments: Option, ) -> Result<()> { - let segments = stream_data.segments(); + let mut segments = stream_data.segments(); + if let Some(max_segments) = max_segments { + segments = segments + .drain(0..max_segments.min(segments.len() - 1)) + .collect(); + } let total_segments = segments.len(); let count = Arc::new(Mutex::new(0)); let progress = if log::max_level() == LevelFilter::Info { - let estimated_file_size = estimate_variant_file_size(stream_data, &segments); + let estimated_file_size = estimate_stream_data_file_size(stream_data, &segments); let progress = ProgressBar::new(estimated_file_size) .with_style( @@ -820,7 +1021,7 @@ impl Downloader { None }; - let cpus = self.download_threads; + let cpus = self.download_threads.min(segments.len()); let mut segs: Vec> = Vec::with_capacity(cpus); for _ in 0..cpus { segs.push(vec![]) @@ -964,12 +1165,12 @@ impl Downloader { } } -fn estimate_variant_file_size(stream_data: &StreamData, segments: &[StreamSegment]) -> u64 { +fn estimate_stream_data_file_size(stream_data: &StreamData, segments: &[StreamSegment]) -> u64 { (stream_data.bandwidth / 8) * segments.iter().map(|s| s.length.as_secs()).sum::() } /// Get the length and fps of a video. -fn get_video_stats(path: &Path) -> Result<(NaiveTime, f64)> { +fn get_video_stats(path: &Path) -> Result<(TimeDelta, f64)> { let video_length = Regex::new(r"Duration:\s(?P