diff --git a/Cargo.lock b/Cargo.lock index 42fe778..ae46f99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -315,6 +315,7 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", + "shlex", "signal-hook", "sys-locale", "tempfile", @@ -1476,6 +1477,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shlex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" + [[package]] name = "signal-hook" version = "0.3.14" diff --git a/crunchy-cli-core/Cargo.toml b/crunchy-cli-core/Cargo.toml index 8eea6a8..e56cf71 100644 --- a/crunchy-cli-core/Cargo.toml +++ b/crunchy-cli-core/Cargo.toml @@ -21,6 +21,7 @@ regex = "1.7" sanitize-filename = "0.4" serde = "1.0" serde_json = "1.0" +shlex = "1.1" signal-hook = "0.3" tempfile = "3.3" terminal_size = "0.2" diff --git a/crunchy-cli-core/src/cli/archive.rs b/crunchy-cli-core/src/cli/archive.rs index e0fc9b0..69f999a 100644 --- a/crunchy-cli-core/src/cli/archive.rs +++ b/crunchy-cli-core/src/cli/archive.rs @@ -15,7 +15,7 @@ use crate::Execute; use anyhow::{bail, Result}; use crunchyroll_rs::media::Resolution; use crunchyroll_rs::{Locale, Media, MediaCollection, Series}; -use log::{debug, error, info, warn}; +use log::{debug, error, info}; use std::collections::BTreeMap; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -97,14 +97,14 @@ pub struct Archive { 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 ")))] + Available presets: \n {}", FFmpegPreset::available_matches_human_readable().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 ")))] + Available presets: \n {}", FFmpegPreset::available_matches_human_readable().join("\n ")))] #[arg(long)] #[arg(value_parser = FFmpegPreset::parse)] - ffmpeg_preset: Vec, + ffmpeg_preset: Option, #[arg( help = "Set which subtitle language should be set as default / auto shown when starting a video" @@ -138,12 +138,6 @@ impl Execute for Archive { { 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") - } self.locale = all_locale_in_locales(self.locale.clone()); self.subtitle = all_locale_in_locales(self.subtitle.clone()); @@ -360,7 +354,11 @@ async fn formats_from_series( "Season {} of series {} is not available with {} audio", season.first().unwrap().metadata.season_number, series.title, - not_present_audio.into_iter().map(|l| l.to_string()).collect::>().join(", ") + not_present_audio + .into_iter() + .map(|l| l.to_string()) + .collect::>() + .join(", ") ) } @@ -545,8 +543,19 @@ fn generate_mkv( } } - let (input_presets, output_presets) = - FFmpegPreset::ffmpeg_presets(archive.ffmpeg_preset.clone())?; + let (input_presets, output_presets) = if let Some(preset) = archive.ffmpeg_preset.clone() { + preset.to_input_output_args() + } else { + ( + vec![], + vec![ + "-c:v".to_string(), + "copy".to_string(), + "-c:a".to_string(), + "copy".to_string(), + ], + ) + }; let mut command_args = vec!["-y".to_string()]; command_args.extend(input_presets); diff --git a/crunchy-cli-core/src/cli/download.rs b/crunchy-cli-core/src/cli/download.rs index c8e244c..b4c037c 100644 --- a/crunchy-cli-core/src/cli/download.rs +++ b/crunchy-cli-core/src/cli/download.rs @@ -1,7 +1,7 @@ use crate::cli::log::tab_info; use crate::cli::utils::{ - download_segments, find_multiple_seasons_with_same_number, - find_resolution, interactive_season_choosing, FFmpegPreset, + download_segments, find_multiple_seasons_with_same_number, find_resolution, + interactive_season_choosing, FFmpegPreset, }; use crate::utils::context::Context; use crate::utils::format::Format; @@ -66,14 +66,14 @@ pub struct Download { resolution: Resolution, #[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 ")))] + Available presets: \n {}", FFmpegPreset::available_matches_human_readable().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 download 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 ")))] + Available presets: \n {}", FFmpegPreset::available_matches_human_readable().join("\n ")))] #[arg(long)] #[arg(value_parser = FFmpegPreset::parse)] - ffmpeg_preset: Vec, + ffmpeg_preset: Option, #[arg(help = "Skip files which are already existing")] #[arg(long, default_value_t = false)] @@ -109,13 +109,6 @@ impl Execute for Download { } } - 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(()) } @@ -277,8 +270,19 @@ async fn download_ffmpeg( subtitle: Option, mut target: PathBuf, ) -> Result<()> { - let (input_presets, mut output_presets) = - FFmpegPreset::ffmpeg_presets(download.ffmpeg_preset.clone())?; + let (input_presets, mut output_presets) = if let Some(preset) = download.ffmpeg_preset.clone() { + preset.to_input_output_args() + } else { + ( + vec![], + vec![ + "-c:v".to_string(), + "copy".to_string(), + "-c:a".to_string(), + "copy".to_string(), + ], + ) + }; // create parent directory if it does not exist if let Some(parent) = target.parent() { diff --git a/crunchy-cli-core/src/cli/utils.rs b/crunchy-cli-core/src/cli/utils.rs index 7352d95..44cd63c 100644 --- a/crunchy-cli-core/src/cli/utils.rs +++ b/crunchy-cli-core/src/cli/utils.rs @@ -8,7 +8,9 @@ use log::{debug, LevelFilter}; use regex::Regex; use std::borrow::{Borrow, BorrowMut}; use std::collections::BTreeMap; +use std::env; use std::io::{BufRead, Write}; +use std::str::FromStr; use std::sync::{mpsc, Arc, Mutex}; use std::time::Duration; use tokio::task::JoinSet; @@ -210,118 +212,335 @@ pub async fn download_segments( #[derive(Clone, Debug, Eq, PartialEq)] pub enum FFmpegPreset { - Nvidia, - - Av1, - H265, - H264, + Predefined(FFmpegCodec, Option, FFmpegQuality), + Custom(Option, Option), } -impl ToString for FFmpegPreset { - fn to_string(&self) -> String { - match self { - &FFmpegPreset::Nvidia => "nvidia", - &FFmpegPreset::Av1 => "av1", - &FFmpegPreset::H265 => "h265", - &FFmpegPreset::H264 => "h264", +lazy_static! { + static ref PREDEFINED_PRESET: Regex = Regex::new(r"^\w+(-\w+)*?$").unwrap(); +} + +macro_rules! FFmpegEnum { + (enum $name:ident { $($field:ident),* }) => { + #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] + pub enum $name { + $( + $field + ),*, } - .to_string() + + impl $name { + fn all() -> Vec<$name> { + vec![ + $( + $name::$field + ),*, + ] + } + } + + impl ToString for $name { + fn to_string(&self) -> String { + match self { + $( + &$name::$field => stringify!($field).to_string().to_lowercase() + ),* + } + } + } + + impl FromStr for $name { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::result::Result { + match s { + $( + stringify!($field) => Ok($name::$field) + ),*, + _ => bail!("{} is not a valid {}", s, stringify!($name).to_lowercase()) + } + } + } + } +} + +FFmpegEnum! { + enum FFmpegCodec { + H264, + H265, + Av1 + } +} + +FFmpegEnum! { + enum FFmpegHwAccel { + Nvidia + } +} + +FFmpegEnum! { + enum FFmpegQuality { + Lossless, + Normal, + Low } } impl FFmpegPreset { - pub(crate) fn all() -> Vec { - vec![ - FFmpegPreset::Nvidia, - FFmpegPreset::Av1, - FFmpegPreset::H265, - FFmpegPreset::H264, - ] + pub(crate) fn available_matches( + ) -> Vec<(FFmpegCodec, Option, Option)> { + let codecs = vec![ + ( + FFmpegCodec::H264, + FFmpegHwAccel::all(), + FFmpegQuality::all(), + ), + ( + FFmpegCodec::H265, + FFmpegHwAccel::all(), + FFmpegQuality::all(), + ), + (FFmpegCodec::Av1, vec![], FFmpegQuality::all()), + ]; + + let mut return_values = vec![]; + + for (codec, hwaccels, qualities) in codecs { + return_values.push((codec.clone(), None, None)); + for hwaccel in hwaccels.clone() { + return_values.push((codec.clone(), Some(hwaccel), None)); + } + for quality in qualities.clone() { + return_values.push((codec.clone(), None, Some(quality))) + } + for hwaccel in hwaccels { + for quality in qualities.clone() { + return_values.push((codec.clone(), Some(hwaccel.clone()), Some(quality))) + } + } + } + + return_values } - pub(crate) fn description(self) -> String { - match self { - FFmpegPreset::Nvidia => "If you're have a nvidia card, use hardware / gpu accelerated video processing if available", - FFmpegPreset::Av1 => "Encode the video(s) with the av1 codec. Hardware acceleration is currently not possible with this", - FFmpegPreset::H265 => "Encode the video(s) with the h265 codec", - FFmpegPreset::H264 => "Encode the video(s) with the h264 codec" - }.to_string() + pub(crate) fn available_matches_human_readable() -> Vec { + let mut return_values = vec![]; + + for (codec, hwaccel, quality) in FFmpegPreset::available_matches() { + let mut description_details = vec![]; + if let Some(h) = &hwaccel { + description_details.push(format!("{} hardware acceleration", h.to_string())) + } + if let Some(q) = &quality { + description_details.push(format!("{} video quality/compression", q.to_string())) + } + + let description = if description_details.len() == 0 { + format!( + "{} encoded with default video quality/compression", + codec.to_string() + ) + } else if description_details.len() == 1 { + format!( + "{} encoded with {}", + codec.to_string(), + description_details[0] + ) + } else { + let first = description_details.remove(0); + let last = description_details.remove(description_details.len() - 1); + let mid = if !description_details.is_empty() { + format!(", {} ", description_details.join(", ")) + } else { + "".to_string() + }; + + format!( + "{} encoded with {}{} and {}", + codec.to_string(), + first, + mid, + last + ) + }; + + return_values.push(format!( + "{} ({})", + vec![ + Some(codec.to_string()), + hwaccel.map(|h| h.to_string()), + quality.map(|q| q.to_string()) + ] + .into_iter() + .flatten() + .collect::>() + .join("-"), + description + )) + } + return_values } pub(crate) fn parse(s: &str) -> Result { - Ok(match s.to_lowercase().as_str() { - "nvidia" => FFmpegPreset::Nvidia, - "av1" => FFmpegPreset::Av1, - "h265" | "h.265" | "hevc" => FFmpegPreset::H265, - "h264" | "h.264" => FFmpegPreset::H264, - _ => return Err(format!("'{}' is not a valid ffmpeg preset", s)), - }) + let env_ffmpeg_input_args = env::var("FFMPEG_INPUT_ARGS").ok(); + let env_ffmpeg_output_args = env::var("FFMPEG_OUTPUT_ARGS").ok(); + + if env_ffmpeg_input_args.is_some() || env_ffmpeg_output_args.is_some() { + if let Some(input) = &env_ffmpeg_input_args { + if shlex::split(input).is_none() { + return Err(format!("Failed to parse custom ffmpeg input '{}' (`FFMPEG_INPUT_ARGS` env variable)", input)); + } + } + if let Some(output) = &env_ffmpeg_output_args { + if shlex::split(output).is_none() { + return Err(format!("Failed to parse custom ffmpeg output '{}' (`FFMPEG_INPUT_ARGS` env variable)", output)); + } + } + + return Ok(FFmpegPreset::Custom( + env_ffmpeg_input_args, + env_ffmpeg_output_args, + )); + } else if !PREDEFINED_PRESET.is_match(s) { + return Ok(FFmpegPreset::Custom(None, Some(s.to_string()))); + } + + let mut codec: Option = None; + let mut hwaccel: Option = None; + let mut quality: Option = None; + for token in s.split('-') { + if let Some(c) = FFmpegCodec::all() + .into_iter() + .find(|p| p.to_string() == token.to_lowercase()) + { + if let Some(cc) = codec { + return Err(format!( + "cannot use multiple codecs (found {} and {})", + cc.to_string(), + c.to_string() + )); + } + codec = Some(c) + } else if let Some(h) = FFmpegHwAccel::all() + .into_iter() + .find(|p| p.to_string() == token.to_lowercase()) + { + if let Some(hh) = hwaccel { + return Err(format!( + "cannot use multiple hardware accelerations (found {} and {})", + hh.to_string(), + h.to_string() + )); + } + hwaccel = Some(h) + } else if let Some(q) = FFmpegQuality::all() + .into_iter() + .find(|p| p.to_string() == token.to_lowercase()) + { + if let Some(qq) = quality { + return Err(format!( + "cannot use multiple ffmpeg preset qualities (found {} and {})", + qq.to_string(), + q.to_string() + )); + } + quality = Some(q) + } else { + return Err(format!( + "'{}' is not a valid ffmpeg preset (unknown token '{}'", + s, token + )); + } + } + + if let Some(c) = codec { + if !FFmpegPreset::available_matches().contains(&( + c.clone(), + hwaccel.clone(), + quality.clone(), + )) { + return Err(format!("ffmpeg preset is not supported")); + } + Ok(FFmpegPreset::Predefined( + c, + hwaccel, + quality.unwrap_or(FFmpegQuality::Normal), + )) + } else { + Err(format!("cannot use ffmpeg preset with without a codec")) + } } - pub(crate) fn ffmpeg_presets( - mut presets: Vec, - ) -> Result<(Vec, Vec)> { - fn preset_check_remove(presets: &mut Vec, preset: FFmpegPreset) -> bool { - if let Some(i) = presets.iter().position(|p| p == &preset) { - presets.remove(i); - true - } else { - false - } - } + pub(crate) fn to_input_output_args(self) -> (Vec, Vec) { + match self { + FFmpegPreset::Custom(input, output) => ( + input.map_or(vec![], |i| shlex::split(&i).unwrap_or_default()), + output.map_or(vec![], |o| shlex::split(&o).unwrap_or_default()), + ), + FFmpegPreset::Predefined(codec, hwaccel_opt, quality) => { + let mut input = vec![]; + let mut output = vec![]; - let nvidia = preset_check_remove(&mut presets, FFmpegPreset::Nvidia); - if presets.len() > 1 { - bail!( - "Can only use one video codec, {} found: {}", - presets.len(), - presets - .iter() - .map(|p| p.to_string()) - .collect::>() - .join(", ") - ) - } + match codec { + FFmpegCodec::H264 => { + if let Some(hwaccel) = hwaccel_opt { + match hwaccel { + FFmpegHwAccel::Nvidia => { + input.extend(["-hwaccel", "cuvid", "-c:v", "h264_cuvid"]); + output.extend(["-c:v", "h264_nvenc", "-c:a", "copy"]) + } + } + } else { + output.extend(["-c:v", "libx264", "-c:a", "copy"]) + } - let (mut input, mut output) = (vec![], vec![]); - for preset in presets { - if nvidia { - match preset { - FFmpegPreset::Av1 => bail!("'nvidia' hardware acceleration preset is not available in combination with the 'av1' codec preset"), - FFmpegPreset::H265 => { - input.extend(["-hwaccel", "cuvid", "-c:v", "h264_cuvid"]); - output.extend(["-c:v", "hevc_nvenc", "-c:a", "copy"]); + match quality { + FFmpegQuality::Lossless => output.extend(["-crf", "18"]), + FFmpegQuality::Normal => (), + FFmpegQuality::Low => output.extend(["-crf", "35"]), + } } - FFmpegPreset::H264 => { - input.extend(["-hwaccel", "cuvid", "-c:v", "h264_cuvid"]); - output.extend(["-c:v", "h264_nvenc", "-c:a", "copy"]); + FFmpegCodec::H265 => { + if let Some(hwaccel) = hwaccel_opt { + match hwaccel { + FFmpegHwAccel::Nvidia => { + input.extend(["-hwaccel", "cuvid", "-c:v", "h264_cuvid"]); + output.extend(["-c:v", "hevc_nvenc", "-c:a", "copy"]) + } + } + } else { + output.extend(["-c:v", "libx265", "-c:a", "copy"]) + } + + match quality { + FFmpegQuality::Lossless => output.extend(["-crf", "20"]), + FFmpegQuality::Normal => (), + FFmpegQuality::Low => output.extend(["-crf", "35"]), + } } - _ => () - } - } else { - match preset { - FFmpegPreset::Av1 => { + FFmpegCodec::Av1 => { output.extend(["-c:v", "libsvtav1", "-c:a", "copy"]); + + match quality { + FFmpegQuality::Lossless => output.extend(["-crf", "22"]), + FFmpegQuality::Normal => (), + FFmpegQuality::Low => output.extend(["-crf", "35"]), + } } - FFmpegPreset::H265 => { - output.extend(["-c:v", "libx265", "-c:a", "copy"]); - } - FFmpegPreset::H264 => { - output.extend(["-c:v", "libx264", "-c:a", "copy"]); - } - _ => (), } + + ( + input + .into_iter() + .map(|s| s.to_string()) + .collect::>(), + output + .into_iter() + .map(|s| s.to_string()) + .collect::>(), + ) } } - - if output.is_empty() { - output.extend(["-c:v", "copy", "-c:a", "copy"]) - } - - Ok(( - input.into_iter().map(|i| i.to_string()).collect(), - output.into_iter().map(|o| o.to_string()).collect(), - )) } }