/*! HiAnime streaming plugin for hianime.ws Reverse-engineered from the site's internal AJAX API. Features: - Search anime with HTML scraping + AJAX suggestions - Fetch detailed information with episodes, score, description - Extract HLS video sources from multiple servers (Sub/Dub) - Token-based AJAX authentication via window.__$ extraction - Full HLS (.m3u8) stream support with subtitle tracks */ #[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"; // ============================================================================ // Minimal HTML DOM parser (WASM-compatible, no external dep) // ============================================================================ 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 { 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 { 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 { 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 { let attr_pattern = format!("{}=\"", attr); if let Some(idx) = tag_str.find(&attr_pattern) { let val_start = idx + attr_pattern.len(); let val_end = tag_str[val_start..].find('"')? + val_start; Some(tag_str[val_start..val_end].to_string()) } else { None } } // ============================================================================ // HTTP helpers // ============================================================================ impl Component { fn get_headers() -> Vec { vec![ Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36".to_string() }, Attr { key: "Connection".to_string(), value: "keep-alive".to_string() }, Attr { key: "Accept".to_string(), value: "text/html, */*; q=0.01".to_string() }, Attr { key: "Accept-Language".to_string(), value: "en-US,en;q=0.5".to_string() }, Attr { key: "Sec-GPC".to_string(), value: "1".to_string() }, Attr { key: "Sec-Fetch-Dest".to_string(), value: "empty".to_string() }, Attr { key: "Sec-Fetch-Mode".to_string(), value: "cors".to_string() }, Attr { key: "Sec-Fetch-Site".to_string(), value: "same-origin".to_string() }, Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, ] } fn get_ajax_headers() -> Vec { let mut h = Self::get_headers(); h.push(Attr { key: "X-Requested-With".to_string(), value: "XMLHttpRequest".to_string() }); h.push(Attr { key: "Accept".to_string(), value: "application/json, text/javascript, */*; q=0.01".to_string() }); h } fn fetch_url(url: &str) -> Result { Self::fetch_url_with_headers(url, Self::get_headers()) } fn fetch_url_with_headers(url: &str, headers: Vec) -> Result { let req = http::Request { method: http::Method::Get, url: url.to_string(), headers, body: None, timeout_ms: Some(15000), follow_redirects: true, cache_mode: http::CacheMode::NoStore, max_bytes: Some(5 * 1024 * 1024), }; match http::send_request(&req) { Ok(resp) if resp.status == 200 => String::from_utf8(resp.body) .map_err(|e| PluginError::Parse(format!("UTF-8 error: {}", e))), Ok(resp) => Err(PluginError::Network(format!("HTTP {}", resp.status))), Err(e) => Err(e), } } fn fetch_json(url: &str, extra_headers: Vec) -> Result { let body = Self::fetch_url_with_headers(url, extra_headers)?; serde_json::from_str(&body).map_err(|e| PluginError::Parse(format!("JSON error: {}", e))) } /// Extract the encryption key `window.__$` from the page HTML fn extract_session_key(html: &str) -> Option { // Pattern: window.__$='ENCRYPTION_KEY' 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 } } // ======================================================================== // Scrape anime cards from search/browse pages // ======================================================================== fn scrape_anime_cards(html: &str) -> Vec { let mut results = Vec::new(); // HiAnime uses href="/watch/{slug}-{id}" format 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()); // Extract the surrounding block for title and image 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("
", "")) .or_else(|| extract_in_block(block, "class=\"dynamic-name\">", "")) .unwrap_or_else(|| anime_id.replace('-', " ")); // Image let image = extract_in_block(block, "data-src=\"", "\"") .or_else(|| extract_in_block(block, "src=\"", "\"")) .unwrap_or_default(); // Score/rating let score = extract_in_block(block, "class=\"tick-item tick-rating\">", "") .or_else(|| extract_in_block(block, "class=\"scd-item score\">", "")) .and_then(|s| s.trim().parse::().ok()) .map(|s| (s * 10.0) as u32); // Type (TV, Movie, etc.) let _type_str = extract_in_block(block, "class=\"tick-item tick-type\">", "") .or_else(|| extract_in_block(block, "class=\"fdi-item\">", "")) .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 } } // ============================================================================ // Guest implementation // ============================================================================ impl Guest for Component { fn get_home(_ctx: RequestContext) -> Result, PluginError> { let mut sections = Vec::new(); // Category links 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![], }); // Fetch home page for trending/featured 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 { 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 { if query.trim().is_empty() { return Ok(PagedResult { items: vec![], categories: vec![], next_page: None, }); } let encoded = query.replace(" ", "+"); let url = format!("{}/browser?keyword={}&page=1", BASE_URL, encoded); let html = Self::fetch_url(&url)?; let items = Self::scrape_anime_cards(&html); Ok(PagedResult { items, categories: vec![], next_page: None, }) } fn get_info(_ctx: RequestContext, id: String) -> Result { let url = format!("{}/watch/{}", BASE_URL, id); let html = Self::fetch_url(&url)?; let dom = Dom::parse(&html); // Title 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\">", "")) .filter(|t| !t.is_empty()) .unwrap_or_else(|| id.clone()); // Image / poster 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(); // Description let description = extract_in_block(&html, "class=\"film-description\"", "
") .or_else(|| extract_in_block(&html, "class=\"anisc-info\"", "")) .or_else(|| extract_in_block(&html, "property=\"og:description\" content=\"", "\"")) .map(|s| clean_html(&s)) .filter(|t| !t.is_empty()); // Score let score = extract_in_block(&html, "class=\"scd-item score\"", "") .or_else(|| extract_in_block(&html, "class=\"film-stats\"", "").and_then(|b| extract_in_block(&b, "tick-rating\">", "().ok()) .map(|s| (s * 10.0) as u32); // Genres let genres = extract_genres(&html); // Status let status = extract_in_block(&html, ">Status", "") .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, }); // Studio let studio = extract_in_block(&html, ">Studios", "") .or_else(|| extract_in_block(&html, ">Studios:", "")) .map(|s| clean_html(&s)); // Type (TV, Movie, etc.) let _type_str = extract_in_block(&html, ">Type", "") .map(|s| clean_html(&s).trim().to_string()) .unwrap_or_default(); // Year let year = extract_in_block(&html, ">Premiered", "") .or_else(|| extract_in_block(&html, ">Year", "")) .map(|s| clean_html(&s).trim().to_string()); // MAL ID let mal_id = dom.find_attr_by_id("watch-page", "data-mal-id") .or_else(|| extract_in_block(&html, "data-mal-id=\"", "\"")); // Episodes — fetch via AJAX // HiAnime loads episodes via AJAX: /ajax/episodes/list/{anime_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, PluginError> { // The episode ID format: "anime_id?ep=NUM" // The id parameter already contains the full episode ID. let ep_id = id.clone(); // Fetch the watch page to get the session key and episode server list 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); // HiAnime AJAX endpoint for servers: /ajax/episodes/servers?episodeId={ep_id} 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(); // Parse server groups: sub, dub 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("").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]; // Also extract server name from the text content 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() { // Fallback: create a default server 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 { // Parse server ID: "episode_id|server_id|type" 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]; // "sub" or "dub" // Fetch the watch page to get session key 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()))?; // HiAnime AJAX endpoint for stream sources // /ajax/episodes/sources?episodeId={ep_id}&server={server_id}&type={type} 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(); // Add session key as cookie-like header ajax_headers.push(Attr { key: "X-CSRF-Token".to_string(), value: session_key.clone() }); let json = Self::fetch_json(&stream_url, ajax_headers)?; // Parse streaming links 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 { // Case 1: Direct m3u8 URL 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, }); } // Case 2: Array of sources 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, }); // Extract tracks/subtitles from this source 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()), }); } } } } } } } // Case 3: Object with "file" key 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, }); } } } // Fallback: try to find any m3u8 URL in the JSON response 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, PluginError> { Err(PluginError::Unsupported) } fn download_subtitle(_ctx: RequestContext, _id: String) -> Result { Err(PluginError::Unsupported) } fn get_articles(_ctx: RequestContext) -> Result, PluginError> { Err(PluginError::Unsupported) } fn search_articles(_ctx: RequestContext, _query: String) -> Result, PluginError> { Err(PluginError::Unsupported) } } // ============================================================================ // Episode fetching via AJAX // ============================================================================ impl Component { fn fetch_episodes(anime_id: &str, page_html: &str) -> Result, PluginError> { // HiAnime AJAX endpoint: /ajax/episodes/list/{anime_id} let url = format!("{}/ajax/episodes/list/{}", BASE_URL, anime_id); let mut headers = Self::get_ajax_headers(); // Add session key if available 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(); // Parse ... let a_pattern = "').unwrap_or(dom.html.len() - a_start) + a_start; let a_tag = &dom.html[a_start..a_end]; let after_a = &dom.html[a_end + 1..]; let ep_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("") { 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; } // Episode ID format: anime_id?ep=ep_id 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) } } // ============================================================================ // Utility functions // ============================================================================ fn extract_in_block(block: &str, prefix: &str, suffix: &str) -> Option { let start = block.find(prefix)? + prefix.len(); let end = block[start..].find(suffix)? + start; Some(block[start..end].to_string()) } fn clean_html(s: &str) -> String { let mut result = String::new(); let mut in_tag = false; for c in s.chars() { match c { '<' => in_tag = true, '>' => in_tag = false, _ if !in_tag => result.push(c), _ => {} } } result = result.replace("&", "&"); result = result.replace("<", "<"); result = result.replace(">", ">"); result = result.replace(""", "\""); result = result.replace("'", "'"); result = result.replace("'", "'"); result.trim().to_string() } fn extract_genres(html: &str) -> Vec { let mut genres = Vec::new(); if let Some(idx) = html.find("Genres") { let block_end = html[idx..].find("").unwrap_or(500).min(500); let block = &html[idx..idx + block_end]; let a_pattern = "') { if let Some(close) = block[abs + tag_end + 1..].find("") { let text = block[abs + tag_end + 1..abs + tag_end + 1 + close].trim().to_string(); if !text.is_empty() && text.len() < 50 { genres.push(clean_html(&text)); } } } offset = abs + 1; } } genres } fn make_image_set(url: &str, layout: ImageLayout) -> ImageSet { let img = Image { url: url.to_string(), layout, width: None, height: None, blurhash: None, }; ImageSet { low: Some(img.clone()), medium: Some(img.clone()), high: Some(img), backdrop: None, logo: None, } } bindings::export!(Component with_types_in bindings);