diff --git a/crunchy-cli-core/src/cli/archive.rs b/crunchy-cli-core/src/cli/archive.rs index 626a4f0..e8a5ca2 100644 --- a/crunchy-cli-core/src/cli/archive.rs +++ b/crunchy-cli-core/src/cli/archive.rs @@ -1,5 +1,5 @@ use crate::cli::log::tab_info; -use crate::cli::utils::{download_segments, find_resolution}; +use crate::cli::utils::{download_segments, FFmpegPreset, find_resolution}; use crate::utils::context::Context; use crate::utils::format::{format_string, Format}; use crate::utils::log::progress; @@ -8,9 +8,10 @@ use crate::utils::parse::{parse_url, UrlFilter}; use crate::utils::sort::{sort_formats_after_seasons, sort_seasons_after_number}; use crate::Execute; use anyhow::{bail, Result}; +use chrono::NaiveTime; use crunchyroll_rs::media::{Resolution, StreamSubtitle}; use crunchyroll_rs::{Locale, Media, MediaCollection, Series}; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use regex::Regex; use std::collections::BTreeMap; use std::io::Write; @@ -25,13 +26,15 @@ pub enum MergeBehavior { Video, } -fn parse_merge_behavior(s: &str) -> Result { - Ok(match s.to_lowercase().as_str() { - "auto" => MergeBehavior::Auto, - "audio" => MergeBehavior::Audio, - "video" => MergeBehavior::Video, - _ => return Err(format!("'{}' is not a valid merge behavior", s)), - }) +impl MergeBehavior { + fn parse(s: &str) -> Result { + Ok(match s.to_lowercase().as_str() { + "auto" => MergeBehavior::Auto, + "audio" => MergeBehavior::Audio, + "video" => MergeBehavior::Video, + _ => return Err(format!("'{}' is not a valid merge behavior", s)), + }) + } } #[derive(Debug, clap::Parser)] @@ -87,9 +90,19 @@ pub struct Archive { 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')" )] #[arg(short, long, default_value = "auto")] - #[arg(value_parser = parse_merge_behavior)] + #[arg(value_parser = MergeBehavior::parse)] merge: MergeBehavior, + #[arg(help = format!("Presets for video converting. Can be used multiple times. \ + Available presets: \n {}", FFmpegPreset::all().into_iter().map(|p| format!("{}: {}", p.to_string(), p.description())).collect::>().join("\n ")))] + #[arg(long_help = format!("Presets for video converting. Can be used multiple times. \ + Generally used to minify the file size with keeping (nearly) the same quality. \ + It is recommended to only use this if you archive videos with high resolutions since low resolution videos tend to result in a larger file with any of the provided presets. \ + Available presets: \n {}", FFmpegPreset::all().into_iter().map(|p| format!("{}: {}", p.to_string(), p.description())).collect::>().join("\n ")))] + #[arg(long)] + #[arg(value_parser = FFmpegPreset::parse)] + ffmpeg_preset: Vec, + #[arg( help = "Set which subtitle language should be set as default / auto shown when starting a video" )] @@ -113,9 +126,18 @@ impl Execute for Archive { fn pre_check(&self) -> Result<()> { if !has_ffmpeg() { bail!("FFmpeg is needed to run this command") - } else if PathBuf::from(&self.output).extension().unwrap_or_default().to_string_lossy() != "mkv" { + } else if PathBuf::from(&self.output) + .extension() + .unwrap_or_default() + .to_string_lossy() + != "mkv" + { bail!("File extension is not '.mkv'. Currently only matroska / '.mkv' files are supported") } + let _ = FFmpegPreset::ffmpeg_presets(self.ffmpeg_preset.clone())?; + if self.ffmpeg_preset.len() == 1 && self.ffmpeg_preset.get(0).unwrap() == &FFmpegPreset::Nvidia { + warn!("Skipping 'nvidia' hardware acceleration preset since no other codec preset was specified") + } Ok(()) } @@ -276,9 +298,13 @@ impl Execute for Archive { } } + let (primary_video, _) = video_paths.get(0).unwrap(); + let primary_video_length = get_video_length(primary_video.to_path_buf()).unwrap(); for subtitle in subtitles { - subtitle_paths - .push((download_subtitle(&self, subtitle.clone()).await?, subtitle)) + subtitle_paths.push(( + download_subtitle(&self, subtitle.clone(), primary_video_length).await?, + subtitle, + )) } generate_mkv(&self, path, video_paths, audio_paths, subtitle_paths)? @@ -387,7 +413,9 @@ async fn download_video(ctx: &Context, format: &Format, only_audio: bool) -> Res .stdout(Stdio::null()) .stderr(Stdio::piped()) .arg("-y") - .args(["-f", "mpegts", "-i", "pipe:"]) + .args(["-f", "mpegts"]) + .args(["-i", "pipe:"]) + .args(["-c", "copy"]) .args(if only_audio { vec!["-vn"] } else { vec![] }) .arg(path.to_str().unwrap()) .spawn()?; @@ -403,15 +431,20 @@ async fn download_video(ctx: &Context, format: &Format, only_audio: bool) -> Res Ok(path) } -async fn download_subtitle(archive: &Archive, subtitle: StreamSubtitle) -> Result { +async fn download_subtitle( + archive: &Archive, + subtitle: StreamSubtitle, + max_length: NaiveTime, +) -> Result { let tempfile = tempfile(".ass")?; let (mut file, path) = tempfile.into_parts(); let mut buf = vec![]; subtitle.write_to(&mut buf).await?; if !archive.no_subtitle_optimizations { - buf = fix_subtitle(buf) + buf = fix_subtitle_look_and_feel(buf) } + buf = fix_subtitle_length(buf, max_length); file.write_all(buf.as_slice())?; @@ -421,7 +454,7 @@ async fn download_subtitle(archive: &Archive, subtitle: StreamSubtitle) -> Resul /// Add `ScaledBorderAndShadows: yes` to subtitles; without it they look very messy on some video /// players. See [crunchy-labs/crunchy-cli#66](https://github.com/crunchy-labs/crunchy-cli/issues/66) /// for more information. -fn fix_subtitle(raw: Vec) -> Vec { +fn fix_subtitle_look_and_feel(raw: Vec) -> Vec { let mut script_info = false; let mut new = String::new(); @@ -439,6 +472,70 @@ fn fix_subtitle(raw: Vec) -> Vec { new.into_bytes() } +/// Fix the length of subtitles to a specified maximum amount. This is required because sometimes +/// subtitles have an unnecessary entry long after the actual video ends with artificially extends +/// the video length on some video players. To prevent this, the video length must be hard set. See +/// [crunchy-labs/crunchy-cli#32](https://github.com/crunchy-labs/crunchy-cli/issues/32) for more +/// information. +fn fix_subtitle_length(raw: Vec, max_length: NaiveTime) -> Vec { + let re = + Regex::new(r#"^Dialogue:\s\d+,(?P\d+:\d+:\d+\.\d+),(?P\d+:\d+:\d+\.\d+),"#) + .unwrap(); + + // chrono panics if we try to format NaiveTime with `%2f` and the nano seconds has more than 2 + // digits so them have to be reduced manually to avoid the panic + fn format_naive_time(native_time: NaiveTime) -> String { + let formatted_time = native_time.format("%f").to_string(); + format!( + "{}.{}", + native_time.format("%T"), + if formatted_time.len() <= 2 { + native_time.format("%2f").to_string() + } else { + formatted_time.split_at(2).0.parse().unwrap() + } + ) + } + + let length_as_string = format_naive_time(max_length); + let mut new = String::new(); + + for line in String::from_utf8_lossy(raw.as_slice()).split('\n') { + if let Some(capture) = re.captures(line) { + let start = capture.name("start").map_or(NaiveTime::default(), |s| { + NaiveTime::parse_from_str(s.as_str(), "%H:%M:%S.%f").unwrap() + }); + let end = capture.name("end").map_or(NaiveTime::default(), |s| { + NaiveTime::parse_from_str(s.as_str(), "%H:%M:%S.%f").unwrap() + }); + + if start > max_length { + continue; + } else if end > max_length { + new.push_str( + re.replace( + line, + format!( + "Dialogue: {},{},", + format_naive_time(start), + &length_as_string + ), + ) + .to_string() + .as_str(), + ) + } else { + new.push_str(line) + } + } else { + new.push_str(line) + } + new.push('\n') + } + + new.into_bytes() +} + fn generate_mkv( archive: &Archive, target: PathBuf, @@ -450,8 +547,6 @@ fn generate_mkv( let mut maps = vec![]; let mut metadata = vec![]; - let mut video_length = (0, 0, 0, 0); - for (i, (video_path, format)) in video_paths.iter().enumerate() { input.extend(["-i".to_string(), video_path.to_string_lossy().to_string()]); maps.extend(["-map".to_string(), i.to_string()]); @@ -471,11 +566,6 @@ fn generate_mkv( format!("-metadata:s:a:{}", i), format!("title={}", format.audio.to_human_readable()), ]); - - let vid_len = get_video_length(video_path.to_path_buf())?; - if vid_len > video_length { - video_length = vid_len - } } for (i, (audio_path, format)) in audio_paths.iter().enumerate() { input.extend(["-i".to_string(), audio_path.to_string_lossy().to_string()]); @@ -508,7 +598,11 @@ fn generate_mkv( ]); } + let (input_presets, output_presets) = + FFmpegPreset::ffmpeg_presets(archive.ffmpeg_preset.clone())?; + let mut command_args = vec!["-y".to_string()]; + command_args.extend(input_presets); command_args.extend(input); command_args.extend(maps); command_args.extend(metadata); @@ -528,9 +622,8 @@ fn generate_mkv( command_args.extend(["-disposition:s:0".to_string(), "0".to_string()]) } + command_args.extend(output_presets); command_args.extend([ - "-c".to_string(), - "copy".to_string(), "-f".to_string(), "matroska".to_string(), target.to_string_lossy().to_string(), @@ -552,11 +645,11 @@ fn generate_mkv( /// Get the length of a video. This is required because sometimes subtitles have an unnecessary entry /// long after the actual video ends with artificially extends the video length on some video players. -/// To prevent this, the video length must be hard set with ffmpeg. See +/// To prevent this, the video length must be hard set. See /// [crunchy-labs/crunchy-cli#32](https://github.com/crunchy-labs/crunchy-cli/issues/32) for more /// information. -fn get_video_length(path: PathBuf) -> Result<(u32, u32, u32, u32)> { - let video_length = Regex::new(r"Duration:\s?(\d+):(\d+):(\d+).(\d+),")?; +fn get_video_length(path: PathBuf) -> Result { + let video_length = Regex::new(r"Duration:\s(?P