/*! 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 { 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 { 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 { 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, Vec), 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 { 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 { 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 { 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 { 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 { let pattern = format!("class=\"{}\"", container_class); let idx = self.html.find(&pattern)?; let block_end = self.html[idx..].find("").unwrap_or(self.html.len() - idx) + idx; let block = &self.html[idx..block_end]; let img_idx = block.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 { 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 { 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 { 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 { Self::fetch_url_with_headers(url, Self::get_headers()) } fn fetch_url_with_headers(url: &str, headers: Vec) -> Result { 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) -> Result { 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 { 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 { 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::(&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 { 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 { 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 { 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\">", "") { 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, 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 { 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 { 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 { 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::().ok()) .map(|s| (s * 10.0) as u32); // Genres let genres = extract_genres(&html); // Status let status = extract_in_block(&html, "Status", "") .or_else(|| extract_in_block(&html, ">Status", "")) .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", "") .or_else(|| extract_in_block(&html, "itemprop=\"director\"", "")) .map(|s| clean_html(&s)); // Type (TV, Movie, OVA, etc.) let _type_str = extract_in_block(&html, ">Type", "") .or_else(|| extract_in_block(&html, "class=\"info\"", "")) .map(|s| clean_html(&s).trim().to_string()) .unwrap_or_default(); // Year / season let year = extract_in_block(&html, ">Premiered", "") .or_else(|| extract_in_block(&html, ">Year", "")) .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, 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 { 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 { 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("").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 { 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, PluginError> { Err(PluginError::Unsupported) } fn download_subtitle(_ctx: RequestContext, _id: String) -> Result { Err(PluginError::Unsupported) } fn get_articles(_ctx: RequestContext) -> Result, PluginError> { Err(PluginError::Unsupported) } fn search_articles(_ctx: RequestContext, _query: String) -> Result, 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, 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 Episode Title let a_pattern = "').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("") { 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) -> Result { 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 { 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("&", "&"); result = result.replace("<", "<"); result = result.replace(">", ">"); result = result.replace(""", "\""); result = result.replace("'", "'"); result = result.replace("'", "'"); result.trim().to_string() } fn extract_genres(html: &str) -> Vec { let mut genres = Vec::new(); if let Some(idx) = html.find("Genres") { let block_end = html[idx..].find("").unwrap_or(500).min(500); let block = &html[idx..idx + block_end]; let a_pattern = "') { if let Some(close) = block[abs + tag_end + 1..].find("") { 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);