| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| #[allow(warnings)] |
| mod bindings; |
|
|
| use bindings::bex::plugin::common::*; |
| use bindings::bex::plugin::http; |
| use bindings::exports::api::Guest; |
|
|
| struct Component; |
|
|
| const BASE_URL: &str = "https://anitaku.to"; |
|
|
| |
| |
| |
|
|
| struct Dom { |
| html: String, |
| } |
|
|
| impl Dom { |
| fn parse(html: &str) -> Self { |
| Dom { html: html.to_string() } |
| } |
|
|
| #[allow(dead_code)] |
| fn find_attr_by_id(&self, id: &str, attr: &str) -> Option<String> { |
| let id_pattern = format!("id=\"{}\"", id); |
| let id_idx = self.html.find(&id_pattern)?; |
| let tag_start = self.html[..id_idx].rfind('<')?; |
| let tag_end = self.html[tag_start..].find('>')? + tag_start; |
| let tag_str = &self.html[tag_start..tag_end]; |
| extract_attr_value(tag_str, attr) |
| } |
|
|
| #[allow(dead_code)] |
| fn find_attr(&self, tag: &str, attr: &str) -> Option<String> { |
| let pattern = format!("<{}", tag); |
| let idx = self.html.find(&pattern)?; |
| let tag_end = self.html[idx..].find('>')? + idx; |
| let tag_str = &self.html[idx..tag_end]; |
| extract_attr_value(tag_str, attr) |
| } |
|
|
| #[allow(dead_code)] |
| fn find_class_text(&self, tag: &str, class: &str) -> Option<String> { |
| let pattern = format!("<{} class=\"{}\"", tag, class); |
| let idx = self.html.find(&pattern)?; |
| let content_start = self.html[idx..].find('>')? + idx + 1; |
| let close = format!("</{}>", tag); |
| let content_end = self.html[content_start..].find(&close)? + content_start; |
| Some(self.html[content_start..content_end].trim().to_string()) |
| } |
|
|
| fn find_tag_content(&self, tag: &str) -> Option<String> { |
| let open_start = self.html.find(&format!("<{}", tag))?; |
| let content_start = self.html[open_start..].find('>')? + open_start + 1; |
| let close = format!("</{}>", tag); |
| let content_end = self.html[content_start..].find(&close)? + content_start; |
| Some(self.html[content_start..content_end].to_string()) |
| } |
|
|
| fn find_img_src(&self, container_class: &str) -> Option<String> { |
| let pattern = format!("class=\"{}\"", container_class); |
| let idx = self.html.find(&pattern)?; |
| let block_end = self.html[idx..].find("</div>").unwrap_or(self.html.len() - idx) + idx; |
| let block = &self.html[idx..block_end]; |
| let img_idx = block.find("<img")?; |
| let img_end = block[img_idx..].find('>')? + img_idx; |
| let img_tag = &block[img_idx..img_end]; |
| extract_attr_value(img_tag, "src").or_else(|| extract_attr_value(img_tag, "data-src")) |
| } |
| } |
|
|
| fn extract_attr_value(tag_str: &str, attr: &str) -> Option<String> { |
| let attr_pattern = format!("{}=\"", attr); |
| if let Some(idx) = tag_str.find(&attr_pattern) { |
| let val_start = idx + attr_pattern.len(); |
| let val_end = tag_str[val_start..].find('"')? + val_start; |
| Some(tag_str[val_start..val_end].to_string()) |
| } else { |
| None |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Component { |
| fn get_headers() -> Vec<Attr> { |
| vec![ |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() }, |
| Attr { key: "Connection".to_string(), value: "keep-alive".to_string() }, |
| Attr { key: "Accept".to_string(), value: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string() }, |
| Attr { key: "Accept-Language".to_string(), value: "en-US,en;q=0.5".to_string() }, |
| Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, |
| ] |
| } |
|
|
| fn fetch_url(url: &str) -> Result<String, PluginError> { |
| Self::fetch_url_with_headers(url, Self::get_headers()) |
| } |
|
|
| fn fetch_url_with_headers(url: &str, headers: Vec<Attr>) -> Result<String, PluginError> { |
| let req = http::Request { |
| method: http::Method::Get, |
| url: url.to_string(), |
| headers, |
| body: None, |
| timeout_ms: Some(15000), |
| follow_redirects: true, |
| cache_mode: http::CacheMode::NoStore, |
| max_bytes: Some(5 * 1024 * 1024), |
| }; |
|
|
| match http::send_request(&req) { |
| Ok(resp) if resp.status == 200 => String::from_utf8(resp.body) |
| .map_err(|e| PluginError::Parse(format!("UTF-8 error: {}", e))), |
| Ok(resp) => Err(PluginError::Network(format!("HTTP {}", resp.status))), |
| Err(e) => Err(e), |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| fn scrape_anime_cards(html: &str) -> Vec<MediaCard> { |
| let mut results = Vec::new(); |
| let mut seen_ids = std::collections::HashSet::new(); |
|
|
| |
| |
| let li_pattern = "<li>"; |
| let mut offset = 0; |
|
|
| while let Some(idx) = html[offset..].find(li_pattern) { |
| let li_start = offset + idx; |
|
|
| |
| let li_close = "</li>"; |
| let li_end = html[li_start..].find(li_close) |
| .map(|i| li_start + i + li_close.len()) |
| .unwrap_or(html.len().min(li_start + 3000)); |
|
|
| let block = &html[li_start..li_end.min(html.len())]; |
|
|
| |
| let slug = if let Some(s) = extract_in_block(block, "href=\"/category/", "\"") { |
| s |
| } else { |
| offset = li_end; |
| continue; |
| }; |
|
|
| if slug.is_empty() || seen_ids.contains(&slug) { |
| offset = li_end; |
| continue; |
| } |
| seen_ids.insert(slug.clone()); |
|
|
| |
| let title = if let Some(t) = extract_in_block(block, "title=\"", "\"") { |
| t |
| } else if let Some(t) = extract_gogo_link_text(block) { |
| t |
| } else { |
| slug.replace('-', " ") |
| }; |
|
|
| |
| let image = extract_in_block(block, "data-original=\"", "\"") |
| .or_else(|| extract_in_block(block, "src=\"", "\"")) |
| .filter(|u| !u.is_empty() && (u.starts_with("http") || u.starts_with("//"))) |
| .unwrap_or_default(); |
|
|
| |
| let image = if image.starts_with("//") { |
| format!("https:{}", image) |
| } else { |
| image |
| }; |
|
|
| |
| let year = extract_in_block(block, "class=\"released\">", "</p>") |
| .or_else(|| extract_in_block(block, "class=\"released\">", "</div>")) |
| .map(|s| { |
| let cleaned = clean_html(&s).trim().to_string(); |
| |
| if let Some(yr) = cleaned.chars().filter(|c| c.is_ascii_digit()).collect::<String>().get(..4) { |
| Some(yr.to_string()) |
| } else { |
| None |
| } |
| }) |
| .unwrap_or(None); |
|
|
| results.push(MediaCard { |
| id: slug.clone(), |
| title: clean_html(&title), |
| kind: Some(MediaKind::Anime), |
| images: if image.is_empty() { |
| None |
| } else { |
| Some(make_image_set(&image, ImageLayout::Portrait)) |
| }, |
| original_title: None, |
| tagline: None, |
| year, |
| score: None, |
| genres: vec![], |
| status: None, |
| content_rating: None, |
| url: Some(format!("{}/category/{}", BASE_URL, slug)), |
| ids: vec![], |
| extra: vec![], |
| }); |
|
|
| offset = li_end; |
| } |
|
|
| results |
| } |
|
|
| |
| |
| |
|
|
| fn parse_episodes(html: &str, slug: &str) -> Vec<Episode> { |
| |
| |
| |
| |
| if let Some(eps) = Self::fetch_episodes_ajax(html, slug) { |
| if !eps.is_empty() { |
| return eps; |
| } |
| } |
|
|
| |
| |
| |
| let mut episodes = Vec::new(); |
|
|
| |
| let ep_container_start = html.find("id=\"episode_related\"") |
| .or_else(|| html.find("id=\"episode_page\"")) |
| .or_else(|| html.find("class=\"episode_related\"")) |
| .or_else(|| html.find("class=\"episode_page\"")); |
|
|
| let ep_section = match ep_container_start { |
| Some(start) => { |
| |
| let end_limit = (start + 50000).min(html.len()); |
| &html[start..end_limit] |
| } |
| None => return episodes, |
| }; |
|
|
| |
| let slug_ep_pattern = format!("/{}-episode-", slug); |
|
|
| |
| let a_pattern = "<a "; |
| let mut offset = 0; |
|
|
| while let Some(idx) = ep_section[offset..].find(a_pattern) { |
| if episodes.len() >= 200 { |
| break; |
| } |
|
|
| let a_start = offset + idx; |
| let a_end = ep_section[a_start..].find('>') |
| .map(|p| a_start + p) |
| .unwrap_or(ep_section.len()); |
| if a_end <= a_start || a_end >= ep_section.len() { |
| offset = a_start + 1; |
| continue; |
| } |
| let a_tag = &ep_section[a_start..a_end]; |
| let after_a = if a_end + 1 < ep_section.len() { |
| &ep_section[a_end + 1..] |
| } else { |
| offset = a_end + 1; |
| continue; |
| }; |
|
|
| |
| let href = extract_attr_value(a_tag, "href").unwrap_or_default(); |
|
|
| |
| if !href.contains(&slug_ep_pattern) && !href.contains("-episode-") { |
| offset = a_end + 1; |
| continue; |
| } |
|
|
| |
| let ep_name = if let Some(close_a) = after_a.find("</a>") { |
| let text = after_a[..close_a].trim(); |
| clean_html(text) |
| } else { |
| String::new() |
| }; |
|
|
| |
| let ep_num = parse_ep_number(&ep_name, &href); |
|
|
| |
| let has_sub = extract_attr_value(a_tag, "data-sub") |
| .map(|v| v == "1" || v.to_lowercase() == "true") |
| .unwrap_or(false); |
| let has_dub = extract_attr_value(a_tag, "data-dub") |
| .map(|v| v == "1" || v.to_lowercase() == "true") |
| .unwrap_or(false); |
|
|
| let sub_flag = if has_sub || !has_dub { 1 } else { 0 }; |
| let dub_flag = if has_dub { 1 } else { 0 }; |
|
|
| if ep_num <= 0.0 { |
| offset = a_end + 1; |
| continue; |
| } |
|
|
| let ep_title = if ep_name.is_empty() { |
| format!("Episode {}", ep_num as i32) |
| } else { |
| ep_name |
| }; |
|
|
| let episode_id = format!("{}$ep={}$sub={}$dub={}", slug, ep_num as i32, sub_flag, dub_flag); |
|
|
| |
| let ep_url = if href.starts_with("http") { |
| href.clone() |
| } else if href.starts_with("/") { |
| format!("{}{}", BASE_URL, href) |
| } else { |
| format!("{}/{}-episode-{}", BASE_URL, slug, ep_num as i32) |
| }; |
|
|
| episodes.push(Episode { |
| id: episode_id, |
| title: ep_title, |
| number: Some(ep_num), |
| season: Some(1.0), |
| images: None, |
| description: None, |
| released: None, |
| score: None, |
| url: Some(ep_url), |
| tags: vec![], |
| extra: vec![], |
| }); |
|
|
| offset = a_end + 1; |
| } |
|
|
| episodes |
| } |
|
|
| |
| |
| |
| |
| fn fetch_episodes_ajax(html: &str, slug: &str) -> Option<Vec<Episode>> { |
| |
| let movie_id = extract_in_block(html, "/load-list-episode?ep_start=0&ep_end=", "&id=") |
| .or_else(|| { |
| |
| if let Some(idx) = html.find("&id=") { |
| let start = idx + 4; |
| let end = html[start..].find(|c: char| c == '"' || c == '&' || c == ' ') |
| .unwrap_or(html.len() - start); |
| let val = html[start..start + end].to_string(); |
| if !val.is_empty() { Some(val) } else { None } |
| } else { |
| None |
| } |
| }) |
| .or_else(|| { |
| |
| extract_in_block(html, "movie_id = '", "'") |
| .or_else(|| extract_in_block(html, "movie_id=\"", "\"")) |
| })?; |
|
|
| if movie_id.is_empty() { |
| return None; |
| } |
|
|
| let ajax_url = format!( |
| "{}/load-list-episode?ep_start=0&ep_end=9999&id={}", |
| BASE_URL, movie_id |
| ); |
|
|
| let ajax_html = Self::fetch_url(&ajax_url).ok()?; |
| let mut episodes = Vec::new(); |
|
|
| |
| let slug_ep_pattern = format!("/{}-episode-", slug); |
|
|
| let li_pattern = "<li>"; |
| let mut offset = 0; |
|
|
| while let Some(idx) = ajax_html[offset..].find(li_pattern) { |
| if episodes.len() >= 200 { |
| break; |
| } |
|
|
| let li_start = offset + idx; |
| let li_close = "</li>"; |
| let li_end = ajax_html[li_start..].find(li_close) |
| .map(|i| li_start + i + li_close.len()) |
| .unwrap_or(ajax_html.len().min(li_start + 2000)); |
|
|
| let block = &ajax_html[li_start..li_end.min(ajax_html.len())]; |
|
|
| let href = extract_in_block(block, "href=\"", "\"").unwrap_or_default(); |
|
|
| |
| if !href.contains(&slug_ep_pattern) && !href.contains("-episode-") { |
| offset = li_end; |
| continue; |
| } |
|
|
| let ep_name = extract_in_block(block, "class=\"name\">", "</div>") |
| .or_else(|| extract_in_block(block, "class=\"name\">", "</a>")) |
| .unwrap_or_default(); |
|
|
| let ep_num = parse_ep_number(&ep_name, &href); |
|
|
| let has_sub = extract_in_block(block, "data-sub=\"", "\"") |
| .map(|v| v == "1") |
| .unwrap_or(true); |
| let has_dub = extract_in_block(block, "data-dub=\"", "\"") |
| .map(|v| v == "1") |
| .unwrap_or(false); |
|
|
| if ep_num <= 0.0 { |
| offset = li_end; |
| continue; |
| } |
|
|
| let sub_flag = if has_sub || !has_dub { 1 } else { 0 }; |
| let dub_flag = if has_dub { 1 } else { 0 }; |
|
|
| let ep_title = if ep_name.trim().is_empty() { |
| format!("Episode {}", ep_num as i32) |
| } else { |
| clean_html(&ep_name) |
| }; |
|
|
| let episode_id = format!("{}$ep={}$sub={}$dub={}", slug, ep_num as i32, sub_flag, dub_flag); |
|
|
| let ep_url = if href.starts_with("http") { |
| href |
| } else if href.starts_with("/") { |
| format!("{}{}", BASE_URL, href) |
| } else { |
| format!("{}/{}-episode-{}", BASE_URL, slug, ep_num as i32) |
| }; |
|
|
| episodes.push(Episode { |
| id: episode_id, |
| title: ep_title, |
| number: Some(ep_num), |
| season: Some(1.0), |
| images: None, |
| description: None, |
| released: None, |
| score: None, |
| url: Some(ep_url), |
| tags: vec![], |
| extra: vec![], |
| }); |
|
|
| offset = li_end; |
| } |
|
|
| Some(episodes) |
| } |
|
|
| |
| |
| |
|
|
| |
| fn extract_vibeplayer_stream(iframe_url: &str) -> Result<(String, Vec<SubtitleTrack>), PluginError> { |
| let html = Self::fetch_url_with_headers(iframe_url, vec![ |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() }, |
| Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, |
| Attr { key: "Accept".to_string(), value: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string() }, |
| ])?; |
|
|
| |
| let m3u8_url = extract_m3u8_from_js(&html) |
| .ok_or_else(|| PluginError::Parse("Could not find m3u8 URL in VibePlayer page".to_string()))?; |
|
|
| |
| let mut subtitles = Vec::new(); |
| if let Some(sub_param) = iframe_url.find("sub=") { |
| let sub_start = sub_param + 4; |
| let sub_end = iframe_url[sub_start..].find('&').unwrap_or(iframe_url.len() - sub_start); |
| let sub_url = &iframe_url[sub_start..sub_start + sub_end]; |
| if sub_url.starts_with("http") || sub_url.starts_with("//") { |
| let sub_url_fixed = if sub_url.starts_with("//") { |
| format!("https:{}", sub_url) |
| } else { |
| sub_url.to_string() |
| }; |
| subtitles.push(SubtitleTrack { |
| label: "English".to_string(), |
| url: sub_url_fixed, |
| language: Some("en".to_string()), |
| format: Some("vtt".to_string()), |
| }); |
| } |
| } |
|
|
| Ok((m3u8_url, subtitles)) |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Guest for Component { |
| fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> { |
| let mut sections = Vec::new(); |
|
|
| |
| let genres = vec![ |
| ("Action", "genre/action"), |
| ("Adventure", "genre/adventure"), |
| ("Comedy", "genre/comedy"), |
| ("Drama", "genre/drama"), |
| ("Fantasy", "genre/fantasy"), |
| ("Horror", "genre/horror"), |
| ("Isekai", "genre/isekai"), |
| ("Mecha", "genre/mecha"), |
| ("Mystery", "genre/mystery"), |
| ("Romance", "genre/romance"), |
| ("Sci-Fi", "genre/sci-fi"), |
| ("Slice of Life", "genre/slice-of-life"), |
| ("Sports", "genre/sports"), |
| ("Thriller", "genre/thriller"), |
| ]; |
|
|
| sections.push(HomeSection { |
| id: "categories".to_string(), |
| title: "Browse".to_string(), |
| subtitle: None, |
| items: vec![], |
| next_page: None, |
| layout: CardLayout::Grid, |
| show_rank: false, |
| categories: genres |
| .into_iter() |
| .map(|(title, id)| CategoryLink { |
| id: id.to_string(), |
| title: title.to_string(), |
| subtitle: None, |
| image: None, |
| }) |
| .collect(), |
| extra: vec![], |
| }); |
|
|
| |
| let section_pages = vec![ |
| ("popular", "Popular", "/popular.html"), |
| ("recent", "Recently Released", "/recent-release.html?page=1"), |
| ("new-season", "New Season", "/season.html"), |
| ("movies", "Anime Movies", "/anime-movies.html"), |
| ]; |
|
|
| for (sec_id, sec_title, sec_path) in section_pages { |
| let url = format!("{}{}", BASE_URL, sec_path); |
| if let Ok(html) = Self::fetch_url(&url) { |
| let items = Self::scrape_anime_cards(&html); |
| if !items.is_empty() { |
| sections.push(HomeSection { |
| id: sec_id.to_string(), |
| title: sec_title.to_string(), |
| subtitle: None, |
| items: items.into_iter().take(20).collect(), |
| next_page: Some(format!("{}:2", sec_id)), |
| layout: CardLayout::Grid, |
| show_rank: false, |
| categories: vec![], |
| extra: vec![], |
| }); |
| } |
| } |
| } |
|
|
| Ok(sections) |
| } |
|
|
| fn get_category(_ctx: RequestContext, id: String, page: PageCursor) -> Result<PagedResult, PluginError> { |
| let page_num: u32 = page |
| .token |
| .as_ref() |
| .and_then(|t| t.split(':').last()) |
| .and_then(|n| n.parse().ok()) |
| .unwrap_or(1); |
|
|
| let endpoint = if id.starts_with("genre/") { |
| let genre = &id[6..]; |
| format!("/genre/{}?page={}", genre, page_num) |
| } else { |
| match id.as_str() { |
| "popular" => format!("/popular.html?page={}", page_num), |
| "recent" => format!("/recent-release.html?page={}", page_num), |
| "new-season" => format!("/season.html?page={}", page_num), |
| "movies" => format!("/anime-movies.html?page={}", page_num), |
| _ => return Err(PluginError::NotFound), |
| } |
| }; |
|
|
| let url = format!("{}{}", BASE_URL, endpoint); |
| let html = Self::fetch_url(&url)?; |
| let items = Self::scrape_anime_cards(&html); |
| let next_page = if items.len() >= 18 { |
| Some(format!("{}:{}", id, page_num + 1)) |
| } else { |
| None |
| }; |
|
|
| Ok(PagedResult { |
| items, |
| categories: vec![], |
| next_page, |
| }) |
| } |
|
|
| fn search(_ctx: RequestContext, query: String, _filters: SearchFilters) -> Result<PagedResult, PluginError> { |
| if query.trim().is_empty() { |
| return Ok(PagedResult { |
| items: vec![], |
| categories: vec![], |
| next_page: None, |
| }); |
| } |
|
|
| let encoded = query.replace(' ', "+"); |
| let url = format!("{}/search.html?keyword={}&page=1", BASE_URL, encoded); |
| let html = Self::fetch_url(&url)?; |
| let items = Self::scrape_anime_cards(&html); |
|
|
| Ok(PagedResult { |
| items, |
| categories: vec![], |
| next_page: None, |
| }) |
| } |
|
|
| fn get_info(_ctx: RequestContext, id: String) -> Result<MediaInfo, PluginError> { |
| let url = format!("{}/category/{}", BASE_URL, id); |
| let html = Self::fetch_url(&url)?; |
| let dom = Dom::parse(&html); |
|
|
| |
| let title = dom.find_tag_content("h1") |
| .filter(|t| !t.trim().is_empty()) |
| .or_else(|| { |
| extract_in_block(&html, "class=\"anime_info_body_bg\"", "</div>") |
| .and_then(|block| extract_in_block(&block, "<h1>", "</h1>")) |
| }) |
| .or_else(|| { |
| extract_in_block(&html, "class=\"title_name\"", "</h1>") |
| .or_else(|| extract_in_block(&html, "entry-title\">", "</h1>")) |
| }) |
| .map(|s| clean_html(&s)) |
| .unwrap_or_else(|| id.replace('-', " ")); |
|
|
| |
| let image = dom.find_img_src("anime_info_body_bg") |
| .or_else(|| { |
| extract_in_block(&html, "class=\"anime_info_body_bg\"", "</div>").and_then(|block| { |
| extract_in_block(&block, "src=\"", "\"") |
| .or_else(|| extract_in_block(&block, "data-src=\"", "\"")) |
| }) |
| }) |
| .or_else(|| { |
| extract_in_block(&html, "property=\"og:image\" content=\"", "\"") |
| .or_else(|| extract_in_block(&html, "property='og:image' content='", "'")) |
| }) |
| .map(|u| { |
| if u.starts_with("//") { |
| format!("https:{}", u) |
| } else { |
| u |
| } |
| }) |
| .unwrap_or_default(); |
|
|
| |
| let description = extract_in_block(&html, "class=\"description\"", "</div>") |
| .or_else(|| extract_in_block(&html, "class=\"desc\"", "</div>")) |
| .or_else(|| { |
| extract_in_block(&html, "property=\"og:description\" content=\"", "\"") |
| .or_else(|| extract_in_block(&html, "name=\"description\" content=\"", "\"")) |
| }) |
| .map(|s| clean_html(&s)) |
| .filter(|t| !t.is_empty()); |
|
|
| |
| let type_str = extract_in_block(&html, ">Type:<", "</p>") |
| .or_else(|| extract_in_block(&html, "class=\"type\">", "</a>")) |
| .or_else(|| { |
| |
| let pattern = "class=\"type\""; |
| if let Some(idx) = html.find(pattern) { |
| let block_end = html[idx..].find("</p>").unwrap_or(200).min(200); |
| let block = &html[idx..idx + block_end]; |
| |
| if let Some(last_gt) = block.rfind('>') { |
| let text = &block[last_gt + 1..]; |
| let cleaned = clean_html(text); |
| if !cleaned.is_empty() { |
| return Some(cleaned); |
| } |
| } |
| } |
| None |
| }) |
| .map(|s| clean_html(&s).trim().to_string()) |
| .unwrap_or_default(); |
|
|
| |
| |
| let genres = extract_gogo_genres(&html); |
|
|
| |
| let status = extract_in_block(&html, ">Status:<", "</p>") |
| .or_else(|| { |
| let pattern = "class=\"type\""; |
| let mut offset = 0; |
| let mut result = None; |
| while let Some(idx) = html[offset..].find(pattern) { |
| let block_end = html[offset + idx..].find("</p>").unwrap_or(300).min(300); |
| let block = &html[offset + idx..offset + idx + block_end]; |
| if block.contains("Status") { |
| if let Some(last_gt) = block.rfind('>') { |
| let text = &block[last_gt + 1..]; |
| let cleaned = clean_html(text); |
| if !cleaned.is_empty() { |
| result = Some(cleaned); |
| break; |
| } |
| } |
| } |
| offset = offset + idx + 1; |
| } |
| result |
| }) |
| .map(|s| clean_html(&s).trim().to_lowercase()) |
| .and_then(|s| match s.as_str() { |
| "ongoing" => Some(Status::Ongoing), |
| "currently airing" => Some(Status::Ongoing), |
| "completed" => Some(Status::Completed), |
| "finished airing" => Some(Status::Completed), |
| "upcoming" => Some(Status::Upcoming), |
| "not yet aired" => Some(Status::Upcoming), |
| _ => None, |
| }); |
|
|
| |
| let year = extract_in_block(&html, ">Released:<", "</p>") |
| .or_else(|| extract_in_block(&html, ">Premiered:<", "</p>")) |
| .or_else(|| { |
| let pattern = "class=\"type\""; |
| let mut offset = 0; |
| let mut result = None; |
| while let Some(idx) = html[offset..].find(pattern) { |
| let block_end = html[offset + idx..].find("</p>").unwrap_or(300).min(300); |
| let block = &html[offset + idx..offset + idx + block_end]; |
| if block.contains("Released") || block.contains("Premiered") { |
| if let Some(last_gt) = block.rfind('>') { |
| let text = &block[last_gt + 1..]; |
| let cleaned = clean_html(text).trim().to_string(); |
| if !cleaned.is_empty() { |
| result = Some(cleaned); |
| break; |
| } |
| } |
| } |
| offset = offset + idx + 1; |
| } |
| result |
| }) |
| .map(|s| clean_html(&s).trim().to_string()); |
|
|
| |
| let studio = extract_in_block(&html, ">Studios:<", "</p>") |
| .or_else(|| { |
| let pattern = "class=\"type\""; |
| let mut offset = 0; |
| let mut result = None; |
| while let Some(idx) = html[offset..].find(pattern) { |
| let block_end = html[offset + idx..].find("</p>").unwrap_or(300).min(300); |
| let block = &html[offset + idx..offset + idx + block_end]; |
| if block.contains("Studios") || block.contains("Producers") { |
| if let Some(last_gt) = block.rfind('>') { |
| let text = &block[last_gt + 1..]; |
| let cleaned = clean_html(text); |
| if !cleaned.is_empty() { |
| result = Some(cleaned); |
| break; |
| } |
| } |
| } |
| offset = offset + idx + 1; |
| } |
| result |
| }); |
|
|
| |
| let episodes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { |
| Self::parse_episodes(&html, &id) |
| })).unwrap_or_default(); |
|
|
| let seasons = if episodes.is_empty() { |
| vec![] |
| } else { |
| vec![Season { |
| id: format!("{}_s1", id), |
| title: "Season 1".to_string(), |
| number: Some(1.0), |
| year: None, |
| episodes, |
| }] |
| }; |
|
|
| Ok(MediaInfo { |
| id: id.clone(), |
| title, |
| kind: MediaKind::Anime, |
| images: if image.is_empty() { |
| None |
| } else { |
| Some(make_image_set(&image, ImageLayout::Portrait)) |
| }, |
| original_title: None, |
| description, |
| score: None, |
| scored_by: None, |
| year, |
| release_date: None, |
| genres, |
| tags: if type_str.is_empty() { vec![] } else { vec![type_str] }, |
| status, |
| content_rating: None, |
| seasons, |
| cast: vec![], |
| crew: vec![], |
| runtime_minutes: None, |
| trailer_url: None, |
| ids: vec![], |
| studio, |
| country: None, |
| language: None, |
| url: Some(format!("{}/category/{}", BASE_URL, id)), |
| extra: vec![], |
| }) |
| } |
|
|
| fn get_servers(_ctx: RequestContext, id: String) -> Result<Vec<Server>, PluginError> { |
| |
| |
| if id.is_empty() { |
| return Err(PluginError::InvalidInput("id is required for get_servers".to_string())); |
| } |
|
|
| |
| let parts: Vec<&str> = id.split('$').collect(); |
| if parts.len() < 2 { |
| return Err(PluginError::InvalidInput( |
| "Invalid episode ID format. Expected 'slug$ep=N$sub=N$dub=N'".to_string() |
| )); |
| } |
|
|
| let slug = parts[0]; |
| let mut ep_num: i32 = 1; |
| let mut sub_flag: u8 = 1; |
| let mut dub_flag: u8 = 0; |
|
|
| for part in &parts[1..] { |
| if let Some(n) = part.strip_prefix("ep=") { |
| ep_num = n.parse().unwrap_or(1); |
| } else if let Some(s) = part.strip_prefix("sub=") { |
| sub_flag = s.parse().unwrap_or(1); |
| } else if let Some(d) = part.strip_prefix("dub=") { |
| dub_flag = d.parse().unwrap_or(0); |
| } |
| } |
|
|
| let ep_url = format!("{}/{}-episode-{}", BASE_URL, slug, ep_num); |
| let html_result = Self::fetch_url(&ep_url); |
| let html = match html_result { |
| Ok(h) => h, |
| Err(e) => return Err(PluginError::Network(format!("Failed to fetch episode page: {}", e))), |
| }; |
|
|
| let mut servers = Vec::new(); |
| let mut seen_urls = std::collections::HashSet::new(); |
|
|
| |
| let default_iframe = extract_in_block(&html, "class=\"play-video\"", "</div>") |
| .or_else(|| extract_in_block(&html, "class=\"play_video\"", "</div>")) |
| .and_then(|block| { |
| extract_in_block(&block, "src=\"", "\"") |
| .or_else(|| extract_in_block(&block, "data-src=\"", "\"")) |
| }); |
|
|
| if let Some(ref iframe_src) = default_iframe { |
| let src = normalize_url(iframe_src); |
| seen_urls.insert(src.clone()); |
|
|
| let server_type = classify_server_type(&src, sub_flag, dub_flag); |
|
|
| servers.push(Server { |
| id: format!("{}-ep{}-default", slug, ep_num), |
| label: format!("{} Server 1", server_type), |
| url: src, |
| priority: 1, |
| extra: vec![ |
| Attr { key: "type".to_string(), value: server_type.to_lowercase() }, |
| Attr { key: "slug".to_string(), value: slug.to_string() }, |
| Attr { key: "ep".to_string(), value: ep_num.to_string() }, |
| ], |
| }); |
| } |
|
|
| |
| |
| |
| if let Some(mutli_start) = html.find("anime_muti_link") { |
| |
| let block_end = mutli_start + 20000; |
| let block = &html[mutli_start..block_end.min(html.len())]; |
|
|
| |
| let server_pattern = "data-video=\""; |
| let mut offset = 0; |
| let mut priority: u8 = 1; |
|
|
| while let Some(idx) = block[offset..].find(server_pattern) { |
| let val_start = offset + idx + server_pattern.len(); |
| let val_end = block[val_start..].find('"').unwrap_or(0); |
| if val_end == 0 { |
| offset = val_start + 1; |
| continue; |
| } |
| let video_url_raw = &block[val_start..val_start + val_end]; |
|
|
| let video_url = normalize_url(video_url_raw); |
|
|
| |
| if seen_urls.contains(&video_url) { |
| offset = val_start + val_end + 1; |
| continue; |
| } |
| seen_urls.insert(video_url.clone()); |
|
|
| |
| |
| let context_start = if offset > 200 { offset - 200 } else { 0 }; |
| let context = &block[context_start..val_start + val_end]; |
|
|
| let server_type = extract_in_block(context, "data-type=\"", "\"") |
| .map(|t| t.to_uppercase()) |
| .unwrap_or_else(|| classify_server_type(&video_url, sub_flag, dub_flag).to_string()); |
|
|
| servers.push(Server { |
| id: format!("{}-ep{}-s{}", slug, ep_num, priority), |
| label: format!("{} Server {}", server_type, priority), |
| url: video_url, |
| priority: priority + 1, |
| extra: vec![ |
| Attr { key: "type".to_string(), value: server_type.to_lowercase() }, |
| Attr { key: "slug".to_string(), value: slug.to_string() }, |
| Attr { key: "ep".to_string(), value: ep_num.to_string() }, |
| ], |
| }); |
|
|
| priority += 1; |
| offset = val_start + val_end + 1; |
| } |
| } |
|
|
| |
| |
| if servers.is_empty() { |
| let iframe_pattern = "<iframe"; |
| let mut offset = 0; |
|
|
| while let Some(idx) = html[offset..].find(iframe_pattern) { |
| let tag_start = offset + idx; |
| |
| let tag_end = html[tag_start..].find('>').unwrap_or(html.len() - tag_start) + tag_start; |
| let iframe_tag = &html[tag_start..tag_end.min(html.len())]; |
|
|
| |
| let iframe_src = extract_attr_value(iframe_tag, "src") |
| .or_else(|| extract_attr_value(iframe_tag, "data-src")); |
|
|
| if let Some(raw_src) = iframe_src { |
| let src = normalize_url(&raw_src); |
|
|
| |
| if is_known_embed_domain(&src) && !seen_urls.contains(&src) { |
| seen_urls.insert(src.clone()); |
| let server_type = classify_server_type(&src, sub_flag, dub_flag); |
|
|
| servers.push(Server { |
| id: format!("{}-ep{}-iframe", slug, ep_num), |
| label: format!("{} Server 1", server_type), |
| url: src, |
| priority: 1, |
| extra: vec![ |
| Attr { key: "type".to_string(), value: server_type.to_lowercase() }, |
| Attr { key: "slug".to_string(), value: slug.to_string() }, |
| Attr { key: "ep".to_string(), value: ep_num.to_string() }, |
| ], |
| }); |
| } |
| } |
|
|
| offset = tag_end + 1; |
| } |
| } |
|
|
| |
| |
| if servers.is_empty() { |
| let known_domains = [ |
| "vibeplayer", "streamani", "goload", "gogoplay", |
| "playgo", "anihdplay", "gogohd", "streamta", |
| "vidcdn", "sbplay", "dood", "mixdrop", |
| ]; |
|
|
| for domain in &known_domains { |
| if let Some(idx) = html.find(domain) { |
| |
| let before = &html[..idx]; |
| let url_start = before.rfind("http").or_else(|| before.rfind("//")) |
| .unwrap_or(idx.saturating_sub(10)); |
| |
| let after = &html[idx..]; |
| let url_end_offset = after.find(|c: char| c == '"' || c == '\'' || c == ' ' || c == '\n' || c == '<' || c == ')') |
| .unwrap_or(after.len().min(300)); |
| let raw_url = &html[url_start..idx + url_end_offset]; |
|
|
| let src = normalize_url(raw_url); |
| if !seen_urls.contains(&src) && src.starts_with("http") { |
| seen_urls.insert(src.clone()); |
| let server_type = classify_server_type(&src, sub_flag, dub_flag); |
|
|
| servers.push(Server { |
| id: format!("{}-ep{}-fallback", slug, ep_num), |
| label: format!("{} Server 1", server_type), |
| url: src, |
| priority: 1, |
| extra: vec![ |
| Attr { key: "type".to_string(), value: server_type.to_lowercase() }, |
| Attr { key: "slug".to_string(), value: slug.to_string() }, |
| Attr { key: "ep".to_string(), value: ep_num.to_string() }, |
| ], |
| }); |
| } |
| break; |
| } |
| } |
| } |
|
|
| if servers.is_empty() { |
| return Err(PluginError::NotFound); |
| } |
|
|
| Ok(servers) |
| } |
|
|
| fn resolve_stream(_ctx: RequestContext, server: Server) -> Result<StreamSource, PluginError> { |
| let server_url = &server.url; |
|
|
| |
| if server_url.contains(".m3u8") { |
| return Self::build_stream_source(&server, server_url, vec![]); |
| } |
|
|
| |
| |
|
|
| |
| if server_url.contains("vibeplayer") || server_url.contains("streamani") || server_url.contains("goload") || server_url.contains("gogoplay") { |
| |
| let (m3u8_url, subtitles) = Self::extract_vibeplayer_stream(server_url)?; |
|
|
| return Ok(StreamSource { |
| id: format!("stream-{}", server.id), |
| label: server.label.clone(), |
| format: StreamFormat::Hls, |
| manifest_url: Some(m3u8_url.clone()), |
| videos: vec![VideoTrack { |
| resolution: VideoResolution { |
| width: 1920, |
| height: 1080, |
| hdr: false, |
| label: "Auto (HLS)".to_string(), |
| }, |
| url: m3u8_url, |
| mime_type: Some("application/vnd.apple.mpegurl".to_string()), |
| bitrate: None, |
| codecs: None, |
| }], |
| subtitles, |
| headers: vec![ |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() }, |
| Attr { key: "Referer".to_string(), value: server_url.clone() }, |
| ], |
| extra: vec![], |
| }); |
| } |
|
|
| |
| let html = Self::fetch_url_with_headers(server_url, vec![ |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() }, |
| Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, |
| ])?; |
|
|
| |
| if let Some(iframe_src) = extract_in_block(&html, "iframe src=\"", "\"") |
| .or_else(|| extract_in_block(&html, "<iframe src=\"", "\"")) |
| { |
| let iframe_url = if iframe_src.starts_with("//") { |
| format!("https:{}", iframe_src) |
| } else if iframe_src.starts_with("/") { |
| format!("{}{}", BASE_URL, iframe_src) |
| } else { |
| iframe_src |
| }; |
|
|
| if iframe_url.contains("vibeplayer") || iframe_url.contains("streamani") || |
| iframe_url.contains("goload") || iframe_url.contains("gogoplay") { |
| let (m3u8_url, subtitles) = Self::extract_vibeplayer_stream(&iframe_url)?; |
| return Ok(StreamSource { |
| id: format!("stream-{}", server.id), |
| label: server.label.clone(), |
| format: StreamFormat::Hls, |
| manifest_url: Some(m3u8_url.clone()), |
| videos: vec![VideoTrack { |
| resolution: VideoResolution { |
| width: 1920, |
| height: 1080, |
| hdr: false, |
| label: "Auto (HLS)".to_string(), |
| }, |
| url: m3u8_url, |
| mime_type: Some("application/vnd.apple.mpegurl".to_string()), |
| bitrate: None, |
| codecs: None, |
| }], |
| subtitles, |
| headers: vec![ |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() }, |
| Attr { key: "Referer".to_string(), value: iframe_url }, |
| ], |
| extra: vec![], |
| }); |
| } |
| } |
|
|
| |
| if let Some(m3u8) = extract_m3u8_from_js(&html) { |
| return Ok(StreamSource { |
| id: format!("stream-{}", server.id), |
| label: server.label.clone(), |
| format: StreamFormat::Hls, |
| manifest_url: Some(m3u8.clone()), |
| videos: vec![VideoTrack { |
| resolution: VideoResolution { |
| width: 1920, |
| height: 1080, |
| hdr: false, |
| label: "Auto (HLS)".to_string(), |
| }, |
| url: m3u8, |
| mime_type: Some("application/vnd.apple.mpegurl".to_string()), |
| bitrate: None, |
| codecs: None, |
| }], |
| subtitles: vec![], |
| headers: vec![ |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() }, |
| Attr { key: "Referer".to_string(), value: server_url.clone() }, |
| ], |
| extra: vec![], |
| }); |
| } |
|
|
| Err(PluginError::NotFound) |
| } |
|
|
| fn search_subtitles(_ctx: RequestContext, _query: SubtitleQuery) -> Result<Vec<SubtitleEntry>, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
|
|
| fn download_subtitle(_ctx: RequestContext, _id: String) -> Result<SubtitleFile, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
|
|
| fn get_articles(_ctx: RequestContext) -> Result<Vec<ArticleSection>, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
|
|
| fn search_articles(_ctx: RequestContext, _query: String) -> Result<Vec<Article>, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Component { |
| fn build_stream_source(server: &Server, stream_url: &str, subtitles: Vec<SubtitleTrack>) -> Result<StreamSource, PluginError> { |
| let is_hls = stream_url.contains(".m3u8") || stream_url.contains("m3u8"); |
| let format = if is_hls { StreamFormat::Hls } else { StreamFormat::Progressive }; |
| let manifest_url = if is_hls { Some(stream_url.to_string()) } else { None }; |
|
|
| Ok(StreamSource { |
| id: format!("stream-{}", server.id), |
| label: server.label.clone(), |
| format, |
| manifest_url, |
| videos: vec![VideoTrack { |
| resolution: VideoResolution { |
| width: 1920, |
| height: 1080, |
| hdr: false, |
| label: "Auto (HLS)".to_string(), |
| }, |
| url: stream_url.to_string(), |
| mime_type: if is_hls { |
| Some("application/vnd.apple.mpegurl".to_string()) |
| } else { |
| Some("video/mp4".to_string()) |
| }, |
| bitrate: None, |
| codecs: None, |
| }], |
| subtitles, |
| headers: vec![ |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() }, |
| Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, |
| ], |
| extra: vec![], |
| }) |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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 normalize_url(raw: &str) -> String { |
| if raw.starts_with("//") { |
| format!("https:{}", raw) |
| } else if raw.starts_with("/") { |
| format!("{}{}", BASE_URL, raw) |
| } else { |
| raw.to_string() |
| } |
| } |
|
|
| |
| fn classify_server_type(url: &str, sub_flag: u8, dub_flag: u8) -> &'static str { |
| if url.contains("/dub/") || dub_flag == 1 { |
| "DUB" |
| } else if url.contains("/sub/") || sub_flag == 1 { |
| "SUB" |
| } else { |
| "HSUB" |
| } |
| } |
|
|
| |
| fn is_known_embed_domain(url: &str) -> bool { |
| let domains = [ |
| "vibeplayer", "streamani", "goload", "gogoplay", |
| "playgo", "anihdplay", "gogohd", "streamta", |
| "vidcdn", "sbplay", "dood", "mixdrop", |
| "streamlare", "filemoon", "voe", "mp4upload", |
| ]; |
| domains.iter().any(|d| url.contains(d)) |
| } |
|
|
| fn clean_html(s: &str) -> String { |
| let mut result = String::new(); |
| let mut in_tag = false; |
| for c in s.chars() { |
| match c { |
| '<' => in_tag = true, |
| '>' => in_tag = false, |
| _ if !in_tag => result.push(c), |
| _ => {} |
| } |
| } |
| result = result.replace("&", "&"); |
| result = result.replace("<", "<"); |
| result = result.replace(">", ">"); |
| result = result.replace(""", "\""); |
| result = result.replace("'", "'"); |
| result = result.replace("'", "'"); |
| result = result.replace(" ", " "); |
| result.trim().to_string() |
| } |
|
|
| fn make_image_set(url: &str, layout: ImageLayout) -> ImageSet { |
| let img = Image { |
| url: url.to_string(), |
| layout, |
| width: None, |
| height: None, |
| blurhash: None, |
| }; |
| ImageSet { |
| low: Some(img.clone()), |
| medium: Some(img.clone()), |
| high: Some(img), |
| backdrop: None, |
| logo: None, |
| } |
| } |
|
|
| |
| fn extract_gogo_link_text(block: &str) -> Option<String> { |
| |
| if let Some(cat_idx) = block.find("/category/") { |
| |
| let a_search_start = if cat_idx > 200 { cat_idx - 200 } else { 0 }; |
| let a_search_area = &block[a_search_start..cat_idx + 200]; |
| if let Some(a_idx) = a_search_area.rfind("<a ") { |
| let a_end_tag = a_search_area[a_idx..].find('>')? + a_idx; |
| let close_a = a_search_area[a_end_tag..].find("</a>")?; |
| let text = &a_search_area[a_end_tag + 1..a_end_tag + close_a]; |
| return Some(clean_html(text)); |
| } |
| } |
| None |
| } |
|
|
| |
| fn parse_ep_number(name: &str, href: &str) -> f64 { |
| let cleaned = clean_html(name); |
|
|
| |
| let lower = cleaned.to_lowercase(); |
| if let Some(ep_idx) = lower.find("ep") { |
| let after = &lower[ep_idx + 2..].trim_start_matches('.').trim_start_matches(' '); |
| let num_str: String = after.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect(); |
| if let Ok(n) = num_str.parse::<f64>() { |
| return n; |
| } |
| } |
|
|
| |
| if let Some(ep_idx) = href.find("-episode-") { |
| let after = &href[ep_idx + 9..]; |
| let num_str: String = after.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect(); |
| if let Ok(n) = num_str.parse::<f64>() { |
| return n; |
| } |
| } |
|
|
| if let Some(ep_idx) = href.find("ep=") { |
| let after = &href[ep_idx + 3..]; |
| let num_str: String = after.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect(); |
| if let Ok(n) = num_str.parse::<f64>() { |
| return n; |
| } |
| } |
|
|
| |
| let words: Vec<&str> = cleaned.split_whitespace().collect(); |
| for word in words.iter().rev() { |
| if let Ok(n) = word.parse::<f64>() { |
| return n; |
| } |
| } |
|
|
| 0.0 |
| } |
|
|
| |
| fn extract_gogo_genres(html: &str) -> Vec<String> { |
| let mut genres = Vec::new(); |
|
|
| |
| |
| let pattern = "class=\"type\""; |
| let mut offset = 0; |
|
|
| while let Some(idx) = html[offset..].find(pattern) { |
| let block_end = html[offset + idx..].find("</p>").unwrap_or(500).min(500); |
| let block = &html[offset + idx..offset + idx + block_end]; |
|
|
| if block.contains("Genre") { |
| |
| let a_pattern = "<a"; |
| let mut a_offset = 0; |
| while let Some(a_idx) = block[a_offset..].find(a_pattern) { |
| let a_tag_start = a_offset + a_idx; |
| if let Some(gt) = block[a_tag_start..].find('>') { |
| let text_start = a_tag_start + gt + 1; |
| if let Some(close) = block[text_start..].find("</a>") { |
| let text = &block[text_start..text_start + close]; |
| let cleaned = clean_html(text); |
| if !cleaned.is_empty() { |
| genres.push(cleaned); |
| } |
| a_offset = text_start + close + 1; |
| continue; |
| } |
| } |
| break; |
| } |
| break; |
| } |
|
|
| offset = offset + idx + 1; |
| } |
|
|
| genres |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| fn extract_m3u8_from_js(html: &str) -> Option<String> { |
| |
| for pattern in &[ |
| "const src = \"", |
| "const src=\"", |
| "var src = \"", |
| "var src=\"", |
| "let src = \"", |
| "let src=\"", |
| ] { |
| if let Some(idx) = html.find(pattern) { |
| let val_start = idx + pattern.len(); |
| if let Some(end) = html[val_start..].find('"') { |
| let url = &html[val_start..val_start + end]; |
| if url.contains(".m3u8") { |
| return Some(url.to_string()); |
| } |
| } |
| } |
| } |
|
|
| |
| for pattern in &[ |
| "file:\"", |
| "file: \"", |
| "source:\"", |
| "source: \"", |
| "src:\"", |
| "src: \"", |
| ] { |
| if let Some(idx) = html.find(pattern) { |
| let val_start = idx + pattern.len(); |
| if let Some(end) = html[val_start..].find('"') { |
| let url = &html[val_start..val_start + end]; |
| if url.contains(".m3u8") { |
| return Some(url.to_string()); |
| } |
| } |
| } |
| } |
|
|
| |
| let m3u8_pattern = ".m3u8"; |
| if let Some(idx) = html.find(m3u8_pattern) { |
| |
| let before = &html[..idx + m3u8_pattern.len()]; |
| let url_start = before.rfind("http").or_else(|| before.rfind("https"))?; |
| |
| let after = &html[idx + m3u8_pattern.len()..]; |
| let url_end_offset = after.find(|c: char| c == '"' || c == '\'' || c == ' ' || c == '\n' || c == '<') |
| .unwrap_or(after.len()); |
| let url = &html[url_start..idx + m3u8_pattern.len() + url_end_offset]; |
| if url.starts_with("http") && url.contains(".m3u8") { |
| return Some(url.to_string()); |
| } |
| } |
|
|
| None |
| } |
|
|
| bindings::export!(Component with_types_in bindings); |
|
|