/*! yFlix streaming plugin for yflix.to / 1movies.is Reverse-engineered with enc-dec.app for token generation and stream decoding. Features: - Search movies/TV shows via enc-dec.app database API (fast, JSON) - Fetch detailed information with seasons and episodes - Extract video streams from multiple servers - Token-based authentication via enc-dec.app/api/enc-movies-flix - Stream decoding via enc-dec.app/api/dec-movies-flix (POST) - HLS and progressive stream support */ #[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://yflix.to"; const ENC_API: &str = "https://enc-dec.app/api/enc-movies-flix"; const DEC_API: &str = "https://enc-dec.app/api/dec-movies-flix"; const DB_SEARCH_API: &str = "https://enc-dec.app/db/flix/search"; const DB_FIND_API: &str = "https://enc-dec.app/db/flix/find"; // ============================================================================ // HTTP helpers — delegate to host // ============================================================================ 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, application/json, */*; q=0.01".to_string() }, Attr { key: "Accept-Language".to_string(), value: "en-US,en;q=0.5".to_string() }, Attr { key: "Sec-GPC".to_string(), value: "1".to_string() }, Attr { key: "Sec-Fetch-Dest".to_string(), value: "empty".to_string() }, Attr { key: "Sec-Fetch-Mode".to_string(), value: "cors".to_string() }, Attr { key: "Sec-Fetch-Site".to_string(), value: "same-origin".to_string() }, Attr { key: "Pragma".to_string(), value: "no-cache".to_string() }, Attr { key: "Cache-Control".to_string(), value: "no-cache".to_string() }, Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, ] } fn ajax_headers() -> Vec { let mut h = Self::get_headers(); h.push(Attr { key: "X-Requested-With".to_string(), value: "XMLHttpRequest".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::Normal, 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 {} for {}", resp.status, url))), Err(e) => Err(e), } } fn fetch_json(url: &str, headers: Vec) -> Result { let body = Self::fetch_url_with_headers(url, headers)?; serde_json::from_str(&body).map_err(|e| PluginError::Parse(format!("JSON error: {} in {}", e, &body[..body.len().min(200)]))) } // ======================================================================== // enc-dec.app token generation and decoding // ======================================================================== /// Call enc-dec.app/api/enc-movies-flix?text={text} to get an encoded token. fn enc_flix(text: &str) -> Option { Self::call_enc_api(ENC_API, text) } /// Call enc-dec.app/api/dec-movies-flix POST with {"text":"encoded"} to decode. /// Returns the decoded JSON result which contains the stream URL. fn dec_flix(text: &str) -> Option { let url = DEC_API.to_string(); let headers = vec![ Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string() }, Attr { key: "Accept".to_string(), value: "application/json, */*".to_string() }, Attr { key: "Content-Type".to_string(), value: "application/json".to_string() }, Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, ]; // POST body: {"text": "encoded_string"} let body = format!("{{\"text\":\"{}\"}}", text); let req = http::Request { method: http::Method::Post, url, headers, body: Some(body.into_bytes()), timeout_ms: Some(10000), follow_redirects: true, cache_mode: http::CacheMode::NoStore, max_bytes: Some(1024 * 1024), }; let resp = http::send_request(&req).ok()?; if resp.status < 200 || resp.status >= 300 { return None; } let body = String::from_utf8(resp.body).ok()?; // Parse JSON response: {"status":200,"result":{...}} or {"status":200,"result":"url"} if let Ok(json) = serde_json::from_str::(&body) { if let Some(result) = json.get("result") { return Some(result.clone()); } } None } /// Generic call to enc-dec.app encode endpoints (GET) fn call_enc_api(api_url: &str, text: &str) -> Option { let url = format!("{}?text={}", api_url, text); let headers = vec![ Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string() }, Attr { key: "Accept".to_string(), value: "application/json, */*".to_string() }, Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, ]; let req = http::Request { method: http::Method::Get, url, headers, body: None, timeout_ms: Some(10000), follow_redirects: true, cache_mode: http::CacheMode::NoStore, max_bytes: Some(1024 * 1024), }; let resp = http::send_request(&req).ok()?; if resp.status < 200 || resp.status >= 300 { return None; } let body = String::from_utf8(resp.body).ok()?; // Parse JSON response: {"status":200,"result":"ENCODED_TOKEN"} if let Ok(json) = serde_json::from_str::(&body) { for field in &["result", "token", "data", "enc", "encrypted", "key", "value"] { if let Some(val) = json.get(*field).and_then(|v| v.as_str()) { if !val.is_empty() { return Some(val.to_string()); } } } if let Some(s) = json.as_str() { if !s.is_empty() { return Some(s.to_string()); } } } // Fallback: plain string response let trimmed = body.trim().trim_matches('"').to_string(); if !trimmed.is_empty() && !trimmed.starts_with('{') { return Some(trimmed); } None } // ======================================================================== // Database API helpers (enc-dec.app/db/flix) // ======================================================================== /// Search the flix database via enc-dec.app fn db_search(query: &str, media_type: &str, page: u32) -> Result { let url = format!( "{}?query={}&type={}&page={}", DB_SEARCH_API, urlencoding(query), media_type, page ); let headers = vec![ Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string() }, Attr { key: "Accept".to_string(), value: "application/json, */*".to_string() }, Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, ]; Self::fetch_json(&url, headers) } /// Find a specific item in the flix database by flix_id, tmdb_id, or imdb_id fn db_find_by_flix_id(flix_id: &str) -> Result { let url = format!("{}?flix_id={}", DB_FIND_API, flix_id); let headers = vec![ Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string() }, Attr { key: "Accept".to_string(), value: "application/json, */*".to_string() }, Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, ]; Self::fetch_json(&url, headers) } // ======================================================================== // Convert database results to MediaCard // ======================================================================== fn db_result_to_card(item: &Value) -> Option { // The API can return items in two formats: // 1. Flat: {"flix_id": "...", "title": "...", ...} // 2. Nested: {"info": {"flix_id": "...", "title_en": "...", ...}, "episodes": {...}} let info = if item.get("info").is_some() { item.get("info")? } else { item }; let flix_id = info.get("flix_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); if flix_id.is_empty() { return None; } // Try title_en first (nested format), then title (flat format) let title = info.get("title_en").or_else(|| info.get("title")) .and_then(|v| v.as_str()).unwrap_or("Unknown").to_string(); let media_type = info.get("type").and_then(|v| v.as_str()).unwrap_or("movie"); let kind = match media_type { "movie" => Some(MediaKind::Movie), "tv" | "series" | "tv_series" => Some(MediaKind::Series), _ => Some(MediaKind::Unknown), }; let poster = info.get("poster").and_then(|v| v.as_str()).unwrap_or("").to_string(); let year = info.get("year").and_then(|v| v.as_str()) .and_then(|y| y.parse::().ok()).map(|y| y.to_string()) .or_else(|| info.get("year").and_then(|v| v.as_u64()).map(|y| y.to_string())); let rating = info.get("rating").and_then(|v| v.as_f64()).map(|r| (r * 10.0) as u32); let tmdb_id = info.get("tmdb_id").and_then(|v| v.as_str()) .map(|s| s.to_string()) .or_else(|| info.get("tmdb_id").and_then(|v| v.as_u64()).map(|id| id.to_string())); let imdb_id = info.get("imdb_id").and_then(|v| v.as_str()).map(|s| s.to_string()); let mut ids = Vec::new(); if let Some(ref tid) = tmdb_id { ids.push(LinkedId { source: "tmdb".to_string(), id: tid.clone() }); } if let Some(ref iid) = imdb_id { ids.push(LinkedId { source: "imdb".to_string(), id: iid.clone() }); } ids.push(LinkedId { source: "flix".to_string(), id: flix_id.clone() }); let slug = title.to_lowercase() .replace(' ', "-") .replace(|c: char| !c.is_alphanumeric() && c != '-', ""); let url_path = match media_type { "tv" | "series" | "tv_series" => format!("{}/series/{}", BASE_URL, slug), _ => format!("{}/movie/{}", BASE_URL, slug), }; Some(MediaCard { id: flix_id.clone(), title, kind, images: if poster.is_empty() { None } else { Some(make_image_set(&poster, ImageLayout::Portrait)) }, original_title: None, tagline: None, year, score: rating, genres: vec![], status: None, content_rating: None, url: Some(url_path), ids, extra: vec![], }) } /// Parse database search results, handling both bare array and {results: [...]} formats fn parse_db_results(json: &Value) -> Vec { let items: &Vec = if json.is_array() { json.as_array().unwrap() } else if let Some(arr) = json.get("results").and_then(|v| v.as_array()) { arr } else { return vec![]; }; items.iter().filter_map(Self::db_result_to_card).collect() } // ======================================================================== // Fetch movie/series info from yFlix HTML page // ======================================================================== /// Fetch info page HTML from yflix.to and extract the data-id fn fetch_info_html(slug: &str, media_type: &str) -> Result { let path = match media_type { "tv" | "series" | "tv_series" => format!("{}/series/{}", BASE_URL, slug), _ => format!("{}/movie/{}", BASE_URL, slug), }; Self::fetch_url(&path) } /// Extract data-id from the yFlix info page HTML fn extract_data_id(html: &str) -> Option { // Method 1: Find data-id near movie-rating or series-rating id for pattern in &["id=\"movie-rating\"", "id=\"series-rating\"", "id=\"rating\""] { if let Some(idx) = html.find(pattern) { let search_start = if idx > 300 { idx - 300 } else { 0 }; let search_end = (idx + 500).min(html.len()); let window = &html[search_start..search_end]; if let Some(id) = extract_attr_value(window, "data-id") { if !id.is_empty() { return Some(id); } } } } // Method 2: Find data-id in rate-box area if let Some(idx) = html.find("rate-box") { let search_end = (idx + 500).min(html.len()); let window = &html[idx..search_end]; if let Some(id) = extract_attr_value(window, "data-id") { if !id.is_empty() { return Some(id); } } } // Method 3: Any element with data-id attribute (most general) if let Some(idx) = html.find("data-id=\"") { let val_start = idx + 9; if let Some(val_end) = html[val_start..].find('"') { let id = &html[val_start..val_start + val_end]; if !id.is_empty() && id.len() < 30 { return Some(id.to_string()); } } } None } // ======================================================================== // Episode fetching via AJAX with enc-dec.app token // ======================================================================== /// Fetch the episode list for a TV series using enc-dec.app token. fn fetch_episodes(data_id: &str, media_slug: &str) -> Result, PluginError> { // Step 1: Get encoded token from enc-dec.app let enc_token = Self::enc_flix(data_id) .ok_or_else(|| PluginError::Network("Failed to get episode token from enc-dec.app".to_string()))?; // Step 2: Fetch episode list from AJAX endpoint let url = format!( "{}/ajax/episodes/list?data_id={}&_={}", BASE_URL, data_id, enc_token ); let headers = { let mut h = Self::ajax_headers(); h.push(Attr { key: "Referer".to_string(), value: format!("{}/series/{}", BASE_URL, media_slug), }); h }; let json = Self::fetch_json(&url, headers)?; // Step 3: Parse the HTML from the result field let html_content = json .get("result") .and_then(|v| v.as_str()) .ok_or(PluginError::Parse("No result in episodes response".to_string()))?; Self::parse_episode_list(html_content, media_slug) } /// Parse the episode list HTML returned by the AJAX endpoint. fn parse_episode_list(html: &str, media_slug: &str) -> Result, PluginError> { let mut episodes = Vec::new(); let li_pattern = "
  • "; let mut offset = 0; while let Some(idx) = html[offset..].find(li_pattern) { let li_start = offset + idx; let li_end = html[li_start..].find("
  • ") .unwrap_or(html.len() - li_start) + li_start; let li_block = &html[li_start..li_end.min(html.len())]; if let Some(a_start) = li_block.find("') { let a_tag = &li_block[a_start..a_start + a_end_offset]; let after_a = &li_block[a_start + a_end_offset + 1..]; let ep_num_str = extract_attr_value(a_tag, "num").unwrap_or_default(); let ep_num: f64 = ep_num_str.parse().unwrap_or(0.0); let ep_token = extract_attr_value(a_tag, "token").unwrap_or_default(); let ep_slug = extract_attr_value(a_tag, "slug").unwrap_or_default(); let season_str = extract_attr_value(a_tag, "season") .or_else(|| extract_attr_value(a_tag, "data-season")) .unwrap_or_default(); let season: f64 = if season_str.is_empty() { 1.0 } else { season_str.parse().unwrap_or(1.0) }; // Extract episode title from inside the let ep_title = if let Some(span_start) = after_a.find("') .map(|i| span_start + i + 1) .unwrap_or(span_start + 6); if let Some(span_end) = after_a[span_content_start..].find("") { let raw = after_a[span_content_start..span_content_start + span_end].trim(); if raw.is_empty() { format!("Episode {}", ep_num) } else { clean_html(raw) } } else { format!("Episode {}", ep_num) } } else { let text = clean_html(after_a); let trimmed = text.trim(); if trimmed.is_empty() || trimmed == ep_num_str { format!("Episode {}", ep_num) } else { trimmed.to_string() } }; // Build episode ID: {slug}$ep={num}$token={ep_token}$slug={ep_slug}$season={season} let episode_id = format!( "{}$ep={}$token={}$slug={}$season={}", media_slug, ep_num, ep_token, ep_slug, season ); episodes.push(Episode { id: episode_id, title: ep_title, number: Some(ep_num), season: Some(season), images: None, description: None, released: None, score: None, url: Some(format!("{}/series/{}{}", BASE_URL, media_slug, ep_slug)), tags: vec![], extra: vec![], }); } } offset = li_end + 1; } Ok(episodes) } // ======================================================================== // Stream extraction // ======================================================================== /// Extract the stream URL from a video embed URL. fn extract_embed_stream(embed_url: &str) -> Option { let headers = vec![ Attr { key: "User-Agent".to_string(), value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string() }, Attr { key: "Accept".to_string(), value: "*/*".to_string() }, Attr { key: "Referer".to_string(), value: format!("{}/", BASE_URL) }, ]; match Self::fetch_url_with_headers(embed_url, headers) { Ok(body) => { let trimmed = body.trim(); // Check if it's a direct m3u8 URL if trimmed.contains(".m3u8") { if let Some(url) = extract_in_block(trimmed, "src=\"", "\"") { if url.contains(".m3u8") || url.starts_with("http") { return Some(url); } } if trimmed.starts_with("http") && trimmed.contains(".m3u8") { return Some(trimmed.to_string()); } } // Check if it's a direct video URL if trimmed.starts_with("http") && (trimmed.contains(".mp4") || trimmed.contains(".m3u8")) { return Some(trimmed.to_string()); } // Try to parse as JSON if let Ok(json) = serde_json::from_str::(trimmed) { for field in &["url", "source", "src", "file", "stream", "result", "link"] { if let Some(url) = json.get(*field).and_then(|v| v.as_str()) { if url.starts_with("http") { return Some(url.to_string()); } } } // The entire result might be the encoded stream data if let Some(result) = json.get("result").and_then(|v| v.as_str()) { if !result.is_empty() { // Try decoding via enc-dec.app if let Some(decoded) = Self::enc_flix(result) { if decoded.starts_with("http") { return Some(decoded); } } if result.starts_with("http") { return Some(result.to_string()); } } } } // Try decoding the entire response via enc-dec.app if !trimmed.is_empty() && !trimmed.starts_with("<") { if let Some(decoded) = Self::enc_flix(trimmed) { if decoded.starts_with("http") { return Some(decoded); } } } None } Err(_) => None, } } /// Build a StreamSource from a resolved URL fn build_stream_source(server: &Server, stream_url: &str) -> Result { let is_hls = stream_url.contains(".m3u8") || stream_url.contains("m3u8"); let format = if is_hls { StreamFormat::Hls } else { StreamFormat::Progressive }; let manifest_url = if is_hls { Some(stream_url.to_string()) } else { None }; Ok(StreamSource { id: format!("stream-{}", server.id), label: server.label.clone(), format, manifest_url, videos: vec![VideoTrack { resolution: VideoResolution { width: 1280, height: 720, hdr: false, label: "720p".to_string(), }, url: stream_url.to_string(), mime_type: if is_hls { Some("application/vnd.apple.mpegurl".to_string()) } else { Some("video/mp4".to_string()) }, bitrate: Some(3000000), codecs: None, }], subtitles: vec![], 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) }, Attr { key: "Origin".to_string(), value: BASE_URL.to_string() }, ], extra: vec![], }) } } // ============================================================================ // Server fetching helpers // ============================================================================ impl Component { /// Get servers for a specific episode fn get_episode_servers(effective_id: &str) -> Result, PluginError> { let parts: Vec<&str> = effective_id.split('$').collect(); let mut ep_token = ""; for part in &parts { if let Some(val) = part.strip_prefix("token=") { ep_token = val; } } if ep_token.is_empty() { return Err(PluginError::InvalidInput( "Invalid episode ID format: missing token".to_string() )); } // Use enc-dec.app to get the encoded token for the links request let enc_token = Self::enc_flix(ep_token) .ok_or_else(|| PluginError::Network("Failed to get links token from enc-dec.app".to_string()))?; let url = format!( "{}/ajax/links/list?token={}&_={}", BASE_URL, ep_token, enc_token ); Self::parse_servers_from_links(&url, &enc_token) } /// Get servers for a movie fn get_movie_servers(flix_id: &str) -> Result, PluginError> { // First, try to get the movie page to find the data-id let db_data = Self::db_find_by_flix_id(flix_id).ok(); let title = db_data.as_ref() .and_then(|d| d.get("title").and_then(|v| v.as_str())) .unwrap_or(""); let media_type = db_data.as_ref() .and_then(|d| d.get("type").and_then(|v| v.as_str())) .unwrap_or("movie"); let slug = title.to_lowercase() .replace(' ', "-") .replace(|c: char| !c.is_alphanumeric() && c != '-', ""); let html = Self::fetch_info_html(&slug, media_type)?; let data_id = Self::extract_data_id(&html) .ok_or_else(|| PluginError::Parse("Could not find data-id on movie page".to_string()))?; // Get encoded token from enc-dec.app let enc_token = Self::enc_flix(&data_id) .ok_or_else(|| PluginError::Network("Failed to get movie links token from enc-dec.app".to_string()))?; let url = format!( "{}/ajax/links/list?data_id={}&_={}", BASE_URL, data_id, enc_token ); Self::parse_servers_from_links(&url, &enc_token) } /// Parse server list from the AJAX links response fn parse_servers_from_links(url: &str, enc_token: &str) -> Result, PluginError> { let json = Self::fetch_json(url, Self::ajax_headers())?; let html_content = json .get("result") .and_then(|v| v.as_str()) .ok_or(PluginError::Parse("No result in links response".to_string()))?; let mut servers = Vec::new(); // Parse server groups from HTML let groups = vec![ ("sub", "Sub"), ("softsub", "Soft Sub"), ("dub", "Dub"), ("primary", "Primary"), ("backup", "Backup"), ]; for (data_id, label) in groups { let pattern = format!("data-id=\"{}\"", data_id); if let Some(group_start) = html_content.find(&pattern) { let search_end = html_content.len().min(group_start + 5000); let block = &html_content[group_start..search_end]; let server_pattern = "data-lid=\""; let mut offset = 0; let mut priority: u8 = 1; while let Some(idx) = block[offset..].find(server_pattern) { let lid_start = offset + idx + server_pattern.len(); if let Some(lid_end) = block[lid_start..].find('"') { let lid = &block[lid_start..lid_start + lid_end]; if lid.is_empty() { offset = lid_start; continue; } // Determine server name from nearby context let ctx_start = if offset >= 50 { offset - 50 } else { 0 }; let ctx_end = (lid_start + lid_end + 50).min(block.len()); let context = &block[ctx_start..ctx_end]; let server_name = if context.contains("megaup") || context.contains("MegaUp") { "MegaUp" } else if context.contains("4spromax") { "4SPromax" } else if context.contains("vidstream") || context.contains("Vidstream") { "Vidstreaming" } else if context.contains("gogo") || context.contains("Gogo") { "Gogo" } else if context.contains("flixhq") || context.contains("FlixHQ") { "FlixHQ" } else if context.contains("autoembed") || context.contains("AutoEmbed") { "AutoEmbed" } else { "Server" }; let full_label = format!("{} {} {}", server_name, label, priority); servers.push(Server { id: lid.to_string(), label: full_label, url: format!( "{}/ajax/links/view?id={}&_={}", BASE_URL, lid, enc_token ), priority, extra: vec![ Attr { key: "type".to_string(), value: data_id.to_string() }, Attr { key: "enc_token".to_string(), value: enc_token.to_string() }, ], }); priority += 1; } offset = lid_start + 1; } } } // If no structured servers found, try to find any data-lid in the response if servers.is_empty() { let server_pattern = "data-lid=\""; let mut offset = 0; let mut priority: u8 = 1; while let Some(idx) = html_content[offset..].find(server_pattern) { let lid_start = offset + idx + server_pattern.len(); if let Some(lid_end) = html_content[lid_start..].find('"') { let lid = &html_content[lid_start..lid_start + lid_end]; if !lid.is_empty() { servers.push(Server { id: lid.to_string(), label: format!("Server {}", priority), url: format!( "{}/ajax/links/view?id={}&_={}", BASE_URL, lid, enc_token ), priority, extra: vec![ Attr { key: "enc_token".to_string(), value: enc_token.to_string() }, ], }); priority += 1; } } offset = lid_start + 1; } } if servers.is_empty() { return Err(PluginError::NotFound); } Ok(servers) } } // ============================================================================ // Guest implementation // ============================================================================ impl Guest for Component { fn get_home(_ctx: RequestContext) -> Result, PluginError> { let mut sections = Vec::new(); // Category links let genres = vec![ ("Action", "genres/action"), ("Adventure", "genres/adventure"), ("Animation", "genres/animation"), ("Comedy", "genres/comedy"), ("Crime", "genres/crime"), ("Documentary", "genres/documentary"), ("Drama", "genres/drama"), ("Fantasy", "genres/fantasy"), ("Horror", "genres/horror"), ("Mystery", "genres/mystery"), ("Romance", "genres/romance"), ("Sci-Fi", "genres/sci-fi"), ("Thriller", "genres/thriller"), ("War", "genres/war"), ]; sections.push(HomeSection { id: "categories".to_string(), title: "Browse".to_string(), subtitle: None, items: vec![], next_page: None, layout: CardLayout::Grid, show_rank: false, categories: genres .into_iter() .map(|(title, id)| CategoryLink { id: id.to_string(), title: title.to_string(), subtitle: None, image: None, }) .collect(), extra: vec![], }); // Fetch trending movies from database API if let Ok(json) = Self::db_search("trending", "movie", 1) { let items: Vec = json .get("results") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(Self::db_result_to_card).take(12).collect()) .unwrap_or_default(); if !items.is_empty() { sections.push(HomeSection { id: "trending_movies".to_string(), title: "Trending Movies".to_string(), subtitle: None, items, next_page: Some("trending_movies:2".to_string()), layout: CardLayout::Grid, show_rank: false, categories: vec![], extra: vec![], }); } } // Fetch trending TV shows from database API if let Ok(json) = Self::db_search("trending", "tv", 1) { let items: Vec = json .get("results") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(Self::db_result_to_card).take(12).collect()) .unwrap_or_default(); if !items.is_empty() { sections.push(HomeSection { id: "trending_tv".to_string(), title: "Trending TV Shows".to_string(), subtitle: None, items, next_page: Some("trending_tv:2".to_string()), layout: CardLayout::Grid, show_rank: false, categories: vec![], extra: vec![], }); } } // Fetch popular movies if let Ok(json) = Self::db_search("popular", "movie", 1) { let items: Vec = json .get("results") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(Self::db_result_to_card).take(12).collect()) .unwrap_or_default(); if !items.is_empty() { sections.push(HomeSection { id: "popular_movies".to_string(), title: "Popular Movies".to_string(), subtitle: None, items, next_page: Some("popular_movies:2".to_string()), layout: CardLayout::Grid, show_rank: true, categories: vec![], extra: vec![], }); } } // Fetch popular TV shows if let Ok(json) = Self::db_search("popular", "tv", 1) { let items: Vec = json .get("results") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(Self::db_result_to_card).take(12).collect()) .unwrap_or_default(); if !items.is_empty() { sections.push(HomeSection { id: "popular_tv".to_string(), title: "Popular TV Shows".to_string(), subtitle: None, items, next_page: Some("popular_tv:2".to_string()), 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.split(':').last()) .and_then(|n| n.parse().ok()) .unwrap_or(1); let (query, media_type) = if id.starts_with("genres/") { let genre = id.strip_prefix("genres/").unwrap_or(""); (genre.replace('-', " "), "movie") } else { match id.as_str() { "trending_movies" => ("trending".to_string(), "movie"), "trending_tv" => ("trending".to_string(), "tv"), "popular_movies" => ("popular".to_string(), "movie"), "popular_tv" => ("popular".to_string(), "tv"), _ => return Err(PluginError::NotFound), } }; let json = Self::db_search(&query, media_type, page_num)?; let items = Self::parse_db_results(&json); let total: u32 = if json.is_array() { items.len() as u32 } else { json.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as u32 }; let next_page = if items.len() >= 20 || total > page_num * 20 { Some(format!("{}:{}", id, page_num + 1)) } else { None }; Ok(PagedResult { items, categories: vec![], next_page, }) } fn search(_ctx: RequestContext, query: String, _filters: SearchFilters) -> Result { if query.trim().is_empty() { return Ok(PagedResult { items: vec![], categories: vec![], next_page: None, }); } let page_num: u32 = _filters.page .token .as_ref() .and_then(|t| t.split(':').last()) .and_then(|n| n.parse().ok()) .unwrap_or(1); let media_type = match _filters.kind { Some(MediaKind::Movie) => "movie", Some(MediaKind::Series) => "tv", _ => "", }; let json = Self::db_search(&query, media_type, page_num)?; let items = Self::parse_db_results(&json); let total: u32 = if json.is_array() { items.len() as u32 } else { json.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as u32 }; let next_page = if items.len() >= 20 || total > page_num * 20 { Some(format!("search:{}:{}", query, page_num + 1)) } else { None }; Ok(PagedResult { items, categories: vec![], next_page, }) } fn get_info(_ctx: RequestContext, id: String) -> Result { // id is the flix_id // First, try to get info from the database find API let db_data = Self::db_find_by_flix_id(&id).ok(); let title = db_data.as_ref() .and_then(|d| d.get("title").and_then(|v| v.as_str())) .unwrap_or("Unknown") .to_string(); let media_type = db_data.as_ref() .and_then(|d| d.get("type").and_then(|v| v.as_str())) .unwrap_or("movie"); let kind = match media_type { "movie" => MediaKind::Movie, "tv" | "series" | "tv_series" => MediaKind::Series, _ => MediaKind::Unknown, }; let poster = db_data.as_ref() .and_then(|d| d.get("poster").and_then(|v| v.as_str())) .unwrap_or("") .to_string(); let backdrop = db_data.as_ref() .and_then(|d| d.get("backdrop").and_then(|v| v.as_str())) .unwrap_or("") .to_string(); let year = db_data.as_ref() .and_then(|d| d.get("year").and_then(|v| v.as_u64())) .map(|y| y.to_string()); let score = db_data.as_ref() .and_then(|d| d.get("rating").and_then(|v| v.as_f64())) .map(|r| (r * 10.0) as u32); let tmdb_id = db_data.as_ref() .and_then(|d| d.get("tmdb_id").and_then(|v| v.as_u64())) .map(|id| id.to_string()); let imdb_id = db_data.as_ref() .and_then(|d| d.get("imdb_id").and_then(|v| v.as_str())) .map(|s| s.to_string()); let mut ids = Vec::new(); if let Some(ref tid) = tmdb_id { ids.push(LinkedId { source: "tmdb".to_string(), id: tid.clone() }); } if let Some(ref iid) = imdb_id { ids.push(LinkedId { source: "imdb".to_string(), id: iid.clone() }); } ids.push(LinkedId { source: "flix".to_string(), id: id.clone() }); // Build slug from title for URL generation let slug = title.to_lowercase() .replace(' ', "-") .replace(|c: char| !c.is_alphanumeric() && c != '-', ""); // Try to fetch the yFlix info page for additional data (description, genres, episodes) let html = Self::fetch_info_html(&slug, media_type).ok(); let data_id = html.as_ref() .and_then(|h| Self::extract_data_id(h)) .unwrap_or_default(); // Extract description from HTML let description = html.as_ref() .and_then(|h| { let dom = Dom::parse(h); dom.find_class_text("div", "desc") .or_else(|| dom.find_class_text("div", "description")) .or_else(|| dom.find_class_text("p", "description")) }) .filter(|t| !t.is_empty()) .map(|t| clean_html(&t)); // Extract genres from HTML let genres = html.as_ref() .map(|h| extract_genres(h)) .unwrap_or_default(); // Extract status from HTML let status = html.as_ref() .map(|h| { if h.contains(">Completed<") || h.contains(">Released<") { Some(Status::Completed) } else if h.contains(">Ongoing<") || h.contains(">Airing<") || h.contains(">Returning<") { Some(Status::Ongoing) } else { None } }) .unwrap_or(None); // Fetch episodes for TV series using enc-dec.app token let seasons = if matches!(kind, MediaKind::Series) && !data_id.is_empty() { let episodes = Self::fetch_episodes(&data_id, &slug).unwrap_or_default(); if episodes.is_empty() { vec![] } else { // Group episodes by season let mut season_map: std::collections::HashMap> = std::collections::HashMap::new(); for ep in episodes { let sn = ep.season.unwrap_or(1.0) as u32; season_map.entry(sn).or_default().push(ep); } let mut seasons: Vec = season_map .into_iter() .map(|(sn, mut eps)| { eps.sort_by(|a, b| { a.number.unwrap_or(0.0).partial_cmp(&b.number.unwrap_or(0.0)).unwrap_or(std::cmp::Ordering::Equal) }); Season { id: format!("{}_s{}", id, sn), title: format!("Season {}", sn), number: Some(sn as f64), year: None, episodes: eps, } }) .collect(); seasons.sort_by(|a, b| { a.number.unwrap_or(0.0).partial_cmp(&b.number.unwrap_or(0.0)).unwrap_or(std::cmp::Ordering::Equal) }); seasons } } else { vec![] }; let url_path = match media_type { "tv" | "series" | "tv_series" => format!("{}/series/{}", BASE_URL, slug), _ => format!("{}/movie/{}", BASE_URL, slug), }; // Build image set with poster and backdrop let images = if poster.is_empty() && backdrop.is_empty() { None } else { let poster_img = if poster.is_empty() { None } else { Some(Image { url: poster.clone(), layout: ImageLayout::Portrait, width: None, height: None, blurhash: None }) }; let backdrop_img = if backdrop.is_empty() { None } else { Some(Image { url: backdrop.clone(), layout: ImageLayout::Landscape, width: None, height: None, blurhash: None }) }; Some(ImageSet { low: poster_img.clone(), medium: poster_img.clone(), high: poster_img, backdrop: backdrop_img, logo: None, }) }; Ok(MediaInfo { id: id.clone(), title, kind, images, 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: None, country: None, language: None, url: Some(url_path), extra: vec![], }) } fn get_servers(_ctx: RequestContext, id: String) -> Result, PluginError> { // In v5, id can be either a flix_id (movie) or an episode ID (contains $) // Check if this is an episode ID (contains $ separator) if id.contains('$') { return Self::get_episode_servers(&id); } // Otherwise, this is a movie - we need to fetch the page and extract servers Self::get_movie_servers(&id) } fn resolve_stream(_ctx: RequestContext, server: Server) -> Result { // The server URL contains the AJAX endpoint for fetching stream data // We need to generate a fresh token for the request let server_id = server.id.clone(); // Get fresh view token from enc-dec.app let fresh_view_token = Self::enc_flix(&server_id) .ok_or_else(|| PluginError::Network("Failed to get fresh view token".to_string()))?; let view_url = format!( "{}/ajax/links/view?id={}&_={}", BASE_URL, server_id, fresh_view_token ); let json = Self::fetch_json(&view_url, Self::ajax_headers())?; let result_str = json .get("result") .and_then(|v| v.as_str()) .ok_or(PluginError::Parse("No result in stream response".to_string()))?; // The result is an encoded string - use dec-movies-flix to decode it let decoded = Self::dec_flix(result_str) .ok_or_else(|| PluginError::Network("Failed to decode stream URL via dec-movies-flix".to_string()))?; // Extract the URL from the decoded result let stream_url = if let Some(url) = decoded.get("url").and_then(|v| v.as_str()) { url.to_string() } else if decoded.is_string() { decoded.as_str().unwrap_or("").to_string() } else { return Err(PluginError::Parse("No URL in decoded stream result".to_string())); }; if stream_url.is_empty() { return Err(PluginError::NotFound); } // Check if this is an embed URL that needs extraction if stream_url.contains("megaup") || stream_url.contains("4spromax") || stream_url.contains("/e/") { if let Some(resolved_url) = Self::extract_embed_stream(&stream_url) { return Self::build_stream_source(&server, &resolved_url); } return Self::build_stream_source(&server, &stream_url); } // Direct stream URL (m3u8, mp4, etc.) Self::build_stream_source(&server, &stream_url) } 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) } } // ============================================================================ // 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_class_text(&self, _tag: &str, class: &str) -> Option { // Try exact class match let pattern = format!("class=\"{}\"", class); if let Some(idx) = self.html.find(&pattern) { let tag_start = self.html[..idx].rfind('<')?; let content_start = self.html[tag_start..].find('>')? + tag_start + 1; let tag_name_end = self.html[tag_start + 1..].find(|c: char| c.is_whitespace() || c == '>')?; let tag_name = &self.html[tag_start + 1..tag_start + 1 + tag_name_end]; let close = format!("", tag_name); if let Some(offset) = self.html[content_start..].find(&close) { let content_end = content_start + offset; let text = self.html[content_start..content_end].trim().to_string(); if !text.is_empty() { return Some(text); } } } // Try class as prefix (multi-class) let prefix_pattern = format!("class=\"{} ", class); if let Some(idx) = self.html.find(&prefix_pattern) { let tag_start = self.html[..idx].rfind('<')?; let content_start = self.html[tag_start..].find('>')? + tag_start + 1; let tag_name_end = self.html[tag_start + 1..].find(|c: char| c.is_whitespace() || c == '>')?; let tag_name = &self.html[tag_start + 1..tag_start + 1 + tag_name_end]; let close = format!("", tag_name); if let Some(offset) = self.html[content_start..].find(&close) { let content_end = content_start + offset; let text = self.html[content_start..content_end].trim().to_string(); if !text.is_empty() { return Some(text); } } } None } } 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 } } // ============================================================================ // 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.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, } } fn urlencoding(s: &str) -> String { let mut out = String::with_capacity(s.len() * 3); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(b as char); } b' ' => out.push('+'), _ => { out.push('%'); out.push_str(&format!("{:02X}", b)); } } } out } bindings::export!(Component with_types_in bindings);