krystv's picture
Upload 107 files
3374e90 verified
/*!
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<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)])))
}
// ========================================================================
// 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<String> {
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<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) },
];
// 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::<Value>(&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<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()?;
// Parse JSON response: {"status":200,"result":"ENCODED_TOKEN"}
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());
}
}
}
// 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<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)
}
/// 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<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)
}
// ========================================================================
// Convert database results to MediaCard
// ========================================================================
fn db_result_to_card(item: &Value) -> Option<MediaCard> {
// 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::<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![],
})
}
/// Parse database search results, handling both bare array and {results: [...]} formats
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()
}
// ========================================================================
// 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<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)
}
/// Extract data-id from the yFlix info page HTML
fn extract_data_id(html: &str) -> Option<String> {
// 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<Vec<Episode>, 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<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) };
// Extract episode title from <span> inside the <a>
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()
}
};
// 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<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();
// 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::<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());
}
}
}
// 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<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![],
})
}
}
// ============================================================================
// Server fetching helpers
// ============================================================================
impl Component {
/// Get servers for a specific episode
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()
));
}
// 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<Vec<Server>, 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<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();
// 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<Vec<HomeSection>, 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<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![],
});
}
}
// Fetch trending TV shows from database API
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![],
});
}
}
// Fetch popular movies
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![],
});
}
}
// Fetch popular TV shows
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> {
// 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<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),
};
// 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<Vec<Server>, 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<StreamSource, PluginError> {
// 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<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)
}
}
// ============================================================================
// 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<String> {
// 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<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
}
}
// ============================================================================
// Utility functions
// ============================================================================
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("&amp;", "&");
result = result.replace("&lt;", "<");
result = result.replace("&gt;", ">");
result = result.replace("&quot;", "\"");
result = result.replace("&#39;", "'");
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);