/* 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::{HashKey, cache_memory}; use jellycommon::{Appearance, CreditCategory, IdentifierType, Node, NodeID, NodeKind, RatingType}; use log::info; use reqwest::{ Client, ClientBuilder, header::{HeaderMap, HeaderName, HeaderValue}, }; use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, fmt::Display, sync::Arc}; use tokio::runtime::Handle; pub struct Trakt { client: Client, } impl Trakt { pub fn new(api_key: &str) -> Self { let client = ClientBuilder::new() .default_headers(HeaderMap::from_iter([ ( HeaderName::from_static("trakt-api-key"), HeaderValue::from_str(api_key).unwrap(), ), ( HeaderName::from_static("trakt-api-version"), HeaderValue::from_static("2"), ), ( HeaderName::from_static("content-type"), HeaderValue::from_static("application/json"), ), ( HeaderName::from_static("user-agent"), HeaderValue::from_static(USER_AGENT), ), ])) .build() .unwrap(); Self { client } } pub fn search( &self, kinds: &[TraktKind], query: &str, rt: &Handle, ) -> Result>> { cache_memory( &format!("ext/trakt/search/{}.json", HashKey(query)), move || { rt.block_on(async { let url = format!( "https://api.trakt.tv/search/{}?query={}&extended=full", kinds .iter() .map(|t| t.singular()) .collect::>() .join(","), urlencoding::encode(query), ); let res = self.client.get(url).send().await?.error_for_status()?; Ok(res.json().await?) }) }, ) .context("trakt search") } pub fn lookup(&self, kind: TraktKind, id: u64, rt: &Handle) -> Result> { cache_memory(&format!("ext/trakt/lookup/{kind}-{id}.json"), move || { rt.block_on(async { info!("trakt lookup {kind:?}:{id:?}"); let url = format!("https://api.trakt.tv/{}/{id}?extended=full", kind.plural()); let res = self.client.get(url).send().await?.error_for_status()?; Ok(res.json().await?) }) }) .context("trakt lookup") } pub fn people(&self, kind: TraktKind, id: u64, rt: &Handle) -> Result> { cache_memory(&format!("ext/trakt/people/{kind}-{id}.json"), move || { rt.block_on(async { info!("trakt people {kind:?}:{id:?}"); let url = format!( "https://api.trakt.tv/{}/{id}/people?extended=full", kind.plural() ); let res = self.client.get(url).send().await?.error_for_status()?; Ok(res.json().await?) }) }) .context("trakt people") } pub fn show_seasons(&self, id: u64, rt: &Handle) -> Result>> { cache_memory(&format!("ext/trakt/seasons/{id}.json"), move || { rt.block_on(async { info!("trakt seasons {id:?}"); let url = format!("https://api.trakt.tv/shows/{id}/seasons?extended=full"); let res = self.client.get(url).send().await?.error_for_status()?; Ok(res.json().await?) }) }) .context("trakt show seasons") } pub fn show_season_episodes( &self, id: u64, season: usize, rt: &Handle, ) -> Result>> { cache_memory( &format!("ext/trakt/episodes/{id}-S{season}.json"), move || { rt.block_on(async { info!("trakt episodes {id:?} season={season}"); let url = format!("https://api.trakt.tv/shows/{id}/seasons/{season}?extended=full"); let res = self.client.get(url).send().await?.error_for_status()?; Ok(res.json().await?) }) }, ) .context("trakt show season episodes") } } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TraktSeason { pub number: usize, pub ids: TraktIds, pub rating: f64, pub votes: usize, pub episode_count: usize, pub aired_count: Option, pub title: String, pub overview: Option, pub network: Option, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TraktEpisode { pub season: Option, pub number: usize, pub number_abs: Option, pub ids: TraktIds, pub rating: Option, pub votes: usize, pub title: String, pub runtime: Option, pub overview: Option, pub available_translations: Vec, pub first_aired: Option, pub episode_type: String, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TraktPeople { #[serde(default)] pub cast: Vec, #[serde(default)] pub crew: BTreeMap>, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TraktAppearance { #[serde(default)] pub jobs: Vec, #[serde(default)] pub characters: Vec, pub person: TraktPerson, } #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct TraktPerson { pub name: String, pub ids: TraktIds, } #[derive(Debug, Serialize, Deserialize)] pub struct TraktSearchResult { pub r#type: TraktKind, pub score: f64, #[serde(flatten)] pub inner: TraktKindObject, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TraktKindObject { Movie(TraktMediaObject), Show(TraktMediaObject), Season(TraktMediaObject), Episode(TraktMediaObject), Person(TraktMediaObject), User(TraktMediaObject), } impl TraktKindObject { pub fn inner(&self) -> &TraktMediaObject { match self { TraktKindObject::Movie(x) | TraktKindObject::Show(x) | TraktKindObject::Season(x) | TraktKindObject::Episode(x) | TraktKindObject::Person(x) | TraktKindObject::User(x) => x, } } } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] pub enum TraktPeopleGroup { #[serde(rename = "production")] Production, #[serde(rename = "art")] Art, #[serde(rename = "crew")] Crew, #[serde(rename = "costume & make-up")] //? they really use that in as a key?! CostumeMakeup, #[serde(rename = "directing")] Directing, #[serde(rename = "writing")] Writing, #[serde(rename = "sound")] Sound, #[serde(rename = "camera")] Camera, #[serde(rename = "visual effects")] VisualEffects, #[serde(rename = "lighting")] Lighting, #[serde(rename = "editing")] Editing, #[serde(rename = "created by")] CreatedBy, } impl TraktPeopleGroup { pub fn as_credit_category(self) -> CreditCategory { match self { TraktPeopleGroup::Production => CreditCategory::Production, TraktPeopleGroup::Art => CreditCategory::Art, TraktPeopleGroup::Crew => CreditCategory::Crew, TraktPeopleGroup::CostumeMakeup => CreditCategory::CostumeMakeup, TraktPeopleGroup::Directing => CreditCategory::Directing, TraktPeopleGroup::Writing => CreditCategory::Writing, TraktPeopleGroup::Sound => CreditCategory::Sound, TraktPeopleGroup::Camera => CreditCategory::Camera, TraktPeopleGroup::VisualEffects => CreditCategory::Vfx, TraktPeopleGroup::Lighting => CreditCategory::Lighting, TraktPeopleGroup::Editing => CreditCategory::Editing, TraktPeopleGroup::CreatedBy => CreditCategory::CreatedBy, } } } impl TraktAppearance { pub fn a(&self) -> Appearance { Appearance { jobs: self.jobs.to_owned(), characters: self.characters.to_owned(), node: NodeID([0; 32]), // person: Person { // name: self.person.name.to_owned(), // headshot: None, // ids: self.person.ids.to_owned(), // }, } } } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct TraktMediaObject { pub title: String, pub year: Option, pub ids: TraktIds, pub tagline: Option, pub overview: Option, pub released: Option, pub runtime: Option, pub country: Option, pub trailer: Option, pub homepage: Option, pub status: Option, pub rating: Option, pub votes: Option, pub comment_count: Option, pub language: Option, pub available_translations: Option>, pub genres: Option>, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct TraktIds { pub trakt: Option, pub slug: Option, pub tvdb: Option, pub imdb: Option, pub tmdb: Option, } impl Display for TraktSearchResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!( "{} ({}) \x1b[2m{} {}\x1b[0m", self.inner.inner().title, self.inner.inner().year.unwrap_or(0), self.r#type, self.inner.inner().ids.trakt.unwrap_or_default() )) } } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Hash, PartialEq)] #[serde(rename_all = "snake_case")] pub enum TraktKind { Movie, Show, Season, Episode, Person, User, } impl TraktKind { pub fn as_node_kind(self) -> NodeKind { match self { TraktKind::Movie => NodeKind::Movie, TraktKind::Show => NodeKind::Show, TraktKind::Season => NodeKind::Season, TraktKind::Episode => NodeKind::Episode, TraktKind::Person => NodeKind::Channel, TraktKind::User => NodeKind::Channel, } } } impl TraktKind { pub fn singular(self) -> &'static str { match self { TraktKind::Movie => "movie", TraktKind::Show => "show", TraktKind::Season => "season", TraktKind::Episode => "episode", TraktKind::Person => "person", TraktKind::User => "user", } } pub fn plural(self) -> &'static str { match self { TraktKind::Movie => "movies", TraktKind::Show => "shows", TraktKind::Season => "seasons", TraktKind::Episode => "episodes", TraktKind::Person => "people", TraktKind::User => "users", // not used in API } } } impl Display for TraktKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { TraktKind::Movie => "Movie", TraktKind::Show => "Show", TraktKind::Season => "Season", TraktKind::Episode => "Episode", TraktKind::Person => "Person", TraktKind::User => "User", }) } } impl ImportPlugin for Trakt { fn info(&self) -> PluginInfo { PluginInfo { name: "trakt", handle_instruction: true, handle_process: true, ..Default::default() } } fn instruction(&self, ct: &ImportContext, node: NodeID, line: &str) -> Result<()> { if let Some(value) = line.strip_prefix("trakt-").or(line.strip_prefix("trakt=")) { let (ty, id) = value.split_once(":").unwrap_or(("movie", value)); let ty = match ty { "movie" => IdentifierType::TraktMovie, "show" => IdentifierType::TraktShow, "season" => IdentifierType::TraktSeason, "episode" => IdentifierType::TraktEpisode, _ => bail!("unknown trakt kind"), }; ct.db.update_node_init(node, |node| { node.identifiers.insert(ty, id.to_owned()); })?; } Ok(()) } fn process(&self, ct: &ImportContext, node: NodeID, data: &Node) -> Result<()> { self.process_primary(ct, node, data)?; self.process_episode(ct, node, data)?; Ok(()) } } impl Trakt { fn process_primary(&self, ct: &ImportContext, node: NodeID, data: &Node) -> Result<()> { let (trakt_kind, trakt_id): (_, u64) = if let Some(id) = data.identifiers.get(&IdentifierType::TraktShow) { (TraktKind::Show, id.parse()?) } else if let Some(id) = data.identifiers.get(&IdentifierType::TraktMovie) { (TraktKind::Movie, id.parse()?) } else { return Ok(()); }; let details = self.lookup(trakt_kind, trakt_id, ct.rt)?; let people = self.people(trakt_kind, trakt_id, ct.rt)?; let mut people_map = BTreeMap::>::new(); for p in people.cast.iter() { people_map .entry(CreditCategory::Cast) .or_default() .push(p.a()) } for (group, people) in people.crew.iter() { for p in people { people_map .entry(group.as_credit_category()) .or_default() .push(p.a()) } } // for p in people_map.values_mut().flatten() { // if let Some(id) = p.person.ids.tmdb { // let k = rthandle.block_on(tmdb.person_image(id))?; // if let Some(prof) = k.profiles.first() { // let im = rthandle.block_on(tmdb.image(&prof.file_path))?; // p.person.headshot = Some(AssetInner::Cache(im).ser()); // } // } // } ct.db.update_node_init(node, |node| { node.kind = trakt_kind.as_node_kind(); node.title = Some(details.title.clone()); if let Some(overview) = &details.overview { node.description = Some(overview.clone()) } if let Some(tagline) = &details.tagline { node.tagline = Some(tagline.clone()) } node.credits.extend(people_map); if let Some(x) = details.ids.imdb.clone() { node.identifiers.insert(IdentifierType::Imdb, x); } if let Some(x) = details.ids.tvdb.clone() { node.identifiers.insert(IdentifierType::Tvdb, x.to_string()); } if let Some(x) = details.ids.tmdb.clone() { match trakt_kind { TraktKind::Movie => node .identifiers .insert(IdentifierType::TmdbMovie, x.to_string()), TraktKind::Show => node .identifiers .insert(IdentifierType::TmdbSeries, x.to_string()), _ => None, }; } if let Some(rating) = &details.rating { node.ratings.insert(RatingType::Trakt, *rating); } })?; Ok(()) } fn process_episode(&self, ct: &ImportContext, node: NodeID, node_data: &Node) -> Result<()> { let (Some(episode), Some(season)) = (node_data.index, node_data.season_index) else { return Ok(()); }; let mut show_id = None; for &parent in &node_data.parents { let parent_data = ct.db.get_node(parent)?.ok_or(anyhow!("parent missing"))?; if let Some(id) = parent_data.identifiers.get(&IdentifierType::TraktShow) { show_id = Some(id.parse::()?); break; } } let Some(show_id) = show_id else { return Ok(()); }; let seasons = self.show_seasons(show_id, ct.rt)?; if seasons.iter().any(|x| x.number == season) { let episodes = self.show_season_episodes(show_id, season, ct.rt)?; if let Some(episode) = episodes.get(episode.saturating_sub(1)) { ct.db.update_node_init(node, |node| { node.kind = NodeKind::Episode; node.index = Some(episode.number); node.title = Some(episode.title.clone()); node.description = episode.overview.clone().or(node.description.clone()); if let Some(r) = episode.rating { node.ratings.insert(RatingType::Trakt, r); } })?; } } Ok(()) } }