mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-16 12:13:44 +00:00
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:
parent
ec5418a295
commit
a0df2534a6
2 changed files with 384 additions and 0 deletions
28
src/ffi.rs
28
src/ffi.rs
|
|
@ -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();
|
||||
|
|
|
|||
356
src/tmdb_plan.rs
356
src/tmdb_plan.rs
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue