Skip to main content

ani_lib/
models.rs

1use std::{cmp::Ordering, collections::BTreeMap, fmt, str::FromStr};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{AniError, Result};
6
7#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
8#[serde(rename_all = "lowercase")]
9pub enum CatalogProvider {
10    #[default]
11    Anikoto,
12    Anikoto2,
13}
14
15impl fmt::Display for CatalogProvider {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        f.write_str(match self {
18            Self::Anikoto => "anikoto",
19            Self::Anikoto2 => "anikoto2",
20        })
21    }
22}
23
24impl FromStr for CatalogProvider {
25    type Err = AniError;
26
27    fn from_str(value: &str) -> Result<Self> {
28        match value.to_ascii_lowercase().as_str() {
29            "anikoto" | "anikoto1" | "anikoto-api" => Ok(Self::Anikoto),
30            "anikoto2" | "anikoto-cz" | "anikoto.cz" => Ok(Self::Anikoto2),
31            _ => Err(AniError::Input(format!(
32                "provider must be anikoto or anikoto2, got {value}"
33            ))),
34        }
35    }
36}
37
38#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum TranslationType {
41    #[default]
42    Sub,
43    Dub,
44}
45
46impl fmt::Display for TranslationType {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str(match self {
49            Self::Sub => "sub",
50            Self::Dub => "dub",
51        })
52    }
53}
54
55impl FromStr for TranslationType {
56    type Err = AniError;
57    fn from_str(value: &str) -> Result<Self> {
58        match value.to_ascii_lowercase().as_str() {
59            "sub" => Ok(Self::Sub),
60            "dub" => Ok(Self::Dub),
61            _ => Err(AniError::Input(format!(
62                "translation type must be sub or dub, got {value}"
63            ))),
64        }
65    }
66}
67
68#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
69pub struct SearchOptions {
70    /// Include titles marked as adult by the selected catalog.
71    pub allow_adult: bool,
72}
73
74#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
75pub struct SearchResult {
76    pub id: String,
77    pub name: String,
78    pub episodes: f64,
79    #[serde(default)]
80    pub provider: CatalogProvider,
81}
82
83#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
84pub struct SubtitleTrack {
85    pub label: String,
86    pub url: String,
87    #[serde(default)]
88    pub default: bool,
89}
90
91#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
92pub struct RequestHeaders {
93    pub referer: Option<String>,
94    pub origin: Option<String>,
95    #[serde(default)]
96    pub extra: BTreeMap<String, String>,
97}
98
99#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
100pub struct StreamLink {
101    pub url: String,
102    pub resolution: String,
103    pub hls: bool,
104    pub provider: String,
105    pub downloadable: bool,
106    #[serde(default)]
107    pub headers: RequestHeaders,
108    #[serde(default)]
109    pub subtitles: Vec<SubtitleTrack>,
110}
111
112fn resolution_weight(value: &str) -> i32 {
113    let digits: String = value.chars().take_while(|c| c.is_ascii_digit()).collect();
114    digits.parse().unwrap_or_else(|_| {
115        if value.eq_ignore_ascii_case("auto") {
116            -1
117        } else {
118            0
119        }
120    })
121}
122
123fn provider_weight(value: &str) -> i32 {
124    let value = value.to_ascii_lowercase();
125    if value.contains("s-mp4") {
126        3_000
127    } else if value.contains("mp4") {
128        2_000
129    } else if value.contains("default") {
130        1_000
131    } else {
132        0
133    }
134}
135
136pub(crate) fn sort_streams(streams: &mut [StreamLink]) {
137    streams.sort_by(|a, b| {
138        provider_weight(&b.provider)
139            .cmp(&provider_weight(&a.provider))
140            .then_with(|| resolution_weight(&b.resolution).cmp(&resolution_weight(&a.resolution)))
141            .then_with(|| b.hls.cmp(&a.hls))
142            .then_with(|| a.provider.cmp(&b.provider))
143    });
144}
145
146pub fn choose_quality<'a>(streams: &'a [StreamLink], quality: &str) -> Option<&'a StreamLink> {
147    if streams.is_empty() {
148        return None;
149    }
150    match quality.to_ascii_lowercase().as_str() {
151        "best" => streams.first(),
152        "worst" => streams
153            .iter()
154            .filter(|s| resolution_weight(&s.resolution) > 0)
155            .min_by_key(|s| resolution_weight(&s.resolution))
156            .or_else(|| streams.last()),
157        requested => streams
158            .iter()
159            .find(|s| s.resolution.to_ascii_lowercase().contains(requested))
160            .or_else(|| streams.first()),
161    }
162}
163
164fn episode_number(value: &str) -> Option<f64> {
165    value.parse::<f64>().ok().filter(|n| n.is_finite())
166}
167
168pub fn sort_episodes(episodes: &mut [String]) {
169    episodes.sort_by(|a, b| match (episode_number(a), episode_number(b)) {
170        (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
171        _ => a.cmp(b),
172    });
173}
174
175pub fn expand_episode_selection(selection: &str, available: &[String]) -> Result<Vec<String>> {
176    let trimmed = selection.trim();
177    if trimmed == "-1" {
178        return available
179            .last()
180            .cloned()
181            .map(|v| vec![v])
182            .ok_or_else(|| AniError::UnavailableNoEpisodes);
183    }
184    if trimmed.contains(char::is_whitespace) {
185        let requested: Vec<_> = trimmed.split_whitespace().map(str::to_owned).collect();
186        if requested.iter().all(|v| available.contains(v)) {
187            return Ok(requested);
188        }
189        eprintln!("One or more selected episodes do not exist");
190        return Err(AniError::InputInvalidEpisode);
191    }
192    if let Some((start, end)) = trimmed.split_once('-') {
193        let end = if end == "-1" || end.is_empty() {
194            available.last().map(String::as_str).unwrap_or("")
195        } else {
196            end
197        };
198        let start_index = available
199            .iter()
200            .position(|v| v == start)
201            .ok_or_else(|| AniError::InputInvalidEpisode)?;
202        let end_index = available
203            .iter()
204            .position(|v| v == end)
205            .ok_or_else(|| AniError::InputInvalidEpisode)?;
206        if start_index > end_index {
207            eprintln!("Episode range is reversed");
208            return Err(AniError::InputInvalidEpisode);
209        }
210        return Ok(available[start_index..=end_index].to_vec());
211    }
212    if available.iter().any(|v| v == trimmed) {
213        Ok(vec![trimmed.to_owned()])
214    } else {
215        Err(AniError::InputInvalidEpisode)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    fn stream(resolution: &str) -> StreamLink {
223        StreamLink {
224            url: resolution.into(),
225            resolution: resolution.into(),
226            hls: false,
227            provider: "Default".into(),
228            downloadable: true,
229            headers: RequestHeaders::default(),
230            subtitles: vec![],
231        }
232    }
233
234    #[test]
235    fn quality_selection_falls_back_to_best() {
236        let streams = vec![stream("1080p"), stream("720p"), stream("480p")];
237        assert_eq!(
238            choose_quality(&streams, "worst").unwrap().resolution,
239            "480p"
240        );
241        assert_eq!(choose_quality(&streams, "720").unwrap().resolution, "720p");
242        assert_eq!(
243            choose_quality(&streams, "1440p").unwrap().resolution,
244            "1080p"
245        );
246    }
247
248    #[test]
249    fn expands_ranges_and_latest() {
250        let eps = vec!["1".into(), "2".into(), "2.5".into(), "3".into()];
251        assert_eq!(
252            expand_episode_selection("2-3", &eps).unwrap(),
253            vec!["2", "2.5", "3"]
254        );
255        assert_eq!(expand_episode_selection("-1", &eps).unwrap(), vec!["3"]);
256    }
257}