Rewrite it in Rust

This commit is contained in:
ByteDream 2022-10-20 18:52:08 +02:00
parent d4bef511cb
commit 039d7cfb81
51 changed files with 4018 additions and 3208 deletions

View file

@ -0,0 +1,6 @@
use crate::utils::parse::parse_resolution;
use crunchyroll_rs::media::Resolution;
pub fn clap_parse_resolution(s: &str) -> Result<Resolution, String> {
parse_resolution(s.to_string()).map_err(|e| e.to_string())
}

View file

@ -0,0 +1,6 @@
use crunchyroll_rs::Crunchyroll;
pub struct Context {
pub crunchy: Crunchyroll,
pub client: isahc::HttpClient,
}

View file

@ -0,0 +1,77 @@
use crunchyroll_rs::media::VariantData;
use crunchyroll_rs::{Episode, Locale, Media, Movie};
use std::time::Duration;
#[derive(Clone)]
pub struct Format {
pub id: String,
pub title: String,
pub description: String,
pub number: u32,
pub audio: Locale,
pub duration: Duration,
pub stream: VariantData,
pub series_id: String,
pub series_name: String,
pub season_id: String,
pub season_title: String,
pub season_number: u32,
}
impl Format {
pub fn new_from_episode(episode: Media<Episode>, stream: VariantData) -> Self {
Self {
id: episode.id,
title: episode.title,
description: episode.description,
number: episode.metadata.episode_number,
audio: episode.metadata.audio_locale,
duration: episode.metadata.duration.to_std().unwrap(),
stream,
series_id: episode.metadata.series_id,
series_name: episode.metadata.series_title,
season_id: episode.metadata.season_id,
season_title: episode.metadata.season_title,
season_number: episode.metadata.season_number,
}
}
pub fn new_from_movie(movie: Media<Movie>, stream: VariantData) -> Self {
Self {
id: movie.id,
title: movie.title,
description: movie.description,
number: 1,
audio: Locale::ja_JP,
duration: movie.metadata.duration.to_std().unwrap(),
stream,
series_id: movie.metadata.movie_listing_id.clone(),
series_name: movie.metadata.movie_listing_title.clone(),
season_id: movie.metadata.movie_listing_id,
season_title: movie.metadata.movie_listing_title,
season_number: 1,
}
}
}
pub fn format_string(s: String, format: &Format) -> String {
s.replace("{title}", &format.title)
.replace("{series_name}", &format.series_name)
.replace("{season_name}", &format.season_title)
.replace("{audio}", &format.audio.to_string())
.replace("{resolution}", &format.stream.resolution.to_string())
.replace("{season_number}", &format.season_number.to_string())
.replace("{episode_number}", &format.number.to_string())
.replace("{series_id}", &format.series_id)
.replace("{season_id}", &format.season_id)
.replace("{episode_id}", &format.id)
}

View file

@ -0,0 +1,15 @@
use crunchyroll_rs::Locale;
/// Return the locale of the system.
pub fn system_locale() -> Locale {
if let Some(system_locale) = sys_locale::get_locale() {
let locale = Locale::from(system_locale);
if let Locale::Custom(_) = locale {
Locale::en_US
} else {
locale
}
} else {
Locale::en_US
}
}

View file

@ -0,0 +1,19 @@
use log::info;
pub struct ProgressHandler;
impl Drop for ProgressHandler {
fn drop(&mut self) {
info!(target: "progress_end", "")
}
}
macro_rules! progress {
($($arg:tt)+) => {
{
log::info!(target: "progress", $($arg)+);
$crate::utils::log::ProgressHandler{}
}
}
}
pub(crate) use progress;

View file

@ -0,0 +1,8 @@
pub mod clap;
pub mod context;
pub mod format;
pub mod locale;
pub mod log;
pub mod os;
pub mod parse;
pub mod sort;

View file

@ -0,0 +1,52 @@
use log::debug;
use std::io::ErrorKind;
use std::path::PathBuf;
use std::process::Command;
use std::{env, io};
use tempfile::{Builder, NamedTempFile};
pub fn has_ffmpeg() -> bool {
if let Err(e) = Command::new("ffmpeg").spawn() {
if ErrorKind::NotFound != e.kind() {
debug!(
"unknown error occurred while checking if ffmpeg exists: {}",
e.kind()
)
}
false
} else {
true
}
}
/// Any tempfiles should be created with this function. The prefix and directory of every file
/// created with this method stays the same which is helpful to query all existing tempfiles and
/// e.g. remove them in a case of ctrl-c. Having one function also good to prevent mistakes like
/// setting the wrong prefix if done manually.
pub fn tempfile<S: AsRef<str>>(suffix: S) -> io::Result<NamedTempFile> {
let tempfile = Builder::default()
.prefix(".crunchy-cli_")
.suffix(suffix.as_ref())
.tempfile_in(&env::temp_dir())?;
debug!(
"Created temporary file: {}",
tempfile.path().to_string_lossy()
);
Ok(tempfile)
}
/// 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) {
let mut i = 0;
while path.exists() {
i += 1;
let ext = path.extension().unwrap().to_str().unwrap();
let mut filename = path.file_name().unwrap().to_str().unwrap();
filename = &filename[0..filename.len() - ext.len() - 1];
path.set_file_name(format!("{} ({}).{}", filename, i, ext))
}
(path, i != 0)
}

View file

@ -0,0 +1,170 @@
use anyhow::{anyhow, bail, Result};
use crunchyroll_rs::media::Resolution;
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.
/// If `from_*` is [`None`] they're set to [`u32::MIN`].
/// If `to_*` is [`None`] they're set to [`u32::MAX`].
#[derive(Debug)]
pub struct InnerUrlFilter {
from_episode: Option<u32>,
to_episode: Option<u32>,
from_season: Option<u32>,
to_season: Option<u32>,
}
#[derive(Debug, Default)]
pub struct UrlFilter {
inner: Vec<InnerUrlFilter>,
}
impl UrlFilter {
pub fn is_season_valid(&self, season: u32) -> bool {
self.inner.iter().any(|f| {
let from_season = f.from_season.unwrap_or(u32::MIN);
let to_season = f.to_season.unwrap_or(u32::MAX);
season >= from_season && season <= to_season
})
}
pub fn is_episode_valid(&self, episode: u32, season: u32) -> bool {
self.inner.iter().any(|f| {
let from_episode = f.from_episode.unwrap_or(u32::MIN);
let to_episode = f.to_episode.unwrap_or(u32::MAX);
let from_season = f.from_season.unwrap_or(u32::MIN);
let to_season = f.to_season.unwrap_or(u32::MAX);
episode >= from_episode
&& episode <= to_episode
&& season >= from_season
&& season <= to_season
})
}
}
/// Parse a url and return all [`crunchyroll_rs::Media<crunchyroll_rs::Episode>`] &
/// [`crunchyroll_rs::Media<crunchyroll_rs::Movie>`] which could be related to it.
///
/// The `with_filter` arguments says if filtering should be enabled for the url. Filtering is a
/// specific pattern at the end of the url which declares which parts of the url content should be
/// returned / filtered (out). _This only works if the url points to a series_.
///
/// Examples how filtering works:
/// - `...[E5]` - Download the fifth episode.
/// - `...[S1]` - Download the full first season.
/// - `...[-S2]` - Download all seasons up to and including season 2.
/// - `...[S3E4-]` - Download all episodes from and including season 3, episode 4.
/// - `...[S1E4-S3]` - Download all episodes from and including season 1, episode 4, until andincluding season 3.
/// - `...[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]`.
pub async fn parse_url(
crunchy: &Crunchyroll,
mut url: String,
with_filter: bool,
) -> Result<(MediaCollection, UrlFilter)> {
let url_filter = if with_filter {
debug!("Url may contain filters");
let open_index = url.rfind('[').unwrap_or(0);
let close_index = url.rfind(']').unwrap_or(0);
let filter = if open_index < close_index {
let filter = url.as_str()[open_index + 1..close_index].to_string();
url = url.as_str()[0..open_index].to_string();
filter
} else {
"".to_string()
};
let filter_regex = Regex::new(r"((S(?P<from_season>\d+))?(E(?P<from_episode>\d+))?)(((?P<dash>-)((S(?P<to_season>\d+))?(E(?P<to_episode>\d+))?))?)(,|$)").unwrap();
let mut filters = vec![];
for capture in filter_regex.captures_iter(&filter) {
let dash = capture.name("dash").is_some();
let from_episode = capture
.name("from_episode")
.map_or(anyhow::Ok(None), |fe| Ok(Some(fe.as_str().parse()?)))?;
let to_episode = capture
.name("to_episode")
.map_or(anyhow::Ok(if dash { None } else { from_episode }), |te| {
Ok(Some(te.as_str().parse()?))
})?;
let from_season = capture
.name("from_season")
.map_or(anyhow::Ok(None), |fs| Ok(Some(fs.as_str().parse()?)))?;
let to_season = capture
.name("to_season")
.map_or(anyhow::Ok(if dash { None } else { from_season }), |ts| {
Ok(Some(ts.as_str().parse()?))
})?;
filters.push(InnerUrlFilter {
from_episode,
to_episode,
from_season,
to_season,
})
}
let url_filter = UrlFilter { inner: filters };
debug!("Url filter: {:?}", url_filter);
url_filter
} else {
UrlFilter::default()
};
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?
}
};
Ok((media_collection, url_filter))
}
/// Parse a resolution given as a [`String`] to a [`crunchyroll_rs::media::Resolution`].
pub fn parse_resolution(mut resolution: String) -> Result<Resolution> {
resolution = resolution.to_lowercase();
if resolution == "best" {
Ok(Resolution {
width: u64::MAX,
height: u64::MAX,
})
} else if resolution == "worst" {
Ok(Resolution {
width: u64::MIN,
height: u64::MIN,
})
} else if resolution.ends_with('p') {
let without_p = resolution.as_str()[0..resolution.len() - 2]
.parse()
.map_err(|_| anyhow!("Could not parse resolution"))?;
Ok(Resolution {
width: without_p * 16 / 9,
height: without_p,
})
} else if let Some((w, h)) = resolution.split_once('x') {
Ok(Resolution {
width: w
.parse()
.map_err(|_| anyhow!("Could not parse resolution"))?,
height: h
.parse()
.map_err(|_| anyhow!("Could not parse resolution"))?,
})
} else {
bail!("Could not parse resolution")
}
}

View file

@ -0,0 +1,47 @@
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 {
as_map.entry(format.season_number).or_insert_with(Vec::new);
as_map.get_mut(&format.season_number).unwrap().push(format);
}
let mut sorted = as_map
.into_iter()
.map(|(_, mut values)| {
values.sort_by(|a, b| a.number.cmp(&b.number));
values
})
.collect::<Vec<Vec<Format>>>();
sorted.sort_by(|a, b| a[0].series_id.cmp(&b[0].series_id));
sorted
}