1use std::{
2 collections::{HashMap, HashSet},
3 sync::{Arc, Mutex},
4 time::{Duration, Instant},
5};
6
7use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
8use regex::Regex;
9use reqwest::{Client, Response, StatusCode, header};
10use scraper::{Html, Selector};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use url::Url;
14
15use crate::{
16 AniError, CatalogProvider, RequestHeaders, Result, SearchOptions, SearchResult, SearchSort,
17 StreamLink, SubtitleTrack, TranslationType,
18 models::{sort_episodes, sort_streams},
19};
20
21const DEFAULT_BASE: &str = "https://anikoto.cz";
22const DEFAULT_MAPPER_BASE: &str = "https://mapper.nekostream.site/api/mal";
23const DEFAULT_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
24const CACHE_TTL: Duration = Duration::from_secs(5 * 60);
25const CACHE_LIMIT: usize = 100;
26const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
27const MAX_PLAYLIST_BYTES: usize = 4 * 1024 * 1024;
28
29#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "camelCase")]
31struct AnikotoCzId {
32 slug: String,
33 title: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 episodes: Option<u32>,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
39struct Episode {
40 number: String,
41 slug: String,
42 token: String,
43 mal_id: String,
44 timestamp: String,
45 sub: bool,
46 dub: bool,
47}
48
49#[derive(Clone, Debug)]
50struct Series {
51 canonical: String,
52 episodes: Vec<Episode>,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56struct Server {
57 label: String,
58 token: String,
59}
60
61#[derive(Clone, Debug)]
62struct Cached<T> {
63 expires_at: Instant,
64 value: T,
65}
66
67#[derive(Clone, Debug)]
68pub struct AnikotoCzClientBuilder {
69 base: String,
70 mapper_base: String,
71 user_agent: String,
72 timeout: Duration,
73}
74
75impl Default for AnikotoCzClientBuilder {
76 fn default() -> Self {
77 Self {
78 base: DEFAULT_BASE.into(),
79 mapper_base: DEFAULT_MAPPER_BASE.into(),
80 user_agent: DEFAULT_AGENT.into(),
81 timeout: Duration::from_secs(15),
82 }
83 }
84}
85
86impl AnikotoCzClientBuilder {
87 pub fn base_url(mut self, value: impl Into<String>) -> Self {
88 self.base = value.into();
89 self
90 }
91
92 pub fn mapper_base(mut self, value: impl Into<String>) -> Self {
93 self.mapper_base = value.into();
94 self
95 }
96
97 pub fn timeout(mut self, value: Duration) -> Self {
98 self.timeout = value;
99 self
100 }
101
102 pub fn build(self) -> Result<AnikotoCzClient> {
103 let http = Client::builder()
104 .timeout(self.timeout)
105 .user_agent(&self.user_agent)
106 .cookie_store(true)
107 .redirect(reqwest::redirect::Policy::limited(10))
108 .build()?;
109 Ok(AnikotoCzClient {
110 inner: Arc::new(Inner {
111 http,
112 base: self.base.trim_end_matches('/').into(),
113 mapper_base: self.mapper_base.trim_end_matches('/').into(),
114 user_agent: self.user_agent,
115 series: Mutex::new(HashMap::new()),
116 }),
117 })
118 }
119}
120
121struct Inner {
122 http: Client,
123 base: String,
124 mapper_base: String,
125 user_agent: String,
126 series: Mutex<HashMap<String, Cached<Series>>>,
127}
128
129#[derive(Clone)]
130pub struct AnikotoCzClient {
131 inner: Arc<Inner>,
132}
133
134impl AnikotoCzClient {
135 pub fn builder() -> AnikotoCzClientBuilder {
136 AnikotoCzClientBuilder::default()
137 }
138
139 pub fn new() -> Result<Self> {
140 Self::builder().build()
141 }
142
143 pub async fn search(&self, query: &str, mode: TranslationType) -> Result<Vec<SearchResult>> {
144 self.search_with_options(query, mode, SearchOptions::default())
145 .await
146 }
147
148 pub async fn search_with_options(
149 &self,
150 query: &str,
151 _mode: TranslationType,
152 options: SearchOptions,
153 ) -> Result<Vec<SearchResult>> {
154 let query = query.trim();
155 if query.is_empty() {
156 return Err(AniError::InputEmptyQuery);
157 }
158
159 let mut request_urls = filter_search_urls_for_base(&self.inner.base, query, options.sort);
160 let mut ajax_url = Url::parse(&format!("{}/ajax/anime/search", self.inner.base))?;
161 ajax_url.query_pairs_mut().append_pair("keyword", query);
162 request_urls.push(ajax_url.to_string());
163
164 for request_url in request_urls {
165 let html = if request_url.contains("/filter") {
166 match self
167 .get_text(
168 &request_url,
169 &self.inner.base,
170 false,
171 None,
172 MAX_RESPONSE_BYTES,
173 )
174 .await
175 {
176 Ok(html) => html,
177 Err(_) => continue,
178 }
179 } else {
180 let payload = match self
181 .get_json(&request_url, &self.inner.base, true, None)
182 .await
183 {
184 Ok(payload) => payload,
185 Err(_) => continue,
186 };
187 let result = match provider_result(&payload, "search") {
188 Ok(result) => result,
189 Err(_) => continue,
190 };
191 match result
192 .as_str()
193 .or_else(|| result.get("html").and_then(Value::as_str))
194 {
195 Some(html) => html.into(),
196 None => continue,
197 }
198 };
199
200 let results = parse_search(&self.inner.base, &html)?;
201 if !results.is_empty() {
202 return Ok(results);
203 }
204 }
205
206 Err(AniError::Provider(format!(
207 "Anikoto.cz returned no search results for {query:?}"
208 )))
209 }
210
211 pub async fn episodes(&self, show_id: &str, mode: TranslationType) -> Result<Vec<String>> {
212 let id = decode_id(show_id)?;
213 let series = self.load_series(&id.slug).await?;
214 let mut episodes = series
215 .episodes
216 .iter()
217 .filter(|episode| match mode {
218 TranslationType::Sub => episode.sub,
219 TranslationType::Dub => episode.dub,
220 })
221 .map(|episode| episode.number.clone())
222 .collect::<Vec<_>>();
223 sort_episodes(&mut episodes);
224 if episodes.is_empty() {
225 eprintln!("Anikoto.cz has no {mode} episodes for {}", id.title);
226 return Err(AniError::UnavailableNoEpisodes);
227 }
228 Ok(episodes)
229 }
230
231 pub async fn streams(
232 &self,
233 show_id: &str,
234 episode: &str,
235 mode: TranslationType,
236 ) -> Result<Vec<StreamLink>> {
237 let id = decode_id(show_id)?;
238 let series = self.load_series(&id.slug).await?;
239 let episode_number = normalize_episode(episode)?;
240 let selected = series
241 .episodes
242 .iter()
243 .find(|value| value.number == episode_number)
244 .ok_or_else(|| AniError::UnavailableNoEpisodes)?;
245 let available = match mode {
246 TranslationType::Sub => selected.sub,
247 TranslationType::Dub => selected.dub,
248 };
249 if !available {
250 eprintln!("Episode {episode} has no {mode} servers");
251 return Err(AniError::UnavailableNoEpisodes);
252 }
253
254 let episode_url = format!(
255 "{}/ep-{}",
256 series.canonical,
257 url::form_urlencoded::byte_serialize(selected.slug.as_bytes()).collect::<String>()
258 );
259 self.get_text(
260 &episode_url,
261 &series.canonical,
262 false,
263 None,
264 MAX_RESPONSE_BYTES,
265 )
266 .await?;
267 let mut server_url = Url::parse(&format!("{}/ajax/server/list", self.inner.base))?;
268 server_url
269 .query_pairs_mut()
270 .append_pair("servers", &selected.token);
271 let payload = self
272 .get_json(server_url.as_str(), &episode_url, true, None)
273 .await?;
274 let html = provider_result(&payload, "server list")?
275 .as_str()
276 .ok_or_else(|| {
277 AniError::Provider("Anikoto.cz server list contained no markup".into())
278 })?;
279 let mut servers = parse_servers(html, mode)?;
280 servers.extend(self.mapper_servers(selected, mode).await);
281 let mut seen = HashSet::new();
282 servers.retain(|server| seen.insert(server.token.clone()));
283
284 let mut streams = Vec::new();
285 let mut failures = Vec::new();
286 for server in servers {
287 match self.resolve_server(&server, &episode_url).await {
288 Ok(embed) => match self
289 .extract_native(&embed, &server.label, &episode_url)
290 .await
291 {
292 Ok(mut resolved) => streams.append(&mut resolved),
293 Err(error) => failures.push(format!("{}: {error}", server.label)),
294 },
295 Err(error) => failures.push(format!("{}: {error}", server.label)),
296 }
297 }
298 let mut seen = HashSet::new();
299 streams.retain(|stream| seen.insert(stream.url.clone()));
300 sort_streams(&mut streams);
301 if streams.is_empty() {
302 let detail = if failures.is_empty() {
303 "no supported native servers were returned".into()
304 } else {
305 failures.join("; ")
306 };
307 eprintln!("Anikoto.cz native source resolution failed: {detail}");
308 return Err(AniError::UnavailableNoEpisodes);
309 }
310 Ok(streams)
311 }
312
313 async fn load_series(&self, slug: &str) -> Result<Series> {
314 validate_slug(slug)?;
315 if let Some(value) = cache_get(&self.inner.series, slug) {
316 return Ok(value);
317 }
318 let requested = format!("{}/watch/{slug}", self.inner.base);
319 let show_html = self
320 .get_text(
321 &requested,
322 &self.inner.base,
323 false,
324 None,
325 MAX_RESPONSE_BYTES,
326 )
327 .await?;
328 let (show_id, canonical) = parse_show(&self.inner.base, &show_html)?;
329 let mut list_url = Url::parse(&format!("{}/ajax/episode/list/{show_id}", self.inner.base))?;
330 list_url
331 .query_pairs_mut()
332 .append_pair("style", "grid")
333 .append_pair("vrf", "");
334 let payload = self
335 .get_json(list_url.as_str(), &canonical, true, None)
336 .await?;
337 let html = provider_result(&payload, "episode list")?
338 .as_str()
339 .ok_or_else(|| {
340 AniError::Provider("Anikoto.cz episode list contained no markup".into())
341 })?;
342 let episodes = parse_episodes(html)?;
343 if episodes.is_empty() {
344 return Err(AniError::UnavailableNoEpisodes);
345 }
346 let series = Series {
347 canonical,
348 episodes,
349 };
350 cache_put(&self.inner.series, slug.into(), series.clone());
351 Ok(series)
352 }
353
354 async fn mapper_servers(&self, episode: &Episode, mode: TranslationType) -> Vec<Server> {
355 if episode.mal_id.is_empty() || episode.timestamp.is_empty() {
356 return Vec::new();
357 }
358 let mode = mode.to_string();
359 let url = format!(
360 "{}/{}/{}/{}",
361 self.inner.mapper_base,
362 encode_path(&episode.mal_id),
363 encode_path(&episode.slug),
364 encode_path(&episode.timestamp)
365 );
366 let Ok(payload) = self.get_json(&url, &self.inner.base, false, None).await else {
367 return Vec::new();
368 };
369 parse_mapper(&payload, &mode)
370 }
371
372 async fn resolve_server(&self, server: &Server, episode_url: &str) -> Result<String> {
373 let mut url = Url::parse(&format!("{}/ajax/server", self.inner.base))?;
374 url.query_pairs_mut().append_pair("get", &server.token);
375 let payload = self.get_json(url.as_str(), episode_url, true, None).await?;
376 let result = provider_result(&payload, "server")?;
377 let raw = result
378 .get("url")
379 .and_then(Value::as_str)
380 .or_else(|| result.as_str())
381 .ok_or_else(|| AniError::Provider("server returned no embed URL".into()))?;
382 validate_remote_url(raw).map(|url| url.to_string())
383 }
384
385 async fn extract_native(
386 &self,
387 embed_url: &str,
388 provider: &str,
389 episode_url: &str,
390 ) -> Result<Vec<StreamLink>> {
391 let embed = validate_remote_url(embed_url)?;
392 let host = embed.host_str().unwrap_or_default();
393 if !["megaplay.buzz", "vidtube.site"]
394 .iter()
395 .any(|domain| host_matches(host, domain))
396 {
397 return Err(AniError::Provider(format!("unsupported embed host {host}")));
398 }
399 let origin = embed.origin().ascii_serialization();
400 let html = self
401 .get_text(embed.as_str(), episode_url, false, None, MAX_RESPONSE_BYTES)
402 .await?;
403 let data_id = parse_data_id(&html).ok_or_else(|| {
404 AniError::Provider("embed did not expose a playable source id".into())
405 })?;
406 let mut source_url = Url::parse(&format!("{origin}/stream/getSources"))?;
407 source_url.query_pairs_mut().append_pair("id", &data_id);
408 let payload = self
409 .get_json(source_url.as_str(), embed.as_str(), true, Some(&origin))
410 .await?;
411 let (sources, subtitles) = parse_sources(&payload);
412 if sources.is_empty() {
413 return Err(AniError::Provider(
414 "embed returned no supported native streams".into(),
415 ));
416 }
417
418 let headers = RequestHeaders {
419 referer: Some(format!("{origin}/")),
420 origin: Some(origin.clone()),
421 extra: [("User-Agent".into(), self.inner.user_agent.clone())].into(),
422 };
423 let mut streams = Vec::new();
424 for (url, label) in sources {
425 let parsed = validate_remote_url(&url)?;
426 let hls = parsed.path().to_ascii_lowercase().contains(".m3u8")
427 || parsed.query().is_some_and(|query| query.contains(".m3u8"));
428 let variants = if hls {
429 self.expand_hls(&url, &headers).await?
430 } else {
431 vec![(label.clone(), url)]
432 };
433 for (resolution, url) in variants {
434 streams.push(StreamLink {
435 url,
436 resolution: if resolution.eq_ignore_ascii_case("auto") {
437 label.clone()
438 } else {
439 resolution
440 },
441 hls,
442 provider: provider.into(),
443 downloadable: true,
444 headers: headers.clone(),
445 subtitles: subtitles.clone(),
446 });
447 }
448 }
449 Ok(streams)
450 }
451
452 async fn expand_hls(
453 &self,
454 url: &str,
455 headers: &RequestHeaders,
456 ) -> Result<Vec<(String, String)>> {
457 let mut request = self.inner.http.get(url);
458 request = apply_headers(request, headers);
459 let response = request.send().await?;
460 if !response.status().is_success() {
461 return Ok(vec![("Auto".into(), url.into())]);
462 }
463 let bytes = response.bytes().await?;
464 if bytes.len() > MAX_PLAYLIST_BYTES {
465 return Err(AniError::Provider(
466 "provider playlist exceeds the 4 MiB limit".into(),
467 ));
468 }
469 let text = String::from_utf8_lossy(&bytes);
470 if !text.contains("#EXTM3U") || !text.contains("#EXT-X-STREAM-INF") {
471 return Ok(vec![("Auto".into(), url.into())]);
472 }
473 let base = Url::parse(url)?;
474 let resolution = Regex::new(r"(?i)RESOLUTION=\d+x(\d+)").expect("static regex");
475 let lines = text.lines().collect::<Vec<_>>();
476 let mut variants = Vec::new();
477 for (index, line) in lines.iter().enumerate() {
478 if !line.trim_start().starts_with("#EXT-X-STREAM-INF") {
479 continue;
480 }
481 let label = resolution
482 .captures(line)
483 .map(|captures| format!("{}p", &captures[1]))
484 .unwrap_or_else(|| "Auto".into());
485 if let Some(path) = lines[index + 1..]
486 .iter()
487 .map(|line| line.trim())
488 .find(|line| !line.is_empty() && !line.starts_with('#'))
489 {
490 variants.push((label, base.join(path)?.to_string()));
491 }
492 }
493 if variants.is_empty() {
494 variants.push(("Auto".into(), url.into()));
495 }
496 variants.sort_by_key(|(label, _)| std::cmp::Reverse(quality_number(label)));
497 Ok(variants)
498 }
499
500 async fn get_json(
501 &self,
502 url: &str,
503 referer: &str,
504 ajax: bool,
505 origin: Option<&str>,
506 ) -> Result<Value> {
507 let text = self
508 .get_text(url, referer, ajax, origin, MAX_RESPONSE_BYTES)
509 .await?;
510 serde_json::from_str(&text)
511 .map_err(|_| AniError::Provider("provider returned invalid JSON".into()))
512 }
513
514 async fn get_text(
515 &self,
516 url: &str,
517 referer: &str,
518 ajax: bool,
519 origin: Option<&str>,
520 max_bytes: usize,
521 ) -> Result<String> {
522 let mut request = self
523 .inner
524 .http
525 .get(url)
526 .header(header::REFERER, referer)
527 .header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9");
528 if ajax {
529 request = request.header("X-Requested-With", "XMLHttpRequest").header(
530 header::ACCEPT,
531 "application/json, text/javascript, */*; q=0.01",
532 );
533 } else {
534 request = request.header(header::ACCEPT, "text/html,application/xhtml+xml,*/*;q=0.8");
535 }
536 if let Some(origin) = origin {
537 request = request.header(header::ORIGIN, origin);
538 }
539 checked_text(request.send().await?, max_bytes).await
540 }
541}
542
543fn encode_id(value: &AnikotoCzId) -> Result<String> {
544 Ok(format!(
545 "anikoto2:{}",
546 URL_SAFE_NO_PAD.encode(serde_json::to_vec(value)?)
547 ))
548}
549
550fn decode_id(value: &str) -> Result<AnikotoCzId> {
551 if validate_slug(value).is_ok() && !value.starts_with("anikoto2:") {
552 return Ok(AnikotoCzId {
553 slug: value.into(),
554 title: value.replace('-', " "),
555 episodes: None,
556 });
557 }
558 let payload = value
559 .strip_prefix("anikoto2:")
560 .ok_or_else(|| AniError::Input("invalid Anikoto.cz show ID".into()))?;
561 let bytes = URL_SAFE_NO_PAD
562 .decode(payload)
563 .map_err(|_| AniError::Input("invalid Anikoto.cz show ID encoding".into()))?;
564 let decoded: AnikotoCzId = serde_json::from_slice(&bytes)
565 .map_err(|_| AniError::Input("invalid Anikoto.cz show metadata".into()))?;
566 validate_slug(&decoded.slug)?;
567 Ok(decoded)
568}
569
570fn filter_search_urls_for_base(
571 base: &str,
572 query: &str,
573 preferred_sort: Option<SearchSort>,
574) -> Vec<String> {
575 let base = Url::parse(base).unwrap_or_else(|_| Url::parse("https://anikoto.cz").unwrap());
576 let defaults = [
577 SearchSort::LatestUpdated,
578 SearchSort::LatestAdded,
579 SearchSort::Score,
580 SearchSort::NameAz,
581 SearchSort::ReleaseDate,
582 SearchSort::MostViewed,
583 SearchSort::NumberOfEpisodes,
584 ];
585
586 let mut urls = Vec::new();
587 let mut seen = HashSet::new();
588 let candidates = std::iter::once(None)
589 .chain(std::iter::once(preferred_sort))
590 .chain(defaults.into_iter().map(Some));
591
592 for sort in candidates {
593 let sort_value = sort.map(|value| value.to_string());
594 if !seen.insert(sort_value.clone()) {
595 continue;
596 }
597
598 let mut url = base.join("/filter").unwrap();
599 {
600 let mut query_pairs = url.query_pairs_mut();
601 query_pairs.clear();
602 query_pairs.append_pair("keyword", query);
603 query_pairs.append_pair("type", "");
604 query_pairs.append_pair("ep_min", "");
605 query_pairs.append_pair("ep_max", "");
606 if let Some(sort) = sort_value.as_deref() {
607 query_pairs.append_pair("sort", sort);
608 }
609 }
610 urls.push(url.to_string());
611 }
612 urls
613}
614
615#[cfg(test)]
616fn filter_search_urls(query: &str) -> Vec<String> {
617 filter_search_urls_for_base("https://anikoto.cz", query, None)
618}
619
620fn parse_search(base: &str, html: &str) -> Result<Vec<SearchResult>> {
621 let document = Html::parse_fragment(html);
622 let list = Selector::parse("#list-items .item")
623 .map_err(|_| AniError::Provider("invalid result list selector".into()))?;
624 let title_selector = Selector::parse(".name, .d-title")
625 .map_err(|_| AniError::Provider("invalid title selector".into()))?;
626 let base = Url::parse(base)?;
627 let expected_host = base.host_str().unwrap_or_default();
628 let mut seen = HashSet::new();
629 let mut results = Vec::new();
630 let anchor = Selector::parse("a[href]")
631 .map_err(|_| AniError::Provider("invalid anchor selector".into()))?;
632 for element in document.select(&list) {
633 let Some(href) = element
634 .select(&anchor)
635 .next()
636 .and_then(|anchor| anchor.value().attr("href"))
637 else {
638 continue;
639 };
640 let Ok(url) = base.join(href) else {
641 continue;
642 };
643 if url.scheme() != "https" || url.host_str() != Some(expected_host) {
644 continue;
645 }
646 let Some(slug) = url
647 .path()
648 .trim_end_matches('/')
649 .strip_prefix("/watch/")
650 .and_then(|slug| slug.split('/').next())
651 .filter(|slug| validate_slug(slug).is_ok())
652 else {
653 continue;
654 };
655 if !seen.insert(slug.to_owned()) {
656 continue;
657 }
658 let title = element
659 .select(&title_selector)
660 .next()
661 .map(|value| clean_text(&value.text().collect::<String>()))
662 .filter(|value| !value.is_empty())
663 .unwrap_or_else(|| slug.replace('-', " "));
664 results.push(SearchResult {
665 id: encode_id(&AnikotoCzId {
666 slug: slug.into(),
667 title: title.clone(),
668 episodes: None,
669 })?,
670 name: title,
671 episodes: 0.0,
672 provider: CatalogProvider::Anikoto2,
673 });
674 }
675 Ok(results)
676}
677
678fn parse_show(base: &str, html: &str) -> Result<(String, String)> {
679 let document = Html::parse_document(html);
680 let selector = Selector::parse("#watch-main")
681 .map_err(|_| AniError::Provider("invalid show selector".into()))?;
682 let element = document
683 .select(&selector)
684 .next()
685 .ok_or_else(|| AniError::Provider("show page did not expose #watch-main".into()))?;
686 let show_id = element.value().attr("data-id").unwrap_or_default();
687 let canonical = element.value().attr("data-url").unwrap_or_default();
688 if !show_id.bytes().all(|byte| byte.is_ascii_digit()) || show_id.is_empty() {
689 return Err(AniError::Provider(
690 "show page exposed an invalid catalog ID".into(),
691 ));
692 }
693 let base = Url::parse(base)?;
694 let canonical = base.join(canonical)?;
695 if canonical.scheme() != "https" || canonical.host_str() != base.host_str() {
696 return Err(AniError::Provider(
697 "show page exposed an invalid canonical URL".into(),
698 ));
699 }
700 Ok((
701 show_id.into(),
702 canonical.to_string().trim_end_matches('/').into(),
703 ))
704}
705
706fn parse_episodes(html: &str) -> Result<Vec<Episode>> {
707 let document = Html::parse_fragment(html);
708 let selector = Selector::parse("a[data-num][data-ids]")
709 .map_err(|_| AniError::Provider("invalid episode selector".into()))?;
710 let mut seen = HashSet::new();
711 let mut episodes = Vec::new();
712 for element in document.select(&selector) {
713 let Some(number) = element
714 .value()
715 .attr("data-num")
716 .and_then(|value| normalize_episode(value).ok())
717 else {
718 continue;
719 };
720 if !seen.insert(number.clone()) {
721 continue;
722 }
723 let attrs = element.value();
724 episodes.push(Episode {
725 slug: attrs.attr("data-slug").unwrap_or(&number).into(),
726 token: attrs.attr("data-ids").unwrap_or_default().into(),
727 mal_id: attrs.attr("data-mal").unwrap_or_default().into(),
728 timestamp: attrs.attr("data-timestamp").unwrap_or_default().into(),
729 sub: attrs.attr("data-sub") == Some("1"),
730 dub: attrs.attr("data-dub") == Some("1"),
731 number,
732 });
733 }
734 episodes.sort_by(|a, b| {
735 a.number
736 .parse::<f64>()
737 .unwrap_or_default()
738 .total_cmp(&b.number.parse::<f64>().unwrap_or_default())
739 });
740 Ok(episodes)
741}
742
743fn parse_servers(html: &str, mode: TranslationType) -> Result<Vec<Server>> {
744 let document = Html::parse_fragment(html);
745 let groups = Selector::parse("div.type")
746 .map_err(|_| AniError::Provider("invalid server selector".into()))?;
747 let labels = Selector::parse("label")
748 .map_err(|_| AniError::Provider("invalid server label selector".into()))?;
749 let items = Selector::parse("li[data-link-id]")
750 .map_err(|_| AniError::Provider("invalid server item selector".into()))?;
751 let mut servers = Vec::new();
752 let mut seen = HashSet::new();
753 for group in document.select(&groups) {
754 let type_name = group.value().attr("data-type").unwrap_or_default();
755 let label = group
756 .select(&labels)
757 .next()
758 .map(|label| clean_text(&label.text().collect::<String>()))
759 .unwrap_or_default();
760 let kind = server_kind(type_name, &label);
761 if (mode == TranslationType::Dub) != (kind == "dub") {
762 continue;
763 }
764 for item in group.select(&items) {
765 let Some(token) = item.value().attr("data-link-id") else {
766 continue;
767 };
768 if !seen.insert(token.to_owned()) {
769 continue;
770 }
771 let prefix = if kind == "hsub" { "H-SUB" } else { kind };
772 let name = clean_text(&item.text().collect::<String>());
773 servers.push(Server {
774 label: format!(
775 "{} · {}",
776 prefix.to_ascii_uppercase(),
777 if name.is_empty() { "Server" } else { &name }
778 ),
779 token: token.into(),
780 });
781 }
782 }
783 Ok(servers)
784}
785
786fn server_kind(type_name: &str, label: &str) -> &'static str {
787 let combined = format!("{type_name} {label}").to_ascii_lowercase();
788 if type_name.eq_ignore_ascii_case("dub")
789 || Regex::new(r"\b(?:a-?dub|dub|h-?dub)\b")
790 .expect("static regex")
791 .is_match(&combined)
792 {
793 "dub"
794 } else if type_name.eq_ignore_ascii_case("hsub")
795 || Regex::new(r"\bh[\s-]*sub\b")
796 .expect("static regex")
797 .is_match(&combined)
798 {
799 "hsub"
800 } else {
801 "sub"
802 }
803}
804
805fn parse_mapper(payload: &Value, mode: &str) -> Vec<Server> {
806 let Some(object) = payload.as_object() else {
807 return Vec::new();
808 };
809 let labels = [
810 ("gogoanime", "Vidstream"),
811 ("animepahe", "Kiwi-Stream"),
812 ("anivibe", "Vibe-Stream"),
813 ]
814 .into_iter()
815 .collect::<HashMap<_, _>>();
816 object
817 .iter()
818 .filter(|(provider, _)| provider.as_str() != "status")
819 .filter_map(|(provider, entry)| {
820 let token = entry.get(mode)?.get("url")?.as_str()?;
821 Some(Server {
822 label: format!(
823 "{} · {}",
824 if mode == "dub" { "A-DUB" } else { "H-SUB" },
825 labels.get(provider.as_str()).copied().unwrap_or(provider)
826 ),
827 token: token.into(),
828 })
829 })
830 .collect()
831}
832
833fn parse_sources(value: &Value) -> (Vec<(String, String)>, Vec<SubtitleTrack>) {
834 fn collect(value: &Value, output: &mut Vec<(String, String)>) {
835 match value {
836 Value::String(url) => output.push((url.clone(), "Auto".into())),
837 Value::Array(values) => values.iter().for_each(|value| collect(value, output)),
838 Value::Object(object) => {
839 if let Some(url) = ["file", "url", "src"]
840 .iter()
841 .find_map(|key| object.get(*key).and_then(Value::as_str))
842 {
843 let label = ["label", "quality"]
844 .iter()
845 .find_map(|key| object.get(*key).and_then(Value::as_str))
846 .unwrap_or("Auto");
847 output.push((url.into(), clean_text(label)));
848 }
849 let mut traversed = false;
850 for key in ["sources", "source", "links"] {
851 if let Some(child) = object.get(key) {
852 collect(child, output);
853 traversed = true;
854 }
855 }
856 if !traversed
857 && !["file", "url", "src"]
858 .iter()
859 .any(|key| object.contains_key(*key))
860 {
861 object.values().for_each(|value| collect(value, output));
862 }
863 }
864 _ => {}
865 }
866 }
867
868 let mut sources = Vec::new();
869 if let Some(root) = value.get("sources").or_else(|| value.get("source")) {
870 collect(root, &mut sources);
871 }
872 let mut subtitles = Vec::new();
873 for key in ["tracks", "captions", "subtitles"] {
874 let Some(entries) = value.get(key).and_then(Value::as_array) else {
875 continue;
876 };
877 for entry in entries {
878 let kind = entry
879 .get("kind")
880 .or_else(|| entry.get("type"))
881 .and_then(Value::as_str)
882 .unwrap_or_default()
883 .to_ascii_lowercase();
884 if !kind.is_empty()
885 && !kind.contains("caption")
886 && !kind.contains("subtitle")
887 && !kind.contains("sub")
888 {
889 continue;
890 }
891 let Some(url) = ["file", "src", "url"]
892 .iter()
893 .find_map(|key| entry.get(*key).and_then(Value::as_str))
894 .and_then(|url| validate_remote_url(url).ok())
895 else {
896 continue;
897 };
898 subtitles.push(SubtitleTrack {
899 label: entry
900 .get("label")
901 .or_else(|| entry.get("title"))
902 .and_then(Value::as_str)
903 .map(clean_text)
904 .filter(|label| !label.is_empty())
905 .unwrap_or_else(|| "Unknown".into()),
906 url: url.to_string(),
907 default: entry
908 .get("default")
909 .and_then(Value::as_bool)
910 .unwrap_or(false),
911 });
912 }
913 }
914 let mut seen = HashSet::new();
915 sources.retain(|(url, _)| validate_remote_url(url).is_ok() && seen.insert(url.clone()));
916 let mut seen = HashSet::new();
917 subtitles.retain(|track| seen.insert(track.url.clone()));
918 (sources, subtitles)
919}
920
921fn parse_data_id(html: &str) -> Option<String> {
922 Regex::new(r#"(?i)\bdata-id=["'](\d+)["']"#)
923 .expect("static regex")
924 .captures(html)
925 .map(|captures| captures[1].into())
926}
927
928fn provider_result<'a>(value: &'a Value, context: &str) -> Result<&'a Value> {
929 if value.get("status").and_then(Value::as_u64) != Some(200) {
930 let message = value
931 .get("message")
932 .and_then(Value::as_str)
933 .unwrap_or(context);
934 return Err(AniError::Provider(format!(
935 "invalid Anikoto.cz {context} response: {message}"
936 )));
937 }
938 value
939 .get("result")
940 .ok_or_else(|| AniError::Provider(format!("Anikoto.cz {context} response has no result")))
941}
942
943async fn checked_text(response: Response, max_bytes: usize) -> Result<String> {
944 let status = response.status();
945 if status == StatusCode::TOO_MANY_REQUESTS {
946 let retry_after_seconds = response
947 .headers()
948 .get(header::RETRY_AFTER)
949 .and_then(|value| value.to_str().ok())
950 .and_then(|value| value.parse().ok())
951 .unwrap_or(120);
952 return Err(AniError::ProviderRateLimited {
953 provider: "Anikoto.cz".into(),
954 retry_after_seconds,
955 });
956 }
957 if !status.is_success() {
958 return Err(AniError::Catalog {
959 provider: "Anikoto.cz".into(),
960 message: format!("HTTP {status}"),
961 });
962 }
963 if response
964 .content_length()
965 .is_some_and(|length| length > max_bytes as u64)
966 {
967 return Err(AniError::Provider(
968 "provider response exceeded the safety limit".into(),
969 ));
970 }
971 let bytes = response.bytes().await?;
972 if bytes.len() > max_bytes {
973 return Err(AniError::Provider(
974 "provider response exceeded the safety limit".into(),
975 ));
976 }
977 Ok(String::from_utf8_lossy(&bytes).into_owned())
978}
979
980fn apply_headers(
981 mut request: reqwest::RequestBuilder,
982 headers: &RequestHeaders,
983) -> reqwest::RequestBuilder {
984 if let Some(referer) = &headers.referer {
985 request = request.header(header::REFERER, referer);
986 }
987 if let Some(origin) = &headers.origin {
988 request = request.header(header::ORIGIN, origin);
989 }
990 for (name, value) in &headers.extra {
991 request = request.header(name, value);
992 }
993 request
994}
995
996fn normalize_episode(value: &str) -> Result<String> {
997 let number = value
998 .trim()
999 .parse::<f64>()
1000 .ok()
1001 .filter(|number| number.is_finite() && *number >= 0.0)
1002 .ok_or_else(|| AniError::Input(format!("invalid episode number: {value}")))?;
1003 if number.fract() == 0.0 {
1004 Ok(format!("{number:.0}"))
1005 } else {
1006 Ok(number.to_string())
1007 }
1008}
1009
1010fn validate_slug(value: &str) -> Result<()> {
1011 if Regex::new(r"(?i)^[a-z0-9][a-z0-9-]{0,199}$")
1012 .expect("static regex")
1013 .is_match(value)
1014 {
1015 Ok(())
1016 } else {
1017 Err(AniError::Input("invalid Anikoto.cz show slug".into()))
1018 }
1019}
1020
1021fn validate_remote_url(value: &str) -> Result<Url> {
1022 let url = Url::parse(value)?;
1023 if url.scheme() != "https"
1024 || url.host_str().is_none()
1025 || !url.username().is_empty()
1026 || url.password().is_some()
1027 {
1028 return Err(AniError::Provider("unsafe provider URL".into()));
1029 }
1030 if url
1031 .host_str()
1032 .is_some_and(|host| host.parse::<std::net::IpAddr>().is_ok())
1033 {
1034 return Err(AniError::Provider(
1035 "literal-IP provider URLs are not allowed".into(),
1036 ));
1037 }
1038 Ok(url)
1039}
1040
1041fn host_matches(host: &str, domain: &str) -> bool {
1042 let host = host.trim_end_matches('.').to_ascii_lowercase();
1043 host == domain || host.ends_with(&format!(".{domain}"))
1044}
1045
1046fn clean_text(value: &str) -> String {
1047 value.split_whitespace().collect::<Vec<_>>().join(" ")
1048}
1049
1050fn quality_number(value: &str) -> u32 {
1051 Regex::new(r"(\d{3,4})")
1052 .expect("static regex")
1053 .captures(value)
1054 .and_then(|captures| captures[1].parse().ok())
1055 .unwrap_or(0)
1056}
1057
1058fn encode_path(value: &str) -> String {
1059 url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
1060}
1061
1062fn cache_get<T: Clone>(cache: &Mutex<HashMap<String, Cached<T>>>, key: &str) -> Option<T> {
1063 let mut cache = cache.lock().ok()?;
1064 cache.retain(|_, value| value.expires_at > Instant::now());
1065 cache.get(key).map(|value| value.value.clone())
1066}
1067
1068fn cache_put<T>(cache: &Mutex<HashMap<String, Cached<T>>>, key: String, value: T) {
1069 if let Ok(mut cache) = cache.lock() {
1070 if cache.len() >= CACHE_LIMIT
1071 && let Some(key) = cache.keys().next().cloned()
1072 {
1073 cache.remove(&key);
1074 }
1075 cache.insert(
1076 key,
1077 Cached {
1078 expires_at: Instant::now() + CACHE_TTL,
1079 value,
1080 },
1081 );
1082 }
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087 use super::*;
1088
1089 #[test]
1090 fn ids_round_trip_and_raw_slugs_are_supported() {
1091 let id = AnikotoCzId {
1092 slug: "black-torch-1d364".into(),
1093 title: "Black Torch".into(),
1094 episodes: None,
1095 };
1096 let encoded = encode_id(&id).unwrap();
1097 assert_eq!(decode_id(&encoded).unwrap(), id);
1098 assert_eq!(
1099 decode_id("black-torch-1d364").unwrap().slug,
1100 "black-torch-1d364"
1101 );
1102 }
1103
1104 #[test]
1105 fn parses_search_and_deduplicates_slugs() {
1106 let html = r#"
1107 <div id="list-items" class="ani items">
1108 <div class="item"><a href="/watch/black-torch-1d364"><div class="name">Black Torch</div></a></div>
1109 <div class="item"><a href="/watch/black-torch-1d364"><span class="d-title">Duplicate</span></a></div>
1110 </div>
1111 "#;
1112 let values = parse_search("https://anikoto.cz", html).unwrap();
1113 assert_eq!(values.len(), 1);
1114 assert_eq!(values[0].name, "Black Torch");
1115 assert_eq!(values[0].provider, CatalogProvider::Anikoto2);
1116 }
1117
1118 #[test]
1119 fn ignores_unrelated_items_outside_the_filter_result_list() {
1120 let html = r#"
1121 <div class="top-rated">
1122 <a class="item" href="/watch/dorohedoro-season-2-bqfe6"><span class="name">Dorohedoro Season 2</span></a>
1123 </div>
1124 <div id="list-items" class="ani items">
1125 <div class="item"><a href="/watch/one-piece-episode-of-luffy-hand-island-adventure-br7lf/ep-1"><span class="d-title">One Piece: Episode of Luffy - Hand Island Adventure</span></a></div>
1126 </div>
1127 "#;
1128 let values = parse_search("https://anikoto.cz", html).unwrap();
1129 assert_eq!(values.len(), 1);
1130 assert_eq!(
1131 values[0].name,
1132 "One Piece: Episode of Luffy - Hand Island Adventure"
1133 );
1134 }
1135
1136 #[test]
1137 fn builds_supported_filter_sort_urls() {
1138 let urls = filter_search_urls("one piece");
1139 assert_eq!(urls.len(), 8);
1140 assert!(urls[0].contains("/filter?keyword=one+piece"));
1141 assert!(urls.iter().any(|url| url.contains("sort=latest-updated")));
1142 assert!(urls.iter().any(|url| url.contains("sort=name-az")));
1143 assert!(
1144 urls.iter()
1145 .any(|url| url.contains("sort=number_of_episodes"))
1146 );
1147 }
1148
1149 #[test]
1150 fn parses_fractional_episode_availability() {
1151 let html = r#"
1152 <a data-num="2.5" data-slug="2-5" data-ids="two" data-sub="1" data-dub="0"></a>
1153 <a data-num="1" data-slug="1" data-ids="one" data-sub="1" data-dub="1"></a>
1154 "#;
1155 let episodes = parse_episodes(html).unwrap();
1156 assert_eq!(episodes[0].number, "1");
1157 assert!(episodes[0].dub);
1158 assert_eq!(episodes[1].number, "2.5");
1159 }
1160
1161 #[test]
1162 fn keeps_soft_hard_and_dub_servers_separate() {
1163 let html = r#"
1164 <div class="type" data-type="sub"><label>SUB</label>
1165 <ul><li data-link-id="soft">VidPlay-1</li></ul>
1166 </div>
1167 <div class="type" data-type="hsub"><label>HSUB</label>
1168 <ul><li data-link-id="hard">HD-1</li></ul>
1169 </div>
1170 <div class="type" data-type="dub"><label>DUB</label>
1171 <ul><li data-link-id="dub">Vidstream-2</li></ul>
1172 </div>
1173 "#;
1174 let sub = parse_servers(html, TranslationType::Sub).unwrap();
1175 assert_eq!(sub.len(), 2);
1176 assert!(sub[1].label.starts_with("H-SUB"));
1177 let dub = parse_servers(html, TranslationType::Dub).unwrap();
1178 assert_eq!(dub.len(), 1);
1179 assert!(dub[0].label.starts_with("DUB"));
1180 }
1181
1182 #[test]
1183 fn nested_sources_and_subtitles_are_normalized() {
1184 let (sources, subtitles) = parse_sources(&serde_json::json!({
1185 "sources":{"links":[{"file":"https://megap.kotocdn.site/master.m3u8","label":"1080p"}]},
1186 "tracks":[{"file":"https://megap.kotocdn.site/en.vtt","label":"English","kind":"captions","default":true}]
1187 }));
1188 assert_eq!(sources[0].1, "1080p");
1189 assert_eq!(subtitles[0].label, "English");
1190 assert!(subtitles[0].default);
1191 }
1192
1193 #[tokio::test]
1194 async fn live_anikoto_cz_smoke_test_is_opt_in() {
1195 if std::env::var("ANI_CLI_LIVE_ANIKOTO2").as_deref() != Ok("1") {
1196 return;
1197 }
1198 let client = AnikotoCzClient::new().unwrap();
1199 let results = client
1200 .search("black torch", TranslationType::Sub)
1201 .await
1202 .unwrap();
1203 let show = results
1204 .iter()
1205 .find(|result| result.name.eq_ignore_ascii_case("Black Torch"))
1206 .unwrap();
1207 let episodes = client
1208 .episodes(&show.id, TranslationType::Sub)
1209 .await
1210 .unwrap();
1211 assert!(episodes.iter().any(|episode| episode == "1"));
1212 let streams = client
1213 .streams(&show.id, "1", TranslationType::Sub)
1214 .await
1215 .unwrap();
1216 assert!(!streams.is_empty());
1217 assert!(streams.iter().any(|stream| stream.hls));
1218 }
1219}