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::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 pub allow_adult: bool,
126 #[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}