| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| #[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"; |
|
|
| |
| |
| |
|
|
| #[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>, |
| } |
|
|
| |
| |
| |
|
|
| 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() |
| } |
|
|
| |
| |
| |
|
|
| 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, |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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![], |
| }) |
| } |
|
|
| |
| |
| |
|
|
| 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) |
| } |
|
|
| |
| |
| |
|
|
| 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) |
| } |
|
|
| |
| |
| |
|
|
| #[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![], |
| } |
| } |
|
|
| |
| |
| |
|
|
| #[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) |
| } |
|
|
| |
| |
| |
|
|
| 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"), |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Guest for Component { |
| fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> { |
| let mut sections = Vec::new(); |
|
|
| |
| 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![], |
| }); |
| } |
| } |
|
|
| |
| 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![], |
| }); |
| } |
| } |
|
|
| |
| 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![], |
| }); |
| } |
| } |
|
|
| |
| 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> { |
| |
| 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; |
|
|
| |
| let description = movie.synopsis.as_deref() |
| .or(movie.description_full.as_deref()) |
| .or(movie.summary.as_deref()) |
| .map(|s| s.to_string()); |
|
|
| |
| let trailer_url = movie.yt_trailer_code.as_deref() |
| .filter(|c| !c.is_empty()) |
| .map(|c| format!("https://www.youtube.com/watch?v={}", c)); |
|
|
| |
| 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()); |
|
|
| |
| 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(); |
|
|
| |
| 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() }); |
| } |
|
|
| |
| 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(); |
|
|
| |
| 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> { |
| |
| 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())); |
| } |
|
|
| |
| 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) |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| 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 |
| } |
|
|
| |
| 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); |
|
|