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