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 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}