Merge pull request #178 from crunchy-labs/feature/refactoring

Refactoring & library update
This commit is contained in:
ByteDream 2023-03-23 18:49:00 +00:00 committed by GitHub
commit e819e44671
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 3695 additions and 2982 deletions

775
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,18 +5,16 @@ version = "3.0.0-dev.8"
edition = "2021"
[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" }
[build-dependencies]
chrono = "0.4"
clap = { version = "4.0", features = ["string"] }
clap_complete = "4.0"
clap = { version = "4.1", features = ["string"] }
clap_complete = "4.1"
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" }
[profile.release]

File diff suppressed because it is too large Load diff

View file

@ -7,12 +7,12 @@ edition = "2021"
[dependencies]
anyhow = "1.0"
async-trait = "0.1"
clap = { version = "4.0", features = ["derive", "string"] }
clap = { version = "4.1", features = ["derive", "string"] }
chrono = "0.4"
crunchyroll-rs = "0.2"
csv = "1.1"
crunchyroll-rs = { version = "0.3", features = ["dash-stream"] }
ctrlc = "3.2"
dirs = "4.0"
dirs = "5.0"
derive_setters = "0.1"
indicatif = "0.17"
lazy_static = "1.4"
log = { version = "0.4", features = ["std"] }
@ -23,9 +23,9 @@ serde = "1.0"
serde_json = "1.0"
shlex = "1.1"
signal-hook = "0.3"
tempfile = "3.3"
tempfile = "3.4"
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"
[build-dependencies]

View 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(())
}
}

View 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()
}

View file

@ -0,0 +1,4 @@
mod command;
mod filter;
pub use command::Archive;

View file

@ -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(())
}

View file

@ -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)
}
}

View file

@ -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;

View file

@ -1,5 +0,0 @@
pub mod archive;
pub mod download;
pub mod log;
pub mod login;
mod utils;

View file

@ -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>>>()
}

View 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(())
}
}

View 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)
}
}

View file

@ -0,0 +1,4 @@
mod command;
mod filter;
pub use command::Download;

View file

@ -1,7 +1,6 @@
use crate::cli::log::CliLogger;
use crate::utils::context::Context;
use crate::utils::locale::system_locale;
use crate::utils::log::progress;
use crate::utils::log::{progress, CliLogger};
use anyhow::bail;
use anyhow::Result;
use clap::{Parser, Subcommand};
@ -9,10 +8,14 @@ use crunchyroll_rs::{Crunchyroll, Locale};
use log::{debug, error, warn, LevelFilter};
use std::{env, fs};
mod cli;
mod archive;
mod download;
mod login;
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)]
trait Execute {
@ -35,6 +38,15 @@ pub struct Cli {
#[arg(long)]
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)]
login_method: LoginMethod,
@ -222,9 +234,14 @@ async fn crunchyroll_session(cli: &Cli) -> Result<Crunchyroll> {
lang
};
let builder = Crunchyroll::builder()
let mut builder = Crunchyroll::builder()
.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
+ 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");
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() {
let session = fs::read_to_string(login_file_path)?;
if let Some((token_type, token)) = session.split_once(':') {

View file

@ -0,0 +1,4 @@
mod command;
pub use command::login_file_path;
pub use command::Login;

View 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()
}

View 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>>(),
)
}
}
}
}

View 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
}

View file

@ -1,19 +1,22 @@
use crunchyroll_rs::media::{StreamSubtitle, VariantData};
use crunchyroll_rs::{Episode, Locale, Media, Movie};
use log::warn;
use std::path::PathBuf;
use std::time::Duration;
use crate::utils::filter::real_dedup_vec;
use crate::utils::log::tab_info;
use crate::utils::os::is_special_file;
use crunchyroll_rs::media::{Resolution, VariantData};
use crunchyroll_rs::{Concert, Episode, Locale, Movie, MusicVideo};
use log::{debug, info};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct Format {
pub struct SingleFormat {
pub title: String,
pub description: String,
pub audio: Locale,
pub subtitles: Vec<StreamSubtitle>,
pub subtitles: Vec<Locale>,
pub duration: Duration,
pub stream: VariantData,
pub resolution: Resolution,
pub fps: f64,
pub series_id: String,
pub series_name: String,
@ -23,76 +26,148 @@ pub struct Format {
pub season_number: u32,
pub episode_id: String,
pub episode_number: f32,
pub relative_episode_number: f32,
pub episode_number: String,
pub sequence_number: f32,
pub relative_episode_number: Option<u32>,
}
impl Format {
impl SingleFormat {
pub fn new_from_episode(
episode: &Media<Episode>,
season_episodes: &Vec<Media<Episode>>,
stream: VariantData,
subtitles: Vec<StreamSubtitle>,
episode: &Episode,
video: &VariantData,
subtitles: Vec<Locale>,
relative_episode_number: Option<u32>,
) -> Self {
Self {
title: episode.title.clone(),
description: episode.description.clone(),
audio: episode.metadata.audio_locale.clone(),
audio: episode.audio_locale.clone(),
subtitles,
duration: episode.metadata.duration.to_std().unwrap(),
stream,
series_id: episode.metadata.series_id.clone(),
series_name: episode.metadata.series_title.clone(),
season_id: episode.metadata.season_id.clone(),
season_title: episode.metadata.season_title.clone(),
season_number: episode.metadata.season_number.clone(),
resolution: video.resolution.clone(),
fps: video.fps,
series_id: episode.series_id.clone(),
series_name: episode.series_title.clone(),
season_id: episode.season_id.clone(),
season_title: episode.season_title.to_string(),
season_number: episode.season_number,
episode_id: episode.id.clone(),
episode_number: episode
.metadata
.episode
.parse()
.unwrap_or(episode.metadata.sequence_number),
relative_episode_number: season_episodes
.iter()
.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)
}),
episode_number: if episode.episode.is_empty() {
episode.sequence_number.to_string()
} else {
episode.episode.clone()
},
sequence_number: episode.sequence_number,
relative_episode_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 {
title: movie.title.clone(),
description: movie.description.clone(),
audio: Locale::ja_JP,
duration: movie.metadata.duration.to_std().unwrap(),
stream,
subtitles: vec![],
series_id: movie.metadata.movie_listing_id.clone(),
series_name: movie.metadata.movie_listing_title.clone(),
season_id: movie.metadata.movie_listing_id.clone(),
season_title: movie.metadata.movie_listing_title.clone(),
subtitles,
resolution: video.resolution.clone(),
fps: video.fps,
series_id: movie.movie_listing_id.clone(),
series_name: movie.movie_listing_title.clone(),
season_id: movie.movie_listing_id.clone(),
season_title: movie.movie_listing_title.to_string(),
season_number: 1,
episode_id: movie.id.clone(),
episode_number: 1.0,
relative_episode_number: 1.0,
episode_number: "1".to_string(),
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(
as_string
.replace("{title}", &sanitize_func(&self.title))
.replace("{audio}", &sanitize_func(&self.audio.to_string()))
.replace(
"{resolution}",
&sanitize_func(&self.stream.resolution.to_string()),
"{audio}",
&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_name}", &sanitize_func(&self.series_name))
.replace("{season_id}", &sanitize_func(&self.season_id))
@ -131,12 +213,109 @@ impl Format {
)
.replace(
"{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 {
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()
}

View file

@ -13,3 +13,17 @@ pub fn system_locale() -> Locale {
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
}
}

View file

@ -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(crate) stopped: bool,
@ -28,3 +36,133 @@ macro_rules! 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)
}
}
}

View file

@ -1,10 +1,11 @@
pub mod clap;
pub mod context;
pub mod download;
pub mod ffmpeg;
pub mod filter;
pub mod format;
pub mod locale;
pub mod log;
pub mod os;
pub mod parse;
pub mod sort;
pub mod subtitle;
pub mod video;

View file

@ -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.
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) {
return (path, false);
}

View file

@ -4,8 +4,8 @@ use crunchyroll_rs::{Crunchyroll, MediaCollection, UrlType};
use log::debug;
use regex::Regex;
/// Define a filter, based on season and episode number to filter episodes / movies.
/// If a struct instance equals the [`Default::default()`] it's considered that no filter is applied.
/// 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 find is applied.
/// If `from_*` is [`None`] they're set to [`u32::MIN`].
/// If `to_*` is [`None`] they're set to [`u32::MAX`].
#[derive(Debug)]
@ -62,7 +62,7 @@ impl UrlFilter {
/// - `...[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.
/// 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(
crunchy: &Crunchyroll,
mut url: String,
@ -115,7 +115,7 @@ pub async fn parse_url(
let url_filter = UrlFilter { inner: filters };
debug!("Url filter: {:?}", url_filter);
debug!("Url find: {:?}", url_filter);
url_filter
} 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)?;
debug!("Url type: {:?}", parsed_url);
let media_collection = match parsed_url {
UrlType::Series(id) | UrlType::MovieListing(id) | UrlType::EpisodeOrMovie(id) => {
crunchy.media_collection_from_id(id).await?
}
UrlType::Series(id)
| UrlType::MovieListing(id)
| UrlType::EpisodeOrMovie(id)
| UrlType::MusicVideo(id)
| UrlType::Concert(id) => crunchy.media_collection_from_id(id).await?,
};
Ok((media_collection, url_filter))
@ -150,7 +152,7 @@ pub fn parse_resolution(mut resolution: String) -> Result<Resolution> {
} else if resolution.ends_with('p') {
let without_p = resolution.as_str()[0..resolution.len() - 1]
.parse()
.map_err(|_| anyhow!("Could not parse resolution"))?;
.map_err(|_| anyhow!("Could not find resolution"))?;
Ok(Resolution {
width: without_p * 16 / 9,
height: without_p,
@ -159,12 +161,12 @@ pub fn parse_resolution(mut resolution: String) -> Result<Resolution> {
Ok(Resolution {
width: w
.parse()
.map_err(|_| anyhow!("Could not parse resolution"))?,
.map_err(|_| anyhow!("Could not find resolution"))?,
height: h
.parse()
.map_err(|_| anyhow!("Could not parse resolution"))?,
.map_err(|_| anyhow!("Could not find resolution"))?,
})
} else {
bail!("Could not parse resolution")
bail!("Could not find resolution")
}
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -1,25 +1,25 @@
use anyhow::Result;
use chrono::NaiveTime;
use regex::Regex;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use crunchyroll_rs::media::{Resolution, Stream, VariantData};
/// 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: PathBuf) -> Result<NaiveTime> {
let video_length = Regex::new(r"Duration:\s(?P<time>\d+:\d+:\d+\.\d+),")?;
pub async fn variant_data_from_stream(
stream: &Stream,
resolution: &Resolution,
) -> Result<Option<(VariantData, VariantData)>> {
let mut streaming_data = stream.dash_streaming_data(None).await?;
streaming_data
.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")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.arg("-y")
.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())
let video_variant = match resolution.height {
u64::MAX => Some(streaming_data.0.into_iter().next().unwrap()),
u64::MIN => Some(streaming_data.0.into_iter().last().unwrap()),
_ => streaming_data
.0
.into_iter()
.find(|v| resolution.height == v.resolution.height),
};
Ok(video_variant.map(|v| (v, streaming_data.1.first().unwrap().clone())))
}