mirror of
https://github.com/crunchy-labs/crunchy-cli.git
synced 2026-01-21 12:12:00 -06:00
Add basic search command
This commit is contained in:
parent
0beaa99bfd
commit
0aa648b1a5
10 changed files with 1352 additions and 702 deletions
579
Cargo.lock
generated
579
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,14 +5,14 @@ version = "3.0.0-dev.13"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.27", features = ["macros", "rt-multi-thread", "time"], default-features = false }
|
tokio = { version = "1.28", features = ["macros", "rt-multi-thread", "time"], default-features = false }
|
||||||
|
|
||||||
crunchy-cli-core = { path = "./crunchy-cli-core" }
|
crunchy-cli-core = { path = "./crunchy-cli-core" }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
clap = { version = "4.2", features = ["string"] }
|
clap = { version = "4.3", features = ["string"] }
|
||||||
clap_complete = "4.2"
|
clap_complete = "4.3"
|
||||||
clap_mangen = "0.2"
|
clap_mangen = "0.2"
|
||||||
|
|
||||||
crunchy-cli-core = { path = "./crunchy-cli-core" }
|
crunchy-cli-core = { path = "./crunchy-cli-core" }
|
||||||
|
|
|
||||||
572
crunchy-cli-core/Cargo.lock
generated
572
crunchy-cli-core/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,7 @@ anyhow = "1.0"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
clap = { version = "4.2", features = ["derive", "string"] }
|
clap = { version = "4.2", features = ["derive", "string"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
crunchyroll-rs = { version = "0.3.4", features = ["dash-stream"] }
|
crunchyroll-rs = { version = "0.3.6", features = ["dash-stream"] }
|
||||||
ctrlc = "3.2"
|
ctrlc = "3.2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
derive_setters = "0.1"
|
derive_setters = "0.1"
|
||||||
|
|
@ -23,10 +23,11 @@ reqwest = { version = "0.11", default-features = false, features = ["socks"] }
|
||||||
sanitize-filename = "0.4"
|
sanitize-filename = "0.4"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
serde_plain = "1.0"
|
||||||
shlex = "1.1"
|
shlex = "1.1"
|
||||||
tempfile = "3.5"
|
tempfile = "3.5"
|
||||||
terminal_size = "0.2"
|
terminal_size = "0.2"
|
||||||
tokio = { version = "1.27", features = ["macros", "rt-multi-thread", "time"] }
|
tokio = { version = "1.28", features = ["macros", "rt-multi-thread", "time"] }
|
||||||
sys-locale = "0.3"
|
sys-locale = "0.3"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,13 @@ use std::{env, fs};
|
||||||
mod archive;
|
mod archive;
|
||||||
mod download;
|
mod download;
|
||||||
mod login;
|
mod login;
|
||||||
|
mod search;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use archive::Archive;
|
pub use archive::Archive;
|
||||||
pub use download::Download;
|
pub use download::Download;
|
||||||
pub use login::Login;
|
pub use login::Login;
|
||||||
|
pub use search::Search;
|
||||||
|
|
||||||
#[async_trait::async_trait(?Send)]
|
#[async_trait::async_trait(?Send)]
|
||||||
trait Execute {
|
trait Execute {
|
||||||
|
|
@ -81,6 +83,7 @@ enum Command {
|
||||||
Archive(Archive),
|
Archive(Archive),
|
||||||
Download(Download),
|
Download(Download),
|
||||||
Login(Login),
|
Login(Login),
|
||||||
|
Search(Search),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
|
|
@ -128,6 +131,7 @@ pub async fn cli_entrypoint() {
|
||||||
pre_check_executor(login).await
|
pre_check_executor(login).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Command::Search(search) => pre_check_executor(search).await,
|
||||||
};
|
};
|
||||||
|
|
||||||
let ctx = match create_ctx(&mut cli).await {
|
let ctx = match create_ctx(&mut cli).await {
|
||||||
|
|
@ -173,6 +177,7 @@ pub async fn cli_entrypoint() {
|
||||||
Command::Archive(archive) => execute_executor(archive, ctx).await,
|
Command::Archive(archive) => execute_executor(archive, ctx).await,
|
||||||
Command::Download(download) => execute_executor(download, ctx).await,
|
Command::Download(download) => execute_executor(download, ctx).await,
|
||||||
Command::Login(login) => execute_executor(login, ctx).await,
|
Command::Login(login) => execute_executor(login, ctx).await,
|
||||||
|
Command::Search(search) => execute_executor(search, ctx).await,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
189
crunchy-cli-core/src/search/command.rs
Normal file
189
crunchy-cli-core/src/search/command.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
use crate::search::filter::FilterOptions;
|
||||||
|
use crate::search::format::Format;
|
||||||
|
use crate::utils::context::Context;
|
||||||
|
use crate::utils::parse::{parse_url, UrlFilter};
|
||||||
|
use crate::Execute;
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use crunchyroll_rs::common::StreamExt;
|
||||||
|
use crunchyroll_rs::search::QueryResults;
|
||||||
|
use crunchyroll_rs::{Episode, Locale, MediaCollection, MovieListing, MusicVideo, Series};
|
||||||
|
|
||||||
|
#[derive(Debug, clap::Parser)]
|
||||||
|
pub struct Search {
|
||||||
|
#[arg(help = "Audio languages to include")]
|
||||||
|
#[arg(long, default_values_t = vec![crate::utils::locale::system_locale()])]
|
||||||
|
audio: Vec<Locale>,
|
||||||
|
|
||||||
|
#[arg(help = "Filter the locale/language of subtitles according to the value of `--audio`")]
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
filter_subtitles: bool,
|
||||||
|
|
||||||
|
#[arg(help = "Limit of search top search results")]
|
||||||
|
#[arg(long, default_value_t = 5)]
|
||||||
|
search_top_results_limit: u32,
|
||||||
|
#[arg(help = "Limit of search series results")]
|
||||||
|
#[arg(long, default_value_t = 0)]
|
||||||
|
search_series_limit: u32,
|
||||||
|
#[arg(help = "Limit of search movie listing results")]
|
||||||
|
#[arg(long, default_value_t = 0)]
|
||||||
|
search_movie_listing_limit: u32,
|
||||||
|
#[arg(help = "Limit of search episode results")]
|
||||||
|
#[arg(long, default_value_t = 0)]
|
||||||
|
search_episode_limit: u32,
|
||||||
|
#[arg(help = "Limit of search music results")]
|
||||||
|
#[arg(long, default_value_t = 0)]
|
||||||
|
search_music_limit: u32,
|
||||||
|
|
||||||
|
/// Format of the output text.
|
||||||
|
///
|
||||||
|
/// You can specify keywords in a specific pattern and they will get replaced in the output text.
|
||||||
|
/// The required pattern for this begins with `{{`, then the keyword, and closes with `}}` (e.g. `{{episode.title}}`).
|
||||||
|
/// For example, if you want to get the title of an episode, you can use `Title {{episode.title}}` and `{{episode.title}}` will be replaced with the episode title
|
||||||
|
///
|
||||||
|
/// See the following list for all keywords and their meaning:
|
||||||
|
/// series.title → Series title
|
||||||
|
/// series.description → Series description
|
||||||
|
///
|
||||||
|
/// season.title → Season title
|
||||||
|
/// season.description → Season description
|
||||||
|
/// season.number → Season number
|
||||||
|
///
|
||||||
|
/// episode.title → Episode title
|
||||||
|
/// episode.description → Episode description
|
||||||
|
/// episode.locale → Episode locale/language
|
||||||
|
/// episode.number → Episode number
|
||||||
|
/// episode.sequence_number → Episode number. This number is unique unlike `episode.number` which sometimes can be duplicated
|
||||||
|
///
|
||||||
|
/// movie_listing.title → Movie listing title
|
||||||
|
/// movie_listing.description → Movie listing description
|
||||||
|
///
|
||||||
|
/// movie.title → Movie title
|
||||||
|
/// movie.description → Movie description
|
||||||
|
///
|
||||||
|
/// music_video.title → Music video title
|
||||||
|
/// music_video.description → Music video description
|
||||||
|
///
|
||||||
|
/// concert.title → Concert title
|
||||||
|
/// concert.description → Concert description
|
||||||
|
///
|
||||||
|
/// stream.locale → Stream locale/language
|
||||||
|
/// stream.dash_url → Stream url in DASH format
|
||||||
|
/// stream.hls_url → Stream url in HLS format
|
||||||
|
///
|
||||||
|
/// subtitle.locale → Subtitle locale/language
|
||||||
|
/// subtitle.url → Subtitle url
|
||||||
|
#[arg(short, long, verbatim_doc_comment)]
|
||||||
|
#[arg(default_value = "S{{season.number}}E{{episode.number}} - {{episode.title}}")]
|
||||||
|
output: String,
|
||||||
|
|
||||||
|
input: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait(?Send)]
|
||||||
|
impl Execute for Search {
|
||||||
|
async fn execute(self, ctx: Context) -> Result<()> {
|
||||||
|
let input = if crunchyroll_rs::parse::parse_url(&self.input).is_some() {
|
||||||
|
match parse_url(&ctx.crunchy, self.input.clone(), true).await {
|
||||||
|
Ok(ok) => vec![ok],
|
||||||
|
Err(e) => bail!("url {} could not be parsed: {}", self.input, e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut output = vec![];
|
||||||
|
|
||||||
|
let query = resolve_query(&self, ctx.crunchy.query(&self.input)).await?;
|
||||||
|
output.extend(query.0.into_iter().map(|m| (m, UrlFilter::default())));
|
||||||
|
output.extend(
|
||||||
|
query
|
||||||
|
.1
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| (s.into(), UrlFilter::default())),
|
||||||
|
);
|
||||||
|
output.extend(
|
||||||
|
query
|
||||||
|
.2
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| (m.into(), UrlFilter::default())),
|
||||||
|
);
|
||||||
|
output.extend(
|
||||||
|
query
|
||||||
|
.3
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| (e.into(), UrlFilter::default())),
|
||||||
|
);
|
||||||
|
output.extend(
|
||||||
|
query
|
||||||
|
.4
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| (m.into(), UrlFilter::default())),
|
||||||
|
);
|
||||||
|
|
||||||
|
output
|
||||||
|
};
|
||||||
|
|
||||||
|
for (media_collection, url_filter) in input {
|
||||||
|
let filter_options = FilterOptions {
|
||||||
|
audio: self.audio.clone(),
|
||||||
|
filter_subtitles: self.filter_subtitles,
|
||||||
|
url_filter,
|
||||||
|
};
|
||||||
|
|
||||||
|
let format = Format::new(self.output.clone(), filter_options)?;
|
||||||
|
println!("{}", format.parse(media_collection).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! resolve_query {
|
||||||
|
($limit:expr, $vec:expr, $item:expr) => {
|
||||||
|
if $limit > 0 {
|
||||||
|
let mut item_results = $item;
|
||||||
|
while let Some(item) = item_results.next().await {
|
||||||
|
$vec.push(item?);
|
||||||
|
if $vec.len() >= $limit as usize {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_query(
|
||||||
|
search: &Search,
|
||||||
|
query_results: QueryResults,
|
||||||
|
) -> Result<(
|
||||||
|
Vec<MediaCollection>,
|
||||||
|
Vec<Series>,
|
||||||
|
Vec<MovieListing>,
|
||||||
|
Vec<Episode>,
|
||||||
|
Vec<MusicVideo>,
|
||||||
|
)> {
|
||||||
|
let mut media_collection = vec![];
|
||||||
|
let mut series = vec![];
|
||||||
|
let mut movie_listing = vec![];
|
||||||
|
let mut episode = vec![];
|
||||||
|
let mut music_video = vec![];
|
||||||
|
|
||||||
|
resolve_query!(
|
||||||
|
search.search_top_results_limit,
|
||||||
|
media_collection,
|
||||||
|
query_results.top_results
|
||||||
|
);
|
||||||
|
resolve_query!(search.search_series_limit, series, query_results.series);
|
||||||
|
resolve_query!(
|
||||||
|
search.search_movie_listing_limit,
|
||||||
|
movie_listing,
|
||||||
|
query_results.movie_listing
|
||||||
|
);
|
||||||
|
resolve_query!(search.search_episode_limit, episode, query_results.episode);
|
||||||
|
resolve_query!(search.search_music_limit, music_video, query_results.music);
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
media_collection,
|
||||||
|
series,
|
||||||
|
movie_listing,
|
||||||
|
episode,
|
||||||
|
music_video,
|
||||||
|
))
|
||||||
|
}
|
||||||
56
crunchy-cli-core/src/search/filter.rs
Normal file
56
crunchy-cli-core/src/search/filter.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
use crate::utils::parse::UrlFilter;
|
||||||
|
use crunchyroll_rs::media::Subtitle;
|
||||||
|
use crunchyroll_rs::{Episode, Locale, MovieListing, Season, Series};
|
||||||
|
|
||||||
|
pub struct FilterOptions {
|
||||||
|
pub audio: Vec<Locale>,
|
||||||
|
pub filter_subtitles: bool,
|
||||||
|
pub url_filter: UrlFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FilterOptions {
|
||||||
|
pub fn check_series(&self, series: &Series) -> bool {
|
||||||
|
self.check_audio_language(&series.audio_locales)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filter_seasons(&self, mut seasons: Vec<Season>) -> Vec<Season> {
|
||||||
|
seasons.retain(|s| {
|
||||||
|
self.check_audio_language(&s.audio_locales)
|
||||||
|
&& self.url_filter.is_season_valid(s.season_number)
|
||||||
|
});
|
||||||
|
seasons
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filter_episodes(&self, mut episodes: Vec<Episode>) -> Vec<Episode> {
|
||||||
|
episodes.retain(|e| {
|
||||||
|
self.check_audio_language(&vec![e.audio_locale.clone()])
|
||||||
|
&& self
|
||||||
|
.url_filter
|
||||||
|
.is_episode_valid(e.episode_number, e.season_number)
|
||||||
|
});
|
||||||
|
episodes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_movie_listing(&self, movie_listing: &MovieListing) -> bool {
|
||||||
|
self.check_audio_language(
|
||||||
|
&movie_listing
|
||||||
|
.audio_locale
|
||||||
|
.clone()
|
||||||
|
.map_or(vec![], |a| vec![a.clone()]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filter_subtitles(&self, mut subtitles: Vec<Subtitle>) -> Vec<Subtitle> {
|
||||||
|
if self.filter_subtitles {
|
||||||
|
subtitles.retain(|s| self.check_audio_language(&vec![s.locale.clone()]))
|
||||||
|
}
|
||||||
|
subtitles
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_audio_language(&self, audio: &Vec<Locale>) -> bool {
|
||||||
|
if !self.audio.is_empty() {
|
||||||
|
return self.audio.iter().any(|a| audio.contains(a));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
625
crunchy-cli-core/src/search/format.rs
Normal file
625
crunchy-cli-core/src/search/format.rs
Normal file
|
|
@ -0,0 +1,625 @@
|
||||||
|
use crate::search::filter::FilterOptions;
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use crunchyroll_rs::media::{Stream, Subtitle};
|
||||||
|
use crunchyroll_rs::{
|
||||||
|
Concert, 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;
|
||||||
|
|
||||||
|
#[derive(Default, Serialize)]
|
||||||
|
struct FormatSeries {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Series> for FormatSeries {
|
||||||
|
fn from(value: &Series) -> Self {
|
||||||
|
Self {
|
||||||
|
title: value.title.clone(),
|
||||||
|
description: value.description.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize)]
|
||||||
|
struct FormatSeason {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub number: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Season> for FormatSeason {
|
||||||
|
fn from(value: &Season) -> Self {
|
||||||
|
Self {
|
||||||
|
title: value.title.clone(),
|
||||||
|
description: value.description.clone(),
|
||||||
|
number: value.season_number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize)]
|
||||||
|
struct FormatEpisode {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub locale: Locale,
|
||||||
|
pub number: u32,
|
||||||
|
pub sequence_number: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Episode> for FormatEpisode {
|
||||||
|
fn from(value: &Episode) -> Self {
|
||||||
|
Self {
|
||||||
|
title: value.title.clone(),
|
||||||
|
description: value.description.clone(),
|
||||||
|
locale: value.audio_locale.clone(),
|
||||||
|
number: value.episode_number,
|
||||||
|
sequence_number: value.sequence_number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize)]
|
||||||
|
struct FormatMovieListing {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&MovieListing> for FormatMovieListing {
|
||||||
|
fn from(value: &MovieListing) -> Self {
|
||||||
|
Self {
|
||||||
|
title: value.title.clone(),
|
||||||
|
description: value.description.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize)]
|
||||||
|
struct FormatMovie {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Movie> for FormatMovie {
|
||||||
|
fn from(value: &Movie) -> Self {
|
||||||
|
Self {
|
||||||
|
title: value.title.clone(),
|
||||||
|
description: value.description.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize)]
|
||||||
|
struct FormatMusicVideo {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&MusicVideo> for FormatMusicVideo {
|
||||||
|
fn from(value: &MusicVideo) -> Self {
|
||||||
|
Self {
|
||||||
|
title: value.title.clone(),
|
||||||
|
description: value.description.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize)]
|
||||||
|
struct FormatConcert {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Concert> for FormatConcert {
|
||||||
|
fn from(value: &Concert) -> Self {
|
||||||
|
Self {
|
||||||
|
title: value.title.clone(),
|
||||||
|
description: value.description.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize)]
|
||||||
|
struct FormatStream {
|
||||||
|
pub locale: Locale,
|
||||||
|
pub dash_url: String,
|
||||||
|
pub hls_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Stream> for FormatStream {
|
||||||
|
fn from(value: &Stream) -> Self {
|
||||||
|
let (dash_url, hls_url) = value.variants.get(&Locale::Custom("".to_string())).map_or(
|
||||||
|
("".to_string(), "".to_string()),
|
||||||
|
|v| {
|
||||||
|
(
|
||||||
|
v.adaptive_dash.clone().unwrap_or_default().url,
|
||||||
|
v.adaptive_hls.clone().unwrap_or_default().url,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
locale: value.audio_locale.clone(),
|
||||||
|
dash_url,
|
||||||
|
hls_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(Clone, Eq, PartialEq, Hash)]
|
||||||
|
enum Scope {
|
||||||
|
Series,
|
||||||
|
Season,
|
||||||
|
Episode,
|
||||||
|
MovieListing,
|
||||||
|
Movie,
|
||||||
|
MusicVideo,
|
||||||
|
Concert,
|
||||||
|
Stream,
|
||||||
|
Subtitle,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Format {
|
||||||
|
pattern: Vec<(Range<usize>, Scope, String)>,
|
||||||
|
pattern_count: HashMap<Scope, u32>,
|
||||||
|
input: String,
|
||||||
|
filter_options: FilterOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Format {
|
||||||
|
pub fn new(input: String, filter_options: FilterOptions) -> 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();
|
||||||
|
|
||||||
|
let field_check = HashMap::from([
|
||||||
|
(
|
||||||
|
Scope::Series,
|
||||||
|
serde_json::to_value(FormatSeries::default()).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Scope::Season,
|
||||||
|
serde_json::to_value(FormatSeason::default()).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Scope::Episode,
|
||||||
|
serde_json::to_value(FormatEpisode::default()).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Scope::MovieListing,
|
||||||
|
serde_json::to_value(FormatMovieListing::default()).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Scope::Movie,
|
||||||
|
serde_json::to_value(FormatMovie::default()).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Scope::MusicVideo,
|
||||||
|
serde_json::to_value(FormatMusicVideo::default()).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Scope::Concert,
|
||||||
|
serde_json::to_value(FormatConcert::default()).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Scope::Stream,
|
||||||
|
serde_json::to_value(FormatStream::default()).unwrap(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Scope::Subtitle,
|
||||||
|
serde_json::to_value(FormatSubtitle::default()).unwrap(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
_ => bail!("'{}.{}' is not a valid keyword", scope, field),
|
||||||
|
};
|
||||||
|
|
||||||
|
if field_check
|
||||||
|
.get(&format_pattern_scope)
|
||||||
|
.unwrap()
|
||||||
|
.as_object()
|
||||||
|
.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_pattern_count_empty(&self, scope: Scope) -> bool {
|
||||||
|
self.pattern_count.get(&scope).cloned().unwrap_or_default() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn parse(&self, media_collection: MediaCollection) -> Result<String> {
|
||||||
|
match &media_collection {
|
||||||
|
MediaCollection::Series(_)
|
||||||
|
| MediaCollection::Season(_)
|
||||||
|
| MediaCollection::Episode(_) => self.parse_series(media_collection).await,
|
||||||
|
MediaCollection::MovieListing(_) | MediaCollection::Movie(_) => {
|
||||||
|
self.parse_movie_listing(media_collection).await
|
||||||
|
}
|
||||||
|
MediaCollection::MusicVideo(_) => self.parse_music_video(media_collection).await,
|
||||||
|
MediaCollection::Concert(_) => 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 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 mut season in tmp_seasons {
|
||||||
|
if self
|
||||||
|
.filter_options
|
||||||
|
.audio
|
||||||
|
.iter()
|
||||||
|
.any(|a| season.audio_locales.contains(a))
|
||||||
|
{
|
||||||
|
seasons.push(season.clone())
|
||||||
|
}
|
||||||
|
seasons.extend(season.version(self.filter_options.audio.clone()).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![];
|
||||||
|
if self.filter_options.audio.contains(&episode.audio_locale) {
|
||||||
|
episodes.push(episode.clone())
|
||||||
|
}
|
||||||
|
episodes.extend(
|
||||||
|
episode
|
||||||
|
.clone()
|
||||||
|
.version(self.filter_options.audio.clone())
|
||||||
|
.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 {
|
||||||
|
streams.push(episode.streams().await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (_, episodes) in tree.iter_mut() {
|
||||||
|
for (_, streams) in episodes {
|
||||||
|
streams.push(Stream::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = vec![];
|
||||||
|
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 mut stream in streams {
|
||||||
|
let stream_map = self.serializable_to_json_map(FormatStream::from(&stream));
|
||||||
|
if stream.subtitles.is_empty() {
|
||||||
|
if !self.check_pattern_count_empty(Scope::Subtitle) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stream
|
||||||
|
.subtitles
|
||||||
|
.insert(Locale::Custom("".to_string()), Subtitle::default());
|
||||||
|
}
|
||||||
|
for subtitle in self
|
||||||
|
.filter_options
|
||||||
|
.filter_subtitles(stream.subtitles.into_values().collect())
|
||||||
|
{
|
||||||
|
let subtitle_map =
|
||||||
|
self.serializable_to_json_map(FormatSubtitle::from(&subtitle));
|
||||||
|
let replace_map = HashMap::from([
|
||||||
|
(Scope::Series, &series_map),
|
||||||
|
(Scope::Season, &season_map),
|
||||||
|
(Scope::Episode, &episode_map),
|
||||||
|
(Scope::Stream, &stream_map),
|
||||||
|
(Scope::Subtitle, &subtitle_map),
|
||||||
|
]);
|
||||||
|
output.push(self.replace(replace_map))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.streams().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 mut stream in streams {
|
||||||
|
let stream_map = self.serializable_to_json_map(FormatStream::from(&stream));
|
||||||
|
if stream.subtitles.is_empty() {
|
||||||
|
if !self.check_pattern_count_empty(Scope::Subtitle) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stream
|
||||||
|
.subtitles
|
||||||
|
.insert(Locale::Custom("".to_string()), Subtitle::default());
|
||||||
|
}
|
||||||
|
for subtitle in self
|
||||||
|
.filter_options
|
||||||
|
.filter_subtitles(stream.subtitles.into_values().collect())
|
||||||
|
{
|
||||||
|
let subtitle_map =
|
||||||
|
self.serializable_to_json_map(FormatSubtitle::from(&subtitle));
|
||||||
|
let replace_map = HashMap::from([
|
||||||
|
(Scope::MovieListing, &movie_listing_map),
|
||||||
|
(Scope::Movie, &movie_map),
|
||||||
|
(Scope::Stream, &stream_map),
|
||||||
|
(Scope::Subtitle, &subtitle_map),
|
||||||
|
]);
|
||||||
|
output.push(self.replace(replace_map))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = if !music_video_empty {
|
||||||
|
match &media_collection {
|
||||||
|
MediaCollection::MusicVideo(music_video) => music_video.clone(),
|
||||||
|
_ => panic!(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MusicVideo::default()
|
||||||
|
};
|
||||||
|
let mut stream = if !stream_empty {
|
||||||
|
match &media_collection {
|
||||||
|
MediaCollection::MusicVideo(music_video) => music_video.streams().await?,
|
||||||
|
_ => panic!(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Stream::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut output = vec![];
|
||||||
|
let music_video_map = self.serializable_to_json_map(FormatMusicVideo::from(&music_video));
|
||||||
|
let stream_map = self.serializable_to_json_map(FormatStream::from(&stream));
|
||||||
|
if stream.subtitles.is_empty() {
|
||||||
|
if !self.check_pattern_count_empty(Scope::Subtitle) {
|
||||||
|
return Ok("".to_string());
|
||||||
|
}
|
||||||
|
stream
|
||||||
|
.subtitles
|
||||||
|
.insert(Locale::Custom("".to_string()), Subtitle::default());
|
||||||
|
}
|
||||||
|
for subtitle in self
|
||||||
|
.filter_options
|
||||||
|
.filter_subtitles(stream.subtitles.into_values().collect())
|
||||||
|
{
|
||||||
|
let subtitle_map = self.serializable_to_json_map(FormatSubtitle::from(&subtitle));
|
||||||
|
let replace_map = HashMap::from([
|
||||||
|
(Scope::MusicVideo, &music_video_map),
|
||||||
|
(Scope::Stream, &stream_map),
|
||||||
|
(Scope::Subtitle, &subtitle_map),
|
||||||
|
]);
|
||||||
|
output.push(self.replace(replace_map))
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = if !concert_empty {
|
||||||
|
match &media_collection {
|
||||||
|
MediaCollection::Concert(concert) => concert.clone(),
|
||||||
|
_ => panic!(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Concert::default()
|
||||||
|
};
|
||||||
|
let mut stream = if !stream_empty {
|
||||||
|
match &media_collection {
|
||||||
|
MediaCollection::Concert(concert) => concert.streams().await?,
|
||||||
|
_ => panic!(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Stream::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut output = vec![];
|
||||||
|
let concert_map = self.serializable_to_json_map(FormatConcert::from(&concert));
|
||||||
|
let stream_map = self.serializable_to_json_map(FormatStream::from(&stream));
|
||||||
|
if stream.subtitles.is_empty() {
|
||||||
|
if !self.check_pattern_count_empty(Scope::Subtitle) {
|
||||||
|
return Ok("".to_string());
|
||||||
|
}
|
||||||
|
stream
|
||||||
|
.subtitles
|
||||||
|
.insert(Locale::Custom("".to_string()), Subtitle::default());
|
||||||
|
}
|
||||||
|
for subtitle in self
|
||||||
|
.filter_options
|
||||||
|
.filter_subtitles(stream.subtitles.into_values().collect())
|
||||||
|
{
|
||||||
|
let subtitle_map = self.serializable_to_json_map(FormatSubtitle::from(&subtitle));
|
||||||
|
let replace_map = HashMap::from([
|
||||||
|
(Scope::MusicVideo, &concert_map),
|
||||||
|
(Scope::Stream, &stream_map),
|
||||||
|
(Scope::Subtitle, &subtitle_map),
|
||||||
|
]);
|
||||||
|
output.push(self.replace(replace_map))
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
5
crunchy-cli-core/src/search/mod.rs
Normal file
5
crunchy-cli-core/src/search/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
mod command;
|
||||||
|
mod filter;
|
||||||
|
mod format;
|
||||||
|
|
||||||
|
pub use command::Search;
|
||||||
|
|
@ -8,7 +8,7 @@ use regex::Regex;
|
||||||
/// If a struct instance equals the [`Default::default()`] it's considered that no find is applied.
|
/// If a struct instance equals the [`Default::default()`] it's considered that no find is applied.
|
||||||
/// If `from_*` is [`None`] they're set to [`u32::MIN`].
|
/// If `from_*` is [`None`] they're set to [`u32::MIN`].
|
||||||
/// If `to_*` is [`None`] they're set to [`u32::MAX`].
|
/// If `to_*` is [`None`] they're set to [`u32::MAX`].
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Default)]
|
||||||
pub struct InnerUrlFilter {
|
pub struct InnerUrlFilter {
|
||||||
from_episode: Option<u32>,
|
from_episode: Option<u32>,
|
||||||
to_episode: Option<u32>,
|
to_episode: Option<u32>,
|
||||||
|
|
@ -16,11 +16,19 @@ pub struct InnerUrlFilter {
|
||||||
to_season: Option<u32>,
|
to_season: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug)]
|
||||||
pub struct UrlFilter {
|
pub struct UrlFilter {
|
||||||
inner: Vec<InnerUrlFilter>,
|
inner: Vec<InnerUrlFilter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for UrlFilter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: vec![InnerUrlFilter::default()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl UrlFilter {
|
impl UrlFilter {
|
||||||
pub fn is_season_valid(&self, season: u32) -> bool {
|
pub fn is_season_valid(&self, season: u32) -> bool {
|
||||||
self.inner.iter().any(|f| {
|
self.inner.iter().any(|f| {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue