| #[allow(warnings)] |
| 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"; |
|
|
| |
| |
| |
|
|
| #[derive(Deserialize, Debug, Clone, Default)] |
| #[allow(dead_code)] |
| struct ApiTitle { |
| #[serde(default)] |
| id: String, |
| #[serde(rename = "type", default)] |
| title_type: String, |
| #[serde(rename = "primaryTitle", default)] |
| primary_title: String, |
| #[serde(rename = "originalTitle", default)] |
| original_title: Option<String>, |
| #[serde(rename = "primaryImage", default)] |
| primary_image: Option<ApiImage>, |
| #[serde(rename = "startYear", default, deserialize_with = "deserialize_u32_or_string")] |
| start_year: Option<u32>, |
| #[serde(rename = "endYear", default, deserialize_with = "deserialize_u32_or_string")] |
| end_year: Option<u32>, |
| #[serde(rename = "runtimeSeconds", default)] |
| runtime_seconds: Option<u32>, |
| #[serde(default)] |
| genres: Option<Vec<String>>, |
| #[serde(default)] |
| rating: Option<ApiRating>, |
| #[serde(default)] |
| plot: Option<String>, |
| #[serde(default)] |
| directors: Option<Vec<ApiName>>, |
| #[serde(default)] |
| writers: Option<Vec<ApiName>>, |
| #[serde(default)] |
| stars: Option<Vec<ApiName>>, |
| #[serde(rename = "originCountries", default)] |
| origin_countries: Option<Vec<ApiCountry>>, |
| #[serde(rename = "spokenLanguages", default)] |
| 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) |
| } |
|
|
| #[derive(Deserialize, Debug, Clone)] |
| #[allow(dead_code)] |
| struct ApiImage { |
| url: Option<String>, |
| width: Option<u32>, |
| height: Option<u32>, |
| } |
|
|
| #[derive(Deserialize, Debug, Clone, Default)] |
| struct ApiRating { |
| #[serde(rename = "aggregateRating", default, deserialize_with = "deserialize_f64_or_string")] |
| aggregate_rating: Option<f64>, |
| #[serde(rename = "voteCount", default, deserialize_with = "deserialize_u64_or_string")] |
| 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) |
| } |
|
|
| #[derive(Deserialize, Debug, Clone)] |
| struct ApiName { |
| id: String, |
| #[serde(rename = "displayName")] |
| display_name: String, |
| #[serde(rename = "primaryImage")] |
| primary_image: Option<ApiImage>, |
| } |
|
|
| #[derive(Deserialize, Debug, Clone)] |
| struct ApiCountry { |
| code: String, |
| name: Option<String>, |
| } |
|
|
| #[derive(Deserialize, Debug, Clone)] |
| struct ApiLanguage { |
| code: String, |
| name: Option<String>, |
| } |
|
|
| #[derive(Deserialize, Debug, Clone)] |
| #[allow(dead_code)] |
| struct ApiEpisode { |
| id: String, |
| title: Option<String>, |
| #[serde(rename = "primaryImage")] |
| primary_image: Option<ApiImage>, |
| season: Option<String>, |
| #[serde(rename = "episodeNumber")] |
| episode_number: Option<u32>, |
| #[serde(rename = "runtimeSeconds")] |
| runtime_seconds: Option<u32>, |
| plot: Option<String>, |
| rating: Option<ApiRating>, |
| #[serde(rename = "releaseDate")] |
| release_date: Option<ApiPrecisionDate>, |
| } |
|
|
| #[derive(Deserialize, Debug, Clone)] |
| struct ApiPrecisionDate { |
| year: Option<u32>, |
| month: Option<u32>, |
| day: Option<u32>, |
| } |
|
|
| #[derive(Deserialize, Debug, Clone)] |
| struct ListTitlesResponse { |
| titles: Option<Vec<ApiTitle>>, |
| #[serde(rename = "nextPageToken")] |
| next_page_token: Option<String>, |
| } |
|
|
| #[derive(Deserialize, Debug, Clone)] |
| #[allow(dead_code)] |
| struct ListTitleEpisodesResponse { |
| episodes: Option<Vec<ApiEpisode>>, |
| #[serde(rename = "nextPageToken")] |
| next_page_token: Option<String>, |
| } |
|
|
| #[derive(Deserialize, Debug, Clone)] |
| struct SearchTitlesResponse { |
| titles: Option<Vec<ApiTitle>>, |
| } |
|
|
| |
| |
| |
|
|
| 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() |
| } |
|
|
| |
| |
| |
|
|
| 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) |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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 |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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![], |
| }) |
| } |
|
|
| |
| |
| |
|
|
| 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![]; |
| } |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| |
| |
|
|
| impl Guest for Component { |
| fn get_home(_ctx: RequestContext) -> Result<Vec<HomeSection>, PluginError> { |
| let mut sections = Vec::new(); |
|
|
| |
| 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![], |
| }); |
| } |
| } |
| } |
| } |
|
|
| |
| 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> { |
| |
| 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); |
|
|
| |
| 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)), |
| }); |
| } |
| } |
|
|
| |
| 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(); |
|
|
| |
| 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> { |
| |
| 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) |
| } |
| } |
|
|
| |
| 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); |
|
|