mirror of
https://github.com/crunchy-labs/crunchy-cli.git
synced 2026-01-21 04:02:00 -06:00
Merge pull request #178 from crunchy-labs/feature/refactoring
Refactoring & library update
This commit is contained in:
commit
e819e44671
30 changed files with 3695 additions and 2982 deletions
775
Cargo.lock
generated
775
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,18 +5,16 @@ version = "3.0.0-dev.8"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.24", features = ["macros", "rt-multi-thread", "time"], default-features = false }
|
tokio = { version = "1.26", features = ["macros", "rt-multi-thread", "time"], default-features = false }
|
||||||
|
|
||||||
crunchy-cli-core = { path = "./crunchy-cli-core" }
|
crunchy-cli-core = { path = "./crunchy-cli-core" }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
clap = { version = "4.0", features = ["string"] }
|
clap = { version = "4.1", features = ["string"] }
|
||||||
clap_complete = "4.0"
|
clap_complete = "4.1"
|
||||||
clap_mangen = "0.2"
|
clap_mangen = "0.2"
|
||||||
|
|
||||||
# The static-* features must be used here since build dependency features cannot be manipulated from the features
|
|
||||||
# specified in this Cargo.toml [features].
|
|
||||||
crunchy-cli-core = { path = "./crunchy-cli-core" }
|
crunchy-cli-core = { path = "./crunchy-cli-core" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|
|
||||||
776
crunchy-cli-core/Cargo.lock
generated
776
crunchy-cli-core/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,12 +7,12 @@ edition = "2021"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
clap = { version = "4.0", features = ["derive", "string"] }
|
clap = { version = "4.1", features = ["derive", "string"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
crunchyroll-rs = "0.2"
|
crunchyroll-rs = { version = "0.3", features = ["dash-stream"] }
|
||||||
csv = "1.1"
|
|
||||||
ctrlc = "3.2"
|
ctrlc = "3.2"
|
||||||
dirs = "4.0"
|
dirs = "5.0"
|
||||||
|
derive_setters = "0.1"
|
||||||
indicatif = "0.17"
|
indicatif = "0.17"
|
||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
log = { version = "0.4", features = ["std"] }
|
log = { version = "0.4", features = ["std"] }
|
||||||
|
|
@ -23,9 +23,9 @@ serde = "1.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
shlex = "1.1"
|
shlex = "1.1"
|
||||||
signal-hook = "0.3"
|
signal-hook = "0.3"
|
||||||
tempfile = "3.3"
|
tempfile = "3.4"
|
||||||
terminal_size = "0.2"
|
terminal_size = "0.2"
|
||||||
tokio = { version = "1.24", features = ["macros", "rt-multi-thread", "time"] }
|
tokio = { version = "1.26", features = ["macros", "rt-multi-thread", "time"] }
|
||||||
sys-locale = "0.2"
|
sys-locale = "0.2"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|
|
||||||
185
crunchy-cli-core/src/archive/command.rs
Normal file
185
crunchy-cli-core/src/archive/command.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
use crate::archive::filter::ArchiveFilter;
|
||||||
|
use crate::utils::context::Context;
|
||||||
|
use crate::utils::download::MergeBehavior;
|
||||||
|
use crate::utils::ffmpeg::FFmpegPreset;
|
||||||
|
use crate::utils::filter::Filter;
|
||||||
|
use crate::utils::format::formats_visual_output;
|
||||||
|
use crate::utils::locale::all_locale_in_locales;
|
||||||
|
use crate::utils::log::progress;
|
||||||
|
use crate::utils::os::{free_file, has_ffmpeg, is_special_file};
|
||||||
|
use crate::utils::parse::parse_url;
|
||||||
|
use crate::Execute;
|
||||||
|
use anyhow::bail;
|
||||||
|
use anyhow::Result;
|
||||||
|
use crunchyroll_rs::media::Resolution;
|
||||||
|
use crunchyroll_rs::Locale;
|
||||||
|
use log::debug;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, clap::Parser)]
|
||||||
|
#[clap(about = "Archive a video")]
|
||||||
|
#[command(arg_required_else_help(true))]
|
||||||
|
#[command()]
|
||||||
|
pub struct Archive {
|
||||||
|
#[arg(help = format!("Audio languages. Can be used multiple times. \
|
||||||
|
Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
||||||
|
#[arg(long_help = format!("Audio languages. Can be used multiple times. \
|
||||||
|
Available languages are:\n{}", Locale::all().into_iter().map(|l| format!("{:<6} → {}", l.to_string(), l.to_human_readable())).collect::<Vec<String>>().join("\n ")))]
|
||||||
|
#[arg(short, long, default_values_t = vec![Locale::ja_JP, crate::utils::locale::system_locale()])]
|
||||||
|
pub(crate) locale: Vec<Locale>,
|
||||||
|
#[arg(help = format!("Subtitle languages. Can be used multiple times. \
|
||||||
|
Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
||||||
|
#[arg(long_help = format!("Subtitle languages. Can be used multiple times. \
|
||||||
|
Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
||||||
|
#[arg(short, long, default_values_t = Locale::all())]
|
||||||
|
pub(crate) subtitle: Vec<Locale>,
|
||||||
|
|
||||||
|
#[arg(help = "Name of the output file")]
|
||||||
|
#[arg(long_help = "Name of the output file.\
|
||||||
|
If you use one of the following pattern they will get replaced:\n \
|
||||||
|
{title} → Title of the video\n \
|
||||||
|
{series_name} → Name of the series\n \
|
||||||
|
{season_name} → Name of the season\n \
|
||||||
|
{audio} → Audio language of the video\n \
|
||||||
|
{resolution} → Resolution of the video\n \
|
||||||
|
{season_number} → Number of the season\n \
|
||||||
|
{episode_number} → Number of the episode\n \
|
||||||
|
{relative_episode_number} → Number of the episode relative to its season\
|
||||||
|
{series_id} → ID of the series\n \
|
||||||
|
{season_id} → ID of the season\n \
|
||||||
|
{episode_id} → ID of the episode")]
|
||||||
|
#[arg(short, long, default_value = "{title}.mkv")]
|
||||||
|
pub(crate) output: String,
|
||||||
|
|
||||||
|
#[arg(help = "Video resolution")]
|
||||||
|
#[arg(long_help = "The video resolution.\
|
||||||
|
Can either be specified via the pixels (e.g. 1920x1080), the abbreviation for pixels (e.g. 1080p) or 'common-use' words (e.g. best). \
|
||||||
|
Specifying the exact pixels is not recommended, use one of the other options instead. \
|
||||||
|
Crunchyroll let you choose the quality with pixel abbreviation on their clients, so you might be already familiar with the available options. \
|
||||||
|
The available common-use words are 'best' (choose the best resolution available) and 'worst' (worst resolution available)")]
|
||||||
|
#[arg(short, long, default_value = "best")]
|
||||||
|
#[arg(value_parser = crate::utils::clap::clap_parse_resolution)]
|
||||||
|
pub(crate) resolution: Resolution,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
help = "Sets the behavior of the stream merging. Valid behaviors are 'auto', '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')"
|
||||||
|
)]
|
||||||
|
#[arg(short, long, default_value = "auto")]
|
||||||
|
#[arg(value_parser = MergeBehavior::parse)]
|
||||||
|
pub(crate) merge: MergeBehavior,
|
||||||
|
|
||||||
|
#[arg(help = format!("Presets for video converting. Can be used multiple times. \
|
||||||
|
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::available_matches_human_readable().join("\n ")))]
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(value_parser = FFmpegPreset::parse)]
|
||||||
|
pub(crate) ffmpeg_preset: Option<FFmpegPreset>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
help = "Set which subtitle language should be set as default / auto shown when starting a video"
|
||||||
|
)]
|
||||||
|
#[arg(long)]
|
||||||
|
pub(crate) default_subtitle: Option<Locale>,
|
||||||
|
|
||||||
|
#[arg(help = "Skip files which are already existing")]
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
pub(crate) skip_existing: bool,
|
||||||
|
|
||||||
|
#[arg(help = "Crunchyroll series url(s)")]
|
||||||
|
pub(crate) urls: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait(?Send)]
|
||||||
|
impl Execute for Archive {
|
||||||
|
fn pre_check(&mut 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"
|
||||||
|
&& !is_special_file(PathBuf::from(&self.output))
|
||||||
|
&& self.output != "-"
|
||||||
|
{
|
||||||
|
bail!("File extension is not '.mkv'. Currently only matroska / '.mkv' files are supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
self.locale = all_locale_in_locales(self.locale.clone());
|
||||||
|
self.subtitle = all_locale_in_locales(self.subtitle.clone());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(self, ctx: Context) -> Result<()> {
|
||||||
|
let mut parsed_urls = vec![];
|
||||||
|
|
||||||
|
for (i, url) in self.urls.clone().into_iter().enumerate() {
|
||||||
|
let progress_handler = progress!("Parsing url {}", i + 1);
|
||||||
|
match parse_url(&ctx.crunchy, url.clone(), true).await {
|
||||||
|
Ok((media_collection, url_filter)) => {
|
||||||
|
progress_handler.stop(format!("Parsed url {}", i + 1));
|
||||||
|
parsed_urls.push((media_collection, url_filter))
|
||||||
|
}
|
||||||
|
Err(e) => bail!("url {} could not be parsed: {}", url, e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, (media_collection, url_filter)) in parsed_urls.into_iter().enumerate() {
|
||||||
|
let progress_handler = progress!("Fetching series details");
|
||||||
|
let archive_formats = ArchiveFilter::new(url_filter, self.clone())
|
||||||
|
.visit(media_collection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if archive_formats.is_empty() {
|
||||||
|
progress_handler.stop(format!("Skipping url {} (no matching videos found)", i + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
progress_handler.stop(format!("Loaded series information for url {}", i + 1));
|
||||||
|
|
||||||
|
formats_visual_output(archive_formats.iter().map(|(_, f)| f).collect());
|
||||||
|
|
||||||
|
for (downloader, mut format) in archive_formats {
|
||||||
|
let formatted_path = format.format_path((&self.output).into(), true);
|
||||||
|
let (path, changed) = free_file(formatted_path.clone());
|
||||||
|
|
||||||
|
if changed && self.skip_existing {
|
||||||
|
debug!(
|
||||||
|
"Skipping already existing file '{}'",
|
||||||
|
formatted_path.to_string_lossy()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
format.locales.sort_by(|(a, _), (b, _)| {
|
||||||
|
self.locale
|
||||||
|
.iter()
|
||||||
|
.position(|l| l == a)
|
||||||
|
.cmp(&self.locale.iter().position(|l| l == b))
|
||||||
|
});
|
||||||
|
for (_, subtitles) in format.locales.iter_mut() {
|
||||||
|
subtitles.sort_by(|a, b| {
|
||||||
|
self.subtitle
|
||||||
|
.iter()
|
||||||
|
.position(|l| l == a)
|
||||||
|
.cmp(&self.subtitle.iter().position(|l| l == b))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
format.visual_output(&path);
|
||||||
|
|
||||||
|
downloader.download(&ctx, &path).await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
493
crunchy-cli-core/src/archive/filter.rs
Normal file
493
crunchy-cli-core/src/archive/filter.rs
Normal file
|
|
@ -0,0 +1,493 @@
|
||||||
|
use crate::archive::command::Archive;
|
||||||
|
use crate::utils::download::{DownloadBuilder, DownloadFormat, Downloader, MergeBehavior};
|
||||||
|
use crate::utils::filter::{real_dedup_vec, Filter};
|
||||||
|
use crate::utils::format::{Format, SingleFormat};
|
||||||
|
use crate::utils::parse::UrlFilter;
|
||||||
|
use crate::utils::video::variant_data_from_stream;
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use chrono::Duration;
|
||||||
|
use crunchyroll_rs::media::{Subtitle, VariantData};
|
||||||
|
use crunchyroll_rs::{Concert, Episode, Locale, Movie, MovieListing, MusicVideo, Season, Series};
|
||||||
|
use log::warn;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::hash::Hash;
|
||||||
|
|
||||||
|
pub(crate) struct FilterResult {
|
||||||
|
format: SingleFormat,
|
||||||
|
video: VariantData,
|
||||||
|
audio: VariantData,
|
||||||
|
duration: Duration,
|
||||||
|
subtitles: Vec<Subtitle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Visited {
|
||||||
|
Series,
|
||||||
|
Season,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ArchiveFilter {
|
||||||
|
url_filter: UrlFilter,
|
||||||
|
archive: Archive,
|
||||||
|
season_episode_count: HashMap<u32, Vec<String>>,
|
||||||
|
season_subtitles_missing: Vec<u32>,
|
||||||
|
visited: Visited,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArchiveFilter {
|
||||||
|
pub(crate) fn new(url_filter: UrlFilter, archive: Archive) -> Self {
|
||||||
|
Self {
|
||||||
|
url_filter,
|
||||||
|
archive,
|
||||||
|
season_episode_count: HashMap::new(),
|
||||||
|
season_subtitles_missing: vec![],
|
||||||
|
visited: Visited::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Filter for ArchiveFilter {
|
||||||
|
type T = Vec<FilterResult>;
|
||||||
|
type Output = (Downloader, Format);
|
||||||
|
|
||||||
|
async fn visit_series(&mut self, series: Series) -> Result<Vec<Season>> {
|
||||||
|
// `series.audio_locales` isn't always populated b/c of crunchyrolls api. so check if the
|
||||||
|
// audio is matching only if the field is populated
|
||||||
|
if !series.audio_locales.is_empty() {
|
||||||
|
let missing_audio = missing_locales(&series.audio_locales, &self.archive.locale);
|
||||||
|
if !missing_audio.is_empty() {
|
||||||
|
warn!(
|
||||||
|
"Series {} is not available with {} audio",
|
||||||
|
series.title,
|
||||||
|
missing_audio
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let missing_subtitle =
|
||||||
|
missing_locales(&series.subtitle_locales, &self.archive.subtitle);
|
||||||
|
if !missing_subtitle.is_empty() {
|
||||||
|
warn!(
|
||||||
|
"Series {} is not available with {} subtitles",
|
||||||
|
series.title,
|
||||||
|
missing_subtitle
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.visited = Visited::Series
|
||||||
|
}
|
||||||
|
Ok(series.seasons().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_season(&mut self, mut season: Season) -> Result<Vec<Episode>> {
|
||||||
|
if !self.url_filter.is_season_valid(season.season_number) {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seasons = season.version(self.archive.locale.clone()).await?;
|
||||||
|
if self
|
||||||
|
.archive
|
||||||
|
.locale
|
||||||
|
.iter()
|
||||||
|
.any(|l| season.audio_locales.contains(l))
|
||||||
|
{
|
||||||
|
seasons.insert(0, season.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches!(self.visited, Visited::Series) {
|
||||||
|
let mut audio_locales: Vec<Locale> = seasons
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.audio_locales.clone())
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
real_dedup_vec(&mut audio_locales);
|
||||||
|
let missing_audio = missing_locales(&audio_locales, &self.archive.locale);
|
||||||
|
if !missing_audio.is_empty() {
|
||||||
|
warn!(
|
||||||
|
"Season {} is not available with {} audio",
|
||||||
|
season.season_number,
|
||||||
|
missing_audio
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let subtitle_locales: Vec<Locale> = seasons
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.subtitle_locales.clone())
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
let missing_subtitle = missing_locales(&subtitle_locales, &self.archive.subtitle);
|
||||||
|
if !missing_subtitle.is_empty() {
|
||||||
|
warn!(
|
||||||
|
"Season {} is not available with {} subtitles",
|
||||||
|
season.season_number,
|
||||||
|
missing_subtitle
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.visited = Visited::Season
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut episodes = vec![];
|
||||||
|
for season in seasons {
|
||||||
|
episodes.extend(season.episodes().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Format::has_relative_episodes_fmt(&self.archive.output) {
|
||||||
|
for episode in episodes.iter() {
|
||||||
|
self.season_episode_count
|
||||||
|
.entry(episode.season_number)
|
||||||
|
.or_insert(vec![])
|
||||||
|
.push(episode.id.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(episodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_episode(&mut self, mut episode: Episode) -> Result<Option<Self::T>> {
|
||||||
|
if !self
|
||||||
|
.url_filter
|
||||||
|
.is_episode_valid(episode.episode_number, episode.season_number)
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut episodes = vec![];
|
||||||
|
if !matches!(self.visited, Visited::Series) && !matches!(self.visited, Visited::Season) {
|
||||||
|
if self.archive.locale.contains(&episode.audio_locale) {
|
||||||
|
episodes.push(episode.clone())
|
||||||
|
}
|
||||||
|
episodes.extend(episode.version(self.archive.locale.clone()).await?);
|
||||||
|
let audio_locales: Vec<Locale> =
|
||||||
|
episodes.iter().map(|e| e.audio_locale.clone()).collect();
|
||||||
|
let missing_audio = missing_locales(&audio_locales, &self.archive.locale);
|
||||||
|
if !missing_audio.is_empty() {
|
||||||
|
warn!(
|
||||||
|
"Episode {} is not available with {} audio",
|
||||||
|
episode.episode_number,
|
||||||
|
missing_audio
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut subtitle_locales: Vec<Locale> = episodes
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.subtitle_locales.clone())
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
real_dedup_vec(&mut subtitle_locales);
|
||||||
|
let missing_subtitles = missing_locales(&subtitle_locales, &self.archive.subtitle);
|
||||||
|
if !missing_subtitles.is_empty()
|
||||||
|
&& !self
|
||||||
|
.season_subtitles_missing
|
||||||
|
.contains(&episode.season_number)
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
"Episode {} is not available with {} subtitles",
|
||||||
|
episode.episode_number,
|
||||||
|
missing_subtitles
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
self.season_subtitles_missing.push(episode.season_number)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
episodes.push(episode.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut formats = vec![];
|
||||||
|
for episode in episodes {
|
||||||
|
let stream = episode.streams().await?;
|
||||||
|
let (video, audio) = if let Some((video, audio)) =
|
||||||
|
variant_data_from_stream(&stream, &self.archive.resolution).await?
|
||||||
|
{
|
||||||
|
(video, audio)
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"Resolution ({}) is not available for episode {} ({}) of {} season {}",
|
||||||
|
&self.archive.resolution,
|
||||||
|
episode.episode_number,
|
||||||
|
episode.title,
|
||||||
|
episode.series_title,
|
||||||
|
episode.season_number,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let subtitles: Vec<Subtitle> = self
|
||||||
|
.archive
|
||||||
|
.subtitle
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| stream.subtitles.get(s).cloned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let relative_episode_number = if Format::has_relative_episodes_fmt(&self.archive.output)
|
||||||
|
{
|
||||||
|
if self
|
||||||
|
.season_episode_count
|
||||||
|
.get(&episode.season_number)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
let season_episodes = episode.season().await?.episodes().await?;
|
||||||
|
self.season_episode_count.insert(
|
||||||
|
episode.season_number,
|
||||||
|
season_episodes.into_iter().map(|e| e.id).collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let relative_episode_number = self
|
||||||
|
.season_episode_count
|
||||||
|
.get(&episode.season_number)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.position(|id| id == &episode.id);
|
||||||
|
if relative_episode_number.is_none() {
|
||||||
|
warn!(
|
||||||
|
"Failed to get relative episode number for episode {} ({}) of {} season {}",
|
||||||
|
episode.episode_number,
|
||||||
|
episode.title,
|
||||||
|
episode.series_title,
|
||||||
|
episode.season_number,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
relative_episode_number
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
formats.push(FilterResult {
|
||||||
|
format: SingleFormat::new_from_episode(
|
||||||
|
&episode,
|
||||||
|
&video,
|
||||||
|
subtitles.iter().map(|s| s.locale.clone()).collect(),
|
||||||
|
relative_episode_number.map(|n| n as u32),
|
||||||
|
),
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
duration: episode.duration.clone(),
|
||||||
|
subtitles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(formats))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_movie_listing(&mut self, movie_listing: MovieListing) -> Result<Vec<Movie>> {
|
||||||
|
Ok(movie_listing.movies().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_movie(&mut self, movie: Movie) -> Result<Option<Self::T>> {
|
||||||
|
let stream = movie.streams().await?;
|
||||||
|
let subtitles: Vec<&Subtitle> = self
|
||||||
|
.archive
|
||||||
|
.subtitle
|
||||||
|
.iter()
|
||||||
|
.filter_map(|l| stream.subtitles.get(l))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let missing_subtitles = missing_locales(
|
||||||
|
&subtitles.iter().map(|&s| s.locale.clone()).collect(),
|
||||||
|
&self.archive.subtitle,
|
||||||
|
);
|
||||||
|
if !missing_subtitles.is_empty() {
|
||||||
|
warn!(
|
||||||
|
"Movie '{}' is not available with {} subtitles",
|
||||||
|
movie.title,
|
||||||
|
missing_subtitles
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let (video, audio) = if let Some((video, audio)) =
|
||||||
|
variant_data_from_stream(&stream, &self.archive.resolution).await?
|
||||||
|
{
|
||||||
|
(video, audio)
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"Resolution ({}) of movie {} is not available",
|
||||||
|
self.archive.resolution,
|
||||||
|
movie.title
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(vec![FilterResult {
|
||||||
|
format: SingleFormat::new_from_movie(&movie, &video, vec![]),
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
duration: movie.duration,
|
||||||
|
subtitles: vec![],
|
||||||
|
}]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_music_video(&mut self, music_video: MusicVideo) -> Result<Option<Self::T>> {
|
||||||
|
let stream = music_video.streams().await?;
|
||||||
|
let (video, audio) = if let Some((video, audio)) =
|
||||||
|
variant_data_from_stream(&stream, &self.archive.resolution).await?
|
||||||
|
{
|
||||||
|
(video, audio)
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"Resolution ({}) of music video {} is not available",
|
||||||
|
self.archive.resolution,
|
||||||
|
music_video.title
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(vec![FilterResult {
|
||||||
|
format: SingleFormat::new_from_music_video(&music_video, &video),
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
duration: music_video.duration,
|
||||||
|
subtitles: vec![],
|
||||||
|
}]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_concert(&mut self, concert: Concert) -> Result<Option<Self::T>> {
|
||||||
|
let stream = concert.streams().await?;
|
||||||
|
let (video, audio) = if let Some((video, audio)) =
|
||||||
|
variant_data_from_stream(&stream, &self.archive.resolution).await?
|
||||||
|
{
|
||||||
|
(video, audio)
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"Resolution ({}x{}) of music video {} is not available",
|
||||||
|
self.archive.resolution.width,
|
||||||
|
self.archive.resolution.height,
|
||||||
|
concert.title
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(vec![FilterResult {
|
||||||
|
format: SingleFormat::new_from_concert(&concert, &video),
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
duration: concert.duration,
|
||||||
|
subtitles: vec![],
|
||||||
|
}]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish(self, input: Vec<Self::T>) -> Result<Vec<Self::Output>> {
|
||||||
|
let flatten_input: Vec<FilterResult> = input.into_iter().flatten().collect();
|
||||||
|
|
||||||
|
#[derive(Hash, Eq, PartialEq)]
|
||||||
|
struct SortKey {
|
||||||
|
season: u32,
|
||||||
|
episode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted: HashMap<SortKey, Vec<FilterResult>> = HashMap::new();
|
||||||
|
for data in flatten_input {
|
||||||
|
sorted
|
||||||
|
.entry(SortKey {
|
||||||
|
season: data.format.season_number,
|
||||||
|
episode: data.format.episode_number.to_string(),
|
||||||
|
})
|
||||||
|
.or_insert(vec![])
|
||||||
|
.push(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut values: Vec<Vec<FilterResult>> = sorted.into_values().collect();
|
||||||
|
values.sort_by(|a, b| {
|
||||||
|
a.first()
|
||||||
|
.unwrap()
|
||||||
|
.format
|
||||||
|
.sequence_number
|
||||||
|
.total_cmp(&b.first().unwrap().format.sequence_number)
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut result = vec![];
|
||||||
|
for data in values {
|
||||||
|
let single_formats: Vec<SingleFormat> =
|
||||||
|
data.iter().map(|fr| fr.format.clone()).collect();
|
||||||
|
let format = Format::from_single_formats(single_formats);
|
||||||
|
|
||||||
|
let mut downloader = DownloadBuilder::new()
|
||||||
|
.default_subtitle(self.archive.default_subtitle.clone())
|
||||||
|
.ffmpeg_preset(self.archive.ffmpeg_preset.clone().unwrap_or_default())
|
||||||
|
.output_format(Some("matroska".to_string()))
|
||||||
|
.audio_sort(Some(self.archive.locale.clone()))
|
||||||
|
.subtitle_sort(Some(self.archive.subtitle.clone()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
match self.archive.merge.clone() {
|
||||||
|
MergeBehavior::Video => {
|
||||||
|
for d in data {
|
||||||
|
downloader.add_format(DownloadFormat {
|
||||||
|
video: (d.video, d.format.audio.clone()),
|
||||||
|
audios: vec![(d.audio, d.format.audio.clone())],
|
||||||
|
subtitles: d.subtitles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MergeBehavior::Audio => downloader.add_format(DownloadFormat {
|
||||||
|
video: (
|
||||||
|
data.first().unwrap().video.clone(),
|
||||||
|
data.first().unwrap().format.audio.clone(),
|
||||||
|
),
|
||||||
|
audios: data
|
||||||
|
.iter()
|
||||||
|
.map(|d| (d.audio.clone(), d.format.audio.clone()))
|
||||||
|
.collect(),
|
||||||
|
// mix all subtitles together and then reduce them via a map so that only one
|
||||||
|
// subtitle per language exists
|
||||||
|
subtitles: data
|
||||||
|
.iter()
|
||||||
|
.flat_map(|d| d.subtitles.clone())
|
||||||
|
.map(|s| (s.locale.clone(), s))
|
||||||
|
.collect::<HashMap<Locale, Subtitle>>()
|
||||||
|
.into_values()
|
||||||
|
.collect(),
|
||||||
|
}),
|
||||||
|
MergeBehavior::Auto => {
|
||||||
|
let mut download_formats: HashMap<Duration, DownloadFormat> = HashMap::new();
|
||||||
|
|
||||||
|
for d in data {
|
||||||
|
if let Some(download_format) = download_formats.get_mut(&d.duration) {
|
||||||
|
download_format.audios.push((d.audio, d.format.audio));
|
||||||
|
download_format.subtitles.extend(d.subtitles)
|
||||||
|
} else {
|
||||||
|
download_formats.insert(
|
||||||
|
d.duration,
|
||||||
|
DownloadFormat {
|
||||||
|
video: (d.video, d.format.audio.clone()),
|
||||||
|
audios: vec![(d.audio, d.format.audio)],
|
||||||
|
subtitles: d.subtitles,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for download_format in download_formats.into_values() {
|
||||||
|
downloader.add_format(download_format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push((downloader, format))
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_locales<'a>(available: &Vec<Locale>, searched: &'a Vec<Locale>) -> Vec<&'a Locale> {
|
||||||
|
searched.iter().filter(|p| !available.contains(p)).collect()
|
||||||
|
}
|
||||||
4
crunchy-cli-core/src/archive/mod.rs
Normal file
4
crunchy-cli-core/src/archive/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
mod command;
|
||||||
|
mod filter;
|
||||||
|
|
||||||
|
pub use command::Archive;
|
||||||
|
|
@ -1,618 +0,0 @@
|
||||||
use crate::cli::log::tab_info;
|
|
||||||
use crate::cli::utils::{
|
|
||||||
all_locale_in_locales, download_segments, find_multiple_seasons_with_same_number,
|
|
||||||
find_resolution, interactive_season_choosing, FFmpegPreset,
|
|
||||||
};
|
|
||||||
use crate::utils::context::Context;
|
|
||||||
use crate::utils::format::Format;
|
|
||||||
use crate::utils::log::progress;
|
|
||||||
use crate::utils::os::{free_file, has_ffmpeg, is_special_file, tempfile};
|
|
||||||
use crate::utils::parse::{parse_url, UrlFilter};
|
|
||||||
use crate::utils::sort::{sort_formats_after_seasons, sort_seasons_after_number};
|
|
||||||
use crate::utils::subtitle::{download_subtitle, Subtitle};
|
|
||||||
use crate::utils::video::get_video_length;
|
|
||||||
use crate::Execute;
|
|
||||||
use anyhow::{bail, Result};
|
|
||||||
use crunchyroll_rs::media::Resolution;
|
|
||||||
use crunchyroll_rs::{Locale, Media, MediaCollection, Series};
|
|
||||||
use log::{debug, error, info};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
use tempfile::TempPath;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum MergeBehavior {
|
|
||||||
Auto,
|
|
||||||
Audio,
|
|
||||||
Video,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MergeBehavior {
|
|
||||||
fn parse(s: &str) -> Result<MergeBehavior, String> {
|
|
||||||
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)]
|
|
||||||
#[clap(about = "Archive a video")]
|
|
||||||
#[command(arg_required_else_help(true))]
|
|
||||||
#[command()]
|
|
||||||
pub struct Archive {
|
|
||||||
#[arg(help = format!("Audio languages. Can be used multiple times. \
|
|
||||||
Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
|
||||||
#[arg(long_help = format!("Audio languages. Can be used multiple times. \
|
|
||||||
Available languages are:\n{}", Locale::all().into_iter().map(|l| format!("{:<6} → {}", l.to_string(), l.to_human_readable())).collect::<Vec<String>>().join("\n ")))]
|
|
||||||
#[arg(short, long, default_values_t = vec![crate::utils::locale::system_locale(), Locale::ja_JP])]
|
|
||||||
locale: Vec<Locale>,
|
|
||||||
#[arg(help = format!("Subtitle languages. Can be used multiple times. \
|
|
||||||
Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
|
||||||
#[arg(long_help = format!("Subtitle languages. Can be used multiple times. \
|
|
||||||
Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
|
||||||
#[arg(short, long, default_values_t = Locale::all())]
|
|
||||||
subtitle: Vec<Locale>,
|
|
||||||
|
|
||||||
#[arg(help = "Name of the output file")]
|
|
||||||
#[arg(long_help = "Name of the output file.\
|
|
||||||
If you use one of the following pattern they will get replaced:\n \
|
|
||||||
{title} → Title of the video\n \
|
|
||||||
{series_name} → Name of the series\n \
|
|
||||||
{season_name} → Name of the season\n \
|
|
||||||
{audio} → Audio language of the video\n \
|
|
||||||
{resolution} → Resolution of the video\n \
|
|
||||||
{season_number} → Number of the season\n \
|
|
||||||
{episode_number} → Number of the episode\n \
|
|
||||||
{relative_episode_number} → Number of the episode relative to its season\
|
|
||||||
{series_id} → ID of the series\n \
|
|
||||||
{season_id} → ID of the season\n \
|
|
||||||
{episode_id} → ID of the episode")]
|
|
||||||
#[arg(short, long, default_value = "{title}.mkv")]
|
|
||||||
output: String,
|
|
||||||
|
|
||||||
#[arg(help = "Video resolution")]
|
|
||||||
#[arg(long_help = "The video resolution.\
|
|
||||||
Can either be specified via the pixels (e.g. 1920x1080), the abbreviation for pixels (e.g. 1080p) or 'common-use' words (e.g. best). \
|
|
||||||
Specifying the exact pixels is not recommended, use one of the other options instead. \
|
|
||||||
Crunchyroll let you choose the quality with pixel abbreviation on their clients, so you might be already familiar with the available options. \
|
|
||||||
The available common-use words are 'best' (choose the best resolution available) and 'worst' (worst resolution available)")]
|
|
||||||
#[arg(short, long, default_value = "best")]
|
|
||||||
#[arg(value_parser = crate::utils::clap::clap_parse_resolution)]
|
|
||||||
resolution: Resolution,
|
|
||||||
|
|
||||||
#[arg(
|
|
||||||
help = "Sets the behavior of the stream merging. Valid behaviors are 'auto', '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')"
|
|
||||||
)]
|
|
||||||
#[arg(short, long, default_value = "auto")]
|
|
||||||
#[arg(value_parser = MergeBehavior::parse)]
|
|
||||||
merge: MergeBehavior,
|
|
||||||
|
|
||||||
#[arg(help = format!("Presets for video converting. Can be used multiple times. \
|
|
||||||
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::available_matches_human_readable().join("\n ")))]
|
|
||||||
#[arg(long)]
|
|
||||||
#[arg(value_parser = FFmpegPreset::parse)]
|
|
||||||
ffmpeg_preset: Option<FFmpegPreset>,
|
|
||||||
|
|
||||||
#[arg(
|
|
||||||
help = "Set which subtitle language should be set as default / auto shown when starting a video"
|
|
||||||
)]
|
|
||||||
#[arg(long)]
|
|
||||||
default_subtitle: Option<Locale>,
|
|
||||||
|
|
||||||
#[arg(help = "Skip files which are already existing")]
|
|
||||||
#[arg(long, default_value_t = false)]
|
|
||||||
skip_existing: bool,
|
|
||||||
|
|
||||||
#[arg(help = "Ignore interactive input")]
|
|
||||||
#[arg(short, long, default_value_t = false)]
|
|
||||||
yes: bool,
|
|
||||||
|
|
||||||
#[arg(help = "Crunchyroll series url(s)")]
|
|
||||||
urls: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait(?Send)]
|
|
||||||
impl Execute for Archive {
|
|
||||||
fn pre_check(&mut 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"
|
|
||||||
&& !is_special_file(PathBuf::from(&self.output))
|
|
||||||
{
|
|
||||||
bail!("File extension is not '.mkv'. Currently only matroska / '.mkv' files are supported")
|
|
||||||
}
|
|
||||||
|
|
||||||
self.locale = all_locale_in_locales(self.locale.clone());
|
|
||||||
self.subtitle = all_locale_in_locales(self.subtitle.clone());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(self, ctx: Context) -> Result<()> {
|
|
||||||
let mut parsed_urls = vec![];
|
|
||||||
|
|
||||||
for (i, url) in self.urls.iter().enumerate() {
|
|
||||||
let progress_handler = progress!("Parsing url {}", i + 1);
|
|
||||||
match parse_url(&ctx.crunchy, url.clone(), true).await {
|
|
||||||
Ok((media_collection, url_filter)) => {
|
|
||||||
parsed_urls.push((media_collection, url_filter));
|
|
||||||
progress_handler.stop(format!("Parsed url {}", i + 1))
|
|
||||||
}
|
|
||||||
Err(e) => bail!("url {} could not be parsed: {}", url, e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, (media_collection, url_filter)) in parsed_urls.into_iter().enumerate() {
|
|
||||||
let progress_handler = progress!("Fetching series details");
|
|
||||||
let archive_formats = match media_collection {
|
|
||||||
MediaCollection::Series(series) => {
|
|
||||||
formats_from_series(&self, series, &url_filter).await?
|
|
||||||
}
|
|
||||||
MediaCollection::Season(_) => bail!("Archiving a season is not supported"),
|
|
||||||
MediaCollection::Episode(episode) => bail!("Archiving a episode is not supported. Use url filtering instead to specify the episode (https://www.crunchyroll.com/series/{}/{}[S{}E{}])", episode.metadata.series_id, episode.metadata.series_slug_title, episode.metadata.season_number, episode.metadata.episode_number),
|
|
||||||
MediaCollection::MovieListing(_) => bail!("Archiving a movie listing is not supported"),
|
|
||||||
MediaCollection::Movie(_) => bail!("Archiving a movie is not supported")
|
|
||||||
};
|
|
||||||
|
|
||||||
if archive_formats.is_empty() {
|
|
||||||
progress_handler.stop(format!(
|
|
||||||
"Skipping url {} (no matching episodes found)",
|
|
||||||
i + 1
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
progress_handler.stop(format!("Loaded series information for url {}", i + 1));
|
|
||||||
|
|
||||||
if log::max_level() == log::Level::Debug {
|
|
||||||
let seasons = sort_formats_after_seasons(
|
|
||||||
archive_formats
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.map(|(a, _)| a.get(0).unwrap().clone())
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
debug!("Series has {} seasons", seasons.len());
|
|
||||||
for (i, season) in seasons.into_iter().enumerate() {
|
|
||||||
info!("Season {} ({})", i + 1, season.get(0).unwrap().season_title);
|
|
||||||
for format in season {
|
|
||||||
info!(
|
|
||||||
"{}: {}px, {:.02} FPS (S{:02}E{:02})",
|
|
||||||
format.title,
|
|
||||||
format.stream.resolution,
|
|
||||||
format.stream.fps,
|
|
||||||
format.season_number,
|
|
||||||
format.episode_number,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for season in sort_formats_after_seasons(
|
|
||||||
archive_formats
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.map(|(a, _)| a.get(0).unwrap().clone())
|
|
||||||
.collect(),
|
|
||||||
) {
|
|
||||||
let first = season.get(0).unwrap();
|
|
||||||
info!(
|
|
||||||
"{} Season {} ({})",
|
|
||||||
first.series_name, first.season_number, first.season_title
|
|
||||||
);
|
|
||||||
|
|
||||||
for (i, format) in season.into_iter().enumerate() {
|
|
||||||
tab_info!(
|
|
||||||
"{}. {} » {}px, {:.2} FPS (S{:02}E{:02})",
|
|
||||||
i + 1,
|
|
||||||
format.title,
|
|
||||||
format.stream.resolution,
|
|
||||||
format.stream.fps,
|
|
||||||
format.season_number,
|
|
||||||
format.episode_number
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (formats, mut subtitles) in archive_formats {
|
|
||||||
let (primary, additionally) = formats.split_first().unwrap();
|
|
||||||
|
|
||||||
let formatted_path = primary.format_path((&self.output).into(), true);
|
|
||||||
let (path, changed) = free_file(formatted_path.clone());
|
|
||||||
|
|
||||||
if changed && self.skip_existing {
|
|
||||||
debug!(
|
|
||||||
"Skipping already existing file '{}'",
|
|
||||||
formatted_path.to_string_lossy()
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Downloading {} to '{}'",
|
|
||||||
primary.title,
|
|
||||||
if is_special_file(&path) {
|
|
||||||
path.to_str().unwrap()
|
|
||||||
} else {
|
|
||||||
path.file_name().unwrap().to_str().unwrap()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
tab_info!(
|
|
||||||
"Episode: S{:02}E{:02}",
|
|
||||||
primary.season_number,
|
|
||||||
primary.episode_number
|
|
||||||
);
|
|
||||||
tab_info!(
|
|
||||||
"Audio: {} (primary), {}",
|
|
||||||
primary.audio,
|
|
||||||
additionally
|
|
||||||
.iter()
|
|
||||||
.map(|a| a.audio.to_string())
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join(", ")
|
|
||||||
);
|
|
||||||
tab_info!(
|
|
||||||
"Subtitle: {}",
|
|
||||||
subtitles
|
|
||||||
.iter()
|
|
||||||
.filter(|s| s.primary) // Don't print subtitles of non-primary streams. They might get removed depending on the merge behavior.
|
|
||||||
.map(|s| {
|
|
||||||
if let Some(default) = &self.default_subtitle {
|
|
||||||
if default == &s.stream_subtitle.locale {
|
|
||||||
return format!("{} (primary)", default);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.stream_subtitle.locale.to_string()
|
|
||||||
})
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join(", ")
|
|
||||||
);
|
|
||||||
tab_info!("Resolution: {}", primary.stream.resolution);
|
|
||||||
tab_info!("FPS: {:.2}", primary.stream.fps);
|
|
||||||
|
|
||||||
let mut video_paths = vec![];
|
|
||||||
let mut audio_paths = vec![];
|
|
||||||
let mut subtitle_paths = vec![];
|
|
||||||
|
|
||||||
video_paths.push((download_video(&ctx, primary, false).await?, primary));
|
|
||||||
for additional in additionally {
|
|
||||||
let identical_video = additionally
|
|
||||||
.iter()
|
|
||||||
.all(|a| a.stream.bandwidth == primary.stream.bandwidth);
|
|
||||||
let only_audio = match self.merge {
|
|
||||||
MergeBehavior::Auto => identical_video,
|
|
||||||
MergeBehavior::Audio => true,
|
|
||||||
MergeBehavior::Video => false,
|
|
||||||
};
|
|
||||||
let path = download_video(&ctx, additional, only_audio).await?;
|
|
||||||
if only_audio {
|
|
||||||
audio_paths.push((path, additional))
|
|
||||||
} else {
|
|
||||||
video_paths.push((path, additional))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove subtitles of forcibly deleted video
|
|
||||||
if matches!(self.merge, MergeBehavior::Audio) && !identical_video {
|
|
||||||
subtitles.retain(|s| s.episode_id != additional.episode_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(subtitle.stream_subtitle.clone(), primary_video_length)
|
|
||||||
.await?,
|
|
||||||
subtitle,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
let progess_handler = progress!("Generating mkv");
|
|
||||||
generate_mkv(&self, path, video_paths, audio_paths, subtitle_paths)?;
|
|
||||||
progess_handler.stop("Mkv generated")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn formats_from_series(
|
|
||||||
archive: &Archive,
|
|
||||||
series: Media<Series>,
|
|
||||||
url_filter: &UrlFilter,
|
|
||||||
) -> Result<Vec<(Vec<Format>, Vec<Subtitle>)>> {
|
|
||||||
let mut seasons = series.seasons().await?;
|
|
||||||
|
|
||||||
// filter any season out which does not contain the specified audio languages
|
|
||||||
for season in sort_seasons_after_number(seasons.clone()) {
|
|
||||||
// get all locales which are specified but not present in the current iterated season and
|
|
||||||
// print an error saying this
|
|
||||||
let not_present_audio = archive
|
|
||||||
.locale
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.filter(|l| !season.iter().any(|s| s.metadata.audio_locales.contains(l)))
|
|
||||||
.collect::<Vec<Locale>>();
|
|
||||||
if !not_present_audio.is_empty() {
|
|
||||||
error!(
|
|
||||||
"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::<Vec<String>>()
|
|
||||||
.join(", ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove all seasons with the wrong audio for the current iterated season number
|
|
||||||
seasons.retain(|s| {
|
|
||||||
s.metadata.season_number != season.first().unwrap().metadata.season_number
|
|
||||||
|| archive
|
|
||||||
.locale
|
|
||||||
.iter()
|
|
||||||
.any(|l| s.metadata.audio_locales.contains(l))
|
|
||||||
});
|
|
||||||
// remove seasons which match the url filter. this is mostly done to not trigger the
|
|
||||||
// interactive season choosing when dupilcated seasons are excluded by the filter
|
|
||||||
seasons.retain(|s| url_filter.is_season_valid(s.metadata.season_number))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !archive.yes && !find_multiple_seasons_with_same_number(&seasons).is_empty() {
|
|
||||||
info!(target: "progress_end", "Fetched seasons");
|
|
||||||
seasons = interactive_season_choosing(seasons);
|
|
||||||
info!(target: "progress", "Fetching series details")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
let mut result: Vec<(Vec<Format>, Vec<Subtitle>)> = Vec::new();
|
|
||||||
let mut primary_season = true;
|
|
||||||
for season in seasons {
|
|
||||||
let episodes = season.episodes().await?;
|
|
||||||
|
|
||||||
for episode in episodes.iter() {
|
|
||||||
if !url_filter.is_episode_valid(
|
|
||||||
episode.metadata.episode_number,
|
|
||||||
episode.metadata.season_number,
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let streams = episode.streams().await?;
|
|
||||||
let streaming_data = streams.hls_streaming_data(None).await?;
|
|
||||||
let Some(stream) = find_resolution(streaming_data, &archive.resolution) else {
|
|
||||||
bail!(
|
|
||||||
"Resolution ({}x{}) is not available for episode {} ({}) of season {} ({}) of {}",
|
|
||||||
archive.resolution.width,
|
|
||||||
archive.resolution.height,
|
|
||||||
episode.metadata.episode_number,
|
|
||||||
episode.title,
|
|
||||||
episode.metadata.season_number,
|
|
||||||
episode.metadata.season_title,
|
|
||||||
episode.metadata.series_title
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut formats: Vec<Format> = Vec::new();
|
|
||||||
let mut subtitles: Vec<Subtitle> = Vec::new();
|
|
||||||
subtitles.extend(archive.subtitle.iter().filter_map(|l| {
|
|
||||||
let stream_subtitle = streams.subtitles.get(l).cloned()?;
|
|
||||||
let subtitle = Subtitle {
|
|
||||||
stream_subtitle,
|
|
||||||
audio_locale: episode.metadata.audio_locale.clone(),
|
|
||||||
episode_id: episode.id.clone(),
|
|
||||||
forced: !episode.metadata.is_subbed,
|
|
||||||
primary: primary_season,
|
|
||||||
};
|
|
||||||
Some(subtitle)
|
|
||||||
}));
|
|
||||||
formats.push(Format::new_from_episode(episode, &episodes, stream, vec![]));
|
|
||||||
|
|
||||||
result.push((formats, subtitles));
|
|
||||||
}
|
|
||||||
|
|
||||||
primary_season = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn download_video(ctx: &Context, format: &Format, only_audio: bool) -> Result<TempPath> {
|
|
||||||
let tempfile = if only_audio {
|
|
||||||
tempfile(".aac")?
|
|
||||||
} else {
|
|
||||||
tempfile(".ts")?
|
|
||||||
};
|
|
||||||
let (_, path) = tempfile.into_parts();
|
|
||||||
|
|
||||||
let ffmpeg = Command::new("ffmpeg")
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.arg("-y")
|
|
||||||
.args(["-f", "mpegts"])
|
|
||||||
.args(["-i", "pipe:"])
|
|
||||||
.args(["-c", "copy"])
|
|
||||||
.args(if only_audio { vec!["-vn"] } else { vec![] })
|
|
||||||
.arg(path.to_str().unwrap())
|
|
||||||
.spawn()?;
|
|
||||||
|
|
||||||
download_segments(
|
|
||||||
ctx,
|
|
||||||
&mut ffmpeg.stdin.unwrap(),
|
|
||||||
Some(format!("Download {}", format.audio)),
|
|
||||||
format.stream.clone(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_mkv(
|
|
||||||
archive: &Archive,
|
|
||||||
target: PathBuf,
|
|
||||||
video_paths: Vec<(TempPath, &Format)>,
|
|
||||||
audio_paths: Vec<(TempPath, &Format)>,
|
|
||||||
subtitle_paths: Vec<(TempPath, Subtitle)>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut input = vec![];
|
|
||||||
let mut maps = vec![];
|
|
||||||
let mut metadata = vec![];
|
|
||||||
let mut dispositions = vec![vec![]; subtitle_paths.len()];
|
|
||||||
|
|
||||||
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()]);
|
|
||||||
metadata.extend([
|
|
||||||
format!("-metadata:s:v:{}", i),
|
|
||||||
format!("language={}", format.audio),
|
|
||||||
]);
|
|
||||||
metadata.extend([
|
|
||||||
format!("-metadata:s:v:{}", i),
|
|
||||||
format!("title={}", format.audio.to_human_readable()),
|
|
||||||
]);
|
|
||||||
metadata.extend([
|
|
||||||
format!("-metadata:s:a:{}", i),
|
|
||||||
format!("language={}", format.audio),
|
|
||||||
]);
|
|
||||||
metadata.extend([
|
|
||||||
format!("-metadata:s:a:{}", i),
|
|
||||||
format!("title={}", format.audio.to_human_readable()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
for (i, (audio_path, format)) in audio_paths.iter().enumerate() {
|
|
||||||
input.extend(["-i".to_string(), audio_path.to_string_lossy().to_string()]);
|
|
||||||
maps.extend(["-map".to_string(), (i + video_paths.len()).to_string()]);
|
|
||||||
metadata.extend([
|
|
||||||
format!("-metadata:s:a:{}", i + video_paths.len()),
|
|
||||||
format!("language={}", format.audio),
|
|
||||||
]);
|
|
||||||
metadata.extend([
|
|
||||||
format!("-metadata:s:a:{}", i + video_paths.len()),
|
|
||||||
format!("title={}", format.audio.to_human_readable()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
for (i, (subtitle_path, subtitle)) in subtitle_paths.iter().enumerate() {
|
|
||||||
input.extend([
|
|
||||||
"-i".to_string(),
|
|
||||||
subtitle_path.to_string_lossy().to_string(),
|
|
||||||
]);
|
|
||||||
maps.extend([
|
|
||||||
"-map".to_string(),
|
|
||||||
(i + video_paths.len() + audio_paths.len()).to_string(),
|
|
||||||
]);
|
|
||||||
metadata.extend([
|
|
||||||
format!("-metadata:s:s:{}", i),
|
|
||||||
format!("language={}", subtitle.stream_subtitle.locale),
|
|
||||||
]);
|
|
||||||
metadata.extend([
|
|
||||||
format!("-metadata:s:s:{}", i),
|
|
||||||
format!(
|
|
||||||
"title={}",
|
|
||||||
subtitle.stream_subtitle.locale.to_human_readable()
|
|
||||||
+ if !subtitle.primary {
|
|
||||||
format!(" [Video: {}]", subtitle.audio_locale.to_human_readable())
|
|
||||||
} else {
|
|
||||||
"".to_string()
|
|
||||||
}
|
|
||||||
.as_str()
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// mark forced subtitles
|
|
||||||
if subtitle.forced {
|
|
||||||
dispositions[i].push("forced");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
command_args.extend(input);
|
|
||||||
command_args.extend(maps);
|
|
||||||
command_args.extend(metadata);
|
|
||||||
|
|
||||||
// set default subtitle
|
|
||||||
if let Some(default_subtitle) = &archive.default_subtitle {
|
|
||||||
// if `--default_subtitle <locale>` is given set the default subtitle to the given locale
|
|
||||||
if let Some(position) = subtitle_paths
|
|
||||||
.iter()
|
|
||||||
.position(|(_, subtitle)| &subtitle.stream_subtitle.locale == default_subtitle)
|
|
||||||
{
|
|
||||||
dispositions[position].push("default");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let disposition_args: Vec<String> = dispositions
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.flat_map(|(i, d)| {
|
|
||||||
vec![
|
|
||||||
format!("-disposition:s:{}", i),
|
|
||||||
if !d.is_empty() {
|
|
||||||
d.join("+")
|
|
||||||
} else {
|
|
||||||
"0".to_string()
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
command_args.extend(disposition_args);
|
|
||||||
|
|
||||||
command_args.extend(output_presets);
|
|
||||||
command_args.extend([
|
|
||||||
"-f".to_string(),
|
|
||||||
"matroska".to_string(),
|
|
||||||
target.to_string_lossy().to_string(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
debug!("ffmpeg {}", command_args.join(" "));
|
|
||||||
|
|
||||||
// create parent directory if it does not exist
|
|
||||||
if let Some(parent) = target.parent() {
|
|
||||||
if !parent.exists() {
|
|
||||||
std::fs::create_dir_all(parent)?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let ffmpeg = Command::new("ffmpeg")
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.args(command_args)
|
|
||||||
.output()?;
|
|
||||||
if !ffmpeg.status.success() {
|
|
||||||
bail!("{}", String::from_utf8_lossy(ffmpeg.stderr.as_slice()))
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,603 +0,0 @@
|
||||||
use crate::cli::log::tab_info;
|
|
||||||
use crate::cli::utils::{
|
|
||||||
download_segments, find_multiple_seasons_with_same_number, find_resolution,
|
|
||||||
interactive_season_choosing, FFmpegPreset,
|
|
||||||
};
|
|
||||||
use crate::utils::context::Context;
|
|
||||||
use crate::utils::format::Format;
|
|
||||||
use crate::utils::log::progress;
|
|
||||||
use crate::utils::os::{free_file, has_ffmpeg, is_special_file, tempfile};
|
|
||||||
use crate::utils::parse::{parse_url, UrlFilter};
|
|
||||||
use crate::utils::sort::{sort_formats_after_seasons, sort_seasons_after_number};
|
|
||||||
use crate::utils::subtitle::download_subtitle;
|
|
||||||
use crate::utils::video::get_video_length;
|
|
||||||
use crate::Execute;
|
|
||||||
use anyhow::{bail, Result};
|
|
||||||
use crunchyroll_rs::media::{Resolution, StreamSubtitle, VariantData};
|
|
||||||
use crunchyroll_rs::{
|
|
||||||
Episode, Locale, Media, MediaCollection, Movie, MovieListing, Season, Series,
|
|
||||||
};
|
|
||||||
use log::{debug, error, info, warn};
|
|
||||||
use std::borrow::Cow;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
#[derive(Debug, clap::Parser)]
|
|
||||||
#[clap(about = "Download a video")]
|
|
||||||
#[command(arg_required_else_help(true))]
|
|
||||||
pub struct Download {
|
|
||||||
#[arg(help = format!("Audio language. Can only be used if the provided url(s) point to a series. \
|
|
||||||
Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
|
||||||
#[arg(long_help = format!("Audio language. Can only be used if the provided url(s) point to a series. \
|
|
||||||
Available languages are:\n{}", Locale::all().into_iter().map(|l| format!("{:<6} → {}", l.to_string(), l.to_human_readable())).collect::<Vec<String>>().join("\n ")))]
|
|
||||||
#[arg(short, long, default_value_t = crate::utils::locale::system_locale())]
|
|
||||||
audio: Locale,
|
|
||||||
#[arg(help = format!("Subtitle language. Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
|
||||||
#[arg(long_help = format!("Subtitle language. If set, the subtitle will be burned into the video and cannot be disabled. \
|
|
||||||
Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
|
||||||
#[arg(short, long)]
|
|
||||||
subtitle: Option<Locale>,
|
|
||||||
|
|
||||||
#[arg(help = "Name of the output file")]
|
|
||||||
#[arg(long_help = "Name of the output file.\
|
|
||||||
If you use one of the following pattern they will get replaced:\n \
|
|
||||||
{title} → Title of the video\n \
|
|
||||||
{series_name} → Name of the series\n \
|
|
||||||
{season_name} → Name of the season\n \
|
|
||||||
{audio} → Audio language of the video\n \
|
|
||||||
{resolution} → Resolution of the video\n \
|
|
||||||
{season_number} → Number of the season\n \
|
|
||||||
{episode_number} → Number of the episode\n \
|
|
||||||
{relative_episode_number} → Number of the episode relative to its season\
|
|
||||||
{series_id} → ID of the series\n \
|
|
||||||
{season_id} → ID of the season\n \
|
|
||||||
{episode_id} → ID of the episode")]
|
|
||||||
#[arg(short, long, default_value = "{title}.mp4")]
|
|
||||||
output: String,
|
|
||||||
|
|
||||||
#[arg(help = "Video resolution")]
|
|
||||||
#[arg(long_help = "The video resolution.\
|
|
||||||
Can either be specified via the pixels (e.g. 1920x1080), the abbreviation for pixels (e.g. 1080p) or 'common-use' words (e.g. best). \
|
|
||||||
Specifying the exact pixels is not recommended, use one of the other options instead. \
|
|
||||||
Crunchyroll let you choose the quality with pixel abbreviation on their clients, so you might be already familiar with the available options. \
|
|
||||||
The available common-use words are 'best' (choose the best resolution available) and 'worst' (worst resolution available)")]
|
|
||||||
#[arg(short, long, default_value = "best")]
|
|
||||||
#[arg(value_parser = crate::utils::clap::clap_parse_resolution)]
|
|
||||||
resolution: Resolution,
|
|
||||||
|
|
||||||
#[arg(help = format!("Presets for video converting. Can be used multiple times. \
|
|
||||||
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::available_matches_human_readable().join("\n ")))]
|
|
||||||
#[arg(long)]
|
|
||||||
#[arg(value_parser = FFmpegPreset::parse)]
|
|
||||||
ffmpeg_preset: Option<FFmpegPreset>,
|
|
||||||
|
|
||||||
#[arg(help = "Skip files which are already existing")]
|
|
||||||
#[arg(long, default_value_t = false)]
|
|
||||||
skip_existing: bool,
|
|
||||||
|
|
||||||
#[arg(help = "Ignore interactive input")]
|
|
||||||
#[arg(short, long, default_value_t = false)]
|
|
||||||
yes: bool,
|
|
||||||
|
|
||||||
#[arg(help = "Url(s) to Crunchyroll episodes or series")]
|
|
||||||
urls: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait(?Send)]
|
|
||||||
impl Execute for Download {
|
|
||||||
fn pre_check(&mut self) -> Result<()> {
|
|
||||||
if !has_ffmpeg() {
|
|
||||||
bail!("FFmpeg is needed to run this command")
|
|
||||||
} else if Path::new(&self.output)
|
|
||||||
.extension()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.is_empty()
|
|
||||||
&& self.output != "-"
|
|
||||||
{
|
|
||||||
bail!("No file extension found. Please specify a file extension (via `-o`) for the output file")
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.subtitle.is_some() {
|
|
||||||
if let Some(ext) = Path::new(&self.output).extension() {
|
|
||||||
if ext.to_string_lossy() != "mp4" {
|
|
||||||
warn!("Detected a non mp4 output container. Adding subtitles may take a while")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(self, ctx: Context) -> Result<()> {
|
|
||||||
let mut parsed_urls = vec![];
|
|
||||||
|
|
||||||
for (i, url) in self.urls.iter().enumerate() {
|
|
||||||
let progress_handler = progress!("Parsing url {}", i + 1);
|
|
||||||
match parse_url(&ctx.crunchy, url.clone(), true).await {
|
|
||||||
Ok((media_collection, url_filter)) => {
|
|
||||||
parsed_urls.push((media_collection, url_filter));
|
|
||||||
progress_handler.stop(format!("Parsed url {}", i + 1))
|
|
||||||
}
|
|
||||||
Err(e) => bail!("url {} could not be parsed: {}", url, e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, (media_collection, url_filter)) in parsed_urls.into_iter().enumerate() {
|
|
||||||
let progress_handler = progress!("Fetching series details");
|
|
||||||
let formats = match media_collection {
|
|
||||||
MediaCollection::Series(series) => {
|
|
||||||
debug!("Url {} is series ({})", i + 1, series.title);
|
|
||||||
formats_from_series(&self, series, &url_filter).await?
|
|
||||||
}
|
|
||||||
MediaCollection::Season(season) => {
|
|
||||||
debug!(
|
|
||||||
"Url {} is season {} ({})",
|
|
||||||
i + 1,
|
|
||||||
season.metadata.season_number,
|
|
||||||
season.title
|
|
||||||
);
|
|
||||||
formats_from_season(&self, season, &url_filter).await?
|
|
||||||
}
|
|
||||||
MediaCollection::Episode(episode) => {
|
|
||||||
debug!(
|
|
||||||
"Url {} is episode {} ({}) of season {} ({}) of {}",
|
|
||||||
i + 1,
|
|
||||||
episode.metadata.episode_number,
|
|
||||||
episode.title,
|
|
||||||
episode.metadata.season_number,
|
|
||||||
episode.metadata.season_title,
|
|
||||||
episode.metadata.series_title
|
|
||||||
);
|
|
||||||
format_from_episode(&self, &episode, &url_filter, None, false)
|
|
||||||
.await?
|
|
||||||
.map(|fmt| vec![fmt])
|
|
||||||
}
|
|
||||||
MediaCollection::MovieListing(movie_listing) => {
|
|
||||||
debug!("Url {} is movie listing ({})", i + 1, movie_listing.title);
|
|
||||||
format_from_movie_listing(&self, movie_listing, &url_filter).await?
|
|
||||||
}
|
|
||||||
MediaCollection::Movie(movie) => {
|
|
||||||
debug!("Url {} is movie ({})", i + 1, movie.title);
|
|
||||||
format_from_movie(&self, movie, &url_filter)
|
|
||||||
.await?
|
|
||||||
.map(|fmt| vec![fmt])
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(formats) = formats else {
|
|
||||||
progress_handler.stop(format!("Skipping url {} (no matching episodes found)", i + 1));
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
progress_handler.stop(format!("Loaded series information for url {}", i + 1));
|
|
||||||
|
|
||||||
if log::max_level() == log::Level::Debug {
|
|
||||||
let seasons = sort_formats_after_seasons(formats.clone());
|
|
||||||
debug!("Series has {} seasons", seasons.len());
|
|
||||||
for (i, season) in seasons.into_iter().enumerate() {
|
|
||||||
info!("Season {} ({})", i + 1, season.get(0).unwrap().season_title);
|
|
||||||
for format in season {
|
|
||||||
info!(
|
|
||||||
"{}: {}px, {:.02} FPS (S{:02}E{:02})",
|
|
||||||
format.title,
|
|
||||||
format.stream.resolution,
|
|
||||||
format.stream.fps,
|
|
||||||
format.season_number,
|
|
||||||
format.episode_number,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for season in sort_formats_after_seasons(formats.clone()) {
|
|
||||||
let first = season.get(0).unwrap();
|
|
||||||
info!(
|
|
||||||
"{} Season {} ({})",
|
|
||||||
first.series_name, first.season_number, first.season_title
|
|
||||||
);
|
|
||||||
|
|
||||||
for (i, format) in season.into_iter().enumerate() {
|
|
||||||
tab_info!(
|
|
||||||
"{}. {} » {}px, {:.2} FPS (S{:02}E{:02})",
|
|
||||||
i + 1,
|
|
||||||
format.title,
|
|
||||||
format.stream.resolution,
|
|
||||||
format.stream.fps,
|
|
||||||
format.season_number,
|
|
||||||
format.episode_number
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for format in formats {
|
|
||||||
let formatted_path = format.format_path((&self.output).into(), true);
|
|
||||||
let (path, changed) = free_file(formatted_path.clone());
|
|
||||||
|
|
||||||
if changed && self.skip_existing {
|
|
||||||
debug!(
|
|
||||||
"Skipping already existing file '{}'",
|
|
||||||
formatted_path.to_string_lossy()
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Downloading {} to '{}'",
|
|
||||||
format.title,
|
|
||||||
if is_special_file(&path) {
|
|
||||||
path.to_str().unwrap()
|
|
||||||
} else {
|
|
||||||
path.file_name().unwrap().to_str().unwrap()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
tab_info!(
|
|
||||||
"Episode: S{:02}E{:02}",
|
|
||||||
format.season_number,
|
|
||||||
format.episode_number
|
|
||||||
);
|
|
||||||
tab_info!("Audio: {}", format.audio);
|
|
||||||
tab_info!(
|
|
||||||
"Subtitles: {}",
|
|
||||||
self.subtitle
|
|
||||||
.clone()
|
|
||||||
.map_or("None".to_string(), |l| l.to_string())
|
|
||||||
);
|
|
||||||
tab_info!("Resolution: {}", format.stream.resolution);
|
|
||||||
tab_info!("FPS: {:.2}", format.stream.fps);
|
|
||||||
|
|
||||||
download_ffmpeg(
|
|
||||||
&ctx,
|
|
||||||
&self,
|
|
||||||
format.stream,
|
|
||||||
format.subtitles.get(0).cloned(),
|
|
||||||
path.to_path_buf(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn download_ffmpeg(
|
|
||||||
ctx: &Context,
|
|
||||||
download: &Download,
|
|
||||||
variant_data: VariantData,
|
|
||||||
subtitle: Option<StreamSubtitle>,
|
|
||||||
mut target: PathBuf,
|
|
||||||
) -> Result<()> {
|
|
||||||
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() {
|
|
||||||
if !parent.exists() {
|
|
||||||
std::fs::create_dir_all(parent)?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut video_file = tempfile(".ts")?;
|
|
||||||
download_segments(ctx, &mut video_file, None, variant_data).await?;
|
|
||||||
let subtitle_file = if let Some(ref sub) = subtitle {
|
|
||||||
let video_len = get_video_length(video_file.path().to_path_buf())?;
|
|
||||||
Some(download_subtitle(sub.clone(), video_len).await?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let stdout_tempfile = if target.to_string_lossy() == "-" {
|
|
||||||
let file = tempfile(".mp4")?;
|
|
||||||
target = file.path().to_path_buf();
|
|
||||||
|
|
||||||
Some(file)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let subtitle_presets = if let Some(sub_file) = &subtitle_file {
|
|
||||||
if target.extension().unwrap_or_default().to_string_lossy() == "mp4" {
|
|
||||||
vec![
|
|
||||||
"-i".to_string(),
|
|
||||||
sub_file.to_string_lossy().to_string(),
|
|
||||||
"-movflags".to_string(),
|
|
||||||
"faststart".to_string(),
|
|
||||||
"-c:s".to_string(),
|
|
||||||
"mov_text".to_string(),
|
|
||||||
"-disposition:s:s:0".to_string(),
|
|
||||||
"forced".to_string(),
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
// remove '-c:v copy' and '-c:a copy' from output presets as its causes issues with
|
|
||||||
// burning subs into the video
|
|
||||||
let mut last = String::new();
|
|
||||||
let mut remove_count = 0;
|
|
||||||
for (i, s) in output_presets.clone().iter().enumerate() {
|
|
||||||
if (last == "-c:v" || last == "-c:a") && s == "copy" {
|
|
||||||
// remove last
|
|
||||||
output_presets.remove(i - remove_count - 1);
|
|
||||||
remove_count += 1;
|
|
||||||
output_presets.remove(i - remove_count);
|
|
||||||
remove_count += 1;
|
|
||||||
}
|
|
||||||
last = s.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
vec![
|
|
||||||
"-vf".to_string(),
|
|
||||||
format!("subtitles={}", sub_file.to_string_lossy()),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut ffmpeg = Command::new("ffmpeg")
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.arg("-y")
|
|
||||||
.args(input_presets)
|
|
||||||
.args(["-i", video_file.path().to_string_lossy().as_ref()])
|
|
||||||
.args(subtitle_presets)
|
|
||||||
.args(output_presets)
|
|
||||||
.arg(target.to_str().unwrap())
|
|
||||||
.spawn()?;
|
|
||||||
|
|
||||||
let progress_handler = progress!("Generating output file");
|
|
||||||
if !ffmpeg.wait()?.success() {
|
|
||||||
bail!("{}", std::io::read_to_string(ffmpeg.stderr.unwrap())?)
|
|
||||||
}
|
|
||||||
progress_handler.stop("Output file generated");
|
|
||||||
|
|
||||||
if let Some(mut stdout_file) = stdout_tempfile {
|
|
||||||
let mut stdout = std::io::stdout();
|
|
||||||
|
|
||||||
std::io::copy(&mut stdout_file, &mut stdout)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn formats_from_series(
|
|
||||||
download: &Download,
|
|
||||||
series: Media<Series>,
|
|
||||||
url_filter: &UrlFilter,
|
|
||||||
) -> Result<Option<Vec<Format>>> {
|
|
||||||
if !series.metadata.audio_locales.is_empty()
|
|
||||||
&& !series.metadata.audio_locales.contains(&download.audio)
|
|
||||||
{
|
|
||||||
error!(
|
|
||||||
"Series {} is not available with {} audio",
|
|
||||||
series.title, download.audio
|
|
||||||
);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut seasons = series.seasons().await?;
|
|
||||||
|
|
||||||
// filter any season out which does not contain the specified audio language
|
|
||||||
for season in sort_seasons_after_number(seasons.clone()) {
|
|
||||||
// check if the current iterated season has the specified audio language
|
|
||||||
if !season
|
|
||||||
.iter()
|
|
||||||
.any(|s| s.metadata.audio_locales.contains(&download.audio))
|
|
||||||
{
|
|
||||||
error!(
|
|
||||||
"Season {} of series {} is not available with {} audio",
|
|
||||||
season.first().unwrap().metadata.season_number,
|
|
||||||
series.title,
|
|
||||||
download.audio
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove all seasons with the wrong audio for the current iterated season number
|
|
||||||
seasons.retain(|s| {
|
|
||||||
s.metadata.season_number != season.first().unwrap().metadata.season_number
|
|
||||||
|| s.metadata.audio_locales.contains(&download.audio)
|
|
||||||
});
|
|
||||||
// remove seasons which match the url filter. this is mostly done to not trigger the
|
|
||||||
// interactive season choosing when dupilcated seasons are excluded by the filter
|
|
||||||
seasons.retain(|s| url_filter.is_season_valid(s.metadata.season_number))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !download.yes && !find_multiple_seasons_with_same_number(&seasons).is_empty() {
|
|
||||||
info!(target: "progress_end", "Fetched seasons");
|
|
||||||
seasons = interactive_season_choosing(seasons);
|
|
||||||
info!(target: "progress", "Fetching series details")
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut formats = vec![];
|
|
||||||
for season in seasons {
|
|
||||||
if let Some(fmts) = formats_from_season(download, season, url_filter).await? {
|
|
||||||
formats.extend(fmts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(some_vec_or_none(formats))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn formats_from_season(
|
|
||||||
download: &Download,
|
|
||||||
season: Media<Season>,
|
|
||||||
url_filter: &UrlFilter,
|
|
||||||
) -> Result<Option<Vec<Format>>> {
|
|
||||||
if !url_filter.is_season_valid(season.metadata.season_number) {
|
|
||||||
return Ok(None);
|
|
||||||
} else if !season.metadata.audio_locales.contains(&download.audio) {
|
|
||||||
error!(
|
|
||||||
"Season {} ({}) is not available with {} audio",
|
|
||||||
season.metadata.season_number, season.title, download.audio
|
|
||||||
);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut formats = vec![];
|
|
||||||
|
|
||||||
let episodes = season.episodes().await?;
|
|
||||||
for episode in episodes.iter() {
|
|
||||||
if let Some(fmt) =
|
|
||||||
format_from_episode(download, &episode, url_filter, Some(&episodes), true).await?
|
|
||||||
{
|
|
||||||
formats.push(fmt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(some_vec_or_none(formats))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn format_from_episode(
|
|
||||||
download: &Download,
|
|
||||||
episode: &Media<Episode>,
|
|
||||||
url_filter: &UrlFilter,
|
|
||||||
season_episodes: Option<&Vec<Media<Episode>>>,
|
|
||||||
filter_audio: bool,
|
|
||||||
) -> Result<Option<Format>> {
|
|
||||||
if filter_audio && episode.metadata.audio_locale != download.audio {
|
|
||||||
error!(
|
|
||||||
"Episode {} ({}) of season {} ({}) of {} has no {} audio",
|
|
||||||
episode.metadata.episode_number,
|
|
||||||
episode.title,
|
|
||||||
episode.metadata.season_number,
|
|
||||||
episode.metadata.season_title,
|
|
||||||
episode.metadata.series_title,
|
|
||||||
download.audio
|
|
||||||
);
|
|
||||||
return Ok(None);
|
|
||||||
} else if !url_filter.is_episode_valid(
|
|
||||||
episode.metadata.episode_number,
|
|
||||||
episode.metadata.season_number,
|
|
||||||
) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let streams = episode.streams().await?;
|
|
||||||
let streaming_data = streams.hls_streaming_data(None).await?;
|
|
||||||
let subtitle = if let Some(subtitle) = &download.subtitle {
|
|
||||||
if let Some(sub) = streams.subtitles.get(subtitle) {
|
|
||||||
Some(sub.clone())
|
|
||||||
} else {
|
|
||||||
error!(
|
|
||||||
"Episode {} ({}) of season {} ({}) of {} has no {} subtitles",
|
|
||||||
episode.metadata.episode_number,
|
|
||||||
episode.title,
|
|
||||||
episode.metadata.season_number,
|
|
||||||
episode.metadata.season_title,
|
|
||||||
episode.metadata.series_title,
|
|
||||||
subtitle
|
|
||||||
);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(stream) = find_resolution(streaming_data, &download.resolution) else {
|
|
||||||
bail!(
|
|
||||||
"Resolution ({}x{}) is not available for episode {} ({}) of season {} ({}) of {}",
|
|
||||||
download.resolution.width,
|
|
||||||
download.resolution.height,
|
|
||||||
episode.metadata.episode_number,
|
|
||||||
episode.title,
|
|
||||||
episode.metadata.season_number,
|
|
||||||
episode.metadata.season_title,
|
|
||||||
episode.metadata.series_title
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let season_eps = if Format::has_relative_episodes_fmt(&download.output) {
|
|
||||||
if let Some(eps) = season_episodes {
|
|
||||||
Cow::from(eps)
|
|
||||||
} else {
|
|
||||||
Cow::from(episode.season().await?.episodes().await?)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Cow::from(vec![])
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(Format::new_from_episode(
|
|
||||||
episode,
|
|
||||||
&season_eps.to_vec(),
|
|
||||||
stream,
|
|
||||||
subtitle.map_or_else(|| vec![], |s| vec![s]),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn format_from_movie_listing(
|
|
||||||
download: &Download,
|
|
||||||
movie_listing: Media<MovieListing>,
|
|
||||||
url_filter: &UrlFilter,
|
|
||||||
) -> Result<Option<Vec<Format>>> {
|
|
||||||
let mut formats = vec![];
|
|
||||||
|
|
||||||
for movie in movie_listing.movies().await? {
|
|
||||||
if let Some(fmt) = format_from_movie(download, movie, url_filter).await? {
|
|
||||||
formats.push(fmt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(some_vec_or_none(formats))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn format_from_movie(
|
|
||||||
download: &Download,
|
|
||||||
movie: Media<Movie>,
|
|
||||||
_: &UrlFilter,
|
|
||||||
) -> Result<Option<Format>> {
|
|
||||||
let streams = movie.streams().await?;
|
|
||||||
let mut streaming_data = if let Some(subtitle) = &download.subtitle {
|
|
||||||
if !streams.subtitles.keys().cloned().any(|x| &x == subtitle) {
|
|
||||||
error!("Movie {} has no {} subtitles", movie.title, subtitle);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
streams.hls_streaming_data(Some(subtitle.clone())).await?
|
|
||||||
} else {
|
|
||||||
streams.hls_streaming_data(None).await?
|
|
||||||
};
|
|
||||||
|
|
||||||
streaming_data.sort_by(|a, b| a.resolution.width.cmp(&b.resolution.width).reverse());
|
|
||||||
let stream = {
|
|
||||||
match download.resolution.height {
|
|
||||||
u64::MAX => streaming_data.into_iter().next().unwrap(),
|
|
||||||
u64::MIN => streaming_data.into_iter().last().unwrap(),
|
|
||||||
_ => {
|
|
||||||
if let Some(streaming_data) = streaming_data.into_iter().find(|v| {
|
|
||||||
download.resolution.height == u64::MAX
|
|
||||||
|| v.resolution.height == download.resolution.height
|
|
||||||
}) {
|
|
||||||
streaming_data
|
|
||||||
} else {
|
|
||||||
bail!(
|
|
||||||
"Resolution ({}x{}) is not available for movie {}",
|
|
||||||
download.resolution.width,
|
|
||||||
download.resolution.height,
|
|
||||||
movie.title
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(Format::new_from_movie(&movie, stream)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn some_vec_or_none<T>(v: Vec<T>) -> Option<Vec<T>> {
|
|
||||||
if v.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
|
||||||
use log::{
|
|
||||||
set_boxed_logger, set_max_level, Level, LevelFilter, Log, Metadata, Record, SetLoggerError,
|
|
||||||
};
|
|
||||||
use std::io::{stdout, Write};
|
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub struct CliLogger {
|
|
||||||
all: bool,
|
|
||||||
level: LevelFilter,
|
|
||||||
progress: Mutex<Option<ProgressBar>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Log for CliLogger {
|
|
||||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
|
||||||
metadata.level() <= self.level
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log(&self, record: &Record) {
|
|
||||||
if !self.enabled(record.metadata())
|
|
||||||
|| (record.target() != "progress"
|
|
||||||
&& record.target() != "progress_end"
|
|
||||||
&& (!self.all && !record.target().starts_with("crunchy_cli")))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.level >= LevelFilter::Debug {
|
|
||||||
self.extended(record);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match record.target() {
|
|
||||||
"progress" => self.progress(record, false),
|
|
||||||
"progress_end" => self.progress(record, true),
|
|
||||||
_ => {
|
|
||||||
if self.progress.lock().unwrap().is_some() {
|
|
||||||
self.progress(record, false)
|
|
||||||
} else if record.level() > Level::Warn {
|
|
||||||
self.normal(record)
|
|
||||||
} else {
|
|
||||||
self.error(record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush(&self) {
|
|
||||||
let _ = stdout().flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CliLogger {
|
|
||||||
pub fn new(all: bool, level: LevelFilter) -> Self {
|
|
||||||
Self {
|
|
||||||
all,
|
|
||||||
level,
|
|
||||||
progress: Mutex::new(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init(all: bool, level: LevelFilter) -> Result<(), SetLoggerError> {
|
|
||||||
set_max_level(level);
|
|
||||||
set_boxed_logger(Box::new(CliLogger::new(all, level)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extended(&self, record: &Record) {
|
|
||||||
println!(
|
|
||||||
"[{}] {} {} ({}) {}",
|
|
||||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"),
|
|
||||||
record.level(),
|
|
||||||
// replace the 'progress' prefix if this function is invoked via 'progress!'
|
|
||||||
record
|
|
||||||
.target()
|
|
||||||
.replacen("crunchy_cli_core", "crunchy_cli", 1)
|
|
||||||
.replacen("progress_end", "crunchy_cli", 1)
|
|
||||||
.replacen("progress", "crunchy_cli", 1),
|
|
||||||
format!("{:?}", thread::current().id())
|
|
||||||
.replace("ThreadId(", "")
|
|
||||||
.replace(')', ""),
|
|
||||||
record.args()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normal(&self, record: &Record) {
|
|
||||||
println!(":: {}", record.args())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn error(&self, record: &Record) {
|
|
||||||
eprintln!(":: {}", record.args())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn progress(&self, record: &Record, stop: bool) {
|
|
||||||
let mut progress = self.progress.lock().unwrap();
|
|
||||||
|
|
||||||
let msg = format!("{}", record.args());
|
|
||||||
if stop && progress.is_some() {
|
|
||||||
if msg.is_empty() {
|
|
||||||
progress.take().unwrap().finish()
|
|
||||||
} else {
|
|
||||||
progress.take().unwrap().finish_with_message(msg)
|
|
||||||
}
|
|
||||||
} else if let Some(p) = &*progress {
|
|
||||||
p.println(format!(":: → {}", msg))
|
|
||||||
} else {
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
let finish_str = "✔";
|
|
||||||
#[cfg(windows)]
|
|
||||||
// windows does not support all unicode characters by default in their consoles, so
|
|
||||||
// we're using this (square root?) symbol instead. microsoft.
|
|
||||||
let finish_str = "√";
|
|
||||||
|
|
||||||
let pb = ProgressBar::new_spinner();
|
|
||||||
pb.set_style(
|
|
||||||
ProgressStyle::with_template(":: {spinner} {msg}")
|
|
||||||
.unwrap()
|
|
||||||
.tick_strings(&["—", "\\", "|", "/", finish_str]),
|
|
||||||
);
|
|
||||||
pb.enable_steady_tick(Duration::from_millis(200));
|
|
||||||
pb.set_message(msg);
|
|
||||||
*progress = Some(pb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! tab_info {
|
|
||||||
($($arg:tt)+) => {
|
|
||||||
if log::max_level() == log::LevelFilter::Debug {
|
|
||||||
info!($($arg)+)
|
|
||||||
} else {
|
|
||||||
info!("\t{}", format!($($arg)+))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub(crate) use tab_info;
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
pub mod archive;
|
|
||||||
pub mod download;
|
|
||||||
pub mod log;
|
|
||||||
pub mod login;
|
|
||||||
mod utils;
|
|
||||||
|
|
@ -1,693 +0,0 @@
|
||||||
use crate::utils::context::Context;
|
|
||||||
use anyhow::{bail, Result};
|
|
||||||
use crunchyroll_rs::media::{Resolution, VariantData, VariantSegment};
|
|
||||||
use crunchyroll_rs::{Locale, Media, Season};
|
|
||||||
use indicatif::{ProgressBar, ProgressFinish, ProgressStyle};
|
|
||||||
use lazy_static::lazy_static;
|
|
||||||
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;
|
|
||||||
|
|
||||||
pub fn find_resolution(
|
|
||||||
mut streaming_data: Vec<VariantData>,
|
|
||||||
resolution: &Resolution,
|
|
||||||
) -> Option<VariantData> {
|
|
||||||
streaming_data.sort_by(|a, b| a.resolution.width.cmp(&b.resolution.width).reverse());
|
|
||||||
match resolution.height {
|
|
||||||
u64::MAX => Some(streaming_data.into_iter().next().unwrap()),
|
|
||||||
u64::MIN => Some(streaming_data.into_iter().last().unwrap()),
|
|
||||||
_ => streaming_data
|
|
||||||
.into_iter()
|
|
||||||
.find(|v| resolution.height == u64::MAX || v.resolution.height == resolution.height),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn download_segments(
|
|
||||||
ctx: &Context,
|
|
||||||
writer: &mut impl Write,
|
|
||||||
message: Option<String>,
|
|
||||||
variant_data: VariantData,
|
|
||||||
) -> Result<()> {
|
|
||||||
let segments = variant_data.segments().await?;
|
|
||||||
let total_segments = segments.len();
|
|
||||||
|
|
||||||
let client = Arc::new(ctx.crunchy.client());
|
|
||||||
let count = Arc::new(Mutex::new(0));
|
|
||||||
|
|
||||||
let progress = if log::max_level() == LevelFilter::Info {
|
|
||||||
let estimated_file_size = (variant_data.bandwidth / 8)
|
|
||||||
* segments
|
|
||||||
.iter()
|
|
||||||
.map(|s| s.length.unwrap_or_default().as_secs())
|
|
||||||
.sum::<u64>();
|
|
||||||
|
|
||||||
let progress = ProgressBar::new(estimated_file_size)
|
|
||||||
.with_style(
|
|
||||||
ProgressStyle::with_template(
|
|
||||||
":: {msg}{bytes:>10} {bytes_per_sec:>12} [{wide_bar}] {percent:>3}%",
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.progress_chars("##-"),
|
|
||||||
)
|
|
||||||
.with_message(message.map(|m| m + " ").unwrap_or_default())
|
|
||||||
.with_finish(ProgressFinish::Abandon);
|
|
||||||
Some(progress)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let cpus = num_cpus::get();
|
|
||||||
let mut segs: Vec<Vec<VariantSegment>> = Vec::with_capacity(cpus);
|
|
||||||
for _ in 0..cpus {
|
|
||||||
segs.push(vec![])
|
|
||||||
}
|
|
||||||
for (i, segment) in segments.clone().into_iter().enumerate() {
|
|
||||||
segs[i - ((i / cpus) * cpus)].push(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (sender, receiver) = mpsc::channel();
|
|
||||||
|
|
||||||
let mut join_set: JoinSet<Result<()>> = JoinSet::new();
|
|
||||||
for num in 0..cpus {
|
|
||||||
let thread_client = client.clone();
|
|
||||||
let thread_sender = sender.clone();
|
|
||||||
let thread_segments = segs.remove(0);
|
|
||||||
let thread_count = count.clone();
|
|
||||||
join_set.spawn(async move {
|
|
||||||
let after_download_sender = thread_sender.clone();
|
|
||||||
|
|
||||||
// the download process is encapsulated in its own function. this is done to easily
|
|
||||||
// catch errors which get returned with `...?` and `bail!(...)` and that the thread
|
|
||||||
// itself can report that an error has occured
|
|
||||||
let download = || async move {
|
|
||||||
for (i, segment) in thread_segments.into_iter().enumerate() {
|
|
||||||
let mut retry_count = 0;
|
|
||||||
let mut buf = loop {
|
|
||||||
let response = thread_client
|
|
||||||
.get(&segment.url)
|
|
||||||
.timeout(Duration::from_secs(60))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match response.bytes().await {
|
|
||||||
Ok(b) => break b.to_vec(),
|
|
||||||
Err(e) => {
|
|
||||||
if e.is_body() {
|
|
||||||
if retry_count == 5 {
|
|
||||||
bail!("Max retry count reached ({}), multiple errors occured while receiving segment {}: {}", retry_count, num + (i * cpus), e)
|
|
||||||
}
|
|
||||||
debug!("Failed to download segment {} ({}). Retrying, {} out of 5 retries left", num + (i * cpus), e, 5 - retry_count)
|
|
||||||
} else {
|
|
||||||
bail!("{}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
retry_count += 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
buf = VariantSegment::decrypt(buf.borrow_mut(), segment.key)?.to_vec();
|
|
||||||
|
|
||||||
let mut c = thread_count.lock().unwrap();
|
|
||||||
debug!(
|
|
||||||
"Downloaded and decrypted segment [{}/{} {:.2}%] {}",
|
|
||||||
num + (i * cpus),
|
|
||||||
total_segments,
|
|
||||||
((*c + 1) as f64 / total_segments as f64) * 100f64,
|
|
||||||
segment.url
|
|
||||||
);
|
|
||||||
|
|
||||||
thread_sender.send((num as i32 + (i * cpus) as i32, buf))?;
|
|
||||||
|
|
||||||
*c += 1;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
let result = download().await;
|
|
||||||
if result.is_err() {
|
|
||||||
after_download_sender.send((-1 as i32, vec![]))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// drop the sender already here so it does not outlive all (download) threads which are the only
|
|
||||||
// real consumers of it
|
|
||||||
drop(sender);
|
|
||||||
|
|
||||||
// this is the main loop which writes the data. it uses a BTreeMap as a buffer as the write
|
|
||||||
// happens synchronized. the download consist of multiple segments. the map keys are representing
|
|
||||||
// the segment number and the values the corresponding bytes
|
|
||||||
let mut data_pos = 0;
|
|
||||||
let mut buf: BTreeMap<i32, Vec<u8>> = BTreeMap::new();
|
|
||||||
for (pos, bytes) in receiver.iter() {
|
|
||||||
// if the position is lower than 0, an error occured in the sending download thread
|
|
||||||
if pos < 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(p) = &progress {
|
|
||||||
let progress_len = p.length().unwrap();
|
|
||||||
let estimated_segment_len = (variant_data.bandwidth / 8)
|
|
||||||
* segments
|
|
||||||
.get(pos as usize)
|
|
||||||
.unwrap()
|
|
||||||
.length
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
let bytes_len = bytes.len() as u64;
|
|
||||||
|
|
||||||
p.set_length(progress_len - estimated_segment_len + bytes_len);
|
|
||||||
p.inc(bytes_len)
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if the currently sent bytes are the next in the buffer. if so, write them directly
|
|
||||||
// to the target without first adding them to the buffer.
|
|
||||||
// if not, add them to the buffer
|
|
||||||
if data_pos == pos {
|
|
||||||
writer.write_all(bytes.borrow())?;
|
|
||||||
data_pos += 1;
|
|
||||||
} else {
|
|
||||||
buf.insert(pos, bytes);
|
|
||||||
}
|
|
||||||
// check if the buffer contains the next segment(s)
|
|
||||||
while let Some(b) = buf.remove(&data_pos) {
|
|
||||||
writer.write_all(b.borrow())?;
|
|
||||||
data_pos += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if any error has occured while downloading it gets returned here
|
|
||||||
while let Some(joined) = join_set.join_next().await {
|
|
||||||
joined??
|
|
||||||
}
|
|
||||||
|
|
||||||
// write the remaining buffer, if existent
|
|
||||||
while let Some(b) = buf.remove(&data_pos) {
|
|
||||||
writer.write_all(b.borrow())?;
|
|
||||||
data_pos += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !buf.is_empty() {
|
|
||||||
bail!(
|
|
||||||
"Download buffer is not empty. Remaining segments: {}",
|
|
||||||
buf.into_keys()
|
|
||||||
.map(|k| k.to_string())
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join(", ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub enum FFmpegPreset {
|
|
||||||
Predefined(FFmpegCodec, Option<FFmpegHwAccel>, FFmpegQuality),
|
|
||||||
Custom(Option<String>, Option<String>),
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
),*,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Self, Self::Err> {
|
|
||||||
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 available_matches(
|
|
||||||
) -> Vec<(FFmpegCodec, Option<FFmpegHwAccel>, Option<FFmpegQuality>)> {
|
|
||||||
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 available_matches_human_readable() -> Vec<String> {
|
|
||||||
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::<Vec<String>>()
|
|
||||||
.join("-"),
|
|
||||||
description
|
|
||||||
))
|
|
||||||
}
|
|
||||||
return_values
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn parse(s: &str) -> Result<FFmpegPreset, String> {
|
|
||||||
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<FFmpegCodec> = None;
|
|
||||||
let mut hwaccel: Option<FFmpegHwAccel> = None;
|
|
||||||
let mut quality: Option<FFmpegQuality> = 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 to_input_output_args(self) -> (Vec<String>, Vec<String>) {
|
|
||||||
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![];
|
|
||||||
|
|
||||||
match codec {
|
|
||||||
FFmpegCodec::H264 => {
|
|
||||||
if let Some(hwaccel) = hwaccel_opt {
|
|
||||||
match hwaccel {
|
|
||||||
FFmpegHwAccel::Nvidia => {
|
|
||||||
input.extend(["-hwaccel", "cuda", "-hwaccel_output_format", "cuda", "-c:v", "h264_cuvid"]);
|
|
||||||
output.extend(["-c:v", "h264_nvenc", "-c:a", "copy"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
output.extend(["-c:v", "libx264", "-c:a", "copy"])
|
|
||||||
}
|
|
||||||
|
|
||||||
match quality {
|
|
||||||
FFmpegQuality::Lossless => output.extend(["-crf", "18"]),
|
|
||||||
FFmpegQuality::Normal => (),
|
|
||||||
FFmpegQuality::Low => output.extend(["-crf", "35"]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FFmpegCodec::H265 => {
|
|
||||||
if let Some(hwaccel) = hwaccel_opt {
|
|
||||||
match hwaccel {
|
|
||||||
FFmpegHwAccel::Nvidia => {
|
|
||||||
input.extend(["-hwaccel", "cuda", "-hwaccel_output_format", "cuda", "-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"]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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"]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(
|
|
||||||
input
|
|
||||||
.into_iter()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect::<Vec<String>>(),
|
|
||||||
output
|
|
||||||
.into_iter()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect::<Vec<String>>(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lazy_static! {
|
|
||||||
static ref DUPLICATED_SEASONS_MULTILANG_REGEX: Regex = Regex::new(r"(-arabic|-castilian|-english|-english-in|-french|-german|-hindi|-italian|-portuguese|-russian|-spanish)$").unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn find_multiple_seasons_with_same_number(seasons: &Vec<Media<Season>>) -> Vec<u32> {
|
|
||||||
let mut seasons_map: BTreeMap<u32, u32> = BTreeMap::new();
|
|
||||||
for season in seasons {
|
|
||||||
if let Some(s) = seasons_map.get_mut(&season.metadata.season_number) {
|
|
||||||
*s += 1;
|
|
||||||
} else {
|
|
||||||
seasons_map.insert(season.metadata.season_number, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
seasons_map
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(k, v)| {
|
|
||||||
if v > 1 {
|
|
||||||
// check if the different seasons are actual the same but with different dub languages
|
|
||||||
let mut multilang_season_vec: Vec<String> = seasons
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
DUPLICATED_SEASONS_MULTILANG_REGEX
|
|
||||||
.replace(s.slug_title.trim_end_matches("-dub"), "")
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
multilang_season_vec.dedup();
|
|
||||||
|
|
||||||
if multilang_season_vec.len() > 1 {
|
|
||||||
return Some(k);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if [`Locale::Custom("all")`] is in the provided locale list and return [`Locale::all`] if
|
|
||||||
/// so. If not, just return the provided locale list.
|
|
||||||
pub(crate) fn all_locale_in_locales(locales: Vec<Locale>) -> Vec<Locale> {
|
|
||||||
if locales
|
|
||||||
.iter()
|
|
||||||
.find(|l| l.to_string().to_lowercase().trim() == "all")
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
Locale::all()
|
|
||||||
} else {
|
|
||||||
locales
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn interactive_season_choosing(seasons: Vec<Media<Season>>) -> Vec<Media<Season>> {
|
|
||||||
let input_regex =
|
|
||||||
Regex::new(r"((?P<single>\d+)|(?P<range_from>\d+)-(?P<range_to>\d+)?)(\s|$)").unwrap();
|
|
||||||
|
|
||||||
let mut seasons_map: BTreeMap<u32, Vec<Media<Season>>> = BTreeMap::new();
|
|
||||||
for season in seasons {
|
|
||||||
if let Some(s) = seasons_map.get_mut(&season.metadata.season_number) {
|
|
||||||
s.push(season);
|
|
||||||
} else {
|
|
||||||
seasons_map.insert(season.metadata.season_number, vec![season]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (num, season_vec) in seasons_map.iter_mut() {
|
|
||||||
if season_vec.len() == 1 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if the different seasons are actual the same but with different dub languages
|
|
||||||
let mut multilang_season_vec: Vec<String> = season_vec
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
DUPLICATED_SEASONS_MULTILANG_REGEX
|
|
||||||
.replace(s.slug_title.trim_end_matches("-dub"), "")
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
multilang_season_vec.dedup();
|
|
||||||
|
|
||||||
if multilang_season_vec.len() == 1 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(":: Found multiple seasons for season number {}", num);
|
|
||||||
println!(":: Select the number of the seasons you want to download (eg \"1 2 4\", \"1-3\", \"1-3 5\"):");
|
|
||||||
for (i, season) in season_vec.iter().enumerate() {
|
|
||||||
println!(":: \t{}. {}", i + 1, season.title)
|
|
||||||
}
|
|
||||||
let mut stdout = std::io::stdout();
|
|
||||||
let _ = write!(stdout, ":: => ");
|
|
||||||
let _ = stdout.flush();
|
|
||||||
let mut user_input = String::new();
|
|
||||||
std::io::stdin()
|
|
||||||
.lock()
|
|
||||||
.read_line(&mut user_input)
|
|
||||||
.expect("cannot open stdin");
|
|
||||||
|
|
||||||
let mut nums = vec![];
|
|
||||||
for capture in input_regex.captures_iter(&user_input) {
|
|
||||||
if let Some(single) = capture.name("single") {
|
|
||||||
nums.push(single.as_str().parse::<usize>().unwrap() - 1);
|
|
||||||
} else {
|
|
||||||
let range_from = capture.name("range_from");
|
|
||||||
let range_to = capture.name("range_to");
|
|
||||||
|
|
||||||
// input is '-' which means use all seasons
|
|
||||||
if range_from.is_none() && range_to.is_none() {
|
|
||||||
nums = vec![];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let from = range_from
|
|
||||||
.map(|f| f.as_str().parse::<usize>().unwrap() - 1)
|
|
||||||
.unwrap_or(usize::MIN);
|
|
||||||
let to = range_from
|
|
||||||
.map(|f| f.as_str().parse::<usize>().unwrap() - 1)
|
|
||||||
.unwrap_or(usize::MAX);
|
|
||||||
|
|
||||||
nums.extend(
|
|
||||||
season_vec
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(i, _)| if i >= from && i <= to { Some(i) } else { None })
|
|
||||||
.collect::<Vec<usize>>(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nums.dedup();
|
|
||||||
|
|
||||||
if !nums.is_empty() {
|
|
||||||
let mut remove_count = 0;
|
|
||||||
for i in 0..season_vec.len() - 1 {
|
|
||||||
if !nums.contains(&i) {
|
|
||||||
season_vec.remove(i - remove_count);
|
|
||||||
remove_count += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
seasons_map
|
|
||||||
.into_values()
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.collect::<Vec<Media<Season>>>()
|
|
||||||
}
|
|
||||||
151
crunchy-cli-core/src/download/command.rs
Normal file
151
crunchy-cli-core/src/download/command.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
use crate::download::filter::DownloadFilter;
|
||||||
|
use crate::utils::context::Context;
|
||||||
|
use crate::utils::ffmpeg::FFmpegPreset;
|
||||||
|
use crate::utils::filter::Filter;
|
||||||
|
use crate::utils::format::formats_visual_output;
|
||||||
|
use crate::utils::log::progress;
|
||||||
|
use crate::utils::os::{free_file, has_ffmpeg};
|
||||||
|
use crate::utils::parse::parse_url;
|
||||||
|
use crate::Execute;
|
||||||
|
use anyhow::bail;
|
||||||
|
use anyhow::Result;
|
||||||
|
use crunchyroll_rs::media::Resolution;
|
||||||
|
use crunchyroll_rs::Locale;
|
||||||
|
use log::{debug, warn};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, clap::Parser)]
|
||||||
|
#[clap(about = "Download a video")]
|
||||||
|
#[command(arg_required_else_help(true))]
|
||||||
|
pub struct Download {
|
||||||
|
#[arg(help = format!("Audio language. Can only be used if the provided url(s) point to a series. \
|
||||||
|
Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
||||||
|
#[arg(long_help = format!("Audio language. Can only be used if the provided url(s) point to a series. \
|
||||||
|
Available languages are:\n{}", Locale::all().into_iter().map(|l| format!("{:<6} → {}", l.to_string(), l.to_human_readable())).collect::<Vec<String>>().join("\n ")))]
|
||||||
|
#[arg(short, long, default_value_t = crate::utils::locale::system_locale())]
|
||||||
|
pub(crate) audio: Locale,
|
||||||
|
#[arg(help = format!("Subtitle language. Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
||||||
|
#[arg(long_help = format!("Subtitle language. If set, the subtitle will be burned into the video and cannot be disabled. \
|
||||||
|
Available languages are: {}", Locale::all().into_iter().map(|l| l.to_string()).collect::<Vec<String>>().join(", ")))]
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub(crate) subtitle: Option<Locale>,
|
||||||
|
|
||||||
|
#[arg(help = "Name of the output file")]
|
||||||
|
#[arg(long_help = "Name of the output file.\
|
||||||
|
If you use one of the following pattern they will get replaced:\n \
|
||||||
|
{title} → Title of the video\n \
|
||||||
|
{series_name} → Name of the series\n \
|
||||||
|
{season_name} → Name of the season\n \
|
||||||
|
{audio} → Audio language of the video\n \
|
||||||
|
{resolution} → Resolution of the video\n \
|
||||||
|
{season_number} → Number of the season\n \
|
||||||
|
{episode_number} → Number of the episode\n \
|
||||||
|
{relative_episode_number} → Number of the episode relative to its season\
|
||||||
|
{series_id} → ID of the series\n \
|
||||||
|
{season_id} → ID of the season\n \
|
||||||
|
{episode_id} → ID of the episode")]
|
||||||
|
#[arg(short, long, default_value = "{title}.mp4")]
|
||||||
|
pub(crate) output: String,
|
||||||
|
|
||||||
|
#[arg(help = "Video resolution")]
|
||||||
|
#[arg(long_help = "The video resolution.\
|
||||||
|
Can either be specified via the pixels (e.g. 1920x1080), the abbreviation for pixels (e.g. 1080p) or 'common-use' words (e.g. best). \
|
||||||
|
Specifying the exact pixels is not recommended, use one of the other options instead. \
|
||||||
|
Crunchyroll let you choose the quality with pixel abbreviation on their clients, so you might be already familiar with the available options. \
|
||||||
|
The available common-use words are 'best' (choose the best resolution available) and 'worst' (worst resolution available)")]
|
||||||
|
#[arg(short, long, default_value = "best")]
|
||||||
|
#[arg(value_parser = crate::utils::clap::clap_parse_resolution)]
|
||||||
|
pub(crate) resolution: Resolution,
|
||||||
|
|
||||||
|
#[arg(help = format!("Presets for video converting. Can be used multiple times. \
|
||||||
|
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::available_matches_human_readable().join("\n ")))]
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(value_parser = FFmpegPreset::parse)]
|
||||||
|
pub(crate) ffmpeg_preset: Option<FFmpegPreset>,
|
||||||
|
|
||||||
|
#[arg(help = "Skip files which are already existing")]
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
pub(crate) skip_existing: bool,
|
||||||
|
|
||||||
|
#[arg(help = "Url(s) to Crunchyroll episodes or series")]
|
||||||
|
pub(crate) urls: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait(?Send)]
|
||||||
|
impl Execute for Download {
|
||||||
|
fn pre_check(&mut self) -> Result<()> {
|
||||||
|
if !has_ffmpeg() {
|
||||||
|
bail!("FFmpeg is needed to run this command")
|
||||||
|
} else if Path::new(&self.output)
|
||||||
|
.extension()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.is_empty()
|
||||||
|
&& self.output != "-"
|
||||||
|
{
|
||||||
|
bail!("No file extension found. Please specify a file extension (via `-o`) for the output file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.subtitle.is_some() {
|
||||||
|
if let Some(ext) = Path::new(&self.output).extension() {
|
||||||
|
if ext.to_string_lossy() != "mp4" {
|
||||||
|
warn!("Detected a non mp4 output container. Adding subtitles may take a while")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(self, ctx: Context) -> Result<()> {
|
||||||
|
let mut parsed_urls = vec![];
|
||||||
|
|
||||||
|
for (i, url) in self.urls.clone().into_iter().enumerate() {
|
||||||
|
let progress_handler = progress!("Parsing url {}", i + 1);
|
||||||
|
match parse_url(&ctx.crunchy, url.clone(), true).await {
|
||||||
|
Ok((media_collection, url_filter)) => {
|
||||||
|
progress_handler.stop(format!("Parsed url {}", i + 1));
|
||||||
|
parsed_urls.push((media_collection, url_filter))
|
||||||
|
}
|
||||||
|
Err(e) => bail!("url {} could not be parsed: {}", url, e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, (media_collection, url_filter)) in parsed_urls.into_iter().enumerate() {
|
||||||
|
let progress_handler = progress!("Fetching series details");
|
||||||
|
let download_formats = DownloadFilter::new(url_filter, self.clone())
|
||||||
|
.visit(media_collection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if download_formats.is_empty() {
|
||||||
|
progress_handler.stop(format!("Skipping url {} (no matching videos found)", i + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
progress_handler.stop(format!("Loaded series information for url {}", i + 1));
|
||||||
|
|
||||||
|
formats_visual_output(download_formats.iter().map(|(_, f)| f).collect());
|
||||||
|
|
||||||
|
for (downloader, format) in download_formats {
|
||||||
|
let formatted_path = format.format_path((&self.output).into(), true);
|
||||||
|
let (path, changed) = free_file(formatted_path.clone());
|
||||||
|
|
||||||
|
if changed && self.skip_existing {
|
||||||
|
debug!(
|
||||||
|
"Skipping already existing file '{}'",
|
||||||
|
formatted_path.to_string_lossy()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
format.visual_output(&path);
|
||||||
|
|
||||||
|
downloader.download(&ctx, &path).await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
355
crunchy-cli-core/src/download/filter.rs
Normal file
355
crunchy-cli-core/src/download/filter.rs
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
use crate::download::Download;
|
||||||
|
use crate::utils::download::{DownloadBuilder, DownloadFormat, Downloader};
|
||||||
|
use crate::utils::filter::Filter;
|
||||||
|
use crate::utils::format::{Format, SingleFormat};
|
||||||
|
use crate::utils::parse::UrlFilter;
|
||||||
|
use crate::utils::video::variant_data_from_stream;
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use crunchyroll_rs::media::{Subtitle, VariantData};
|
||||||
|
use crunchyroll_rs::{Concert, Episode, Movie, MovieListing, MusicVideo, Season, Series};
|
||||||
|
use log::{error, warn};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub(crate) struct FilterResult {
|
||||||
|
format: SingleFormat,
|
||||||
|
video: VariantData,
|
||||||
|
audio: VariantData,
|
||||||
|
subtitle: Option<Subtitle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct DownloadFilter {
|
||||||
|
url_filter: UrlFilter,
|
||||||
|
download: Download,
|
||||||
|
season_episode_count: HashMap<u32, Vec<String>>,
|
||||||
|
season_subtitles_missing: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadFilter {
|
||||||
|
pub(crate) fn new(url_filter: UrlFilter, download: Download) -> Self {
|
||||||
|
Self {
|
||||||
|
url_filter,
|
||||||
|
download,
|
||||||
|
season_episode_count: HashMap::new(),
|
||||||
|
season_subtitles_missing: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Filter for DownloadFilter {
|
||||||
|
type T = FilterResult;
|
||||||
|
type Output = (Downloader, Format);
|
||||||
|
|
||||||
|
async fn visit_series(&mut self, series: Series) -> Result<Vec<Season>> {
|
||||||
|
// `series.audio_locales` isn't always populated b/c of crunchyrolls api. so check if the
|
||||||
|
// audio is matching only if the field is populated
|
||||||
|
if !series.audio_locales.is_empty() {
|
||||||
|
if !series.audio_locales.contains(&self.download.audio) {
|
||||||
|
error!(
|
||||||
|
"Series {} is not available with {} audio",
|
||||||
|
series.title, self.download.audio
|
||||||
|
);
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let seasons = series.seasons().await?;
|
||||||
|
|
||||||
|
Ok(seasons)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_season(&mut self, mut season: Season) -> Result<Vec<Episode>> {
|
||||||
|
if !self.url_filter.is_season_valid(season.season_number) {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !season
|
||||||
|
.audio_locales
|
||||||
|
.iter()
|
||||||
|
.any(|l| l == &self.download.audio)
|
||||||
|
{
|
||||||
|
if season
|
||||||
|
.available_versions()
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.any(|l| l == &self.download.audio)
|
||||||
|
{
|
||||||
|
season = season
|
||||||
|
.version(vec![self.download.audio.clone()])
|
||||||
|
.await?
|
||||||
|
.remove(0)
|
||||||
|
} else {
|
||||||
|
error!(
|
||||||
|
"Season {} - '{}' is not available with {} audio",
|
||||||
|
season.season_number,
|
||||||
|
season.title,
|
||||||
|
self.download.audio.clone(),
|
||||||
|
);
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut episodes = season.episodes().await?;
|
||||||
|
|
||||||
|
if Format::has_relative_episodes_fmt(&self.download.output) {
|
||||||
|
for episode in episodes.iter() {
|
||||||
|
self.season_episode_count
|
||||||
|
.entry(episode.season_number)
|
||||||
|
.or_insert(vec![])
|
||||||
|
.push(episode.id.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes.retain(|e| {
|
||||||
|
self.url_filter
|
||||||
|
.is_episode_valid(e.episode_number, season.season_number)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(episodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_episode(&mut self, mut episode: Episode) -> Result<Option<Self::T>> {
|
||||||
|
if !self
|
||||||
|
.url_filter
|
||||||
|
.is_episode_valid(episode.episode_number, episode.season_number)
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the audio locale is correct.
|
||||||
|
// should only be incorrect if the console input was a episode url. otherwise
|
||||||
|
// `DownloadFilter::visit_season` returns the correct episodes with matching audio
|
||||||
|
if episode.audio_locale != self.download.audio {
|
||||||
|
// check if any other version (same episode, other language) of this episode is available
|
||||||
|
// with the requested audio. if not, return an error
|
||||||
|
if !episode
|
||||||
|
.available_versions()
|
||||||
|
.await?
|
||||||
|
.contains(&self.download.audio)
|
||||||
|
{
|
||||||
|
bail!(
|
||||||
|
"Episode {} ({}) of {} season {} is not available with {} audio",
|
||||||
|
episode.episode_number,
|
||||||
|
episode.title,
|
||||||
|
episode.series_title,
|
||||||
|
episode.season_number,
|
||||||
|
self.download.audio
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// overwrite the current episode with the other version episode
|
||||||
|
episode = episode
|
||||||
|
.version(vec![self.download.audio.clone()])
|
||||||
|
.await?
|
||||||
|
.remove(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the subtitles are supported
|
||||||
|
if let Some(subtitle_locale) = &self.download.subtitle {
|
||||||
|
if !episode.subtitle_locales.contains(subtitle_locale) {
|
||||||
|
// if the episode doesn't have the requested subtitles, print a error. to print this
|
||||||
|
// error only once per season, it's checked if an error got printed before by looking
|
||||||
|
// up if the season id is present in `self.season_subtitles_missing`. if not, print
|
||||||
|
// the error and add the season id to `self.season_subtitles_missing`. if it is
|
||||||
|
// present, skip the error printing
|
||||||
|
if !self
|
||||||
|
.season_subtitles_missing
|
||||||
|
.contains(&episode.season_number)
|
||||||
|
{
|
||||||
|
self.season_subtitles_missing.push(episode.season_number);
|
||||||
|
error!(
|
||||||
|
"{} season {} is not available with {} subtitles",
|
||||||
|
episode.series_title, episode.season_number, subtitle_locale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the correct video stream
|
||||||
|
let stream = episode.streams().await?;
|
||||||
|
let (video, audio) = if let Some((video, audio)) =
|
||||||
|
variant_data_from_stream(&stream, &self.download.resolution).await?
|
||||||
|
{
|
||||||
|
(video, audio)
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"Resolution ({}) is not available for episode {} ({}) of {} season {}",
|
||||||
|
self.download.resolution,
|
||||||
|
episode.episode_number,
|
||||||
|
episode.title,
|
||||||
|
episode.series_title,
|
||||||
|
episode.season_number,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// it is assumed that the subtitle, if requested, exists b/c the subtitle check above must
|
||||||
|
// be passed to reach this condition.
|
||||||
|
// the check isn't done in this if block to reduce unnecessary fetching of the stream
|
||||||
|
let subtitle = if let Some(subtitle_locale) = &self.download.subtitle {
|
||||||
|
stream.subtitles.get(subtitle_locale).map(|s| s.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// get the relative episode number. only done if the output string has the pattern to include
|
||||||
|
// the relative episode number as this requires some extra fetching
|
||||||
|
let relative_episode_number = if Format::has_relative_episodes_fmt(&self.download.output) {
|
||||||
|
if self
|
||||||
|
.season_episode_count
|
||||||
|
.get(&episode.season_number)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
let season_episodes = episode.season().await?.episodes().await?;
|
||||||
|
self.season_episode_count.insert(
|
||||||
|
episode.season_number,
|
||||||
|
season_episodes.into_iter().map(|e| e.id).collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let relative_episode_number = self
|
||||||
|
.season_episode_count
|
||||||
|
.get(&episode.season_number)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.position(|id| id == &episode.id);
|
||||||
|
if relative_episode_number.is_none() {
|
||||||
|
warn!(
|
||||||
|
"Failed to get relative episode number for episode {} ({}) of {} season {}",
|
||||||
|
episode.episode_number,
|
||||||
|
episode.title,
|
||||||
|
episode.series_title,
|
||||||
|
episode.season_number,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
relative_episode_number
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(FilterResult {
|
||||||
|
format: SingleFormat::new_from_episode(
|
||||||
|
&episode,
|
||||||
|
&video,
|
||||||
|
subtitle.clone().map_or(vec![], |s| vec![s.locale]),
|
||||||
|
relative_episode_number.map(|n| n as u32),
|
||||||
|
),
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
subtitle,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_movie_listing(&mut self, movie_listing: MovieListing) -> Result<Vec<Movie>> {
|
||||||
|
Ok(movie_listing.movies().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_movie(&mut self, movie: Movie) -> Result<Option<Self::T>> {
|
||||||
|
let stream = movie.streams().await?;
|
||||||
|
let (video, audio) = if let Some((video, audio)) =
|
||||||
|
variant_data_from_stream(&stream, &self.download.resolution).await?
|
||||||
|
{
|
||||||
|
(video, audio)
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"Resolution ({}) of movie '{}' is not available",
|
||||||
|
self.download.resolution,
|
||||||
|
movie.title
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let subtitle = if let Some(subtitle_locale) = &self.download.subtitle {
|
||||||
|
let Some(subtitle) = stream.subtitles.get(subtitle_locale) else {
|
||||||
|
error!(
|
||||||
|
"Movie '{}' has no {} subtitles",
|
||||||
|
movie.title,
|
||||||
|
subtitle_locale
|
||||||
|
);
|
||||||
|
return Ok(None)
|
||||||
|
};
|
||||||
|
Some(subtitle.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(FilterResult {
|
||||||
|
format: SingleFormat::new_from_movie(
|
||||||
|
&movie,
|
||||||
|
&video,
|
||||||
|
subtitle.clone().map_or(vec![], |s| vec![s.locale]),
|
||||||
|
),
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
subtitle,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_music_video(&mut self, music_video: MusicVideo) -> Result<Option<Self::T>> {
|
||||||
|
let stream = music_video.streams().await?;
|
||||||
|
let (video, audio) = if let Some((video, audio)) =
|
||||||
|
variant_data_from_stream(&stream, &self.download.resolution).await?
|
||||||
|
{
|
||||||
|
(video, audio)
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"Resolution ({}) of music video {} is not available",
|
||||||
|
self.download.resolution,
|
||||||
|
music_video.title
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(FilterResult {
|
||||||
|
format: SingleFormat::new_from_music_video(&music_video, &video),
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
subtitle: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn visit_concert(&mut self, concert: Concert) -> Result<Option<Self::T>> {
|
||||||
|
let stream = concert.streams().await?;
|
||||||
|
let (video, audio) = if let Some((video, audio)) =
|
||||||
|
variant_data_from_stream(&stream, &self.download.resolution).await?
|
||||||
|
{
|
||||||
|
(video, audio)
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"Resolution ({}) of music video {} is not available",
|
||||||
|
self.download.resolution,
|
||||||
|
concert.title
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(FilterResult {
|
||||||
|
format: SingleFormat::new_from_concert(&concert, &video),
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
subtitle: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish(self, mut input: Vec<Self::T>) -> Result<Vec<Self::Output>> {
|
||||||
|
let mut result = vec![];
|
||||||
|
input.sort_by(|a, b| {
|
||||||
|
a.format
|
||||||
|
.sequence_number
|
||||||
|
.total_cmp(&b.format.sequence_number)
|
||||||
|
});
|
||||||
|
for data in input {
|
||||||
|
let mut download_builder =
|
||||||
|
DownloadBuilder::new().default_subtitle(self.download.subtitle.clone());
|
||||||
|
// set the output format to mpegts / mpeg transport stream if the output file is stdout.
|
||||||
|
// mp4 isn't used here as the output file must be readable which isn't possible when
|
||||||
|
// writing to stdout
|
||||||
|
if self.download.output == "-" {
|
||||||
|
download_builder = download_builder.output_format(Some("mpegts".to_string()))
|
||||||
|
}
|
||||||
|
let mut downloader = download_builder.build();
|
||||||
|
downloader.add_format(DownloadFormat {
|
||||||
|
video: (data.video, data.format.audio.clone()),
|
||||||
|
audios: vec![(data.audio, data.format.audio.clone())],
|
||||||
|
subtitles: data.subtitle.map_or(vec![], |s| vec![s]),
|
||||||
|
});
|
||||||
|
result.push((downloader, Format::from_single_formats(vec![data.format])))
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crunchy-cli-core/src/download/mod.rs
Normal file
4
crunchy-cli-core/src/download/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
mod command;
|
||||||
|
mod filter;
|
||||||
|
|
||||||
|
pub use command::Download;
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
use crate::cli::log::CliLogger;
|
|
||||||
use crate::utils::context::Context;
|
use crate::utils::context::Context;
|
||||||
use crate::utils::locale::system_locale;
|
use crate::utils::locale::system_locale;
|
||||||
use crate::utils::log::progress;
|
use crate::utils::log::{progress, CliLogger};
|
||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
@ -9,10 +8,14 @@ use crunchyroll_rs::{Crunchyroll, Locale};
|
||||||
use log::{debug, error, warn, LevelFilter};
|
use log::{debug, error, warn, LevelFilter};
|
||||||
use std::{env, fs};
|
use std::{env, fs};
|
||||||
|
|
||||||
mod cli;
|
mod archive;
|
||||||
|
mod download;
|
||||||
|
mod login;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use cli::{archive::Archive, download::Download, login::Login};
|
pub use archive::Archive;
|
||||||
|
pub use download::Download;
|
||||||
|
pub use login::Login;
|
||||||
|
|
||||||
#[async_trait::async_trait(?Send)]
|
#[async_trait::async_trait(?Send)]
|
||||||
trait Execute {
|
trait Execute {
|
||||||
|
|
@ -35,6 +38,15 @@ pub struct Cli {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
lang: Option<Locale>,
|
lang: Option<Locale>,
|
||||||
|
|
||||||
|
#[arg(help = "Enable experimental fixes which may resolve some unexpected errors")]
|
||||||
|
#[arg(
|
||||||
|
long_help = "Enable experimental fixes which may resolve some unexpected errors. \
|
||||||
|
If everything works as intended this option isn't needed, but sometimes Crunchyroll mislabels \
|
||||||
|
the audio of a series/season or episode or returns a wrong season number. This is when using this option might help to solve the issue"
|
||||||
|
)]
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
experimental_fixes: bool,
|
||||||
|
|
||||||
#[clap(flatten)]
|
#[clap(flatten)]
|
||||||
login_method: LoginMethod,
|
login_method: LoginMethod,
|
||||||
|
|
||||||
|
|
@ -222,9 +234,14 @@ async fn crunchyroll_session(cli: &Cli) -> Result<Crunchyroll> {
|
||||||
lang
|
lang
|
||||||
};
|
};
|
||||||
|
|
||||||
let builder = Crunchyroll::builder()
|
let mut builder = Crunchyroll::builder()
|
||||||
.locale(locale)
|
.locale(locale)
|
||||||
.stabilization_locales(true);
|
.stabilization_locales(cli.experimental_fixes)
|
||||||
|
.stabilization_season_number(cli.experimental_fixes);
|
||||||
|
|
||||||
|
if let Command::Download(download) = &cli.command {
|
||||||
|
builder = builder.preferred_audio_locale(download.audio.clone())
|
||||||
|
}
|
||||||
|
|
||||||
let login_methods_count = cli.login_method.credentials.is_some() as u8
|
let login_methods_count = cli.login_method.credentials.is_some() as u8
|
||||||
+ cli.login_method.etp_rt.is_some() as u8
|
+ cli.login_method.etp_rt.is_some() as u8
|
||||||
|
|
@ -232,7 +249,7 @@ async fn crunchyroll_session(cli: &Cli) -> Result<Crunchyroll> {
|
||||||
|
|
||||||
let progress_handler = progress!("Logging in");
|
let progress_handler = progress!("Logging in");
|
||||||
if login_methods_count == 0 {
|
if login_methods_count == 0 {
|
||||||
if let Some(login_file_path) = cli::login::login_file_path() {
|
if let Some(login_file_path) = login::login_file_path() {
|
||||||
if login_file_path.exists() {
|
if login_file_path.exists() {
|
||||||
let session = fs::read_to_string(login_file_path)?;
|
let session = fs::read_to_string(login_file_path)?;
|
||||||
if let Some((token_type, token)) = session.split_once(':') {
|
if let Some((token_type, token)) = session.split_once(':') {
|
||||||
|
|
|
||||||
4
crunchy-cli-core/src/login/mod.rs
Normal file
4
crunchy-cli-core/src/login/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
mod command;
|
||||||
|
|
||||||
|
pub use command::login_file_path;
|
||||||
|
pub use command::Login;
|
||||||
662
crunchy-cli-core/src/utils/download.rs
Normal file
662
crunchy-cli-core/src/utils/download.rs
Normal file
|
|
@ -0,0 +1,662 @@
|
||||||
|
use crate::utils::context::Context;
|
||||||
|
use crate::utils::ffmpeg::FFmpegPreset;
|
||||||
|
use crate::utils::log::progress;
|
||||||
|
use crate::utils::os::tempfile;
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use chrono::NaiveTime;
|
||||||
|
use crunchyroll_rs::media::{Subtitle, VariantData, VariantSegment};
|
||||||
|
use crunchyroll_rs::Locale;
|
||||||
|
use indicatif::{ProgressBar, ProgressFinish, ProgressStyle};
|
||||||
|
use log::{debug, LevelFilter};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::borrow::Borrow;
|
||||||
|
use std::borrow::BorrowMut;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tempfile::TempPath;
|
||||||
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum MergeBehavior {
|
||||||
|
Video,
|
||||||
|
Audio,
|
||||||
|
Auto,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MergeBehavior {
|
||||||
|
pub fn parse(s: &str) -> Result<MergeBehavior, String> {
|
||||||
|
Ok(match s.to_lowercase().as_str() {
|
||||||
|
"video" => MergeBehavior::Video,
|
||||||
|
"audio" => MergeBehavior::Audio,
|
||||||
|
"auto" => MergeBehavior::Auto,
|
||||||
|
_ => return Err(format!("'{}' is not a valid merge behavior", s)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(derive_setters::Setters)]
|
||||||
|
pub struct DownloadBuilder {
|
||||||
|
ffmpeg_preset: FFmpegPreset,
|
||||||
|
default_subtitle: Option<Locale>,
|
||||||
|
output_format: Option<String>,
|
||||||
|
audio_sort: Option<Vec<Locale>>,
|
||||||
|
subtitle_sort: Option<Vec<Locale>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadBuilder {
|
||||||
|
pub fn new() -> DownloadBuilder {
|
||||||
|
Self {
|
||||||
|
ffmpeg_preset: FFmpegPreset::default(),
|
||||||
|
default_subtitle: None,
|
||||||
|
output_format: None,
|
||||||
|
audio_sort: None,
|
||||||
|
subtitle_sort: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> Downloader {
|
||||||
|
Downloader {
|
||||||
|
ffmpeg_preset: self.ffmpeg_preset,
|
||||||
|
default_subtitle: self.default_subtitle,
|
||||||
|
output_format: self.output_format,
|
||||||
|
audio_sort: self.audio_sort,
|
||||||
|
subtitle_sort: self.subtitle_sort,
|
||||||
|
|
||||||
|
formats: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FFmpegMeta {
|
||||||
|
path: TempPath,
|
||||||
|
language: Locale,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DownloadFormat {
|
||||||
|
pub video: (VariantData, Locale),
|
||||||
|
pub audios: Vec<(VariantData, Locale)>,
|
||||||
|
pub subtitles: Vec<Subtitle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Downloader {
|
||||||
|
ffmpeg_preset: FFmpegPreset,
|
||||||
|
default_subtitle: Option<Locale>,
|
||||||
|
output_format: Option<String>,
|
||||||
|
audio_sort: Option<Vec<Locale>>,
|
||||||
|
subtitle_sort: Option<Vec<Locale>>,
|
||||||
|
|
||||||
|
formats: Vec<DownloadFormat>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downloader {
|
||||||
|
pub fn add_format(&mut self, format: DownloadFormat) {
|
||||||
|
self.formats.push(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download(mut self, ctx: &Context, dst: &Path) -> Result<()> {
|
||||||
|
if let Some(audio_sort_locales) = &self.audio_sort {
|
||||||
|
self.formats.sort_by(|a, b| {
|
||||||
|
audio_sort_locales
|
||||||
|
.iter()
|
||||||
|
.position(|l| l == &a.video.1)
|
||||||
|
.cmp(&audio_sort_locales.iter().position(|l| l == &b.video.1))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for format in self.formats.iter_mut() {
|
||||||
|
if let Some(audio_sort_locales) = &self.audio_sort {
|
||||||
|
format.audios.sort_by(|(_, a), (_, b)| {
|
||||||
|
audio_sort_locales
|
||||||
|
.iter()
|
||||||
|
.position(|l| l == a)
|
||||||
|
.cmp(&audio_sort_locales.iter().position(|l| l == b))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if let Some(subtitle_sort) = &self.subtitle_sort {
|
||||||
|
format.subtitles.sort_by(|a, b| {
|
||||||
|
subtitle_sort
|
||||||
|
.iter()
|
||||||
|
.position(|l| l == &a.locale)
|
||||||
|
.cmp(&subtitle_sort.iter().position(|l| l == &b.locale))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut videos = vec![];
|
||||||
|
let mut audios = vec![];
|
||||||
|
let mut subtitles = vec![];
|
||||||
|
|
||||||
|
for (i, format) in self.formats.iter().enumerate() {
|
||||||
|
let fmt_space = format
|
||||||
|
.audios
|
||||||
|
.iter()
|
||||||
|
.map(|(_, locale)| format!("Downloading {} audio", locale).len())
|
||||||
|
.max()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let video_path = self
|
||||||
|
.download_video(
|
||||||
|
ctx,
|
||||||
|
&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(
|
||||||
|
ctx,
|
||||||
|
variant_data,
|
||||||
|
format!("{:<1$}", format!("Downloading {} audio", locale), fmt_space),
|
||||||
|
)
|
||||||
|
.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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let len = get_video_length(&video_path)?;
|
||||||
|
for subtitle in format.subtitles.iter() {
|
||||||
|
let subtitle_path = self.download_subtitle(subtitle.clone(), len).await?;
|
||||||
|
subtitles.push(FFmpegMeta {
|
||||||
|
path: subtitle_path,
|
||||||
|
language: subtitle.locale.clone(),
|
||||||
|
title: if i == 0 {
|
||||||
|
subtitle.locale.to_human_readable()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} [Video: #{}]",
|
||||||
|
subtitle.locale.to_human_readable(),
|
||||||
|
i + 1
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
videos.push(FFmpegMeta {
|
||||||
|
path: video_path,
|
||||||
|
language: format.video.1.clone(),
|
||||||
|
title: if self.formats.len() == 1 {
|
||||||
|
"Default".to_string()
|
||||||
|
} else {
|
||||||
|
format!("#{}", i + 1)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut input = vec![];
|
||||||
|
let mut maps = vec![];
|
||||||
|
let mut metadata = vec![];
|
||||||
|
|
||||||
|
for (i, meta) in videos.iter().enumerate() {
|
||||||
|
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),
|
||||||
|
]);
|
||||||
|
// the empty language metadata is created to avoid that metadata from the original track
|
||||||
|
// is copied
|
||||||
|
metadata.extend([format!("-metadata:s:v:{}", i), format!("language=")])
|
||||||
|
}
|
||||||
|
for (i, meta) in audios.iter().enumerate() {
|
||||||
|
input.extend(["-i".to_string(), meta.path.to_string_lossy().to_string()]);
|
||||||
|
maps.extend(["-map".to_string(), (i + videos.len()).to_string()]);
|
||||||
|
metadata.extend([
|
||||||
|
format!("-metadata:s:a:{}", i),
|
||||||
|
format!("language={}", meta.language),
|
||||||
|
]);
|
||||||
|
metadata.extend([
|
||||||
|
format!("-metadata:s:a:{}", i),
|
||||||
|
format!("title={}", meta.title),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (i, meta) in subtitles.iter().enumerate() {
|
||||||
|
input.extend(["-i".to_string(), meta.path.to_string_lossy().to_string()]);
|
||||||
|
maps.extend([
|
||||||
|
"-map".to_string(),
|
||||||
|
(i + videos.len() + audios.len()).to_string(),
|
||||||
|
]);
|
||||||
|
metadata.extend([
|
||||||
|
format!("-metadata:s:s:{}", i),
|
||||||
|
format!("language={}", meta.language),
|
||||||
|
]);
|
||||||
|
metadata.extend([
|
||||||
|
format!("-metadata:s:s:{}", i),
|
||||||
|
format!("title={}", meta.title),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (input_presets, mut output_presets) = self.ffmpeg_preset.into_input_output_args();
|
||||||
|
|
||||||
|
let mut command_args = vec!["-y".to_string(), "-hide_banner".to_string()];
|
||||||
|
command_args.extend(input_presets);
|
||||||
|
command_args.extend(input);
|
||||||
|
command_args.extend(maps);
|
||||||
|
command_args.extend(metadata);
|
||||||
|
|
||||||
|
// set default subtitle
|
||||||
|
if let Some(default_subtitle) = self.default_subtitle {
|
||||||
|
if let Some(position) = subtitles
|
||||||
|
.iter()
|
||||||
|
.position(|m| m.language == default_subtitle)
|
||||||
|
{
|
||||||
|
match dst.extension().unwrap_or_default().to_str().unwrap() {
|
||||||
|
"mp4" => output_presets.extend([
|
||||||
|
"-movflags".to_string(),
|
||||||
|
"faststart".to_string(),
|
||||||
|
"-c:s".to_string(),
|
||||||
|
"mov_text".to_string(),
|
||||||
|
format!("-disposition:s:s:{}", position),
|
||||||
|
"forced".to_string(),
|
||||||
|
]),
|
||||||
|
"mkv" => output_presets.extend([
|
||||||
|
format!("-disposition:s:s:{}", position),
|
||||||
|
"forced".to_string(),
|
||||||
|
]),
|
||||||
|
_ => {
|
||||||
|
// remove '-c:v copy' and '-c:a copy' from output presets as its causes issues with
|
||||||
|
// burning subs into the video
|
||||||
|
let mut last = String::new();
|
||||||
|
let mut remove_count = 0;
|
||||||
|
for (i, s) in output_presets.clone().iter().enumerate() {
|
||||||
|
if (last == "-c:v" || last == "-c:a") && s == "copy" {
|
||||||
|
// remove last
|
||||||
|
output_presets.remove(i - remove_count - 1);
|
||||||
|
remove_count += 1;
|
||||||
|
output_presets.remove(i - remove_count);
|
||||||
|
remove_count += 1;
|
||||||
|
}
|
||||||
|
last = s.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
output_presets.extend([
|
||||||
|
"-vf".to_string(),
|
||||||
|
format!(
|
||||||
|
"subtitles={}",
|
||||||
|
subtitles.get(position).unwrap().path.to_str().unwrap()
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(position) = subtitles
|
||||||
|
.iter()
|
||||||
|
.position(|meta| meta.language == default_subtitle)
|
||||||
|
{
|
||||||
|
command_args.extend([
|
||||||
|
format!("-disposition:s:s:{}", position),
|
||||||
|
"forced".to_string(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
command_args.extend(output_presets);
|
||||||
|
if let Some(output_format) = self.output_format {
|
||||||
|
command_args.extend(["-f".to_string(), output_format]);
|
||||||
|
}
|
||||||
|
command_args.push(dst.to_str().unwrap().to_string());
|
||||||
|
|
||||||
|
debug!("ffmpeg {}", command_args.join(" "));
|
||||||
|
|
||||||
|
// create parent directory if it does not exist
|
||||||
|
if let Some(parent) = dst.parent() {
|
||||||
|
if !parent.exists() {
|
||||||
|
std::fs::create_dir_all(parent)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let progress_handler = progress!("Generating output file");
|
||||||
|
|
||||||
|
let ffmpeg = Command::new("ffmpeg")
|
||||||
|
// pass ffmpeg stdout to real stdout only if output file is stdout
|
||||||
|
.stdout(if dst.to_str().unwrap() == "-" {
|
||||||
|
Stdio::inherit()
|
||||||
|
} else {
|
||||||
|
Stdio::null()
|
||||||
|
})
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.args(command_args)
|
||||||
|
.output()?;
|
||||||
|
if !ffmpeg.status.success() {
|
||||||
|
bail!("{}", String::from_utf8_lossy(ffmpeg.stderr.as_slice()))
|
||||||
|
}
|
||||||
|
|
||||||
|
progress_handler.stop("Output file generated");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_video(
|
||||||
|
&self,
|
||||||
|
ctx: &Context,
|
||||||
|
variant_data: &VariantData,
|
||||||
|
message: String,
|
||||||
|
) -> Result<TempPath> {
|
||||||
|
let tempfile = tempfile(".mp4")?;
|
||||||
|
let (mut file, path) = tempfile.into_parts();
|
||||||
|
|
||||||
|
download_segments(ctx, &mut file, Some(message), variant_data).await?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_audio(
|
||||||
|
&self,
|
||||||
|
ctx: &Context,
|
||||||
|
variant_data: &VariantData,
|
||||||
|
message: String,
|
||||||
|
) -> Result<TempPath> {
|
||||||
|
let tempfile = tempfile(".m4a")?;
|
||||||
|
let (mut file, path) = tempfile.into_parts();
|
||||||
|
|
||||||
|
download_segments(ctx, &mut file, Some(message), variant_data).await?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_subtitle(
|
||||||
|
&self,
|
||||||
|
subtitle: Subtitle,
|
||||||
|
max_length: NaiveTime,
|
||||||
|
) -> Result<TempPath> {
|
||||||
|
let tempfile = tempfile(".ass")?;
|
||||||
|
let (mut file, path) = tempfile.into_parts();
|
||||||
|
|
||||||
|
let mut buf = vec![];
|
||||||
|
subtitle.write_to(&mut buf).await?;
|
||||||
|
fix_subtitle_look_and_feel(&mut buf);
|
||||||
|
fix_subtitle_length(&mut buf, max_length);
|
||||||
|
|
||||||
|
file.write_all(buf.as_slice())?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_segments(
|
||||||
|
ctx: &Context,
|
||||||
|
writer: &mut impl Write,
|
||||||
|
message: Option<String>,
|
||||||
|
variant_data: &VariantData,
|
||||||
|
) -> Result<()> {
|
||||||
|
let segments = variant_data.segments().await?;
|
||||||
|
let total_segments = segments.len();
|
||||||
|
|
||||||
|
let client = Arc::new(ctx.crunchy.client());
|
||||||
|
let count = Arc::new(Mutex::new(0));
|
||||||
|
|
||||||
|
let progress = if log::max_level() == LevelFilter::Info {
|
||||||
|
let estimated_file_size =
|
||||||
|
(variant_data.bandwidth / 8) * segments.iter().map(|s| s.length.as_secs()).sum::<u64>();
|
||||||
|
|
||||||
|
let progress = ProgressBar::new(estimated_file_size)
|
||||||
|
.with_style(
|
||||||
|
ProgressStyle::with_template(
|
||||||
|
":: {msg}{bytes:>10} {bytes_per_sec:>12} [{wide_bar}] {percent:>3}%",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.progress_chars("##-"),
|
||||||
|
)
|
||||||
|
.with_message(message.map(|m| m + " ").unwrap_or_default())
|
||||||
|
.with_finish(ProgressFinish::Abandon);
|
||||||
|
Some(progress)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let cpus = num_cpus::get();
|
||||||
|
let mut segs: Vec<Vec<VariantSegment>> = Vec::with_capacity(cpus);
|
||||||
|
for _ in 0..cpus {
|
||||||
|
segs.push(vec![])
|
||||||
|
}
|
||||||
|
for (i, segment) in segments.clone().into_iter().enumerate() {
|
||||||
|
segs[i - ((i / cpus) * cpus)].push(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
|
||||||
|
let mut join_set: JoinSet<Result<()>> = JoinSet::new();
|
||||||
|
for num in 0..cpus {
|
||||||
|
let thread_client = client.clone();
|
||||||
|
let thread_sender = sender.clone();
|
||||||
|
let thread_segments = segs.remove(0);
|
||||||
|
let thread_count = count.clone();
|
||||||
|
join_set.spawn(async move {
|
||||||
|
let after_download_sender = thread_sender.clone();
|
||||||
|
|
||||||
|
// the download process is encapsulated in its own function. this is done to easily
|
||||||
|
// catch errors which get returned with `...?` and `bail!(...)` and that the thread
|
||||||
|
// itself can report that an error has occurred
|
||||||
|
let download = || async move {
|
||||||
|
for (i, segment) in thread_segments.into_iter().enumerate() {
|
||||||
|
let mut retry_count = 0;
|
||||||
|
let mut buf = loop {
|
||||||
|
let response = thread_client
|
||||||
|
.get(&segment.url)
|
||||||
|
.timeout(Duration::from_secs(60))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match response.bytes().await {
|
||||||
|
Ok(b) => break b.to_vec(),
|
||||||
|
Err(e) => {
|
||||||
|
if e.is_body() {
|
||||||
|
if retry_count == 5 {
|
||||||
|
bail!("Max retry count reached ({}), multiple errors occurred while receiving segment {}: {}", retry_count, num + (i * cpus), e)
|
||||||
|
}
|
||||||
|
debug!("Failed to download segment {} ({}). Retrying, {} out of 5 retries left", num + (i * cpus), e, 5 - retry_count)
|
||||||
|
} else {
|
||||||
|
bail!("{}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
retry_count += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
buf = VariantSegment::decrypt(buf.borrow_mut(), segment.key)?.to_vec();
|
||||||
|
|
||||||
|
let mut c = thread_count.lock().unwrap();
|
||||||
|
debug!(
|
||||||
|
"Downloaded and decrypted segment [{}/{} {:.2}%] {}",
|
||||||
|
num + (i * cpus) + 1,
|
||||||
|
total_segments,
|
||||||
|
((*c + 1) as f64 / total_segments as f64) * 100f64,
|
||||||
|
segment.url
|
||||||
|
);
|
||||||
|
|
||||||
|
thread_sender.send((num as i32 + (i * cpus) as i32, buf))?;
|
||||||
|
|
||||||
|
*c += 1;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let result = download().await;
|
||||||
|
if result.is_err() {
|
||||||
|
after_download_sender.send((-1 as i32, vec![]))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// drop the sender already here so it does not outlive all download threads which are the only
|
||||||
|
// real consumers of it
|
||||||
|
drop(sender);
|
||||||
|
|
||||||
|
// this is the main loop which writes the data. it uses a BTreeMap as a buffer as the write
|
||||||
|
// happens synchronized. the download consist of multiple segments. the map keys are representing
|
||||||
|
// the segment number and the values the corresponding bytes
|
||||||
|
let mut data_pos = 0;
|
||||||
|
let mut buf: BTreeMap<i32, Vec<u8>> = BTreeMap::new();
|
||||||
|
for (pos, bytes) in receiver.iter() {
|
||||||
|
// if the position is lower than 0, an error occurred in the sending download thread
|
||||||
|
if pos < 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(p) = &progress {
|
||||||
|
let progress_len = p.length().unwrap();
|
||||||
|
let estimated_segment_len =
|
||||||
|
(variant_data.bandwidth / 8) * segments.get(pos as usize).unwrap().length.as_secs();
|
||||||
|
let bytes_len = bytes.len() as u64;
|
||||||
|
|
||||||
|
p.set_length(progress_len - estimated_segment_len + bytes_len);
|
||||||
|
p.inc(bytes_len)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the currently sent bytes are the next in the buffer. if so, write them directly
|
||||||
|
// to the target without first adding them to the buffer.
|
||||||
|
// if not, add them to the buffer
|
||||||
|
if data_pos == pos {
|
||||||
|
writer.write_all(bytes.borrow())?;
|
||||||
|
data_pos += 1;
|
||||||
|
} else {
|
||||||
|
buf.insert(pos, bytes);
|
||||||
|
}
|
||||||
|
// check if the buffer contains the next segment(s)
|
||||||
|
while let Some(b) = buf.remove(&data_pos) {
|
||||||
|
writer.write_all(b.borrow())?;
|
||||||
|
data_pos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if any error has occurred while downloading it gets returned here
|
||||||
|
while let Some(joined) = join_set.join_next().await {
|
||||||
|
joined??
|
||||||
|
}
|
||||||
|
|
||||||
|
// write the remaining buffer, if existent
|
||||||
|
while let Some(b) = buf.remove(&data_pos) {
|
||||||
|
writer.write_all(b.borrow())?;
|
||||||
|
data_pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !buf.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"Download buffer is not empty. Remaining segments: {}",
|
||||||
|
buf.into_keys()
|
||||||
|
.map(|k| k.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_look_and_feel(raw: &mut Vec<u8>) {
|
||||||
|
let mut script_info = false;
|
||||||
|
let mut new = String::new();
|
||||||
|
|
||||||
|
for line in String::from_utf8_lossy(raw.as_slice()).split('\n') {
|
||||||
|
if line.trim().starts_with('[') && script_info {
|
||||||
|
new.push_str("ScaledBorderAndShadow: yes\n");
|
||||||
|
script_info = false
|
||||||
|
} else if line.trim() == "[Script Info]" {
|
||||||
|
script_info = true
|
||||||
|
}
|
||||||
|
new.push_str(line);
|
||||||
|
new.push('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
*raw = new.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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. See
|
||||||
|
/// [crunchy-labs/crunchy-cli#32](https://github.com/crunchy-labs/crunchy-cli/issues/32) for more
|
||||||
|
/// information.
|
||||||
|
pub fn get_video_length(path: &Path) -> Result<NaiveTime> {
|
||||||
|
let video_length = Regex::new(r"Duration:\s(?P<time>\d+:\d+:\d+\.\d+),")?;
|
||||||
|
|
||||||
|
let ffmpeg = Command::new("ffmpeg")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.arg("-y")
|
||||||
|
.arg("-hide_banner")
|
||||||
|
.args(["-i", path.to_str().unwrap()])
|
||||||
|
.output()?;
|
||||||
|
let ffmpeg_output = String::from_utf8(ffmpeg.stderr)?;
|
||||||
|
let caps = video_length.captures(ffmpeg_output.as_str()).unwrap();
|
||||||
|
|
||||||
|
Ok(NaiveTime::parse_from_str(caps.name("time").unwrap().as_str(), "%H:%M:%S%.f").unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: &mut Vec<u8>, max_length: NaiveTime) {
|
||||||
|
let re =
|
||||||
|
Regex::new(r#"^Dialogue:\s\d+,(?P<start>\d+:\d+:\d+\.\d+),(?P<end>\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')
|
||||||
|
}
|
||||||
|
|
||||||
|
*raw = new.into_bytes()
|
||||||
|
}
|
||||||
358
crunchy-cli-core/src/utils/ffmpeg.rs
Normal file
358
crunchy-cli-core/src/utils/ffmpeg.rs
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use regex::Regex;
|
||||||
|
use std::env;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum FFmpegPreset {
|
||||||
|
Predefined(FFmpegCodec, Option<FFmpegHwAccel>, FFmpegQuality),
|
||||||
|
Custom(Option<String>, Option<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref PREDEFINED_PRESET: Regex = Regex::new(r"^\w+(-\w+)*?$").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! ffmpeg_enum {
|
||||||
|
(enum $name:ident { $($field:ident),* }) => {
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
|
||||||
|
pub enum $name {
|
||||||
|
$(
|
||||||
|
$field
|
||||||
|
),*,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
$(
|
||||||
|
stringify!($field) => Ok($name::$field)
|
||||||
|
),*,
|
||||||
|
_ => anyhow::bail!("{} is not a valid {}", s, stringify!($name).to_lowercase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg_enum! {
|
||||||
|
enum FFmpegCodec {
|
||||||
|
H264,
|
||||||
|
H265,
|
||||||
|
Av1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg_enum! {
|
||||||
|
enum FFmpegHwAccel {
|
||||||
|
Nvidia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg_enum! {
|
||||||
|
enum FFmpegQuality {
|
||||||
|
Lossless,
|
||||||
|
Normal,
|
||||||
|
Low
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FFmpegPreset {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Custom(None, Some("-c:v copy -c:a copy".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FFmpegPreset {
|
||||||
|
pub(crate) fn available_matches(
|
||||||
|
) -> Vec<(FFmpegCodec, Option<FFmpegHwAccel>, Option<FFmpegQuality>)> {
|
||||||
|
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 available_matches_human_readable() -> Vec<String> {
|
||||||
|
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::<Vec<String>>()
|
||||||
|
.join("-"),
|
||||||
|
description
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return_values
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse(s: &str) -> Result<FFmpegPreset, String> {
|
||||||
|
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 find 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 find 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<FFmpegCodec> = None;
|
||||||
|
let mut hwaccel: Option<FFmpegHwAccel> = None;
|
||||||
|
let mut quality: Option<FFmpegQuality> = 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 into_input_output_args(self) -> (Vec<String>, Vec<String>) {
|
||||||
|
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![];
|
||||||
|
|
||||||
|
match codec {
|
||||||
|
FFmpegCodec::H264 => {
|
||||||
|
if let Some(hwaccel) = hwaccel_opt {
|
||||||
|
match hwaccel {
|
||||||
|
FFmpegHwAccel::Nvidia => {
|
||||||
|
input.extend([
|
||||||
|
"-hwaccel",
|
||||||
|
"cuda",
|
||||||
|
"-hwaccel_output_format",
|
||||||
|
"cuda",
|
||||||
|
"-c:v",
|
||||||
|
"h264_cuvid",
|
||||||
|
]);
|
||||||
|
output.extend(["-c:v", "h264_nvenc", "-c:a", "copy"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output.extend(["-c:v", "libx264", "-c:a", "copy"])
|
||||||
|
}
|
||||||
|
|
||||||
|
match quality {
|
||||||
|
FFmpegQuality::Lossless => output.extend(["-crf", "18"]),
|
||||||
|
FFmpegQuality::Normal => (),
|
||||||
|
FFmpegQuality::Low => output.extend(["-crf", "35"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FFmpegCodec::H265 => {
|
||||||
|
if let Some(hwaccel) = hwaccel_opt {
|
||||||
|
match hwaccel {
|
||||||
|
FFmpegHwAccel::Nvidia => {
|
||||||
|
input.extend([
|
||||||
|
"-hwaccel",
|
||||||
|
"cuda",
|
||||||
|
"-hwaccel_output_format",
|
||||||
|
"cuda",
|
||||||
|
"-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"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
input
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect::<Vec<String>>(),
|
||||||
|
output
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect::<Vec<String>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
crunchy-cli-core/src/utils/filter.rs
Normal file
95
crunchy-cli-core/src/utils/filter.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use crunchyroll_rs::{
|
||||||
|
Concert, Episode, MediaCollection, Movie, MovieListing, MusicVideo, Season, Series,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check when https://github.com/dtolnay/async-trait/issues/224 is resolved and update async-trait
|
||||||
|
// to the new fixed version (as this causes some issues)
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait Filter {
|
||||||
|
type T: Send + Sized;
|
||||||
|
type Output: Send + Sized;
|
||||||
|
|
||||||
|
async fn visit_series(&mut self, series: Series) -> Result<Vec<Season>>;
|
||||||
|
async fn visit_season(&mut self, season: Season) -> Result<Vec<Episode>>;
|
||||||
|
async fn visit_episode(&mut self, episode: Episode) -> Result<Option<Self::T>>;
|
||||||
|
async fn visit_movie_listing(&mut self, movie_listing: MovieListing) -> Result<Vec<Movie>>;
|
||||||
|
async fn visit_movie(&mut self, movie: Movie) -> Result<Option<Self::T>>;
|
||||||
|
async fn visit_music_video(&mut self, music_video: MusicVideo) -> Result<Option<Self::T>>;
|
||||||
|
async fn visit_concert(&mut self, concert: Concert) -> Result<Option<Self::T>>;
|
||||||
|
|
||||||
|
async fn visit(mut self, media_collection: MediaCollection) -> Result<Vec<Self::Output>>
|
||||||
|
where
|
||||||
|
Self: Send + Sized,
|
||||||
|
{
|
||||||
|
let mut items = vec![media_collection];
|
||||||
|
let mut result = vec![];
|
||||||
|
|
||||||
|
while !items.is_empty() {
|
||||||
|
let mut new_items: Vec<MediaCollection> = vec![];
|
||||||
|
|
||||||
|
for i in items {
|
||||||
|
match i {
|
||||||
|
MediaCollection::Series(series) => new_items.extend(
|
||||||
|
self.visit_series(series)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| s.into())
|
||||||
|
.collect::<Vec<MediaCollection>>(),
|
||||||
|
),
|
||||||
|
MediaCollection::Season(season) => new_items.extend(
|
||||||
|
self.visit_season(season)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| s.into())
|
||||||
|
.collect::<Vec<MediaCollection>>(),
|
||||||
|
),
|
||||||
|
MediaCollection::Episode(episode) => {
|
||||||
|
if let Some(t) = self.visit_episode(episode).await? {
|
||||||
|
result.push(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MediaCollection::MovieListing(movie_listing) => new_items.extend(
|
||||||
|
self.visit_movie_listing(movie_listing)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| m.into())
|
||||||
|
.collect::<Vec<MediaCollection>>(),
|
||||||
|
),
|
||||||
|
MediaCollection::Movie(movie) => {
|
||||||
|
if let Some(t) = self.visit_movie(movie).await? {
|
||||||
|
result.push(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MediaCollection::MusicVideo(music_video) => {
|
||||||
|
if let Some(t) = self.visit_music_video(music_video).await? {
|
||||||
|
result.push(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MediaCollection::Concert(concert) => {
|
||||||
|
if let Some(t) = self.visit_concert(concert).await? {
|
||||||
|
result.push(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items = new_items
|
||||||
|
}
|
||||||
|
|
||||||
|
self.finish(result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish(self, input: Vec<Self::T>) -> Result<Vec<Self::Output>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all duplicates from a [`Vec`].
|
||||||
|
pub fn real_dedup_vec<T: Clone + Eq>(input: &mut Vec<T>) {
|
||||||
|
let mut dedup = vec![];
|
||||||
|
for item in input.clone() {
|
||||||
|
if !dedup.contains(&item) {
|
||||||
|
dedup.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*input = dedup
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
use crunchyroll_rs::media::{StreamSubtitle, VariantData};
|
use crate::utils::filter::real_dedup_vec;
|
||||||
use crunchyroll_rs::{Episode, Locale, Media, Movie};
|
use crate::utils::log::tab_info;
|
||||||
use log::warn;
|
use crate::utils::os::is_special_file;
|
||||||
use std::path::PathBuf;
|
use crunchyroll_rs::media::{Resolution, VariantData};
|
||||||
use std::time::Duration;
|
use crunchyroll_rs::{Concert, Episode, Locale, Movie, MusicVideo};
|
||||||
|
use log::{debug, info};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Format {
|
pub struct SingleFormat {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
|
||||||
pub audio: Locale,
|
pub audio: Locale,
|
||||||
pub subtitles: Vec<StreamSubtitle>,
|
pub subtitles: Vec<Locale>,
|
||||||
|
|
||||||
pub duration: Duration,
|
pub resolution: Resolution,
|
||||||
pub stream: VariantData,
|
pub fps: f64,
|
||||||
|
|
||||||
pub series_id: String,
|
pub series_id: String,
|
||||||
pub series_name: String,
|
pub series_name: String,
|
||||||
|
|
@ -23,76 +26,148 @@ pub struct Format {
|
||||||
pub season_number: u32,
|
pub season_number: u32,
|
||||||
|
|
||||||
pub episode_id: String,
|
pub episode_id: String,
|
||||||
pub episode_number: f32,
|
pub episode_number: String,
|
||||||
pub relative_episode_number: f32,
|
pub sequence_number: f32,
|
||||||
|
pub relative_episode_number: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Format {
|
impl SingleFormat {
|
||||||
pub fn new_from_episode(
|
pub fn new_from_episode(
|
||||||
episode: &Media<Episode>,
|
episode: &Episode,
|
||||||
season_episodes: &Vec<Media<Episode>>,
|
video: &VariantData,
|
||||||
stream: VariantData,
|
subtitles: Vec<Locale>,
|
||||||
subtitles: Vec<StreamSubtitle>,
|
relative_episode_number: Option<u32>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
title: episode.title.clone(),
|
title: episode.title.clone(),
|
||||||
description: episode.description.clone(),
|
description: episode.description.clone(),
|
||||||
|
audio: episode.audio_locale.clone(),
|
||||||
audio: episode.metadata.audio_locale.clone(),
|
|
||||||
subtitles,
|
subtitles,
|
||||||
|
resolution: video.resolution.clone(),
|
||||||
duration: episode.metadata.duration.to_std().unwrap(),
|
fps: video.fps,
|
||||||
stream,
|
series_id: episode.series_id.clone(),
|
||||||
|
series_name: episode.series_title.clone(),
|
||||||
series_id: episode.metadata.series_id.clone(),
|
season_id: episode.season_id.clone(),
|
||||||
series_name: episode.metadata.series_title.clone(),
|
season_title: episode.season_title.to_string(),
|
||||||
|
season_number: episode.season_number,
|
||||||
season_id: episode.metadata.season_id.clone(),
|
|
||||||
season_title: episode.metadata.season_title.clone(),
|
|
||||||
season_number: episode.metadata.season_number.clone(),
|
|
||||||
|
|
||||||
episode_id: episode.id.clone(),
|
episode_id: episode.id.clone(),
|
||||||
episode_number: episode
|
episode_number: if episode.episode.is_empty() {
|
||||||
.metadata
|
episode.sequence_number.to_string()
|
||||||
.episode
|
} else {
|
||||||
.parse()
|
episode.episode.clone()
|
||||||
.unwrap_or(episode.metadata.sequence_number),
|
},
|
||||||
relative_episode_number: season_episodes
|
sequence_number: episode.sequence_number,
|
||||||
.iter()
|
relative_episode_number,
|
||||||
.enumerate()
|
|
||||||
.find_map(|(i, e)| if e == episode { Some((i + 1) as f32) } else { None })
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
warn!("Cannot find relative episode number for episode {} ({}) of season {} ({}) of {}, using normal episode number", episode.metadata.episode_number, episode.title, episode.metadata.season_number, episode.metadata.season_title, episode.metadata.series_title);
|
|
||||||
episode
|
|
||||||
.metadata
|
|
||||||
.episode
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(episode.metadata.sequence_number)
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_from_movie(movie: &Media<Movie>, stream: VariantData) -> Self {
|
pub fn new_from_movie(movie: &Movie, video: &VariantData, subtitles: Vec<Locale>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
title: movie.title.clone(),
|
title: movie.title.clone(),
|
||||||
description: movie.description.clone(),
|
description: movie.description.clone(),
|
||||||
|
|
||||||
audio: Locale::ja_JP,
|
audio: Locale::ja_JP,
|
||||||
|
subtitles,
|
||||||
duration: movie.metadata.duration.to_std().unwrap(),
|
resolution: video.resolution.clone(),
|
||||||
stream,
|
fps: video.fps,
|
||||||
subtitles: vec![],
|
series_id: movie.movie_listing_id.clone(),
|
||||||
|
series_name: movie.movie_listing_title.clone(),
|
||||||
series_id: movie.metadata.movie_listing_id.clone(),
|
season_id: movie.movie_listing_id.clone(),
|
||||||
series_name: movie.metadata.movie_listing_title.clone(),
|
season_title: movie.movie_listing_title.to_string(),
|
||||||
|
|
||||||
season_id: movie.metadata.movie_listing_id.clone(),
|
|
||||||
season_title: movie.metadata.movie_listing_title.clone(),
|
|
||||||
season_number: 1,
|
season_number: 1,
|
||||||
|
|
||||||
episode_id: movie.id.clone(),
|
episode_id: movie.id.clone(),
|
||||||
episode_number: 1.0,
|
episode_number: "1".to_string(),
|
||||||
relative_episode_number: 1.0,
|
sequence_number: 1.0,
|
||||||
|
relative_episode_number: Some(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_from_music_video(music_video: &MusicVideo, video: &VariantData) -> Self {
|
||||||
|
Self {
|
||||||
|
title: music_video.title.clone(),
|
||||||
|
description: music_video.description.clone(),
|
||||||
|
audio: Locale::ja_JP,
|
||||||
|
subtitles: vec![],
|
||||||
|
resolution: video.resolution.clone(),
|
||||||
|
fps: video.fps,
|
||||||
|
series_id: music_video.id.clone(),
|
||||||
|
series_name: music_video.title.clone(),
|
||||||
|
season_id: music_video.id.clone(),
|
||||||
|
season_title: music_video.title.clone(),
|
||||||
|
season_number: 1,
|
||||||
|
episode_id: music_video.id.clone(),
|
||||||
|
episode_number: "1".to_string(),
|
||||||
|
sequence_number: 1.0,
|
||||||
|
relative_episode_number: Some(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_from_concert(concert: &Concert, video: &VariantData) -> Self {
|
||||||
|
Self {
|
||||||
|
title: concert.title.clone(),
|
||||||
|
description: concert.description.clone(),
|
||||||
|
audio: Locale::ja_JP,
|
||||||
|
subtitles: vec![],
|
||||||
|
resolution: video.resolution.clone(),
|
||||||
|
fps: video.fps,
|
||||||
|
series_id: concert.id.clone(),
|
||||||
|
series_name: concert.title.clone(),
|
||||||
|
season_id: concert.id.clone(),
|
||||||
|
season_title: concert.title.clone(),
|
||||||
|
season_number: 1,
|
||||||
|
episode_id: concert.id.clone(),
|
||||||
|
episode_number: "1".to_string(),
|
||||||
|
sequence_number: 1.0,
|
||||||
|
relative_episode_number: Some(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Format {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
|
||||||
|
pub locales: Vec<(Locale, Vec<Locale>)>,
|
||||||
|
|
||||||
|
pub resolution: Resolution,
|
||||||
|
pub fps: f64,
|
||||||
|
|
||||||
|
pub series_id: String,
|
||||||
|
pub series_name: String,
|
||||||
|
|
||||||
|
pub season_id: String,
|
||||||
|
pub season_title: String,
|
||||||
|
pub season_number: u32,
|
||||||
|
|
||||||
|
pub episode_id: String,
|
||||||
|
pub episode_number: String,
|
||||||
|
pub sequence_number: f32,
|
||||||
|
pub relative_episode_number: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Format {
|
||||||
|
pub fn from_single_formats(mut single_formats: Vec<SingleFormat>) -> Self {
|
||||||
|
let locales: Vec<(Locale, Vec<Locale>)> = single_formats
|
||||||
|
.iter()
|
||||||
|
.map(|sf| (sf.audio.clone(), sf.subtitles.clone()))
|
||||||
|
.collect();
|
||||||
|
let first = single_formats.remove(0);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
title: first.title,
|
||||||
|
description: first.description,
|
||||||
|
locales,
|
||||||
|
resolution: first.resolution,
|
||||||
|
fps: first.fps,
|
||||||
|
series_id: first.series_id,
|
||||||
|
series_name: first.series_name,
|
||||||
|
season_id: first.season_id,
|
||||||
|
season_title: first.season_title,
|
||||||
|
season_number: first.season_number,
|
||||||
|
episode_id: first.episode_id,
|
||||||
|
episode_number: first.episode_number,
|
||||||
|
sequence_number: first.sequence_number,
|
||||||
|
relative_episode_number: first.relative_episode_number,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,11 +186,18 @@ impl Format {
|
||||||
PathBuf::from(
|
PathBuf::from(
|
||||||
as_string
|
as_string
|
||||||
.replace("{title}", &sanitize_func(&self.title))
|
.replace("{title}", &sanitize_func(&self.title))
|
||||||
.replace("{audio}", &sanitize_func(&self.audio.to_string()))
|
|
||||||
.replace(
|
.replace(
|
||||||
"{resolution}",
|
"{audio}",
|
||||||
&sanitize_func(&self.stream.resolution.to_string()),
|
&sanitize_func(
|
||||||
|
&self
|
||||||
|
.locales
|
||||||
|
.iter()
|
||||||
|
.map(|(a, _)| a.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("|"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
.replace("{resolution}", &sanitize_func(&self.resolution.to_string()))
|
||||||
.replace("{series_id}", &sanitize_func(&self.series_id))
|
.replace("{series_id}", &sanitize_func(&self.series_id))
|
||||||
.replace("{series_name}", &sanitize_func(&self.series_name))
|
.replace("{series_name}", &sanitize_func(&self.series_name))
|
||||||
.replace("{season_id}", &sanitize_func(&self.season_id))
|
.replace("{season_id}", &sanitize_func(&self.season_id))
|
||||||
|
|
@ -131,12 +213,109 @@ impl Format {
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
"{relative_episode_number}",
|
"{relative_episode_number}",
|
||||||
&sanitize_func(&format!("{:0>2}", self.relative_episode_number.to_string())),
|
&sanitize_func(&format!(
|
||||||
|
"{:0>2}",
|
||||||
|
self.relative_episode_number.unwrap_or_default().to_string()
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn visual_output(&self, dst: &Path) {
|
||||||
|
info!(
|
||||||
|
"Downloading {} to {}",
|
||||||
|
self.title,
|
||||||
|
if is_special_file(&dst) || dst.to_str().unwrap() == "-" {
|
||||||
|
dst.to_string_lossy().to_string()
|
||||||
|
} else {
|
||||||
|
format!("'{}'", dst.to_str().unwrap())
|
||||||
|
}
|
||||||
|
);
|
||||||
|
tab_info!(
|
||||||
|
"Episode: S{:02}E{:0>2}",
|
||||||
|
self.season_number,
|
||||||
|
self.episode_number
|
||||||
|
);
|
||||||
|
tab_info!(
|
||||||
|
"Audio: {}",
|
||||||
|
self.locales
|
||||||
|
.iter()
|
||||||
|
.map(|(a, _)| a.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
let mut subtitles: Vec<Locale> = self.locales.iter().flat_map(|(_, s)| s.clone()).collect();
|
||||||
|
real_dedup_vec(&mut subtitles);
|
||||||
|
tab_info!(
|
||||||
|
"Subtitles: {}",
|
||||||
|
subtitles
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
tab_info!("Resolution: {}", self.resolution);
|
||||||
|
tab_info!("FPS: {:.2}", self.fps)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn has_relative_episodes_fmt<S: AsRef<str>>(s: S) -> bool {
|
pub fn has_relative_episodes_fmt<S: AsRef<str>>(s: S) -> bool {
|
||||||
return s.as_ref().contains("{relative_episode_number}");
|
return s.as_ref().contains("{relative_episode_number}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn formats_visual_output(formats: Vec<&Format>) {
|
||||||
|
if log::max_level() == log::Level::Debug {
|
||||||
|
let seasons = sort_formats_after_seasons(formats);
|
||||||
|
debug!("Series has {} seasons", seasons.len());
|
||||||
|
for (i, season) in seasons.into_iter().enumerate() {
|
||||||
|
info!("Season {}", i + 1);
|
||||||
|
for format in season {
|
||||||
|
info!(
|
||||||
|
"{}: {}px, {:.02} FPS (S{:02}E{:0>2})",
|
||||||
|
format.title,
|
||||||
|
format.resolution,
|
||||||
|
format.fps,
|
||||||
|
format.season_number,
|
||||||
|
format.episode_number,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for season in sort_formats_after_seasons(formats) {
|
||||||
|
let first = season.get(0).unwrap();
|
||||||
|
info!("{} Season {}", first.series_name, first.season_number);
|
||||||
|
|
||||||
|
for (i, format) in season.into_iter().enumerate() {
|
||||||
|
tab_info!(
|
||||||
|
"{}. {} » {}px, {:.2} FPS (S{:02}E{:0>2})",
|
||||||
|
i + 1,
|
||||||
|
format.title,
|
||||||
|
format.resolution,
|
||||||
|
format.fps,
|
||||||
|
format.season_number,
|
||||||
|
format.episode_number
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_formats_after_seasons(formats: Vec<&Format>) -> Vec<Vec<&Format>> {
|
||||||
|
let mut season_map = BTreeMap::new();
|
||||||
|
|
||||||
|
for format in formats {
|
||||||
|
season_map
|
||||||
|
.entry(format.season_number)
|
||||||
|
.or_insert(vec![])
|
||||||
|
.push(format)
|
||||||
|
}
|
||||||
|
|
||||||
|
season_map
|
||||||
|
.into_values()
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut fmts| {
|
||||||
|
fmts.sort_by(|a, b| a.sequence_number.total_cmp(&b.sequence_number));
|
||||||
|
fmts
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,17 @@ pub fn system_locale() -> Locale {
|
||||||
Locale::en_US
|
Locale::en_US
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if [`Locale::Custom("all")`] is in the provided locale list and return [`Locale::all`] if
|
||||||
|
/// so. If not, just return the provided locale list.
|
||||||
|
pub fn all_locale_in_locales(locales: Vec<Locale>) -> Vec<Locale> {
|
||||||
|
if locales
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.to_string().to_lowercase().trim() == "all")
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
Locale::all()
|
||||||
|
} else {
|
||||||
|
locales
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
use log::info;
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use log::{
|
||||||
|
info, set_boxed_logger, set_max_level, Level, LevelFilter, Log, Metadata, Record,
|
||||||
|
SetLoggerError,
|
||||||
|
};
|
||||||
|
use std::io::{stdout, Write};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub struct ProgressHandler {
|
pub struct ProgressHandler {
|
||||||
pub(crate) stopped: bool,
|
pub(crate) stopped: bool,
|
||||||
|
|
@ -28,3 +36,133 @@ macro_rules! progress {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub(crate) use progress;
|
pub(crate) use progress;
|
||||||
|
|
||||||
|
macro_rules! tab_info {
|
||||||
|
($($arg:tt)+) => {
|
||||||
|
if log::max_level() == log::LevelFilter::Debug {
|
||||||
|
info!($($arg)+)
|
||||||
|
} else {
|
||||||
|
info!("\t{}", format!($($arg)+))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) use tab_info;
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub struct CliLogger {
|
||||||
|
all: bool,
|
||||||
|
level: LevelFilter,
|
||||||
|
progress: Mutex<Option<ProgressBar>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Log for CliLogger {
|
||||||
|
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||||
|
metadata.level() <= self.level
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &Record) {
|
||||||
|
if !self.enabled(record.metadata())
|
||||||
|
|| (record.target() != "progress"
|
||||||
|
&& record.target() != "progress_end"
|
||||||
|
&& (!self.all && !record.target().starts_with("crunchy_cli")))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.level >= LevelFilter::Debug {
|
||||||
|
self.extended(record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match record.target() {
|
||||||
|
"progress" => self.progress(record, false),
|
||||||
|
"progress_end" => self.progress(record, true),
|
||||||
|
_ => {
|
||||||
|
if self.progress.lock().unwrap().is_some() {
|
||||||
|
self.progress(record, false)
|
||||||
|
} else if record.level() > Level::Warn {
|
||||||
|
self.normal(record)
|
||||||
|
} else {
|
||||||
|
self.error(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {
|
||||||
|
let _ = stdout().flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliLogger {
|
||||||
|
pub fn new(all: bool, level: LevelFilter) -> Self {
|
||||||
|
Self {
|
||||||
|
all,
|
||||||
|
level,
|
||||||
|
progress: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(all: bool, level: LevelFilter) -> Result<(), SetLoggerError> {
|
||||||
|
set_max_level(level);
|
||||||
|
set_boxed_logger(Box::new(CliLogger::new(all, level)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extended(&self, record: &Record) {
|
||||||
|
println!(
|
||||||
|
"[{}] {} {} ({}) {}",
|
||||||
|
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
record.level(),
|
||||||
|
// replace the 'progress' prefix if this function is invoked via 'progress!'
|
||||||
|
record
|
||||||
|
.target()
|
||||||
|
.replacen("crunchy_cli_core", "crunchy_cli", 1)
|
||||||
|
.replacen("progress_end", "crunchy_cli", 1)
|
||||||
|
.replacen("progress", "crunchy_cli", 1),
|
||||||
|
format!("{:?}", thread::current().id())
|
||||||
|
.replace("ThreadId(", "")
|
||||||
|
.replace(')', ""),
|
||||||
|
record.args()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normal(&self, record: &Record) {
|
||||||
|
println!(":: {}", record.args())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error(&self, record: &Record) {
|
||||||
|
eprintln!(":: {}", record.args())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn progress(&self, record: &Record, stop: bool) {
|
||||||
|
let mut progress = self.progress.lock().unwrap();
|
||||||
|
|
||||||
|
let msg = format!("{}", record.args());
|
||||||
|
if stop && progress.is_some() {
|
||||||
|
if msg.is_empty() {
|
||||||
|
progress.take().unwrap().finish()
|
||||||
|
} else {
|
||||||
|
progress.take().unwrap().finish_with_message(msg)
|
||||||
|
}
|
||||||
|
} else if let Some(p) = &*progress {
|
||||||
|
p.println(format!(":: → {}", msg))
|
||||||
|
} else {
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let finish_str = "✔";
|
||||||
|
#[cfg(windows)]
|
||||||
|
// windows does not support all unicode characters by default in their consoles, so
|
||||||
|
// we're using this (square root?) symbol instead. microsoft.
|
||||||
|
let finish_str = "√";
|
||||||
|
|
||||||
|
let pb = ProgressBar::new_spinner();
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::with_template(":: {spinner} {msg}")
|
||||||
|
.unwrap()
|
||||||
|
.tick_strings(&["—", "\\", "|", "/", finish_str]),
|
||||||
|
);
|
||||||
|
pb.enable_steady_tick(Duration::from_millis(200));
|
||||||
|
pb.set_message(msg);
|
||||||
|
*progress = Some(pb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
pub mod clap;
|
pub mod clap;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
|
pub mod download;
|
||||||
|
pub mod ffmpeg;
|
||||||
|
pub mod filter;
|
||||||
pub mod format;
|
pub mod format;
|
||||||
pub mod locale;
|
pub mod locale;
|
||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod os;
|
pub mod os;
|
||||||
pub mod parse;
|
pub mod parse;
|
||||||
pub mod sort;
|
|
||||||
pub mod subtitle;
|
|
||||||
pub mod video;
|
pub mod video;
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ pub fn tempfile<S: AsRef<str>>(suffix: S) -> io::Result<NamedTempFile> {
|
||||||
|
|
||||||
/// Check if the given path exists and rename it until the new (renamed) file does not exist.
|
/// Check if the given path exists and rename it until the new (renamed) file does not exist.
|
||||||
pub fn free_file(mut path: PathBuf) -> (PathBuf, bool) {
|
pub fn free_file(mut path: PathBuf) -> (PathBuf, bool) {
|
||||||
// if it's a special file does not rename it
|
// do not rename it if it exists but is a special file
|
||||||
if is_special_file(&path) {
|
if is_special_file(&path) {
|
||||||
return (path, false);
|
return (path, false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ use crunchyroll_rs::{Crunchyroll, MediaCollection, UrlType};
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
/// Define a filter, based on season and episode number to filter episodes / movies.
|
/// Define a find, based on season and episode number to find episodes / movies.
|
||||||
/// If a struct instance equals the [`Default::default()`] it's considered that no filter is applied.
|
/// If a struct instance equals the [`Default::default()`] it's considered that no find is applied.
|
||||||
/// If `from_*` is [`None`] they're set to [`u32::MIN`].
|
/// If `from_*` is [`None`] they're set to [`u32::MIN`].
|
||||||
/// If `to_*` is [`None`] they're set to [`u32::MAX`].
|
/// If `to_*` is [`None`] they're set to [`u32::MAX`].
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -62,7 +62,7 @@ impl UrlFilter {
|
||||||
/// - `...[S3,S5]` - Download episode 3 and 5.
|
/// - `...[S3,S5]` - Download episode 3 and 5.
|
||||||
/// - `...[S1-S3,S4E2-S4E6]` - Download season 1 to 3 and episode 2 to episode 6 of season 4.
|
/// - `...[S1-S3,S4E2-S4E6]` - Download season 1 to 3 and episode 2 to episode 6 of season 4.
|
||||||
|
|
||||||
/// In practice, it would look like this: `https://beta.crunchyroll.com/series/12345678/example[S1E5-S3E2]`.
|
/// In practice, it would look like this: `https://crunchyroll.com/series/12345678/example[S1E5-S3E2]`.
|
||||||
pub async fn parse_url(
|
pub async fn parse_url(
|
||||||
crunchy: &Crunchyroll,
|
crunchy: &Crunchyroll,
|
||||||
mut url: String,
|
mut url: String,
|
||||||
|
|
@ -115,7 +115,7 @@ pub async fn parse_url(
|
||||||
|
|
||||||
let url_filter = UrlFilter { inner: filters };
|
let url_filter = UrlFilter { inner: filters };
|
||||||
|
|
||||||
debug!("Url filter: {:?}", url_filter);
|
debug!("Url find: {:?}", url_filter);
|
||||||
|
|
||||||
url_filter
|
url_filter
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -125,9 +125,11 @@ pub async fn parse_url(
|
||||||
let parsed_url = crunchyroll_rs::parse_url(url).map_or(Err(anyhow!("Invalid url")), Ok)?;
|
let parsed_url = crunchyroll_rs::parse_url(url).map_or(Err(anyhow!("Invalid url")), Ok)?;
|
||||||
debug!("Url type: {:?}", parsed_url);
|
debug!("Url type: {:?}", parsed_url);
|
||||||
let media_collection = match parsed_url {
|
let media_collection = match parsed_url {
|
||||||
UrlType::Series(id) | UrlType::MovieListing(id) | UrlType::EpisodeOrMovie(id) => {
|
UrlType::Series(id)
|
||||||
crunchy.media_collection_from_id(id).await?
|
| UrlType::MovieListing(id)
|
||||||
}
|
| UrlType::EpisodeOrMovie(id)
|
||||||
|
| UrlType::MusicVideo(id)
|
||||||
|
| UrlType::Concert(id) => crunchy.media_collection_from_id(id).await?,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((media_collection, url_filter))
|
Ok((media_collection, url_filter))
|
||||||
|
|
@ -150,7 +152,7 @@ pub fn parse_resolution(mut resolution: String) -> Result<Resolution> {
|
||||||
} else if resolution.ends_with('p') {
|
} else if resolution.ends_with('p') {
|
||||||
let without_p = resolution.as_str()[0..resolution.len() - 1]
|
let without_p = resolution.as_str()[0..resolution.len() - 1]
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| anyhow!("Could not parse resolution"))?;
|
.map_err(|_| anyhow!("Could not find resolution"))?;
|
||||||
Ok(Resolution {
|
Ok(Resolution {
|
||||||
width: without_p * 16 / 9,
|
width: without_p * 16 / 9,
|
||||||
height: without_p,
|
height: without_p,
|
||||||
|
|
@ -159,12 +161,12 @@ pub fn parse_resolution(mut resolution: String) -> Result<Resolution> {
|
||||||
Ok(Resolution {
|
Ok(Resolution {
|
||||||
width: w
|
width: w
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| anyhow!("Could not parse resolution"))?,
|
.map_err(|_| anyhow!("Could not find resolution"))?,
|
||||||
height: h
|
height: h
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| anyhow!("Could not parse resolution"))?,
|
.map_err(|_| anyhow!("Could not find resolution"))?,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
bail!("Could not parse resolution")
|
bail!("Could not find resolution")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
use crate::utils::format::Format;
|
|
||||||
use crunchyroll_rs::{Media, Season};
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
/// Sort seasons after their season number. Crunchyroll may have multiple seasons for one season
|
|
||||||
/// number. They generally store different language in individual seasons with the same season number.
|
|
||||||
/// E.g. series X has one official season but crunchy has translations for it in 3 different languages
|
|
||||||
/// so there exist 3 different "seasons" on Crunchyroll which are actual the same season but with
|
|
||||||
/// different audio.
|
|
||||||
pub fn sort_seasons_after_number(seasons: Vec<Media<Season>>) -> Vec<Vec<Media<Season>>> {
|
|
||||||
let mut as_map = BTreeMap::new();
|
|
||||||
|
|
||||||
for season in seasons {
|
|
||||||
as_map
|
|
||||||
.entry(season.metadata.season_number)
|
|
||||||
.or_insert_with(Vec::new);
|
|
||||||
as_map
|
|
||||||
.get_mut(&season.metadata.season_number)
|
|
||||||
.unwrap()
|
|
||||||
.push(season)
|
|
||||||
}
|
|
||||||
|
|
||||||
as_map.into_values().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sort formats after their seasons and episodes (inside it) ascending. Make sure to have only
|
|
||||||
/// episodes from one series and in one language as argument since the function does not handle those
|
|
||||||
/// differences which could then lead to a semi messed up result.
|
|
||||||
pub fn sort_formats_after_seasons(formats: Vec<Format>) -> Vec<Vec<Format>> {
|
|
||||||
let mut as_map = BTreeMap::new();
|
|
||||||
|
|
||||||
for format in formats {
|
|
||||||
// the season title is used as key instead of season number to distinguish duplicated season
|
|
||||||
// numbers which are actually two different seasons; season id is not used as this somehow
|
|
||||||
// messes up ordering when duplicated seasons exist
|
|
||||||
as_map
|
|
||||||
.entry(format.season_title.clone())
|
|
||||||
.or_insert_with(Vec::new);
|
|
||||||
as_map.get_mut(&format.season_title).unwrap().push(format);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sorted = as_map
|
|
||||||
.into_iter()
|
|
||||||
.map(|(_, mut values)| {
|
|
||||||
values.sort_by(|a, b| a.episode_number.total_cmp(&b.episode_number));
|
|
||||||
values
|
|
||||||
})
|
|
||||||
.collect::<Vec<Vec<Format>>>();
|
|
||||||
sorted.sort_by(|a, b| a[0].season_number.cmp(&b[0].season_number));
|
|
||||||
|
|
||||||
sorted
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
use crate::utils::os::tempfile;
|
|
||||||
use anyhow::Result;
|
|
||||||
use chrono::NaiveTime;
|
|
||||||
use crunchyroll_rs::media::StreamSubtitle;
|
|
||||||
use crunchyroll_rs::Locale;
|
|
||||||
use regex::Regex;
|
|
||||||
use std::io::Write;
|
|
||||||
use tempfile::TempPath;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Subtitle {
|
|
||||||
pub stream_subtitle: StreamSubtitle,
|
|
||||||
pub audio_locale: Locale,
|
|
||||||
pub episode_id: String,
|
|
||||||
pub forced: bool,
|
|
||||||
pub primary: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn download_subtitle(
|
|
||||||
subtitle: StreamSubtitle,
|
|
||||||
max_length: NaiveTime,
|
|
||||||
) -> Result<TempPath> {
|
|
||||||
let tempfile = tempfile(".ass")?;
|
|
||||||
let (mut file, path) = tempfile.into_parts();
|
|
||||||
|
|
||||||
let mut buf = vec![];
|
|
||||||
subtitle.write_to(&mut buf).await?;
|
|
||||||
buf = fix_subtitle_look_and_feel(buf);
|
|
||||||
buf = fix_subtitle_length(buf, max_length);
|
|
||||||
|
|
||||||
file.write_all(buf.as_slice())?;
|
|
||||||
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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_look_and_feel(raw: Vec<u8>) -> Vec<u8> {
|
|
||||||
let mut script_info = false;
|
|
||||||
let mut new = String::new();
|
|
||||||
|
|
||||||
for line in String::from_utf8_lossy(raw.as_slice()).split('\n') {
|
|
||||||
if line.trim().starts_with('[') && script_info {
|
|
||||||
new.push_str("ScaledBorderAndShadow: yes\n");
|
|
||||||
script_info = false
|
|
||||||
} else if line.trim() == "[Script Info]" {
|
|
||||||
script_info = true
|
|
||||||
}
|
|
||||||
new.push_str(line);
|
|
||||||
new.push('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
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<u8>, max_length: NaiveTime) -> Vec<u8> {
|
|
||||||
let re =
|
|
||||||
Regex::new(r#"^Dialogue:\s\d+,(?P<start>\d+:\d+:\d+\.\d+),(?P<end>\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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::NaiveTime;
|
use crunchyroll_rs::media::{Resolution, Stream, VariantData};
|
||||||
use regex::Regex;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
/// Get the length of a video. This is required because sometimes subtitles have an unnecessary entry
|
pub async fn variant_data_from_stream(
|
||||||
/// long after the actual video ends with artificially extends the video length on some video players.
|
stream: &Stream,
|
||||||
/// To prevent this, the video length must be hard set. See
|
resolution: &Resolution,
|
||||||
/// [crunchy-labs/crunchy-cli#32](https://github.com/crunchy-labs/crunchy-cli/issues/32) for more
|
) -> Result<Option<(VariantData, VariantData)>> {
|
||||||
/// information.
|
let mut streaming_data = stream.dash_streaming_data(None).await?;
|
||||||
pub fn get_video_length(path: PathBuf) -> Result<NaiveTime> {
|
streaming_data
|
||||||
let video_length = Regex::new(r"Duration:\s(?P<time>\d+:\d+:\d+\.\d+),")?;
|
.0
|
||||||
|
.sort_by(|a, b| a.bandwidth.cmp(&b.bandwidth).reverse());
|
||||||
|
streaming_data
|
||||||
|
.1
|
||||||
|
.sort_by(|a, b| a.bandwidth.cmp(&b.bandwidth).reverse());
|
||||||
|
|
||||||
let ffmpeg = Command::new("ffmpeg")
|
let video_variant = match resolution.height {
|
||||||
.stdout(Stdio::null())
|
u64::MAX => Some(streaming_data.0.into_iter().next().unwrap()),
|
||||||
.stderr(Stdio::piped())
|
u64::MIN => Some(streaming_data.0.into_iter().last().unwrap()),
|
||||||
.arg("-y")
|
_ => streaming_data
|
||||||
.args(["-i", path.to_str().unwrap()])
|
.0
|
||||||
.output()?;
|
.into_iter()
|
||||||
let ffmpeg_output = String::from_utf8(ffmpeg.stderr)?;
|
.find(|v| resolution.height == v.resolution.height),
|
||||||
let caps = video_length.captures(ffmpeg_output.as_str()).unwrap();
|
};
|
||||||
|
Ok(video_variant.map(|v| (v, streaming_data.1.first().unwrap().clone())))
|
||||||
Ok(NaiveTime::parse_from_str(caps.name("time").unwrap().as_str(), "%H:%M:%S%.f").unwrap())
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue