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 ]
728 .iter()
729 .any(|domain| host == *domain || host.ends_with(&format!(".{domain}")))
730}
731
732pub fn requires_hls_relay(stream: &StreamLink) -> bool {
733 stream.hls
734 && Url::parse(&stream.url)
735 .ok()
736 .and_then(|url| url.host_str().map(str::to_owned))
737 .is_some_and(|host| is_megaplay_media_host(&host))
738}
739
740fn stream_link(url: String, resolution: String, hls: bool, headers: RequestHeaders) -> StreamLink {
741 StreamLink {
742 url,
743 resolution,
744 hls,
745 provider: "MegaPlay".into(),
746 downloadable: true,
747 headers,
748 subtitles: vec![],
749 }
750}
751
752fn apply_headers(
753 mut request: reqwest::RequestBuilder,
754 headers: &RequestHeaders,
755) -> reqwest::RequestBuilder {
756 if let Some(referer) = &headers.referer {
757 request = request.header(header::REFERER, referer);
758 }
759 if let Some(origin) = &headers.origin {
760 request = request.header(header::ORIGIN, origin);
761 }
762 for (name, value) in &headers.extra {
763 request = request.header(name, value);
764 }
765 request
766}
767
768async fn checked_json(response: Response, provider: &str) -> Result<Value> {
769 let status = response.status();
770 if status == StatusCode::TOO_MANY_REQUESTS {
771 let retry_after_seconds = response
772 .headers()
773 .get(header::RETRY_AFTER)
774 .and_then(|value| value.to_str().ok())
775 .and_then(|value| value.parse().ok())
776 .unwrap_or(120);
777 return Err(AniError::ProviderRateLimited {
778 provider: provider.into(),
779 retry_after_seconds,
780 });
781 }
782 if !status.is_success() {
783 return Err(AniError::Catalog {
784 provider: provider.into(),
785 message: format!("HTTP {status}"),
786 });
787 }
788 response.json().await.map_err(Into::into)
789}
790
791async fn checked_text(response: Response, provider: &str) -> Result<String> {
792 let status = response.status();
793 if status == StatusCode::TOO_MANY_REQUESTS {
794 let retry_after_seconds = response
795 .headers()
796 .get(header::RETRY_AFTER)
797 .and_then(|value| value.to_str().ok())
798 .and_then(|value| value.parse().ok())
799 .unwrap_or(120);
800 return Err(AniError::ProviderRateLimited {
801 provider: provider.into(),
802 retry_after_seconds,
803 });
804 }
805 if !status.is_success() {
806 return Err(AniError::Catalog {
807 provider: provider.into(),
808 message: format!("HTTP {status}"),
809 });
810 }
811 response.text().await.map_err(Into::into)
812}
813
814fn validate_remote_url(value: &str) -> Result<Url> {
815 let url = Url::parse(value)?;
816 if !url.username().is_empty() || url.password().is_some() {
817 return Err(AniError::Provider("media URL contains credentials".into()));
818 }
819 let loopback = url.host_str().is_some_and(|host| {
820 host == "localhost"
821 || host
822 .parse::<std::net::IpAddr>()
823 .is_ok_and(|ip| ip.is_loopback())
824 });
825 if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
826 return Err(AniError::Provider("media URL must use HTTPS".into()));
827 }
828 Ok(url)
829}
830
831fn cache_get<T: Clone>(cache: &Mutex<HashMap<String, Cached<T>>>, key: &str) -> Option<T> {
832 let mut cache = cache.lock().ok()?;
833 cache.retain(|_, value| value.expires_at > Instant::now());
834 cache.get(key).map(|value| value.value.clone())
835}
836
837fn cache_put<T>(cache: &Mutex<HashMap<String, Cached<T>>>, key: String, value: T) {
838 if let Ok(mut cache) = cache.lock() {
839 if cache.len() >= CACHE_LIMIT
840 && let Some(key) = cache.keys().next().cloned()
841 {
842 cache.remove(&key);
843 }
844 cache.insert(
845 key,
846 Cached {
847 expires_at: Instant::now() + CACHE_TTL,
848 value,
849 },
850 );
851 }
852}
853
854fn value_string(value: &Value) -> Option<String> {
855 value
856 .as_str()
857 .map(str::trim)
858 .filter(|value| !value.is_empty())
859 .map(str::to_owned)
860}
861
862fn string_value(value: Option<&Value>) -> Option<String> {
863 match value? {
864 Value::String(value) => Some(value.trim().to_owned()).filter(|value| !value.is_empty()),
865 Value::Number(value) => Some(value.to_string()),
866 _ => None,
867 }
868}
869
870fn number_value(value: Option<&Value>) -> Option<f64> {
871 match value? {
872 Value::Number(value) => value.as_f64(),
873 Value::String(value) => value.parse().ok(),
874 _ => None,
875 }
876}
877
878fn normalize_title(value: &str) -> String {
879 value
880 .chars()
881 .filter(|character| character.is_alphanumeric())
882 .flat_map(char::to_lowercase)
883 .collect()
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
889 use wiremock::{
890 Mock, MockServer, ResponseTemplate,
891 matchers::{method, path},
892 };
893
894 #[test]
895 fn ids_round_trip_and_detect_provider() {
896 let value = AnikotoId {
897 anilist_id: Some("123".into()),
898 mal_id: Some("456".into()),
899 anikoto_id: Some("789".into()),
900 title: Some("Example".into()),
901 episodes: Some(12),
902 };
903 let encoded = encode_id(&value).unwrap();
904 assert_eq!(decode_id(&encoded).unwrap(), value);
905 assert_eq!(provider_from_show_id(&encoded), CatalogProvider::Anikoto);
906 assert_eq!(
907 provider_from_show_id("anikoto2:metadata"),
908 CatalogProvider::Anikoto2
909 );
910 assert_eq!(provider_from_show_id("legacy"), CatalogProvider::Anikoto);
911 }
912
913 #[test]
914 fn parses_series_and_candidate_order() {
915 let episodes = parse_episode_payload(&json!({"data":{"episodes":[
916 {"number":"2","episode_embed_id":"22"},
917 {"number":1,"episode_embed_id":"11","embed_url":{"sub":"https://megaplay.buzz/explicit"}}
918 ]}}));
919 assert_eq!(episodes[0].number, "1");
920 let id = AnikotoId {
921 anilist_id: Some("1".into()),
922 mal_id: Some("2".into()),
923 ..Default::default()
924 };
925 let candidates = embed_candidates(
926 "https://megaplay.buzz",
927 &id,
928 "1",
929 TranslationType::Sub,
930 Some(&episodes[0]),
931 );
932 assert_eq!(candidates[0], "https://megaplay.buzz/explicit");
933 assert!(candidates[1].contains("/stream/s-2/11/sub"));
934 assert!(candidates[2].contains("/stream/ani/1/1/sub"));
935 assert!(candidates[3].contains("/stream/mal/2/1/sub"));
936 }
937
938 #[test]
939 fn parses_nested_sources_and_subtitles() {
940 let (sources, subtitles) = parse_megaplay_sources(&json!({
941 "sources":{"links":[{"file":"https://megap.kotocdn.site/master.m3u8","label":"1080p"}]},
942 "tracks":[{"file":"https://megap.kotocdn.site/en.vtt","label":"English","kind":"captions","default":true}]
943 }));
944 assert_eq!(sources[0].1, "1080p");
945 assert_eq!(subtitles[0].label, "English");
946 assert!(subtitles[0].default);
947 }
948
949 #[test]
950 fn host_allowlist_rejects_lookalikes() {
951 assert!(is_megaplay_media_host("megap.kotocdn.site"));
952 assert!(!is_megaplay_media_host("kotocdn.site.example.com"));
953 assert!(!is_megaplay_media_host("evilmegaplay.buzz"));
954 }
955
956 #[test]
957 fn adult_search_results_are_filtered() {
958 let payload = json!({"data":{"Page":{"media":[
959 {"id":1,"title":{"english":"Safe"},"episodes":12,"isAdult":false},
960 {"id":2,"title":{"english":"Adult"},"episodes":1,"isAdult":true}
961 ]}}});
962 assert_eq!(parse_search_payload(&payload, false).len(), 1);
963 assert_eq!(parse_search_payload(&payload, true).len(), 2);
964 }
965
966 #[tokio::test]
967 async fn search_tolerates_anilist_failure_when_catalog_succeeds() {
968 let server = MockServer::start().await;
969 Mock::given(method("GET"))
970 .and(path("/recent-anime"))
971 .respond_with(ResponseTemplate::new(200).set_body_json(
972 json!({"data":[{"id":9,"ani_id":1,"title":"Example","episodes":2}]}),
973 ))
974 .mount(&server)
975 .await;
976 Mock::given(method("POST"))
977 .and(path("/graphql"))
978 .respond_with(ResponseTemplate::new(500))
979 .mount(&server)
980 .await;
981 let client = AnikotoClient::builder()
982 .anikoto_api(server.uri())
983 .anilist_api(format!("{}/graphql", server.uri()))
984 .build()
985 .unwrap();
986 let values = client
987 .search("example", TranslationType::Sub)
988 .await
989 .unwrap();
990 assert_eq!(values.len(), 1);
991 assert_eq!(values[0].provider, CatalogProvider::Anikoto);
992 assert_eq!(
993 decode_id(&values[0].id).unwrap().anikoto_id.as_deref(),
994 Some("9")
995 );
996 }
997
998 #[tokio::test]
999 async fn megaplay_candidates_fall_back_to_the_mal_route() {
1000 let server = MockServer::start().await;
1001 Mock::given(method("GET"))
1002 .and(path("/stream/ani/1/1/sub"))
1003 .respond_with(ResponseTemplate::new(404))
1004 .expect(1)
1005 .mount(&server)
1006 .await;
1007 Mock::given(method("GET"))
1008 .and(path("/stream/mal/2/1/sub"))
1009 .respond_with(ResponseTemplate::new(200).set_body_string("<div data-id=\"99\"></div>"))
1010 .expect(1)
1011 .mount(&server)
1012 .await;
1013 Mock::given(method("GET"))
1014 .and(path("/stream/getSources"))
1015 .respond_with(
1016 ResponseTemplate::new(200)
1017 .set_body_json(json!({"sources":{"file":"https://voltara.click/video.mp4"}})),
1018 )
1019 .expect(1)
1020 .mount(&server)
1021 .await;
1022 let client = AnikotoClient::builder()
1023 .megaplay_base(server.uri())
1024 .build()
1025 .unwrap();
1026 let id = encode_id(&AnikotoId {
1027 anilist_id: Some("1".into()),
1028 mal_id: Some("2".into()),
1029 episodes: Some(1),
1030 ..Default::default()
1031 })
1032 .unwrap();
1033 let streams = client
1034 .streams(&id, "1", TranslationType::Sub)
1035 .await
1036 .unwrap();
1037 assert_eq!(streams[0].url, "https://voltara.click/video.mp4");
1038 }
1039
1040 #[tokio::test]
1041 async fn live_anikoto_smoke_test_is_opt_in() {
1042 if std::env::var("ANI_CLI_LIVE_ANIKOTO").as_deref() != Ok("1") {
1043 return;
1044 }
1045 let results = AnikotoClient::new()
1046 .unwrap()
1047 .search("Frieren", TranslationType::Sub)
1048 .await
1049 .unwrap();
1050 assert!(!results.is_empty());
1051 }
1052}