| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| #[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"; |
|
|
| |
| |
| |
|
|
| 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/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<Attr> { |
| 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<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::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<Attr>) -> Result<Value, PluginError> { |
| 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)]))) |
| } |
|
|
| |
| |
| |
|
|
| |
| fn enc_flix(text: &str) -> Option<String> { |
| Self::call_enc_api(ENC_API, text) |
| } |
|
|
| |
| |
| fn dec_flix(text: &str) -> Option<Value> { |
| 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) }, |
| ]; |
|
|
| |
| 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()?; |
|
|
| |
| if let Ok(json) = serde_json::from_str::<Value>(&body) { |
| if let Some(result) = json.get("result") { |
| return Some(result.clone()); |
| } |
| } |
| None |
| } |
|
|
| |
| fn call_enc_api(api_url: &str, text: &str) -> Option<String> { |
| 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()?; |
|
|
| |
| if let Ok(json) = serde_json::from_str::<Value>(&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()); |
| } |
| } |
| } |
|
|
| |
| let trimmed = body.trim().trim_matches('"').to_string(); |
| if !trimmed.is_empty() && !trimmed.starts_with('{') { |
| return Some(trimmed); |
| } |
| None |
| } |
|
|
| |
| |
| |
|
|
| |
| fn db_search(query: &str, media_type: &str, page: u32) -> Result<Value, PluginError> { |
| 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) |
| } |
|
|
| |
| fn db_find_by_flix_id(flix_id: &str) -> Result<Value, PluginError> { |
| 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) |
| } |
|
|
| |
| |
| |
|
|
| fn db_result_to_card(item: &Value) -> Option<MediaCard> { |
| |
| |
| |
| 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; |
| } |
|
|
| |
| 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::<u64>().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![], |
| }) |
| } |
|
|
| |
| fn parse_db_results(json: &Value) -> Vec<MediaCard> { |
| let items: &Vec<Value> = 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() |
| } |
|
|
| |
| |
| |
|
|
| |
| fn fetch_info_html(slug: &str, media_type: &str) -> Result<String, PluginError> { |
| let path = match media_type { |
| "tv" | "series" | "tv_series" => format!("{}/series/{}", BASE_URL, slug), |
| _ => format!("{}/movie/{}", BASE_URL, slug), |
| }; |
| Self::fetch_url(&path) |
| } |
|
|
| |
| fn extract_data_id(html: &str) -> Option<String> { |
| |
| 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); |
| } |
| } |
| } |
| } |
|
|
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| |
| |
|
|
| |
| fn fetch_episodes(data_id: &str, media_slug: &str) -> Result<Vec<Episode>, PluginError> { |
| |
| let enc_token = Self::enc_flix(data_id) |
| .ok_or_else(|| PluginError::Network("Failed to get episode token from enc-dec.app".to_string()))?; |
|
|
| |
| 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)?; |
|
|
| |
| 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) |
| } |
|
|
| |
| fn parse_episode_list(html: &str, media_slug: &str) -> Result<Vec<Episode>, PluginError> { |
| let mut episodes = Vec::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_end = html[li_start..].find("</li>") |
| .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("<a ") { |
| if let Some(a_end_offset) = li_block[a_start..].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) }; |
|
|
| |
| let ep_title = if let Some(span_start) = after_a.find("<span") { |
| let span_content_start = after_a[span_start..].find('>') |
| .map(|i| span_start + i + 1) |
| .unwrap_or(span_start + 6); |
| if let Some(span_end) = after_a[span_content_start..].find("</span>") { |
| 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() |
| } |
| }; |
|
|
| |
| 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) |
| } |
|
|
| |
| |
| |
|
|
| |
| fn extract_embed_stream(embed_url: &str) -> Option<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: "*/*".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(); |
|
|
| |
| 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()); |
| } |
| } |
|
|
| |
| if trimmed.starts_with("http") && (trimmed.contains(".mp4") || trimmed.contains(".m3u8")) { |
| return Some(trimmed.to_string()); |
| } |
|
|
| |
| if let Ok(json) = serde_json::from_str::<Value>(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()); |
| } |
| } |
| } |
|
|
| |
| if let Some(result) = json.get("result").and_then(|v| v.as_str()) { |
| if !result.is_empty() { |
| |
| 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()); |
| } |
| } |
| } |
| } |
|
|
| |
| 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, |
| } |
| } |
|
|
| |
| fn build_stream_source(server: &Server, stream_url: &str) -> Result<StreamSource, PluginError> { |
| let is_hls = stream_url.contains(".m3u8") || stream_url.contains("m3u8"); |
| let format = if is_hls { |
| StreamFormat::Hls |
| } else { |
| StreamFormat::Progressive |
| }; |
|
|
| let manifest_url = if is_hls { Some(stream_url.to_string()) } else { None }; |
|
|
| Ok(StreamSource { |
| id: format!("stream-{}", server.id), |
| label: server.label.clone(), |
| format, |
| manifest_url, |
| videos: vec![VideoTrack { |
| resolution: VideoResolution { |
| width: 1280, |
| height: 720, |
| hdr: false, |
| label: "720p".to_string(), |
| }, |
| url: stream_url.to_string(), |
| mime_type: if is_hls { |
| Some("application/vnd.apple.mpegurl".to_string()) |
| } else { |
| Some("video/mp4".to_string()) |
| }, |
| bitrate: Some(3000000), |
| codecs: None, |
| }], |
| subtitles: 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![], |
| }) |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Component { |
| |
| fn get_episode_servers(effective_id: &str) -> Result<Vec<Server>, 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() |
| )); |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| fn get_movie_servers(flix_id: &str) -> Result<Vec<Server>, PluginError> { |
| |
| 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()))?; |
|
|
| |
| 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) |
| } |
|
|
| |
| fn parse_servers_from_links(url: &str, enc_token: &str) -> Result<Vec<Server>, 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(); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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 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) |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Guest for Component { |
| fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> { |
| let mut sections = Vec::new(); |
|
|
| |
| let genres = vec![ |
| ("Action", "genres/action"), |
| ("Adventure", "genres/adventure"), |
| ("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![], |
| }); |
|
|
| |
| if let Ok(json) = Self::db_search("trending", "movie", 1) { |
| let items: Vec<MediaCard> = 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![], |
| }); |
| } |
| } |
|
|
| |
| if let Ok(json) = Self::db_search("trending", "tv", 1) { |
| let items: Vec<MediaCard> = 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![], |
| }); |
| } |
| } |
|
|
| |
| if let Ok(json) = Self::db_search("popular", "movie", 1) { |
| let items: Vec<MediaCard> = 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![], |
| }); |
| } |
| } |
|
|
| |
| if let Ok(json) = Self::db_search("popular", "tv", 1) { |
| let items: Vec<MediaCard> = 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<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 (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<PagedResult, PluginError> { |
| 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<MediaInfo, PluginError> { |
| |
| |
| 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() }); |
|
|
| |
| let slug = title.to_lowercase() |
| .replace(' ', "-") |
| .replace(|c: char| !c.is_alphanumeric() && c != '-', ""); |
|
|
| |
| 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(); |
|
|
| |
| 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)); |
|
|
| |
| let genres = html.as_ref() |
| .map(|h| extract_genres(h)) |
| .unwrap_or_default(); |
|
|
| |
| 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); |
|
|
| |
| 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 { |
| |
| let mut season_map: std::collections::HashMap<u32, Vec<Episode>> = 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> = 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), |
| }; |
|
|
| |
| 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<Vec<Server>, PluginError> { |
| |
| |
| if id.contains('$') { |
| return Self::get_episode_servers(&id); |
| } |
|
|
| |
| Self::get_movie_servers(&id) |
| } |
|
|
| fn resolve_stream(_ctx: RequestContext, server: Server) -> Result<StreamSource, PluginError> { |
| |
| |
| let server_id = server.id.clone(); |
|
|
| |
| 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()))?; |
|
|
| |
| let decoded = Self::dec_flix(result_str) |
| .ok_or_else(|| PluginError::Network("Failed to decode stream URL via dec-movies-flix".to_string()))?; |
|
|
| |
| 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); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| Self::build_stream_source(&server, &stream_url) |
| } |
|
|
| 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) |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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<String> { |
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| 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<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 |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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.trim().to_string() |
| } |
|
|
| fn extract_genres(html: &str) -> Vec<String> { |
| let mut genres = Vec::new(); |
| if let Some(idx) = html.find("Genres") { |
| let block_end = html[idx..].find("</div>").unwrap_or(500).min(500); |
| let block = &html[idx..idx + block_end]; |
| let a_pattern = "<a"; |
| let mut offset = 0; |
| while let Some(a_idx) = block[offset..].find(a_pattern) { |
| let abs = offset + a_idx; |
| if let Some(tag_end) = block[abs..].find('>') { |
| if let Some(close) = block[abs + tag_end + 1..].find("</a>") { |
| let text = block[abs + tag_end + 1..abs + tag_end + 1 + close].trim().to_string(); |
| if !text.is_empty() && text.len() < 50 { |
| genres.push(clean_html(&text)); |
| } |
| } |
| } |
| offset = abs + 1; |
| } |
| } |
| genres |
| } |
|
|
| fn make_image_set(url: &str, layout: ImageLayout) -> ImageSet { |
| let img = Image { |
| url: url.to_string(), |
| layout, |
| width: None, |
| height: None, |
| blurhash: None, |
| }; |
| ImageSet { |
| low: Some(img.clone()), |
| medium: Some(img.clone()), |
| high: Some(img), |
| backdrop: None, |
| logo: None, |
| } |
| } |
|
|
| fn 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); |
|
|