Skip to main content

ani_lib/
anikoto.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 serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use url::Url;
13
14use crate::{
15    AniError, CatalogProvider, RequestHeaders, Result, SearchOptions, SearchResult, StreamLink,
16    SubtitleTrack, TranslationType,
17    models::{sort_episodes, sort_streams},
18};
19
20const DEFAULT_ANIKOTO_API: &str = "https://anikotoapi.site";
21const DEFAULT_ANILIST_API: &str = "https://graphql.anilist.co";
22const DEFAULT_MEGAPLAY_BASE: &str = "https://megaplay.buzz";
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;
26
27#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "camelCase")]
29struct AnikotoId {
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    anilist_id: Option<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    mal_id: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    anikoto_id: Option<String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    title: Option<String>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    episodes: Option<u32>,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
43struct AnikotoEpisode {
44    number: String,
45    embed_id: Option<String>,
46    sub_url: Option<String>,
47    dub_url: Option<String>,
48}
49
50#[derive(Clone, Debug)]
51struct Cached<T> {
52    expires_at: Instant,
53    value: T,
54}
55
56#[derive(Clone, Debug)]
57pub struct AnikotoClientBuilder {
58    anikoto_api: String,
59    anilist_api: String,
60    megaplay_base: String,
61    user_agent: String,
62    timeout: Duration,
63}
64
65impl Default for AnikotoClientBuilder {
66    fn default() -> Self {
67        Self {
68            anikoto_api: DEFAULT_ANIKOTO_API.into(),
69            anilist_api: DEFAULT_ANILIST_API.into(),
70            megaplay_base: DEFAULT_MEGAPLAY_BASE.into(),
71            user_agent: DEFAULT_AGENT.into(),
72            timeout: Duration::from_secs(12),
73        }
74    }
75}
76
77impl AnikotoClientBuilder {
78    pub fn anikoto_api(mut self, value: impl Into<String>) -> Self {
79        self.anikoto_api = value.into();
80        self
81    }
82
83    pub fn anilist_api(mut self, value: impl Into<String>) -> Self {
84        self.anilist_api = value.into();
85        self
86    }
87
88    pub fn megaplay_base(mut self, value: impl Into<String>) -> Self {
89        self.megaplay_base = value.into();
90        self
91    }
92
93    pub fn timeout(mut self, value: Duration) -> Self {
94        self.timeout = value;
95        self
96    }
97
98    pub fn build(self) -> Result<AnikotoClient> {
99        let http = Client::builder()
100            .timeout(self.timeout)
101            .user_agent(&self.user_agent)
102            .cookie_store(true)
103            .redirect(reqwest::redirect::Policy::limited(10))
104            .build()?;
105        Ok(AnikotoClient {
106            inner: Arc::new(Inner {
107                http,
108                anikoto_api: self.anikoto_api.trim_end_matches('/').into(),
109                anilist_api: self.anilist_api,
110                megaplay_base: self.megaplay_base.trim_end_matches('/').into(),
111                user_agent: self.user_agent,
112                searches: Mutex::new(HashMap::new()),
113                series: Mutex::new(HashMap::new()),
114            }),
115        })
116    }
117}
118
119struct Inner {
120    http: Client,
121    anikoto_api: String,
122    anilist_api: String,
123    megaplay_base: String,
124    user_agent: String,
125    searches: Mutex<HashMap<String, Cached<Vec<SearchResult>>>>,
126    series: Mutex<HashMap<String, Cached<Vec<AnikotoEpisode>>>>,
127}
128
129#[derive(Clone)]
130pub struct AnikotoClient {
131    inner: Arc<Inner>,
132}
133
134impl AnikotoClient {
135    pub fn builder() -> AnikotoClientBuilder {
136        AnikotoClientBuilder::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        let cache_key = format!("{}:{}", options.allow_adult, query.to_ascii_lowercase());
159        if let Some(value) = cache_get(&self.inner.searches, &cache_key) {
160            return Ok(value);
161        }
162
163        let recent = self.search_recent(query, options.allow_adult);
164        let anilist = self.search_anilist(query, options.allow_adult);
165        let (recent, anilist) = tokio::join!(recent, anilist);
166        let values = match (recent, anilist) {
167            (Ok(recent), Ok(anilist)) => merge_search_results(recent, anilist),
168            (Ok(recent), Err(_)) if !recent.is_empty() => recent,
169            (Err(_), Ok(anilist)) => anilist,
170            (Ok(recent), Err(error)) => {
171                if recent.is_empty() {
172                    return Err(error);
173                }
174                recent
175            }
176            (Err(error @ AniError::ProviderRateLimited { .. }), Err(_))
177            | (Err(_), Err(error @ AniError::ProviderRateLimited { .. })) => return Err(error),
178            (Err(first), Err(second)) => {
179                return Err(AniError::Catalog {
180                    provider: "Anikoto".into(),
181                    message: format!(
182                        "recent catalog failed ({first}); AniList search failed ({second})"
183                    ),
184                });
185            }
186        };
187        cache_put(&self.inner.searches, cache_key, values.clone());
188        Ok(values)
189    }
190
191    pub async fn episodes(&self, show_id: &str, _mode: TranslationType) -> Result<Vec<String>> {
192        let id = decode_id(show_id)?;
193        let series = match self.load_series(&id).await {
194            Ok(series) => series,
195            Err(AniError::Network(_)) if id.episodes.is_some() => Vec::new(),
196            Err(error) => return Err(error),
197        };
198        if !series.is_empty() {
199            return Ok(series.into_iter().map(|episode| episode.number).collect());
200        }
201        let count = id.episodes.unwrap_or(0);
202        if count == 0 {
203            return Err(AniError::UnavailableNoEpisodes);
204        }
205        Ok((1..=count).map(|episode| episode.to_string()).collect())
206    }
207
208    pub async fn streams(
209        &self,
210        show_id: &str,
211        episode: &str,
212        mode: TranslationType,
213    ) -> Result<Vec<StreamLink>> {
214        let id = decode_id(show_id)?;
215        let series = match self.load_series(&id).await {
216            Ok(series) => series,
217            Err(AniError::Network(_)) if id.anilist_id.is_some() || id.mal_id.is_some() => {
218                Vec::new()
219            }
220            Err(error) => return Err(error),
221        };
222        let selected = series.iter().find(|value| value.number == episode);
223        let candidates = embed_candidates(&self.inner.megaplay_base, &id, episode, mode, selected);
224        if candidates.is_empty() {
225            return Err(AniError::UnavailableNoEpisodes);
226        }
227
228        let mut failures = Vec::new();
229        for candidate in candidates {
230            match self.resolve_megaplay(&candidate).await {
231                Ok(mut streams) if !streams.is_empty() => {
232                    sort_streams(&mut streams);
233                    return Ok(streams);
234                }
235                Ok(_) => failures.push("MegaPlay returned no native streams".to_owned()),
236                Err(error @ AniError::ProviderRateLimited { .. }) => return Err(error),
237                Err(error) => failures.push(error.to_string()),
238            }
239        }
240        eprintln!(
241            "Anikoto native source resolution failures: {}",
242            failures.join("; ")
243        );
244        Err(AniError::UnavailableNoEpisodes)
245    }
246
247    async fn search_recent(&self, query: &str, allow_adult: bool) -> Result<Vec<SearchResult>> {
248        let url = format!("{}/recent-anime?page=1&per_page=40", self.inner.anikoto_api);
249        let value = self.get_json(&url, None, None).await?;
250        let needle = query.to_ascii_lowercase();
251        Ok(parse_search_payload(&value, allow_adult)
252            .into_iter()
253            .filter(|value| value.name.to_ascii_lowercase().contains(&needle))
254            .collect())
255    }
256
257    async fn search_anilist(&self, query: &str, allow_adult: bool) -> Result<Vec<SearchResult>> {
258        let graphql = r#"query ($search: String!) { Page(page: 1, perPage: 40) { media(type: ANIME, search: $search, sort: SEARCH_MATCH) { id idMal title { romaji english native } episodes isAdult } } }"#;
259        let response = self
260            .inner
261            .http
262            .post(&self.inner.anilist_api)
263            .header(header::REFERER, "https://anilist.co/")
264            .json(&json!({"query":graphql,"variables":{"search":query}}))
265            .send()
266            .await?;
267        let value = checked_json(response, "AniList").await?;
268        Ok(parse_search_payload(&value, allow_adult))
269    }
270
271    async fn load_series(&self, id: &AnikotoId) -> Result<Vec<AnikotoEpisode>> {
272        let Some(series_id) = id.anikoto_id.as_deref() else {
273            return Ok(vec![]);
274        };
275        if let Some(value) = cache_get(&self.inner.series, series_id) {
276            return Ok(value);
277        }
278        let url = format!("{}/series/{}", self.inner.anikoto_api, series_id);
279        let value = parse_episode_payload(&self.get_json(&url, None, None).await?);
280        cache_put(&self.inner.series, series_id.to_owned(), value.clone());
281        Ok(value)
282    }
283
284    async fn resolve_megaplay(&self, embed_url: &str) -> Result<Vec<StreamLink>> {
285        validate_remote_url(embed_url)?;
286        let html = self
287            .inner
288            .http
289            .get(embed_url)
290            .header(header::REFERER, format!("{}/", self.inner.megaplay_base))
291            .header(header::ACCEPT, "text/html,application/json,text/plain,*/*")
292            .send()
293            .await?;
294        let html = checked_text(html, "MegaPlay").await?;
295        let data_id = parse_data_id(&html).ok_or_else(|| {
296            AniError::Provider("MegaPlay did not expose a playable source id".into())
297        })?;
298        let source_url = format!(
299            "{}/stream/getSources?id={data_id}",
300            self.inner.megaplay_base
301        );
302        let payload = self
303            .get_json(
304                &source_url,
305                Some(embed_url),
306                Some(&self.inner.megaplay_base),
307            )
308            .await?;
309        let (sources, subtitles) = parse_megaplay_sources(&payload);
310        if sources.is_empty() {
311            return Err(AniError::Provider(
312                "MegaPlay did not return any supported native streams".into(),
313            ));
314        }
315
316        let mut streams = Vec::new();
317        for (url, resolution) in sources {
318            let parsed = validate_remote_url(&url)?;
319            let hls = parsed.path().to_ascii_lowercase().contains(".m3u8")
320                || parsed.query().is_some_and(|query| query.contains(".m3u8"));
321            let headers = media_headers(
322                parsed.host_str().unwrap_or_default(),
323                &self.inner.user_agent,
324            );
325            let mut expanded = if hls {
326                self.expand_hls(&url, &resolution, &headers).await?
327            } else {
328                vec![stream_link(url, resolution, false, headers)]
329            };
330            for stream in &mut expanded {
331                stream.subtitles = subtitles.clone();
332            }
333            streams.extend(expanded);
334        }
335        let mut seen = HashSet::new();
336        streams.retain(|stream| seen.insert(stream.url.clone()));
337        Ok(streams)
338    }
339
340    async fn expand_hls(
341        &self,
342        url: &str,
343        fallback_resolution: &str,
344        headers: &RequestHeaders,
345    ) -> Result<Vec<StreamLink>> {
346        let mut request = self.inner.http.get(url);
347        request = apply_headers(request, headers);
348        let response = request.send().await?;
349        if !response.status().is_success() {
350            return Ok(vec![stream_link(
351                url.into(),
352                fallback_resolution.into(),
353                true,
354                headers.clone(),
355            )]);
356        }
357        let text = response.text().await?;
358        if !text.trim_start().starts_with("#EXTM3U") {
359            return Ok(vec![stream_link(
360                url.into(),
361                fallback_resolution.into(),
362                true,
363                headers.clone(),
364            )]);
365        }
366        let base = Url::parse(url)?;
367        let resolution = Regex::new(r"RESOLUTION=\d+x(\d+)").expect("static regex");
368        let mut streams = Vec::new();
369        let mut lines = text.lines();
370        while let Some(line) = lines.next() {
371            if !line.starts_with("#EXT-X-STREAM-INF:") {
372                continue;
373            }
374            let label = resolution
375                .captures(line)
376                .map(|captures| format!("{}p", &captures[1]))
377                .unwrap_or_else(|| fallback_resolution.into());
378            if let Some(path) = lines.by_ref().find(|line| {
379                let line = line.trim();
380                !line.is_empty() && !line.starts_with('#')
381            }) {
382                streams.push(stream_link(
383                    base.join(path.trim())?.to_string(),
384                    label,
385                    true,
386                    headers.clone(),
387                ));
388            }
389        }
390        if streams.is_empty() {
391            streams.push(stream_link(
392                url.into(),
393                fallback_resolution.into(),
394                true,
395                headers.clone(),
396            ));
397        }
398        Ok(streams)
399    }
400
401    async fn get_json(
402        &self,
403        url: &str,
404        referer: Option<&str>,
405        origin: Option<&str>,
406    ) -> Result<Value> {
407        let mut request = self
408            .inner
409            .http
410            .get(url)
411            .header(header::ACCEPT, "application/json,text/plain,*/*");
412        if let Some(referer) = referer {
413            request = request.header(header::REFERER, referer);
414        }
415        if let Some(origin) = origin {
416            request = request.header(header::ORIGIN, origin);
417        }
418        let provider = if url.starts_with(&self.inner.megaplay_base) {
419            "MegaPlay"
420        } else {
421            "Anikoto"
422        };
423        checked_json(request.send().await?, provider).await
424    }
425}
426
427pub fn provider_from_show_id(show_id: &str) -> CatalogProvider {
428    if show_id.starts_with("anikoto2:") {
429        CatalogProvider::Anikoto2
430    } else {
431        CatalogProvider::Anikoto
432    }
433}
434
435fn encode_id(id: &AnikotoId) -> Result<String> {
436    Ok(format!(
437        "anikoto:{}",
438        URL_SAFE_NO_PAD.encode(serde_json::to_vec(id)?)
439    ))
440}
441
442fn decode_id(value: &str) -> Result<AnikotoId> {
443    if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) {
444        return Ok(AnikotoId {
445            anikoto_id: Some(value.into()),
446            ..Default::default()
447        });
448    }
449    let payload = value
450        .strip_prefix("anikoto:")
451        .ok_or_else(|| AniError::Input("invalid Anikoto show ID".into()))?;
452    let bytes = URL_SAFE_NO_PAD
453        .decode(payload)
454        .map_err(|_| AniError::Input("invalid Anikoto show ID encoding".into()))?;
455    serde_json::from_slice(&bytes)
456        .map_err(|_| AniError::Input("invalid Anikoto show metadata".into()))
457}
458
459fn parse_search_payload(value: &Value, allow_adult: bool) -> Vec<SearchResult> {
460    let values = value
461        .pointer("/data/Page/media")
462        .or_else(|| value.get("data"))
463        .and_then(Value::as_array)
464        .or_else(|| value.as_array())
465        .cloned()
466        .unwrap_or_default();
467    values
468        .iter()
469        .filter_map(|item| {
470            if !allow_adult
471                && item
472                    .get("isAdult")
473                    .or_else(|| item.get("is_adult"))
474                    .and_then(Value::as_bool)
475                    .unwrap_or(false)
476            {
477                return None;
478            }
479            let anilist_id = string_value(item.get("ani_id").or_else(|| item.get("id")))?;
480            let title = item
481                .get("title")
482                .and_then(|title| {
483                    if let Some(title) = title.as_str() {
484                        return Some(title.to_owned());
485                    }
486                    ["english", "romaji", "native"]
487                        .iter()
488                        .find_map(|key| string_value(title.get(*key)))
489                })
490                .or_else(|| string_value(item.get("name")))
491                .unwrap_or_else(|| format!("AniList {anilist_id}"));
492            let episodes = number_value(item.get("episodes")).unwrap_or(0.0);
493            let anikoto_id = item.get("ani_id").and_then(|ani_id| {
494                let item_id = string_value(item.get("id"));
495                let ani_id = string_value(Some(ani_id));
496                (item_id != ani_id).then_some(item_id).flatten()
497            });
498            let id = encode_id(&AnikotoId {
499                anilist_id: Some(anilist_id),
500                mal_id: string_value(item.get("idMal").or_else(|| item.get("mal_id"))),
501                anikoto_id,
502                title: Some(title.clone()),
503                episodes: (episodes.is_finite() && episodes > 0.0).then_some(episodes as u32),
504            })
505            .ok()?;
506            Some(SearchResult {
507                id,
508                name: title,
509                episodes,
510                provider: CatalogProvider::Anikoto,
511            })
512        })
513        .collect()
514}
515
516fn merge_search_results(
517    recent: Vec<SearchResult>,
518    anilist: Vec<SearchResult>,
519) -> Vec<SearchResult> {
520    let mut seen = HashSet::new();
521    recent
522        .into_iter()
523        .chain(anilist)
524        .filter(|item| {
525            let id = decode_id(&item.id).ok();
526            let key = id
527                .and_then(|id| id.anilist_id)
528                .map(|id| format!("ani:{id}"))
529                .unwrap_or_else(|| format!("title:{}", normalize_title(&item.name)));
530            seen.insert(key)
531        })
532        .collect()
533}
534
535fn parse_episode_payload(value: &Value) -> Vec<AnikotoEpisode> {
536    let data = value.get("data").unwrap_or(value);
537    let mut episodes = data
538        .get("episodes")
539        .and_then(Value::as_array)
540        .into_iter()
541        .flatten()
542        .filter_map(|item| {
543            let number = string_value(
544                item.get("number")
545                    .or_else(|| item.get("episode"))
546                    .or_else(|| item.get("episode_number")),
547            )?;
548            Some(AnikotoEpisode {
549                number,
550                embed_id: string_value(item.get("episode_embed_id")),
551                sub_url: item.pointer("/embed_url/sub").and_then(value_string),
552                dub_url: item.pointer("/embed_url/dub").and_then(value_string),
553            })
554        })
555        .collect::<Vec<_>>();
556    let mut numbers = episodes
557        .iter()
558        .map(|value| value.number.clone())
559        .collect::<Vec<_>>();
560    sort_episodes(&mut numbers);
561    let positions = numbers
562        .into_iter()
563        .enumerate()
564        .map(|(index, number)| (number, index))
565        .collect::<HashMap<_, _>>();
566    episodes.sort_by_key(|episode| {
567        positions
568            .get(&episode.number)
569            .copied()
570            .unwrap_or(usize::MAX)
571    });
572    episodes
573}
574
575fn embed_candidates(
576    base: &str,
577    id: &AnikotoId,
578    episode: &str,
579    mode: TranslationType,
580    selected: Option<&AnikotoEpisode>,
581) -> Vec<String> {
582    let language = mode.to_string();
583    let mut candidates = Vec::new();
584    if let Some(selected) = selected {
585        let explicit = match mode {
586            TranslationType::Sub => selected.sub_url.as_ref(),
587            TranslationType::Dub => selected.dub_url.as_ref(),
588        };
589        if let Some(url) = explicit {
590            candidates.push(url.clone());
591        }
592        if let Some(embed_id) = &selected.embed_id {
593            candidates.push(format!("{base}/stream/s-2/{embed_id}/{language}"));
594        }
595    }
596    if let Some(anilist_id) = &id.anilist_id {
597        candidates.push(format!(
598            "{base}/stream/ani/{anilist_id}/{episode}/{language}"
599        ));
600    }
601    if let Some(mal_id) = &id.mal_id {
602        candidates.push(format!("{base}/stream/mal/{mal_id}/{episode}/{language}"));
603    }
604    let mut seen = HashSet::new();
605    candidates.retain(|candidate| seen.insert(candidate.clone()));
606    candidates
607}
608
609fn parse_data_id(html: &str) -> Option<String> {
610    Regex::new(r#"(?i)\bdata-id=["'](\d+)["']"#)
611        .expect("static regex")
612        .captures(html)
613        .map(|captures| captures[1].to_owned())
614}
615
616fn parse_megaplay_sources(value: &Value) -> (Vec<(String, String)>, Vec<SubtitleTrack>) {
617    fn collect_sources(value: &Value, values: &mut Vec<(String, String)>) {
618        match value {
619            Value::String(url) => values.push((url.clone(), "Auto".into())),
620            Value::Array(items) => items.iter().for_each(|item| collect_sources(item, values)),
621            Value::Object(object) => {
622                if let Some(url) = object
623                    .get("file")
624                    .or_else(|| object.get("url"))
625                    .or_else(|| object.get("src"))
626                    .and_then(Value::as_str)
627                {
628                    let label = object
629                        .get("label")
630                        .or_else(|| object.get("quality"))
631                        .and_then(Value::as_str)
632                        .unwrap_or("Auto");
633                    values.push((url.into(), label.into()));
634                }
635                for key in ["sources", "source", "links"] {
636                    if let Some(child) = object.get(key) {
637                        collect_sources(child, values);
638                    }
639                }
640            }
641            _ => {}
642        }
643    }
644
645    fn collect_tracks(value: &Value, tracks: &mut Vec<SubtitleTrack>) {
646        match value {
647            Value::Array(items) => items.iter().for_each(|item| collect_tracks(item, tracks)),
648            Value::Object(object) => {
649                let kind = object
650                    .get("kind")
651                    .or_else(|| object.get("type"))
652                    .and_then(Value::as_str)
653                    .unwrap_or("")
654                    .to_ascii_lowercase();
655                if !kind.is_empty()
656                    && !kind.contains("caption")
657                    && !kind.contains("subtitle")
658                    && !kind.contains("sub")
659                {
660                    return;
661                }
662                if let Some(url) = object
663                    .get("file")
664                    .or_else(|| object.get("url"))
665                    .or_else(|| object.get("src"))
666                    .and_then(Value::as_str)
667                {
668                    tracks.push(SubtitleTrack {
669                        label: object
670                            .get("label")
671                            .or_else(|| object.get("title"))
672                            .and_then(Value::as_str)
673                            .unwrap_or("Subtitle")
674                            .into(),
675                        url: url.into(),
676                        default: object
677                            .get("default")
678                            .and_then(Value::as_bool)
679                            .unwrap_or(false),
680                    });
681                }
682            }
683            _ => {}
684        }
685    }
686
687    let mut sources = Vec::new();
688    let mut tracks = Vec::new();
689    if let Some(value) = value.get("sources") {
690        collect_sources(value, &mut sources);
691    }
692    if let Some(value) = value.get("source") {
693        collect_sources(value, &mut sources);
694    }
695    for key in ["tracks", "captions", "subtitles"] {
696        if let Some(value) = value.get(key) {
697            collect_tracks(value, &mut tracks);
698        }
699    }
700    let mut seen = HashSet::new();
701    sources.retain(|(url, _)| seen.insert(url.clone()));
702    let mut seen = HashSet::new();
703    tracks.retain(|track| seen.insert(track.url.clone()));
704    (sources, tracks)
705}
706
707fn media_headers(host: &str, user_agent: &str) -> RequestHeaders {
708    if is_megaplay_media_host(host) {
709        RequestHeaders {
710            referer: Some("https://megaplay.buzz/".into()),
711            origin: Some("https://megaplay.buzz".into()),
712            extra: [("User-Agent".into(), user_agent.into())].into(),
713        }
714    } else {
715        RequestHeaders::default()
716    }
717}
718
719pub(crate) fn is_megaplay_media_host(host: &str) -> bool {
720    let host = host.trim_end_matches('.').to_ascii_lowercase();
721    [
722        "megaplay.buzz",
723        "mewstream.buzz",
724        "lostproject.club",
725        "voltara.click",
726        "kotocdn.site",
727        "megap.shiora.top",
728        "shiora.top",
729        "megap.kotocdn.site",
730        "megap.akirax.buzz",
731        "akirax.buzz",
732    ]
733    .iter()
734    .any(|domain| host == *domain || host.ends_with(&format!(".{domain}")))
735}
736
737pub fn requires_hls_relay(stream: &StreamLink) -> bool {
738    stream.hls
739        && Url::parse(&stream.url)
740            .ok()
741            .and_then(|url| url.host_str().map(str::to_owned))
742            .is_some_and(|host| is_megaplay_media_host(&host))
743}
744
745fn stream_link(url: String, resolution: String, hls: bool, headers: RequestHeaders) -> StreamLink {
746    StreamLink {
747        url,
748        resolution,
749        hls,
750        provider: "MegaPlay".into(),
751        downloadable: true,
752        headers,
753        subtitles: vec![],
754    }
755}
756
757fn apply_headers(
758    mut request: reqwest::RequestBuilder,
759    headers: &RequestHeaders,
760) -> reqwest::RequestBuilder {
761    if let Some(referer) = &headers.referer {
762        request = request.header(header::REFERER, referer);
763    }
764    if let Some(origin) = &headers.origin {
765        request = request.header(header::ORIGIN, origin);
766    }
767    for (name, value) in &headers.extra {
768        request = request.header(name, value);
769    }
770    request
771}
772
773async fn checked_json(response: Response, provider: &str) -> Result<Value> {
774    let status = response.status();
775    if status == StatusCode::TOO_MANY_REQUESTS {
776        let retry_after_seconds = response
777            .headers()
778            .get(header::RETRY_AFTER)
779            .and_then(|value| value.to_str().ok())
780            .and_then(|value| value.parse().ok())
781            .unwrap_or(120);
782        return Err(AniError::ProviderRateLimited {
783            provider: provider.into(),
784            retry_after_seconds,
785        });
786    }
787    if !status.is_success() {
788        return Err(AniError::Catalog {
789            provider: provider.into(),
790            message: format!("HTTP {status}"),
791        });
792    }
793    response.json().await.map_err(Into::into)
794}
795
796async fn checked_text(response: Response, provider: &str) -> Result<String> {
797    let status = response.status();
798    if status == StatusCode::TOO_MANY_REQUESTS {
799        let retry_after_seconds = response
800            .headers()
801            .get(header::RETRY_AFTER)
802            .and_then(|value| value.to_str().ok())
803            .and_then(|value| value.parse().ok())
804            .unwrap_or(120);
805        return Err(AniError::ProviderRateLimited {
806            provider: provider.into(),
807            retry_after_seconds,
808        });
809    }
810    if !status.is_success() {
811        return Err(AniError::Catalog {
812            provider: provider.into(),
813            message: format!("HTTP {status}"),
814        });
815    }
816    response.text().await.map_err(Into::into)
817}
818
819fn validate_remote_url(value: &str) -> Result<Url> {
820    let url = Url::parse(value)?;
821    if !url.username().is_empty() || url.password().is_some() {
822        return Err(AniError::Provider("media URL contains credentials".into()));
823    }
824    let loopback = url.host_str().is_some_and(|host| {
825        host == "localhost"
826            || host
827                .parse::<std::net::IpAddr>()
828                .is_ok_and(|ip| ip.is_loopback())
829    });
830    if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
831        return Err(AniError::Provider("media URL must use HTTPS".into()));
832    }
833    Ok(url)
834}
835
836fn cache_get<T: Clone>(cache: &Mutex<HashMap<String, Cached<T>>>, key: &str) -> Option<T> {
837    let mut cache = cache.lock().ok()?;
838    cache.retain(|_, value| value.expires_at > Instant::now());
839    cache.get(key).map(|value| value.value.clone())
840}
841
842fn cache_put<T>(cache: &Mutex<HashMap<String, Cached<T>>>, key: String, value: T) {
843    if let Ok(mut cache) = cache.lock() {
844        if cache.len() >= CACHE_LIMIT
845            && let Some(key) = cache.keys().next().cloned()
846        {
847            cache.remove(&key);
848        }
849        cache.insert(
850            key,
851            Cached {
852                expires_at: Instant::now() + CACHE_TTL,
853                value,
854            },
855        );
856    }
857}
858
859fn value_string(value: &Value) -> Option<String> {
860    value
861        .as_str()
862        .map(str::trim)
863        .filter(|value| !value.is_empty())
864        .map(str::to_owned)
865}
866
867fn string_value(value: Option<&Value>) -> Option<String> {
868    match value? {
869        Value::String(value) => Some(value.trim().to_owned()).filter(|value| !value.is_empty()),
870        Value::Number(value) => Some(value.to_string()),
871        _ => None,
872    }
873}
874
875fn number_value(value: Option<&Value>) -> Option<f64> {
876    match value? {
877        Value::Number(value) => value.as_f64(),
878        Value::String(value) => value.parse().ok(),
879        _ => None,
880    }
881}
882
883fn normalize_title(value: &str) -> String {
884    value
885        .chars()
886        .filter(|character| character.is_alphanumeric())
887        .flat_map(char::to_lowercase)
888        .collect()
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894    use wiremock::{
895        Mock, MockServer, ResponseTemplate,
896        matchers::{method, path},
897    };
898
899    #[test]
900    fn ids_round_trip_and_detect_provider() {
901        let value = AnikotoId {
902            anilist_id: Some("123".into()),
903            mal_id: Some("456".into()),
904            anikoto_id: Some("789".into()),
905            title: Some("Example".into()),
906            episodes: Some(12),
907        };
908        let encoded = encode_id(&value).unwrap();
909        assert_eq!(decode_id(&encoded).unwrap(), value);
910        assert_eq!(provider_from_show_id(&encoded), CatalogProvider::Anikoto);
911        assert_eq!(
912            provider_from_show_id("anikoto2:metadata"),
913            CatalogProvider::Anikoto2
914        );
915        assert_eq!(provider_from_show_id("legacy"), CatalogProvider::Anikoto);
916    }
917
918    #[test]
919    fn parses_series_and_candidate_order() {
920        let episodes = parse_episode_payload(&json!({"data":{"episodes":[
921            {"number":"2","episode_embed_id":"22"},
922            {"number":1,"episode_embed_id":"11","embed_url":{"sub":"https://megaplay.buzz/explicit"}}
923        ]}}));
924        assert_eq!(episodes[0].number, "1");
925        let id = AnikotoId {
926            anilist_id: Some("1".into()),
927            mal_id: Some("2".into()),
928            ..Default::default()
929        };
930        let candidates = embed_candidates(
931            "https://megaplay.buzz",
932            &id,
933            "1",
934            TranslationType::Sub,
935            Some(&episodes[0]),
936        );
937        assert_eq!(candidates[0], "https://megaplay.buzz/explicit");
938        assert!(candidates[1].contains("/stream/s-2/11/sub"));
939        assert!(candidates[2].contains("/stream/ani/1/1/sub"));
940        assert!(candidates[3].contains("/stream/mal/2/1/sub"));
941    }
942
943    #[test]
944    fn parses_nested_sources_and_subtitles() {
945        let (sources, subtitles) = parse_megaplay_sources(&json!({
946            "sources":{"links":[{"file":"https://megap.kotocdn.site/master.m3u8","label":"1080p"}]},
947            "tracks":[{"file":"https://megap.kotocdn.site/en.vtt","label":"English","kind":"captions","default":true}]
948        }));
949        assert_eq!(sources[0].1, "1080p");
950        assert_eq!(subtitles[0].label, "English");
951        assert!(subtitles[0].default);
952    }
953
954    #[test]
955    fn host_allowlist_rejects_lookalikes() {
956        assert!(is_megaplay_media_host("megap.kotocdn.site"));
957        assert!(!is_megaplay_media_host("kotocdn.site.example.com"));
958        assert!(!is_megaplay_media_host("evilmegaplay.buzz"));
959    }
960
961    #[test]
962    fn adult_search_results_are_filtered() {
963        let payload = json!({"data":{"Page":{"media":[
964            {"id":1,"title":{"english":"Safe"},"episodes":12,"isAdult":false},
965            {"id":2,"title":{"english":"Adult"},"episodes":1,"isAdult":true}
966        ]}}});
967        assert_eq!(parse_search_payload(&payload, false).len(), 1);
968        assert_eq!(parse_search_payload(&payload, true).len(), 2);
969    }
970
971    #[tokio::test]
972    async fn search_tolerates_anilist_failure_when_catalog_succeeds() {
973        let server = MockServer::start().await;
974        Mock::given(method("GET"))
975            .and(path("/recent-anime"))
976            .respond_with(ResponseTemplate::new(200).set_body_json(
977                json!({"data":[{"id":9,"ani_id":1,"title":"Example","episodes":2}]}),
978            ))
979            .mount(&server)
980            .await;
981        Mock::given(method("POST"))
982            .and(path("/graphql"))
983            .respond_with(ResponseTemplate::new(500))
984            .mount(&server)
985            .await;
986        let client = AnikotoClient::builder()
987            .anikoto_api(server.uri())
988            .anilist_api(format!("{}/graphql", server.uri()))
989            .build()
990            .unwrap();
991        let values = client
992            .search("example", TranslationType::Sub)
993            .await
994            .unwrap();
995        assert_eq!(values.len(), 1);
996        assert_eq!(values[0].provider, CatalogProvider::Anikoto);
997        assert_eq!(
998            decode_id(&values[0].id).unwrap().anikoto_id.as_deref(),
999            Some("9")
1000        );
1001    }
1002
1003    #[tokio::test]
1004    async fn megaplay_candidates_fall_back_to_the_mal_route() {
1005        let server = MockServer::start().await;
1006        Mock::given(method("GET"))
1007            .and(path("/stream/ani/1/1/sub"))
1008            .respond_with(ResponseTemplate::new(404))
1009            .expect(1)
1010            .mount(&server)
1011            .await;
1012        Mock::given(method("GET"))
1013            .and(path("/stream/mal/2/1/sub"))
1014            .respond_with(ResponseTemplate::new(200).set_body_string("<div data-id=\"99\"></div>"))
1015            .expect(1)
1016            .mount(&server)
1017            .await;
1018        Mock::given(method("GET"))
1019            .and(path("/stream/getSources"))
1020            .respond_with(
1021                ResponseTemplate::new(200)
1022                    .set_body_json(json!({"sources":{"file":"https://voltara.click/video.mp4"}})),
1023            )
1024            .expect(1)
1025            .mount(&server)
1026            .await;
1027        let client = AnikotoClient::builder()
1028            .megaplay_base(server.uri())
1029            .build()
1030            .unwrap();
1031        let id = encode_id(&AnikotoId {
1032            anilist_id: Some("1".into()),
1033            mal_id: Some("2".into()),
1034            episodes: Some(1),
1035            ..Default::default()
1036        })
1037        .unwrap();
1038        let streams = client
1039            .streams(&id, "1", TranslationType::Sub)
1040            .await
1041            .unwrap();
1042        assert_eq!(streams[0].url, "https://voltara.click/video.mp4");
1043    }
1044
1045    #[tokio::test]
1046    async fn live_anikoto_smoke_test_is_opt_in() {
1047        if std::env::var("ANI_CLI_LIVE_ANIKOTO").as_deref() != Ok("1") {
1048            return;
1049        }
1050        let results = AnikotoClient::new()
1051            .unwrap()
1052            .search("Frieren", TranslationType::Sub)
1053            .await
1054            .unwrap();
1055        assert!(!results.is_empty());
1056    }
1057}