/* This file is part of jellything (https://codeberg.org/metamuffin/jellything) which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2026 metamuffin */ use crate::{ USER_AGENT, plugins::{ImportContext, ImportPlugin, PluginInfo}, }; use anyhow::{Context, Result, anyhow, bail}; use jellycache::{EscapeKey, HashKey, cache_memory, cache_store}; use jellycommon::{ Asset, IdentifierType, Node, NodeID, PictureSlot, RatingType, chrono::{Utc, format::Parsed}, }; use log::info; use reqwest::{ Client, ClientBuilder, header::{HeaderMap, HeaderName, HeaderValue}, }; use serde::{Deserialize, Serialize}; use std::{fmt::Display, sync::Arc}; use tokio::runtime::Handle; pub struct Tmdb { client: Client, image_client: Client, key: String, } impl Tmdb { pub fn new(api_key: &str) -> Self { let client = ClientBuilder::new() .default_headers(HeaderMap::from_iter([ ( HeaderName::from_static("accept"), HeaderValue::from_static("application/json"), ), ( HeaderName::from_static("user-agent"), HeaderValue::from_static(USER_AGENT), ), ])) .build() .unwrap(); let image_client = ClientBuilder::new().build().unwrap(); Self { client, image_client, key: api_key.to_owned(), } } pub fn search(&self, kind: TmdbKind, query: &str, rt: &Handle) -> Result> { cache_memory( &format!("ext/tmdb/search/{kind}-{}.json", HashKey(query)), move || { rt.block_on(async { info!("searching tmdb: {query:?}"); Ok(self .client .get(format!( "https://api.themoviedb.org/3/search/{kind}?query={}?api_key={}", query.replace(" ", "+"), self.key )) .send() .await? .error_for_status()? .json::() .await?) }) }, ) .context("tmdb search") } pub fn details(&self, kind: TmdbKind, id: u64, rt: &Handle) -> Result> { cache_memory(&format!("ext/tmdb/details/{kind}-{id}.json"), move || { rt.block_on(async { info!("fetching details: {id:?}"); Ok(self .client .get(format!( "https://api.themoviedb.org/3/{kind}/{id}?api_key={}", self.key, )) .send() .await? .error_for_status()? .json() .await?) }) }) .context("tmdb details") } pub fn person_image(&self, id: u64, rt: &Handle) -> Result> { cache_memory(&format!("ext/tmdb/person/images/{id}.json"), move || { rt.block_on(async { Ok(self .client .get(format!( "https://api.themoviedb.org/3/person/{id}/images?api_key={}", self.key, )) .send() .await? .error_for_status()? .json() .await?) }) }) .context("tmdb person images") } pub fn image(&self, path: &str, rt: &Handle) -> Result { cache_store( format!("ext/tmdb/image/{}.image", EscapeKey(path)), move || { rt.block_on(async { info!("downloading image {path:?}"); Ok(self .image_client .get(format!("https://image.tmdb.org/t/p/original{path}")) .send() .await? .error_for_status()? .bytes() .await? .to_vec()) }) }, ) .context("tmdb image download") .map(Asset) } pub fn episode_details( &self, series_id: u64, season: usize, episode: usize, rt: &Handle, ) -> Result> { cache_memory(&format!("ext/tmdb/episode-details/{series_id}-S{season}-E{episode}.json"), move || { rt.block_on(async { info!("tmdb episode details {series_id} S={season} E={episode}"); Ok(self .image_client .get(format!("https://api.themoviedb.org/3/tv/{series_id}/season/{season}/episode/{episode}?api_key={}", self.key)) .send() .await? .error_for_status()? .json() .await?) }) }) .context("tmdb episode details") } } impl ImportPlugin for Tmdb { fn info(&self) -> PluginInfo { PluginInfo { name: "tmdb", handle_process: true, ..Default::default() } } fn process(&self, ct: &ImportContext, node: NodeID, data: &Node) -> Result<()> { self.process_primary(ct, node, data)?; self.process_episode(ct, node, data)?; Ok(()) } } impl Tmdb { fn process_primary(&self, ct: &ImportContext, node: NodeID, data: &Node) -> Result<()> { let (tmdb_kind, tmdb_id): (_, u64) = if let Some(id) = data.identifiers.get(&IdentifierType::TmdbSeries) { (TmdbKind::Tv, id.parse()?) } else if let Some(id) = data.identifiers.get(&IdentifierType::TmdbMovie) { (TmdbKind::Movie, id.parse()?) } else { return Ok(()); }; let details = self.details(tmdb_kind, tmdb_id, ct.rt)?; let mut images = Vec::new(); if let Some(path) = &details.backdrop_path { images.push(( PictureSlot::Backdrop, self.image(path, ct.rt).context("backdrop image")?, )); } if let Some(path) = &details.poster_path { images.push(( PictureSlot::Cover, self.image(path, ct.rt).context("poster image")?, )); } let release_date = details .release_date .as_ref() .map(|s| parse_release_date(s)) .transpose()? .flatten(); ct.db.update_node_init(node, |node| { node.title = details.title.clone().or(node.title.clone()); node.tagline = details.tagline.clone().or(node.tagline.clone()); node.description = Some(details.overview.clone()); node.ratings.insert(RatingType::Tmdb, details.vote_average); node.pictures.extend(images); node.release_date = release_date.or(node.release_date); })?; Ok(()) } fn process_episode(&self, ct: &ImportContext, node: NodeID, data: &Node) -> Result<()> { let (Some(episode), Some(season)) = (data.index, data.season_index) else { return Ok(()); }; let mut series_id = None; for &parent in &data.parents { let parent_data = ct.db.get_node(parent)?.ok_or(anyhow!("parent missing"))?; if let Some(id) = parent_data.identifiers.get(&IdentifierType::TmdbSeries) { series_id = Some(id.parse::()?); break; } } let Some(series_id) = series_id else { return Ok(()); }; let details = self.episode_details(series_id, season, episode, ct.rt)?; let mut images = Vec::new(); if let Some(path) = &details.still_path { images.push((PictureSlot::Cover, self.image(path, ct.rt)?)) } let release_date = parse_release_date(&details.air_date)?; ct.db.update_node_init(node, |node| { node.title = Some(details.name.clone()); node.description = Some(details.overview.clone()); node.release_date = release_date.or(node.release_date); node.ratings.insert(RatingType::Tmdb, details.vote_average); node.pictures.extend(images); })?; Ok(()) } } pub fn parse_release_date(d: &str) -> Result> { if d.is_empty() { return Ok(None); } else if d.len() < 10 { bail!(anyhow!("date string too short")) } let (year, month, day) = (&d[0..4], &d[5..7], &d[8..10]); let (year, month, day) = ( year.parse().context("parsing year")?, month.parse().context("parsing month")?, day.parse().context("parsing day")?, ); let mut p = Parsed::new(); p.year = Some(year); p.month = Some(month); p.day = Some(day); p.hour_div_12 = Some(0); p.hour_mod_12 = Some(0); p.minute = Some(0); p.second = Some(0); Ok(Some(p.to_datetime_with_timezone(&Utc)?.timestamp_millis())) } impl Display for TmdbKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { TmdbKind::Tv => "tv", TmdbKind::Movie => "movie", }) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbEpisode { pub air_date: String, pub overview: String, pub name: String, pub id: u64, pub runtime: f64, pub still_path: Option, pub vote_average: f64, pub vote_count: usize, } #[derive(Debug, Clone, Copy, Hash, Serialize, Deserialize)] pub enum TmdbKind { Tv, Movie, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbPersonImage { pub id: u64, pub profiles: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbPersonImageProfile { pub aspect_ratio: f64, pub height: u32, pub width: u32, pub file_path: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbQuery { pub page: usize, pub results: Vec, pub total_pages: usize, pub total_results: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbQueryResult { pub adult: bool, pub backdrop_path: Option, pub genre_ids: Vec, pub id: u64, pub original_language: Option, pub original_title: Option, pub overview: String, pub popularity: f64, pub poster_path: Option, pub release_date: Option, pub title: Option, pub name: Option, pub vote_average: f64, pub vote_count: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbDetails { pub adult: bool, pub backdrop_path: Option, pub genres: Vec, pub id: u64, pub original_language: Option, pub original_title: Option, pub overview: String, pub popularity: f64, pub poster_path: Option, pub release_date: Option, pub title: Option, pub name: Option, pub vote_average: f64, pub vote_count: usize, pub budget: Option, pub homepage: Option, pub imdb_id: Option, pub production_companies: Vec, pub revenue: Option, pub tagline: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbGenre { pub id: u64, pub name: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbProductionCompany { pub id: u64, pub name: String, pub logo_path: Option, }