| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| #[allow(warnings)] |
| mod bindings; |
|
|
| use bindings::bex::plugin::common::*; |
| use bindings::bex::plugin::http; |
| use bindings::exports::api::Guest; |
| use serde_json::Value; |
|
|
| struct Component; |
|
|
| const BASE_URL: &str = "https://hianime.ws"; |
|
|
| |
| |
| |
|
|
| struct Dom { |
| html: String, |
| } |
|
|
| impl Dom { |
| fn parse(html: &str) -> Self { |
| Dom { html: html.to_string() } |
| } |
|
|
| fn find_attr(&self, tag: &str, attr: &str) -> Option<String> { |
| let pattern = format!("<{}", tag); |
| let idx = self.html.find(&pattern)?; |
| let tag_end = self.html[idx..].find('>')? + idx; |
| let tag_str = &self.html[idx..tag_end]; |
| extract_attr_value(tag_str, attr) |
| } |
|
|
| fn find_attr_by_id(&self, id: &str, attr: &str) -> Option<String> { |
| let id_pattern = format!("id=\"{}\"", id); |
| let id_idx = self.html.find(&id_pattern)?; |
| let tag_start = self.html[..id_idx].rfind('<')?; |
| let tag_end = self.html[tag_start..].find('>')? + tag_start; |
| let tag_str = &self.html[tag_start..tag_end]; |
| extract_attr_value(tag_str, attr) |
| } |
|
|
| fn find_class_text(&self, tag: &str, class: &str) -> Option<String> { |
| let pattern = format!("<{} class=\"{}\"", tag, class); |
| let idx = self.html.find(&pattern)?; |
| let content_start = self.html[idx..].find('>')? + idx + 1; |
| let close = format!("</{}>", tag); |
| let content_end = self.html[content_start..].find(&close)? + content_start; |
| Some(self.html[content_start..content_end].trim().to_string()) |
| } |
| } |
|
|
| fn extract_attr_value(tag_str: &str, attr: &str) -> Option<String> { |
| let attr_pattern = format!("{}=\"", attr); |
| if let Some(idx) = tag_str.find(&attr_pattern) { |
| let val_start = idx + attr_pattern.len(); |
| let val_end = tag_str[val_start..].find('"')? + val_start; |
| Some(tag_str[val_start..val_end].to_string()) |
| } else { |
| None |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Component { |
| fn get_headers() -> Vec<Attr> { |
| vec![ |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() }, |
| Attr { key: "Connection".to_string(), value: "keep-alive".to_string() }, |
| Attr { key: "Accept".to_string(), value: "text/html, */*; q=0.01".to_string() }, |
| Attr { key: "Accept-Language".to_string(), value: "en-US,en;q=0.5".to_string() }, |
| Attr { key: "Sec-GPC".to_string(), value: "1".to_string() }, |
| Attr { key: "Sec-Fetch-Dest".to_string(), value: "empty".to_string() }, |
| Attr { key: "Sec-Fetch-Mode".to_string(), value: "cors".to_string() }, |
| Attr { key: "Sec-Fetch-Site".to_string(), value: "same-origin".to_string() }, |
| Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, |
| ] |
| } |
|
|
| fn get_ajax_headers() -> Vec<Attr> { |
| let mut h = Self::get_headers(); |
| h.push(Attr { key: "X-Requested-With".to_string(), value: "XMLHttpRequest".to_string() }); |
| h.push(Attr { key: "Accept".to_string(), value: "application/json, text/javascript, */*; q=0.01".to_string() }); |
| h |
| } |
|
|
| fn fetch_url(url: &str) -> Result<String, PluginError> { |
| Self::fetch_url_with_headers(url, Self::get_headers()) |
| } |
|
|
| fn fetch_url_with_headers(url: &str, headers: Vec<Attr>) -> Result<String, PluginError> { |
| let req = http::Request { |
| method: http::Method::Get, |
| url: url.to_string(), |
| headers, |
| body: None, |
| timeout_ms: Some(15000), |
| follow_redirects: true, |
| cache_mode: http::CacheMode::NoStore, |
| max_bytes: Some(5 * 1024 * 1024), |
| }; |
|
|
| match http::send_request(&req) { |
| Ok(resp) if resp.status == 200 => String::from_utf8(resp.body) |
| .map_err(|e| PluginError::Parse(format!("UTF-8 error: {}", e))), |
| Ok(resp) => Err(PluginError::Network(format!("HTTP {}", resp.status))), |
| Err(e) => Err(e), |
| } |
| } |
|
|
| fn fetch_json(url: &str, extra_headers: Vec<Attr>) -> Result<Value, PluginError> { |
| let body = Self::fetch_url_with_headers(url, extra_headers)?; |
| serde_json::from_str(&body).map_err(|e| PluginError::Parse(format!("JSON error: {}", e))) |
| } |
|
|
| |
| fn extract_session_key(html: &str) -> Option<String> { |
| |
| let pattern = "window.__$='"; |
| let idx = html.find(pattern)?; |
| let start = idx + pattern.len(); |
| let end = html[start..].find('\'')?; |
| let key = &html[start..start + end]; |
| if key.len() > 10 { |
| Some(key.to_string()) |
| } else { |
| None |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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=\"flw-item\"") |
| .or_else(|| html[..abs_idx].rfind("class=\"film-poster\"")) |
| .or_else(|| html[..abs_idx].rfind("<div class=\"")) |
| .unwrap_or(abs_idx); |
|
|
| let block_end = html[abs_idx..].find("class=\"flw-item\"") |
| .or_else(|| html[abs_idx..].find("class=\"film-poster\"")) |
| .map(|i| abs_idx + i) |
| .unwrap_or(html.len().min(abs_idx + 3000)); |
|
|
| let block = &html[block_start..block_end.min(html.len())]; |
|
|
| |
| let title = extract_in_block(block, "title=\"", "\"") |
| .or_else(|| extract_in_block(block, "class=\"film-name\">", "</a>")) |
| .or_else(|| extract_in_block(block, "class=\"dynamic-name\">", "</a>")) |
| .unwrap_or_else(|| anime_id.replace('-', " ")); |
|
|
| |
| let image = extract_in_block(block, "data-src=\"", "\"") |
| .or_else(|| extract_in_block(block, "src=\"", "\"")) |
| .unwrap_or_default(); |
|
|
| |
| let score = extract_in_block(block, "class=\"tick-item tick-rating\">", "</span>") |
| .or_else(|| extract_in_block(block, "class=\"scd-item score\">", "</span>")) |
| .and_then(|s| s.trim().parse::<f64>().ok()) |
| .map(|s| (s * 10.0) as u32); |
|
|
| |
| let _type_str = extract_in_block(block, "class=\"tick-item tick-type\">", "</span>") |
| .or_else(|| extract_in_block(block, "class=\"fdi-item\">", "</span>")) |
| .map(|s| s.trim().to_string()) |
| .unwrap_or_default(); |
|
|
| results.push(MediaCard { |
| id: anime_id.to_string(), |
| title: clean_html(&title), |
| kind: Some(MediaKind::Anime), |
| images: if image.is_empty() { |
| None |
| } else { |
| Some(make_image_set(&image, ImageLayout::Portrait)) |
| }, |
| original_title: None, |
| tagline: None, |
| year: None, |
| score, |
| genres: vec![], |
| status: None, |
| content_rating: None, |
| url: Some(format!("{}/watch/{}", BASE_URL, anime_id)), |
| ids: vec![], |
| extra: vec![], |
| }); |
|
|
| offset = val_start + 1; |
| } |
|
|
| results |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Guest for Component { |
| fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> { |
| let mut sections = Vec::new(); |
|
|
| |
| let categories = vec![ |
| ("Most Popular", "most-popular"), |
| ("Top Airing", "top-airing"), |
| ("Most Favorite", "most-favorite"), |
| ("Completed", "completed"), |
| ("Recently Updated", "recently-updated"), |
| ("Recently Added", "recently-added"), |
| ("Top Upcoming", "top-upcoming"), |
| ("Subbed Anime", "subbed-anime"), |
| ("Dubbed Anime", "dubbed-anime"), |
| ("Movie", "movie"), |
| ("TV", "tv"), |
| ("OVA", "ova"), |
| ("ONA", "ona"), |
| ("Special", "special"), |
| ]; |
|
|
| sections.push(HomeSection { |
| id: "categories".to_string(), |
| title: "Browse".to_string(), |
| subtitle: None, |
| items: vec![], |
| next_page: None, |
| layout: CardLayout::Grid, |
| show_rank: false, |
| categories: categories |
| .into_iter() |
| .map(|(title, id)| CategoryLink { |
| id: id.to_string(), |
| title: title.to_string(), |
| subtitle: None, |
| image: None, |
| }) |
| .collect(), |
| extra: vec![], |
| }); |
|
|
| |
| let url = format!("{}/home", BASE_URL); |
| if let Ok(html) = Self::fetch_url(&url) { |
| let items = Self::scrape_anime_cards(&html); |
| if !items.is_empty() { |
| sections.push(HomeSection { |
| id: "trending".to_string(), |
| title: "Trending".to_string(), |
| subtitle: None, |
| items: items.into_iter().take(20).collect(), |
| next_page: None, |
| layout: CardLayout::Grid, |
| show_rank: true, |
| categories: vec![], |
| extra: vec![], |
| }); |
| } |
| } |
|
|
| Ok(sections) |
| } |
|
|
| fn get_category(_ctx: RequestContext, id: String, page: PageCursor) -> Result<PagedResult, PluginError> { |
| let page_num: u32 = page |
| .token |
| .as_ref() |
| .and_then(|t| t.parse().ok()) |
| .unwrap_or(1); |
|
|
| let url = format!("{}/{}?page={}", BASE_URL, id, page_num); |
| let html = Self::fetch_url(&url)?; |
| let items = Self::scrape_anime_cards(&html); |
| let next_page = if items.len() >= 20 { |
| Some((page_num + 1).to_string()) |
| } else { |
| None |
| }; |
|
|
| Ok(PagedResult { |
| items, |
| categories: vec![], |
| next_page, |
| }) |
| } |
|
|
| fn search(_ctx: RequestContext, query: String, _filters: SearchFilters) -> Result<PagedResult, PluginError> { |
| if query.trim().is_empty() { |
| return Ok(PagedResult { |
| items: vec![], |
| categories: vec![], |
| next_page: None, |
| }); |
| } |
|
|
| let encoded = query.replace(" ", "+"); |
| let url = format!("{}/browser?keyword={}&page=1", BASE_URL, encoded); |
| let html = Self::fetch_url(&url)?; |
| let items = Self::scrape_anime_cards(&html); |
|
|
| Ok(PagedResult { |
| items, |
| categories: vec![], |
| next_page: None, |
| }) |
| } |
|
|
| fn get_info(_ctx: RequestContext, id: String) -> Result<MediaInfo, PluginError> { |
| let url = format!("{}/watch/{}", BASE_URL, id); |
| let html = Self::fetch_url(&url)?; |
| let dom = Dom::parse(&html); |
|
|
| |
| let title = dom.find_attr("h1", "data-jp") |
| .or_else(|| dom.find_class_text("h1", "title")) |
| .or_else(|| dom.find_class_text("h2", "film-name")) |
| .or_else(|| extract_in_block(&html, "class=\"anisc-detail\">", "</h1>")) |
| .filter(|t| !t.is_empty()) |
| .unwrap_or_else(|| id.clone()); |
|
|
| |
| let image = extract_in_block(&html, "class=\"film-poster\"", "class=\"film-poster-img\"") |
| .and_then(|block| extract_in_block(&block, "src=\"", "\"")) |
| .or_else(|| extract_in_block(&html, "class=\"anisc-poster\"", "src=\"").and_then(|_| extract_in_block(&html, "src=\"", "\""))) |
| .or_else(|| extract_in_block(&html, "property=\"og:image\" content=\"", "\"")) |
| .unwrap_or_default(); |
|
|
| |
| let description = extract_in_block(&html, "class=\"film-description\"", "</div>") |
| .or_else(|| extract_in_block(&html, "class=\"anisc-info\"", "</div>")) |
| .or_else(|| extract_in_block(&html, "property=\"og:description\" content=\"", "\"")) |
| .map(|s| clean_html(&s)) |
| .filter(|t| !t.is_empty()); |
|
|
| |
| let score = extract_in_block(&html, "class=\"scd-item score\"", "</span>") |
| .or_else(|| extract_in_block(&html, "class=\"film-stats\"", "</div>").and_then(|b| extract_in_block(&b, "tick-rating\">", "</"))) |
| .or_else(|| dom.find_attr_by_id("vote-info", "data-score")) |
| .and_then(|s| s.trim().parse::<f64>().ok()) |
| .map(|s| (s * 10.0) as u32); |
|
|
| |
| let genres = extract_genres(&html); |
|
|
| |
| let status = extract_in_block(&html, ">Status</span>", "</span>") |
| .map(|s| clean_html(&s).trim().to_lowercase()) |
| .and_then(|s| match s.as_str() { |
| "ongoing" | "currently airing" => Some(Status::Ongoing), |
| "completed" | "finished airing" => Some(Status::Completed), |
| "upcoming" | "not yet aired" => Some(Status::Upcoming), |
| _ => None, |
| }); |
|
|
| |
| let studio = extract_in_block(&html, ">Studios</span>", "</span>") |
| .or_else(|| extract_in_block(&html, ">Studios:</span>", "</a>")) |
| .map(|s| clean_html(&s)); |
|
|
| |
| let _type_str = extract_in_block(&html, ">Type</span>", "</span>") |
| .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 mal_id = dom.find_attr_by_id("watch-page", "data-mal-id") |
| .or_else(|| extract_in_block(&html, "data-mal-id=\"", "\"")); |
|
|
| |
| |
| let episodes = Self::fetch_episodes(&id, &html).unwrap_or_default(); |
|
|
| let seasons = if episodes.is_empty() { |
| vec![] |
| } else { |
| vec![Season { |
| id: format!("{}_s1", id), |
| title: "Season 1".to_string(), |
| number: Some(1.0), |
| year: None, |
| episodes, |
| }] |
| }; |
|
|
| let mut ids = vec![]; |
| if let Some(ref mid) = mal_id { |
| ids.push(LinkedId { source: "mal".to_string(), id: mid.clone() }); |
| } |
|
|
| Ok(MediaInfo { |
| id: id.clone(), |
| title: clean_html(&title), |
| kind: MediaKind::Anime, |
| images: if image.is_empty() { |
| None |
| } else { |
| Some(make_image_set(&image, ImageLayout::Portrait)) |
| }, |
| original_title: None, |
| description, |
| score, |
| scored_by: None, |
| year, |
| release_date: None, |
| genres, |
| tags: vec![], |
| status, |
| content_rating: None, |
| seasons, |
| cast: vec![], |
| crew: vec![], |
| runtime_minutes: None, |
| trailer_url: None, |
| ids, |
| studio, |
| country: None, |
| language: None, |
| url: Some(format!("{}/watch/{}", BASE_URL, id)), |
| extra: vec![], |
| }) |
| } |
|
|
| fn get_servers(_ctx: RequestContext, id: String) -> Result<Vec<Server>, PluginError> { |
| |
| |
| let ep_id = id.clone(); |
|
|
| |
| let anime_id = id.split("?ep=").next().unwrap_or(&id); |
| let watch_url = format!("{}/watch/{}", BASE_URL, anime_id); |
| let html = Self::fetch_url(&watch_url)?; |
| let _session_key = Self::extract_session_key(&html); |
|
|
| |
| let encoded_ep = ep_id.replace("?", "%3F").replace("=", "%3D"); |
| let servers_url = format!("{}/ajax/episodes/servers?episodeId={}", BASE_URL, encoded_ep); |
|
|
| let json = Self::fetch_json(&servers_url, Self::get_ajax_headers())?; |
| let html_content = json |
| .get("html") |
| .and_then(|v| v.as_str()) |
| .ok_or(PluginError::Parse("No html in servers response".to_string()))?; |
|
|
| let dom = Dom::parse(html_content); |
| let mut servers = Vec::new(); |
|
|
| |
| let groups = vec![("sub", "Sub"), ("dub", "Dub")]; |
| for (data_id, label) in groups { |
| let pattern = format!("data-type=\"{}\"", data_id); |
| if let Some(group_start) = dom.html.find(&pattern) { |
| let group_end = dom.html[group_start..].find("</div>").unwrap_or(dom.html.len() - group_start) + group_start; |
| let block = &dom.html[group_start..group_end.min(dom.html.len())]; |
|
|
| let server_pattern = "data-server-id=\""; |
| let mut offset = 0; |
| let mut priority: u8 = 1; |
| while let Some(idx) = block[offset..].find(server_pattern) { |
| let sid_start = offset + idx + server_pattern.len(); |
| if let Some(sid_end) = block[sid_start..].find('"') { |
| let server_id = &block[sid_start..sid_start + sid_end]; |
|
|
| |
| let name_pattern = "class=\"server\">"; |
| let server_name = if let Some(nidx) = block[sid_start..].find(name_pattern) { |
| let name_start = sid_start + nidx + name_pattern.len(); |
| if let Some(name_end) = block[name_start..].find('<') { |
| block[name_start..name_start + name_end].trim().to_string() |
| } else { |
| format!("{} {}", label, priority) |
| } |
| } else { |
| format!("{} {}", label, priority) |
| }; |
|
|
| servers.push(Server { |
| id: format!("{}|{}|{}", ep_id, server_id, data_id), |
| label: server_name, |
| url: format!("{}/ajax/episodes/servers?episodeId={}", BASE_URL, encoded_ep), |
| priority, |
| extra: vec![Attr { key: "type".to_string(), value: data_id.to_string() }], |
| }); |
| priority += 1; |
| } |
| offset = sid_start + 1; |
| } |
| } |
| } |
|
|
| if servers.is_empty() { |
| |
| servers.push(Server { |
| id: format!("{}|hd-1|sub", ep_id), |
| label: "Sub HD-1".to_string(), |
| url: format!("{}/ajax/episodes/servers?episodeId={}", BASE_URL, encoded_ep), |
| priority: 1, |
| extra: vec![Attr { key: "type".to_string(), value: "sub".to_string() }], |
| }); |
| } |
|
|
| Ok(servers) |
| } |
|
|
| fn resolve_stream(_ctx: RequestContext, server: Server) -> Result<StreamSource, PluginError> { |
| |
| let parts: Vec<&str> = server.id.split('|').collect(); |
| if parts.len() < 3 { |
| return Err(PluginError::InvalidInput("Invalid server ID format".to_string())); |
| } |
|
|
| let episode_id = parts[0]; |
| let server_id = parts[1]; |
| let server_type = parts[2]; |
|
|
| |
| let anime_id = episode_id.split("?ep=").next().unwrap_or(episode_id); |
| let watch_url = format!("{}/watch/{}", BASE_URL, anime_id); |
| let html = Self::fetch_url(&watch_url)?; |
| let session_key = Self::extract_session_key(&html) |
| .ok_or_else(|| PluginError::Parse("Failed to extract session key from page".to_string()))?; |
|
|
| |
| |
| let encoded_ep = episode_id.replace("?", "%3F").replace("=", "%3D"); |
| let stream_url = format!( |
| "{}/ajax/episodes/sources?episodeId={}&server={}&type={}", |
| BASE_URL, encoded_ep, server_id, server_type |
| ); |
|
|
| let mut ajax_headers = Self::get_ajax_headers(); |
| |
| ajax_headers.push(Attr { key: "X-CSRF-Token".to_string(), value: session_key.clone() }); |
|
|
| let json = Self::fetch_json(&stream_url, ajax_headers)?; |
|
|
| |
| let sources = json |
| .get("link") |
| .or_else(|| json.get("sources")) |
| .or_else(|| json.get("streamingLink")) |
| .or_else(|| json.get("result")); |
|
|
| let mut videos = Vec::new(); |
| let mut subtitles = Vec::new(); |
|
|
| if let Some(sources_val) = sources { |
| |
| if let Some(file_url) = sources_val.get("file").and_then(|v| v.as_str()) { |
| let is_hls = file_url.contains(".m3u8"); |
| videos.push(VideoTrack { |
| resolution: VideoResolution { |
| width: 1920, |
| height: 1080, |
| hdr: false, |
| label: "Auto (HLS)".to_string(), |
| }, |
| url: file_url.to_string(), |
| mime_type: if is_hls { |
| Some("application/vnd.apple.mpegurl".to_string()) |
| } else { |
| Some("video/mp4".to_string()) |
| }, |
| bitrate: None, |
| codecs: None, |
| }); |
| } |
|
|
| |
| if let Some(sources_arr) = sources_val.as_array() { |
| for source in sources_arr { |
| if let Some(link_obj) = source.get("link") { |
| if let Some(file_url) = link_obj.get("file").and_then(|v| v.as_str()) { |
| let is_hls = file_url.contains(".m3u8"); |
| let server_name = source.get("server").and_then(|v| v.as_str()).unwrap_or("default"); |
| let label = if is_hls { format!("HLS ({})", server_name) } else { format!("MP4 ({})", server_name) }; |
|
|
| videos.push(VideoTrack { |
| resolution: VideoResolution { width: 1920, height: 1080, hdr: false, label }, |
| url: file_url.to_string(), |
| mime_type: if is_hls { Some("application/vnd.apple.mpegurl".to_string()) } else { Some("video/mp4".to_string()) }, |
| bitrate: None, |
| codecs: None, |
| }); |
|
|
| |
| if let Some(tracks) = source.get("tracks").and_then(|v| v.as_array()) { |
| for track in tracks { |
| let track_file = track.get("file").and_then(|v| v.as_str()).unwrap_or(""); |
| let track_label = track.get("label").and_then(|v| v.as_str()).unwrap_or("Unknown"); |
| let track_kind = track.get("kind").and_then(|v| v.as_str()).unwrap_or("captions"); |
|
|
| if !track_file.is_empty() && track_kind == "captions" { |
| subtitles.push(SubtitleTrack { |
| label: track_label.to_string(), |
| url: track_file.to_string(), |
| language: Some(track_label.to_string()), |
| format: Some("vtt".to_string()), |
| }); |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| if let Some(file_url) = sources_val.get("file").and_then(|v| v.as_str()) { |
| if !file_url.is_empty() && videos.is_empty() { |
| let is_hls = file_url.contains(".m3u8"); |
| videos.push(VideoTrack { |
| resolution: VideoResolution { width: 1920, height: 1080, hdr: false, label: "Auto (HLS)".to_string() }, |
| url: file_url.to_string(), |
| mime_type: if is_hls { Some("application/vnd.apple.mpegurl".to_string()) } else { Some("video/mp4".to_string()) }, |
| bitrate: None, |
| codecs: None, |
| }); |
| } |
| } |
| } |
|
|
| |
| if videos.is_empty() { |
| let json_str = json.to_string(); |
| if let Some(idx) = json_str.find("http") { |
| let rest = &json_str[idx..]; |
| let end = rest.find('"').unwrap_or(rest.len()); |
| let url = &rest[..end]; |
| if url.contains(".m3u8") || url.contains("m3u8") { |
| videos.push(VideoTrack { |
| resolution: VideoResolution { width: 1920, height: 1080, hdr: false, label: "Auto (HLS)".to_string() }, |
| url: url.to_string(), |
| mime_type: Some("application/vnd.apple.mpegurl".to_string()), |
| bitrate: None, |
| codecs: None, |
| }); |
| } |
| } |
| } |
|
|
| if videos.is_empty() { |
| return Err(PluginError::NotFound); |
| } |
|
|
| let is_hls = videos[0].url.contains(".m3u8"); |
| let format = if is_hls { StreamFormat::Hls } else { StreamFormat::Progressive }; |
| let manifest_url = if is_hls { Some(videos[0].url.clone()) } else { None }; |
|
|
| Ok(StreamSource { |
| id: format!("stream-{}", server.id.replace('|', "-")), |
| label: server.label.clone(), |
| format, |
| manifest_url, |
| videos, |
| subtitles, |
| headers: vec![ |
| Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string() }, |
| Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, |
| ], |
| extra: vec![], |
| }) |
| } |
|
|
| fn search_subtitles(_ctx: RequestContext, _query: SubtitleQuery) -> Result<Vec<SubtitleEntry>, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
|
|
| fn download_subtitle(_ctx: RequestContext, _id: String) -> Result<SubtitleFile, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
|
|
| fn get_articles(_ctx: RequestContext) -> Result<Vec<ArticleSection>, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
|
|
| fn search_articles(_ctx: RequestContext, _query: String) -> Result<Vec<Article>, PluginError> { |
| Err(PluginError::Unsupported) |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Component { |
| fn fetch_episodes(anime_id: &str, page_html: &str) -> Result<Vec<Episode>, PluginError> { |
| |
| let url = format!("{}/ajax/episodes/list/{}", BASE_URL, anime_id); |
|
|
| let mut headers = Self::get_ajax_headers(); |
| |
| if let Some(key) = Self::extract_session_key(page_html) { |
| headers.push(Attr { key: "X-CSRF-Token".to_string(), value: key }); |
| } |
|
|
| let json = Self::fetch_json(&url, headers)?; |
| let html_content = json |
| .get("html") |
| .and_then(|v| v.as_str()) |
| .ok_or(PluginError::Parse("No html in episodes response".to_string()))?; |
|
|
| let dom = Dom::parse(html_content); |
| let mut episodes = Vec::new(); |
|
|
| |
| let a_pattern = "<a "; |
| let mut offset = 0; |
| while let Some(idx) = dom.html[offset..].find(a_pattern) { |
| let a_start = offset + idx; |
| let a_end = dom.html[a_start..].find('>').unwrap_or(dom.html.len() - a_start) + a_start; |
| let a_tag = &dom.html[a_start..a_end]; |
| let after_a = &dom.html[a_end + 1..]; |
|
|
| let ep_id = extract_attr_value(a_tag, "data-id").unwrap_or_default(); |
| let ep_num_str = extract_attr_value(a_tag, "data-number") |
| .or_else(|| extract_attr_value(a_tag, "num")) |
| .unwrap_or_default(); |
| let ep_num: f64 = ep_num_str.parse().unwrap_or(0.0); |
|
|
| let ep_title = if let Some(close_a) = after_a.find("</a>") { |
| let text = after_a[..close_a].trim(); |
| if text.is_empty() { |
| format!("Episode {}", ep_num as i32) |
| } else { |
| clean_html(text) |
| } |
| } else { |
| format!("Episode {}", ep_num as i32) |
| }; |
|
|
| if ep_id.is_empty() { |
| offset = a_end + 1; |
| continue; |
| } |
|
|
| |
| let episode_id = format!("{}?ep={}", anime_id, ep_id); |
|
|
| episodes.push(Episode { |
| id: episode_id, |
| title: ep_title, |
| number: Some(ep_num), |
| season: Some(1.0), |
| images: None, |
| description: None, |
| released: None, |
| score: None, |
| url: Some(format!("{}/watch/{}?ep={}", BASE_URL, anime_id, ep_id)), |
| tags: vec![], |
| extra: vec![], |
| }); |
|
|
| offset = a_end + 1; |
| } |
|
|
| Ok(episodes) |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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, |
| } |
| } |
|
|
| bindings::export!(Component with_types_in bindings); |
|
|