Skip to main content

ani_lib/
anikoto_cz.rs

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        // VidTube shares episode IDs across languages and defaults to sub.
477        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        // a media playlist contains only its video segments when audio is a
623        // separate rendition. Keep the master so the player can follow its
624        // EXT-X-MEDIA audio URI through the relay
625        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}