#[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"; // ============================================================================ // IMDb API Response Types // ============================================================================ #[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, #[serde(rename = "primaryImage", default)] primary_image: Option, #[serde(rename = "startYear", default, deserialize_with = "deserialize_u32_or_string")] start_year: Option, #[serde(rename = "endYear", default, deserialize_with = "deserialize_u32_or_string")] end_year: Option, #[serde(rename = "runtimeSeconds", default)] runtime_seconds: Option, #[serde(default)] genres: Option>, #[serde(default)] rating: Option, #[serde(default)] plot: Option, #[serde(default)] directors: Option>, #[serde(default)] writers: Option>, #[serde(default)] stars: Option>, #[serde(rename = "originCountries", default)] origin_countries: Option>, #[serde(rename = "spokenLanguages", default)] spoken_languages: Option>, } fn deserialize_u32_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { use serde::de::{self, Visitor}; struct U32OrString; impl<'de> Visitor<'de> for U32OrString { type Value = Option; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u32 or string") } fn visit_none(self) -> Result, E> { Ok(None) } fn visit_some>(self, d2: D2) -> Result, D2::Error> { d2.deserialize_any(U32Inner) } fn visit_u64(self, v: u64) -> Result, E> { Ok(v.try_into().ok()) } fn visit_i64(self, v: i64) -> Result, E> { Ok(v.try_into().ok()) } fn visit_str(self, v: &str) -> Result, E> { Ok(v.parse().ok()) } } struct U32Inner; impl<'de> Visitor<'de> for U32Inner { type Value = Option; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u32 or string") } fn visit_u64(self, v: u64) -> Result, E> { Ok(v.try_into().ok()) } fn visit_i64(self, v: i64) -> Result, E> { Ok(v.try_into().ok()) } fn visit_str(self, v: &str) -> Result, E> { Ok(v.parse().ok()) } fn visit_none(self) -> Result, E> { Ok(None) } } d.deserialize_option(U32OrString) } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct ApiImage { url: Option, width: Option, height: Option, } #[derive(Deserialize, Debug, Clone, Default)] struct ApiRating { #[serde(rename = "aggregateRating", default, deserialize_with = "deserialize_f64_or_string")] aggregate_rating: Option, #[serde(rename = "voteCount", default, deserialize_with = "deserialize_u64_or_string")] vote_count: Option, } fn deserialize_f64_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { use serde::de::{self, Visitor}; struct F64OrString; impl<'de> Visitor<'de> for F64OrString { type Value = Option; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("f64 or string") } fn visit_none(self) -> Result, E> { Ok(None) } fn visit_some>(self, d2: D2) -> Result, D2::Error> { d2.deserialize_any(F64OrStringInner) } fn visit_f64(self, v: f64) -> Result, E> { Ok(Some(v)) } fn visit_i64(self, v: i64) -> Result, E> { Ok(Some(v as f64)) } fn visit_u64(self, v: u64) -> Result, E> { Ok(Some(v as f64)) } fn visit_str(self, v: &str) -> Result, E> { Ok(v.parse().ok()) } } struct F64OrStringInner; impl<'de> Visitor<'de> for F64OrStringInner { type Value = Option; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("f64 or string") } fn visit_f64(self, v: f64) -> Result, E> { Ok(Some(v)) } fn visit_i64(self, v: i64) -> Result, E> { Ok(Some(v as f64)) } fn visit_u64(self, v: u64) -> Result, E> { Ok(Some(v as f64)) } fn visit_str(self, v: &str) -> Result, E> { Ok(v.parse().ok()) } fn visit_none(self) -> Result, E> { Ok(None) } } d.deserialize_option(F64OrString) } fn deserialize_u64_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { use serde::de::{self, Visitor}; struct U64OrString; impl<'de> Visitor<'de> for U64OrString { type Value = Option; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u64 or string") } fn visit_none(self) -> Result, E> { Ok(None) } fn visit_some>(self, d2: D2) -> Result, D2::Error> { d2.deserialize_any(U64OrStringInner) } fn visit_u64(self, v: u64) -> Result, E> { Ok(Some(v)) } fn visit_i64(self, v: i64) -> Result, E> { Ok(v.try_into().ok()) } fn visit_f64(self, v: f64) -> Result, E> { Ok(Some(v as u64)) } fn visit_str(self, v: &str) -> Result, E> { Ok(v.parse().ok()) } } struct U64OrStringInner; impl<'de> Visitor<'de> for U64OrStringInner { type Value = Option; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("u64 or string") } fn visit_u64(self, v: u64) -> Result, E> { Ok(Some(v)) } fn visit_i64(self, v: i64) -> Result, E> { Ok(v.try_into().ok()) } fn visit_f64(self, v: f64) -> Result, E> { Ok(Some(v as u64)) } fn visit_str(self, v: &str) -> Result, E> { Ok(v.parse().ok()) } fn visit_none(self) -> Result, 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, } #[derive(Deserialize, Debug, Clone)] struct ApiCountry { code: String, name: Option, } #[derive(Deserialize, Debug, Clone)] struct ApiLanguage { code: String, name: Option, } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct ApiEpisode { id: String, title: Option, #[serde(rename = "primaryImage")] primary_image: Option, season: Option, #[serde(rename = "episodeNumber")] episode_number: Option, #[serde(rename = "runtimeSeconds")] runtime_seconds: Option, plot: Option, rating: Option, #[serde(rename = "releaseDate")] release_date: Option, } #[derive(Deserialize, Debug, Clone)] struct ApiPrecisionDate { year: Option, month: Option, day: Option, } #[derive(Deserialize, Debug, Clone)] struct ListTitlesResponse { titles: Option>, #[serde(rename = "nextPageToken")] next_page_token: Option, } #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] struct ListTitleEpisodesResponse { episodes: Option>, #[serde(rename = "nextPageToken")] next_page_token: Option, } #[derive(Deserialize, Debug, Clone)] struct SearchTitlesResponse { titles: Option>, } // ============================================================================ // HTTP Helper — delegates to host-provided http::send-request // ============================================================================ impl Component { fn fetch_url(url: &str) -> Option { let req = http::Request { method: http::Method::Get, url: url.to_string(), headers: vec![ Attr { key: "User-Agent".to_string(), value: "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(json_str: &str) -> Option { 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 { 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 { let mut all_eps = Vec::new(); let mut page_token: Option = 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> = 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::().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 = 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::().ok(); let year = episodes .first() .and_then(|ep| ep.released.as_ref()) .and_then(|rd| rd.split('-').next()) .and_then(|y| y.parse::().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, 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::(&resp) { if let Some(titles) = data.titles { let items: Vec = 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::(&resp) { if let Some(titles) = data.titles { let items: Vec = 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 { // 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 = 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 { 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 = 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 { 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 = 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 = 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::>() .join(", ") }); let language = title.spoken_languages.map(|ls| { ls.iter() .map(|l| l.name.as_deref().unwrap_or(&l.code).to_string()) .collect::>() .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, PluginError> { // IMDB is metadata-only; no streaming servers Err(PluginError::Unsupported) } fn resolve_stream(_ctx: RequestContext, _server: Server) -> Result { Err(PluginError::Unsupported) } fn search_subtitles(_ctx: RequestContext, _query: SubtitleQuery) -> Result, PluginError> { Err(PluginError::Unsupported) } fn download_subtitle(_ctx: RequestContext, _id: String) -> Result { Err(PluginError::Unsupported) } fn get_articles(_ctx: RequestContext) -> Result, PluginError> { Err(PluginError::Unsupported) } fn search_articles(_ctx: RequestContext, _query: String) -> Result, PluginError> { Err(PluginError::Unsupported) } } /// 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);