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 cbc::cipher::{BlockModeDecrypt, KeyIvInit};
9use regex::Regex;
10use reqwest::{Client, Response, StatusCode, header};
11use scraper::{Html, Selector};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use url::Url;
15
16use crate::{
17 AniError, CatalogProvider, RequestHeaders, Result, SearchOptions, SearchResult, SearchSort,
18 StreamLink, SubtitleTrack, TranslationType,
19 models::{sort_episodes, sort_streams},
20};
21
22const DEFAULT_BASE: &str = "https://anikoto.cz";
23const DEFAULT_MAPPER_BASE: &str = "https://mapper.nekostream.site/api/mal";
24const 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";
25const CACHE_TTL: Duration = Duration::from_secs(5 * 60);
26const CACHE_LIMIT: usize = 100;
27const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
28const MAX_PLAYLIST_BYTES: usize = 4 * 1024 * 1024;
29
30#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
31#[serde(rename_all = "camelCase")]
32struct AnikotoCzId {
33 slug: String,
34 title: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 episodes: Option<u32>,
37}
38
39#[derive(Clone, Debug, PartialEq, Eq)]
40struct Episode {
41 number: String,
42 slug: String,
43 token: String,
44 mal_id: String,
45 timestamp: String,
46 sub: bool,
47 dub: bool,
48}
49
50#[derive(Clone, Debug)]
51struct Series {
52 canonical: String,
53 episodes: Vec<Episode>,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
57struct Server {
58 label: String,
59 token: String,
60}
61
62#[derive(Clone, Debug)]
63struct Cached<T> {
64 expires_at: Instant,
65 value: T,
66}
67
68#[derive(Clone, Debug)]
69pub struct AnikotoCzClientBuilder {
70 base: String,
71 mapper_base: String,
72 user_agent: String,
73 timeout: Duration,
74}
75
76impl Default for AnikotoCzClientBuilder {
77 fn default() -> Self {
78 Self {
79 base: DEFAULT_BASE.into(),
80 mapper_base: DEFAULT_MAPPER_BASE.into(),
81 user_agent: DEFAULT_AGENT.into(),
82 timeout: Duration::from_secs(15),
83 }
84 }
85}
86
87impl AnikotoCzClientBuilder {
88 pub fn base_url(mut self, value: impl Into<String>) -> Self {
89 self.base = value.into();
90 self
91 }
92
93 pub fn mapper_base(mut self, value: impl Into<String>) -> Self {
94 self.mapper_base = value.into();
95 self
96 }
97
98 pub fn timeout(mut self, value: Duration) -> Self {
99 self.timeout = value;
100 self
101 }
102
103 pub fn build(self) -> Result<AnikotoCzClient> {
104 let http = Client::builder()
105 .timeout(self.timeout)
106 .user_agent(&self.user_agent)
107 .cookie_store(true)
108 .redirect(reqwest::redirect::Policy::limited(10))
109 .build()?;
110 Ok(AnikotoCzClient {
111 inner: Arc::new(Inner {
112 http,
113 base: self.base.trim_end_matches('/').into(),
114 mapper_base: self.mapper_base.trim_end_matches('/').into(),
115 user_agent: self.user_agent,
116 series: Mutex::new(HashMap::new()),
117 }),
118 })
119 }
120}
121
122struct Inner {
123 http: Client,
124 base: String,
125 mapper_base: String,
126 user_agent: String,
127 series: Mutex<HashMap<String, Cached<Series>>>,
128}
129
130#[derive(Clone)]
131pub struct AnikotoCzClient {
132 inner: Arc<Inner>,
133}
134
135impl AnikotoCzClient {
136 pub fn builder() -> AnikotoCzClientBuilder {
137 AnikotoCzClientBuilder::default()
138 }
139
140 pub fn new() -> Result<Self> {
141 Self::builder().build()
142 }
143
144 pub async fn search(&self, query: &str, mode: TranslationType) -> Result<Vec<SearchResult>> {
145 self.search_with_options(query, mode, SearchOptions::default())
146 .await
147 }
148
149 pub async fn search_with_options(
150 &self,
151 query: &str,
152 _mode: TranslationType,
153 options: SearchOptions,
154 ) -> Result<Vec<SearchResult>> {
155 let query = query.trim();
156 if query.is_empty() {
157 return Err(AniError::EmptySearchQuery);
158 }
159
160 let mut request_urls = filter_search_urls_for_base(&self.inner.base, query, options.sort);
161 let mut ajax_url = Url::parse(&format!("{}/ajax/anime/search", self.inner.base))?;
162 ajax_url.query_pairs_mut().append_pair("keyword", query);
163 request_urls.push(ajax_url.to_string());
164
165 for request_url in request_urls {
166 let html = if request_url.contains("/filter") {
167 match self
168 .get_text(
169 &request_url,
170 &self.inner.base,
171 false,
172 None,
173 MAX_RESPONSE_BYTES,
174 )
175 .await
176 {
177 Ok(html) => html,
178 Err(_) => continue,
179 }
180 } else {
181 let payload = match self
182 .get_json(&request_url, &self.inner.base, true, None)
183 .await
184 {
185 Ok(payload) => payload,
186 Err(_) => continue,
187 };
188 let result = match provider_result(&payload, "search") {
189 Ok(result) => result,
190 Err(_) => continue,
191 };
192 match result
193 .as_str()
194 .or_else(|| result.get("html").and_then(Value::as_str))
195 {
196 Some(html) => html.into(),
197 None => continue,
198 }
199 };
200
201 let results = parse_search(&self.inner.base, &html, _mode)?;
202 if !results.is_empty() {
203 return Ok(results);
204 }
205 }
206
207 Err(AniError::NoSearchResults)
208 }
209
210 pub async fn episodes(&self, show_id: &str, mode: TranslationType) -> Result<Vec<String>> {
211 let id = decode_id(show_id)?;
212 let series = self.load_series(&id.slug).await?;
213 let mut episodes = series
214 .episodes
215 .iter()
216 .filter(|episode| match mode {
217 TranslationType::Sub => episode.sub,
218 TranslationType::Dub => episode.dub,
219 })
220 .map(|episode| episode.number.clone())
221 .collect::<Vec<_>>();
222 sort_episodes(&mut episodes);
223 if episodes.is_empty() {
224 eprintln!("Anikoto.cz has no {mode} episodes for {}", id.title);
225 return Err(AniError::NoEpisodesAvailable {
226 anime: Some(id.title.clone()),
227 });
228 }
229 Ok(episodes)
230 }
231
232 pub async fn streams(
233 &self,
234 show_id: &str,
235 episode: &str,
236 mode: TranslationType,
237 ) -> Result<Vec<StreamLink>> {
238 let id = decode_id(show_id)?;
239 let series = self.load_series(&id.slug).await?;
240 let episode_number = normalize_episode(episode)?;
241 let selected = series
242 .episodes
243 .iter()
244 .find(|value| value.number == episode_number)
245 .ok_or_else(|| AniError::InvalidEpisodeSelection {
246 episode: episode.to_string(),
247 reason: "episode is not available for this anime".to_string(),
248 })?;
249 let available = match mode {
250 TranslationType::Sub => selected.sub,
251 TranslationType::Dub => selected.dub,
252 };
253 if !available {
254 eprintln!("Episode {episode} has no {mode} servers");
255 return Err(AniError::NoEpisodesAvailable {
256 anime: Some(id.title.clone()),
257 });
258 }
259
260 let episode_url = format!(
261 "{}/ep-{}",
262 series.canonical,
263 url::form_urlencoded::byte_serialize(selected.slug.as_bytes()).collect::<String>()
264 );
265 self.get_text(
266 &episode_url,
267 &series.canonical,
268 false,
269 None,
270 MAX_RESPONSE_BYTES,
271 )
272 .await?;
273 let mut server_url = Url::parse(&format!("{}/ajax/server/list", self.inner.base))?;
274 server_url
275 .query_pairs_mut()
276 .append_pair("servers", &selected.token);
277 let payload = self
278 .get_json(server_url.as_str(), &episode_url, true, None)
279 .await?;
280 let html = provider_result(&payload, "server list")?
281 .as_str()
282 .ok_or_else(|| provider_invalid_response("server list contained no markup"))?;
283 let mut servers = parse_servers(html, mode)?;
284 servers.extend(self.mapper_servers(selected, mode).await);
285 let mut seen = HashSet::new();
286 servers.retain(|server| seen.insert(server.token.clone()));
287
288 let mut streams = Vec::new();
289 let mut failures = Vec::new();
290 for server in servers {
291 match self.resolve_server(&server, &episode_url).await {
292 Ok(embed) => match self
293 .extract_native(&embed, &server.label, &episode_url, mode)
294 .await
295 {
296 Ok(mut resolved) => streams.append(&mut resolved),
297 Err(error) => failures.push(format!("{}: {error}", server.label)),
298 },
299 Err(error) => failures.push(format!("{}: {error}", server.label)),
300 }
301 }
302 let mut seen = HashSet::new();
303 streams.retain(|stream| seen.insert(stream.url.clone()));
304 sort_streams(&mut streams);
305 if streams.is_empty() {
306 let detail = if failures.is_empty() {
307 "no supported native servers were returned".into()
308 } else {
309 failures.join("; ")
310 };
311 eprintln!("Anikoto.cz native source resolution failed: {detail}");
312 return Err(AniError::NoPlayableSources {
313 anime: Some(id.title.clone()),
314 episode: Some(episode.to_string()),
315 mode: Some(mode.to_string()),
316 });
317 }
318 Ok(streams)
319 }
320
321 async fn load_series(&self, slug: &str) -> Result<Series> {
322 validate_slug(slug)?;
323 if let Some(value) = cache_get(&self.inner.series, slug) {
324 return Ok(value);
325 }
326 let requested = format!("{}/watch/{slug}", self.inner.base);
327 let show_html = self
328 .get_text(
329 &requested,
330 &self.inner.base,
331 false,
332 None,
333 MAX_RESPONSE_BYTES,
334 )
335 .await?;
336 let (show_id, canonical) = parse_show(&self.inner.base, &show_html)?;
337 let mut list_url = Url::parse(&format!("{}/ajax/episode/list/{show_id}", self.inner.base))?;
338 list_url
339 .query_pairs_mut()
340 .append_pair("style", "grid")
341 .append_pair("vrf", "");
342 let payload = self
343 .get_json(list_url.as_str(), &canonical, true, None)
344 .await?;
345 let html = provider_result(&payload, "episode list")?
346 .as_str()
347 .ok_or_else(|| provider_invalid_response("episode list contained no markup"))?;
348 let episodes = parse_episodes(html)?;
349 if episodes.is_empty() {
350 return Err(AniError::NoEpisodesAvailable { anime: None });
351 }
352 let series = Series {
353 canonical,
354 episodes,
355 };
356 cache_put(&self.inner.series, slug.into(), series.clone());
357 Ok(series)
358 }
359
360 async fn mapper_servers(&self, episode: &Episode, mode: TranslationType) -> Vec<Server> {
361 if episode.mal_id.is_empty() || episode.timestamp.is_empty() {
362 return Vec::new();
363 }
364 let mode = mode.to_string();
365 let url = format!(
366 "{}/{}/{}/{}",
367 self.inner.mapper_base,
368 encode_path(&episode.mal_id),
369 encode_path(&episode.slug),
370 encode_path(&episode.timestamp)
371 );
372 let Ok(payload) = self.get_json(&url, &self.inner.base, false, None).await else {
373 return Vec::new();
374 };
375 parse_mapper(&payload, &mode)
376 }
377
378 async fn resolve_server(&self, server: &Server, episode_url: &str) -> Result<String> {
379 let mut url = Url::parse(&format!("{}/ajax/server", self.inner.base))?;
380 url.query_pairs_mut().append_pair("get", &server.token);
381 let payload = self.get_json(url.as_str(), episode_url, true, None).await?;
382 let result = provider_result(&payload, "server")?;
383 let raw = result
384 .get("url")
385 .and_then(Value::as_str)
386 .or_else(|| result.as_str())
387 .ok_or_else(|| provider_invalid_response("server returned no embed URL"))?;
388 validate_remote_url(raw).map(|url| url.to_string())
389 }
390
391 async fn extract_native(
392 &self,
393 embed_url: &str,
394 provider: &str,
395 episode_url: &str,
396 mode: TranslationType,
397 ) -> Result<Vec<StreamLink>> {
398 let embed = validate_remote_url(embed_url)?;
399 let host = embed.host_str().unwrap_or_default();
400 if ![
401 "megaplay.buzz",
402 "vidtube.site",
403 "megap.shiora.top",
404 "shiora.top",
405 "megap.kotocdn.site",
406 "megap.akirax.buzz",
407 "akirax.buzz",
408 ]
409 .iter()
410 .any(|domain| host_matches(host, domain))
411 {
412 return Err(AniError::UnsupportedEmbedHost {
413 host: host.to_string(),
414 });
415 }
416 let origin = embed.origin().ascii_serialization();
417 let html = self
418 .get_text(embed.as_str(), episode_url, false, None, MAX_RESPONSE_BYTES)
419 .await?;
420 let data_id = parse_data_id(&html).ok_or_else(|| {
421 provider_invalid_response("embed did not expose a playable source id")
422 })?;
423 let payload = self
424 .get_native_sources(&origin, embed.as_str(), &data_id, mode, &html)
425 .await?;
426 let (sources, subtitles) = parse_sources(&payload);
427 if sources.is_empty() {
428 return Err(AniError::NoNativeStreams {
429 provider: host.to_string(),
430 });
431 }
432
433 let headers = RequestHeaders {
434 referer: Some(format!("{origin}/")),
435 origin: Some(origin.clone()),
436 extra: [("User-Agent".into(), self.inner.user_agent.clone())].into(),
437 };
438 let mut streams = Vec::new();
439 for (url, label) in sources {
440 let parsed = validate_remote_url(&url)?;
441 let hls = parsed.path().to_ascii_lowercase().contains(".m3u8")
442 || parsed.query().is_some_and(|query| query.contains(".m3u8"));
443 let variants = if hls {
444 self.expand_hls(&url, &headers).await?
445 } else {
446 vec![(label.clone(), url)]
447 };
448 for (resolution, url) in variants {
449 streams.push(StreamLink {
450 url,
451 resolution: if resolution.eq_ignore_ascii_case("auto") {
452 label.clone()
453 } else {
454 resolution
455 },
456 hls,
457 provider: provider.into(),
458 downloadable: true,
459 headers: headers.clone(),
460 subtitles: subtitles.clone(),
461 });
462 }
463 }
464 Ok(streams)
465 }
466
467 async fn get_native_sources(
468 &self,
469 origin: &str,
470 embed_url: &str,
471 data_id: &str,
472 mode: TranslationType,
473 html: &str,
474 ) -> Result<Value> {
475 let mut source_url = Url::parse(&format!("{origin}/stream/getSourcesNew"))?;
476 source_url
478 .query_pairs_mut()
479 .append_pair("id", data_id)
480 .append_pair("type", &mode.to_string());
481 let raw_text = self
482 .get_text(
483 source_url.as_str(),
484 embed_url,
485 true,
486 Some(origin),
487 MAX_RESPONSE_BYTES,
488 )
489 .await?;
490 eprintln!(
491 "[DEBUG] getSourcesNew raw: {}",
492 &raw_text[..raw_text.len().min(500)]
493 );
494 let mut payload: Value =
495 serde_json::from_str(&raw_text).map_err(|error| AniError::ProviderInvalidResponse {
496 provider: "Anikoto.cz".to_string(),
497 message: "provider returned invalid JSON".to_string(),
498 source: Some(Box::new(error)),
499 })?;
500
501 if let Some(enc) = payload.get("enc").and_then(Value::as_str) {
502 eprintln!("[DEBUG] enc field found, attempting decryption");
503 let e1_script_url = Html::parse_fragment(html)
504 .select(
505 &Selector::parse("script[src]")
506 .map_err(|_| provider_invalid_response("invalid selector"))?,
507 )
508 .find_map(|elem| {
509 let src = elem.value().attr("src")?;
510 if src.contains("e1-player") {
511 Some(src.to_string())
512 } else {
513 None
514 }
515 })
516 .ok_or_else(|| provider_invalid_response("Missing e1-player script"))?;
517
518 let e1_script_url = if e1_script_url.starts_with("//") {
519 format!("https:{}", e1_script_url)
520 } else if e1_script_url.starts_with('/') {
521 format!("{}{}", origin, e1_script_url)
522 } else {
523 e1_script_url
524 };
525
526 eprintln!("[DEBUG] fetching e1-player script: {}", e1_script_url);
527 let js = self
528 .get_text(&e1_script_url, embed_url, false, None, MAX_RESPONSE_BYTES)
529 .await?;
530 let (key, iv, secret, ttl) = player_parameters(&js)?;
531 eprintln!(
532 "[DEBUG] player params: key={:?} iv={:?} ttl={}",
533 &key[..key.len().min(8)],
534 &iv[..iv.len().min(8)],
535 ttl
536 );
537
538 let enc_owned = enc.to_owned();
539 let mut decrypted = decrypt_source(&enc_owned, &key, &iv)?;
540 eprintln!(
541 "[DEBUG] decrypted: {}",
542 serde_json::to_string(&decrypted)
543 .unwrap_or_default()
544 .chars()
545 .take(300)
546 .collect::<String>()
547 );
548
549 fn recursively_sign(v: &mut Value, secret: &str, ttl: u64) {
550 match v {
551 Value::String(s) => {
552 if let Ok(signed) = signed_url(s, secret, ttl) {
553 *s = signed;
554 }
555 }
556 Value::Array(arr) => {
557 for item in arr.iter_mut() {
558 recursively_sign(item, secret, ttl);
559 }
560 }
561 Value::Object(obj) => {
562 for (k, val) in obj.iter_mut() {
563 if k == "file" || k == "url" || k == "src" {
564 if let Value::String(s) = val {
565 if let Ok(signed) = signed_url(s, secret, ttl) {
566 *s = signed;
567 }
568 }
569 } else {
570 recursively_sign(val, secret, ttl);
571 }
572 }
573 }
574 _ => {}
575 }
576 }
577 recursively_sign(&mut decrypted, &secret, ttl);
578 eprintln!(
579 "[DEBUG] signed decrypted: {}",
580 serde_json::to_string(&decrypted)
581 .unwrap_or_default()
582 .chars()
583 .take(300)
584 .collect::<String>()
585 );
586 payload
587 .as_object_mut()
588 .unwrap()
589 .insert("sources".to_string(), decrypted);
590 } else {
591 eprintln!(
592 "[DEBUG] no enc field found in payload, keys: {:?}",
593 payload.as_object().map(|o| o.keys().collect::<Vec<_>>())
594 );
595 }
596 Ok(payload)
597 }
598
599 async fn expand_hls(
600 &self,
601 url: &str,
602 headers: &RequestHeaders,
603 ) -> Result<Vec<(String, String)>> {
604 let mut request = self.inner.http.get(url);
605 request = apply_headers(request, headers);
606 let response = request.send().await?;
607 if !response.status().is_success() {
608 return Ok(vec![("Auto".into(), url.into())]);
609 }
610 let bytes = response.bytes().await?;
611 if bytes.len() > MAX_PLAYLIST_BYTES {
612 return Err(AniError::ProviderResponseSizeExceeded {
613 provider: "Anikoto.cz".to_string(),
614 size: bytes.len(),
615 limit: MAX_PLAYLIST_BYTES,
616 });
617 }
618 let text = String::from_utf8_lossy(&bytes);
619 if !text.contains("#EXTM3U") || !text.contains("#EXT-X-STREAM-INF") {
620 return Ok(vec![("Auto".into(), url.into())]);
621 }
622 let base = Url::parse(url)?;
623 let resolution = Regex::new(r"(?i)RESOLUTION=\d+x(\d+)").expect("static regex");
624 let lines = text.lines().collect::<Vec<_>>();
625 let mut variants = Vec::new();
626 for (index, line) in lines.iter().enumerate() {
627 if !line.trim_start().starts_with("#EXT-X-STREAM-INF") {
628 continue;
629 }
630 let label = resolution
631 .captures(line)
632 .map(|captures| format!("{}p", &captures[1]))
633 .unwrap_or_else(|| "Auto".into());
634 if let Some(path) = lines[index + 1..]
635 .iter()
636 .map(|line| line.trim())
637 .find(|line| !line.is_empty() && !line.starts_with('#'))
638 {
639 variants.push((label, base.join(path)?.to_string()));
640 }
641 }
642 if variants.is_empty() {
643 variants.push(("Auto".into(), url.into()));
644 }
645 variants.sort_by_key(|(label, _)| std::cmp::Reverse(quality_number(label)));
646 Ok(variants)
647 }
648
649 async fn get_json(
650 &self,
651 url: &str,
652 referer: &str,
653 ajax: bool,
654 origin: Option<&str>,
655 ) -> Result<Value> {
656 let text = self
657 .get_text(url, referer, ajax, origin, MAX_RESPONSE_BYTES)
658 .await?;
659 serde_json::from_str(&text).map_err(|error| AniError::ProviderInvalidResponse {
660 provider: "Anikoto.cz".to_string(),
661 message: "provider returned invalid JSON".to_string(),
662 source: Some(Box::new(error)),
663 })
664 }
665
666 async fn get_text(
667 &self,
668 url: &str,
669 referer: &str,
670 ajax: bool,
671 origin: Option<&str>,
672 max_bytes: usize,
673 ) -> Result<String> {
674 let mut request = self
675 .inner
676 .http
677 .get(url)
678 .header(header::REFERER, referer)
679 .header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9");
680 if ajax {
681 request = request.header("X-Requested-With", "XMLHttpRequest").header(
682 header::ACCEPT,
683 "application/json, text/javascript, */*; q=0.01",
684 );
685 } else {
686 request = request.header(header::ACCEPT, "text/html,application/xhtml+xml,*/*;q=0.8");
687 }
688 if let Some(origin) = origin {
689 request = request.header(header::ORIGIN, origin);
690 }
691 checked_text(request.send().await?, max_bytes).await
692 }
693}
694
695fn encode_id(value: &AnikotoCzId) -> Result<String> {
696 Ok(format!(
697 "anikoto2:{}",
698 URL_SAFE_NO_PAD.encode(serde_json::to_vec(value)?)
699 ))
700}
701
702fn decode_id(value: &str) -> Result<AnikotoCzId> {
703 if validate_slug(value).is_ok() && !value.starts_with("anikoto2:") {
704 return Ok(AnikotoCzId {
705 slug: value.into(),
706 title: value.replace('-', " "),
707 episodes: None,
708 });
709 }
710 let payload = value
711 .strip_prefix("anikoto2:")
712 .ok_or_else(|| AniError::InvalidInput {
713 input: value.to_string(),
714 reason: "invalid Anikoto.cz show ID".to_string(),
715 })?;
716 let bytes = URL_SAFE_NO_PAD
717 .decode(payload)
718 .map_err(|_| AniError::InvalidInput {
719 input: value.to_string(),
720 reason: "invalid Anikoto.cz show ID encoding".to_string(),
721 })?;
722 let decoded: AnikotoCzId =
723 serde_json::from_slice(&bytes).map_err(|_| AniError::InvalidInput {
724 input: value.to_string(),
725 reason: "invalid Anikoto.cz show metadata".to_string(),
726 })?;
727 validate_slug(&decoded.slug)?;
728 Ok(decoded)
729}
730
731fn filter_search_urls_for_base(
732 base: &str,
733 query: &str,
734 preferred_sort: Option<SearchSort>,
735) -> Vec<String> {
736 let base = Url::parse(base).unwrap_or_else(|_| Url::parse("https://anikoto.cz").unwrap());
737 let defaults = [
738 SearchSort::LatestUpdated,
739 SearchSort::LatestAdded,
740 SearchSort::Score,
741 SearchSort::NameAz,
742 SearchSort::ReleaseDate,
743 SearchSort::MostViewed,
744 SearchSort::NumberOfEpisodes,
745 ];
746
747 let mut urls = Vec::new();
748 let mut seen = HashSet::new();
749 let candidates = std::iter::once(None)
750 .chain(std::iter::once(preferred_sort))
751 .chain(defaults.into_iter().map(Some));
752
753 for sort in candidates {
754 let sort_value = sort.map(|value| value.to_string());
755 if !seen.insert(sort_value.clone()) {
756 continue;
757 }
758
759 let mut url = base.join("/filter").unwrap();
760 {
761 let mut query_pairs = url.query_pairs_mut();
762 query_pairs.clear();
763 query_pairs.append_pair("keyword", query);
764 query_pairs.append_pair("type", "");
765 query_pairs.append_pair("ep_min", "");
766 query_pairs.append_pair("ep_max", "");
767 if let Some(sort) = sort_value.as_deref() {
768 query_pairs.append_pair("sort", sort);
769 }
770 }
771 urls.push(url.to_string());
772 }
773 urls
774}
775
776#[cfg(test)]
777fn filter_search_urls(query: &str) -> Vec<String> {
778 filter_search_urls_for_base("https://anikoto.cz", query, None)
779}
780
781fn parse_episode_counts(element: &scraper::ElementRef) -> (u32, u32, u32) {
782 let sub = element
783 .select(&Selector::parse(".ep-status.sub").unwrap())
784 .find_map(|candidate| {
785 candidate
786 .text()
787 .collect::<String>()
788 .trim()
789 .parse::<u32>()
790 .ok()
791 })
792 .unwrap_or(0);
793 let dub = element
794 .select(&Selector::parse(".ep-status.dub").unwrap())
795 .find_map(|candidate| {
796 candidate
797 .text()
798 .collect::<String>()
799 .trim()
800 .parse::<u32>()
801 .ok()
802 })
803 .unwrap_or(0);
804 let total = element
805 .select(&Selector::parse(".ep-status.total").unwrap())
806 .find_map(|candidate| {
807 candidate
808 .text()
809 .collect::<String>()
810 .trim()
811 .parse::<u32>()
812 .ok()
813 })
814 .unwrap_or(0);
815 (sub, dub, total)
816}
817
818fn parse_search(base: &str, html: &str, mode: TranslationType) -> Result<Vec<SearchResult>> {
819 let document = Html::parse_fragment(html);
820 let list = Selector::parse("#list-items .item")
821 .map_err(|_| provider_invalid_response("invalid result list selector"))?;
822 let title_selector = Selector::parse(".name, .d-title")
823 .map_err(|_| provider_invalid_response("invalid title selector"))?;
824 let base = Url::parse(base)?;
825 let expected_host = base.host_str().unwrap_or_default();
826 let mut seen = HashSet::new();
827 let mut results = Vec::new();
828 let anchor = Selector::parse("a[href]")
829 .map_err(|_| provider_invalid_response("invalid anchor selector"))?;
830 for element in document.select(&list) {
831 let Some(href) = element
832 .select(&anchor)
833 .next()
834 .and_then(|anchor| anchor.value().attr("href"))
835 else {
836 continue;
837 };
838 let Ok(url) = base.join(href) else {
839 continue;
840 };
841 if url.scheme() != "https" || url.host_str() != Some(expected_host) {
842 continue;
843 }
844 let Some(slug) = url
845 .path()
846 .trim_end_matches('/')
847 .strip_prefix("/watch/")
848 .and_then(|slug| slug.split('/').next())
849 .filter(|slug| validate_slug(slug).is_ok())
850 else {
851 continue;
852 };
853 if !seen.insert(slug.to_owned()) {
854 continue;
855 }
856 let title = element
857 .select(&title_selector)
858 .next()
859 .map(|value| clean_text(&value.text().collect::<String>()))
860 .filter(|value| !value.is_empty())
861 .unwrap_or_else(|| slug.replace('-', " "));
862 let (sub_count, dub_count, total_count) = parse_episode_counts(&element);
863 let episode_count = match mode {
864 TranslationType::Sub => sub_count.max(total_count),
865 TranslationType::Dub => dub_count.max(total_count),
866 };
867 let total = total_count.max(sub_count).max(dub_count);
868 results.push(SearchResult {
869 id: encode_id(&AnikotoCzId {
870 slug: slug.into(),
871 title: title.clone(),
872 episodes: (total > 0).then_some(total),
873 })?,
874 name: title,
875 episodes: episode_count as f64,
876 provider: CatalogProvider::Anikoto2,
877 });
878 }
879 Ok(results)
880}
881
882fn parse_show(base: &str, html: &str) -> Result<(String, String)> {
883 let document = Html::parse_document(html);
884 let selector = Selector::parse("#watch-main")
885 .map_err(|_| provider_invalid_response("invalid show selector"))?;
886 let element = document
887 .select(&selector)
888 .next()
889 .ok_or_else(|| provider_invalid_response("show page did not expose #watch-main"))?;
890 let show_id = element.value().attr("data-id").unwrap_or_default();
891 let canonical = element.value().attr("data-url").unwrap_or_default();
892 if !show_id.bytes().all(|byte| byte.is_ascii_digit()) || show_id.is_empty() {
893 return Err(AniError::ProviderInvalidResponse {
894 provider: "Anikoto.cz".to_string(),
895 message: "show page exposed an invalid catalog ID".to_string(),
896 source: None,
897 });
898 }
899 let base = Url::parse(base)?;
900 let canonical = base.join(canonical)?;
901 if canonical.scheme() != "https" || canonical.host_str() != base.host_str() {
902 return Err(AniError::ProviderInvalidResponse {
903 provider: "Anikoto.cz".to_string(),
904 message: "show page exposed an invalid canonical URL".to_string(),
905 source: None,
906 });
907 }
908 Ok((
909 show_id.into(),
910 canonical.to_string().trim_end_matches('/').into(),
911 ))
912}
913
914fn parse_episodes(html: &str) -> Result<Vec<Episode>> {
915 let document = Html::parse_fragment(html);
916 let selector = Selector::parse("a[data-num][data-ids]")
917 .map_err(|_| provider_invalid_response("invalid episode selector"))?;
918 let mut seen = HashSet::new();
919 let mut episodes = Vec::new();
920 for element in document.select(&selector) {
921 let Some(number) = element
922 .value()
923 .attr("data-num")
924 .and_then(|value| normalize_episode(value).ok())
925 else {
926 continue;
927 };
928 if !seen.insert(number.clone()) {
929 continue;
930 }
931 let attrs = element.value();
932 episodes.push(Episode {
933 slug: attrs.attr("data-slug").unwrap_or(&number).into(),
934 token: attrs.attr("data-ids").unwrap_or_default().into(),
935 mal_id: attrs.attr("data-mal").unwrap_or_default().into(),
936 timestamp: attrs.attr("data-timestamp").unwrap_or_default().into(),
937 sub: attrs.attr("data-sub") == Some("1"),
938 dub: attrs.attr("data-dub") == Some("1"),
939 number,
940 });
941 }
942 episodes.sort_by(|a, b| {
943 a.number
944 .parse::<f64>()
945 .unwrap_or_default()
946 .total_cmp(&b.number.parse::<f64>().unwrap_or_default())
947 });
948 Ok(episodes)
949}
950
951fn parse_servers(html: &str, mode: TranslationType) -> Result<Vec<Server>> {
952 let document = Html::parse_fragment(html);
953 let groups = Selector::parse("div.type")
954 .map_err(|_| provider_invalid_response("invalid server selector"))?;
955 let labels = Selector::parse("label")
956 .map_err(|_| provider_invalid_response("invalid server label selector"))?;
957 let items = Selector::parse("li[data-link-id]")
958 .map_err(|_| provider_invalid_response("invalid server item selector"))?;
959 let mut servers = Vec::new();
960 let mut seen = HashSet::new();
961 for group in document.select(&groups) {
962 let type_name = group.value().attr("data-type").unwrap_or_default();
963 let label = group
964 .select(&labels)
965 .next()
966 .map(|label| clean_text(&label.text().collect::<String>()))
967 .unwrap_or_default();
968 let kind = server_kind(type_name, &label);
969 if (mode == TranslationType::Dub) != (kind == "dub") {
970 continue;
971 }
972 for item in group.select(&items) {
973 let Some(token) = item.value().attr("data-link-id") else {
974 continue;
975 };
976 if !seen.insert(token.to_owned()) {
977 continue;
978 }
979 let prefix = if kind == "hsub" { "H-SUB" } else { kind };
980 let name = clean_text(&item.text().collect::<String>());
981 servers.push(Server {
982 label: format!(
983 "{} ยท {}",
984 prefix.to_ascii_uppercase(),
985 if name.is_empty() { "Server" } else { &name }
986 ),
987 token: token.into(),
988 });
989 }
990 }
991 Ok(servers)
992}
993
994fn server_kind(type_name: &str, label: &str) -> &'static str {
995 let combined = format!("{type_name} {label}").to_ascii_lowercase();
996 if type_name.eq_ignore_ascii_case("dub")
997 || Regex::new(r"\b(?:a-?dub|dub|h-?dub)\b")
998 .expect("static regex")
999 .is_match(&combined)
1000 {
1001 "dub"
1002 } else if type_name.eq_ignore_ascii_case("hsub")
1003 || Regex::new(r"\bh[\s-]*sub\b")
1004 .expect("static regex")
1005 .is_match(&combined)
1006 {
1007 "hsub"
1008 } else {
1009 "sub"
1010 }
1011}
1012
1013fn parse_mapper(payload: &Value, mode: &str) -> Vec<Server> {
1014 let Some(object) = payload.as_object() else {
1015 return Vec::new();
1016 };
1017 let labels = [
1018 ("gogoanime", "Vidstream"),
1019 ("animepahe", "Kiwi-Stream"),
1020 ("anivibe", "Vibe-Stream"),
1021 ]
1022 .into_iter()
1023 .collect::<HashMap<_, _>>();
1024 object
1025 .iter()
1026 .filter(|(provider, _)| provider.as_str() != "status")
1027 .filter_map(|(provider, entry)| {
1028 let token = entry.get(mode)?.get("url")?.as_str()?;
1029 Some(Server {
1030 label: format!(
1031 "{} ยท {}",
1032 if mode == "dub" { "A-DUB" } else { "H-SUB" },
1033 labels.get(provider.as_str()).copied().unwrap_or(provider)
1034 ),
1035 token: token.into(),
1036 })
1037 })
1038 .collect()
1039}
1040
1041fn parse_sources(value: &Value) -> (Vec<(String, String)>, Vec<SubtitleTrack>) {
1042 fn collect(value: &Value, output: &mut Vec<(String, String)>) {
1043 match value {
1044 Value::String(url) => output.push((url.clone(), "Auto".into())),
1045 Value::Array(values) => values.iter().for_each(|value| collect(value, output)),
1046 Value::Object(object) => {
1047 if let Some(url) = ["file", "url", "src"]
1048 .iter()
1049 .find_map(|key| object.get(*key).and_then(Value::as_str))
1050 {
1051 let label = ["label", "quality"]
1052 .iter()
1053 .find_map(|key| object.get(*key).and_then(Value::as_str))
1054 .unwrap_or("Auto");
1055 output.push((url.into(), clean_text(label)));
1056 }
1057 let mut traversed = false;
1058 for key in ["sources", "source", "links"] {
1059 if let Some(child) = object.get(key) {
1060 collect(child, output);
1061 traversed = true;
1062 }
1063 }
1064 if !traversed
1065 && !["file", "url", "src"]
1066 .iter()
1067 .any(|key| object.contains_key(*key))
1068 {
1069 object.values().for_each(|value| collect(value, output));
1070 }
1071 }
1072 _ => {}
1073 }
1074 }
1075
1076 let mut sources = Vec::new();
1077 if let Some(root) = value.get("sources").or_else(|| value.get("source")) {
1078 collect(root, &mut sources);
1079 }
1080 let mut subtitles = Vec::new();
1081 for key in ["tracks", "captions", "subtitles"] {
1082 let Some(entries) = value.get(key).and_then(Value::as_array) else {
1083 continue;
1084 };
1085 for entry in entries {
1086 let kind = entry
1087 .get("kind")
1088 .or_else(|| entry.get("type"))
1089 .and_then(Value::as_str)
1090 .unwrap_or_default()
1091 .to_ascii_lowercase();
1092 if !kind.is_empty()
1093 && !kind.contains("caption")
1094 && !kind.contains("subtitle")
1095 && !kind.contains("sub")
1096 {
1097 continue;
1098 }
1099 let Some(url) = ["file", "src", "url"]
1100 .iter()
1101 .find_map(|key| entry.get(*key).and_then(Value::as_str))
1102 .and_then(|url| validate_remote_url(url).ok())
1103 else {
1104 continue;
1105 };
1106 subtitles.push(SubtitleTrack {
1107 label: entry
1108 .get("label")
1109 .or_else(|| entry.get("title"))
1110 .and_then(Value::as_str)
1111 .map(clean_text)
1112 .filter(|label| !label.is_empty())
1113 .unwrap_or_else(|| "Unknown".into()),
1114 url: url.to_string(),
1115 default: entry
1116 .get("default")
1117 .and_then(Value::as_bool)
1118 .unwrap_or(false),
1119 });
1120 }
1121 }
1122 let mut seen = HashSet::new();
1123 sources.retain(|(url, _)| validate_remote_url(url).is_ok() && seen.insert(url.clone()));
1124 let mut seen = HashSet::new();
1125 subtitles.retain(|track| seen.insert(track.url.clone()));
1126 (sources, subtitles)
1127}
1128
1129fn parse_data_id(html: &str) -> Option<String> {
1130 Regex::new(r#"(?i)\bdata-id=["'](\d+)["']"#)
1131 .expect("static regex")
1132 .captures(html)
1133 .map(|captures| captures[1].into())
1134}
1135
1136fn provider_invalid_response(message: impl Into<String>) -> AniError {
1137 AniError::ProviderInvalidResponse {
1138 provider: "Anikoto.cz".to_string(),
1139 message: message.into(),
1140 source: None,
1141 }
1142}
1143
1144fn provider_result<'a>(value: &'a Value, context: &str) -> Result<&'a Value> {
1145 if value.get("status").and_then(Value::as_u64) != Some(200) {
1146 let message = value
1147 .get("message")
1148 .and_then(Value::as_str)
1149 .unwrap_or(context);
1150 return Err(AniError::ProviderInvalidResponse {
1151 provider: "Anikoto.cz".to_string(),
1152 message: format!("invalid Anikoto.cz {context} response: {message}"),
1153 source: None,
1154 });
1155 }
1156 value
1157 .get("result")
1158 .ok_or_else(|| provider_invalid_response(format!("{context} response has no result")))
1159}
1160
1161async fn checked_text(response: Response, max_bytes: usize) -> Result<String> {
1162 let status = response.status();
1163 if status == StatusCode::TOO_MANY_REQUESTS {
1164 let retry_after_seconds = response
1165 .headers()
1166 .get(header::RETRY_AFTER)
1167 .and_then(|value| value.to_str().ok())
1168 .and_then(|value| value.parse().ok())
1169 .unwrap_or(120);
1170 return Err(AniError::ProviderRateLimited {
1171 provider: "Anikoto.cz".into(),
1172 retry_after_seconds,
1173 });
1174 }
1175 if !status.is_success() {
1176 return Err(AniError::ProviderCatalogError {
1177 provider: "Anikoto.cz".into(),
1178 message: format!("HTTP {}", status),
1179 source: None,
1180 });
1181 }
1182 if let Some(length) = response
1183 .content_length()
1184 .filter(|&length| length > max_bytes as u64)
1185 {
1186 return Err(AniError::ProviderResponseSizeExceeded {
1187 provider: "Anikoto.cz".to_string(),
1188 size: length as usize,
1189 limit: max_bytes,
1190 });
1191 }
1192 let bytes = response.bytes().await?;
1193 if bytes.len() > max_bytes {
1194 return Err(AniError::ProviderResponseSizeExceeded {
1195 provider: "Anikoto.cz".to_string(),
1196 size: bytes.len(),
1197 limit: max_bytes,
1198 });
1199 }
1200 Ok(String::from_utf8_lossy(&bytes).into_owned())
1201}
1202
1203fn apply_headers(
1204 mut request: reqwest::RequestBuilder,
1205 headers: &RequestHeaders,
1206) -> reqwest::RequestBuilder {
1207 if let Some(referer) = &headers.referer {
1208 request = request.header(header::REFERER, referer);
1209 }
1210 if let Some(origin) = &headers.origin {
1211 request = request.header(header::ORIGIN, origin);
1212 }
1213 for (name, value) in &headers.extra {
1214 request = request.header(name, value);
1215 }
1216 request
1217}
1218
1219fn normalize_episode(value: &str) -> Result<String> {
1220 let number = value
1221 .trim()
1222 .parse::<f64>()
1223 .ok()
1224 .filter(|number| number.is_finite() && *number >= 0.0)
1225 .ok_or_else(|| AniError::InvalidInput {
1226 input: value.to_string(),
1227 reason: "invalid episode number".to_string(),
1228 })?;
1229 if number.fract() == 0.0 {
1230 Ok(format!("{number:.0}"))
1231 } else {
1232 Ok(number.to_string())
1233 }
1234}
1235
1236fn validate_slug(value: &str) -> Result<()> {
1237 if Regex::new(r"(?i)^[a-z0-9][a-z0-9-]{0,199}$")
1238 .expect("static regex")
1239 .is_match(value)
1240 {
1241 Ok(())
1242 } else {
1243 Err(AniError::InvalidInput {
1244 input: value.to_string(),
1245 reason: "invalid Anikoto.cz show slug".to_string(),
1246 })
1247 }
1248}
1249
1250fn validate_remote_url(value: &str) -> Result<Url> {
1251 let url = Url::parse(value)?;
1252 if url.scheme() != "https"
1253 || url.host_str().is_none()
1254 || !url.username().is_empty()
1255 || url.password().is_some()
1256 {
1257 return Err(AniError::ProviderUrlValidationFailed {
1258 reason: "unsafe provider URL".to_string(),
1259 });
1260 }
1261 if url
1262 .host_str()
1263 .is_some_and(|host| host.parse::<std::net::IpAddr>().is_ok())
1264 {
1265 return Err(AniError::ProviderUrlValidationFailed {
1266 reason: "literal-IP provider URLs are not allowed".to_string(),
1267 });
1268 }
1269 Ok(url)
1270}
1271
1272fn host_matches(host: &str, domain: &str) -> bool {
1273 let host = host.trim_end_matches('.').to_ascii_lowercase();
1274 let domain = domain.trim_end_matches('.').to_ascii_lowercase();
1275 host == domain || host.ends_with(&format!(".{domain}"))
1276}
1277
1278fn clean_text(value: &str) -> String {
1279 value.split_whitespace().collect::<Vec<_>>().join(" ")
1280}
1281
1282fn quality_number(value: &str) -> u32 {
1283 Regex::new(r"(\d{3,4})")
1284 .expect("static regex")
1285 .captures(value)
1286 .and_then(|captures| captures[1].parse().ok())
1287 .unwrap_or(0)
1288}
1289
1290fn encode_path(value: &str) -> String {
1291 url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
1292}
1293
1294fn cache_get<T: Clone>(cache: &Mutex<HashMap<String, Cached<T>>>, key: &str) -> Option<T> {
1295 let mut cache = cache.lock().ok()?;
1296 cache.retain(|_, value| value.expires_at > Instant::now());
1297 cache.get(key).map(|value| value.value.clone())
1298}
1299
1300fn cache_put<T>(cache: &Mutex<HashMap<String, Cached<T>>>, key: String, value: T) {
1301 if let Ok(mut cache) = cache.lock() {
1302 if cache.len() >= CACHE_LIMIT
1303 && let Some(key) = cache.keys().next().cloned()
1304 {
1305 cache.remove(&key);
1306 }
1307 cache.insert(
1308 key,
1309 Cached {
1310 expires_at: Instant::now() + CACHE_TTL,
1311 value,
1312 },
1313 );
1314 }
1315}
1316
1317pub fn requires_hls_relay(stream: &StreamLink) -> bool {
1318 stream.hls
1319 && Url::parse(&stream.url)
1320 .ok()
1321 .and_then(|url| url.host_str().map(str::to_owned))
1322 .is_some_and(|host| {
1323 [
1324 "megaplay.buzz",
1325 "megap.shiora.top",
1326 "shiora.top",
1327 "megap.kotocdn.site",
1328 "kotocdn.site",
1329 "megap.akirax.buzz",
1330 "akirax.buzz",
1331 ]
1332 .iter()
1333 .any(|domain| host_matches(&host, domain))
1334 })
1335}
1336
1337fn player_parameters(script: &str) -> Result<(String, String, String, u64)> {
1338 let re = Regex::new(r#"\bconst\s+[\w$]+="((?:\\.|[^"\\])*)",[\w$]+="((?:\\.|[^"\\])*)",[\w$]+="((?:\\.|[^"\\])*)",[\w$]+=(\d+);function\s+[\w$]+\("#).unwrap();
1339 for captures in re.captures_iter(script) {
1340 let m = captures.get(0).unwrap();
1341 let end = m.end();
1342 let limit = (end + 2500).min(script.len());
1343 if script[end..limit].contains("AES-CBC") {
1344 let parse = |s: &str| -> String {
1345 serde_json::from_str(&format!("\"{}\"", s)).unwrap_or_else(|_| s.to_string())
1346 };
1347 let key = parse(&captures[1]);
1348 let iv = parse(&captures[2]);
1349 let secret = parse(&captures[3]);
1350 let ttl = captures[4].parse::<u64>().unwrap_or(0);
1351 return Ok((key, iv, secret, ttl));
1352 }
1353 }
1354 Err(provider_invalid_response(
1355 "Player crypto parameters changed; update player_parameters().",
1356 ))
1357}
1358
1359fn decrypt_source(encoded: &str, key: &str, iv: &str) -> Result<Value> {
1360 let mut key_bytes = [0u8; 32];
1361 let key_src = key.as_bytes();
1362 let key_len = key_src.len().min(32);
1363 key_bytes[..key_len].copy_from_slice(&key_src[..key_len]);
1364
1365 let mut iv_bytes = [0u8; 16];
1366 let iv_src = iv.as_bytes();
1367 let iv_len = iv_src.len().min(16);
1368 iv_bytes[..iv_len].copy_from_slice(&iv_src[..iv_len]);
1369
1370 let mut enc = encoded.to_string();
1371 if !enc.len().is_multiple_of(4) {
1372 enc.push_str(&"=".repeat(4 - (enc.len() % 4)));
1373 }
1374 let mut encrypted = base64::engine::general_purpose::URL_SAFE
1375 .decode(enc)
1376 .map_err(|_| provider_invalid_response("Invalid base64 in enc"))?;
1377
1378 let pt = cbc::Decryptor::<aes::Aes256>::new(&key_bytes.into(), &iv_bytes.into())
1379 .decrypt_padded::<aes::cipher::block_padding::Pkcs7>(&mut encrypted)
1380 .map_err(|_| provider_invalid_response("Decryption failed"))?;
1381
1382 let json_str = std::str::from_utf8(pt)
1383 .map_err(|_| provider_invalid_response("Invalid UTF-8 in decrypted data"))?;
1384 serde_json::from_str(json_str)
1385 .map_err(|_| provider_invalid_response("Invalid JSON in decrypted data"))
1386}
1387
1388fn signed_url(url: &str, secret: &str, ttl: u64) -> Result<String> {
1389 let mut parsed =
1390 Url::parse(url).map_err(|_| provider_invalid_response("Invalid url for signing"))?;
1391 if parsed.query_pairs().any(|(k, _)| k == "token") {
1392 return Ok(url.to_string());
1393 }
1394
1395 let path = parsed.path();
1396 let re = Regex::new(r"(?i)/([a-f0-9]{32})/([a-f0-9]{32})/").unwrap();
1397 let captures = re.captures(path).ok_or_else(|| {
1398 provider_invalid_response("Unexpected CDN path; cannot reproduce the player's token.")
1399 })?;
1400
1401 let expires = std::time::SystemTime::now()
1402 .duration_since(std::time::UNIX_EPOCH)
1403 .unwrap()
1404 .as_secs()
1405 + ttl;
1406 let message = format!(
1407 "{}|{}/{}",
1408 expires,
1409 captures[1].to_ascii_lowercase(),
1410 captures[2].to_ascii_lowercase()
1411 );
1412
1413 use hmac::{Hmac, Mac};
1414 use sha2::Sha256;
1415 type HmacSha256 = Hmac<Sha256>;
1416
1417 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
1418 .map_err(|_| provider_invalid_response("Invalid secret size for HMAC"))?;
1419 mac.update(message.as_bytes());
1420 let signature = mac.finalize().into_bytes();
1421
1422 let encode =
1423 |value: &[u8]| -> String { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(value) };
1424 let token = format!("{}.{}", encode(message.as_bytes()), encode(&signature));
1425
1426 parsed.query_pairs_mut().append_pair("token", &token);
1427 Ok(parsed.to_string())
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432 use super::*;
1433 use wiremock::{
1434 Mock, MockServer, ResponseTemplate,
1435 matchers::{header, method, path, query_param},
1436 };
1437
1438 #[tokio::test]
1439 async fn native_source_requests_preserve_language_for_shared_episode_ids() {
1440 let server = MockServer::start().await;
1441 let client = AnikotoCzClient::new().unwrap();
1442
1443 for (mode, language) in [(TranslationType::Sub, "sub"), (TranslationType::Dub, "dub")] {
1444 let embed_url = format!("https://vidtube.site/stream/example/{language}");
1445 let media_url = format!("https://media.example/{language}.m3u8");
1446 Mock::given(method("GET"))
1447 .and(path("/stream/getSourcesNew"))
1448 .and(query_param("id", "42"))
1449 .and(query_param("type", language))
1450 .and(header("Referer", embed_url.as_str()))
1451 .and(header("Origin", server.uri().as_str()))
1452 .and(header("X-Requested-With", "XMLHttpRequest"))
1453 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1454 "sources": {"file": media_url}
1455 })))
1456 .expect(1)
1457 .mount(&server)
1458 .await;
1459
1460 let payload = client
1461 .get_native_sources(&server.uri(), &embed_url, "42", mode, "<html></html>")
1462 .await
1463 .unwrap();
1464 let (sources, _) = parse_sources(&payload);
1465 assert_eq!(sources[0].0, media_url);
1466 }
1467 }
1468
1469 #[test]
1470 fn ids_round_trip_and_raw_slugs_are_supported() {
1471 let id = AnikotoCzId {
1472 slug: "black-torch-1d364".into(),
1473 title: "Black Torch".into(),
1474 episodes: None,
1475 };
1476 let encoded = encode_id(&id).unwrap();
1477 assert_eq!(decode_id(&encoded).unwrap(), id);
1478 assert_eq!(
1479 decode_id("black-torch-1d364").unwrap().slug,
1480 "black-torch-1d364"
1481 );
1482 }
1483
1484 #[test]
1485 fn parses_search_and_deduplicates_slugs() {
1486 let html = r#"
1487 <div id="list-items" class="ani items">
1488 <div class="item">
1489 <a href="/watch/black-torch-1d364">
1490 <div class="name">Black Torch</div>
1491 <div class="ep-status total"><span>22</span></div>
1492 </a>
1493 </div>
1494 <div class="item"><a href="/watch/black-torch-1d364"><span class="d-title">Duplicate</span></a></div>
1495 </div>
1496 "#;
1497 let values = parse_search("https://anikoto.cz", html, TranslationType::Sub).unwrap();
1498 assert_eq!(values.len(), 1);
1499 assert_eq!(values[0].name, "Black Torch");
1500 assert_eq!(values[0].episodes, 22.0);
1501 assert_eq!(values[0].provider, CatalogProvider::Anikoto2);
1502 assert_eq!(decode_id(&values[0].id).unwrap().episodes, Some(22));
1503 }
1504
1505 #[test]
1506 fn ignores_unrelated_items_outside_the_filter_result_list() {
1507 let html = r#"
1508 <div class="top-rated">
1509 <a class="item" href="/watch/dorohedoro-season-2-bqfe6"><span class="name">Dorohedoro Season 2</span></a>
1510 </div>
1511 <div id="list-items" class="ani items">
1512 <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>
1513 </div>
1514 "#;
1515 let values = parse_search("https://anikoto.cz", html, TranslationType::Sub).unwrap();
1516 assert_eq!(values.len(), 1);
1517 assert_eq!(
1518 values[0].name,
1519 "One Piece: Episode of Luffy - Hand Island Adventure"
1520 );
1521 }
1522
1523 #[test]
1524 fn builds_supported_filter_sort_urls() {
1525 let urls = filter_search_urls("one piece");
1526 assert_eq!(urls.len(), 8);
1527 assert!(urls[0].contains("/filter?keyword=one+piece"));
1528 assert!(urls.iter().any(|url| url.contains("sort=latest-updated")));
1529 assert!(urls.iter().any(|url| url.contains("sort=name-az")));
1530 assert!(
1531 urls.iter()
1532 .any(|url| url.contains("sort=number_of_episodes"))
1533 );
1534 }
1535
1536 #[test]
1537 fn parses_fractional_episode_availability() {
1538 let html = r#"
1539 <a data-num="2.5" data-slug="2-5" data-ids="two" data-sub="1" data-dub="0"></a>
1540 <a data-num="1" data-slug="1" data-ids="one" data-sub="1" data-dub="1"></a>
1541 "#;
1542 let episodes = parse_episodes(html).unwrap();
1543 assert_eq!(episodes[0].number, "1");
1544 assert!(episodes[0].dub);
1545 assert_eq!(episodes[1].number, "2.5");
1546 }
1547
1548 #[test]
1549 fn keeps_soft_hard_and_dub_servers_separate() {
1550 let html = r#"
1551 <div class="type" data-type="sub"><label>SUB</label>
1552 <ul><li data-link-id="soft">VidPlay-1</li></ul>
1553 </div>
1554 <div class="type" data-type="hsub"><label>HSUB</label>
1555 <ul><li data-link-id="hard">HD-1</li></ul>
1556 </div>
1557 <div class="type" data-type="dub"><label>DUB</label>
1558 <ul><li data-link-id="dub">Vidstream-2</li></ul>
1559 </div>
1560 "#;
1561 let sub = parse_servers(html, TranslationType::Sub).unwrap();
1562 assert_eq!(sub.len(), 2);
1563 assert!(sub[1].label.starts_with("H-SUB"));
1564 let dub = parse_servers(html, TranslationType::Dub).unwrap();
1565 assert_eq!(dub.len(), 1);
1566 assert!(dub[0].label.starts_with("DUB"));
1567 }
1568
1569 #[test]
1570 fn nested_sources_and_subtitles_are_normalized() {
1571 let (sources, subtitles) = parse_sources(&serde_json::json!({
1572 "sources":{"links":[{"file":"https://megap.kotocdn.site/master.m3u8","label":"1080p"}]},
1573 "tracks":[{"file":"https://megap.kotocdn.site/en.vtt","label":"English","kind":"captions","default":true}]
1574 }));
1575 assert_eq!(sources[0].1, "1080p");
1576 assert_eq!(subtitles[0].label, "English");
1577 assert!(subtitles[0].default);
1578 }
1579
1580 #[tokio::test]
1581 async fn live_anikoto_cz_smoke_test_is_opt_in() {
1582 if std::env::var("ANI_CLI_LIVE_ANIKOTO2").as_deref() != Ok("1") {
1583 return;
1584 }
1585 let client = AnikotoCzClient::new().unwrap();
1586 let results = client
1587 .search("black torch", TranslationType::Sub)
1588 .await
1589 .unwrap();
1590 let show = results
1591 .iter()
1592 .find(|result| result.name.eq_ignore_ascii_case("Black Torch"))
1593 .unwrap();
1594 let episodes = client
1595 .episodes(&show.id, TranslationType::Sub)
1596 .await
1597 .unwrap();
1598 assert!(episodes.iter().any(|episode| episode == "1"));
1599 let streams = client
1600 .streams(&show.id, "1", TranslationType::Sub)
1601 .await
1602 .unwrap();
1603 assert!(!streams.is_empty());
1604 assert!(streams.iter().any(|stream| stream.hls));
1605 }
1606}