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    Anikoto2,
12    Anikoto,
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::InvalidInput {
32                input: value.to_string(),
33                reason: "provider must be anikoto or anikoto2".to_string(),
34            }),
35        }
36    }
37}
38
39#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
40#[serde(rename_all = "lowercase")]
41pub enum TranslationType {
42    #[default]
43    Sub,
44    Dub,
45}
46
47impl fmt::Display for TranslationType {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.write_str(match self {
50            Self::Sub => "sub",
51            Self::Dub => "dub",
52        })
53    }
54}
55
56impl FromStr for TranslationType {
57    type Err = AniError;
58    fn from_str(value: &str) -> Result<Self> {
59        match value.to_ascii_lowercase().as_str() {
60            "sub" => Ok(Self::Sub),
61            "dub" => Ok(Self::Dub),
62            _ => Err(AniError::InvalidInput {
63                input: value.to_string(),
64                reason: "translation type must be sub or dub".to_string(),
65            }),
66        }
67    }
68}
69
70#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
71pub enum SearchSort {
72    #[serde(rename = "latest-updated")]
73    LatestUpdated,
74    #[serde(rename = "latest-added")]
75    LatestAdded,
76    Score,
77    #[serde(rename = "name-az")]
78    NameAz,
79    #[serde(rename = "release-date")]
80    ReleaseDate,
81    #[serde(rename = "most-viewed")]
82    MostViewed,
83    #[serde(rename = "number_of_episodes")]
84    NumberOfEpisodes,
85}
86
87impl fmt::Display for SearchSort {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.write_str(match self {
90            Self::LatestUpdated => "latest-updated",
91            Self::LatestAdded => "latest-added",
92            Self::Score => "score",
93            Self::NameAz => "name-az",
94            Self::ReleaseDate => "release-date",
95            Self::MostViewed => "most-viewed",
96            Self::NumberOfEpisodes => "number_of_episodes",
97        })
98    }
99}
100
101impl FromStr for SearchSort {
102    type Err = AniError;
103
104    fn from_str(value: &str) -> Result<Self> {
105        let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
106        match normalized.as_str() {
107            "latest-updated" => Ok(Self::LatestUpdated),
108            "latest-added" => Ok(Self::LatestAdded),
109            "score" => Ok(Self::Score),
110            "name-az" | "nameaz" => Ok(Self::NameAz),
111            "release-date" | "releasedate" => Ok(Self::ReleaseDate),
112            "most-viewed" | "mostviewed" => Ok(Self::MostViewed),
113            "number-of-episodes" | "numberofepisodes" => Ok(Self::NumberOfEpisodes),
114            _ => Err(AniError::InvalidInput {
115                input: value.to_string(),
116                reason: "search sort must be one of: latest-updated, latest-added, score, name-az, release-date, most-viewed, number_of_episodes".to_string(),
117            }),
118        }
119    }
120}
121
122#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
123pub struct SearchOptions {
124    /// Include titles marked as adult by the selected catalog.
125    pub allow_adult: bool,
126    /// Preferred ordering for provider-specific search filter URLs.
127    #[serde(default)]
128    pub sort: Option<SearchSort>,
129}
130
131#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
132pub struct SearchResult {
133    pub id: String,
134    pub name: String,
135    pub episodes: f64,
136    #[serde(default)]
137    pub provider: CatalogProvider,
138}
139
140#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
141pub struct SubtitleTrack {
142    pub label: String,
143    pub url: String,
144    #[serde(default)]
145    pub default: bool,
146}
147
148#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
149pub struct RequestHeaders {
150    pub referer: Option<String>,
151    pub origin: Option<String>,
152    #[serde(default)]
153    pub extra: BTreeMap<String, String>,
154}
155
156#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
157pub struct StreamLink {
158    pub url: String,
159    pub resolution: String,
160    pub hls: bool,
161    pub provider: String,
162    pub downloadable: bool,
163    #[serde(default)]
164    pub headers: RequestHeaders,
165    #[serde(default)]
166    pub subtitles: Vec<SubtitleTrack>,
167}
168
169fn resolution_weight(value: &str) -> i32 {
170    let digits: String = value.chars().take_while(|c| c.is_ascii_digit()).collect();
171    digits.parse().unwrap_or_else(|_| {
172        if value.eq_ignore_ascii_case("auto") {
173            -1
174        } else {
175            0
176        }
177    })
178}
179
180fn provider_weight(value: &str) -> i32 {
181    let value = value.to_ascii_lowercase();
182    if value.contains("s-mp4") {
183        3_000
184    } else if value.contains("mp4") {
185        2_000
186    } else if value.contains("default") {
187        1_000
188    } else {
189        0
190    }
191}
192
193pub(crate) fn sort_streams(streams: &mut [StreamLink]) {
194    streams.sort_by(|a, b| {
195        provider_weight(&b.provider)
196            .cmp(&provider_weight(&a.provider))
197            .then_with(|| resolution_weight(&b.resolution).cmp(&resolution_weight(&a.resolution)))
198            .then_with(|| b.hls.cmp(&a.hls))
199            .then_with(|| a.provider.cmp(&b.provider))
200    });
201}
202
203pub fn choose_quality<'a>(streams: &'a [StreamLink], quality: &str) -> Option<&'a StreamLink> {
204    if streams.is_empty() {
205        return None;
206    }
207    match quality.to_ascii_lowercase().as_str() {
208        "best" => streams.first(),
209        "worst" => streams
210            .iter()
211            .filter(|s| resolution_weight(&s.resolution) > 0)
212            .min_by_key(|s| resolution_weight(&s.resolution))
213            .or_else(|| streams.last()),
214        requested => streams
215            .iter()
216            .find(|s| s.resolution.to_ascii_lowercase().contains(requested))
217            .or_else(|| streams.first()),
218    }
219}
220
221fn episode_number(value: &str) -> Option<f64> {
222    value.parse::<f64>().ok().filter(|n| n.is_finite())
223}
224
225pub fn sort_episodes(episodes: &mut [String]) {
226    episodes.sort_by(|a, b| match (episode_number(a), episode_number(b)) {
227        (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
228        _ => a.cmp(b),
229    });
230}
231
232pub fn expand_episode_selection(selection: &str, available: &[String]) -> Result<Vec<String>> {
233    let trimmed = selection.trim();
234    if trimmed == "-1" {
235        return available
236            .last()
237            .cloned()
238            .map(|v| vec![v])
239            .ok_or(AniError::NoEpisodesAvailable { anime: None });
240    }
241    if trimmed.contains(char::is_whitespace) {
242        let requested: Vec<_> = trimmed.split_whitespace().map(str::to_owned).collect();
243        if requested.iter().all(|v| available.contains(v)) {
244            return Ok(requested);
245        }
246        eprintln!("One or more selected episodes do not exist");
247        return Err(AniError::InvalidEpisodeSelection {
248            episode: trimmed.to_string(),
249            reason: "one or more selected episodes do not exist".to_string(),
250        });
251    }
252    if let Some((start, end)) = trimmed.split_once('-') {
253        let end = if end == "-1" || end.is_empty() {
254            available.last().map(String::as_str).unwrap_or("")
255        } else {
256            end
257        };
258        let start_index = available.iter().position(|v| v == start).ok_or_else(|| {
259            AniError::InvalidEpisodeSelection {
260                episode: start.to_string(),
261                reason: "range start does not exist".to_string(),
262            }
263        })?;
264        let end_index = available.iter().position(|v| v == end).ok_or_else(|| {
265            AniError::InvalidEpisodeSelection {
266                episode: end.to_string(),
267                reason: "range end does not exist".to_string(),
268            }
269        })?;
270        if start_index > end_index {
271            eprintln!("Episode range is reversed");
272            return Err(AniError::InvalidEpisodeSelection {
273                episode: trimmed.to_string(),
274                reason: "episode range is reversed".to_string(),
275            });
276        }
277        return Ok(available[start_index..=end_index].to_vec());
278    }
279    if available.iter().any(|v| v == trimmed) {
280        Ok(vec![trimmed.to_owned()])
281    } else {
282        Err(AniError::InvalidEpisodeSelection {
283            episode: trimmed.to_string(),
284            reason: "episode does not exist".to_string(),
285        })
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    fn stream(resolution: &str) -> StreamLink {
293        StreamLink {
294            url: resolution.into(),
295            resolution: resolution.into(),
296            hls: false,
297            provider: "Default".into(),
298            downloadable: true,
299            headers: RequestHeaders::default(),
300            subtitles: vec![],
301        }
302    }
303
304    #[test]
305    fn default_provider_is_anikoto_cz() {
306        assert_eq!(CatalogProvider::default(), CatalogProvider::Anikoto2);
307    }
308
309    #[test]
310    fn parse_search_sort_variants() {
311        assert_eq!(
312            SearchSort::from_str("latest-updated").unwrap(),
313            SearchSort::LatestUpdated
314        );
315        assert_eq!(SearchSort::from_str("score").unwrap(), SearchSort::Score);
316        assert_eq!(
317            SearchSort::from_str("number_of_episodes").unwrap(),
318            SearchSort::NumberOfEpisodes
319        );
320        assert_eq!(
321            SearchSort::from_str("most-viewed").unwrap(),
322            SearchSort::MostViewed
323        );
324    }
325
326    #[test]
327    fn quality_selection_falls_back_to_best() {
328        let streams = vec![stream("1080p"), stream("720p"), stream("480p")];
329        assert_eq!(
330            choose_quality(&streams, "worst").unwrap().resolution,
331            "480p"
332        );
333        assert_eq!(choose_quality(&streams, "720").unwrap().resolution, "720p");
334        assert_eq!(
335            choose_quality(&streams, "1440p").unwrap().resolution,
336            "1080p"
337        );
338    }
339
340    #[test]
341    fn expands_ranges_and_latest() {
342        let eps = vec!["1".into(), "2".into(), "2.5".into(), "3".into()];
343        assert_eq!(
344            expand_episode_selection("2-3", &eps).unwrap(),
345            vec!["2", "2.5", "3"]
346        );
347        assert_eq!(expand_episode_selection("-1", &eps).unwrap(), vec!["3"]);
348    }
349}