krystv's picture
Upload 107 files
3374e90 verified
/*!
KaiAnime streaming plugin for anikai.to
Properly reverse-engineered with enc-dec.app for stream URL encryption/decryption.
Features:
- Search anime with HTML scraping
- Fetch detailed information with episodes (score, description, genres, status)
- Extract video sources from multiple servers (Sub/SoftSub/Dub)
- Multi-step encryption via enc-dec.app (enc-kai, dec-kai, dec-mega)
- HLS stream support with MegaUp extractor
*/
#[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://anikai.to";
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36";
const ENC_API: &str = "https://enc-dec.app/api";
struct MegaUp;
impl MegaUp {
fn generate_token(text: &str) -> Result<String, String> {
let url = format!("{}/enc-kai?text={}", ENC_API, text);
let body = Component::fetch_url_with_headers(
&url,
vec![
Attr { key: "User-Agent".to_string(), value: USER_AGENT.to_string() },
Attr { key: "Accept".to_string(), value: "application/json".to_string() },
Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) },
],
)
.map_err(|e| format!("Failed to generate token: {}", e))?;
#[derive(Debug, serde::Deserialize)]
struct TokenResponse {
result: String,
}
let token_response: TokenResponse = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse token response: {}", e))?;
Ok(token_response.result)
}
fn decode_iframe_data(data: &str) -> Result<Value, String> {
let url = format!("{}/dec-kai", ENC_API);
let payload = serde_json::json!({"text": data});
let body = Component::post_json(&url, &payload.to_string())
.ok_or_else(|| "Failed to decode iframe data".to_string())?;
Ok(body.get("result").cloned().unwrap_or(body))
}
fn decode_media_data(data: &str) -> Result<Value, String> {
let url = format!("{}/dec-mega", ENC_API);
let payload = serde_json::json!({
"text": data,
"agent": USER_AGENT,
});
let body = Component::post_json(&url, &payload.to_string())
.ok_or_else(|| "Failed to decode media data".to_string())?;
Ok(body.get("result").cloned().unwrap_or(body))
}
fn extract(url: &str) -> Result<(Vec<VideoTrack>, Vec<SubtitleTrack>), String> {
let media_url = url.replace("/e/", "/media/");
let body = Component::fetch_url_with_headers(
&media_url,
vec![
Attr { key: "Connection".to_string(), value: "keep-alive".to_string() },
Attr { key: "User-Agent".to_string(), value: USER_AGENT.to_string() },
],
)
.map_err(|e| format!("Failed to fetch media URL: {}", e))?;
let media_response: Value = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse media response: {}", e))?;
let result = media_response
.get("result")
.and_then(|v| v.as_str())
.ok_or_else(|| "No result field in media response".to_string())?;
let decrypted = Self::decode_media_data(result)?;
let mut videos = Vec::new();
if let Some(sources) = decrypted.get("sources").and_then(|v| v.as_array()) {
for source in sources {
let file = source.get("file").and_then(|v| v.as_str()).unwrap_or("");
if file.is_empty() {
continue;
}
let is_m3u8 = file.ends_with(".m3u8");
let label = source.get("label").and_then(|v| v.as_str()).unwrap_or(if is_m3u8 { "Auto (HLS)" } else { "720p" });
let (width, height) = parse_resolution_label(label);
videos.push(VideoTrack {
resolution: VideoResolution {
width,
height,
hdr: false,
label: label.to_string(),
},
url: file.to_string(),
mime_type: if is_m3u8 {
Some("application/vnd.apple.mpegurl".to_string())
} else {
Some("video/mp4".to_string())
},
bitrate: None,
codecs: None,
});
}
}
if videos.is_empty() {
let result_str = decrypted.to_string();
if let Some(start) = result_str.find("http") {
let rest = &result_str[start..];
let end = rest.find('"').unwrap_or(rest.len());
let m3u8_url = &rest[..end];
if m3u8_url.contains(".m3u8") || m3u8_url.contains("m3u8") {
videos.push(VideoTrack {
resolution: VideoResolution {
width: 1920,
height: 1080,
hdr: false,
label: "Auto (HLS)".to_string(),
},
url: m3u8_url.to_string(),
mime_type: Some("application/vnd.apple.mpegurl".to_string()),
bitrate: None,
codecs: None,
});
}
}
}
if videos.is_empty() {
return Err("No video sources found".to_string());
}
let mut subtitles = Vec::new();
if let Some(tracks) = decrypted.get("tracks").and_then(|v| v.as_array()) {
for track in tracks {
let file = track.get("file").and_then(|v| v.as_str()).unwrap_or("");
let label = track.get("label").and_then(|v| v.as_str()).unwrap_or("Unknown");
let kind = track.get("kind").and_then(|v| v.as_str()).unwrap_or("captions");
if !file.is_empty() && kind == "captions" {
subtitles.push(SubtitleTrack {
label: label.to_string(),
url: file.to_string(),
language: Some(label.to_string()),
format: Some("vtt".to_string()),
});
}
}
}
Ok((videos, subtitles))
}
}
struct Dom {
html: String,
}
impl Dom {
fn parse(html: &str) -> Self {
Dom { html: html.to_string() }
}
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_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_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
// ============================================================================
#[allow(dead_code)]
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: "Pragma".to_string(), value: "no-cache".to_string() },
Attr { key: "Cache-Control".to_string(), value: "no-cache".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)))
}
fn post_json(url: &str, body_json: &str) -> Option<Value> {
let 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: "Accept".to_string(), value: "application/json".to_string() },
Attr { key: "Content-Type".to_string(), value: "application/json".to_string() },
Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) },
];
let req = http::Request {
method: http::Method::Post,
url: url.to_string(),
headers,
body: Some(body_json.as_bytes().to_vec()),
timeout_ms: Some(10000),
follow_redirects: true,
cache_mode: http::CacheMode::NoStore,
max_bytes: Some(1024 * 1024),
};
let resp = http::send_request(&req).ok()?;
if resp.status < 200 || resp.status >= 300 {
return None;
}
let body = String::from_utf8(resp.body).ok()?;
serde_json::from_str(&body).ok()
}
// ========================================================================
// enc-dec.app API wrappers
// ========================================================================
/// Encrypt text using enc-dec.app (GET /api/enc-kai?text=...)
fn enc_kai(text: &str) -> Option<String> {
let url = format!("{}/enc-kai?text={}", ENC_API, text);
let 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: "Accept".to_string(), value: "application/json".to_string() },
Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) },
];
let req = http::Request {
method: http::Method::Get,
url,
headers,
body: None,
timeout_ms: Some(10000),
follow_redirects: true,
cache_mode: http::CacheMode::NoStore,
max_bytes: Some(1024 * 1024),
};
let resp = http::send_request(&req).ok()?;
if resp.status < 200 || resp.status >= 300 {
return None;
}
let body = String::from_utf8(resp.body).ok()?;
if let Ok(json) = serde_json::from_str::<Value>(&body) {
for field in &["result", "token", "data", "enc", "encrypted", "key"] {
if let Some(val) = json.get(*field).and_then(|v| v.as_str()) {
if !val.is_empty() {
return Some(val.to_string());
}
}
}
if let Some(s) = json.as_str() {
if !s.is_empty() {
return Some(s.to_string());
}
}
}
let trimmed = body.trim().trim_matches('"').to_string();
if !trimmed.is_empty() && !trimmed.starts_with('{') {
return Some(trimmed);
}
None
}
/// Decrypt encrypted data using enc-dec.app (POST /api/dec-kai)
fn dec_kai(encrypted: &str) -> Option<Value> {
let url = format!("{}/dec-kai", ENC_API);
let body = format!("{{\"text\":\"{}\"}}", encrypted.replace('"', "\\\""));
Self::post_json(&url, &body)
}
/// Decrypt MegaUp encrypted data using enc-dec.app (POST /api/dec-mega)
fn dec_mega(encrypted: &str, user_agent: &str) -> Option<Value> {
let url = format!("{}/dec-mega", ENC_API);
let ua_escaped = user_agent.replace('"', "\\\"");
let enc_escaped = encrypted.replace('\\', "\\\\").replace('"', "\\\"");
let body = format!("{{\"text\":\"{}\",\"agent\":\"{}\"}}", enc_escaped, ua_escaped);
Self::post_json(&url, &body)
}
// ========================================================================
// Scrape anime cards from search/browse pages
// ========================================================================
fn scrape_anime_cards(html: &str) -> Vec<MediaCard> {
let mut results = Vec::new();
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());
let block_start = html[..abs_idx].rfind("class=\"aitem\"")
.unwrap_or(abs_idx);
let block_end = html[abs_idx..].find("class=\"aitem\"")
.map(|i| abs_idx + i)
.unwrap_or(html.len().min(abs_idx + 3000));
let block = &html[block_start..block_end.min(html.len())];
let title = if let Some(t) = extract_in_block(block, "class=\"title\" title=\"", "\"") {
t
} else if let Some(t) = extract_in_block(block, "class=\"title\" data-jp=\"", "\"") {
t
} else if let Some(t) = extract_in_block(block, "class=\"title\">", "</a>") {
t
} else {
anime_id.replace('-', " ")
};
let image = extract_in_block(block, "data-src=\"", "\"")
.or_else(|| extract_in_block(block, "src=\"", "\""))
.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: None,
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 genres = vec![
("Action", "genres/action"),
("Adventure", "genres/adventure"),
("Comedy", "genres/comedy"),
("Drama", "genres/drama"),
("Fantasy", "genres/fantasy"),
("Horror", "genres/horror"),
("Isekai", "genres/isekai"),
("Mecha", "genres/mecha"),
("Mystery", "genres/mystery"),
("Romance", "genres/romance"),
("Sci-Fi", "genres/sci-fi"),
("Slice of Life", "genres/slice-of-life"),
("Sports", "genres/sports"),
("Thriller", "genres/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![
("recent", "Recently Added", "/recent"),
("updates", "Recently Updated", "/updates"),
("new-releases", "New Releases", "/new-releases"),
("movies", "Movies", "/movie"),
];
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(12).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("genres/") {
format!("/{}/?page={}", id, page_num)
} else {
match id.as_str() {
"recent" => format!("/recent?page={}", page_num),
"updates" => format!("/updates?page={}", page_num),
"new-releases" => format!("/new-releases?page={}", page_num),
"movies" => format!("/movie?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() >= 20 {
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!("{}/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 — try h1.title, then p.title, fallback to slug
let title = dom.find_attr("h1", "data-jp")
.filter(|t| !t.is_empty())
.or_else(|| dom.find_class_text("p", "title").filter(|t| !t.is_empty()))
.or_else(|| dom.find_tag_content("h1").filter(|t| !t.trim().is_empty()))
.unwrap_or_else(|| id.clone());
// Poster image — from .poster container or meta tag
let image = dom.find_img_src("poster")
.or_else(|| {
extract_in_block(&html, "itemprop=\"image\"", ".jpg")
.or_else(|| extract_in_block(&html, "itemprop=\"image\"", ".png"))
.and_then(|raw| {
extract_in_block(raw.split("src=\"").next()?, "", "")
.or_else(|| extract_in_block(&html, "content=\"", "\""))
})
})
.or_else(|| {
// Try extracting from og:image meta tag
extract_in_block(&html, "property=\"og:image\" content=\"", "\"")
.or_else(|| extract_in_block(&html, "property='og:image' content='", "'"))
})
.unwrap_or_default();
// Description — from div.desc or meta description
let description = dom.find_class_text("div", "desc text-expand")
.or_else(|| dom.find_class_text("p", "desc"))
.or_else(|| {
extract_in_block(&html, "property=\"og:description\" content=\"", "\"")
.or_else(|| extract_in_block(&html, "name=\"description\" content=\"", "\""))
})
.filter(|t| !t.is_empty());
// Score — from div.rate-box[data-score]
let score = dom.find_attr_by_id("anime-rating", "data-score")
.or_else(|| {
let pattern = "class=\"rate-box\"";
if let Some(idx) = html.find(pattern) {
let block_end = html[idx..].find('>').unwrap_or(200).min(200);
let block = &html[idx..idx + block_end];
extract_attr_value(block, "data-score")
} else {
None
}
})
.and_then(|s| s.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>")
.or_else(|| extract_in_block(&html, ">Status</span>", "</div>"))
.map(|s| clean_html(&s).trim().to_lowercase())
.and_then(|s| match s.as_str() {
"ongoing" => Some(Status::Ongoing),
"completed" => Some(Status::Completed),
"upcoming" => Some(Status::Upcoming),
_ => None,
});
// Studio
let studio = extract_in_block(&html, ">Studios</span>", "</span>")
.or_else(|| extract_in_block(&html, "itemprop=\"director\"", "</a>"))
.map(|s| clean_html(&s));
// Type (TV, Movie, OVA, etc.)
let _type_str = extract_in_block(&html, ">Type</span>", "</span>")
.or_else(|| extract_in_block(&html, "class=\"info\"", "</div>"))
.map(|s| clean_html(&s).trim().to_string())
.unwrap_or_default();
// Year / season
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());
// Content ID for episode AJAX — from div.rate-box[data-id]
let content_id = dom.find_attr_by_id("anime-rating", "data-id")
.or_else(|| {
let pattern = "class=\"rate-box\"";
if let Some(idx) = html.find(pattern) {
let block_end = html[idx..].find('>').unwrap_or(200).min(200);
let block = &html[idx..idx + block_end];
extract_attr_value(block, "data-id")
} else {
None
}
})
.unwrap_or_default();
// Episodes — fetch via AJAX with enc-dec.app token
let episodes = if !content_id.is_empty() {
Self::fetch_episodes(&content_id, &id).unwrap_or_default()
} else {
vec![]
};
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: 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: vec![],
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=N$token=XXXXX"
let parts: Vec<&str> = id.split("$token=").collect();
if parts.len() < 2 {
return Err(PluginError::InvalidInput("Invalid episode ID format — expected $token= suffix".to_string()));
}
let token = parts[1];
// Encrypt the token using enc-dec.app to get the _ parameter
let enc_token = MegaUp::generate_token(token)
.map_err(|_| PluginError::Network("Failed to encrypt token via enc-dec.app".to_string()))?;
let anime_id = id.split("$ep=").next().unwrap_or("");
let url = format!(
"{}/ajax/links/list?token={}&_={}",
BASE_URL, token, enc_token
);
let mut headers = Self::get_ajax_headers();
if !anime_id.is_empty() {
headers.push(Attr {
key: "Referer".to_string(),
value: format!("{}/watch/{}", BASE_URL, anime_id),
});
}
let json = Self::fetch_json(&url, headers)?;
let html_content = json
.get("result")
.and_then(|v| v.as_str())
.ok_or(PluginError::Parse("No result in links response".to_string()))?;
let dom = Dom::parse(html_content);
let mut servers = Vec::new();
let find_group = |html: &str, data_id: &str| -> Option<usize> {
html.find(&format!("class=\"server-items lang-group\" data-id=\"{}\"", data_id))
.or_else(|| html.find(&format!("class='server-items lang-group' data-id='{}'", data_id)))
.or_else(|| html.find(&format!("data-id=\"{}\" class=\"server-items lang-group\"", data_id)))
.or_else(|| html.find(&format!("data-id='{}' class='server-items lang-group'", data_id)))
};
let find_lid = |html: &str| -> Option<usize> {
html.find("data-lid=\"").or_else(|| html.find("data-lid='"))
};
// Parse server groups: sub, softsub, dub
let groups = vec![("sub", "Sub"), ("softsub", "Soft Sub"), ("dub", "Dub")];
for (data_id, label) in groups {
if let Some(group_start) = find_group(&dom.html, data_id) {
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 mut offset = 0;
let mut priority: u8 = 1;
while let Some(idx) = find_lid(&block[offset..]) {
let lid_start = offset + idx + if block[offset + idx..].starts_with("data-lid=\"") { 10 } else { 10 };
if let Some(lid_end) = block[lid_start..].find('"').or_else(|| block[lid_start..].find('\'')) {
let lid = &block[lid_start..lid_start + lid_end];
if lid.is_empty() {
offset = lid_start;
continue;
}
let server_name = format!("{} {}", label, priority);
servers.push(Server {
id: lid.to_string(),
label: server_name,
url: format!("{}/ajax/links/view?id={}&_={}", BASE_URL, lid, enc_token),
priority,
extra: vec![
Attr { key: "type".to_string(), value: data_id.to_string() },
Attr { key: "referer".to_string(), value: format!("{}/watch/{}", BASE_URL, anime_id) },
],
});
priority += 1;
}
offset = lid_start + 1;
}
}
}
if servers.is_empty() {
return Err(PluginError::NotFound);
}
Ok(servers)
}
fn resolve_stream(_ctx: RequestContext, server: Server) -> Result<StreamSource, PluginError> {
let referer = server
.extra
.iter()
.find(|attr| attr.key == "referer")
.map(|attr| attr.value.clone())
.unwrap_or_else(|| format!("{}/", BASE_URL));
let mut headers = Self::get_ajax_headers();
if let Some(header) = headers.iter_mut().find(|header| header.key == "Referer") {
header.value = referer.clone();
} else {
headers.push(Attr { key: "Referer".to_string(), value: referer.clone() });
}
let response = Self::fetch_json(&server.url, headers)?;
let iframe_data = response
.get("result")
.and_then(|v| v.as_str())
.ok_or(PluginError::Parse("No result in view response".to_string()))?;
let decoded = MegaUp::decode_iframe_data(iframe_data)
.map_err(|e| PluginError::Parse(format!("Failed to decode iframe data: {}", e)))?;
let megaup_url = decoded
.get("result")
.and_then(|v| v.get("url"))
.and_then(|v| v.as_str())
.or_else(|| decoded.get("url").and_then(|v| v.as_str()))
.or_else(|| decoded.as_str())
.ok_or(PluginError::NotFound)?;
let (videos, subtitles) = MegaUp::extract(megaup_url)
.map_err(|e| PluginError::Parse(format!("Failed to extract MegaUp streams: {}", e)))?;
let first_video = videos.first().ok_or(PluginError::NotFound)?;
let is_hls = first_video.mime_type.as_deref() == Some("application/vnd.apple.mpegurl");
Ok(StreamSource {
id: format!("stream-{}", server.id),
label: server.label.clone(),
format: if is_hls { StreamFormat::Hls } else { StreamFormat::Progressive },
manifest_url: if is_hls { Some(first_video.url.clone()) } else { None },
videos,
subtitles,
headers: vec![
Attr { key: "User-Agent".to_string(), value: USER_AGENT.to_string() },
Attr { key: "Referer".to_string(), value: referer },
],
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 with enc-dec.app token
// ============================================================================
impl Component {
fn fetch_episodes(content_id: &str, anime_id: &str) -> Result<Vec<Episode>, PluginError> {
// Encrypt content_id for the _ parameter
let enc_id = MegaUp::generate_token(content_id)
.map_err(|_| PluginError::Network("Failed to encrypt content_id via enc-dec.app".to_string()))?;
let url = format!(
"{}/ajax/episodes/list?ani_id={}&_={}",
BASE_URL, content_id, enc_id
);
let json = Self::fetch_json(&url, Self::get_ajax_headers())?;
let html_content = json
.get("result")
.and_then(|v| v.as_str())
.ok_or(PluginError::Parse("No result in episodes response".to_string()))?;
let dom = Dom::parse(html_content);
let mut episodes = Vec::new();
// Parse <a num="1" slug="..." token="...">Episode Title</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_num_str = extract_attr_value(a_tag, "num").unwrap_or_default();
let ep_num: f64 = ep_num_str.parse().unwrap_or(0.0);
let ep_token = extract_attr_value(a_tag, "token").unwrap_or_default();
let ep_href = extract_attr_value(a_tag, "href").unwrap_or_default();
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 {
// Strip any inner HTML tags from title
clean_html(text)
}
} else {
format!("Episode {}", ep_num as i32)
};
if ep_token.is_empty() {
offset = a_end + 1;
continue;
}
let episode_id = format!("{}$ep={}$token={}", anime_id, ep_num, ep_token);
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/{}{}", BASE_URL, anime_id, ep_href)),
tags: vec![],
extra: vec![],
});
offset = a_end + 1;
}
Ok(episodes)
}
/// Build a StreamSource from a direct URL (fallback for non-MegaUp sources)
#[allow(dead_code)]
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: 1280,
height: 720,
hdr: false,
label: "720p".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: Some(3000000),
codecs: None,
}],
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![],
})
}
}
// ============================================================================
// 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,
}
}
/// Parse resolution from label string like "1080p", "720p", "480p", "360p"
fn parse_resolution_label(label: &str) -> (u32, u32) {
let lower = label.to_lowercase();
if lower.contains("1080") {
(1920, 1080)
} else if lower.contains("720") {
(1280, 720)
} else if lower.contains("480") {
(854, 480)
} else if lower.contains("360") {
(640, 360)
} else {
(1920, 1080) // Default to highest for "Auto"
}
}
bindings::export!(Component with_types_in bindings);