krystv's picture
Upload 107 files
3374e90 verified
/*!
HiAnime streaming plugin for hianime.ws
Reverse-engineered from the site's internal AJAX API.
Features:
- Search anime with HTML scraping + AJAX suggestions
- Fetch detailed information with episodes, score, description
- Extract HLS video sources from multiple servers (Sub/Dub)
- Token-based AJAX authentication via window.__$ extraction
- Full HLS (.m3u8) stream support with subtitle tracks
*/
#[allow(warnings)]
mod bindings;
use bindings::bex::plugin::common::*;
use bindings::bex::plugin::http;
use bindings::exports::api::Guest;
use serde_json::Value;
struct Component;
const BASE_URL: &str = "https://hianime.ws";
// ============================================================================
// 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() }
}
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)
}
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)
}
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 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
// ============================================================================
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, */*; q=0.01".to_string() },
Attr { key: "Accept-Language".to_string(), value: "en-US,en;q=0.5".to_string() },
Attr { key: "Sec-GPC".to_string(), value: "1".to_string() },
Attr { key: "Sec-Fetch-Dest".to_string(), value: "empty".to_string() },
Attr { key: "Sec-Fetch-Mode".to_string(), value: "cors".to_string() },
Attr { key: "Sec-Fetch-Site".to_string(), value: "same-origin".to_string() },
Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) },
]
}
fn get_ajax_headers() -> Vec<Attr> {
let mut h = Self::get_headers();
h.push(Attr { key: "X-Requested-With".to_string(), value: "XMLHttpRequest".to_string() });
h.push(Attr { key: "Accept".to_string(), value: "application/json, text/javascript, */*; q=0.01".to_string() });
h
}
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),
}
}
fn fetch_json(url: &str, extra_headers: Vec<Attr>) -> Result<Value, PluginError> {
let body = Self::fetch_url_with_headers(url, extra_headers)?;
serde_json::from_str(&body).map_err(|e| PluginError::Parse(format!("JSON error: {}", e)))
}
/// Extract the encryption key `window.__$` from the page HTML
fn extract_session_key(html: &str) -> Option<String> {
// Pattern: window.__$='ENCRYPTION_KEY'
let pattern = "window.__$='";
let idx = html.find(pattern)?;
let start = idx + pattern.len();
let end = html[start..].find('\'')?;
let key = &html[start..start + end];
if key.len() > 10 {
Some(key.to_string())
} else {
None
}
}
// ========================================================================
// Scrape anime cards from search/browse pages
// ========================================================================
fn scrape_anime_cards(html: &str) -> Vec<MediaCard> {
let mut results = Vec::new();
// HiAnime uses href="/watch/{slug}-{id}" format
let watch_pattern = "href=\"/watch/";
let mut offset = 0;
let mut seen_ids = std::collections::HashSet::new();
while let Some(idx) = html[offset..].find(watch_pattern) {
let abs_idx = offset + idx;
let val_start = abs_idx + watch_pattern.len();
let after = &html[val_start..];
let id_end = after.find('"').unwrap_or(0);
if id_end == 0 {
offset = val_start + 1;
continue;
}
let anime_id = &after[..id_end];
if anime_id.is_empty() || seen_ids.contains(anime_id) {
offset = val_start + 1;
continue;
}
seen_ids.insert(anime_id.to_string());
// Extract the surrounding block for title and image
let block_start = html[..abs_idx].rfind("class=\"flw-item\"")
.or_else(|| html[..abs_idx].rfind("class=\"film-poster\""))
.or_else(|| html[..abs_idx].rfind("<div class=\""))
.unwrap_or(abs_idx);
let block_end = html[abs_idx..].find("class=\"flw-item\"")
.or_else(|| html[abs_idx..].find("class=\"film-poster\""))
.map(|i| abs_idx + i)
.unwrap_or(html.len().min(abs_idx + 3000));
let block = &html[block_start..block_end.min(html.len())];
// Title
let title = extract_in_block(block, "title=\"", "\"")
.or_else(|| extract_in_block(block, "class=\"film-name\">", "</a>"))
.or_else(|| extract_in_block(block, "class=\"dynamic-name\">", "</a>"))
.unwrap_or_else(|| anime_id.replace('-', " "));
// Image
let image = extract_in_block(block, "data-src=\"", "\"")
.or_else(|| extract_in_block(block, "src=\"", "\""))
.unwrap_or_default();
// Score/rating
let score = extract_in_block(block, "class=\"tick-item tick-rating\">", "</span>")
.or_else(|| extract_in_block(block, "class=\"scd-item score\">", "</span>"))
.and_then(|s| s.trim().parse::<f64>().ok())
.map(|s| (s * 10.0) as u32);
// Type (TV, Movie, etc.)
let _type_str = extract_in_block(block, "class=\"tick-item tick-type\">", "</span>")
.or_else(|| extract_in_block(block, "class=\"fdi-item\">", "</span>"))
.map(|s| s.trim().to_string())
.unwrap_or_default();
results.push(MediaCard {
id: anime_id.to_string(),
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: None,
score,
genres: vec![],
status: None,
content_rating: None,
url: Some(format!("{}/watch/{}", BASE_URL, anime_id)),
ids: vec![],
extra: vec![],
});
offset = val_start + 1;
}
results
}
}
// ============================================================================
// Guest implementation
// ============================================================================
impl Guest for Component {
fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> {
let mut sections = Vec::new();
// Category links
let categories = vec![
("Most Popular", "most-popular"),
("Top Airing", "top-airing"),
("Most Favorite", "most-favorite"),
("Completed", "completed"),
("Recently Updated", "recently-updated"),
("Recently Added", "recently-added"),
("Top Upcoming", "top-upcoming"),
("Subbed Anime", "subbed-anime"),
("Dubbed Anime", "dubbed-anime"),
("Movie", "movie"),
("TV", "tv"),
("OVA", "ova"),
("ONA", "ona"),
("Special", "special"),
];
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: categories
.into_iter()
.map(|(title, id)| CategoryLink {
id: id.to_string(),
title: title.to_string(),
subtitle: None,
image: None,
})
.collect(),
extra: vec![],
});
// Fetch home page for trending/featured
let url = format!("{}/home", BASE_URL);
if let Ok(html) = Self::fetch_url(&url) {
let items = Self::scrape_anime_cards(&html);
if !items.is_empty() {
sections.push(HomeSection {
id: "trending".to_string(),
title: "Trending".to_string(),
subtitle: None,
items: items.into_iter().take(20).collect(),
next_page: None,
layout: CardLayout::Grid,
show_rank: true,
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.parse().ok())
.unwrap_or(1);
let url = format!("{}/{}?page={}", BASE_URL, id, page_num);
let html = Self::fetch_url(&url)?;
let items = Self::scrape_anime_cards(&html);
let next_page = if items.len() >= 20 {
Some((page_num + 1).to_string())
} 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!("{}/browser?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!("{}/watch/{}", BASE_URL, id);
let html = Self::fetch_url(&url)?;
let dom = Dom::parse(&html);
// Title
let title = dom.find_attr("h1", "data-jp")
.or_else(|| dom.find_class_text("h1", "title"))
.or_else(|| dom.find_class_text("h2", "film-name"))
.or_else(|| extract_in_block(&html, "class=\"anisc-detail\">", "</h1>"))
.filter(|t| !t.is_empty())
.unwrap_or_else(|| id.clone());
// Image / poster
let image = extract_in_block(&html, "class=\"film-poster\"", "class=\"film-poster-img\"")
.and_then(|block| extract_in_block(&block, "src=\"", "\""))
.or_else(|| extract_in_block(&html, "class=\"anisc-poster\"", "src=\"").and_then(|_| extract_in_block(&html, "src=\"", "\"")))
.or_else(|| extract_in_block(&html, "property=\"og:image\" content=\"", "\""))
.unwrap_or_default();
// Description
let description = extract_in_block(&html, "class=\"film-description\"", "</div>")
.or_else(|| extract_in_block(&html, "class=\"anisc-info\"", "</div>"))
.or_else(|| extract_in_block(&html, "property=\"og:description\" content=\"", "\""))
.map(|s| clean_html(&s))
.filter(|t| !t.is_empty());
// Score
let score = extract_in_block(&html, "class=\"scd-item score\"", "</span>")
.or_else(|| extract_in_block(&html, "class=\"film-stats\"", "</div>").and_then(|b| extract_in_block(&b, "tick-rating\">", "</")))
.or_else(|| dom.find_attr_by_id("vote-info", "data-score"))
.and_then(|s| s.trim().parse::<f64>().ok())
.map(|s| (s * 10.0) as u32);
// Genres
let genres = extract_genres(&html);
// Status
let status = extract_in_block(&html, ">Status</span>", "</span>")
.map(|s| clean_html(&s).trim().to_lowercase())
.and_then(|s| match s.as_str() {
"ongoing" | "currently airing" => Some(Status::Ongoing),
"completed" | "finished airing" => Some(Status::Completed),
"upcoming" | "not yet aired" => Some(Status::Upcoming),
_ => None,
});
// Studio
let studio = extract_in_block(&html, ">Studios</span>", "</span>")
.or_else(|| extract_in_block(&html, ">Studios:</span>", "</a>"))
.map(|s| clean_html(&s));
// Type (TV, Movie, etc.)
let _type_str = extract_in_block(&html, ">Type</span>", "</span>")
.map(|s| clean_html(&s).trim().to_string())
.unwrap_or_default();
// Year
let year = extract_in_block(&html, ">Premiered</span>", "</span>")
.or_else(|| extract_in_block(&html, ">Year</span>", "</span>"))
.map(|s| clean_html(&s).trim().to_string());
// MAL ID
let mal_id = dom.find_attr_by_id("watch-page", "data-mal-id")
.or_else(|| extract_in_block(&html, "data-mal-id=\"", "\""));
// Episodes — fetch via AJAX
// HiAnime loads episodes via AJAX: /ajax/episodes/list/{anime_id}
let episodes = Self::fetch_episodes(&id, &html).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,
}]
};
let mut ids = vec![];
if let Some(ref mid) = mal_id {
ids.push(LinkedId { source: "mal".to_string(), id: mid.clone() });
}
Ok(MediaInfo {
id: id.clone(),
title: clean_html(&title),
kind: MediaKind::Anime,
images: if image.is_empty() {
None
} else {
Some(make_image_set(&image, ImageLayout::Portrait))
},
original_title: None,
description,
score,
scored_by: None,
year,
release_date: None,
genres,
tags: vec![],
status,
content_rating: None,
seasons,
cast: vec![],
crew: vec![],
runtime_minutes: None,
trailer_url: None,
ids,
studio,
country: None,
language: None,
url: Some(format!("{}/watch/{}", BASE_URL, id)),
extra: vec![],
})
}
fn get_servers(_ctx: RequestContext, id: String) -> Result<Vec<Server>, PluginError> {
// The episode ID format: "anime_id?ep=NUM"
// The id parameter already contains the full episode ID.
let ep_id = id.clone();
// Fetch the watch page to get the session key and episode server list
let anime_id = id.split("?ep=").next().unwrap_or(&id);
let watch_url = format!("{}/watch/{}", BASE_URL, anime_id);
let html = Self::fetch_url(&watch_url)?;
let _session_key = Self::extract_session_key(&html);
// HiAnime AJAX endpoint for servers: /ajax/episodes/servers?episodeId={ep_id}
let encoded_ep = ep_id.replace("?", "%3F").replace("=", "%3D");
let servers_url = format!("{}/ajax/episodes/servers?episodeId={}", BASE_URL, encoded_ep);
let json = Self::fetch_json(&servers_url, Self::get_ajax_headers())?;
let html_content = json
.get("html")
.and_then(|v| v.as_str())
.ok_or(PluginError::Parse("No html in servers response".to_string()))?;
let dom = Dom::parse(html_content);
let mut servers = Vec::new();
// Parse server groups: sub, dub
let groups = vec![("sub", "Sub"), ("dub", "Dub")];
for (data_id, label) in groups {
let pattern = format!("data-type=\"{}\"", data_id);
if let Some(group_start) = dom.html.find(&pattern) {
let group_end = dom.html[group_start..].find("</div>").unwrap_or(dom.html.len() - group_start) + group_start;
let block = &dom.html[group_start..group_end.min(dom.html.len())];
let server_pattern = "data-server-id=\"";
let mut offset = 0;
let mut priority: u8 = 1;
while let Some(idx) = block[offset..].find(server_pattern) {
let sid_start = offset + idx + server_pattern.len();
if let Some(sid_end) = block[sid_start..].find('"') {
let server_id = &block[sid_start..sid_start + sid_end];
// Also extract server name from the text content
let name_pattern = "class=\"server\">";
let server_name = if let Some(nidx) = block[sid_start..].find(name_pattern) {
let name_start = sid_start + nidx + name_pattern.len();
if let Some(name_end) = block[name_start..].find('<') {
block[name_start..name_start + name_end].trim().to_string()
} else {
format!("{} {}", label, priority)
}
} else {
format!("{} {}", label, priority)
};
servers.push(Server {
id: format!("{}|{}|{}", ep_id, server_id, data_id),
label: server_name,
url: format!("{}/ajax/episodes/servers?episodeId={}", BASE_URL, encoded_ep),
priority,
extra: vec![Attr { key: "type".to_string(), value: data_id.to_string() }],
});
priority += 1;
}
offset = sid_start + 1;
}
}
}
if servers.is_empty() {
// Fallback: create a default server
servers.push(Server {
id: format!("{}|hd-1|sub", ep_id),
label: "Sub HD-1".to_string(),
url: format!("{}/ajax/episodes/servers?episodeId={}", BASE_URL, encoded_ep),
priority: 1,
extra: vec![Attr { key: "type".to_string(), value: "sub".to_string() }],
});
}
Ok(servers)
}
fn resolve_stream(_ctx: RequestContext, server: Server) -> Result<StreamSource, PluginError> {
// Parse server ID: "episode_id|server_id|type"
let parts: Vec<&str> = server.id.split('|').collect();
if parts.len() < 3 {
return Err(PluginError::InvalidInput("Invalid server ID format".to_string()));
}
let episode_id = parts[0];
let server_id = parts[1];
let server_type = parts[2]; // "sub" or "dub"
// Fetch the watch page to get session key
let anime_id = episode_id.split("?ep=").next().unwrap_or(episode_id);
let watch_url = format!("{}/watch/{}", BASE_URL, anime_id);
let html = Self::fetch_url(&watch_url)?;
let session_key = Self::extract_session_key(&html)
.ok_or_else(|| PluginError::Parse("Failed to extract session key from page".to_string()))?;
// HiAnime AJAX endpoint for stream sources
// /ajax/episodes/sources?episodeId={ep_id}&server={server_id}&type={type}
let encoded_ep = episode_id.replace("?", "%3F").replace("=", "%3D");
let stream_url = format!(
"{}/ajax/episodes/sources?episodeId={}&server={}&type={}",
BASE_URL, encoded_ep, server_id, server_type
);
let mut ajax_headers = Self::get_ajax_headers();
// Add session key as cookie-like header
ajax_headers.push(Attr { key: "X-CSRF-Token".to_string(), value: session_key.clone() });
let json = Self::fetch_json(&stream_url, ajax_headers)?;
// Parse streaming links
let sources = json
.get("link")
.or_else(|| json.get("sources"))
.or_else(|| json.get("streamingLink"))
.or_else(|| json.get("result"));
let mut videos = Vec::new();
let mut subtitles = Vec::new();
if let Some(sources_val) = sources {
// Case 1: Direct m3u8 URL
if let Some(file_url) = sources_val.get("file").and_then(|v| v.as_str()) {
let is_hls = file_url.contains(".m3u8");
videos.push(VideoTrack {
resolution: VideoResolution {
width: 1920,
height: 1080,
hdr: false,
label: "Auto (HLS)".to_string(),
},
url: file_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,
});
}
// Case 2: Array of sources
if let Some(sources_arr) = sources_val.as_array() {
for source in sources_arr {
if let Some(link_obj) = source.get("link") {
if let Some(file_url) = link_obj.get("file").and_then(|v| v.as_str()) {
let is_hls = file_url.contains(".m3u8");
let server_name = source.get("server").and_then(|v| v.as_str()).unwrap_or("default");
let label = if is_hls { format!("HLS ({})", server_name) } else { format!("MP4 ({})", server_name) };
videos.push(VideoTrack {
resolution: VideoResolution { width: 1920, height: 1080, hdr: false, label },
url: file_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,
});
// Extract tracks/subtitles from this source
if let Some(tracks) = source.get("tracks").and_then(|v| v.as_array()) {
for track in tracks {
let track_file = track.get("file").and_then(|v| v.as_str()).unwrap_or("");
let track_label = track.get("label").and_then(|v| v.as_str()).unwrap_or("Unknown");
let track_kind = track.get("kind").and_then(|v| v.as_str()).unwrap_or("captions");
if !track_file.is_empty() && track_kind == "captions" {
subtitles.push(SubtitleTrack {
label: track_label.to_string(),
url: track_file.to_string(),
language: Some(track_label.to_string()),
format: Some("vtt".to_string()),
});
}
}
}
}
}
}
}
// Case 3: Object with "file" key
if let Some(file_url) = sources_val.get("file").and_then(|v| v.as_str()) {
if !file_url.is_empty() && videos.is_empty() {
let is_hls = file_url.contains(".m3u8");
videos.push(VideoTrack {
resolution: VideoResolution { width: 1920, height: 1080, hdr: false, label: "Auto (HLS)".to_string() },
url: file_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,
});
}
}
}
// Fallback: try to find any m3u8 URL in the JSON response
if videos.is_empty() {
let json_str = json.to_string();
if let Some(idx) = json_str.find("http") {
let rest = &json_str[idx..];
let end = rest.find('"').unwrap_or(rest.len());
let url = &rest[..end];
if url.contains(".m3u8") || url.contains("m3u8") {
videos.push(VideoTrack {
resolution: VideoResolution { width: 1920, height: 1080, hdr: false, label: "Auto (HLS)".to_string() },
url: url.to_string(),
mime_type: Some("application/vnd.apple.mpegurl".to_string()),
bitrate: None,
codecs: None,
});
}
}
}
if videos.is_empty() {
return Err(PluginError::NotFound);
}
let is_hls = videos[0].url.contains(".m3u8");
let format = if is_hls { StreamFormat::Hls } else { StreamFormat::Progressive };
let manifest_url = if is_hls { Some(videos[0].url.clone()) } else { None };
Ok(StreamSource {
id: format!("stream-{}", server.id.replace('|', "-")),
label: server.label.clone(),
format,
manifest_url,
videos,
subtitles,
headers: vec![
Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string() },
Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) },
],
extra: vec![],
})
}
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)
}
}
// ============================================================================
// Episode fetching via AJAX
// ============================================================================
impl Component {
fn fetch_episodes(anime_id: &str, page_html: &str) -> Result<Vec<Episode>, PluginError> {
// HiAnime AJAX endpoint: /ajax/episodes/list/{anime_id}
let url = format!("{}/ajax/episodes/list/{}", BASE_URL, anime_id);
let mut headers = Self::get_ajax_headers();
// Add session key if available
if let Some(key) = Self::extract_session_key(page_html) {
headers.push(Attr { key: "X-CSRF-Token".to_string(), value: key });
}
let json = Self::fetch_json(&url, headers)?;
let html_content = json
.get("html")
.and_then(|v| v.as_str())
.ok_or(PluginError::Parse("No html in episodes response".to_string()))?;
let dom = Dom::parse(html_content);
let mut episodes = Vec::new();
// Parse <a data-id="XXXX" data-number="1" title="Episode 1">...</a>
let a_pattern = "<a ";
let mut offset = 0;
while let Some(idx) = dom.html[offset..].find(a_pattern) {
let a_start = offset + idx;
let a_end = dom.html[a_start..].find('>').unwrap_or(dom.html.len() - a_start) + a_start;
let a_tag = &dom.html[a_start..a_end];
let after_a = &dom.html[a_end + 1..];
let ep_id = extract_attr_value(a_tag, "data-id").unwrap_or_default();
let ep_num_str = extract_attr_value(a_tag, "data-number")
.or_else(|| extract_attr_value(a_tag, "num"))
.unwrap_or_default();
let ep_num: f64 = ep_num_str.parse().unwrap_or(0.0);
let ep_title = if let Some(close_a) = after_a.find("</a>") {
let text = after_a[..close_a].trim();
if text.is_empty() {
format!("Episode {}", ep_num as i32)
} else {
clean_html(text)
}
} else {
format!("Episode {}", ep_num as i32)
};
if ep_id.is_empty() {
offset = a_end + 1;
continue;
}
// Episode ID format: anime_id?ep=ep_id
let episode_id = format!("{}?ep={}", anime_id, ep_id);
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(format!("{}/watch/{}?ep={}", BASE_URL, anime_id, ep_id)),
tags: vec![],
extra: vec![],
});
offset = a_end + 1;
}
Ok(episodes)
}
}
// ============================================================================
// 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())
}
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.trim().to_string()
}
fn extract_genres(html: &str) -> Vec<String> {
let mut genres = Vec::new();
if let Some(idx) = html.find("Genres") {
let block_end = html[idx..].find("</div>").unwrap_or(500).min(500);
let block = &html[idx..idx + block_end];
let a_pattern = "<a";
let mut offset = 0;
while let Some(a_idx) = block[offset..].find(a_pattern) {
let abs = offset + a_idx;
if let Some(tag_end) = block[abs..].find('>') {
if let Some(close) = block[abs + tag_end + 1..].find("</a>") {
let text = block[abs + tag_end + 1..abs + tag_end + 1 + close].trim().to_string();
if !text.is_empty() && text.len() < 50 {
genres.push(clean_html(&text));
}
}
}
offset = abs + 1;
}
}
genres
}
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,
}
}
bindings::export!(Component with_types_in bindings);