mirror of
https://github.com/crunchy-labs/crunchy-cli.git
synced 2026-01-21 04:02:00 -06:00
687 lines
22 KiB
Rust
687 lines
22 KiB
Rust
use crate::search::filter::FilterOptions;
|
|
use anyhow::{bail, Result};
|
|
use crunchyroll_rs::media::{Stream, Subtitle};
|
|
use crunchyroll_rs::{
|
|
Concert, Crunchyroll, Episode, Locale, MediaCollection, Movie, MovieListing, MusicVideo,
|
|
Season, Series,
|
|
};
|
|
use regex::Regex;
|
|
use serde::Serialize;
|
|
use serde_json::{Map, Value};
|
|
use std::collections::HashMap;
|
|
use std::ops::Range;
|
|
use std::sync::Arc;
|
|
|
|
#[derive(Default, Serialize)]
|
|
struct FormatSeries {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub description: String,
|
|
pub release_year: u32,
|
|
}
|
|
|
|
impl From<&Series> for FormatSeries {
|
|
fn from(value: &Series) -> Self {
|
|
Self {
|
|
id: value.id.clone(),
|
|
title: value.title.clone(),
|
|
description: value.description.clone(),
|
|
release_year: value.series_launch_year.unwrap_or_default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Serialize)]
|
|
struct FormatSeason {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub description: String,
|
|
pub number: u32,
|
|
pub episodes: u32,
|
|
}
|
|
|
|
impl From<&Season> for FormatSeason {
|
|
fn from(value: &Season) -> Self {
|
|
Self {
|
|
id: value.id.clone(),
|
|
title: value.title.clone(),
|
|
description: value.description.clone(),
|
|
number: value.season_number,
|
|
episodes: value.number_of_episodes,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Serialize)]
|
|
struct FormatEpisode {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub description: String,
|
|
pub locale: Locale,
|
|
pub number: u32,
|
|
pub sequence_number: f32,
|
|
pub duration: i64,
|
|
pub air_date: i64,
|
|
pub premium_only: bool,
|
|
}
|
|
|
|
impl From<&Episode> for FormatEpisode {
|
|
fn from(value: &Episode) -> Self {
|
|
Self {
|
|
id: value.id.clone(),
|
|
title: value.title.clone(),
|
|
description: value.description.clone(),
|
|
locale: value.audio_locale.clone(),
|
|
number: value.episode_number.unwrap_or_default(),
|
|
sequence_number: value.sequence_number,
|
|
duration: value.duration.num_milliseconds(),
|
|
air_date: value.episode_air_date.timestamp(),
|
|
premium_only: value.is_premium_only,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Serialize)]
|
|
struct FormatMovieListing {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub description: String,
|
|
}
|
|
|
|
impl From<&MovieListing> for FormatMovieListing {
|
|
fn from(value: &MovieListing) -> Self {
|
|
Self {
|
|
id: value.id.clone(),
|
|
title: value.title.clone(),
|
|
description: value.description.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Serialize)]
|
|
struct FormatMovie {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub description: String,
|
|
pub duration: i64,
|
|
pub premium_only: bool,
|
|
}
|
|
|
|
impl From<&Movie> for FormatMovie {
|
|
fn from(value: &Movie) -> Self {
|
|
Self {
|
|
id: value.id.clone(),
|
|
title: value.title.clone(),
|
|
description: value.description.clone(),
|
|
duration: value.duration.num_milliseconds(),
|
|
premium_only: value.is_premium_only,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Serialize)]
|
|
struct FormatMusicVideo {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub description: String,
|
|
pub duration: i64,
|
|
pub premium_only: bool,
|
|
}
|
|
|
|
impl From<&MusicVideo> for FormatMusicVideo {
|
|
fn from(value: &MusicVideo) -> Self {
|
|
Self {
|
|
id: value.id.clone(),
|
|
title: value.title.clone(),
|
|
description: value.description.clone(),
|
|
duration: value.duration.num_milliseconds(),
|
|
premium_only: value.is_premium_only,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Serialize)]
|
|
struct FormatConcert {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub description: String,
|
|
pub duration: i64,
|
|
pub premium_only: bool,
|
|
}
|
|
|
|
impl From<&Concert> for FormatConcert {
|
|
fn from(value: &Concert) -> Self {
|
|
Self {
|
|
id: value.id.clone(),
|
|
title: value.title.clone(),
|
|
description: value.description.clone(),
|
|
duration: value.duration.num_milliseconds(),
|
|
premium_only: value.is_premium_only,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Serialize)]
|
|
struct FormatStream {
|
|
pub locale: Locale,
|
|
pub dash_url: String,
|
|
pub is_drm: bool,
|
|
}
|
|
|
|
impl From<&Stream> for FormatStream {
|
|
fn from(value: &Stream) -> Self {
|
|
Self {
|
|
locale: value.audio_locale.clone(),
|
|
dash_url: value.url.clone(),
|
|
is_drm: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Serialize)]
|
|
struct FormatSubtitle {
|
|
pub locale: Locale,
|
|
pub url: String,
|
|
}
|
|
|
|
impl From<&Subtitle> for FormatSubtitle {
|
|
fn from(value: &Subtitle) -> Self {
|
|
Self {
|
|
locale: value.locale.clone(),
|
|
url: value.url.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Serialize)]
|
|
struct FormatAccount {
|
|
pub token: String,
|
|
pub id: String,
|
|
pub profile_name: String,
|
|
pub email: String,
|
|
}
|
|
|
|
impl FormatAccount {
|
|
pub async fn async_from(value: &Crunchyroll) -> Result<Self> {
|
|
let account = value.account().await?;
|
|
|
|
Ok(Self {
|
|
token: value.access_token().await,
|
|
id: account.account_id,
|
|
profile_name: account.profile_name,
|
|
email: account.email,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
|
enum Scope {
|
|
Series,
|
|
Season,
|
|
Episode,
|
|
MovieListing,
|
|
Movie,
|
|
MusicVideo,
|
|
Concert,
|
|
Stream,
|
|
Subtitle,
|
|
Account,
|
|
}
|
|
|
|
macro_rules! must_match_if_true {
|
|
($condition:expr => $media_collection:ident | $field:pat => $expr:expr) => {
|
|
if $condition {
|
|
match &$media_collection {
|
|
$field => Some($expr),
|
|
_ => panic!(),
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
}
|
|
|
|
pub struct Format {
|
|
pattern: Vec<(Range<usize>, Scope, String)>,
|
|
pattern_count: HashMap<Scope, u32>,
|
|
input: String,
|
|
filter_options: FilterOptions,
|
|
crunchyroll: Arc<Crunchyroll>,
|
|
}
|
|
|
|
impl Format {
|
|
pub fn new(
|
|
input: String,
|
|
filter_options: FilterOptions,
|
|
crunchyroll: Arc<Crunchyroll>,
|
|
) -> Result<Self> {
|
|
let scope_regex = Regex::new(r"(?m)\{\{\s*(?P<scope>\w+)\.(?P<field>\w+)\s*}}").unwrap();
|
|
let mut pattern = vec![];
|
|
let mut pattern_count = HashMap::new();
|
|
|
|
macro_rules! generate_field_check {
|
|
($($scope:expr => $struct_:ident)+) => {
|
|
HashMap::from([
|
|
$(
|
|
(
|
|
$scope,
|
|
serde_json::from_value::<Map<String, Value>>(serde_json::to_value($struct_::default()).unwrap()).unwrap()
|
|
)
|
|
),+
|
|
])
|
|
};
|
|
}
|
|
let field_check = generate_field_check!(
|
|
Scope::Series => FormatSeries
|
|
Scope::Season => FormatSeason
|
|
Scope::Episode => FormatEpisode
|
|
Scope::MovieListing => FormatMovieListing
|
|
Scope::Movie => FormatMovie
|
|
Scope::MusicVideo => FormatMusicVideo
|
|
Scope::Concert => FormatConcert
|
|
Scope::Stream => FormatStream
|
|
Scope::Subtitle => FormatSubtitle
|
|
Scope::Account => FormatAccount
|
|
);
|
|
|
|
for capture in scope_regex.captures_iter(&input) {
|
|
let full = capture.get(0).unwrap();
|
|
let scope = capture.name("scope").unwrap().as_str();
|
|
let field = capture.name("field").unwrap().as_str();
|
|
|
|
let format_pattern_scope = match scope {
|
|
"series" => Scope::Series,
|
|
"season" => Scope::Season,
|
|
"episode" => Scope::Episode,
|
|
"movie_listing" => Scope::MovieListing,
|
|
"movie" => Scope::Movie,
|
|
"music_video" => Scope::MusicVideo,
|
|
"concert" => Scope::Concert,
|
|
"stream" => Scope::Stream,
|
|
"subtitle" => Scope::Subtitle,
|
|
"account" => Scope::Account,
|
|
_ => bail!("'{}.{}' is not a valid keyword", scope, field),
|
|
};
|
|
|
|
if field_check
|
|
.get(&format_pattern_scope)
|
|
.unwrap()
|
|
.get(field)
|
|
.is_none()
|
|
{
|
|
bail!("'{}.{}' is not a valid keyword", scope, field)
|
|
}
|
|
|
|
pattern.push((
|
|
full.start()..full.end(),
|
|
format_pattern_scope.clone(),
|
|
field.to_string(),
|
|
));
|
|
*pattern_count.entry(format_pattern_scope).or_default() += 1
|
|
}
|
|
|
|
Ok(Self {
|
|
pattern,
|
|
pattern_count,
|
|
input,
|
|
filter_options,
|
|
crunchyroll,
|
|
})
|
|
}
|
|
|
|
pub async fn parse(&self, media_collection: MediaCollection) -> Result<String> {
|
|
match &media_collection {
|
|
MediaCollection::Series(_)
|
|
| MediaCollection::Season(_)
|
|
| MediaCollection::Episode(_) => {
|
|
self.check_scopes(vec![
|
|
Scope::Series,
|
|
Scope::Season,
|
|
Scope::Episode,
|
|
Scope::Stream,
|
|
Scope::Subtitle,
|
|
Scope::Account,
|
|
])?;
|
|
|
|
self.parse_series(media_collection).await
|
|
}
|
|
MediaCollection::MovieListing(_) | MediaCollection::Movie(_) => {
|
|
self.check_scopes(vec![
|
|
Scope::MovieListing,
|
|
Scope::Movie,
|
|
Scope::Stream,
|
|
Scope::Subtitle,
|
|
Scope::Account,
|
|
])?;
|
|
|
|
self.parse_movie_listing(media_collection).await
|
|
}
|
|
MediaCollection::MusicVideo(_) => {
|
|
self.check_scopes(vec![
|
|
Scope::MusicVideo,
|
|
Scope::Stream,
|
|
Scope::Subtitle,
|
|
Scope::Account,
|
|
])?;
|
|
|
|
self.parse_music_video(media_collection).await
|
|
}
|
|
MediaCollection::Concert(_) => {
|
|
self.check_scopes(vec![
|
|
Scope::Concert,
|
|
Scope::Stream,
|
|
Scope::Subtitle,
|
|
Scope::Account,
|
|
])?;
|
|
|
|
self.parse_concert(media_collection).await
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn parse_series(&self, media_collection: MediaCollection) -> Result<String> {
|
|
let series_empty = self.check_pattern_count_empty(Scope::Series);
|
|
let season_empty = self.check_pattern_count_empty(Scope::Season);
|
|
let episode_empty = self.check_pattern_count_empty(Scope::Episode);
|
|
let stream_empty = self.check_pattern_count_empty(Scope::Stream)
|
|
&& self.check_pattern_count_empty(Scope::Subtitle);
|
|
let account_empty = self.check_pattern_count_empty(Scope::Account);
|
|
|
|
#[allow(clippy::type_complexity)]
|
|
let mut tree: Vec<(Season, Vec<(Episode, Vec<Stream>)>)> = vec![];
|
|
|
|
let series = if !series_empty {
|
|
let series = match &media_collection {
|
|
MediaCollection::Series(series) => series.clone(),
|
|
MediaCollection::Season(season) => season.series().await?,
|
|
MediaCollection::Episode(episode) => episode.series().await?,
|
|
_ => panic!(),
|
|
};
|
|
if !self.filter_options.check_series(&series) {
|
|
return Ok("".to_string());
|
|
}
|
|
series
|
|
} else {
|
|
Series::default()
|
|
};
|
|
if !season_empty || !episode_empty || !stream_empty {
|
|
let tmp_seasons = match &media_collection {
|
|
MediaCollection::Series(series) => series.seasons().await?,
|
|
MediaCollection::Season(season) => vec![season.clone()],
|
|
MediaCollection::Episode(_) => vec![],
|
|
_ => panic!(),
|
|
};
|
|
let mut seasons = vec![];
|
|
for season in tmp_seasons {
|
|
seasons.push(season.clone());
|
|
for version in season.versions {
|
|
if season.id == version.id {
|
|
continue;
|
|
}
|
|
if self.filter_options.audio.contains(&version.audio_locale) {
|
|
seasons.push(version.season().await?)
|
|
}
|
|
}
|
|
}
|
|
tree.extend(
|
|
self.filter_options
|
|
.filter_seasons(seasons)
|
|
.into_iter()
|
|
.map(|s| (s, vec![])),
|
|
)
|
|
} else {
|
|
tree.push((Season::default(), vec![]))
|
|
}
|
|
if !episode_empty || !stream_empty {
|
|
match &media_collection {
|
|
MediaCollection::Episode(episode) => {
|
|
let mut episodes = vec![episode.clone()];
|
|
for version in &episode.versions {
|
|
if episode.id == version.id {
|
|
continue;
|
|
}
|
|
if self.filter_options.audio.contains(&version.audio_locale) {
|
|
episodes.push(version.episode().await?)
|
|
}
|
|
}
|
|
tree.push((
|
|
Season::default(),
|
|
episodes
|
|
.into_iter()
|
|
.filter(|e| self.filter_options.audio.contains(&e.audio_locale))
|
|
.map(|e| (e, vec![]))
|
|
.collect(),
|
|
))
|
|
}
|
|
_ => {
|
|
for (season, episodes) in tree.iter_mut() {
|
|
episodes.extend(
|
|
self.filter_options
|
|
.filter_episodes(season.episodes().await?)
|
|
.into_iter()
|
|
.map(|e| (e, vec![])),
|
|
)
|
|
}
|
|
}
|
|
};
|
|
} else {
|
|
for (_, episodes) in tree.iter_mut() {
|
|
episodes.push((Episode::default(), vec![]))
|
|
}
|
|
}
|
|
if !stream_empty {
|
|
for (_, episodes) in tree.iter_mut() {
|
|
for (episode, streams) in episodes {
|
|
let stream = episode.stream_maybe_without_drm().await?;
|
|
stream.clone().invalidate().await?;
|
|
streams.push(stream)
|
|
}
|
|
}
|
|
} else {
|
|
for (_, episodes) in tree.iter_mut() {
|
|
for (_, streams) in episodes {
|
|
streams.push(Stream::default())
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut output = vec![];
|
|
let account_map = if !account_empty {
|
|
self.serializable_to_json_map(FormatAccount::async_from(&self.crunchyroll).await?)
|
|
} else {
|
|
Map::default()
|
|
};
|
|
let series_map = self.serializable_to_json_map(FormatSeries::from(&series));
|
|
for (season, episodes) in tree {
|
|
let season_map = self.serializable_to_json_map(FormatSeason::from(&season));
|
|
for (episode, streams) in episodes {
|
|
let episode_map = self.serializable_to_json_map(FormatEpisode::from(&episode));
|
|
for stream in streams {
|
|
let stream_map = self.serializable_to_json_map(FormatStream::from(&stream));
|
|
|
|
output.push(
|
|
self.replace_all(
|
|
HashMap::from([
|
|
(Scope::Account, &account_map),
|
|
(Scope::Series, &series_map),
|
|
(Scope::Season, &season_map),
|
|
(Scope::Episode, &episode_map),
|
|
(Scope::Stream, &stream_map),
|
|
]),
|
|
stream,
|
|
)
|
|
.unwrap_or_default(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(output.join("\n"))
|
|
}
|
|
|
|
async fn parse_movie_listing(&self, media_collection: MediaCollection) -> Result<String> {
|
|
let movie_listing_empty = self.check_pattern_count_empty(Scope::MovieListing);
|
|
let movie_empty = self.check_pattern_count_empty(Scope::Movie);
|
|
let stream_empty = self.check_pattern_count_empty(Scope::Stream);
|
|
|
|
let mut tree: Vec<(Movie, Vec<Stream>)> = vec![];
|
|
|
|
let movie_listing = if !movie_listing_empty {
|
|
let movie_listing = match &media_collection {
|
|
MediaCollection::MovieListing(movie_listing) => movie_listing.clone(),
|
|
MediaCollection::Movie(movie) => movie.movie_listing().await?,
|
|
_ => panic!(),
|
|
};
|
|
if !self.filter_options.check_movie_listing(&movie_listing) {
|
|
return Ok("".to_string());
|
|
}
|
|
movie_listing
|
|
} else {
|
|
MovieListing::default()
|
|
};
|
|
if !movie_empty || !stream_empty {
|
|
let movies = match &media_collection {
|
|
MediaCollection::MovieListing(movie_listing) => movie_listing.movies().await?,
|
|
MediaCollection::Movie(movie) => vec![movie.clone()],
|
|
_ => panic!(),
|
|
};
|
|
tree.extend(movies.into_iter().map(|m| (m, vec![])))
|
|
}
|
|
if !stream_empty {
|
|
for (movie, streams) in tree.iter_mut() {
|
|
streams.push(movie.stream_maybe_without_drm().await?)
|
|
}
|
|
} else {
|
|
for (_, streams) in tree.iter_mut() {
|
|
streams.push(Stream::default())
|
|
}
|
|
}
|
|
|
|
let mut output = vec![];
|
|
let movie_listing_map =
|
|
self.serializable_to_json_map(FormatMovieListing::from(&movie_listing));
|
|
for (movie, streams) in tree {
|
|
let movie_map = self.serializable_to_json_map(FormatMovie::from(&movie));
|
|
for stream in streams {
|
|
let stream_map = self.serializable_to_json_map(FormatStream::from(&stream));
|
|
|
|
output.push(
|
|
self.replace_all(
|
|
HashMap::from([
|
|
(Scope::MovieListing, &movie_listing_map),
|
|
(Scope::Movie, &movie_map),
|
|
(Scope::Stream, &stream_map),
|
|
]),
|
|
stream,
|
|
)
|
|
.unwrap_or_default(),
|
|
)
|
|
}
|
|
}
|
|
|
|
Ok(output.join("\n"))
|
|
}
|
|
|
|
async fn parse_music_video(&self, media_collection: MediaCollection) -> Result<String> {
|
|
let music_video_empty = self.check_pattern_count_empty(Scope::MusicVideo);
|
|
let stream_empty = self.check_pattern_count_empty(Scope::Stream);
|
|
|
|
let music_video = must_match_if_true!(!music_video_empty => media_collection|MediaCollection::MusicVideo(music_video) => music_video.clone()).unwrap_or_default();
|
|
let stream = must_match_if_true!(!stream_empty => media_collection|MediaCollection::MusicVideo(music_video) => music_video.stream_maybe_without_drm().await?).unwrap_or_default();
|
|
|
|
let music_video_map = self.serializable_to_json_map(FormatMusicVideo::from(&music_video));
|
|
let stream_map = self.serializable_to_json_map(FormatStream::from(&stream));
|
|
|
|
let output = self
|
|
.replace_all(
|
|
HashMap::from([
|
|
(Scope::MusicVideo, &music_video_map),
|
|
(Scope::Stream, &stream_map),
|
|
]),
|
|
stream,
|
|
)
|
|
.unwrap_or_default();
|
|
Ok(output)
|
|
}
|
|
|
|
async fn parse_concert(&self, media_collection: MediaCollection) -> Result<String> {
|
|
let concert_empty = self.check_pattern_count_empty(Scope::Concert);
|
|
let stream_empty = self.check_pattern_count_empty(Scope::Stream);
|
|
|
|
let concert = must_match_if_true!(!concert_empty => media_collection|MediaCollection::Concert(concert) => concert.clone()).unwrap_or_default();
|
|
let stream = must_match_if_true!(!stream_empty => media_collection|MediaCollection::Concert(concert) => concert.stream_maybe_without_drm().await?).unwrap_or_default();
|
|
|
|
let concert_map = self.serializable_to_json_map(FormatConcert::from(&concert));
|
|
let stream_map = self.serializable_to_json_map(FormatStream::from(&stream));
|
|
|
|
let output = self
|
|
.replace_all(
|
|
HashMap::from([(Scope::Concert, &concert_map), (Scope::Stream, &stream_map)]),
|
|
stream,
|
|
)
|
|
.unwrap_or_default();
|
|
Ok(output)
|
|
}
|
|
|
|
fn serializable_to_json_map<S: Serialize>(&self, s: S) -> Map<String, Value> {
|
|
serde_json::from_value(serde_json::to_value(s).unwrap()).unwrap()
|
|
}
|
|
|
|
fn check_pattern_count_empty(&self, scope: Scope) -> bool {
|
|
self.pattern_count.get(&scope).cloned().unwrap_or_default() == 0
|
|
}
|
|
|
|
fn check_scopes(&self, available_scopes: Vec<Scope>) -> Result<()> {
|
|
for (_, scope, field) in self.pattern.iter() {
|
|
if !available_scopes.contains(scope) {
|
|
bail!(
|
|
"'{}.{}' is not a valid keyword",
|
|
format!("{:?}", scope).to_lowercase(),
|
|
field
|
|
)
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn replace_all(
|
|
&self,
|
|
values: HashMap<Scope, &Map<String, Value>>,
|
|
mut stream: Stream,
|
|
) -> Option<String> {
|
|
if stream.subtitles.is_empty() {
|
|
if !self.check_pattern_count_empty(Scope::Subtitle) {
|
|
return None;
|
|
}
|
|
stream
|
|
.subtitles
|
|
.insert(Locale::Custom("".to_string()), Subtitle::default());
|
|
}
|
|
|
|
let mut output = vec![];
|
|
for (_, subtitle) in stream.subtitles {
|
|
let subtitle_map = self.serializable_to_json_map(FormatSubtitle::from(&subtitle));
|
|
let mut tmp_values = values.clone();
|
|
tmp_values.insert(Scope::Subtitle, &subtitle_map);
|
|
output.push(self.replace(tmp_values))
|
|
}
|
|
|
|
Some(output.join("\n"))
|
|
}
|
|
|
|
fn replace(&self, values: HashMap<Scope, &Map<String, Value>>) -> String {
|
|
let mut output = self.input.clone();
|
|
let mut offset = 0;
|
|
for (range, scope, field) in &self.pattern {
|
|
let item =
|
|
serde_plain::to_string(values.get(scope).unwrap().get(field.as_str()).unwrap())
|
|
.unwrap();
|
|
let start = (range.start as i32 + offset) as usize;
|
|
let end = (range.end as i32 + offset) as usize;
|
|
output.replace_range(start..end, &item);
|
|
offset += item.len() as i32 - range.len() as i32;
|
|
}
|
|
|
|
output
|
|
}
|
|
}
|