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 if Self::has_separate_audio_rendition(&text) {
626 return Ok(vec![("Auto".into(), url.into())]);
627 }
628 let base = Url::parse(url)?;
629 let resolution = Regex::new(r"(?i)RESOLUTION=\d+x(\d+)").expect("static regex");
630 let lines = text.lines().collect::<Vec<_>>();
631 let mut variants = Vec::new();
632 for (index, line) in lines.iter().enumerate() {
633 if !line.trim_start().starts_with("#EXT-X-STREAM-INF") {
634 continue;
635 }
636 let label = resolution
637 .captures(line)
638 .map(|captures| format!("{}p", &captures[1]))
639 .unwrap_or_else(|| "Auto".into());
640 if let Some(path) = lines[index + 1..]
641 .iter()
642 .map(|line| line.trim())
643 .find(|line| !line.is_empty() && !line.starts_with('#'))
644 {
645 variants.push((label, base.join(path)?.to_string()));
646 }
647 }
648 if variants.is_empty() {
649 variants.push(("Auto".into(), url.into()));
650 }
651 variants.sort_by_key(|(label, _)| std::cmp::Reverse(quality_number(label)));
652 Ok(variants)
653 }
654
655 fn has_separate_audio_rendition(playlist: &str) -> bool {
656 playlist.lines().any(|line| {
657 let line = line.trim_start().to_ascii_uppercase();
658 line.starts_with("#EXT-X-MEDIA:")
659 && (line.contains("TYPE=AUDIO") || line.contains("TYPE=\"AUDIO\""))
660 })
661 }
662
663 async fn get_json(
664 &self,
665 url: &str,
666 referer: &str,
667 ajax: bool,
668 origin: Option<&str>,
669 ) -> Result<Value> {
670 let text = self
671 .get_text(url, referer, ajax, origin, MAX_RESPONSE_BYTES)
672 .await?;
673 serde_json::from_str(&text).map_err(|error| AniError::ProviderInvalidResponse {
674 provider: "Anikoto.cz".to_string(),
675 message: "provider returned invalid JSON".to_string(),
676 source: Some(Box::new(error)),
677 })
678 }
679
680 async fn get_text(
681 &self,
682 url: &str,
683 referer: &str,
684 ajax: bool,
685 origin: Option<&str>,
686 max_bytes: usize,
687 ) -> Result<String> {
688 let mut request = self
689 .inner
690 .http
691 .get(url)
692 .header(header::REFERER, referer)
693 .header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9");
694 if ajax {
695 request = request.header("X-Requested-With", "XMLHttpRequest").header(
696 header::ACCEPT,
697 "application/json, text/javascript, */*; q=0.01",
698 );
699 } else {
700 request = request.header(header::ACCEPT, "text/html,application/xhtml+xml,*/*;q=0.8");
701 }
702 if let Some(origin) = origin {
703 request = request.header(header::ORIGIN, origin);
704 }
705 checked_text(request.send().await?, max_bytes).await
706 }
707}
708
709fn encode_id(value: &AnikotoCzId) -> Result<String> {
710 Ok(format!(
711 "anikoto2:{}",
712 URL_SAFE_NO_PAD.encode(serde_json::to_vec(value)?)
713 ))
714}
715
716fn decode_id(value: &str) -> Result<AnikotoCzId> {
717 if validate_slug(value).is_ok() && !value.starts_with("anikoto2:") {
718 return Ok(AnikotoCzId {
719 slug: value.into(),
720 title: value.replace('-', " "),
721 episodes: None,
722 });
723 }
724 let payload = value
725 .strip_prefix("anikoto2:")
726 .ok_or_else(|| AniError::InvalidInput {
727 input: value.to_string(),
728 reason: "invalid Anikoto.cz show ID".to_string(),
729 })?;
730 let bytes = URL_SAFE_NO_PAD
731 .decode(payload)
732 .map_err(|_| AniError::InvalidInput {
733 input: value.to_string(),
734 reason: "invalid Anikoto.cz show ID encoding".to_string(),
735 })?;
736 let decoded: AnikotoCzId =
737 serde_json::from_slice(&bytes).map_err(|_| AniError::InvalidInput {
738 input: value.to_string(),
739 reason: "invalid Anikoto.cz show metadata".to_string(),
740 })?;
741 validate_slug(&decoded.slug)?;
742 Ok(decoded)
743}
744
745fn filter_search_urls_for_base(
746 base: &str,
747 query: &str,
748 preferred_sort: Option<SearchSort>,
749) -> Vec<String> {
750 let base = Url::parse(base).unwrap_or_else(|_| Url::parse("https://anikoto.cz").unwrap());
751 let defaults = [
752 SearchSort::LatestUpdated,
753 SearchSort::LatestAdded,
754 SearchSort::Score,
755 SearchSort::NameAz,
756 SearchSort::ReleaseDate,
757 SearchSort::MostViewed,
758 SearchSort::NumberOfEpisodes,
759 ];
760
761 let mut urls = Vec::new();
762 let mut seen = HashSet::new();
763 let candidates = std::iter::once(None)
764 .chain(std::iter::once(preferred_sort))
765 .chain(defaults.into_iter().map(Some));
766
767 for sort in candidates {
768 let sort_value = sort.map(|value| value.to_string());
769 if !seen.insert(sort_value.clone()) {
770 continue;
771 }
772
773 let mut url = base.join("/filter").unwrap();
774 {
775 let mut query_pairs = url.query_pairs_mut();
776 query_pairs.clear();
777 query_pairs.append_pair("keyword", query);
778 query_pairs.append_pair("type", "");
779 query_pairs.append_pair("ep_min", "");
780 query_pairs.append_pair("ep_max", "");
781 if let Some(sort) = sort_value.as_deref() {
782 query_pairs.append_pair("sort", sort);
783 }
784 }
785 urls.push(url.to_string());
786 }
787 urls
788}
789
790#[cfg(test)]
791fn filter_search_urls(query: &str) -> Vec<String> {
792 filter_search_urls_for_base("https://anikoto.cz", query, None)
793}
794
795fn parse_episode_counts(element: &scraper::ElementRef) -> (u32, u32, u32) {
796 let sub = element
797 .select(&Selector::parse(".ep-status.sub").unwrap())
798 .find_map(|candidate| {
799 candidate
800 .text()
801 .collect::<String>()
802 .trim()
803 .parse::<u32>()
804 .ok()
805 })
806 .unwrap_or(0);
807 let dub = element
808 .select(&Selector::parse(".ep-status.dub").unwrap())
809 .find_map(|candidate| {
810 candidate
811 .text()
812 .collect::<String>()
813 .trim()
814 .parse::<u32>()
815 .ok()
816 })
817 .unwrap_or(0);
818 let total = element
819 .select(&Selector::parse(".ep-status.total").unwrap())
820 .find_map(|candidate| {
821 candidate
822 .text()
823 .collect::<String>()
824 .trim()
825 .parse::<u32>()
826 .ok()
827 })
828 .unwrap_or(0);
829 (sub, dub, total)
830}
831
832fn parse_search(base: &str, html: &str, mode: TranslationType) -> Result<Vec<SearchResult>> {
833 let document = Html::parse_fragment(html);
834 let list = Selector::parse("#list-items .item")
835 .map_err(|_| provider_invalid_response("invalid result list selector"))?;
836 let title_selector = Selector::parse(".name, .d-title")
837 .map_err(|_| provider_invalid_response("invalid title selector"))?;
838 let base = Url::parse(base)?;
839 let expected_host = base.host_str().unwrap_or_default();
840 let mut seen = HashSet::new();
841 let mut results = Vec::new();
842 let anchor = Selector::parse("a[href]")
843 .map_err(|_| provider_invalid_response("invalid anchor selector"))?;
844 for element in document.select(&list) {
845 let Some(href) = element
846 .select(&anchor)
847 .next()
848 .and_then(|anchor| anchor.value().attr("href"))
849 else {
850 continue;
851 };
852 let Ok(url) = base.join(href) else {
853 continue;
854 };
855 if url.scheme() != "https" || url.host_str() != Some(expected_host) {
856 continue;
857 }
858 let Some(slug) = url
859 .path()
860 .trim_end_matches('/')
861 .strip_prefix("/watch/")
862 .and_then(|slug| slug.split('/').next())
863 .filter(|slug| validate_slug(slug).is_ok())
864 else {
865 continue;
866 };
867 if !seen.insert(slug.to_owned()) {
868 continue;
869 }
870 let title = element
871 .select(&title_selector)
872 .next()
873 .map(|value| clean_text(&value.text().collect::<String>()))
874 .filter(|value| !value.is_empty())
875 .unwrap_or_else(|| slug.replace('-', " "));
876 let (sub_count, dub_count, total_count) = parse_episode_counts(&element);
877 let episode_count = match mode {
878 TranslationType::Sub => sub_count.max(total_count),
879 TranslationType::Dub => dub_count.max(total_count),
880 };
881 let total = total_count.max(sub_count).max(dub_count);
882 results.push(SearchResult {
883 id: encode_id(&AnikotoCzId {
884 slug: slug.into(),
885 title: title.clone(),
886 episodes: (total > 0).then_some(total),
887 })?,
888 name: title,
889 episodes: episode_count as f64,
890 provider: CatalogProvider::Anikoto2,
891 });
892 }
893 Ok(results)
894}
895
896fn parse_show(base: &str, html: &str) -> Result<(String, String)> {
897 let document = Html::parse_document(html);
898 let selector = Selector::parse("#watch-main")
899 .map_err(|_| provider_invalid_response("invalid show selector"))?;
900 let element = document
901 .select(&selector)
902 .next()
903 .ok_or_else(|| provider_invalid_response("show page did not expose #watch-main"))?;
904 let show_id = element.value().attr("data-id").unwrap_or_default();
905 let canonical = element.value().attr("data-url").unwrap_or_default();
906 if !show_id.bytes().all(|byte| byte.is_ascii_digit()) || show_id.is_empty() {
907 return Err(AniError::ProviderInvalidResponse {
908 provider: "Anikoto.cz".to_string(),
909 message: "show page exposed an invalid catalog ID".to_string(),
910 source: None,
911 });
912 }
913 let base = Url::parse(base)?;
914 let canonical = base.join(canonical)?;
915 if canonical.scheme() != "https" || canonical.host_str() != base.host_str() {
916 return Err(AniError::ProviderInvalidResponse {
917 provider: "Anikoto.cz".to_string(),
918 message: "show page exposed an invalid canonical URL".to_string(),
919 source: None,
920 });
921 }
922 Ok((
923 show_id.into(),
924 canonical.to_string().trim_end_matches('/').into(),
925 ))
926}
927
928fn parse_episodes(html: &str) -> Result<Vec<Episode>> {
929 let document = Html::parse_fragment(html);
930 let selector = Selector::parse("a[data-num][data-ids]")
931 .map_err(|_| provider_invalid_response("invalid episode selector"))?;
932 let mut seen = HashSet::new();
933 let mut episodes = Vec::new();
934 for element in document.select(&selector) {
935 let Some(number) = element
936 .value()
937 .attr("data-num")
938 .and_then(|value| normalize_episode(value).ok())
939 else {
940 continue;
941 };
942 if !seen.insert(number.clone()) {
943 continue;
944 }
945 let attrs = element.value();
946 episodes.push(Episode {
947 slug: attrs.attr("data-slug").unwrap_or(&number).into(),
948 token: attrs.attr("data-ids").unwrap_or_default().into(),
949 mal_id: attrs.attr("data-mal").unwrap_or_default().into(),
950 timestamp: attrs.attr("data-timestamp").unwrap_or_default().into(),
951 sub: attrs.attr("data-sub") == Some("1"),
952 dub: attrs.attr("data-dub") == Some("1"),
953 number,
954 });
955 }
956 episodes.sort_by(|a, b| {
957 a.number
958 .parse::<f64>()
959 .unwrap_or_default()
960 .total_cmp(&b.number.parse::<f64>().unwrap_or_default())
961 });
962 Ok(episodes)
963}
964
965fn parse_servers(html: &str, mode: TranslationType) -> Result<Vec<Server>> {
966 let document = Html::parse_fragment(html);
967 let groups = Selector::parse("div.type")
968 .map_err(|_| provider_invalid_response("invalid server selector"))?;
969 let labels = Selector::parse("label")
970 .map_err(|_| provider_invalid_response("invalid server label selector"))?;
971 let items = Selector::parse("li[data-link-id]")
972 .map_err(|_| provider_invalid_response("invalid server item selector"))?;
973 let mut servers = Vec::new();
974 let mut seen = HashSet::new();
975 for group in document.select(&groups) {
976 let type_name = group.value().attr("data-type").unwrap_or_default();
977 let label = group
978 .select(&labels)
979 .next()
980 .map(|label| clean_text(&label.text().collect::<String>()))
981 .unwrap_or_default();
982 let kind = server_kind(type_name, &label);
983 if (mode == TranslationType::Dub) != (kind == "dub") {
984 continue;
985 }
986 for item in group.select(&items) {
987 let Some(token) = item.value().attr("data-link-id") else {
988 continue;
989 };
990 if !seen.insert(token.to_owned()) {
991 continue;
992 }
993 let prefix = if kind == "hsub" { "H-SUB" } else { kind };
994 let name = clean_text(&item.text().collect::<String>());
995 servers.push(Server {
996 label: format!(
997 "{} ยท {}",
998 prefix.to_ascii_uppercase(),
999 if name.is_empty() { "Server" } else { &name }
1000 ),
1001 token: token.into(),
1002 });
1003 }
1004 }
1005 Ok(servers)
1006}
1007
1008fn server_kind(type_name: &str, label: &str) -> &'static str {
1009 let combined = format!("{type_name} {label}").to_ascii_lowercase();
1010 if type_name.eq_ignore_ascii_case("dub")
1011 || Regex::new(r"\b(?:a-?dub|dub|h-?dub)\b")
1012 .expect("static regex")
1013 .is_match(&combined)
1014 {
1015 "dub"
1016 } else if type_name.eq_ignore_ascii_case("hsub")
1017 || Regex::new(r"\bh[\s-]*sub\b")
1018 .expect("static regex")
1019 .is_match(&combined)
1020 {
1021 "hsub"
1022 } else {
1023 "sub"
1024 }
1025}
1026
1027fn parse_mapper(payload: &Value, mode: &str) -> Vec<Server> {
1028 let Some(object) = payload.as_object() else {
1029 return Vec::new();
1030 };
1031 let labels = [
1032 ("gogoanime", "Vidstream"),
1033 ("animepahe", "Kiwi-Stream"),
1034 ("anivibe", "Vibe-Stream"),
1035 ]
1036 .into_iter()
1037 .collect::<HashMap<_, _>>();
1038 object
1039 .iter()
1040 .filter(|(provider, _)| provider.as_str() != "status")
1041 .filter_map(|(provider, entry)| {
1042 let token = entry.get(mode)?.get("url")?.as_str()?;
1043 Some(Server {
1044 label: format!(
1045 "{} ยท {}",
1046 if mode == "dub" { "A-DUB" } else { "H-SUB" },
1047 labels.get(provider.as_str()).copied().unwrap_or(provider)
1048 ),
1049 token: token.into(),
1050 })
1051 })
1052 .collect()
1053}
1054
1055fn parse_sources(value: &Value) -> (Vec<(String, String)>, Vec<SubtitleTrack>) {
1056 fn collect(value: &Value, output: &mut Vec<(String, String)>) {
1057 match value {
1058 Value::String(url) => output.push((url.clone(), "Auto".into())),
1059 Value::Array(values) => values.iter().for_each(|value| collect(value, output)),
1060 Value::Object(object) => {
1061 if let Some(url) = ["file", "url", "src"]
1062 .iter()
1063 .find_map(|key| object.get(*key).and_then(Value::as_str))
1064 {
1065 let label = ["label", "quality"]
1066 .iter()
1067 .find_map(|key| object.get(*key).and_then(Value::as_str))
1068 .unwrap_or("Auto");
1069 output.push((url.into(), clean_text(label)));
1070 }
1071 let mut traversed = false;
1072 for key in ["sources", "source", "links"] {
1073 if let Some(child) = object.get(key) {
1074 collect(child, output);
1075 traversed = true;
1076 }
1077 }
1078 if !traversed
1079 && !["file", "url", "src"]
1080 .iter()
1081 .any(|key| object.contains_key(*key))
1082 {
1083 object.values().for_each(|value| collect(value, output));
1084 }
1085 }
1086 _ => {}
1087 }
1088 }
1089
1090 let mut sources = Vec::new();
1091 if let Some(root) = value.get("sources").or_else(|| value.get("source")) {
1092 collect(root, &mut sources);
1093 }
1094 let mut subtitles = Vec::new();
1095 for key in ["tracks", "captions", "subtitles"] {
1096 let Some(entries) = value.get(key).and_then(Value::as_array) else {
1097 continue;
1098 };
1099 for entry in entries {
1100 let kind = entry
1101 .get("kind")
1102 .or_else(|| entry.get("type"))
1103 .and_then(Value::as_str)
1104 .unwrap_or_default()
1105 .to_ascii_lowercase();
1106 if !kind.is_empty()
1107 && !kind.contains("caption")
1108 && !kind.contains("subtitle")
1109 && !kind.contains("sub")
1110 {
1111 continue;
1112 }
1113 let Some(url) = ["file", "src", "url"]
1114 .iter()
1115 .find_map(|key| entry.get(*key).and_then(Value::as_str))
1116 .and_then(|url| validate_remote_url(url).ok())
1117 else {
1118 continue;
1119 };
1120 subtitles.push(SubtitleTrack {
1121 label: entry
1122 .get("label")
1123 .or_else(|| entry.get("title"))
1124 .and_then(Value::as_str)
1125 .map(clean_text)
1126 .filter(|label| !label.is_empty())
1127 .unwrap_or_else(|| "Unknown".into()),
1128 url: url.to_string(),
1129 default: entry
1130 .get("default")
1131 .and_then(Value::as_bool)
1132 .unwrap_or(false),
1133 });
1134 }
1135 }
1136 let mut seen = HashSet::new();
1137 sources.retain(|(url, _)| validate_remote_url(url).is_ok() && seen.insert(url.clone()));
1138 let mut seen = HashSet::new();
1139 subtitles.retain(|track| seen.insert(track.url.clone()));
1140 (sources, subtitles)
1141}
1142
1143fn parse_data_id(html: &str) -> Option<String> {
1144 Regex::new(r#"(?i)\bdata-id=["'](\d+)["']"#)
1145 .expect("static regex")
1146 .captures(html)
1147 .map(|captures| captures[1].into())
1148}
1149
1150fn provider_invalid_response(message: impl Into<String>) -> AniError {
1151 AniError::ProviderInvalidResponse {
1152 provider: "Anikoto.cz".to_string(),
1153 message: message.into(),
1154 source: None,
1155 }
1156}
1157
1158fn provider_result<'a>(value: &'a Value, context: &str) -> Result<&'a Value> {
1159 if value.get("status").and_then(Value::as_u64) != Some(200) {
1160 let message = value
1161 .get("message")
1162 .and_then(Value::as_str)
1163 .unwrap_or(context);
1164 return Err(AniError::ProviderInvalidResponse {
1165 provider: "Anikoto.cz".to_string(),
1166 message: format!("invalid Anikoto.cz {context} response: {message}"),
1167 source: None,
1168 });
1169 }
1170 value
1171 .get("result")
1172 .ok_or_else(|| provider_invalid_response(format!("{context} response has no result")))
1173}
1174
1175async fn checked_text(response: Response, max_bytes: usize) -> Result<String> {
1176 let status = response.status();
1177 if status == StatusCode::TOO_MANY_REQUESTS {
1178 let retry_after_seconds = response
1179 .headers()
1180 .get(header::RETRY_AFTER)
1181 .and_then(|value| value.to_str().ok())
1182 .and_then(|value| value.parse().ok())
1183 .unwrap_or(120);
1184 return Err(AniError::ProviderRateLimited {
1185 provider: "Anikoto.cz".into(),
1186 retry_after_seconds,
1187 });
1188 }
1189 if !status.is_success() {
1190 return Err(AniError::ProviderCatalogError {
1191 provider: "Anikoto.cz".into(),
1192 message: format!("HTTP {}", status),
1193 source: None,
1194 });
1195 }
1196 if let Some(length) = response
1197 .content_length()
1198 .filter(|&length| length > max_bytes as u64)
1199 {
1200 return Err(AniError::ProviderResponseSizeExceeded {
1201 provider: "Anikoto.cz".to_string(),
1202 size: length as usize,
1203 limit: max_bytes,
1204 });
1205 }
1206 let bytes = response.bytes().await?;
1207 if bytes.len() > max_bytes {
1208 return Err(AniError::ProviderResponseSizeExceeded {
1209 provider: "Anikoto.cz".to_string(),
1210 size: bytes.len(),
1211 limit: max_bytes,
1212 });
1213 }
1214 Ok(String::from_utf8_lossy(&bytes).into_owned())
1215}
1216
1217fn apply_headers(
1218 mut request: reqwest::RequestBuilder,
1219 headers: &RequestHeaders,
1220) -> reqwest::RequestBuilder {
1221 if let Some(referer) = &headers.referer {
1222 request = request.header(header::REFERER, referer);
1223 }
1224 if let Some(origin) = &headers.origin {
1225 request = request.header(header::ORIGIN, origin);
1226 }
1227 for (name, value) in &headers.extra {
1228 request = request.header(name, value);
1229 }
1230 request
1231}
1232
1233fn normalize_episode(value: &str) -> Result<String> {
1234 let number = value
1235 .trim()
1236 .parse::<f64>()
1237 .ok()
1238 .filter(|number| number.is_finite() && *number >= 0.0)
1239 .ok_or_else(|| AniError::InvalidInput {
1240 input: value.to_string(),
1241 reason: "invalid episode number".to_string(),
1242 })?;
1243 if number.fract() == 0.0 {
1244 Ok(format!("{number:.0}"))
1245 } else {
1246 Ok(number.to_string())
1247 }
1248}
1249
1250fn validate_slug(value: &str) -> Result<()> {
1251 if Regex::new(r"(?i)^[a-z0-9][a-z0-9-]{0,199}$")
1252 .expect("static regex")
1253 .is_match(value)
1254 {
1255 Ok(())
1256 } else {
1257 Err(AniError::InvalidInput {
1258 input: value.to_string(),
1259 reason: "invalid Anikoto.cz show slug".to_string(),
1260 })
1261 }
1262}
1263
1264fn validate_remote_url(value: &str) -> Result<Url> {
1265 let url = Url::parse(value)?;
1266 if url.scheme() != "https"
1267 || url.host_str().is_none()
1268 || !url.username().is_empty()
1269 || url.password().is_some()
1270 {
1271 return Err(AniError::ProviderUrlValidationFailed {
1272 reason: "unsafe provider URL".to_string(),
1273 });
1274 }
1275 if url
1276 .host_str()
1277 .is_some_and(|host| host.parse::<std::net::IpAddr>().is_ok())
1278 {
1279 return Err(AniError::ProviderUrlValidationFailed {
1280 reason: "literal-IP provider URLs are not allowed".to_string(),
1281 });
1282 }
1283 Ok(url)
1284}
1285
1286fn host_matches(host: &str, domain: &str) -> bool {
1287 let host = host.trim_end_matches('.').to_ascii_lowercase();
1288 let domain = domain.trim_end_matches('.').to_ascii_lowercase();
1289 host == domain || host.ends_with(&format!(".{domain}"))
1290}
1291
1292fn clean_text(value: &str) -> String {
1293 value.split_whitespace().collect::<Vec<_>>().join(" ")
1294}
1295
1296fn quality_number(value: &str) -> u32 {
1297 Regex::new(r"(\d{3,4})")
1298 .expect("static regex")
1299 .captures(value)
1300 .and_then(|captures| captures[1].parse().ok())
1301 .unwrap_or(0)
1302}
1303
1304fn encode_path(value: &str) -> String {
1305 url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
1306}
1307
1308fn cache_get<T: Clone>(cache: &Mutex<HashMap<String, Cached<T>>>, key: &str) -> Option<T> {
1309 let mut cache = cache.lock().ok()?;
1310 cache.retain(|_, value| value.expires_at > Instant::now());
1311 cache.get(key).map(|value| value.value.clone())
1312}
1313
1314fn cache_put<T>(cache: &Mutex<HashMap<String, Cached<T>>>, key: String, value: T) {
1315 if let Ok(mut cache) = cache.lock() {
1316 if cache.len() >= CACHE_LIMIT
1317 && let Some(key) = cache.keys().next().cloned()
1318 {
1319 cache.remove(&key);
1320 }
1321 cache.insert(
1322 key,
1323 Cached {
1324 expires_at: Instant::now() + CACHE_TTL,
1325 value,
1326 },
1327 );
1328 }
1329}
1330
1331pub fn requires_hls_relay(stream: &StreamLink) -> bool {
1332 stream.hls
1333 && Url::parse(&stream.url)
1334 .ok()
1335 .and_then(|url| url.host_str().map(str::to_owned))
1336 .is_some_and(|host| {
1337 [
1338 "megaplay.buzz",
1339 "megap.shiora.top",
1340 "shiora.top",
1341 "megap.kotocdn.site",
1342 "kotocdn.site",
1343 "megap.akirax.buzz",
1344 "akirax.buzz",
1345 ]
1346 .iter()
1347 .any(|domain| host_matches(&host, domain))
1348 })
1349}
1350
1351fn player_parameters(script: &str) -> Result<(String, String, String, u64)> {
1352 let re = Regex::new(r#"\bconst\s+[\w$]+="((?:\\.|[^"\\])*)",[\w$]+="((?:\\.|[^"\\])*)",[\w$]+="((?:\\.|[^"\\])*)",[\w$]+=(\d+);function\s+[\w$]+\("#).unwrap();
1353 for captures in re.captures_iter(script) {
1354 let m = captures.get(0).unwrap();
1355 let end = m.end();
1356 let limit = (end + 2500).min(script.len());
1357 if script[end..limit].contains("AES-CBC") {
1358 let parse = |s: &str| -> String {
1359 serde_json::from_str(&format!("\"{}\"", s)).unwrap_or_else(|_| s.to_string())
1360 };
1361 let key = parse(&captures[1]);
1362 let iv = parse(&captures[2]);
1363 let secret = parse(&captures[3]);
1364 let ttl = captures[4].parse::<u64>().unwrap_or(0);
1365 return Ok((key, iv, secret, ttl));
1366 }
1367 }
1368 Err(provider_invalid_response(
1369 "Player crypto parameters changed; update player_parameters().",
1370 ))
1371}
1372
1373fn decrypt_source(encoded: &str, key: &str, iv: &str) -> Result<Value> {
1374 let mut key_bytes = [0u8; 32];
1375 let key_src = key.as_bytes();
1376 let key_len = key_src.len().min(32);
1377 key_bytes[..key_len].copy_from_slice(&key_src[..key_len]);
1378
1379 let mut iv_bytes = [0u8; 16];
1380 let iv_src = iv.as_bytes();
1381 let iv_len = iv_src.len().min(16);
1382 iv_bytes[..iv_len].copy_from_slice(&iv_src[..iv_len]);
1383
1384 let mut enc = encoded.to_string();
1385 if !enc.len().is_multiple_of(4) {
1386 enc.push_str(&"=".repeat(4 - (enc.len() % 4)));
1387 }
1388 let mut encrypted = base64::engine::general_purpose::URL_SAFE
1389 .decode(enc)
1390 .map_err(|_| provider_invalid_response("Invalid base64 in enc"))?;
1391
1392 let pt = cbc::Decryptor::<aes::Aes256>::new(&key_bytes.into(), &iv_bytes.into())
1393 .decrypt_padded::<aes::cipher::block_padding::Pkcs7>(&mut encrypted)
1394 .map_err(|_| provider_invalid_response("Decryption failed"))?;
1395
1396 let json_str = std::str::from_utf8(pt)
1397 .map_err(|_| provider_invalid_response("Invalid UTF-8 in decrypted data"))?;
1398 serde_json::from_str(json_str)
1399 .map_err(|_| provider_invalid_response("Invalid JSON in decrypted data"))
1400}
1401
1402fn signed_url(url: &str, secret: &str, ttl: u64) -> Result<String> {
1403 let mut parsed =
1404 Url::parse(url).map_err(|_| provider_invalid_response("Invalid url for signing"))?;
1405 if parsed.query_pairs().any(|(k, _)| k == "token") {
1406 return Ok(url.to_string());
1407 }
1408
1409 let path = parsed.path();
1410 let re = Regex::new(r"(?i)/([a-f0-9]{32})/([a-f0-9]{32})/").unwrap();
1411 let captures = re.captures(path).ok_or_else(|| {
1412 provider_invalid_response("Unexpected CDN path; cannot reproduce the player's token.")
1413 })?;
1414
1415 let expires = std::time::SystemTime::now()
1416 .duration_since(std::time::UNIX_EPOCH)
1417 .unwrap()
1418 .as_secs()
1419 + ttl;
1420 let message = format!(
1421 "{}|{}/{}",
1422 expires,
1423 captures[1].to_ascii_lowercase(),
1424 captures[2].to_ascii_lowercase()
1425 );
1426
1427 use hmac::{Hmac, Mac};
1428 use sha2::Sha256;
1429 type HmacSha256 = Hmac<Sha256>;
1430
1431 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
1432 .map_err(|_| provider_invalid_response("Invalid secret size for HMAC"))?;
1433 mac.update(message.as_bytes());
1434 let signature = mac.finalize().into_bytes();
1435
1436 let encode =
1437 |value: &[u8]| -> String { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(value) };
1438 let token = format!("{}.{}", encode(message.as_bytes()), encode(&signature));
1439
1440 parsed.query_pairs_mut().append_pair("token", &token);
1441 Ok(parsed.to_string())
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446 use super::*;
1447 use wiremock::{
1448 Mock, MockServer, ResponseTemplate,
1449 matchers::{header, method, path, query_param},
1450 };
1451
1452 #[tokio::test]
1453 async fn native_source_requests_preserve_language_for_shared_episode_ids() {
1454 let server = MockServer::start().await;
1455 let client = AnikotoCzClient::new().unwrap();
1456
1457 for (mode, language) in [(TranslationType::Sub, "sub"), (TranslationType::Dub, "dub")] {
1458 let embed_url = format!("https://vidtube.site/stream/example/{language}");
1459 let media_url = format!("https://media.example/{language}.m3u8");
1460 Mock::given(method("GET"))
1461 .and(path("/stream/getSourcesNew"))
1462 .and(query_param("id", "42"))
1463 .and(query_param("type", language))
1464 .and(header("Referer", embed_url.as_str()))
1465 .and(header("Origin", server.uri().as_str()))
1466 .and(header("X-Requested-With", "XMLHttpRequest"))
1467 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1468 "sources": {"file": media_url}
1469 })))
1470 .expect(1)
1471 .mount(&server)
1472 .await;
1473
1474 let payload = client
1475 .get_native_sources(&server.uri(), &embed_url, "42", mode, "<html></html>")
1476 .await
1477 .unwrap();
1478 let (sources, _) = parse_sources(&payload);
1479 assert_eq!(sources[0].0, media_url);
1480 }
1481 }
1482
1483 #[test]
1484 fn ids_round_trip_and_raw_slugs_are_supported() {
1485 let id = AnikotoCzId {
1486 slug: "black-torch-1d364".into(),
1487 title: "Black Torch".into(),
1488 episodes: None,
1489 };
1490 let encoded = encode_id(&id).unwrap();
1491 assert_eq!(decode_id(&encoded).unwrap(), id);
1492 assert_eq!(
1493 decode_id("black-torch-1d364").unwrap().slug,
1494 "black-torch-1d364"
1495 );
1496 }
1497
1498 #[test]
1499 fn parses_search_and_deduplicates_slugs() {
1500 let html = r#"
1501 <div id="list-items" class="ani items">
1502 <div class="item">
1503 <a href="/watch/black-torch-1d364">
1504 <div class="name">Black Torch</div>
1505 <div class="ep-status total"><span>22</span></div>
1506 </a>
1507 </div>
1508 <div class="item"><a href="/watch/black-torch-1d364"><span class="d-title">Duplicate</span></a></div>
1509 </div>
1510 "#;
1511 let values = parse_search("https://anikoto.cz", html, TranslationType::Sub).unwrap();
1512 assert_eq!(values.len(), 1);
1513 assert_eq!(values[0].name, "Black Torch");
1514 assert_eq!(values[0].episodes, 22.0);
1515 assert_eq!(values[0].provider, CatalogProvider::Anikoto2);
1516 assert_eq!(decode_id(&values[0].id).unwrap().episodes, Some(22));
1517 }
1518
1519 #[test]
1520 fn ignores_unrelated_items_outside_the_filter_result_list() {
1521 let html = r#"
1522 <div class="top-rated">
1523 <a class="item" href="/watch/dorohedoro-season-2-bqfe6"><span class="name">Dorohedoro Season 2</span></a>
1524 </div>
1525 <div id="list-items" class="ani items">
1526 <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>
1527 </div>
1528 "#;
1529 let values = parse_search("https://anikoto.cz", html, TranslationType::Sub).unwrap();
1530 assert_eq!(values.len(), 1);
1531 assert_eq!(
1532 values[0].name,
1533 "One Piece: Episode of Luffy - Hand Island Adventure"
1534 );
1535 }
1536
1537 #[test]
1538 fn builds_supported_filter_sort_urls() {
1539 let urls = filter_search_urls("one piece");
1540 assert_eq!(urls.len(), 8);
1541 assert!(urls[0].contains("/filter?keyword=one+piece"));
1542 assert!(urls.iter().any(|url| url.contains("sort=latest-updated")));
1543 assert!(urls.iter().any(|url| url.contains("sort=name-az")));
1544 assert!(
1545 urls.iter()
1546 .any(|url| url.contains("sort=number_of_episodes"))
1547 );
1548 }
1549
1550 #[test]
1551 fn parses_fractional_episode_availability() {
1552 let html = r#"
1553 <a data-num="2.5" data-slug="2-5" data-ids="two" data-sub="1" data-dub="0"></a>
1554 <a data-num="1" data-slug="1" data-ids="one" data-sub="1" data-dub="1"></a>
1555 "#;
1556 let episodes = parse_episodes(html).unwrap();
1557 assert_eq!(episodes[0].number, "1");
1558 assert!(episodes[0].dub);
1559 assert_eq!(episodes[1].number, "2.5");
1560 }
1561
1562 #[test]
1563 fn keeps_soft_hard_and_dub_servers_separate() {
1564 let html = r#"
1565 <div class="type" data-type="sub"><label>SUB</label>
1566 <ul><li data-link-id="soft">VidPlay-1</li></ul>
1567 </div>
1568 <div class="type" data-type="hsub"><label>HSUB</label>
1569 <ul><li data-link-id="hard">HD-1</li></ul>
1570 </div>
1571 <div class="type" data-type="dub"><label>DUB</label>
1572 <ul><li data-link-id="dub">Vidstream-2</li></ul>
1573 </div>
1574 "#;
1575 let sub = parse_servers(html, TranslationType::Sub).unwrap();
1576 assert_eq!(sub.len(), 2);
1577 assert!(sub[1].label.starts_with("H-SUB"));
1578 let dub = parse_servers(html, TranslationType::Dub).unwrap();
1579 assert_eq!(dub.len(), 1);
1580 assert!(dub[0].label.starts_with("DUB"));
1581 }
1582
1583 #[test]
1584 fn nested_sources_and_subtitles_are_normalized() {
1585 let (sources, subtitles) = parse_sources(&serde_json::json!({
1586 "sources":{"links":[{"file":"https://megap.kotocdn.site/master.m3u8","label":"1080p"}]},
1587 "tracks":[{"file":"https://megap.kotocdn.site/en.vtt","label":"English","kind":"captions","default":true}]
1588 }));
1589 assert_eq!(sources[0].1, "1080p");
1590 assert_eq!(subtitles[0].label, "English");
1591 assert!(subtitles[0].default);
1592 }
1593
1594 #[test]
1595 fn detects_separate_hls_audio_renditions() {
1596 assert!(AnikotoCzClient::has_separate_audio_rendition(
1597 "#EXTM3U\n#EXT-X-MEDIA:TYPE=\"AUDIO\",GROUP-ID=\"audio\",URI=\"audio.m3u8\"\n"
1598 ));
1599 assert!(!AnikotoCzClient::has_separate_audio_rendition(
1600 "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nvideo.m3u8\n"
1601 ));
1602 }
1603
1604 #[tokio::test]
1605 async fn live_anikoto_cz_smoke_test_is_opt_in() {
1606 if std::env::var("ANI_CLI_LIVE_ANIKOTO2").as_deref() != Ok("1") {
1607 return;
1608 }
1609 let client = AnikotoCzClient::new().unwrap();
1610 let results = client
1611 .search("black torch", TranslationType::Sub)
1612 .await
1613 .unwrap();
1614 let show = results
1615 .iter()
1616 .find(|result| result.name.eq_ignore_ascii_case("Black Torch"))
1617 .unwrap();
1618 let episodes = client
1619 .episodes(&show.id, TranslationType::Sub)
1620 .await
1621 .unwrap();
1622 assert!(episodes.iter().any(|episode| episode == "1"));
1623 let streams = client
1624 .streams(&show.id, "1", TranslationType::Sub)
1625 .await
1626 .unwrap();
1627 assert!(!streams.is_empty());
1628 assert!(streams.iter().any(|stream| stream.hls));
1629 }
1630}