/* 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) 2025 metamuffin */ use crate::USER_AGENT; use anyhow::{anyhow, bail, Context}; use bincode::{Decode, Encode}; use jellybase::common::{ chrono::{format::Parsed, Utc}, TmdbKind, }; use jellycache::{async_cache_file, async_cache_memory, CachePath}; use log::info; use reqwest::{ header::{HeaderMap, HeaderName, HeaderValue}, Client, ClientBuilder, }; use serde::Deserialize; use std::sync::Arc; use tokio::io::AsyncWriteExt; 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 async fn search(&self, kind: TmdbKind, query: &str) -> anyhow::Result> { async_cache_memory("api-tmdb-search", (kind, query), || async move { 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?) }) .await .context("tmdb search") } pub async fn details(&self, kind: TmdbKind, id: u64) -> anyhow::Result> { async_cache_memory("api-tmdb-details", (kind, id), || async move { 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?) }) .await .context("tmdb details") } pub async fn person_image(&self, id: u64) -> anyhow::Result> { async_cache_memory("api-tmdb-search", id, || async move { Ok(self .client .get(format!( "https://api.themoviedb.org/3/person/{id}/images?api_key={}", self.key, )) .send() .await? .error_for_status()? .json() .await?) }) .await .context("tmdb person images") } pub async fn image(&self, path: &str) -> anyhow::Result { async_cache_file("api-tmdb-image", path, |mut file| async move { info!("downloading image {path:?}"); let mut res = self .image_client .get(format!("https://image.tmdb.org/t/p/original{path}")) .send() .await? .error_for_status()?; while let Some(chunk) = res.chunk().await? { file.write_all(&chunk).await?; } Ok(()) }) .await .context("tmdb image download") } pub async fn episode_details( &self, series_id: u64, season: usize, episode: usize, ) -> anyhow::Result> { async_cache_memory("api-tmdb-episode-details", (series_id,season,episode), || async move { 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?) }) .await.context("tmdb episode details") } } pub fn parse_release_date(d: &str) -> anyhow::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())) } #[derive(Debug, Clone, Deserialize, Encode, Decode)] 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, Deserialize, Encode, Decode)] pub struct TmdbPersonImage { pub id: u64, pub profiles: Vec, } #[derive(Debug, Clone, Deserialize, Encode, Decode)] pub struct TmdbPersonImageProfile { pub aspect_ratio: f64, pub height: u32, pub width: u32, pub file_path: String, } #[derive(Debug, Clone, Deserialize, Encode, Decode)] pub struct TmdbQuery { pub page: usize, pub results: Vec, pub total_pages: usize, pub total_results: usize, } #[derive(Debug, Clone, Deserialize, Encode, Decode)] 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, Deserialize, Encode, Decode)] 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, Deserialize, Encode, Decode)] pub struct TmdbGenre { pub id: u64, pub name: String, } #[derive(Debug, Clone, Deserialize, Encode, Decode)] pub struct TmdbProductionCompany { pub id: u64, pub name: String, pub logo_path: Option, }