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