| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| #[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 |
| } |
| } |
|
|
| |
| |
| |
|
|
| #[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() |
| } |
|
|
| |
| |
| |
|
|
| |
| 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 |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| |
| |
|
|
| 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 |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Guest for Component { |
| fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> { |
| let mut sections = Vec::new(); |
|
|
| |
| 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![], |
| }); |
|
|
| |
| 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); |
|
|
| |
| 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()); |
|
|
| |
| 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(|| { |
| |
| extract_in_block(&html, "property=\"og:image\" content=\"", "\"") |
| .or_else(|| extract_in_block(&html, "property='og:image' content='", "'")) |
| }) |
| .unwrap_or_default(); |
|
|
| |
| 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()); |
|
|
| |
| 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); |
|
|
| |
| let genres = extract_genres(&html); |
|
|
| |
| 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, |
| }); |
|
|
| |
| let studio = extract_in_block(&html, ">Studios</span>", "</span>") |
| .or_else(|| extract_in_block(&html, "itemprop=\"director\"", "</a>")) |
| .map(|s| clean_html(&s)); |
|
|
| |
| 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(); |
|
|
| |
| 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()); |
|
|
| |
| 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(); |
|
|
| |
| 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> { |
| |
| 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]; |
|
|
| |
| 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='")) |
| }; |
|
|
| |
| 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) |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Component { |
| fn fetch_episodes(content_id: &str, anime_id: &str) -> Result<Vec<Episode>, PluginError> { |
| |
| 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(); |
|
|
| |
| 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 { |
| |
| 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) |
| } |
|
|
| |
| #[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![], |
| }) |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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("&", "&"); |
| 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<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, |
| } |
| } |
|
|
| |
| 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) |
| } |
| } |
|
|
| bindings::export!(Component with_types_in bindings); |
|
|