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