Download plugins/bex-imdb/src/lib.rs from krystv/pluginengine01: direct link, hf CLI and curl.
- Browser
- Download file 28.2 kB
-
https://huggingface.co/krystv/pluginengine01/resolve/ceb9df490a130d33feb7bb1244b29ebf3dca4772/plugins/bex-imdb/src/lib.rs
- Command line
-
hf download hf://krystv/pluginengine01@ceb9df490a130d33feb7bb1244b29ebf3dca4772/plugins/bex-imdb/src/lib.rs
-
curl -L -o lib.rs https://huggingface.co/krystv/pluginengine01/resolve/ceb9df490a130d33feb7bb1244b29ebf3dca4772/plugins/bex-imdb/src/lib.rs
28.2 kB
| mod bindings; | |
| use bindings::bex::plugin::common::*; | |
| use bindings::bex::plugin::http; | |
| use bindings::exports::api::Guest; | |
| use serde::Deserialize; | |
| use std::collections::HashMap; | |
| struct Component; | |
| const IMDB_API_BASE: &str = "https://api.imdbapi.dev"; | |
| // ============================================================================ | |
| // IMDb API Response Types | |
| // ============================================================================ | |
| struct ApiTitle { | |
| id: String, | |
| title_type: String, | |
| primary_title: String, | |
| original_title: Option<String>, | |
| primary_image: Option<ApiImage>, | |
| start_year: Option<u32>, | |
| end_year: Option<u32>, | |
| runtime_seconds: Option<u32>, | |
| genres: Option<Vec<String>>, | |
| rating: Option<ApiRating>, | |
| plot: Option<String>, | |
| directors: Option<Vec<ApiName>>, | |
| writers: Option<Vec<ApiName>>, | |
| stars: Option<Vec<ApiName>>, | |
| origin_countries: Option<Vec<ApiCountry>>, | |
| spoken_languages: Option<Vec<ApiLanguage>>, | |
| } | |
| fn deserialize_u32_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u32>, D::Error> { | |
| use serde::de::{self, Visitor}; | |
| struct U32OrString; | |
| impl<'de> Visitor<'de> for U32OrString { | |
| type Value = Option<u32>; | |
| fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u32 or string") } | |
| fn visit_none<E: de::Error>(self) -> Result<Option<u32>, E> { Ok(None) } | |
| fn visit_some<D2: serde::Deserializer<'de>>(self, d2: D2) -> Result<Option<u32>, D2::Error> { | |
| d2.deserialize_any(U32Inner) | |
| } | |
| fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<u32>, E> { Ok(v.try_into().ok()) } | |
| fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<u32>, E> { Ok(v.try_into().ok()) } | |
| fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<u32>, E> { Ok(v.parse().ok()) } | |
| } | |
| struct U32Inner; | |
| impl<'de> Visitor<'de> for U32Inner { | |
| type Value = Option<u32>; | |
| fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u32 or string") } | |
| fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<u32>, E> { Ok(v.try_into().ok()) } | |
| fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<u32>, E> { Ok(v.try_into().ok()) } | |
| fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<u32>, E> { Ok(v.parse().ok()) } | |
| fn visit_none<E: de::Error>(self) -> Result<Option<u32>, E> { Ok(None) } | |
| } | |
| d.deserialize_option(U32OrString) | |
| } | |
| struct ApiImage { | |
| url: Option<String>, | |
| width: Option<u32>, | |
| height: Option<u32>, | |
| } | |
| struct ApiRating { | |
| aggregate_rating: Option<f64>, | |
| vote_count: Option<u64>, | |
| } | |
| fn deserialize_f64_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> { | |
| use serde::de::{self, Visitor}; | |
| struct F64OrString; | |
| impl<'de> Visitor<'de> for F64OrString { | |
| type Value = Option<f64>; | |
| fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("f64 or string") } | |
| fn visit_none<E: de::Error>(self) -> Result<Option<f64>, E> { Ok(None) } | |
| fn visit_some<D2: serde::Deserializer<'de>>(self, d2: D2) -> Result<Option<f64>, D2::Error> { | |
| d2.deserialize_any(F64OrStringInner) | |
| } | |
| fn visit_f64<E: de::Error>(self, v: f64) -> Result<Option<f64>, E> { Ok(Some(v)) } | |
| fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<f64>, E> { Ok(Some(v as f64)) } | |
| fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<f64>, E> { Ok(Some(v as f64)) } | |
| fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<f64>, E> { Ok(v.parse().ok()) } | |
| } | |
| struct F64OrStringInner; | |
| impl<'de> Visitor<'de> for F64OrStringInner { | |
| type Value = Option<f64>; | |
| fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("f64 or string") } | |
| fn visit_f64<E: de::Error>(self, v: f64) -> Result<Option<f64>, E> { Ok(Some(v)) } | |
| fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<f64>, E> { Ok(Some(v as f64)) } | |
| fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<f64>, E> { Ok(Some(v as f64)) } | |
| fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<f64>, E> { Ok(v.parse().ok()) } | |
| fn visit_none<E: de::Error>(self) -> Result<Option<f64>, E> { Ok(None) } | |
| } | |
| d.deserialize_option(F64OrString) | |
| } | |
| fn deserialize_u64_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> { | |
| use serde::de::{self, Visitor}; | |
| struct U64OrString; | |
| impl<'de> Visitor<'de> for U64OrString { | |
| type Value = Option<u64>; | |
| fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u64 or string") } | |
| fn visit_none<E: de::Error>(self) -> Result<Option<u64>, E> { Ok(None) } | |
| fn visit_some<D2: serde::Deserializer<'de>>(self, d2: D2) -> Result<Option<u64>, D2::Error> { | |
| d2.deserialize_any(U64OrStringInner) | |
| } | |
| fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<u64>, E> { Ok(Some(v)) } | |
| fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<u64>, E> { Ok(v.try_into().ok()) } | |
| fn visit_f64<E: de::Error>(self, v: f64) -> Result<Option<u64>, E> { Ok(Some(v as u64)) } | |
| fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<u64>, E> { Ok(v.parse().ok()) } | |
| } | |
| struct U64OrStringInner; | |
| impl<'de> Visitor<'de> for U64OrStringInner { | |
| type Value = Option<u64>; | |
| fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u64 or string") } | |
| fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<u64>, E> { Ok(Some(v)) } | |
| fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<u64>, E> { Ok(v.try_into().ok()) } | |
| fn visit_f64<E: de::Error>(self, v: f64) -> Result<Option<u64>, E> { Ok(Some(v as u64)) } | |
| fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<u64>, E> { Ok(v.parse().ok()) } | |
| fn visit_none<E: de::Error>(self) -> Result<Option<u64>, E> { Ok(None) } | |
| } | |
| d.deserialize_option(U64OrString) | |
| } | |
| struct ApiName { | |
| id: String, | |
| display_name: String, | |
| primary_image: Option<ApiImage>, | |
| } | |
| struct ApiCountry { | |
| code: String, | |
| name: Option<String>, | |
| } | |
| struct ApiLanguage { | |
| code: String, | |
| name: Option<String>, | |
| } | |
| struct ApiEpisode { | |
| id: String, | |
| title: Option<String>, | |
| primary_image: Option<ApiImage>, | |
| season: Option<String>, | |
| episode_number: Option<u32>, | |
| runtime_seconds: Option<u32>, | |
| plot: Option<String>, | |
| rating: Option<ApiRating>, | |
| release_date: Option<ApiPrecisionDate>, | |
| } | |
| struct ApiPrecisionDate { | |
| year: Option<u32>, | |
| month: Option<u32>, | |
| day: Option<u32>, | |
| } | |
| struct ListTitlesResponse { | |
| titles: Option<Vec<ApiTitle>>, | |
| next_page_token: Option<String>, | |
| } | |
| struct ListTitleEpisodesResponse { | |
| episodes: Option<Vec<ApiEpisode>>, | |
| next_page_token: Option<String>, | |
| } | |
| struct SearchTitlesResponse { | |
| titles: Option<Vec<ApiTitle>>, | |
| } | |
| // ============================================================================ | |
| // 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: "BexIMDB/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(2 * 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() | |
| } | |
| // ======================================================================== | |
| // Thumbnail / 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 format_precision_date(date: &ApiPrecisionDate) -> String { | |
| let y = date.year.unwrap_or(0); | |
| let m = date.month.unwrap_or(0); | |
| let d = date.day.unwrap_or(0); | |
| if y == 0 { | |
| "Unknown".to_string() | |
| } else if m == 0 { | |
| format!("{:04}", y) | |
| } else if d == 0 { | |
| format!("{:04}-{:02}", y, m) | |
| } else { | |
| format!("{:04}-{:02}-{:02}", y, m, d) | |
| } | |
| } | |
| // ======================================================================== | |
| // Map API type string → BEX media-kind | |
| // ======================================================================== | |
| fn media_kind(type_str: &str) -> MediaKind { | |
| if type_str.eq_ignore_ascii_case("movie") | |
| || type_str.eq_ignore_ascii_case("tv_movie") | |
| || type_str.eq_ignore_ascii_case("tvMovie") | |
| { | |
| MediaKind::Movie | |
| } else if type_str.eq_ignore_ascii_case("tv_series") | |
| || type_str.eq_ignore_ascii_case("tvSeries") | |
| || type_str.eq_ignore_ascii_case("tv_mini_series") | |
| || type_str.eq_ignore_ascii_case("tvMiniSeries") | |
| || type_str.eq_ignore_ascii_case("tv_special") | |
| || type_str.eq_ignore_ascii_case("tvSpecial") | |
| { | |
| MediaKind::Series | |
| } else if type_str.eq_ignore_ascii_case("short") { | |
| MediaKind::Short | |
| } else { | |
| MediaKind::Unknown | |
| } | |
| } | |
| // ======================================================================== | |
| // ApiTitle → MediaCard | |
| // ======================================================================== | |
| fn api_title_to_card(title: &ApiTitle) -> Option<MediaCard> { | |
| let id = title.id.clone(); | |
| let kind = Self::media_kind(&title.title_type); | |
| let img_url = title | |
| .primary_image | |
| .as_ref() | |
| .and_then(|img| img.url.as_deref()); | |
| let score = title | |
| .rating | |
| .as_ref() | |
| .and_then(|r| r.aggregate_rating) | |
| .map(|r| (r * 10.0) as u32); | |
| let year = if let (Some(s), Some(e)) = (title.start_year, title.end_year) { | |
| Some(format!("{} - {}", s, e)) | |
| } else { | |
| title.start_year.map(|y| y.to_string()) | |
| }; | |
| Some(MediaCard { | |
| id: id.clone(), | |
| title: title.primary_title.clone(), | |
| kind: Some(kind), | |
| images: Some(Self::img(img_url, ImageLayout::Portrait)), | |
| original_title: title.original_title.clone(), | |
| tagline: None, | |
| year, | |
| score, | |
| genres: title.genres.clone().unwrap_or_default(), | |
| status: None, | |
| content_rating: None, | |
| url: Some(format!("https://www.imdb.com/title/{}/", id)), | |
| ids: vec![LinkedId { source: "imdb".to_string(), id }], | |
| extra: vec![], | |
| }) | |
| } | |
| // ======================================================================== | |
| // Fetch seasons + episodes (paginated) for TV series | |
| // ======================================================================== | |
| fn fetch_seasons(title_id: &str) -> Vec<Season> { | |
| let mut all_eps = Vec::new(); | |
| let mut page_token: Option<String> = None; | |
| loop { | |
| let url = if let Some(ref token) = page_token { | |
| format!( | |
| "{}/titles/{}/episodes?pageSize=50&pageToken={}", | |
| IMDB_API_BASE, title_id, token | |
| ) | |
| } else { | |
| format!("{}/titles/{}/episodes?pageSize=50", IMDB_API_BASE, title_id) | |
| }; | |
| let resp = match Self::fetch_url(&url) { | |
| Some(r) => r, | |
| None => break, | |
| }; | |
| let data: ListTitleEpisodesResponse = match Self::parse_json(&resp) { | |
| Some(d) => d, | |
| None => break, | |
| }; | |
| if let Some(eps) = data.episodes { | |
| all_eps.extend(eps); | |
| } | |
| page_token = data.next_page_token; | |
| if page_token.is_none() { | |
| break; | |
| } | |
| } | |
| if all_eps.is_empty() { | |
| return vec![]; | |
| } | |
| // Group by season | |
| let mut map: HashMap<String, Vec<Episode>> = HashMap::new(); | |
| for ep in all_eps { | |
| let sn = ep.season.clone().unwrap_or_else(|| "Unknown".to_string()); | |
| let ep_num = ep.episode_number.unwrap_or(0); | |
| let title_text = ep.title.as_deref().unwrap_or("Unknown"); | |
| let full_title = format!("E{}. {}", ep_num, title_text); | |
| let img_url = ep.primary_image.as_ref().and_then(|i| i.url.as_deref()); | |
| let score = ep.rating.as_ref().and_then(|r| r.aggregate_rating).map(|r| (r * 10.0) as u32); | |
| let released = ep.release_date.as_ref().map(Self::format_precision_date); | |
| let episode = Episode { | |
| id: ep.id.clone(), | |
| title: full_title, | |
| number: Some(ep_num as f64), | |
| season: sn.parse::<f64>().ok(), | |
| images: Some(Self::img(img_url, ImageLayout::Landscape)), | |
| description: ep.plot.clone(), | |
| released, | |
| score, | |
| url: Some(format!("https://www.imdb.com/title/{}/", ep.id)), | |
| tags: vec![], | |
| extra: vec![], | |
| }; | |
| map.entry(sn).or_default().push(episode); | |
| } | |
| let mut seasons: Vec<Season> = map | |
| .into_iter() | |
| .map(|(sn, mut episodes)| { | |
| episodes.sort_by(|a, b| { | |
| let an = a.number.unwrap_or(0.0) as u32; | |
| let bn = b.number.unwrap_or(0.0) as u32; | |
| an.cmp(&bn) | |
| }); | |
| let num = sn.parse::<f64>().ok(); | |
| let year = episodes | |
| .first() | |
| .and_then(|ep| ep.released.as_ref()) | |
| .and_then(|rd| rd.split('-').next()) | |
| .and_then(|y| y.parse::<u32>().ok()); | |
| Season { | |
| id: format!("{}_s{}", title_id, sn), | |
| title: format!("Season {}", sn), | |
| number: num, | |
| year, | |
| episodes, | |
| } | |
| }) | |
| .collect(); | |
| seasons.sort_by(|a, b| { | |
| a.number | |
| .unwrap_or(0.0) | |
| .partial_cmp(&b.number.unwrap_or(0.0)) | |
| .unwrap_or(std::cmp::Ordering::Equal) | |
| }); | |
| seasons | |
| } | |
| } | |
| // ============================================================================ | |
| // Guest implementation — the actual plugin API | |
| // ============================================================================ | |
| impl Guest for Component { | |
| fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> { | |
| let mut sections = Vec::new(); | |
| // Popular Movies | |
| let url = format!( | |
| "{}/titles?sortBy=SORT_BY_POPULARITY&types=MOVIE", | |
| IMDB_API_BASE | |
| ); | |
| if let Some(resp) = Self::fetch_url(&url) { | |
| if let Some(data) = Self::parse_json::<ListTitlesResponse>(&resp) { | |
| if let Some(titles) = data.titles { | |
| let items: Vec<MediaCard> = titles.iter().filter_map(Self::api_title_to_card).take(20).collect(); | |
| if !items.is_empty() { | |
| sections.push(HomeSection { | |
| id: "popular_movies".to_string(), | |
| title: "Most Popular Movies".to_string(), | |
| subtitle: None, | |
| items, | |
| next_page: Some("popular_movies:2".to_string()), | |
| layout: CardLayout::Grid, | |
| show_rank: true, | |
| categories: vec![], | |
| extra: vec![], | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // Popular TV Shows | |
| let url = format!( | |
| "{}/titles?sortBy=SORT_BY_POPULARITY&types=TV_SERIES", | |
| IMDB_API_BASE | |
| ); | |
| if let Some(resp) = Self::fetch_url(&url) { | |
| if let Some(data) = Self::parse_json::<ListTitlesResponse>(&resp) { | |
| if let Some(titles) = data.titles { | |
| let items: Vec<MediaCard> = titles.iter().filter_map(Self::api_title_to_card).take(20).collect(); | |
| if !items.is_empty() { | |
| sections.push(HomeSection { | |
| id: "popular_tv".to_string(), | |
| title: "Most Popular TV Shows".to_string(), | |
| subtitle: None, | |
| items, | |
| next_page: Some("popular_tv:2".to_string()), | |
| layout: CardLayout::Grid, | |
| show_rank: true, | |
| categories: vec![], | |
| extra: vec![], | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| Ok(sections) | |
| } | |
| fn get_category(_ctx: RequestContext, id: String, page: PageCursor) -> Result<PagedResult, PluginError> { | |
| // Parse our custom page token format: "category_type: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 = match id.as_str() { | |
| "popular_movies" => format!( | |
| "{}/titles?sortBy=SORT_BY_POPULARITY&types=MOVIE&pageToken={}", | |
| IMDB_API_BASE, page_num | |
| ), | |
| "popular_tv" => format!( | |
| "{}/titles?sortBy=SORT_BY_POPULARITY&types=TV_SERIES&pageToken={}", | |
| IMDB_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: ListTitlesResponse = Self::parse_json(&resp).ok_or(PluginError::Parse("Invalid JSON".to_string()))?; | |
| let items: Vec<MediaCard> = data | |
| .titles | |
| .unwrap_or_default() | |
| .iter() | |
| .filter_map(Self::api_title_to_card) | |
| .collect(); | |
| let next_page = data.next_page_token.map(|_t| format!("{}:{}", id, page_num + 1)); | |
| 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 encoded = urlencoding(&query); | |
| let url = format!( | |
| "{}/search/titles?query={}&limit=30", | |
| IMDB_API_BASE, encoded | |
| ); | |
| let resp = Self::fetch_url(&url).ok_or(PluginError::Network("Search request failed".to_string()))?; | |
| let data: SearchTitlesResponse = Self::parse_json(&resp).ok_or(PluginError::Parse("Invalid search response".to_string()))?; | |
| let items: Vec<MediaCard> = data | |
| .titles | |
| .unwrap_or_default() | |
| .iter() | |
| .filter_map(Self::api_title_to_card) | |
| .collect(); | |
| Ok(PagedResult { | |
| items, | |
| categories: vec![], | |
| next_page: None, | |
| }) | |
| } | |
| fn get_info(_ctx: RequestContext, id: String) -> Result<MediaInfo, PluginError> { | |
| let url = format!("{}/titles/{}", IMDB_API_BASE, id); | |
| let resp = Self::fetch_url(&url).ok_or(PluginError::Network("Failed to fetch title".to_string()))?; | |
| let title: ApiTitle = Self::parse_json(&resp).ok_or(PluginError::Parse("Invalid title JSON".to_string()))?; | |
| let kind = Self::media_kind(&title.title_type); | |
| let img_url = title.primary_image.as_ref().and_then(|i| i.url.as_deref()); | |
| let score = title.rating.as_ref().and_then(|r| r.aggregate_rating).map(|r| (r * 10.0) as u32); | |
| let scored_by = title.rating.as_ref().and_then(|r| r.vote_count); | |
| // Crew: directors + writers | |
| let mut crew_list: Vec<Person> = Vec::new(); | |
| if let Some(directors) = &title.directors { | |
| for d in directors { | |
| let d_img = d.primary_image.as_ref().and_then(|i| i.url.as_deref()); | |
| crew_list.push(Person { | |
| id: d.id.clone(), | |
| name: d.display_name.clone(), | |
| image: Some(Self::img(d_img, ImageLayout::Portrait)), | |
| role: Some("Director".to_string()), | |
| url: Some(format!("https://www.imdb.com/name/{}/", d.id)), | |
| }); | |
| } | |
| } | |
| if let Some(writers) = &title.writers { | |
| for w in writers { | |
| let w_img = w.primary_image.as_ref().and_then(|i| i.url.as_deref()); | |
| crew_list.push(Person { | |
| id: w.id.clone(), | |
| name: w.display_name.clone(), | |
| image: Some(Self::img(w_img, ImageLayout::Portrait)), | |
| role: Some("Writer".to_string()), | |
| url: Some(format!("https://www.imdb.com/name/{}/", w.id)), | |
| }); | |
| } | |
| } | |
| // Cast from stars | |
| let cast: Vec<Person> = title | |
| .stars | |
| .unwrap_or_default() | |
| .iter() | |
| .map(|s| { | |
| let s_img = s.primary_image.as_ref().and_then(|i| i.url.as_deref()); | |
| Person { | |
| id: s.id.clone(), | |
| name: s.display_name.clone(), | |
| image: Some(Self::img(s_img, ImageLayout::Portrait)), | |
| role: None, | |
| url: Some(format!("https://www.imdb.com/name/{}/", s.id)), | |
| } | |
| }) | |
| .collect(); | |
| // Seasons for TV series | |
| let seasons = if matches!(kind, MediaKind::Series) { | |
| Self::fetch_seasons(&id) | |
| } else { | |
| vec![] | |
| }; | |
| let runtime_minutes = title.runtime_seconds.map(|s| s / 60); | |
| let country = title.origin_countries.map(|cs| { | |
| cs.iter() | |
| .map(|c| c.name.as_deref().unwrap_or(&c.code).to_string()) | |
| .collect::<Vec<_>>() | |
| .join(", ") | |
| }); | |
| let language = title.spoken_languages.map(|ls| { | |
| ls.iter() | |
| .map(|l| l.name.as_deref().unwrap_or(&l.code).to_string()) | |
| .collect::<Vec<_>>() | |
| .join(", ") | |
| }); | |
| let year = if let (Some(s), Some(e)) = (title.start_year, title.end_year) { | |
| Some(format!("{} - {}", s, e)) | |
| } else { | |
| title.start_year.map(|y| y.to_string()) | |
| }; | |
| Ok(MediaInfo { | |
| id: id.clone(), | |
| title: title.primary_title.clone(), | |
| kind, | |
| images: Some(Self::img(img_url, ImageLayout::Portrait)), | |
| original_title: title.original_title.clone(), | |
| description: title.plot.clone(), | |
| score, | |
| scored_by, | |
| year, | |
| release_date: None, | |
| genres: title.genres.clone().unwrap_or_default(), | |
| tags: vec![], | |
| status: None, | |
| content_rating: None, | |
| seasons, | |
| cast, | |
| crew: crew_list, | |
| runtime_minutes, | |
| trailer_url: None, | |
| ids: vec![LinkedId { source: "imdb".to_string(), id: id.clone() }], | |
| studio: None, | |
| country, | |
| language, | |
| url: Some(format!("https://www.imdb.com/title/{}/", id)), | |
| extra: vec![], | |
| }) | |
| } | |
| fn get_servers(_ctx: RequestContext, _id: String) -> Result<Vec<Server>, PluginError> { | |
| // IMDB is metadata-only; no streaming servers | |
| Err(PluginError::Unsupported) | |
| } | |
| fn resolve_stream(_ctx: RequestContext, _server: Server) -> Result<StreamSource, PluginError> { | |
| Err(PluginError::Unsupported) | |
| } | |
| fn search_subtitles(_ctx: RequestContext, _query: SubtitleQuery) -> Result<Vec<SubtitleEntry>, PluginError> { | |
| Err(PluginError::Unsupported) | |
| } | |
| fn download_subtitle(_ctx: RequestContext, _id: String) -> Result<SubtitleFile, PluginError> { | |
| Err(PluginError::Unsupported) | |
| } | |
| fn get_articles(_ctx: RequestContext) -> Result<Vec<ArticleSection>, PluginError> { | |
| Err(PluginError::Unsupported) | |
| } | |
| fn search_articles(_ctx: RequestContext, _query: String) -> Result<Vec<Article>, PluginError> { | |
| Err(PluginError::Unsupported) | |
| } | |
| } | |
| /// Minimal 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 | |
| } | |
| bindings::export!(Component with_types_in bindings); | |