Add TMDB builtin-addon mapping functions

Supports fluxa-desktop's synthetic TMDB metadata addon: a static
manifest (catalog + meta resources only, no stream), a genre-name-to-id
catalog URL builder (popular/discover/search, skip mapped to TMDB's
page param), a rich meta mapper that prefers a resolved imdb id and
picks a title logo from TMDB's /images response, and an episode-list
mapper with still_path thumbnails.
This commit is contained in:
KhooLy 2026-07-12 13:49:40 +03:00
parent ec5418a295
commit a0df2534a6
2 changed files with 384 additions and 0 deletions

View file

@ -934,6 +934,34 @@ fn route_tmdb(method: &str, args_json: &str) -> Outcome {
field_str(&args, "language")?,
)))
}
"tmdbBuiltinManifest" => Ok(Value::String(tmdb_plan::tmdb_builtin_manifest_json())),
"tmdbBuiltinCatalogUrl" => {
let args = object(args_json)?;
Ok(Value::String(tmdb_plan::tmdb_builtin_catalog_url(
field_str(&args, "contentType")?,
field(&args, "extra")?,
field_str(&args, "apiKey")?,
field_str(&args, "language")?,
)))
}
"tmdbFullMetaToMeta" => {
let args = object(args_json)?;
opt_json(tmdb_plan::tmdb_full_meta_to_meta_json(
field_str(&args, "detailsJson")?,
field_str(&args, "creditsJson")?,
field_str(&args, "imagesJson")?,
field_str(&args, "externalIdsJson")?,
field_str(&args, "requestedType")?,
field_str(&args, "language")?,
))
}
"tmdbEpisodesToVideos" => {
let args = object(args_json)?;
opt_json(tmdb_plan::tmdb_episodes_to_videos_json(
field_str(&args, "seasonJson")?,
field_str(&args, "seriesId")?,
))
}
"tmdbPeopleImagesFromCredits" => {
let args = object(args_json)?;
let empty: Vec<Value> = Vec::new();

View file

@ -278,3 +278,359 @@ pub(crate) fn tmdb_people_images_from_credits(credits: &Value, links: &[Value])
Value::Object(images)
}
const MOVIE_GENRES: &[(u32, &str)] = &[
(28, "Action"),
(12, "Adventure"),
(16, "Animation"),
(35, "Comedy"),
(80, "Crime"),
(99, "Documentary"),
(18, "Drama"),
(10751, "Family"),
(14, "Fantasy"),
(36, "History"),
(27, "Horror"),
(10402, "Music"),
(9648, "Mystery"),
(10749, "Romance"),
(878, "Science Fiction"),
(10770, "TV Movie"),
(53, "Thriller"),
(10752, "War"),
(37, "Western"),
];
const TV_GENRES: &[(u32, &str)] = &[
(10759, "Action & Adventure"),
(16, "Animation"),
(35, "Comedy"),
(80, "Crime"),
(99, "Documentary"),
(18, "Drama"),
(10751, "Family"),
(10762, "Kids"),
(9648, "Mystery"),
(10763, "News"),
(10764, "Reality"),
(10765, "Sci-Fi & Fantasy"),
(10766, "Soap"),
(10767, "Talk"),
(10768, "War & Politics"),
(37, "Western"),
];
fn genre_table(content_type: &str) -> &'static [(u32, &'static str)] {
if content_type == "series" {
TV_GENRES
} else {
MOVIE_GENRES
}
}
fn tmdb_genre_id(content_type: &str, name: &str) -> Option<u32> {
genre_table(content_type)
.iter()
.find(|(_, n)| n.eq_ignore_ascii_case(name))
.map(|(id, _)| *id)
}
fn genre_names(content_type: &str) -> Vec<&'static str> {
genre_table(content_type).iter().map(|(_, n)| *n).collect()
}
pub(crate) fn tmdb_builtin_manifest_json() -> String {
let catalog = |content_type: &str, id: &str, name: &str| {
json!({
"type": content_type,
"id": id,
"name": name,
"extra": [
{ "name": "search" },
{ "name": "genre", "options": genre_names(content_type) },
{ "name": "skip" },
],
})
};
json!({
"id": "com.fluxa.tmdb-builtin",
"name": "TMDB",
"description": "Built-in metadata sourced directly from TMDB",
"version": "1.0.0",
"resources": ["catalog", "meta"],
"types": ["movie", "series"],
"idPrefixes": ["tt", "tmdb:"],
"catalogs": [
catalog("movie", "tmdb.movies", "TMDB Movies"),
catalog("series", "tmdb.series", "TMDB Series"),
],
})
.to_string()
}
pub(crate) fn tmdb_builtin_catalog_url(
content_type: &str,
extra: &Value,
api_key: &str,
language: &str,
) -> String {
let tmdb_type = tmdb_content_type(content_type);
let skip = extra
.get("skip")
.and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
.unwrap_or(0);
let page = (skip / 20) + 1;
let page_str = page.to_string();
if let Some(search) = extra.get("search").and_then(Value::as_str).filter(|s| !s.is_empty()) {
return tmdb_api_url(
&format!("3/search/{tmdb_type}"),
api_key,
language,
&[("query", search), ("page", &page_str)],
);
}
if let Some(genre_name) = extra.get("genre").and_then(Value::as_str).filter(|s| !s.is_empty()) {
if let Some(genre_id) = tmdb_genre_id(content_type, genre_name) {
let genre_id_str = genre_id.to_string();
return tmdb_api_url(
&format!("3/discover/{tmdb_type}"),
api_key,
language,
&[
("with_genres", genre_id_str.as_str()),
("sort_by", "popularity.desc"),
("page", &page_str),
],
);
}
}
tmdb_api_url(&format!("3/{tmdb_type}/popular"), api_key, language, &[("page", &page_str)])
}
fn pick_logo(images: &Value, language: &str) -> Option<String> {
let logos = images.get("logos").and_then(Value::as_array)?;
let lang = tmdb_language(language);
let lang_prefix = lang.split('-').next().unwrap_or("en");
let pick =
|want: &str| logos.iter().find(|l| l.get("iso_639_1").and_then(Value::as_str) == Some(want));
let chosen = pick(lang_prefix).or_else(|| pick("en")).or_else(|| logos.first())?;
tmdb_image_url(chosen.get("file_path").and_then(Value::as_str), "w500")
}
pub(crate) fn tmdb_full_meta_to_meta_json(
details_json: &str,
credits_json: &str,
images_json: &str,
external_ids_json: &str,
requested_type: &str,
language: &str,
) -> Option<String> {
let details: Value = serde_json::from_str(details_json).ok()?;
let credits: Value = serde_json::from_str(credits_json).unwrap_or_else(|_| json!({}));
let images: Value = serde_json::from_str(images_json).unwrap_or_else(|_| json!({}));
let external_ids: Value = serde_json::from_str(external_ids_json).unwrap_or_else(|_| json!({}));
let tmdb_id = details.get("id").and_then(Value::as_i64)?;
let has_tv = details.get("first_air_date").is_some() || details.get("name").is_some();
let content_type = if requested_type == "series" || has_tv { "series" } else { "movie" };
let imdb_id = external_ids
.get("imdb_id")
.and_then(Value::as_str)
.filter(|s| !s.is_empty());
let id = imdb_id.map(str::to_string).unwrap_or_else(|| format!("tmdb:{tmdb_id}"));
let name = details
.get("title")
.or_else(|| details.get("name"))
.and_then(Value::as_str)
.unwrap_or("Unknown");
let description = details.get("overview").and_then(Value::as_str).filter(|s| !s.is_empty());
let released = details
.get("release_date")
.or_else(|| details.get("first_air_date"))
.and_then(Value::as_str);
let runtime_minutes = details
.get("runtime")
.and_then(Value::as_i64)
.filter(|m| *m > 0)
.or_else(|| {
details
.get("episode_run_time")
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(Value::as_i64)
});
let genres: Vec<String> = details
.get("genres")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|g| g.get("name").and_then(Value::as_str).map(str::to_string))
.collect()
})
.unwrap_or_default();
let poster = tmdb_image_url(details.get("poster_path").and_then(Value::as_str), "w500");
let background = tmdb_image_url(details.get("backdrop_path").and_then(Value::as_str), "original");
let logo = pick_logo(&images, language);
let cast: Vec<Value> = credits
.get("cast")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.take(10)
.filter_map(|c| {
let name = c.get("name").and_then(Value::as_str)?;
Some(json!({
"name": name,
"character": c.get("character").and_then(Value::as_str),
"profilePath": tmdb_image_url(c.get("profile_path").and_then(Value::as_str), "w185"),
}))
})
.collect()
})
.unwrap_or_default();
let director: Vec<String> = credits
.get("crew")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter(|c| c.get("job").and_then(Value::as_str) == Some("Director"))
.filter_map(|c| c.get("name").and_then(Value::as_str).map(str::to_string))
.collect()
})
.unwrap_or_default();
serde_json::to_string(&json!({
"id": id,
"type": content_type,
"name": name,
"description": description,
"poster": poster,
"background": background,
"logo": logo,
"releaseInfo": released.map(|r| r.get(..4).unwrap_or(r)),
"runtime": runtime_minutes.map(|m| format!("{m} min")),
"genres": genres,
"imdbRating": details.get("vote_average").and_then(Value::as_f64),
"cast": cast,
"director": director,
}))
.ok()
}
pub(crate) fn tmdb_episodes_to_videos_json(season_json: &str, series_id: &str) -> Option<String> {
let season: Value = serde_json::from_str(season_json).ok()?;
let episodes = season.get("episodes").and_then(Value::as_array)?;
let videos: Vec<Value> = episodes
.iter()
.filter_map(|ep| {
let season_num = ep.get("season_number").and_then(Value::as_i64)?;
let episode_num = ep.get("episode_number").and_then(Value::as_i64)?;
Some(json!({
"id": format!("{series_id}:{season_num}:{episode_num}"),
"title": ep.get("name").and_then(Value::as_str).unwrap_or("Episode"),
"season": season_num,
"episode": episode_num,
"overview": ep.get("overview").and_then(Value::as_str).filter(|s| !s.is_empty()),
"released": ep.get("air_date").and_then(Value::as_str),
"thumbnail": tmdb_image_url(ep.get("still_path").and_then(Value::as_str), "w300"),
}))
})
.collect();
serde_json::to_string(&videos).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_manifest_declares_no_stream_resource() {
let manifest: Value = serde_json::from_str(&tmdb_builtin_manifest_json()).unwrap();
let resources: Vec<&str> = manifest["resources"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(resources.contains(&"catalog"));
assert!(resources.contains(&"meta"));
assert!(!resources.contains(&"stream"));
}
#[test]
fn full_meta_prefers_imdb_id_when_available() {
let details = json!({"id": 550, "title": "Fight Club", "overview": "...", "vote_average": 8.4}).to_string();
let credits = json!({"cast": [], "crew": []}).to_string();
let images = json!({"logos": []}).to_string();
let external_ids = json!({"imdb_id": "tt0137523"}).to_string();
let result: Value = serde_json::from_str(
&tmdb_full_meta_to_meta_json(&details, &credits, &images, &external_ids, "movie", "en").unwrap(),
)
.unwrap();
assert_eq!(result["id"], "tt0137523");
assert_eq!(result["type"], "movie");
}
#[test]
fn full_meta_falls_back_to_tmdb_id_without_imdb_match() {
let details = json!({"id": 550, "name": "Some Show"}).to_string();
let credits = json!({}).to_string();
let images = json!({}).to_string();
let external_ids = json!({}).to_string();
let result: Value = serde_json::from_str(
&tmdb_full_meta_to_meta_json(&details, &credits, &images, &external_ids, "series", "en").unwrap(),
)
.unwrap();
assert_eq!(result["id"], "tmdb:550");
assert_eq!(result["type"], "series");
}
#[test]
fn full_meta_picks_logo_matching_requested_language_first() {
let details = json!({"id": 1, "title": "X"}).to_string();
let credits = json!({}).to_string();
let images = json!({"logos": [
{"iso_639_1": "en", "file_path": "/en.png"},
{"iso_639_1": "tr", "file_path": "/tr.png"},
]})
.to_string();
let external_ids = json!({}).to_string();
let result: Value = serde_json::from_str(
&tmdb_full_meta_to_meta_json(&details, &credits, &images, &external_ids, "movie", "tr").unwrap(),
)
.unwrap();
assert_eq!(result["logo"], "https://image.tmdb.org/t/p/w500/tr.png");
}
#[test]
fn episodes_map_still_path_to_thumbnail() {
let season = json!({"episodes": [
{"season_number": 1, "episode_number": 3, "name": "Ep 3", "still_path": "/s3.jpg", "air_date": "2020-01-01"},
]})
.to_string();
let result: Value = serde_json::from_str(&tmdb_episodes_to_videos_json(&season, "tt123").unwrap()).unwrap();
let video = &result[0];
assert_eq!(video["id"], "tt123:1:3");
assert_eq!(video["thumbnail"], "https://image.tmdb.org/t/p/w300/s3.jpg");
}
#[test]
fn catalog_url_maps_genre_name_to_id() {
let url = tmdb_builtin_catalog_url("movie", &json!({"genre": "Horror"}), "KEY", "en");
assert!(url.contains("3/discover/movie"));
assert!(url.contains("with_genres=27"));
}
#[test]
fn catalog_url_maps_skip_to_page() {
let url = tmdb_builtin_catalog_url("movie", &json!({"skip": 40}), "KEY", "en");
assert!(url.contains("page=3"));
}
}