/*! 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, data: Option, } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct YtsListData { movie_count: Option, limit: Option, page_number: Option, movies: Option>, } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct YtsMovie { id: u64, url: Option, imdb_code: Option, title: Option, title_english: Option, title_long: Option, slug: Option, year: Option, rating: Option, runtime: Option, genres: Option>, summary: Option, description_full: Option, synopsis: Option, yt_trailer_code: Option, language: Option, mpa_rating: Option, background_image: Option, background_image_original: Option, small_cover_image: Option, medium_cover_image: Option, large_cover_image: Option, torrents: Option>, #[serde(default)] cast: Option>, } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct YtsTorrent { url: Option, hash: Option, quality: Option, #[serde(rename = "type")] torrent_type: Option, seeds: Option, peers: Option, size: Option, size_bytes: Option, date_uploaded: Option, date_uploaded_unix: Option, } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct YtsMovieDetailResponse { status: Option, data: Option, } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct YtsMovieDetailData { movie: Option, } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct YtsSuggestionsResponse { status: Option, data: Option, } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct YtsSuggestionsData { movie_count: Option, movies: Option>, } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct YtsMovieSuggestion { id: u64, url: Option, title: Option, title_english: Option, title_long: Option, year: Option, rating: Option, genres: Option>, medium_cover_image: Option, large_cover_image: Option, } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct YtsCastMember { name: Option, character_name: Option, url_small_image: Option, imdb_code: Option, } // ============================================================================ // HTTP Helper — delegates to host-provided http::send-request // ============================================================================ impl Component { fn fetch_url(url: &str) -> Option { 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(json_str: &str) -> Option { 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 { 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> { 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> { 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 { 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 { 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::(&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, 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 = 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 = 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 = 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 { // 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 = 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 { 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 = 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 { 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 = 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, 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 = 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 { // 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, PluginError> { Err(PluginError::Unsupported) } fn download_subtitle(_ctx: RequestContext, _id: String) -> Result { Err(PluginError::Unsupported) } fn get_articles(_ctx: RequestContext) -> Result, PluginError> { Err(PluginError::Unsupported) } fn search_articles(_ctx: RequestContext, _query: String) -> Result, PluginError> { Err(PluginError::Unsupported) } } // ============================================================================ // 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::() + c.as_str(), } } bindings::export!(Component with_types_in bindings);