krystv's picture
Upload 107 files
3374e90 verified
/*!
YTS (yts.mx) plugin for Bex Engine
Features:
- Browse latest/popular movies sorted by seeds, year, or rating
- Search movies by title
- Detailed movie info with cast, torrents, and suggestions
- Torrent sources as "servers" (each quality is a separate server)
- Magnet links as Progressive stream sources (P2P)
*/
#[allow(warnings)]
mod bindings;
use bindings::bex::plugin::common::*;
use bindings::bex::plugin::http;
use bindings::exports::api::Guest;
use serde::Deserialize;
struct Component;
const YTS_API_BASE: &str = "https://yts.mx/api/v2";
// ============================================================================
// YTS API Response Types
// ============================================================================
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct YtsListResponse {
status: Option<String>,
data: Option<YtsListData>,
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct YtsListData {
movie_count: Option<u64>,
limit: Option<u32>,
page_number: Option<u32>,
movies: Option<Vec<YtsMovie>>,
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct YtsMovie {
id: u64,
url: Option<String>,
imdb_code: Option<String>,
title: Option<String>,
title_english: Option<String>,
title_long: Option<String>,
slug: Option<String>,
year: Option<u32>,
rating: Option<f64>,
runtime: Option<u32>,
genres: Option<Vec<String>>,
summary: Option<String>,
description_full: Option<String>,
synopsis: Option<String>,
yt_trailer_code: Option<String>,
language: Option<String>,
mpa_rating: Option<String>,
background_image: Option<String>,
background_image_original: Option<String>,
small_cover_image: Option<String>,
medium_cover_image: Option<String>,
large_cover_image: Option<String>,
torrents: Option<Vec<YtsTorrent>>,
#[serde(default)]
cast: Option<Vec<YtsCastMember>>,
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct YtsTorrent {
url: Option<String>,
hash: Option<String>,
quality: Option<String>,
#[serde(rename = "type")]
torrent_type: Option<String>,
seeds: Option<u32>,
peers: Option<u32>,
size: Option<String>,
size_bytes: Option<u64>,
date_uploaded: Option<String>,
date_uploaded_unix: Option<u64>,
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct YtsMovieDetailResponse {
status: Option<String>,
data: Option<YtsMovieDetailData>,
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct YtsMovieDetailData {
movie: Option<YtsMovie>,
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct YtsSuggestionsResponse {
status: Option<String>,
data: Option<YtsSuggestionsData>,
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct YtsSuggestionsData {
movie_count: Option<u64>,
movies: Option<Vec<YtsMovieSuggestion>>,
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct YtsMovieSuggestion {
id: u64,
url: Option<String>,
title: Option<String>,
title_english: Option<String>,
title_long: Option<String>,
year: Option<u32>,
rating: Option<f64>,
genres: Option<Vec<String>>,
medium_cover_image: Option<String>,
large_cover_image: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
struct YtsCastMember {
name: Option<String>,
character_name: Option<String>,
url_small_image: Option<String>,
imdb_code: Option<String>,
}
// ============================================================================
// HTTP Helper — delegates to host-provided http::send-request
// ============================================================================
impl Component {
fn fetch_url(url: &str) -> Option<String> {
let req = http::Request {
method: http::Method::Get,
url: url.to_string(),
headers: vec![
Attr { key: "User-Agent".to_string(), value: "BexYTS/1.0".to_string() },
Attr { key: "Accept".to_string(), value: "application/json".to_string() },
],
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).ok(),
_ => None,
}
}
fn parse_json<T: serde::de::DeserializeOwned>(json_str: &str) -> Option<T> {
serde_json::from_str(json_str).ok()
}
// ========================================================================
// Image helpers
// ========================================================================
fn img(url: Option<&str>, layout: ImageLayout) -> ImageSet {
let u = url.unwrap_or("").to_string();
let img = if u.is_empty() {
None
} else {
Some(Image {
url: u.clone(),
layout,
width: None,
height: None,
blurhash: None,
})
};
ImageSet {
low: img.clone(),
medium: img.clone(),
high: img,
backdrop: None,
logo: None,
}
}
fn img_with_backdrop(poster_url: Option<&str>, backdrop_url: Option<&str>) -> ImageSet {
let poster = poster_url.unwrap_or("").to_string();
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 = backdrop_url.unwrap_or("").to_string();
let backdrop_img = if backdrop.is_empty() {
None
} else {
Some(Image {
url: backdrop,
layout: ImageLayout::Landscape,
width: None,
height: None,
blurhash: None,
})
};
ImageSet {
low: poster_img.clone(),
medium: poster_img.clone(),
high: poster_img,
backdrop: backdrop_img,
logo: None,
}
}
// ========================================================================
// YtsMovie → MediaCard
// ========================================================================
fn yts_movie_to_card(movie: &YtsMovie) -> Option<MediaCard> {
let id = movie.id.to_string();
let title = movie.title_english.as_deref()
.or(movie.title.as_deref())
.unwrap_or("Unknown")
.to_string();
let score = movie.rating.map(|r| (r * 10.0) as u32);
let year = movie.year.map(|y| y.to_string());
let genres = movie.genres.clone().unwrap_or_default();
let img_url = movie.large_cover_image.as_deref()
.or(movie.medium_cover_image.as_deref());
let mut ids = vec![LinkedId { source: "yts".to_string(), id: id.clone() }];
if let Some(ref imdb) = movie.imdb_code {
ids.push(LinkedId { source: "imdb".to_string(), id: imdb.clone() });
}
Some(MediaCard {
id,
title,
kind: Some(MediaKind::Movie),
images: Some(Self::img(img_url, ImageLayout::Portrait)),
original_title: movie.title.as_deref().and_then(|t| {
let eng = movie.title_english.as_deref().unwrap_or("");
if t != eng && !t.is_empty() { Some(t.to_string()) } else { None }
}),
tagline: None,
year,
score,
genres,
status: None,
content_rating: movie.mpa_rating.clone(),
url: movie.url.clone(),
ids,
extra: vec![],
})
}
// ========================================================================
// Fetch movies list with given sort and page
// ========================================================================
fn fetch_movies(sort: &str, page: u32, limit: u32) -> Option<Vec<YtsMovie>> {
let url = format!(
"{}/list_movies.json?limit={}&page={}&sort_by={}",
YTS_API_BASE, limit, page, sort
);
let resp = Self::fetch_url(&url)?;
let data: YtsListResponse = Self::parse_json(&resp)?;
data.data.and_then(|d| d.movies)
}
fn fetch_movies_with_quality(sort: &str, page: u32, limit: u32, quality: &str) -> Option<Vec<YtsMovie>> {
let url = format!(
"{}/list_movies.json?limit={}&page={}&sort_by={}&quality={}",
YTS_API_BASE, limit, page, sort, quality
);
let resp = Self::fetch_url(&url)?;
let data: YtsListResponse = Self::parse_json(&resp)?;
data.data.and_then(|d| d.movies)
}
// ========================================================================
// Fetch full movie details (with cast and images)
// ========================================================================
fn fetch_movie_details(movie_id: &str) -> Option<YtsMovie> {
let url = format!(
"{}/movie_details.json?movie_id={}&with_images=true&with_cast=true",
YTS_API_BASE, movie_id
);
let resp = Self::fetch_url(&url)?;
let data: YtsMovieDetailResponse = Self::parse_json(&resp)?;
data.data.and_then(|d| d.movie)
}
// ========================================================================
// Fetch movie suggestions
// ========================================================================
#[allow(dead_code)]
fn fetch_suggestions(movie_id: &str) -> Vec<MediaCard> {
let url = format!(
"{}/movie_suggestions.json?movie_id={}",
YTS_API_BASE, movie_id
);
let resp = match Self::fetch_url(&url) {
Some(r) => r,
None => return vec![],
};
let data: YtsSuggestionsResponse = match Self::parse_json(&resp) {
Some(d) => d,
None => return vec![],
};
match data.data.and_then(|d| d.movies) {
Some(movies) => movies.iter().filter_map(|m| {
let id = m.id.to_string();
let title = m.title_english.as_deref()
.or(m.title.as_deref())
.unwrap_or("Unknown")
.to_string();
let score = m.rating.map(|r| (r * 10.0) as u32);
let year = m.year.map(|y| y.to_string());
let img_url = m.large_cover_image.as_deref()
.or(m.medium_cover_image.as_deref());
Some(MediaCard {
id,
title,
kind: Some(MediaKind::Movie),
images: Some(Self::img(img_url, ImageLayout::Portrait)),
original_title: None,
tagline: None,
year,
score,
genres: m.genres.clone().unwrap_or_default(),
status: None,
content_rating: None,
url: m.url.clone(),
ids: vec![],
extra: vec![],
})
}).collect(),
None => vec![],
}
}
// ========================================================================
// Get total movie count for pagination
// ========================================================================
#[allow(dead_code)]
fn get_movie_count(sort: &str) -> u64 {
let url = format!(
"{}/list_movies.json?limit=1&page=1&sort_by={}",
YTS_API_BASE, sort
);
Self::fetch_url(&url)
.and_then(|resp| Self::parse_json::<YtsListResponse>(&resp))
.and_then(|data| data.data)
.and_then(|d| d.movie_count)
.unwrap_or(0)
}
// ========================================================================
// Quality priority for sorting servers
// ========================================================================
fn quality_priority(quality: &str) -> u8 {
match quality {
"2160p" | "4K" => 1,
"1080p" => 2,
"720p" => 3,
"480p" => 4,
"3D" => 5,
_ => 9,
}
}
fn quality_resolution(quality: &str) -> (u32, u32, &str) {
match quality {
"2160p" | "4K" => (3840, 2160, "4K"),
"1080p" => (1920, 1080, "1080p"),
"720p" => (1280, 720, "720p"),
"480p" => (854, 480, "480p"),
"3D" => (1920, 1080, "3D"),
_ => (1280, 720, "Unknown"),
}
}
}
// ============================================================================
// Guest implementation — the actual plugin API
// ============================================================================
impl Guest for Component {
fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> {
let mut sections = Vec::new();
// Latest Uploads (sorted by date)
if let Some(movies) = Self::fetch_movies("date_added", 1, 20) {
let items: Vec<MediaCard> = movies.iter().filter_map(Self::yts_movie_to_card).collect();
if !items.is_empty() {
sections.push(HomeSection {
id: "latest".to_string(),
title: "Latest Uploads".to_string(),
subtitle: None,
items,
next_page: Some("latest:2".to_string()),
layout: CardLayout::Grid,
show_rank: false,
categories: vec![],
extra: vec![],
});
}
}
// Most Popular (sorted by seeds/peers)
if let Some(movies) = Self::fetch_movies_with_quality("seeds", 1, 20, "1080p") {
let items: Vec<MediaCard> = movies.iter().filter_map(Self::yts_movie_to_card).collect();
if !items.is_empty() {
sections.push(HomeSection {
id: "popular".to_string(),
title: "Most Popular (1080p)".to_string(),
subtitle: None,
items,
next_page: Some("popular:2".to_string()),
layout: CardLayout::Grid,
show_rank: true,
categories: vec![],
extra: vec![],
});
}
}
// Top Rated
if let Some(movies) = Self::fetch_movies("rating", 1, 20) {
let items: Vec<MediaCard> = movies.iter().filter_map(Self::yts_movie_to_card).collect();
if !items.is_empty() {
sections.push(HomeSection {
id: "top_rated".to_string(),
title: "Top Rated".to_string(),
subtitle: None,
items,
next_page: Some("top_rated:2".to_string()),
layout: CardLayout::Grid,
show_rank: true,
categories: vec![],
extra: vec![],
});
}
}
// Category links for genre browsing
sections.push(HomeSection {
id: "genres".to_string(),
title: "Browse by Genre".to_string(),
subtitle: None,
items: vec![],
next_page: None,
layout: CardLayout::Grid,
show_rank: false,
categories: vec![
CategoryLink { id: "genre_action".to_string(), title: "Action".to_string(), subtitle: None, image: None },
CategoryLink { id: "genre_comedy".to_string(), title: "Comedy".to_string(), subtitle: None, image: None },
CategoryLink { id: "genre_drama".to_string(), title: "Drama".to_string(), subtitle: None, image: None },
CategoryLink { id: "genre_thriller".to_string(), title: "Thriller".to_string(), subtitle: None, image: None },
CategoryLink { id: "genre_horror".to_string(), title: "Horror".to_string(), subtitle: None, image: None },
CategoryLink { id: "genre_scifi".to_string(), title: "Sci-Fi".to_string(), subtitle: None, image: None },
CategoryLink { id: "genre_animation".to_string(), title: "Animation".to_string(), subtitle: None, image: None },
CategoryLink { id: "genre_documentary".to_string(), title: "Documentary".to_string(), subtitle: None, image: None },
],
extra: vec![],
});
Ok(sections)
}
fn get_category(_ctx: RequestContext, id: String, page: PageCursor) -> Result<PagedResult, PluginError> {
// Parse page token: "category_id:page_number"
let page_num: u32 = page
.token
.as_ref()
.and_then(|t| t.split(':').last())
.and_then(|n| n.parse().ok())
.unwrap_or(1);
let url = if id.starts_with("genre_") {
let genre = id.strip_prefix("genre_").unwrap_or("");
let genre_capitalized = capitalize_first(genre);
format!(
"{}/list_movies.json?limit=20&page={}&sort_by=seeds&genre={}",
YTS_API_BASE, page_num, genre_capitalized
)
} else {
match id.as_str() {
"latest" => format!(
"{}/list_movies.json?limit=20&page={}&sort_by=date_added",
YTS_API_BASE, page_num
),
"popular" => format!(
"{}/list_movies.json?limit=20&page={}&sort_by=seeds&quality=1080p",
YTS_API_BASE, page_num
),
"top_rated" => format!(
"{}/list_movies.json?limit=20&page={}&sort_by=rating",
YTS_API_BASE, page_num
),
_ => return Err(PluginError::NotFound),
}
};
let resp = Self::fetch_url(&url).ok_or(PluginError::Network("Failed to fetch".to_string()))?;
let data: YtsListResponse = Self::parse_json(&resp).ok_or(PluginError::Parse("Invalid JSON".to_string()))?;
let list_data = data.data.ok_or(PluginError::Parse("Missing data".to_string()))?;
let items: Vec<MediaCard> = list_data
.movies
.unwrap_or_default()
.iter()
.filter_map(Self::yts_movie_to_card)
.collect();
let movie_count = list_data.movie_count.unwrap_or(0);
let has_next = (page_num as u64 * 20) < movie_count;
let next_page = if has_next {
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_deref()
.and_then(|t| t.parse().ok())
.unwrap_or(1);
let encoded = urlencoding(&query);
let url = format!(
"{}/list_movies.json?query_term={}&limit=20&page={}",
YTS_API_BASE, encoded, page_num
);
let resp = Self::fetch_url(&url).ok_or(PluginError::Network("Search request failed".to_string()))?;
let data: YtsListResponse = Self::parse_json(&resp).ok_or(PluginError::Parse("Invalid search response".to_string()))?;
let list_data = data.data.ok_or(PluginError::Parse("Missing data".to_string()))?;
let items: Vec<MediaCard> = list_data
.movies
.unwrap_or_default()
.iter()
.filter_map(Self::yts_movie_to_card)
.collect();
let movie_count = list_data.movie_count.unwrap_or(0);
let has_next = (page_num as u64 * 20) < movie_count;
let next_page = if has_next {
Some((page_num + 1).to_string())
} else {
None
};
Ok(PagedResult {
items,
categories: vec![],
next_page,
})
}
fn get_info(_ctx: RequestContext, id: String) -> Result<MediaInfo, PluginError> {
let movie = Self::fetch_movie_details(&id)
.ok_or(PluginError::NotFound)?;
let title = movie.title_english.as_deref()
.or(movie.title.as_deref())
.unwrap_or("Unknown")
.to_string();
let score = movie.rating.map(|r| (r * 10.0) as u32);
let year = movie.year.map(|y| y.to_string());
let genres = movie.genres.clone().unwrap_or_default();
let runtime_minutes = movie.runtime;
// Description: prefer synopsis > description_full > summary
let description = movie.synopsis.as_deref()
.or(movie.description_full.as_deref())
.or(movie.summary.as_deref())
.map(|s| s.to_string());
// Trailer URL
let trailer_url = movie.yt_trailer_code.as_deref()
.filter(|c| !c.is_empty())
.map(|c| format!("https://www.youtube.com/watch?v={}", c));
// Images: poster + backdrop
let poster_url = movie.large_cover_image.as_deref()
.or(movie.medium_cover_image.as_deref());
let backdrop_url = movie.background_image.as_deref()
.or(movie.background_image_original.as_deref());
// Cast
let cast: Vec<Person> = movie.cast.unwrap_or_default().iter().filter_map(|c| {
let name = c.name.as_deref()?;
let role = c.character_name.as_deref().map(|r| r.to_string());
let img_url = c.url_small_image.as_deref();
let imdb = c.imdb_code.as_deref();
Some(Person {
id: imdb.unwrap_or("").to_string(),
name: name.to_string(),
image: if img_url.is_some() && !img_url.unwrap_or("").is_empty() {
Some(Self::img(img_url, ImageLayout::Portrait))
} else {
None
},
role,
url: imdb.map(|code| format!("https://www.imdb.com/name/{}/", code)),
})
}).collect();
// IDs
let mut ids = vec![LinkedId { source: "yts".to_string(), id: id.clone() }];
if let Some(ref imdb) = movie.imdb_code {
ids.push(LinkedId { source: "imdb".to_string(), id: imdb.clone() });
}
// Torrent count as extra info
let torrent_count = movie.torrents.as_ref().map(|t| t.len()).unwrap_or(0);
Ok(MediaInfo {
id: id.clone(),
title,
kind: MediaKind::Movie,
images: Some(Self::img_with_backdrop(poster_url, backdrop_url)),
original_title: movie.title.as_deref().and_then(|t| {
let eng = movie.title_english.as_deref().unwrap_or("");
if t != eng && !t.is_empty() { Some(t.to_string()) } else { None }
}),
description,
score,
scored_by: None,
year,
release_date: None,
genres,
tags: vec![],
status: None,
content_rating: movie.mpa_rating.clone(),
seasons: vec![],
cast,
crew: vec![],
runtime_minutes,
trailer_url,
ids,
studio: None,
country: None,
language: movie.language.clone(),
url: movie.url.clone(),
extra: vec![
Attr { key: "torrent_count".to_string(), value: torrent_count.to_string() },
],
})
}
fn get_servers(_ctx: RequestContext, id: String) -> Result<Vec<Server>, PluginError> {
let movie = Self::fetch_movie_details(&id)
.ok_or(PluginError::NotFound)?;
let torrents = movie.torrents.unwrap_or_default();
if torrents.is_empty() {
return Err(PluginError::NotFound);
}
let mut servers: Vec<Server> = torrents.iter().filter_map(|t| {
let quality = t.quality.as_deref().unwrap_or("Unknown");
let t_type = t.torrent_type.as_deref().unwrap_or("Unknown");
let size = t.size.as_deref().unwrap_or("?");
let seeds = t.seeds.unwrap_or(0);
let peers = t.peers.unwrap_or(0);
let hash = t.hash.as_deref().unwrap_or("");
let magnet_url = t.url.as_deref()?;
let label = format!("{} {} ({} | ⬆{} ⬇{})", quality, t_type, size, seeds, peers);
let priority = Self::quality_priority(quality);
Some(Server {
id: format!("{}_{}", id, hash),
label,
url: magnet_url.to_string(),
priority,
extra: vec![
Attr { key: "quality".to_string(), value: quality.to_string() },
Attr { key: "type".to_string(), value: t_type.to_string() },
Attr { key: "size".to_string(), value: size.to_string() },
Attr { key: "seeds".to_string(), value: seeds.to_string() },
Attr { key: "peers".to_string(), value: peers.to_string() },
Attr { key: "hash".to_string(), value: hash.to_string() },
],
})
}).collect();
// Sort by priority (best quality first)
servers.sort_by_key(|s| s.priority);
if servers.is_empty() {
return Err(PluginError::NotFound);
}
Ok(servers)
}
fn resolve_stream(_ctx: RequestContext, server: Server) -> Result<StreamSource, PluginError> {
// The server URL is the magnet link
let magnet_url = server.url.clone();
if magnet_url.is_empty() || !magnet_url.starts_with("magnet:") {
return Err(PluginError::InvalidInput("Invalid magnet link".to_string()));
}
// Extract quality info from extra
let quality = server.extra.iter()
.find(|a| a.key == "quality")
.map(|a| a.value.as_str())
.unwrap_or("720p");
let (width, height, label) = Self::quality_resolution(quality);
Ok(StreamSource {
id: format!("stream-{}", server.id),
label: server.label.clone(),
format: StreamFormat::Progressive,
manifest_url: None,
videos: vec![VideoTrack {
resolution: VideoResolution {
width,
height,
hdr: false,
label: label.to_string(),
},
url: magnet_url,
mime_type: Some("application/x-bittorrent".to_string()),
bitrate: None,
codecs: None,
}],
subtitles: vec![],
headers: vec![],
extra: vec![
Attr { key: "source".to_string(), value: "yts".to_string() },
Attr { key: "protocol".to_string(), value: "magnet".to_string() },
],
})
}
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)
}
}
// ============================================================================
// Utility functions
// ============================================================================
/// Minimal URL-encoding (replaces spaces with + and percent-encodes special chars)
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
}
/// Capitalize the first letter of a string (for genre names)
fn capitalize_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
bindings::export!(Component with_types_in bindings);