use crate::error::AppError; use crate::services::tmdb::TmdbClient; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IdType { Imdb, Tmdb, Tvdb, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum MediaType { Movie, Tv, Episode, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EpisodeInfo { pub show_tmdb_id: u64, pub season_number: u32, pub episode_number: u32, pub still_path: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResolvedId { pub imdb_id: Option, pub tmdb_id: u64, pub tvdb_id: Option, pub media_type: MediaType, pub poster_path: Option, pub release_date: Option, pub episode: Option, } pub fn format_tmdb_id_value(tmdb_id: u64, media_type: &MediaType, episode: Option<&EpisodeInfo>) -> String { match media_type { MediaType::Movie => format!("movie-{tmdb_id}"), MediaType::Tv => format!("series-{tmdb_id}"), MediaType::Episode => match episode { Some(ep) => format!("episode-{}-S{}E{}", ep.show_tmdb_id, ep.season_number, ep.episode_number), None => format!("series-{tmdb_id}"), }, } } impl IdType { pub fn parse(s: &str) -> Result { match s { "imdb" => Ok(IdType::Imdb), "tmdb" => Ok(IdType::Tmdb), "tvdb" => Ok(IdType::Tvdb), other => Err(AppError::InvalidIdType(other.to_string())), } } pub fn as_str(&self) -> &'static str { match self { Self::Imdb => "imdb", Self::Tmdb => "tmdb", Self::Tvdb => "tvdb", } } } #[derive(Debug, Deserialize)] struct FindResult { #[serde(default)] movie_results: Vec, #[serde(default)] tv_results: Vec, #[serde(default)] tv_episode_results: Vec, } #[derive(Debug, Deserialize)] struct EpisodeFindEntry { show_id: u64, season_number: u32, episode_number: u32, still_path: Option, } #[derive(Debug, Deserialize)] struct FindEntry { id: u64, poster_path: Option, release_date: Option, first_air_date: Option, #[serde(default)] popularity: f64, } pub async fn resolve( id_type: IdType, id_value: &str, tmdb: &TmdbClient, cache: &moka::future::Cache, db: &sea_orm::DatabaseConnection, ) -> Result { let id_type_str = match id_type { IdType::Imdb => "imdb", IdType::Tmdb => "tmdb", IdType::Tvdb => "tvdb", }; let key = format!("{id_type_str}/{id_value}"); let tmdb = tmdb.clone(); let id_value = id_value.to_owned(); let db = db.clone(); let cache_key = key.clone(); cache .try_get_with(key, async move { let result = match id_type { IdType::Imdb => resolve_imdb(&id_value, &tmdb).await, IdType::Tmdb => resolve_tmdb(&id_value, &tmdb).await, IdType::Tvdb => resolve_tvdb(&id_value, &tmdb).await, }; if let Ok(ref resolved) = result { let db = db.clone(); let cache_key = cache_key.clone(); let resolved = resolved.clone(); tokio::spawn(async move { crate::cache::persistence::save_id_cache_entry(&db, &cache_key, &resolved, 3600).await; }); } result }) .await .map_err(AppError::from_cached) } async fn resolve_imdb(imdb_id: &str, tmdb: &TmdbClient) -> Result { // Handle episode format: episode-{series_imdb_id}-S{season}E{episode} if let Some(rest) = imdb_id.strip_prefix("episode-") { return resolve_imdb_episode(rest, imdb_id, tmdb).await; } let result: FindResult = tmdb .get(&format!("/find/{imdb_id}"), &[("external_source", "imdb_id")]) .await?; // Check episode results first — an episode IMDb ID is unambiguous. if let Some(ep) = result.tv_episode_results.first() { return resolve_episode_details(tmdb, ep.show_id, ep.season_number, ep.episode_number, ep.still_path.clone(), Some(imdb_id.to_string())).await; } // Pick the most popular entry across all movie and TV results. let best_movie = result.movie_results.iter().max_by(|a, b| a.popularity.total_cmp(&b.popularity)); let best_tv = result.tv_results.iter().max_by(|a, b| a.popularity.total_cmp(&b.popularity)); // When both exist, pick the one with higher popularity. // Fall back to movie-first ordering when popularity is equal. let pick_movie = match (best_movie, best_tv) { (Some(m), Some(t)) => m.popularity >= t.popularity, (Some(_), None) => true, (None, Some(_)) => false, (None, None) => false, }; if pick_movie { if let Some(movie) = best_movie { return Ok(ResolvedId { imdb_id: Some(imdb_id.to_string()), tmdb_id: movie.id, tvdb_id: None, media_type: MediaType::Movie, poster_path: movie.poster_path.clone(), release_date: movie.release_date.clone(), episode: None, }); } } if let Some(tv) = best_tv { return Ok(ResolvedId { imdb_id: Some(imdb_id.to_string()), tmdb_id: tv.id, tvdb_id: None, media_type: MediaType::Tv, poster_path: tv.poster_path.clone(), release_date: tv.first_air_date.clone(), episode: None, }); } Err(AppError::IdNotFound(format!("{imdb_id} (not found on TMDB)"))) } async fn resolve_tmdb(id_value: &str, tmdb: &TmdbClient) -> Result { // Handle episode format: episode-{show_id}-S{season}E{episode} if let Some(rest) = id_value.strip_prefix("episode-") { return resolve_tmdb_episode(rest, id_value, tmdb).await; } let (media_type, tmdb_id) = if let Some(rest) = id_value.strip_prefix("movie-") { (MediaType::Movie, rest.parse::().map_err(|_| AppError::InvalidIdType(id_value.to_string()))?) } else if let Some(rest) = id_value.strip_prefix("series-") { (MediaType::Tv, rest.parse::().map_err(|_| AppError::InvalidIdType(id_value.to_string()))?) } else { return Err(AppError::InvalidIdType(format!( "tmdb id must be prefixed with movie-, series-, or episode-: {id_value}" ))); }; #[derive(Deserialize)] struct Details { imdb_id: Option, poster_path: Option, release_date: Option, first_air_date: Option, #[serde(default)] external_ids: Option, } #[derive(Deserialize)] struct ExternalIds { imdb_id: Option, tvdb_id: Option, } let path = match media_type { MediaType::Movie => format!("/movie/{tmdb_id}"), MediaType::Tv => format!("/tv/{tmdb_id}?append_to_response=external_ids"), MediaType::Episode => return Err(AppError::Other("unexpected Episode media type in resolve_tmdb".into())), }; let details: Details = tmdb.get(&path, &[]).await?; // TMDB returns an empty string (not null) for titles it hasn't cross-referenced // to IMDb; treat that as absent so downstream MDBList/Trakt/OMDb lookups take // their TMDB-id fallback or skip cleanly instead of querying with a blank id. let imdb_id = details .imdb_id .or_else(|| details.external_ids.as_ref().and_then(|e| e.imdb_id.clone())) .filter(|s| !s.is_empty()); let tvdb_id = details.external_ids.as_ref().and_then(|e| e.tvdb_id); let release_date = match media_type { MediaType::Movie => details.release_date, MediaType::Tv => details.first_air_date, MediaType::Episode => return Err(AppError::Other("unexpected Episode media type in resolve_tmdb".into())), }; Ok(ResolvedId { imdb_id, tmdb_id, tvdb_id, media_type, poster_path: details.poster_path, release_date, episode: None, }) } /// Parse the `{show_id}-S{season}E{episode}` portion of an episode ID. /// Returns `(show_id, season, episode)` on success. fn parse_episode_id(rest: &str, id_value: &str) -> Result<(u64, u32, u32), AppError> { let (id_str, season, episode) = parse_episode_external(rest, id_value)?; let show_id = id_str .parse::() .map_err(|_| AppError::InvalidIdType(id_value.to_string()))?; Ok((show_id, season, episode)) } /// Parse `episode-{show_id}-S{season}E{episode}` and resolve via TMDB. async fn resolve_tmdb_episode(rest: &str, id_value: &str, tmdb: &TmdbClient) -> Result { let (show_id, season, episode) = parse_episode_id(rest, id_value)?; resolve_episode_details(tmdb, show_id, season, episode, None, None).await } /// Shared helper: fetch episode details from TMDB and build a `ResolvedId`. /// /// Called by all three resolvers (IMDb, TMDB, TVDB) after they determine the /// show ID and season/episode numbers. `hint_still_path` and `hint_imdb_id` are /// values already known from the `/find` response (if any) to avoid redundant lookups. async fn resolve_episode_details( tmdb: &TmdbClient, show_tmdb_id: u64, season: u32, episode: u32, hint_still_path: Option, hint_imdb_id: Option, ) -> Result { #[derive(Deserialize)] struct EpDetails { still_path: Option, air_date: Option, #[serde(default)] external_ids: Option, } #[derive(Deserialize)] struct EpExternalIds { imdb_id: Option, tvdb_id: Option, } let details: EpDetails = tmdb .get( &format!("/tv/{show_tmdb_id}/season/{season}/episode/{episode}"), &[("append_to_response", "external_ids")], ) .await?; let imdb_id = hint_imdb_id .or_else(|| details.external_ids.as_ref().and_then(|e| e.imdb_id.clone())) .filter(|s| !s.is_empty()); let tvdb_id = details.external_ids.as_ref().and_then(|e| e.tvdb_id); let still_path = hint_still_path.or(details.still_path.clone()); // Use still_path as poster_path; fallback to series poster if no still let poster_path = if still_path.is_some() { still_path.clone() } else { #[derive(Deserialize)] struct ShowInfo { poster_path: Option, } let show: ShowInfo = tmdb.get(&format!("/tv/{show_tmdb_id}"), &[]).await?; show.poster_path }; Ok(ResolvedId { imdb_id, tmdb_id: show_tmdb_id, tvdb_id, media_type: MediaType::Episode, poster_path, release_date: details.air_date, episode: Some(EpisodeInfo { show_tmdb_id, season_number: season, episode_number: episode, still_path, }), }) } /// Parse `{external_id}-S{season}E{episode}` for IMDb/TVDB episode lookups. /// Unlike `parse_episode_id`, the ID portion is returned as a string slice /// because IMDb IDs like `tt1234567` aren't purely numeric. fn parse_episode_external<'a>(rest: &'a str, id_value: &str) -> Result<(&'a str, u32, u32), AppError> { let upper = rest.to_ascii_uppercase(); let split_pos = upper.find("-S").ok_or_else(|| { AppError::InvalidIdType(format!( "episode id must be episode-{{id}}-S{{season}}E{{episode}}: {id_value}" )) })?; let external_id = &rest[..split_pos]; if external_id.is_empty() { return Err(AppError::InvalidIdType(format!( "episode id must be episode-{{id}}-S{{season}}E{{episode}}: {id_value}" ))); } let se_str = &upper[split_pos + 2..]; // skip "-S" let se_parts: Vec<&str> = se_str.splitn(2, 'E').collect(); if se_parts.len() != 2 { return Err(AppError::InvalidIdType(format!( "episode id must be episode-{{id}}-S{{season}}E{{episode}}: {id_value}" ))); } let season = se_parts[0] .parse::() .map_err(|_| AppError::InvalidIdType(id_value.to_string()))?; let episode = se_parts[1] .parse::() .map_err(|_| AppError::InvalidIdType(id_value.to_string()))?; if season > 10_000 || episode > 100_000 { return Err(AppError::BadRequest( "season must be ≤ 10 000 and episode must be ≤ 100 000".into(), )); } Ok((external_id, season, episode)) } /// Resolve `episode-{series_imdb_id}-S{season}E{episode}` by first looking up /// the series via TMDB's Find API, then fetching episode details. async fn resolve_imdb_episode( rest: &str, id_value: &str, tmdb: &TmdbClient, ) -> Result { let (series_imdb_id, season, episode): (&str, u32, u32) = parse_episode_external(rest, id_value)?; let result: FindResult = tmdb .get( &format!("/find/{series_imdb_id}"), &[("external_source", "imdb_id")], ) .await?; let show_tmdb_id = result .tv_results .first() .map(|tv| tv.id) .ok_or_else(|| { AppError::IdNotFound(format!( "{series_imdb_id} (not found as a TV series on TMDB)" )) })?; resolve_episode_details(tmdb, show_tmdb_id, season, episode, None, None).await } /// Resolve `episode-{series_tvdb_id}-S{season}E{episode}` by first looking up /// the series via TMDB's Find API, then fetching episode details. async fn resolve_tvdb_episode( rest: &str, id_value: &str, tmdb: &TmdbClient, ) -> Result { let (series_tvdb_id, season, episode) = parse_episode_external(rest, id_value)?; let result: FindResult = tmdb .get( &format!("/find/{series_tvdb_id}"), &[("external_source", "tvdb_id")], ) .await?; let show_tmdb_id = result .tv_results .first() .map(|tv| tv.id) .ok_or_else(|| { AppError::IdNotFound(format!( "{series_tvdb_id} (not found as a TV series on TMDB via TVDB lookup)" )) })?; resolve_episode_details(tmdb, show_tmdb_id, season, episode, None, None).await } #[cfg(test)] mod tests { use super::*; #[test] fn parse_imdb() { assert_eq!(IdType::parse("imdb").unwrap(), IdType::Imdb); } #[test] fn parse_tmdb() { assert_eq!(IdType::parse("tmdb").unwrap(), IdType::Tmdb); } #[test] fn parse_tvdb() { assert_eq!(IdType::parse("tvdb").unwrap(), IdType::Tvdb); } #[test] fn parse_invalid_id_type() { assert!(IdType::parse("invalid").is_err()); } #[test] fn parse_empty_string() { assert!(IdType::parse("").is_err()); } #[test] fn parse_case_sensitive() { // Should not accept uppercase assert!(IdType::parse("IMDB").is_err()); assert!(IdType::parse("Tmdb").is_err()); } #[test] fn format_tmdb_id_value_movie() { assert_eq!(format_tmdb_id_value(278, &MediaType::Movie, None), "movie-278"); } #[test] fn format_tmdb_id_value_tv() { assert_eq!(format_tmdb_id_value(1396, &MediaType::Tv, None), "series-1396"); } #[test] fn format_tmdb_id_value_episode() { let ep = EpisodeInfo { show_tmdb_id: 1396, season_number: 1, episode_number: 1, still_path: None, }; assert_eq!( format_tmdb_id_value(1396, &MediaType::Episode, Some(&ep)), "episode-1396-S1E1" ); } #[test] fn format_tmdb_id_value_episode_large_numbers() { let ep = EpisodeInfo { show_tmdb_id: 456, season_number: 12, episode_number: 24, still_path: Some("/still.jpg".into()), }; assert_eq!( format_tmdb_id_value(456, &MediaType::Episode, Some(&ep)), "episode-456-S12E24" ); } #[test] fn format_tmdb_id_value_episode_none_falls_back() { // Episode with missing EpisodeInfo should not panic, falls back to series format assert_eq!( format_tmdb_id_value(1396, &MediaType::Episode, None), "series-1396" ); } // --- parse_episode_id --- #[test] fn parse_episode_id_valid() { let (show, season, ep) = parse_episode_id("1396-S1E1", "episode-1396-S1E1").unwrap(); assert_eq!(show, 1396); assert_eq!(season, 1); assert_eq!(ep, 1); } #[test] fn parse_episode_id_multi_digit() { let (show, season, ep) = parse_episode_id("456-S12E24", "episode-456-S12E24").unwrap(); assert_eq!(show, 456); assert_eq!(season, 12); assert_eq!(ep, 24); } #[test] fn parse_episode_id_missing_season() { assert!(parse_episode_id("1396", "episode-1396").is_err()); } #[test] fn parse_episode_id_missing_episode() { assert!(parse_episode_id("1396-S1", "episode-1396-S1").is_err()); } #[test] fn parse_episode_id_non_numeric_show() { assert!(parse_episode_id("abc-S1E1", "episode-abc-S1E1").is_err()); } #[test] fn parse_episode_id_non_numeric_season() { assert!(parse_episode_id("1396-SaE1", "episode-1396-SaE1").is_err()); } #[test] fn parse_episode_id_non_numeric_episode() { assert!(parse_episode_id("1396-S1Eb", "episode-1396-S1Eb").is_err()); } #[test] fn parse_episode_id_lowercase() { let (show, season, ep) = parse_episode_id("1396-s1e1", "episode-1396-s1e1").unwrap(); assert_eq!(show, 1396); assert_eq!(season, 1); assert_eq!(ep, 1); } #[test] fn parse_episode_id_mixed_case() { let (show, season, ep) = parse_episode_id("456-s12E24", "episode-456-s12E24").unwrap(); assert_eq!(show, 456); assert_eq!(season, 12); assert_eq!(ep, 24); } // --- parse_episode_external --- #[test] fn parse_episode_external_imdb() { let (id, season, ep) = parse_episode_external("tt14786934-S1E1", "episode-tt14786934-S1E1").unwrap(); assert_eq!(id, "tt14786934"); assert_eq!(season, 1); assert_eq!(ep, 1); } #[test] fn parse_episode_external_tvdb_numeric() { let (id, season, ep) = parse_episode_external("81189-S3E5", "episode-81189-S3E5").unwrap(); assert_eq!(id, "81189"); assert_eq!(season, 3); assert_eq!(ep, 5); } #[test] fn parse_episode_external_lowercase() { let (id, season, ep) = parse_episode_external("tt14786934-s2e10", "episode-tt14786934-s2e10").unwrap(); assert_eq!(id, "tt14786934"); assert_eq!(season, 2); assert_eq!(ep, 10); } #[test] fn parse_episode_external_missing_season() { assert!(parse_episode_external("tt14786934", "episode-tt14786934").is_err()); } #[test] fn parse_episode_external_missing_episode() { assert!(parse_episode_external("tt14786934-S1", "episode-tt14786934-S1").is_err()); } #[test] fn parse_episode_external_zero_values() { let (id, season, ep) = parse_episode_external("tt14786934-S0E0", "episode-tt14786934-S0E0").unwrap(); assert_eq!(id, "tt14786934"); assert_eq!(season, 0); assert_eq!(ep, 0); } #[test] fn parse_episode_external_at_boundary() { let (id, season, ep) = parse_episode_external("81189-S10000E100000", "episode-81189-S10000E100000").unwrap(); assert_eq!(id, "81189"); assert_eq!(season, 10_000); assert_eq!(ep, 100_000); } #[test] fn parse_episode_external_over_boundary() { assert!(parse_episode_external("81189-S10001E1", "episode-81189-S10001E1").is_err()); assert!(parse_episode_external("81189-S1E100001", "episode-81189-S1E100001").is_err()); } #[test] fn parse_episode_external_empty_id() { assert!(parse_episode_external("-S1E1", "episode--S1E1").is_err()); } #[test] fn parse_episode_id_rejects_imdb_style_id() { assert!(parse_episode_id("tt1234-S1E1", "episode-tt1234-S1E1").is_err()); } } async fn resolve_tvdb(tvdb_id: &str, tmdb: &TmdbClient) -> Result { // Handle episode format: episode-{series_tvdb_id}-S{season}E{episode} if let Some(rest) = tvdb_id.strip_prefix("episode-") { return resolve_tvdb_episode(rest, tvdb_id, tmdb).await; } let tvdb_id_num = tvdb_id.parse::().ok(); let result: FindResult = tmdb .get(&format!("/find/{tvdb_id}"), &[("external_source", "tvdb_id")]) .await?; // Check series/movie results first - a TVDB ID passed to this route is almost // always a series or movie ID. TMDB's find-by-tvdb_id can also return an // unrelated match in tv_episode_results for the same query (confirmed real // case: a TVDB series ID for Game of Thrones also matched an unrelated // Alfred Hitchcock Presents episode) - checking series/movie first avoids // that false match. Episode results are now only used as a fallback, which // still covers legitimate bare-numeric TVDB episode IDs per the OpenAPI spec. if let Some(tv) = result.tv_results.first() { // We need to fetch details to get the imdb_id #[derive(Deserialize)] struct TvDetails { external_ids: Option, poster_path: Option, first_air_date: Option, } #[derive(Deserialize)] struct TvExternalIds { imdb_id: Option, } let details: TvDetails = tmdb .get( &format!("/tv/{}", tv.id), &[("append_to_response", "external_ids")], ) .await?; return Ok(ResolvedId { imdb_id: details .external_ids .and_then(|e| e.imdb_id) .filter(|s| !s.is_empty()), tmdb_id: tv.id, tvdb_id: tvdb_id_num, media_type: MediaType::Tv, poster_path: details.poster_path.or_else(|| tv.poster_path.clone()), release_date: details.first_air_date, episode: None, }); } if let Some(movie) = result.movie_results.first() { #[derive(Deserialize)] struct MovieDetails { imdb_id: Option, poster_path: Option, release_date: Option, } let details: MovieDetails = tmdb .get(&format!("/movie/{}", movie.id), &[]) .await?; return Ok(ResolvedId { imdb_id: details.imdb_id.filter(|s| !s.is_empty()), tmdb_id: movie.id, tvdb_id: tvdb_id_num, media_type: MediaType::Movie, poster_path: details.poster_path.or_else(|| movie.poster_path.clone()), release_date: details.release_date, episode: None, }); } // Fallback: only reached when no series/movie result matched. if let Some(ep) = result.tv_episode_results.first() { return resolve_episode_details(tmdb, ep.show_id, ep.season_number, ep.episode_number, ep.still_path.clone(), None).await; } Err(AppError::IdNotFound(format!("{tvdb_id} (not found on TMDB via TVDB lookup)"))) }