use std::{cmp::Ordering, collections::BTreeMap, fmt, str::FromStr};
use serde::{Deserialize, Serialize};
use crate::{AniError, Result};
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CatalogProvider {
#[default]
Anikoto2,
Anikoto,
}
impl fmt::Display for CatalogProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Anikoto => "anikoto",
Self::Anikoto2 => "anikoto2",
})
}
}
impl FromStr for CatalogProvider {
type Err = AniError;
fn from_str(value: &str) -> Result<Self> {
match value.to_ascii_lowercase().as_str() {
"anikoto" | "anikoto1" | "anikoto-api" => Ok(Self::Anikoto),
"anikoto2" | "anikoto-cz" | "anikoto.cz" => Ok(Self::Anikoto2),
_ => Err(AniError::InvalidInput {
input: value.to_string(),
reason: "provider must be anikoto or anikoto2".to_string(),
}),
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TranslationType {
#[default]
Sub,
Dub,
}
impl fmt::Display for TranslationType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Sub => "sub",
Self::Dub => "dub",
})
}
}
impl FromStr for TranslationType {
type Err = AniError;
fn from_str(value: &str) -> Result<Self> {
match value.to_ascii_lowercase().as_str() {
"sub" => Ok(Self::Sub),
"dub" => Ok(Self::Dub),
_ => Err(AniError::InvalidInput {
input: value.to_string(),
reason: "translation type must be sub or dub".to_string(),
}),
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
pub enum SearchSort {
#[serde(rename = "latest-updated")]
LatestUpdated,
#[serde(rename = "latest-added")]
LatestAdded,
Score,
#[serde(rename = "name-az")]
NameAz,
#[serde(rename = "release-date")]
ReleaseDate,
#[serde(rename = "most-viewed")]
MostViewed,
#[serde(rename = "number_of_episodes")]
NumberOfEpisodes,
}
impl fmt::Display for SearchSort {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::LatestUpdated => "latest-updated",
Self::LatestAdded => "latest-added",
Self::Score => "score",
Self::NameAz => "name-az",
Self::ReleaseDate => "release-date",
Self::MostViewed => "most-viewed",
Self::NumberOfEpisodes => "number_of_episodes",
})
}
}
impl FromStr for SearchSort {
type Err = AniError;
fn from_str(value: &str) -> Result<Self> {
let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
match normalized.as_str() {
"latest-updated" => Ok(Self::LatestUpdated),
"latest-added" => Ok(Self::LatestAdded),
"score" => Ok(Self::Score),
"name-az" | "nameaz" => Ok(Self::NameAz),
"release-date" | "releasedate" => Ok(Self::ReleaseDate),
"most-viewed" | "mostviewed" => Ok(Self::MostViewed),
"number-of-episodes" | "numberofepisodes" => Ok(Self::NumberOfEpisodes),
_ => Err(AniError::InvalidInput {
input: value.to_string(),
reason: "search sort must be one of: latest-updated, latest-added, score, name-az, release-date, most-viewed, number_of_episodes".to_string(),
}),
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct SearchOptions {
pub allow_adult: bool,
#[serde(default)]
pub sort: Option<SearchSort>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct SearchResult {
pub id: String,
pub name: String,
pub episodes: f64,
#[serde(default)]
pub provider: CatalogProvider,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct SubtitleTrack {
pub label: String,
pub url: String,
#[serde(default)]
pub default: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct RequestHeaders {
pub referer: Option<String>,
pub origin: Option<String>,
#[serde(default)]
pub extra: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct StreamLink {
pub url: String,
pub resolution: String,
pub hls: bool,
pub provider: String,
pub downloadable: bool,
#[serde(default)]
pub headers: RequestHeaders,
#[serde(default)]
pub subtitles: Vec<SubtitleTrack>,
}
fn resolution_weight(value: &str) -> i32 {
let digits: String = value.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().unwrap_or_else(|_| {
if value.eq_ignore_ascii_case("auto") {
-1
} else {
0
}
})
}
fn provider_weight(value: &str) -> i32 {
let value = value.to_ascii_lowercase();
if value.contains("s-mp4") {
3_000
} else if value.contains("mp4") {
2_000
} else if value.contains("default") {
1_000
} else {
0
}
}
pub(crate) fn sort_streams(streams: &mut [StreamLink]) {
streams.sort_by(|a, b| {
provider_weight(&b.provider)
.cmp(&provider_weight(&a.provider))
.then_with(|| resolution_weight(&b.resolution).cmp(&resolution_weight(&a.resolution)))
.then_with(|| b.hls.cmp(&a.hls))
.then_with(|| a.provider.cmp(&b.provider))
});
}
pub fn choose_quality<'a>(streams: &'a [StreamLink], quality: &str) -> Option<&'a StreamLink> {
if streams.is_empty() {
return None;
}
match quality.to_ascii_lowercase().as_str() {
"best" => streams.first(),
"worst" => streams
.iter()
.filter(|s| resolution_weight(&s.resolution) > 0)
.min_by_key(|s| resolution_weight(&s.resolution))
.or_else(|| streams.last()),
requested => streams
.iter()
.find(|s| s.resolution.to_ascii_lowercase().contains(requested))
.or_else(|| streams.first()),
}
}
fn episode_number(value: &str) -> Option<f64> {
value.parse::<f64>().ok().filter(|n| n.is_finite())
}
pub fn sort_episodes(episodes: &mut [String]) {
episodes.sort_by(|a, b| match (episode_number(a), episode_number(b)) {
(Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
_ => a.cmp(b),
});
}
pub fn expand_episode_selection(selection: &str, available: &[String]) -> Result<Vec<String>> {
let trimmed = selection.trim();
if trimmed == "-1" {
return available
.last()
.cloned()
.map(|v| vec![v])
.ok_or(AniError::NoEpisodesAvailable { anime: None });
}
if trimmed.contains(char::is_whitespace) {
let requested: Vec<_> = trimmed.split_whitespace().map(str::to_owned).collect();
if requested.iter().all(|v| available.contains(v)) {
return Ok(requested);
}
eprintln!("One or more selected episodes do not exist");
return Err(AniError::InvalidEpisodeSelection {
episode: trimmed.to_string(),
reason: "one or more selected episodes do not exist".to_string(),
});
}
if let Some((start, end)) = trimmed.split_once('-') {
let end = if end == "-1" || end.is_empty() {
available.last().map(String::as_str).unwrap_or("")
} else {
end
};
let start_index = available.iter().position(|v| v == start).ok_or_else(|| {
AniError::InvalidEpisodeSelection {
episode: start.to_string(),
reason: "range start does not exist".to_string(),
}
})?;
let end_index = available.iter().position(|v| v == end).ok_or_else(|| {
AniError::InvalidEpisodeSelection {
episode: end.to_string(),
reason: "range end does not exist".to_string(),
}
})?;
if start_index > end_index {
eprintln!("Episode range is reversed");
return Err(AniError::InvalidEpisodeSelection {
episode: trimmed.to_string(),
reason: "episode range is reversed".to_string(),
});
}
return Ok(available[start_index..=end_index].to_vec());
}
if available.iter().any(|v| v == trimmed) {
Ok(vec![trimmed.to_owned()])
} else {
Err(AniError::InvalidEpisodeSelection {
episode: trimmed.to_string(),
reason: "episode does not exist".to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stream(resolution: &str) -> StreamLink {
StreamLink {
url: resolution.into(),
resolution: resolution.into(),
hls: false,
provider: "Default".into(),
downloadable: true,
headers: RequestHeaders::default(),
subtitles: vec![],
}
}
#[test]
fn default_provider_is_anikoto_cz() {
assert_eq!(CatalogProvider::default(), CatalogProvider::Anikoto2);
}
#[test]
fn parse_search_sort_variants() {
assert_eq!(
SearchSort::from_str("latest-updated").unwrap(),
SearchSort::LatestUpdated
);
assert_eq!(SearchSort::from_str("score").unwrap(), SearchSort::Score);
assert_eq!(
SearchSort::from_str("number_of_episodes").unwrap(),
SearchSort::NumberOfEpisodes
);
assert_eq!(
SearchSort::from_str("most-viewed").unwrap(),
SearchSort::MostViewed
);
}
#[test]
fn quality_selection_falls_back_to_best() {
let streams = vec![stream("1080p"), stream("720p"), stream("480p")];
assert_eq!(
choose_quality(&streams, "worst").unwrap().resolution,
"480p"
);
assert_eq!(choose_quality(&streams, "720").unwrap().resolution, "720p");
assert_eq!(
choose_quality(&streams, "1440p").unwrap().resolution,
"1080p"
);
}
#[test]
fn expands_ranges_and_latest() {
let eps = vec!["1".into(), "2".into(), "2.5".into(), "3".into()];
assert_eq!(
expand_episode_selection("2-3", &eps).unwrap(),
vec!["2", "2.5", "3"]
);
assert_eq!(expand_episode_selection("-1", &eps).unwrap(), vec!["3"]);
}
}