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