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 let base = Url::parse(url)?;
378 let resolution = Regex::new(r"RESOLUTION=\d+x(\d+)").expect("static regex");
379 let mut streams = Vec::new();
380 let mut lines = text.lines();
381 while let Some(line) = lines.next() {
382 if !line.starts_with("#EXT-X-STREAM-INF:") {
383 continue;
384 }
385 let label = resolution
386 .captures(line)
387 .map(|captures| format!("{}p", &captures[1]))
388 .unwrap_or_else(|| fallback_resolution.into());
389 if let Some(path) = lines.by_ref().find(|line| {
390 let line = line.trim();
391 !line.is_empty() && !line.starts_with('#')
392 }) {
393 streams.push(stream_link(
394 base.join(path.trim())?.to_string(),
395 label,
396 true,
397 headers.clone(),
398 ));
399 }
400 }
401 if streams.is_empty() {
402 streams.push(stream_link(
403 url.into(),
404 fallback_resolution.into(),
405 true,
406 headers.clone(),
407 ));
408 }
409 Ok(streams)
410 }
411
412 async fn get_json(
413 &self,
414 url: &str,
415 referer: Option<&str>,
416 origin: Option<&str>,
417 ) -> Result<Value> {
418 let mut request = self
419 .inner
420 .http
421 .get(url)
422 .header(header::ACCEPT, "application/json,text/plain,*/*");
423 if let Some(referer) = referer {
424 request = request.header(header::REFERER, referer);
425 }
426 if let Some(origin) = origin {
427 request = request.header(header::ORIGIN, origin);
428 }
429 let provider = if url.starts_with(&self.inner.megaplay_base) {
430 "MegaPlay"
431 } else {
432 "Anikoto"
433 };
434 checked_json(request.send().await?, provider).await
435 }
436}
437
438pub fn provider_from_show_id(show_id: &str) -> CatalogProvider {
439 if show_id.starts_with("anikoto2:") {
440 CatalogProvider::Anikoto2
441 } else {
442 CatalogProvider::Anikoto
443 }
444}
445
446fn encode_id(id: &AnikotoId) -> Result<String> {
447 Ok(format!(
448 "anikoto:{}",
449 URL_SAFE_NO_PAD.encode(serde_json::to_vec(id)?)
450 ))
451}
452
453fn decode_id(value: &str) -> Result<AnikotoId> {
454 if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) {
455 return Ok(AnikotoId {
456 anikoto_id: Some(value.into()),
457 ..Default::default()
458 });
459 }
460 let payload = value
461 .strip_prefix("anikoto:")
462 .ok_or_else(|| AniError::InvalidInput {
463 input: value.to_string(),
464 reason: "invalid Anikoto show ID".to_string(),
465 })?;
466 let bytes = URL_SAFE_NO_PAD
467 .decode(payload)
468 .map_err(|_| AniError::InvalidInput {
469 input: value.to_string(),
470 reason: "invalid Anikoto show ID encoding".to_string(),
471 })?;
472 serde_json::from_slice(&bytes).map_err(|_| AniError::InvalidInput {
473 input: value.to_string(),
474 reason: "invalid Anikoto show metadata".to_string(),
475 })
476}
477
478fn parse_search_payload(value: &Value, allow_adult: bool) -> Vec<SearchResult> {
479 let values = value
480 .pointer("/data/Page/media")
481 .or_else(|| value.get("data"))
482 .and_then(Value::as_array)
483 .or_else(|| value.as_array())
484 .cloned()
485 .unwrap_or_default();
486 values
487 .iter()
488 .filter_map(|item| {
489 if !allow_adult
490 && item
491 .get("isAdult")
492 .or_else(|| item.get("is_adult"))
493 .and_then(Value::as_bool)
494 .unwrap_or(false)
495 {
496 return None;
497 }
498 let anilist_id = string_value(item.get("ani_id").or_else(|| item.get("id")))?;
499 let title = item
500 .get("title")
501 .and_then(|title| {
502 if let Some(title) = title.as_str() {
503 return Some(title.to_owned());
504 }
505 ["english", "romaji", "native"]
506 .iter()
507 .find_map(|key| string_value(title.get(*key)))
508 })
509 .or_else(|| string_value(item.get("name")))
510 .unwrap_or_else(|| format!("AniList {anilist_id}"));
511 let episodes = number_value(item.get("episodes")).unwrap_or(0.0);
512 let anikoto_id = item.get("ani_id").and_then(|ani_id| {
513 let item_id = string_value(item.get("id"));
514 let ani_id = string_value(Some(ani_id));
515 (item_id != ani_id).then_some(item_id).flatten()
516 });
517 let id = encode_id(&AnikotoId {
518 anilist_id: Some(anilist_id),
519 mal_id: string_value(item.get("idMal").or_else(|| item.get("mal_id"))),
520 anikoto_id,
521 title: Some(title.clone()),
522 episodes: (episodes.is_finite() && episodes > 0.0).then_some(episodes as u32),
523 })
524 .ok()?;
525 Some(SearchResult {
526 id,
527 name: title,
528 episodes,
529 provider: CatalogProvider::Anikoto,
530 })
531 })
532 .collect()
533}
534
535fn merge_search_results(
536 recent: Vec<SearchResult>,
537 anilist: Vec<SearchResult>,
538) -> Vec<SearchResult> {
539 let mut seen = HashSet::new();
540 recent
541 .into_iter()
542 .chain(anilist)
543 .filter(|item| {
544 let id = decode_id(&item.id).ok();
545 let key = id
546 .and_then(|id| id.anilist_id)
547 .map(|id| format!("ani:{id}"))
548 .unwrap_or_else(|| format!("title:{}", normalize_title(&item.name)));
549 seen.insert(key)
550 })
551 .collect()
552}
553
554fn parse_episode_payload(value: &Value) -> Vec<AnikotoEpisode> {
555 let data = value.get("data").unwrap_or(value);
556 let mut episodes = data
557 .get("episodes")
558 .and_then(Value::as_array)
559 .into_iter()
560 .flatten()
561 .filter_map(|item| {
562 let number = string_value(
563 item.get("number")
564 .or_else(|| item.get("episode"))
565 .or_else(|| item.get("episode_number")),
566 )?;
567 Some(AnikotoEpisode {
568 number,
569 embed_id: string_value(item.get("episode_embed_id")),
570 sub_url: item.pointer("/embed_url/sub").and_then(value_string),
571 dub_url: item.pointer("/embed_url/dub").and_then(value_string),
572 })
573 })
574 .collect::<Vec<_>>();
575 let mut numbers = episodes
576 .iter()
577 .map(|value| value.number.clone())
578 .collect::<Vec<_>>();
579 sort_episodes(&mut numbers);
580 let positions = numbers
581 .into_iter()
582 .enumerate()
583 .map(|(index, number)| (number, index))
584 .collect::<HashMap<_, _>>();
585 episodes.sort_by_key(|episode| {
586 positions
587 .get(&episode.number)
588 .copied()
589 .unwrap_or(usize::MAX)
590 });
591 episodes
592}
593
594fn embed_candidates(
595 base: &str,
596 id: &AnikotoId,
597 episode: &str,
598 mode: TranslationType,
599 selected: Option<&AnikotoEpisode>,
600) -> Vec<String> {
601 let language = mode.to_string();
602 let mut candidates = Vec::new();
603 if let Some(selected) = selected {
604 let explicit = match mode {
605 TranslationType::Sub => selected.sub_url.as_ref(),
606 TranslationType::Dub => selected.dub_url.as_ref(),
607 };
608 if let Some(url) = explicit {
609 candidates.push(url.clone());
610 }
611 if let Some(embed_id) = &selected.embed_id {
612 candidates.push(format!("{base}/stream/s-2/{embed_id}/{language}"));
613 }
614 }
615 if let Some(anilist_id) = &id.anilist_id {
616 candidates.push(format!(
617 "{base}/stream/ani/{anilist_id}/{episode}/{language}"
618 ));
619 }
620 if let Some(mal_id) = &id.mal_id {
621 candidates.push(format!("{base}/stream/mal/{mal_id}/{episode}/{language}"));
622 }
623 let mut seen = HashSet::new();
624 candidates.retain(|candidate| seen.insert(candidate.clone()));
625 candidates
626}
627
628fn parse_data_id(html: &str) -> Option<String> {
629 Regex::new(r#"(?i)\bdata-id=["'](\d+)["']"#)
630 .expect("static regex")
631 .captures(html)
632 .map(|captures| captures[1].to_owned())
633}
634
635fn parse_megaplay_sources(value: &Value) -> (Vec<(String, String)>, Vec<SubtitleTrack>) {
636 fn collect_sources(value: &Value, values: &mut Vec<(String, String)>) {
637 match value {
638 Value::String(url) => values.push((url.clone(), "Auto".into())),
639 Value::Array(items) => items.iter().for_each(|item| collect_sources(item, values)),
640 Value::Object(object) => {
641 if let Some(url) = object
642 .get("file")
643 .or_else(|| object.get("url"))
644 .or_else(|| object.get("src"))
645 .and_then(Value::as_str)
646 {
647 let label = object
648 .get("label")
649 .or_else(|| object.get("quality"))
650 .and_then(Value::as_str)
651 .unwrap_or("Auto");
652 values.push((url.into(), label.into()));
653 }
654 for key in ["sources", "source", "links"] {
655 if let Some(child) = object.get(key) {
656 collect_sources(child, values);
657 }
658 }
659 }
660 _ => {}
661 }
662 }
663
664 fn collect_tracks(value: &Value, tracks: &mut Vec<SubtitleTrack>) {
665 match value {
666 Value::Array(items) => items.iter().for_each(|item| collect_tracks(item, tracks)),
667 Value::Object(object) => {
668 let kind = object
669 .get("kind")
670 .or_else(|| object.get("type"))
671 .and_then(Value::as_str)
672 .unwrap_or("")
673 .to_ascii_lowercase();
674 if !kind.is_empty()
675 && !kind.contains("caption")
676 && !kind.contains("subtitle")
677 && !kind.contains("sub")
678 {
679 return;
680 }
681 if let Some(url) = object
682 .get("file")
683 .or_else(|| object.get("url"))
684 .or_else(|| object.get("src"))
685 .and_then(Value::as_str)
686 {
687 tracks.push(SubtitleTrack {
688 label: object
689 .get("label")
690 .or_else(|| object.get("title"))
691 .and_then(Value::as_str)
692 .unwrap_or("Subtitle")
693 .into(),
694 url: url.into(),
695 default: object
696 .get("default")
697 .and_then(Value::as_bool)
698 .unwrap_or(false),
699 });
700 }
701 }
702 _ => {}
703 }
704 }
705
706 let mut sources = Vec::new();
707 let mut tracks = Vec::new();
708 if let Some(value) = value.get("sources") {
709 collect_sources(value, &mut sources);
710 }
711 if let Some(value) = value.get("source") {
712 collect_sources(value, &mut sources);
713 }
714 for key in ["tracks", "captions", "subtitles"] {
715 if let Some(value) = value.get(key) {
716 collect_tracks(value, &mut tracks);
717 }
718 }
719 let mut seen = HashSet::new();
720 sources.retain(|(url, _)| seen.insert(url.clone()));
721 let mut seen = HashSet::new();
722 tracks.retain(|track| seen.insert(track.url.clone()));
723 (sources, tracks)
724}
725
726fn media_headers(host: &str, user_agent: &str) -> RequestHeaders {
727 if is_megaplay_media_host(host) {
728 RequestHeaders {
729 referer: Some("https://megaplay.buzz/".into()),
730 origin: Some("https://megaplay.buzz".into()),
731 extra: [("User-Agent".into(), user_agent.into())].into(),
732 }
733 } else {
734 RequestHeaders::default()
735 }
736}
737
738pub(crate) fn is_megaplay_media_host(host: &str) -> bool {
739 let host = host.trim_end_matches('.').to_ascii_lowercase();
740 [
741 "megaplay.buzz",
742 "mewstream.buzz",
743 "lostproject.club",
744 "voltara.click",
745 "kotocdn.site",
746 "megap.shiora.top",
747 "shiora.top",
748 "megap.kotocdn.site",
749 "megap.akirax.buzz",
750 "akirax.buzz",
751 ]
752 .iter()
753 .any(|domain| host == *domain || host.ends_with(&format!(".{domain}")))
754}
755
756pub fn requires_hls_relay(stream: &StreamLink) -> bool {
757 stream.hls
758 && Url::parse(&stream.url)
759 .ok()
760 .and_then(|url| url.host_str().map(str::to_owned))
761 .is_some_and(|host| is_megaplay_media_host(&host))
762}
763
764fn stream_link(url: String, resolution: String, hls: bool, headers: RequestHeaders) -> StreamLink {
765 StreamLink {
766 url,
767 resolution,
768 hls,
769 provider: "MegaPlay".into(),
770 downloadable: true,
771 headers,
772 subtitles: vec![],
773 }
774}
775
776fn apply_headers(
777 mut request: reqwest::RequestBuilder,
778 headers: &RequestHeaders,
779) -> reqwest::RequestBuilder {
780 if let Some(referer) = &headers.referer {
781 request = request.header(header::REFERER, referer);
782 }
783 if let Some(origin) = &headers.origin {
784 request = request.header(header::ORIGIN, origin);
785 }
786 for (name, value) in &headers.extra {
787 request = request.header(name, value);
788 }
789 request
790}
791
792async fn checked_json(response: Response, provider: &str) -> Result<Value> {
793 let status = response.status();
794 if status == StatusCode::TOO_MANY_REQUESTS {
795 let retry_after_seconds = response
796 .headers()
797 .get(header::RETRY_AFTER)
798 .and_then(|value| value.to_str().ok())
799 .and_then(|value| value.parse().ok())
800 .unwrap_or(120);
801 return Err(AniError::ProviderRateLimited {
802 provider: provider.into(),
803 retry_after_seconds,
804 });
805 }
806 if !status.is_success() {
807 return Err(AniError::ProviderCatalogError {
808 provider: provider.into(),
809 message: format!("HTTP {}", status),
810 source: None,
811 });
812 }
813 response.json().await.map_err(Into::into)
814}
815
816async fn checked_text(response: Response, provider: &str) -> Result<String> {
817 let status = response.status();
818 if status == StatusCode::TOO_MANY_REQUESTS {
819 let retry_after_seconds = response
820 .headers()
821 .get(header::RETRY_AFTER)
822 .and_then(|value| value.to_str().ok())
823 .and_then(|value| value.parse().ok())
824 .unwrap_or(120);
825 return Err(AniError::ProviderRateLimited {
826 provider: provider.into(),
827 retry_after_seconds,
828 });
829 }
830 if !status.is_success() {
831 return Err(AniError::ProviderCatalogError {
832 provider: provider.into(),
833 message: format!("HTTP {}", status),
834 source: None,
835 });
836 }
837 response.text().await.map_err(Into::into)
838}
839
840fn validate_remote_url(value: &str) -> Result<Url> {
841 let url = Url::parse(value)?;
842 if !url.username().is_empty() || url.password().is_some() {
843 return Err(AniError::ProviderUrlValidationFailed {
844 reason: "media URL contains credentials".to_string(),
845 });
846 }
847 let loopback = url.host_str().is_some_and(|host| {
848 host == "localhost"
849 || host
850 .parse::<std::net::IpAddr>()
851 .is_ok_and(|ip| ip.is_loopback())
852 });
853 if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
854 return Err(AniError::ProviderUrlValidationFailed {
855 reason: "media URL must use HTTPS".to_string(),
856 });
857 }
858 Ok(url)
859}
860
861fn cache_get<T: Clone>(cache: &Mutex<HashMap<String, Cached<T>>>, key: &str) -> Option<T> {
862 let mut cache = cache.lock().ok()?;
863 cache.retain(|_, value| value.expires_at > Instant::now());
864 cache.get(key).map(|value| value.value.clone())
865}
866
867fn cache_put<T>(cache: &Mutex<HashMap<String, Cached<T>>>, key: String, value: T) {
868 if let Ok(mut cache) = cache.lock() {
869 if cache.len() >= CACHE_LIMIT
870 && let Some(key) = cache.keys().next().cloned()
871 {
872 cache.remove(&key);
873 }
874 cache.insert(
875 key,
876 Cached {
877 expires_at: Instant::now() + CACHE_TTL,
878 value,
879 },
880 );
881 }
882}
883
884fn value_string(value: &Value) -> Option<String> {
885 value
886 .as_str()
887 .map(str::trim)
888 .filter(|value| !value.is_empty())
889 .map(str::to_owned)
890}
891
892fn string_value(value: Option<&Value>) -> Option<String> {
893 match value? {
894 Value::String(value) => Some(value.trim().to_owned()).filter(|value| !value.is_empty()),
895 Value::Number(value) => Some(value.to_string()),
896 _ => None,
897 }
898}
899
900fn number_value(value: Option<&Value>) -> Option<f64> {
901 match value? {
902 Value::Number(value) => value.as_f64(),
903 Value::String(value) => value.parse().ok(),
904 _ => None,
905 }
906}
907
908fn normalize_title(value: &str) -> String {
909 value
910 .chars()
911 .filter(|character| character.is_alphanumeric())
912 .flat_map(char::to_lowercase)
913 .collect()
914}
915
916#[cfg(test)]
917mod tests {
918 use super::*;
919 use wiremock::{
920 Mock, MockServer, ResponseTemplate,
921 matchers::{method, path},
922 };
923
924 #[test]
925 fn ids_round_trip_and_detect_provider() {
926 let value = AnikotoId {
927 anilist_id: Some("123".into()),
928 mal_id: Some("456".into()),
929 anikoto_id: Some("789".into()),
930 title: Some("Example".into()),
931 episodes: Some(12),
932 };
933 let encoded = encode_id(&value).unwrap();
934 assert_eq!(decode_id(&encoded).unwrap(), value);
935 assert_eq!(provider_from_show_id(&encoded), CatalogProvider::Anikoto);
936 assert_eq!(
937 provider_from_show_id("anikoto2:metadata"),
938 CatalogProvider::Anikoto2
939 );
940 assert_eq!(provider_from_show_id("legacy"), CatalogProvider::Anikoto);
941 }
942
943 #[test]
944 fn parses_series_and_candidate_order() {
945 let episodes = parse_episode_payload(&json!({"data":{"episodes":[
946 {"number":"2","episode_embed_id":"22"},
947 {"number":1,"episode_embed_id":"11","embed_url":{"sub":"https://megaplay.buzz/explicit"}}
948 ]}}));
949 assert_eq!(episodes[0].number, "1");
950 let id = AnikotoId {
951 anilist_id: Some("1".into()),
952 mal_id: Some("2".into()),
953 ..Default::default()
954 };
955 let candidates = embed_candidates(
956 "https://megaplay.buzz",
957 &id,
958 "1",
959 TranslationType::Sub,
960 Some(&episodes[0]),
961 );
962 assert_eq!(candidates[0], "https://megaplay.buzz/explicit");
963 assert!(candidates[1].contains("/stream/s-2/11/sub"));
964 assert!(candidates[2].contains("/stream/ani/1/1/sub"));
965 assert!(candidates[3].contains("/stream/mal/2/1/sub"));
966 }
967
968 #[test]
969 fn parses_nested_sources_and_subtitles() {
970 let (sources, subtitles) = parse_megaplay_sources(&json!({
971 "sources":{"links":[{"file":"https://megap.kotocdn.site/master.m3u8","label":"1080p"}]},
972 "tracks":[{"file":"https://megap.kotocdn.site/en.vtt","label":"English","kind":"captions","default":true}]
973 }));
974 assert_eq!(sources[0].1, "1080p");
975 assert_eq!(subtitles[0].label, "English");
976 assert!(subtitles[0].default);
977 }
978
979 #[test]
980 fn host_allowlist_rejects_lookalikes() {
981 assert!(is_megaplay_media_host("megap.kotocdn.site"));
982 assert!(!is_megaplay_media_host("kotocdn.site.example.com"));
983 assert!(!is_megaplay_media_host("evilmegaplay.buzz"));
984 }
985
986 #[test]
987 fn adult_search_results_are_filtered() {
988 let payload = json!({"data":{"Page":{"media":[
989 {"id":1,"title":{"english":"Safe"},"episodes":12,"isAdult":false},
990 {"id":2,"title":{"english":"Adult"},"episodes":1,"isAdult":true}
991 ]}}});
992 assert_eq!(parse_search_payload(&payload, false).len(), 1);
993 assert_eq!(parse_search_payload(&payload, true).len(), 2);
994 }
995
996 #[tokio::test]
997 async fn search_tolerates_anilist_failure_when_catalog_succeeds() {
998 let server = MockServer::start().await;
999 Mock::given(method("GET"))
1000 .and(path("/recent-anime"))
1001 .respond_with(ResponseTemplate::new(200).set_body_json(
1002 json!({"data":[{"id":9,"ani_id":1,"title":"Example","episodes":2}]}),
1003 ))
1004 .mount(&server)
1005 .await;
1006 Mock::given(method("POST"))
1007 .and(path("/graphql"))
1008 .respond_with(ResponseTemplate::new(500))
1009 .mount(&server)
1010 .await;
1011 let client = AnikotoClient::builder()
1012 .anikoto_api(server.uri())
1013 .anilist_api(format!("{}/graphql", server.uri()))
1014 .build()
1015 .unwrap();
1016 let values = client
1017 .search("example", TranslationType::Sub)
1018 .await
1019 .unwrap();
1020 assert_eq!(values.len(), 1);
1021 assert_eq!(values[0].provider, CatalogProvider::Anikoto);
1022 assert_eq!(
1023 decode_id(&values[0].id).unwrap().anikoto_id.as_deref(),
1024 Some("9")
1025 );
1026 }
1027
1028 #[tokio::test]
1029 async fn megaplay_candidates_fall_back_to_the_mal_route() {
1030 let server = MockServer::start().await;
1031 Mock::given(method("GET"))
1032 .and(path("/stream/ani/1/1/sub"))
1033 .respond_with(ResponseTemplate::new(404))
1034 .expect(1)
1035 .mount(&server)
1036 .await;
1037 Mock::given(method("GET"))
1038 .and(path("/stream/mal/2/1/sub"))
1039 .respond_with(ResponseTemplate::new(200).set_body_string("<div data-id=\"99\"></div>"))
1040 .expect(1)
1041 .mount(&server)
1042 .await;
1043 Mock::given(method("GET"))
1044 .and(path("/stream/getSources"))
1045 .respond_with(
1046 ResponseTemplate::new(200)
1047 .set_body_json(json!({"sources":{"file":"https://voltara.click/video.mp4"}})),
1048 )
1049 .expect(1)
1050 .mount(&server)
1051 .await;
1052 let client = AnikotoClient::builder()
1053 .megaplay_base(server.uri())
1054 .build()
1055 .unwrap();
1056 let id = encode_id(&AnikotoId {
1057 anilist_id: Some("1".into()),
1058 mal_id: Some("2".into()),
1059 episodes: Some(1),
1060 ..Default::default()
1061 })
1062 .unwrap();
1063 let streams = client
1064 .streams(&id, "1", TranslationType::Sub)
1065 .await
1066 .unwrap();
1067 assert_eq!(streams[0].url, "https://voltara.click/video.mp4");
1068 }
1069
1070 #[tokio::test]
1071 async fn live_anikoto_smoke_test_is_opt_in() {
1072 if std::env::var("ANI_CLI_LIVE_ANIKOTO").as_deref() != Ok("1") {
1073 return;
1074 }
1075 let results = AnikotoClient::new()
1076 .unwrap()
1077 .search("Frieren", TranslationType::Sub)
1078 .await
1079 .unwrap();
1080 assert!(!results.is_empty());
1081 }
1082}