Download plugins/bex-yts/src/lib.rs from krystv/pluginengine01: direct link, hf CLI and curl.
- Browser
- Download file 29.4 kB
-
https://huggingface.co/krystv/pluginengine01/resolve/ceb9df490a130d33feb7bb1244b29ebf3dca4772/plugins/bex-yts/src/lib.rs
- Command line
-
hf download hf://krystv/pluginengine01@ceb9df490a130d33feb7bb1244b29ebf3dca4772/plugins/bex-yts/src/lib.rs
-
curl -L -o lib.rs https://huggingface.co/krystv/pluginengine01/resolve/ceb9df490a130d33feb7bb1244b29ebf3dca4772/plugins/bex-yts/src/lib.rs
29.4 kB
| /*! | |
| 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) | |
| */ | |
| 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 | |
| // ============================================================================ | |
| struct YtsListResponse { | |
| status: Option<String>, | |
| data: Option<YtsListData>, | |
| } | |
| struct YtsListData { | |
| movie_count: Option<u64>, | |
| limit: Option<u32>, | |
| page_number: Option<u32>, | |
| movies: Option<Vec<YtsMovie>>, | |
| } | |
| 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>>, | |
| cast: Option<Vec<YtsCastMember>>, | |
| } | |
| struct YtsTorrent { | |
| url: Option<String>, | |
| hash: Option<String>, | |
| quality: Option<String>, | |
| 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>, | |
| } | |
| struct YtsMovieDetailResponse { | |
| status: Option<String>, | |
| data: Option<YtsMovieDetailData>, | |
| } | |
| struct YtsMovieDetailData { | |
| movie: Option<YtsMovie>, | |
| } | |
| struct YtsSuggestionsResponse { | |
| status: Option<String>, | |
| data: Option<YtsSuggestionsData>, | |
| } | |
| struct YtsSuggestionsData { | |
| movie_count: Option<u64>, | |
| movies: Option<Vec<YtsMovieSuggestion>>, | |
| } | |
| 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>, | |
| } | |
| 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 | |
| // ======================================================================== | |
| 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 | |
| // ======================================================================== | |
| 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); | |