krystv's picture
Upload 107 files
3374e90 verified
/*!
GogoAnime (anitaku.to) streaming plugin for the BEX engine.
Features:
- Search anime with HTML scraping
- Home page with Popular, Recent, New Season, Movies sections
- Genre/category browsing (Action, Adventure, Comedy, Drama, etc.)
- Fetch detailed info with episodes (title, image, description, type, status, genres)
- Multiple server extraction (HSUB, SUB, DUB) from episode pages
- HLS stream resolution via VibePlayer iframe JS parsing
- Proper Referer/User-Agent headers for mpv playback
*/
#[allow(warnings)]
mod bindings;
use bindings::bex::plugin::common::*;
use bindings::bex::plugin::http;
use bindings::exports::api::Guest;
struct Component;
const BASE_URL: &str = "https://anitaku.to";
// ============================================================================
// Minimal HTML DOM parser (WASM-compatible, no external dep)
// ============================================================================
struct Dom {
html: String,
}
impl Dom {
fn parse(html: &str) -> Self {
Dom { html: html.to_string() }
}
#[allow(dead_code)]
fn find_attr_by_id(&self, id: &str, attr: &str) -> Option<String> {
let id_pattern = format!("id=\"{}\"", id);
let id_idx = self.html.find(&id_pattern)?;
let tag_start = self.html[..id_idx].rfind('<')?;
let tag_end = self.html[tag_start..].find('>')? + tag_start;
let tag_str = &self.html[tag_start..tag_end];
extract_attr_value(tag_str, attr)
}
#[allow(dead_code)]
fn find_attr(&self, tag: &str, attr: &str) -> Option<String> {
let pattern = format!("<{}", tag);
let idx = self.html.find(&pattern)?;
let tag_end = self.html[idx..].find('>')? + idx;
let tag_str = &self.html[idx..tag_end];
extract_attr_value(tag_str, attr)
}
#[allow(dead_code)]
fn find_class_text(&self, tag: &str, class: &str) -> Option<String> {
let pattern = format!("<{} class=\"{}\"", tag, class);
let idx = self.html.find(&pattern)?;
let content_start = self.html[idx..].find('>')? + idx + 1;
let close = format!("</{}>", tag);
let content_end = self.html[content_start..].find(&close)? + content_start;
Some(self.html[content_start..content_end].trim().to_string())
}
fn find_tag_content(&self, tag: &str) -> Option<String> {
let open_start = self.html.find(&format!("<{}", tag))?;
let content_start = self.html[open_start..].find('>')? + open_start + 1;
let close = format!("</{}>", tag);
let content_end = self.html[content_start..].find(&close)? + content_start;
Some(self.html[content_start..content_end].to_string())
}
fn find_img_src(&self, container_class: &str) -> Option<String> {
let pattern = format!("class=\"{}\"", container_class);
let idx = self.html.find(&pattern)?;
let block_end = self.html[idx..].find("</div>").unwrap_or(self.html.len() - idx) + idx;
let block = &self.html[idx..block_end];
let img_idx = block.find("<img")?;
let img_end = block[img_idx..].find('>')? + img_idx;
let img_tag = &block[img_idx..img_end];
extract_attr_value(img_tag, "src").or_else(|| extract_attr_value(img_tag, "data-src"))
}
}
fn extract_attr_value(tag_str: &str, attr: &str) -> Option<String> {
let attr_pattern = format!("{}=\"", attr);
if let Some(idx) = tag_str.find(&attr_pattern) {
let val_start = idx + attr_pattern.len();
let val_end = tag_str[val_start..].find('"')? + val_start;
Some(tag_str[val_start..val_end].to_string())
} else {
None
}
}
// ============================================================================
// HTTP helpers — delegate to host
// ============================================================================
impl Component {
fn get_headers() -> Vec<Attr> {
vec![
Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() },
Attr { key: "Connection".to_string(), value: "keep-alive".to_string() },
Attr { key: "Accept".to_string(), value: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string() },
Attr { key: "Accept-Language".to_string(), value: "en-US,en;q=0.5".to_string() },
Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) },
]
}
fn fetch_url(url: &str) -> Result<String, PluginError> {
Self::fetch_url_with_headers(url, Self::get_headers())
}
fn fetch_url_with_headers(url: &str, headers: Vec<Attr>) -> Result<String, PluginError> {
let req = http::Request {
method: http::Method::Get,
url: url.to_string(),
headers,
body: None,
timeout_ms: Some(15000),
follow_redirects: true,
cache_mode: http::CacheMode::NoStore,
max_bytes: Some(5 * 1024 * 1024),
};
match http::send_request(&req) {
Ok(resp) if resp.status == 200 => String::from_utf8(resp.body)
.map_err(|e| PluginError::Parse(format!("UTF-8 error: {}", e))),
Ok(resp) => Err(PluginError::Network(format!("HTTP {}", resp.status))),
Err(e) => Err(e),
}
}
// ========================================================================
// Scrape anime cards from search/browse listing pages
// ========================================================================
/// Parse `ul.items li` cards from GogoAnime search/category pages.
/// Each card has: `.name a[href]` (title + link), `.img img[src]` (poster),
/// `.released` (year info).
fn scrape_anime_cards(html: &str) -> Vec<MediaCard> {
let mut results = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
// GogoAnime search/category pages use <ul class="items"><li> structure.
// Each <li> contains a link with href="/category/{slug}"
let li_pattern = "<li>";
let mut offset = 0;
while let Some(idx) = html[offset..].find(li_pattern) {
let li_start = offset + idx;
// Find the end of this <li> block — look for the closing </li>
let li_close = "</li>";
let li_end = html[li_start..].find(li_close)
.map(|i| li_start + i + li_close.len())
.unwrap_or(html.len().min(li_start + 3000));
let block = &html[li_start..li_end.min(html.len())];
// Extract the category link: href="/category/{slug}"
let slug = if let Some(s) = extract_in_block(block, "href=\"/category/", "\"") {
s
} else {
offset = li_end;
continue;
};
if slug.is_empty() || seen_ids.contains(&slug) {
offset = li_end;
continue;
}
seen_ids.insert(slug.clone());
// Extract title from the .name a text or the title attribute
let title = if let Some(t) = extract_in_block(block, "title=\"", "\"") {
t
} else if let Some(t) = extract_gogo_link_text(block) {
t
} else {
slug.replace('-', " ")
};
// Extract image from img src or data-original
let image = extract_in_block(block, "data-original=\"", "\"")
.or_else(|| extract_in_block(block, "src=\"", "\""))
.filter(|u| !u.is_empty() && (u.starts_with("http") || u.starts_with("//")))
.unwrap_or_default();
// Fix protocol-relative URLs
let image = if image.starts_with("//") {
format!("https:{}", image)
} else {
image
};
// Extract released/year text
let year = extract_in_block(block, "class=\"released\">", "</p>")
.or_else(|| extract_in_block(block, "class=\"released\">", "</div>"))
.map(|s| {
let cleaned = clean_html(&s).trim().to_string();
// Extract just the year number if present
if let Some(yr) = cleaned.chars().filter(|c| c.is_ascii_digit()).collect::<String>().get(..4) {
Some(yr.to_string())
} else {
None
}
})
.unwrap_or(None);
results.push(MediaCard {
id: slug.clone(),
title: clean_html(&title),
kind: Some(MediaKind::Anime),
images: if image.is_empty() {
None
} else {
Some(make_image_set(&image, ImageLayout::Portrait))
},
original_title: None,
tagline: None,
year,
score: None,
genres: vec![],
status: None,
content_rating: None,
url: Some(format!("{}/category/{}", BASE_URL, slug)),
ids: vec![],
extra: vec![],
});
offset = li_end;
}
results
}
// ========================================================================
// Parse episode list from the anime info page
// ========================================================================
fn parse_episodes(html: &str, slug: &str) -> Vec<Episode> {
// PRIMARY: Use the AJAX endpoint only — it returns only the episodes
// for this specific anime, avoiding greedy scanning of the full page
// which picks up navigation/sidebar/footer links.
// GogoAnime loads episodes via: /load-list-episode?ep_start=0&ep_end=...&id=...
if let Some(eps) = Self::fetch_episodes_ajax(html, slug) {
if !eps.is_empty() {
return eps;
}
}
// FALLBACK: Only look for links within a properly scoped episode container.
// We scope strictly to the #episode_related / #episode_page container and
// filter hrefs to only include those matching the slug pattern.
let mut episodes = Vec::new();
// Find the start of the episode list container
let ep_container_start = html.find("id=\"episode_related\"")
.or_else(|| html.find("id=\"episode_page\""))
.or_else(|| html.find("class=\"episode_related\""))
.or_else(|| html.find("class=\"episode_page\""));
let ep_section = match ep_container_start {
Some(start) => {
// Scope to the container — find the closing </div> or limit to 50KB
let end_limit = (start + 50000).min(html.len());
&html[start..end_limit]
}
None => return episodes, // No container found, return empty
};
// The slug pattern that episode hrefs must contain
let slug_ep_pattern = format!("/{}-episode-", slug);
// Parse <a tags within the scoped container
let a_pattern = "<a ";
let mut offset = 0;
while let Some(idx) = ep_section[offset..].find(a_pattern) {
if episodes.len() >= 200 {
break; // Limit to prevent runaway parsing
}
let a_start = offset + idx;
let a_end = ep_section[a_start..].find('>')
.map(|p| a_start + p)
.unwrap_or(ep_section.len());
if a_end <= a_start || a_end >= ep_section.len() {
offset = a_start + 1;
continue;
}
let a_tag = &ep_section[a_start..a_end];
let after_a = if a_end + 1 < ep_section.len() {
&ep_section[a_end + 1..]
} else {
offset = a_end + 1;
continue;
};
// Extract href
let href = extract_attr_value(a_tag, "href").unwrap_or_default();
// FILTER: Only accept hrefs that match the slug pattern
if !href.contains(&slug_ep_pattern) && !href.contains("-episode-") {
offset = a_end + 1;
continue;
}
// Extract episode number from .name text or from href
let ep_name = if let Some(close_a) = after_a.find("</a>") {
let text = after_a[..close_a].trim();
clean_html(text)
} else {
String::new()
};
// Try to parse episode number from the name (e.g. "EP 1" or just "1")
let ep_num = parse_ep_number(&ep_name, &href);
// Extract sub/dub indicators from data attributes
let has_sub = extract_attr_value(a_tag, "data-sub")
.map(|v| v == "1" || v.to_lowercase() == "true")
.unwrap_or(false);
let has_dub = extract_attr_value(a_tag, "data-dub")
.map(|v| v == "1" || v.to_lowercase() == "true")
.unwrap_or(false);
let sub_flag = if has_sub || !has_dub { 1 } else { 0 };
let dub_flag = if has_dub { 1 } else { 0 };
if ep_num <= 0.0 {
offset = a_end + 1;
continue;
}
let ep_title = if ep_name.is_empty() {
format!("Episode {}", ep_num as i32)
} else {
ep_name
};
let episode_id = format!("{}$ep={}$sub={}$dub={}", slug, ep_num as i32, sub_flag, dub_flag);
// Build URL from href or construct it
let ep_url = if href.starts_with("http") {
href.clone()
} else if href.starts_with("/") {
format!("{}{}", BASE_URL, href)
} else {
format!("{}/{}-episode-{}", BASE_URL, slug, ep_num as i32)
};
episodes.push(Episode {
id: episode_id,
title: ep_title,
number: Some(ep_num),
season: Some(1.0),
images: None,
description: None,
released: None,
score: None,
url: Some(ep_url),
tags: vec![],
extra: vec![],
});
offset = a_end + 1;
}
episodes
}
/// Fetch episodes via the AJAX endpoint used by GogoAnime.
/// This is the PRIMARY and preferred method — the AJAX response contains
/// only the episode list for the requested anime, avoiding false positives
/// from nav/sidebar/footer links.
fn fetch_episodes_ajax(html: &str, slug: &str) -> Option<Vec<Episode>> {
// Extract the anime movie_id from the page — it's in the AJAX load-list-episode URL
let movie_id = extract_in_block(html, "/load-list-episode?ep_start=0&ep_end=", "&id=")
.or_else(|| {
// Try alternate format: value between &id= and the next & or "
if let Some(idx) = html.find("&id=") {
let start = idx + 4;
let end = html[start..].find(|c: char| c == '"' || c == '&' || c == ' ')
.unwrap_or(html.len() - start);
let val = html[start..start + end].to_string();
if !val.is_empty() { Some(val) } else { None }
} else {
None
}
})
.or_else(|| {
// Try alternate format
extract_in_block(html, "movie_id = '", "'")
.or_else(|| extract_in_block(html, "movie_id=\"", "\""))
})?;
if movie_id.is_empty() {
return None;
}
let ajax_url = format!(
"{}/load-list-episode?ep_start=0&ep_end=9999&id={}",
BASE_URL, movie_id
);
let ajax_html = Self::fetch_url(&ajax_url).ok()?;
let mut episodes = Vec::new();
// The slug pattern that episode hrefs must match
let slug_ep_pattern = format!("/{}-episode-", slug);
let li_pattern = "<li>";
let mut offset = 0;
while let Some(idx) = ajax_html[offset..].find(li_pattern) {
if episodes.len() >= 200 {
break; // Limit to prevent runaway parsing
}
let li_start = offset + idx;
let li_close = "</li>";
let li_end = ajax_html[li_start..].find(li_close)
.map(|i| li_start + i + li_close.len())
.unwrap_or(ajax_html.len().min(li_start + 2000));
let block = &ajax_html[li_start..li_end.min(ajax_html.len())];
let href = extract_in_block(block, "href=\"", "\"").unwrap_or_default();
// FILTER: Only accept episodes whose href matches the slug pattern
if !href.contains(&slug_ep_pattern) && !href.contains("-episode-") {
offset = li_end;
continue;
}
let ep_name = extract_in_block(block, "class=\"name\">", "</div>")
.or_else(|| extract_in_block(block, "class=\"name\">", "</a>"))
.unwrap_or_default();
let ep_num = parse_ep_number(&ep_name, &href);
let has_sub = extract_in_block(block, "data-sub=\"", "\"")
.map(|v| v == "1")
.unwrap_or(true);
let has_dub = extract_in_block(block, "data-dub=\"", "\"")
.map(|v| v == "1")
.unwrap_or(false);
if ep_num <= 0.0 {
offset = li_end;
continue;
}
let sub_flag = if has_sub || !has_dub { 1 } else { 0 };
let dub_flag = if has_dub { 1 } else { 0 };
let ep_title = if ep_name.trim().is_empty() {
format!("Episode {}", ep_num as i32)
} else {
clean_html(&ep_name)
};
let episode_id = format!("{}$ep={}$sub={}$dub={}", slug, ep_num as i32, sub_flag, dub_flag);
let ep_url = if href.starts_with("http") {
href
} else if href.starts_with("/") {
format!("{}{}", BASE_URL, href)
} else {
format!("{}/{}-episode-{}", BASE_URL, slug, ep_num as i32)
};
episodes.push(Episode {
id: episode_id,
title: ep_title,
number: Some(ep_num),
season: Some(1.0),
images: None,
description: None,
released: None,
score: None,
url: Some(ep_url),
tags: vec![],
extra: vec![],
});
offset = li_end;
}
Some(episodes)
}
// ========================================================================
// VibePlayer stream extraction
// ========================================================================
/// Fetch the VibePlayer iframe page and extract the HLS m3u8 URL from its JS
fn extract_vibeplayer_stream(iframe_url: &str) -> Result<(String, Vec<SubtitleTrack>), PluginError> {
let html = Self::fetch_url_with_headers(iframe_url, vec![
Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() },
Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) },
Attr { key: "Accept".to_string(), value: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string() },
])?;
// Strategy 1: Look for `const src = "..."` or `var src = "..."` with .m3u8
let m3u8_url = extract_m3u8_from_js(&html)
.ok_or_else(|| PluginError::Parse("Could not find m3u8 URL in VibePlayer page".to_string()))?;
// Check for subtitle URL in ?sub= parameter of the iframe URL
let mut subtitles = Vec::new();
if let Some(sub_param) = iframe_url.find("sub=") {
let sub_start = sub_param + 4;
let sub_end = iframe_url[sub_start..].find('&').unwrap_or(iframe_url.len() - sub_start);
let sub_url = &iframe_url[sub_start..sub_start + sub_end];
if sub_url.starts_with("http") || sub_url.starts_with("//") {
let sub_url_fixed = if sub_url.starts_with("//") {
format!("https:{}", sub_url)
} else {
sub_url.to_string()
};
subtitles.push(SubtitleTrack {
label: "English".to_string(),
url: sub_url_fixed,
language: Some("en".to_string()),
format: Some("vtt".to_string()),
});
}
}
Ok((m3u8_url, subtitles))
}
}
// ============================================================================
// Guest implementation
// ============================================================================
impl Guest for Component {
fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> {
let mut sections = Vec::new();
// Category links — GogoAnime genre pages
let genres = vec![
("Action", "genre/action"),
("Adventure", "genre/adventure"),
("Comedy", "genre/comedy"),
("Drama", "genre/drama"),
("Fantasy", "genre/fantasy"),
("Horror", "genre/horror"),
("Isekai", "genre/isekai"),
("Mecha", "genre/mecha"),
("Mystery", "genre/mystery"),
("Romance", "genre/romance"),
("Sci-Fi", "genre/sci-fi"),
("Slice of Life", "genre/slice-of-life"),
("Sports", "genre/sports"),
("Thriller", "genre/thriller"),
];
sections.push(HomeSection {
id: "categories".to_string(),
title: "Browse".to_string(),
subtitle: None,
items: vec![],
next_page: None,
layout: CardLayout::Grid,
show_rank: false,
categories: genres
.into_iter()
.map(|(title, id)| CategoryLink {
id: id.to_string(),
title: title.to_string(),
subtitle: None,
image: None,
})
.collect(),
extra: vec![],
});
// Fetch sections from site pages
let section_pages = vec![
("popular", "Popular", "/popular.html"),
("recent", "Recently Released", "/recent-release.html?page=1"),
("new-season", "New Season", "/season.html"),
("movies", "Anime Movies", "/anime-movies.html"),
];
for (sec_id, sec_title, sec_path) in section_pages {
let url = format!("{}{}", BASE_URL, sec_path);
if let Ok(html) = Self::fetch_url(&url) {
let items = Self::scrape_anime_cards(&html);
if !items.is_empty() {
sections.push(HomeSection {
id: sec_id.to_string(),
title: sec_title.to_string(),
subtitle: None,
items: items.into_iter().take(20).collect(),
next_page: Some(format!("{}:2", sec_id)),
layout: CardLayout::Grid,
show_rank: false,
categories: vec![],
extra: vec![],
});
}
}
}
Ok(sections)
}
fn get_category(_ctx: RequestContext, id: String, page: PageCursor) -> Result<PagedResult, PluginError> {
let page_num: u32 = page
.token
.as_ref()
.and_then(|t| t.split(':').last())
.and_then(|n| n.parse().ok())
.unwrap_or(1);
let endpoint = if id.starts_with("genre/") {
let genre = &id[6..]; // strip "genre/"
format!("/genre/{}?page={}", genre, page_num)
} else {
match id.as_str() {
"popular" => format!("/popular.html?page={}", page_num),
"recent" => format!("/recent-release.html?page={}", page_num),
"new-season" => format!("/season.html?page={}", page_num),
"movies" => format!("/anime-movies.html?page={}", page_num),
_ => return Err(PluginError::NotFound),
}
};
let url = format!("{}{}", BASE_URL, endpoint);
let html = Self::fetch_url(&url)?;
let items = Self::scrape_anime_cards(&html);
let next_page = if items.len() >= 18 {
Some(format!("{}:{}", id, page_num + 1))
} else {
None
};
Ok(PagedResult {
items,
categories: vec![],
next_page,
})
}
fn search(_ctx: RequestContext, query: String, _filters: SearchFilters) -> Result<PagedResult, PluginError> {
if query.trim().is_empty() {
return Ok(PagedResult {
items: vec![],
categories: vec![],
next_page: None,
});
}
let encoded = query.replace(' ', "+");
let url = format!("{}/search.html?keyword={}&page=1", BASE_URL, encoded);
let html = Self::fetch_url(&url)?;
let items = Self::scrape_anime_cards(&html);
Ok(PagedResult {
items,
categories: vec![],
next_page: None,
})
}
fn get_info(_ctx: RequestContext, id: String) -> Result<MediaInfo, PluginError> {
let url = format!("{}/category/{}", BASE_URL, id);
let html = Self::fetch_url(&url)?;
let dom = Dom::parse(&html);
// Title — from .anime_info_body_bg h1 or h1 entry-title
let title = dom.find_tag_content("h1")
.filter(|t| !t.trim().is_empty())
.or_else(|| {
extract_in_block(&html, "class=\"anime_info_body_bg\"", "</div>")
.and_then(|block| extract_in_block(&block, "<h1>", "</h1>"))
})
.or_else(|| {
extract_in_block(&html, "class=\"title_name\"", "</h1>")
.or_else(|| extract_in_block(&html, "entry-title\">", "</h1>"))
})
.map(|s| clean_html(&s))
.unwrap_or_else(|| id.replace('-', " "));
// Poster image — from .anime_info_body_bg img
let image = dom.find_img_src("anime_info_body_bg")
.or_else(|| {
extract_in_block(&html, "class=\"anime_info_body_bg\"", "</div>").and_then(|block| {
extract_in_block(&block, "src=\"", "\"")
.or_else(|| extract_in_block(&block, "data-src=\"", "\""))
})
})
.or_else(|| {
extract_in_block(&html, "property=\"og:image\" content=\"", "\"")
.or_else(|| extract_in_block(&html, "property='og:image' content='", "'"))
})
.map(|u| {
if u.starts_with("//") {
format!("https:{}", u)
} else {
u
}
})
.unwrap_or_default();
// Description — from .description or div class="description"
let description = extract_in_block(&html, "class=\"description\"", "</div>")
.or_else(|| extract_in_block(&html, "class=\"desc\"", "</div>"))
.or_else(|| {
extract_in_block(&html, "property=\"og:description\" content=\"", "\"")
.or_else(|| extract_in_block(&html, "name=\"description\" content=\"", "\""))
})
.map(|s| clean_html(&s))
.filter(|t| !t.is_empty());
// Type (TV/Movie/OVA) — from p.type text
let type_str = extract_in_block(&html, ">Type:<", "</p>")
.or_else(|| extract_in_block(&html, "class=\"type\">", "</a>"))
.or_else(|| {
// GogoAnime puts type info in <p class="type"> spans
let pattern = "class=\"type\"";
if let Some(idx) = html.find(pattern) {
let block_end = html[idx..].find("</p>").unwrap_or(200).min(200);
let block = &html[idx..idx + block_end];
// Get text after the last > before </p>
if let Some(last_gt) = block.rfind('>') {
let text = &block[last_gt + 1..];
let cleaned = clean_html(text);
if !cleaned.is_empty() {
return Some(cleaned);
}
}
}
None
})
.map(|s| clean_html(&s).trim().to_string())
.unwrap_or_default();
// Parse metadata from the p.type sections on the page
// GogoAnime structure: <p class="type"><span>Genre:</span> <a>Action</a>, ...</p>
let genres = extract_gogo_genres(&html);
// Status
let status = extract_in_block(&html, ">Status:<", "</p>")
.or_else(|| {
let pattern = "class=\"type\"";
let mut offset = 0;
let mut result = None;
while let Some(idx) = html[offset..].find(pattern) {
let block_end = html[offset + idx..].find("</p>").unwrap_or(300).min(300);
let block = &html[offset + idx..offset + idx + block_end];
if block.contains("Status") {
if let Some(last_gt) = block.rfind('>') {
let text = &block[last_gt + 1..];
let cleaned = clean_html(text);
if !cleaned.is_empty() {
result = Some(cleaned);
break;
}
}
}
offset = offset + idx + 1;
}
result
})
.map(|s| clean_html(&s).trim().to_lowercase())
.and_then(|s| match s.as_str() {
"ongoing" => Some(Status::Ongoing),
"currently airing" => Some(Status::Ongoing),
"completed" => Some(Status::Completed),
"finished airing" => Some(Status::Completed),
"upcoming" => Some(Status::Upcoming),
"not yet aired" => Some(Status::Upcoming),
_ => None,
});
// Year / premiered
let year = extract_in_block(&html, ">Released:<", "</p>")
.or_else(|| extract_in_block(&html, ">Premiered:<", "</p>"))
.or_else(|| {
let pattern = "class=\"type\"";
let mut offset = 0;
let mut result = None;
while let Some(idx) = html[offset..].find(pattern) {
let block_end = html[offset + idx..].find("</p>").unwrap_or(300).min(300);
let block = &html[offset + idx..offset + idx + block_end];
if block.contains("Released") || block.contains("Premiered") {
if let Some(last_gt) = block.rfind('>') {
let text = &block[last_gt + 1..];
let cleaned = clean_html(text).trim().to_string();
if !cleaned.is_empty() {
result = Some(cleaned);
break;
}
}
}
offset = offset + idx + 1;
}
result
})
.map(|s| clean_html(&s).trim().to_string());
// Studios
let studio = extract_in_block(&html, ">Studios:<", "</p>")
.or_else(|| {
let pattern = "class=\"type\"";
let mut offset = 0;
let mut result = None;
while let Some(idx) = html[offset..].find(pattern) {
let block_end = html[offset + idx..].find("</p>").unwrap_or(300).min(300);
let block = &html[offset + idx..offset + idx + block_end];
if block.contains("Studios") || block.contains("Producers") {
if let Some(last_gt) = block.rfind('>') {
let text = &block[last_gt + 1..];
let cleaned = clean_html(text);
if !cleaned.is_empty() {
result = Some(cleaned);
break;
}
}
}
offset = offset + idx + 1;
}
result
});
// Episodes — parse from the page (wrapped in catch to prevent panics)
let episodes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Self::parse_episodes(&html, &id)
})).unwrap_or_default();
let seasons = if episodes.is_empty() {
vec![]
} else {
vec![Season {
id: format!("{}_s1", id),
title: "Season 1".to_string(),
number: Some(1.0),
year: None,
episodes,
}]
};
Ok(MediaInfo {
id: id.clone(),
title,
kind: MediaKind::Anime,
images: if image.is_empty() {
None
} else {
Some(make_image_set(&image, ImageLayout::Portrait))
},
original_title: None,
description,
score: None,
scored_by: None,
year,
release_date: None,
genres,
tags: if type_str.is_empty() { vec![] } else { vec![type_str] },
status,
content_rating: None,
seasons,
cast: vec![],
crew: vec![],
runtime_minutes: None,
trailer_url: None,
ids: vec![],
studio,
country: None,
language: None,
url: Some(format!("{}/category/{}", BASE_URL, id)),
extra: vec![],
})
}
fn get_servers(_ctx: RequestContext, id: String) -> Result<Vec<Server>, PluginError> {
// The ID is self-describing: "slug$ep=N$sub=N$dub=N"
// The plugin knows how to parse its own IDs.
if id.is_empty() {
return Err(PluginError::InvalidInput("id is required for get_servers".to_string()));
}
// Parse the episode ID format: "{slug}$ep={n}$sub={0|1}$dub={0|1}"
let parts: Vec<&str> = id.split('$').collect();
if parts.len() < 2 {
return Err(PluginError::InvalidInput(
"Invalid episode ID format. Expected 'slug$ep=N$sub=N$dub=N'".to_string()
));
}
let slug = parts[0];
let mut ep_num: i32 = 1;
let mut sub_flag: u8 = 1;
let mut dub_flag: u8 = 0;
for part in &parts[1..] {
if let Some(n) = part.strip_prefix("ep=") {
ep_num = n.parse().unwrap_or(1);
} else if let Some(s) = part.strip_prefix("sub=") {
sub_flag = s.parse().unwrap_or(1);
} else if let Some(d) = part.strip_prefix("dub=") {
dub_flag = d.parse().unwrap_or(0);
}
}
let ep_url = format!("{}/{}-episode-{}", BASE_URL, slug, ep_num);
let html_result = Self::fetch_url(&ep_url);
let html = match html_result {
Ok(h) => h,
Err(e) => return Err(PluginError::Network(format!("Failed to fetch episode page: {}", e))),
};
let mut servers = Vec::new();
let mut seen_urls = std::collections::HashSet::new();
// Strategy 1: Parse div.play-video iframe for the default server
let default_iframe = extract_in_block(&html, "class=\"play-video\"", "</div>")
.or_else(|| extract_in_block(&html, "class=\"play_video\"", "</div>"))
.and_then(|block| {
extract_in_block(&block, "src=\"", "\"")
.or_else(|| extract_in_block(&block, "data-src=\"", "\""))
});
if let Some(ref iframe_src) = default_iframe {
let src = normalize_url(iframe_src);
seen_urls.insert(src.clone());
let server_type = classify_server_type(&src, sub_flag, dub_flag);
servers.push(Server {
id: format!("{}-ep{}-default", slug, ep_num),
label: format!("{} Server 1", server_type),
url: src,
priority: 1,
extra: vec![
Attr { key: "type".to_string(), value: server_type.to_lowercase() },
Attr { key: "slug".to_string(), value: slug.to_string() },
Attr { key: "ep".to_string(), value: ep_num.to_string() },
],
});
}
// Strategy 2: Parse .anime_muti_link for all server links
// <div class="anime_muti_link"> contains <a href="#" data-video="..." class="server-video">
// Each link also has a .name_type[data-type] for HSUB/SUB/DUB
if let Some(mutli_start) = html.find("anime_muti_link") {
// Take a large block — 20KB should cover all servers
let block_end = mutli_start + 20000;
let block = &html[mutli_start..block_end.min(html.len())];
// Parse each server link
let server_pattern = "data-video=\"";
let mut offset = 0;
let mut priority: u8 = 1;
while let Some(idx) = block[offset..].find(server_pattern) {
let val_start = offset + idx + server_pattern.len();
let val_end = block[val_start..].find('"').unwrap_or(0);
if val_end == 0 {
offset = val_start + 1;
continue;
}
let video_url_raw = &block[val_start..val_start + val_end];
let video_url = normalize_url(video_url_raw);
// Skip duplicate URLs
if seen_urls.contains(&video_url) {
offset = val_start + val_end + 1;
continue;
}
seen_urls.insert(video_url.clone());
// Determine server type from surrounding context
// Look backwards for name_type data-type
let context_start = if offset > 200 { offset - 200 } else { 0 };
let context = &block[context_start..val_start + val_end];
let server_type = extract_in_block(context, "data-type=\"", "\"")
.map(|t| t.to_uppercase())
.unwrap_or_else(|| classify_server_type(&video_url, sub_flag, dub_flag).to_string());
servers.push(Server {
id: format!("{}-ep{}-s{}", slug, ep_num, priority),
label: format!("{} Server {}", server_type, priority),
url: video_url,
priority: priority + 1,
extra: vec![
Attr { key: "type".to_string(), value: server_type.to_lowercase() },
Attr { key: "slug".to_string(), value: slug.to_string() },
Attr { key: "ep".to_string(), value: ep_num.to_string() },
],
});
priority += 1;
offset = val_start + val_end + 1;
}
}
// Strategy 3: Broader iframe extraction — find ANY <iframe> tag in the page
// This handles pages where the video container has a different class name.
if servers.is_empty() {
let iframe_pattern = "<iframe";
let mut offset = 0;
while let Some(idx) = html[offset..].find(iframe_pattern) {
let tag_start = offset + idx;
// Find the end of the iframe tag
let tag_end = html[tag_start..].find('>').unwrap_or(html.len() - tag_start) + tag_start;
let iframe_tag = &html[tag_start..tag_end.min(html.len())];
// Try src and data-src attributes
let iframe_src = extract_attr_value(iframe_tag, "src")
.or_else(|| extract_attr_value(iframe_tag, "data-src"));
if let Some(raw_src) = iframe_src {
let src = normalize_url(&raw_src);
// Only include URLs that look like video embeds
if is_known_embed_domain(&src) && !seen_urls.contains(&src) {
seen_urls.insert(src.clone());
let server_type = classify_server_type(&src, sub_flag, dub_flag);
servers.push(Server {
id: format!("{}-ep{}-iframe", slug, ep_num),
label: format!("{} Server 1", server_type),
url: src,
priority: 1,
extra: vec![
Attr { key: "type".to_string(), value: server_type.to_lowercase() },
Attr { key: "slug".to_string(), value: slug.to_string() },
Attr { key: "ep".to_string(), value: ep_num.to_string() },
],
});
}
}
offset = tag_end + 1;
}
}
// Strategy 4: Last resort — look for any URL in the page that contains
// known embed domains (vibeplayer, streamani, goload, gogoplay, etc.)
if servers.is_empty() {
let known_domains = [
"vibeplayer", "streamani", "goload", "gogoplay",
"playgo", "anihdplay", "gogohd", "streamta",
"vidcdn", "sbplay", "dood", "mixdrop",
];
for domain in &known_domains {
if let Some(idx) = html.find(domain) {
// Walk backwards to find the start of the URL
let before = &html[..idx];
let url_start = before.rfind("http").or_else(|| before.rfind("//"))
.unwrap_or(idx.saturating_sub(10));
// Walk forward to find the end of the URL
let after = &html[idx..];
let url_end_offset = after.find(|c: char| c == '"' || c == '\'' || c == ' ' || c == '\n' || c == '<' || c == ')')
.unwrap_or(after.len().min(300));
let raw_url = &html[url_start..idx + url_end_offset];
let src = normalize_url(raw_url);
if !seen_urls.contains(&src) && src.starts_with("http") {
seen_urls.insert(src.clone());
let server_type = classify_server_type(&src, sub_flag, dub_flag);
servers.push(Server {
id: format!("{}-ep{}-fallback", slug, ep_num),
label: format!("{} Server 1", server_type),
url: src,
priority: 1,
extra: vec![
Attr { key: "type".to_string(), value: server_type.to_lowercase() },
Attr { key: "slug".to_string(), value: slug.to_string() },
Attr { key: "ep".to_string(), value: ep_num.to_string() },
],
});
}
break; // Found one, that's enough for fallback
}
}
}
if servers.is_empty() {
return Err(PluginError::NotFound);
}
Ok(servers)
}
fn resolve_stream(_ctx: RequestContext, server: Server) -> Result<StreamSource, PluginError> {
let server_url = &server.url;
// Check if the URL is already a direct m3u8/mp4 URL
if server_url.contains(".m3u8") {
return Self::build_stream_source(&server, server_url, vec![]);
}
// The server URL should be an embed/iframe URL (e.g., VibePlayer or similar)
// Extract the stream URL from the embed page
// Detect VibePlayer or similar embed providers
if server_url.contains("vibeplayer") || server_url.contains("streamani") || server_url.contains("goload") || server_url.contains("gogoplay") {
// Fetch the embed page and parse the JS for m3u8 URL
let (m3u8_url, subtitles) = Self::extract_vibeplayer_stream(server_url)?;
return Ok(StreamSource {
id: format!("stream-{}", server.id),
label: server.label.clone(),
format: StreamFormat::Hls,
manifest_url: Some(m3u8_url.clone()),
videos: vec![VideoTrack {
resolution: VideoResolution {
width: 1920,
height: 1080,
hdr: false,
label: "Auto (HLS)".to_string(),
},
url: m3u8_url,
mime_type: Some("application/vnd.apple.mpegurl".to_string()),
bitrate: None,
codecs: None,
}],
subtitles,
headers: vec![
Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() },
Attr { key: "Referer".to_string(), value: server_url.clone() },
],
extra: vec![],
});
}
// Generic embed handler: fetch the page and look for iframe or m3u8
let html = Self::fetch_url_with_headers(server_url, vec![
Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() },
Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) },
])?;
// Try to find an iframe src within the page
if let Some(iframe_src) = extract_in_block(&html, "iframe src=\"", "\"")
.or_else(|| extract_in_block(&html, "<iframe src=\"", "\""))
{
let iframe_url = if iframe_src.starts_with("//") {
format!("https:{}", iframe_src)
} else if iframe_src.starts_with("/") {
format!("{}{}", BASE_URL, iframe_src)
} else {
iframe_src
};
if iframe_url.contains("vibeplayer") || iframe_url.contains("streamani") ||
iframe_url.contains("goload") || iframe_url.contains("gogoplay") {
let (m3u8_url, subtitles) = Self::extract_vibeplayer_stream(&iframe_url)?;
return Ok(StreamSource {
id: format!("stream-{}", server.id),
label: server.label.clone(),
format: StreamFormat::Hls,
manifest_url: Some(m3u8_url.clone()),
videos: vec![VideoTrack {
resolution: VideoResolution {
width: 1920,
height: 1080,
hdr: false,
label: "Auto (HLS)".to_string(),
},
url: m3u8_url,
mime_type: Some("application/vnd.apple.mpegurl".to_string()),
bitrate: None,
codecs: None,
}],
subtitles,
headers: vec![
Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() },
Attr { key: "Referer".to_string(), value: iframe_url },
],
extra: vec![],
});
}
}
// Try to find m3u8 directly in the page JS
if let Some(m3u8) = extract_m3u8_from_js(&html) {
return Ok(StreamSource {
id: format!("stream-{}", server.id),
label: server.label.clone(),
format: StreamFormat::Hls,
manifest_url: Some(m3u8.clone()),
videos: vec![VideoTrack {
resolution: VideoResolution {
width: 1920,
height: 1080,
hdr: false,
label: "Auto (HLS)".to_string(),
},
url: m3u8,
mime_type: Some("application/vnd.apple.mpegurl".to_string()),
bitrate: None,
codecs: None,
}],
subtitles: vec![],
headers: vec![
Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() },
Attr { key: "Referer".to_string(), value: server_url.clone() },
],
extra: vec![],
});
}
Err(PluginError::NotFound)
}
fn search_subtitles(_ctx: RequestContext, _query: SubtitleQuery) -> Result<Vec<SubtitleEntry>, PluginError> {
Err(PluginError::Unsupported)
}
fn download_subtitle(_ctx: RequestContext, _id: String) -> Result<SubtitleFile, PluginError> {
Err(PluginError::Unsupported)
}
fn get_articles(_ctx: RequestContext) -> Result<Vec<ArticleSection>, PluginError> {
Err(PluginError::Unsupported)
}
fn search_articles(_ctx: RequestContext, _query: String) -> Result<Vec<Article>, PluginError> {
Err(PluginError::Unsupported)
}
}
// ============================================================================
// Stream source builder (for direct URLs)
// ============================================================================
impl Component {
fn build_stream_source(server: &Server, stream_url: &str, subtitles: Vec<SubtitleTrack>) -> Result<StreamSource, PluginError> {
let is_hls = stream_url.contains(".m3u8") || stream_url.contains("m3u8");
let format = if is_hls { StreamFormat::Hls } else { StreamFormat::Progressive };
let manifest_url = if is_hls { Some(stream_url.to_string()) } else { None };
Ok(StreamSource {
id: format!("stream-{}", server.id),
label: server.label.clone(),
format,
manifest_url,
videos: vec![VideoTrack {
resolution: VideoResolution {
width: 1920,
height: 1080,
hdr: false,
label: "Auto (HLS)".to_string(),
},
url: stream_url.to_string(),
mime_type: if is_hls {
Some("application/vnd.apple.mpegurl".to_string())
} else {
Some("video/mp4".to_string())
},
bitrate: None,
codecs: None,
}],
subtitles,
headers: vec![
Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() },
Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) },
],
extra: vec![],
})
}
}
// ============================================================================
// Utility functions
// ============================================================================
fn extract_in_block(block: &str, prefix: &str, suffix: &str) -> Option<String> {
let start = block.find(prefix)? + prefix.len();
let end = block[start..].find(suffix)? + start;
Some(block[start..end].to_string())
}
/// Normalize a URL: fix protocol-relative URLs and absolute-path URLs.
fn normalize_url(raw: &str) -> String {
if raw.starts_with("//") {
format!("https:{}", raw)
} else if raw.starts_with("/") {
format!("{}{}", BASE_URL, raw)
} else {
raw.to_string()
}
}
/// Classify the server type (HSUB/SUB/DUB) from the URL and flags.
fn classify_server_type(url: &str, sub_flag: u8, dub_flag: u8) -> &'static str {
if url.contains("/dub/") || dub_flag == 1 {
"DUB"
} else if url.contains("/sub/") || sub_flag == 1 {
"SUB"
} else {
"HSUB"
}
}
/// Check if a URL belongs to a known video embed domain.
fn is_known_embed_domain(url: &str) -> bool {
let domains = [
"vibeplayer", "streamani", "goload", "gogoplay",
"playgo", "anihdplay", "gogohd", "streamta",
"vidcdn", "sbplay", "dood", "mixdrop",
"streamlare", "filemoon", "voe", "mp4upload",
];
domains.iter().any(|d| url.contains(d))
}
fn clean_html(s: &str) -> String {
let mut result = String::new();
let mut in_tag = false;
for c in s.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(c),
_ => {}
}
}
result = result.replace("&amp;", "&");
result = result.replace("&lt;", "<");
result = result.replace("&gt;", ">");
result = result.replace("&quot;", "\"");
result = result.replace("&#39;", "'");
result = result.replace("&apos;", "'");
result = result.replace("&nbsp;", " ");
result.trim().to_string()
}
fn make_image_set(url: &str, layout: ImageLayout) -> ImageSet {
let img = Image {
url: url.to_string(),
layout,
width: None,
height: None,
blurhash: None,
};
ImageSet {
low: Some(img.clone()),
medium: Some(img.clone()),
high: Some(img),
backdrop: None,
logo: None,
}
}
/// Extract link text from a GogoAnime card block — finds the .name a text
fn extract_gogo_link_text(block: &str) -> Option<String> {
// Try to find text within a link that contains /category/
if let Some(cat_idx) = block.find("/category/") {
// Find the <a tag that contains this
let a_search_start = if cat_idx > 200 { cat_idx - 200 } else { 0 };
let a_search_area = &block[a_search_start..cat_idx + 200];
if let Some(a_idx) = a_search_area.rfind("<a ") {
let a_end_tag = a_search_area[a_idx..].find('>')? + a_idx;
let close_a = a_search_area[a_end_tag..].find("</a>")?;
let text = &a_search_area[a_end_tag + 1..a_end_tag + close_a];
return Some(clean_html(text));
}
}
None
}
/// Parse episode number from the episode name text or href
fn parse_ep_number(name: &str, href: &str) -> f64 {
let cleaned = clean_html(name);
// Try to parse from "EP 1", "EP 12", "Ep.5", etc.
let lower = cleaned.to_lowercase();
if let Some(ep_idx) = lower.find("ep") {
let after = &lower[ep_idx + 2..].trim_start_matches('.').trim_start_matches(' ');
let num_str: String = after.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
if let Ok(n) = num_str.parse::<f64>() {
return n;
}
}
// Try to parse from href like "/slug-episode-1" or "?ep=5"
if let Some(ep_idx) = href.find("-episode-") {
let after = &href[ep_idx + 9..];
let num_str: String = after.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
if let Ok(n) = num_str.parse::<f64>() {
return n;
}
}
if let Some(ep_idx) = href.find("ep=") {
let after = &href[ep_idx + 3..];
let num_str: String = after.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
if let Ok(n) = num_str.parse::<f64>() {
return n;
}
}
// Try to find any standalone number in the name
let words: Vec<&str> = cleaned.split_whitespace().collect();
for word in words.iter().rev() {
if let Ok(n) = word.parse::<f64>() {
return n;
}
}
0.0
}
/// Extract genres from GogoAnime info page
fn extract_gogo_genres(html: &str) -> Vec<String> {
let mut genres = Vec::new();
// GogoAnime puts genres in p.type sections with Genre: label
// <p class="type"><span class="light">Genre:</span> <a href="...">Action</a>, <a>Adventure</a></p>
let pattern = "class=\"type\"";
let mut offset = 0;
while let Some(idx) = html[offset..].find(pattern) {
let block_end = html[offset + idx..].find("</p>").unwrap_or(500).min(500);
let block = &html[offset + idx..offset + idx + block_end];
if block.contains("Genre") {
// Extract all <a> text within this block
let a_pattern = "<a";
let mut a_offset = 0;
while let Some(a_idx) = block[a_offset..].find(a_pattern) {
let a_tag_start = a_offset + a_idx;
if let Some(gt) = block[a_tag_start..].find('>') {
let text_start = a_tag_start + gt + 1;
if let Some(close) = block[text_start..].find("</a>") {
let text = &block[text_start..text_start + close];
let cleaned = clean_html(text);
if !cleaned.is_empty() {
genres.push(cleaned);
}
a_offset = text_start + close + 1;
continue;
}
}
break;
}
break; // Found the genre block, no need to continue
}
offset = offset + idx + 1;
}
genres
}
/// Extract m3u8 URL from JavaScript code in an embed page.
/// Looks for patterns like:
/// - const src = "https://...m3u8"
/// - var src = "https://...m3u8"
/// - file:"https://...m3u8"
/// - source: "https://...m3u8"
/// - Any .m3u8 URL in the page
fn extract_m3u8_from_js(html: &str) -> Option<String> {
// Strategy 1: Look for const/var/let src = "..." with .m3u8
for pattern in &[
"const src = \"",
"const src=\"",
"var src = \"",
"var src=\"",
"let src = \"",
"let src=\"",
] {
if let Some(idx) = html.find(pattern) {
let val_start = idx + pattern.len();
if let Some(end) = html[val_start..].find('"') {
let url = &html[val_start..val_start + end];
if url.contains(".m3u8") {
return Some(url.to_string());
}
}
}
}
// Strategy 2: Look for file:"..." or file: "..." with .m3u8
for pattern in &[
"file:\"",
"file: \"",
"source:\"",
"source: \"",
"src:\"",
"src: \"",
] {
if let Some(idx) = html.find(pattern) {
let val_start = idx + pattern.len();
if let Some(end) = html[val_start..].find('"') {
let url = &html[val_start..val_start + end];
if url.contains(".m3u8") {
return Some(url.to_string());
}
}
}
}
// Strategy 3: Look for any http URL ending in .m3u8
let m3u8_pattern = ".m3u8";
if let Some(idx) = html.find(m3u8_pattern) {
// Walk backwards to find the start of the URL
let before = &html[..idx + m3u8_pattern.len()];
let url_start = before.rfind("http").or_else(|| before.rfind("https"))?;
// Walk forward from the .m3u8 to find the end
let after = &html[idx + m3u8_pattern.len()..];
let url_end_offset = after.find(|c: char| c == '"' || c == '\'' || c == ' ' || c == '\n' || c == '<')
.unwrap_or(after.len());
let url = &html[url_start..idx + m3u8_pattern.len() + url_end_offset];
if url.starts_with("http") && url.contains(".m3u8") {
return Some(url.to_string());
}
}
None
}
bindings::export!(Component with_types_in bindings);