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 pub allow_adult: bool,
123 #[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(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(AniError::InputInvalidEpisode)?;
256 let end_index = available
257 .iter()
258 .position(|v| v == end)
259 .ok_or(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}