/* 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, helpers::get_or_insert_slug, plugins::{ImportPlugin, PluginContext, PluginInfo}, source_rank::ObjectImportSourceExt, }; use anyhow::{Context, Result, anyhow, bail}; use jellycache::{Cache, HashKey}; use jellycommon::{ jellyobject::{ObjectBuffer, Tag}, *, }; use jellydb::RowNum; 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, cache: &Cache, kinds: &[TraktKind], query: &str, rt: &Handle, ) -> Result>> { cache .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, cache: &Cache, kind: TraktKind, id: u64, rt: &Handle, ) -> Result> { cache .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, cache: &Cache, kind: TraktKind, id: u64, rt: &Handle, ) -> Result> { cache .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, cache: &Cache, id: u64, rt: &Handle, ) -> Result>> { cache .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, cache: &Cache, id: u64, season: u64, rt: &Handle, ) -> Result>> { cache .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: u64, 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: u64, 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")] 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) -> Tag { match self { TraktPeopleGroup::Production => CRCAT_PRODUCTION, TraktPeopleGroup::Art => CRCAT_ART, TraktPeopleGroup::Crew => CRCAT_CREW, TraktPeopleGroup::CostumeMakeup => CRCAT_COSTUME_MAKEUP, TraktPeopleGroup::Directing => CRCAT_DIRECTING, TraktPeopleGroup::Writing => CRCAT_WRITING, TraktPeopleGroup::Sound => CRCAT_SOUND, TraktPeopleGroup::Camera => CRCAT_CAMERA, TraktPeopleGroup::VisualEffects => CRCAT_VFX, TraktPeopleGroup::Lighting => CRCAT_LIGHTING, TraktPeopleGroup::Editing => CRCAT_EDITING, TraktPeopleGroup::CreatedBy => CRCAT_CREATED_BY, } } } #[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) -> Tag { use jellycommon::*; match self { TraktKind::Movie => KIND_MOVIE, TraktKind::Show => KIND_SHOW, TraktKind::Season => KIND_SEASON, TraktKind::Episode => KIND_EPISODE, TraktKind::Person => KIND_CHANNEL, TraktKind::User => KIND_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", tag: MSOURCE_TRAKT, handle_instruction: true, handle_process: true, ..Default::default() } } fn instruction(&self, ct: &PluginContext, node: RowNum, line: &str) -> Result<()> { use jellycommon::*; 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" => IDENT_TRAKT_MOVIE, "show" => IDENT_TRAKT_SHOW, "season" => IDENT_TRAKT_SEASON, "episode" => IDENT_TRAKT_EPISODE, _ => bail!("unknown trakt kind"), }; ct.ic.update_node(node, |node| { node.as_object() .update(NO_IDENTIFIERS, |idents| idents.insert(ty, id)) })?; } Ok(()) } fn process(&self, ct: &PluginContext, node: RowNum) -> Result<()> { self.process_primary(ct, node)?; self.process_episode(ct, node)?; Ok(()) } } impl Trakt { fn process_primary(&self, ct: &PluginContext, node_row: RowNum) -> Result<()> { let data = ct.ic.get_node(node_row)?.unwrap(); let data = data.as_object(); let (trakt_kind, trakt_id): (_, u64) = if let Some(id) = data .get(NO_IDENTIFIERS) .unwrap_or_default() .get(IDENT_TRAKT_SHOW) { (TraktKind::Show, id.parse()?) } else if let Some(id) = data .get(NO_IDENTIFIERS) .unwrap_or_default() .get(IDENT_TRAKT_MOVIE) { (TraktKind::Movie, id.parse()?) } else { return Ok(()); }; let details = self.lookup(&ct.ic.cache, trakt_kind, trakt_id, ct.rt)?; let people = self.people(&ct.ic.cache, trakt_kind, trakt_id, ct.rt)?; let mut appearances = Vec::new(); for p in people.cast.iter() { appearances.push((CRCAT_CAST, p.to_owned())); } for (group, people) in people.crew.iter() { for p in people { appearances.push((group.as_credit_category(), p.to_owned())); } } ct.ic.db.transaction(&mut |txn| { let mut node = txn.get(node_row)?.unwrap(); node = node .as_object() .insert_s(ct.is, NO_KIND, trakt_kind.as_node_kind()); node = node.as_object().insert_s(ct.is, NO_TITLE, &details.title); if let Some(overview) = &details.overview { node = node.as_object().insert_s(ct.is, NO_DESCRIPTION, &overview); } if let Some(tagline) = &details.tagline { node = node.as_object().insert_s(ct.is, NO_TAGLINE, &tagline); } if let Some(x) = details.ids.imdb.clone() { node = node.as_object().update(NO_IDENTIFIERS, |idents| { idents.insert_s(ct.is, IDENT_IMDB, &x) }); } if let Some(x) = details.ids.tvdb.clone() { node = node.as_object().update(NO_IDENTIFIERS, |idents| { idents.insert_s(ct.is, IDENT_TVDB, &x.to_string()) }); } if let Some(x) = details.ids.tmdb.clone() { if let Some(key) = match trakt_kind { TraktKind::Movie => Some(IDENT_TMDB_MOVIE), TraktKind::Show => Some(IDENT_TMDB_SERIES), _ => None, } { node = node .as_object() .update(NO_IDENTIFIERS, |idents| idents.insert(key, &x.to_string())); }; } if let Some(rating) = details.rating { node = node .as_object() .update(NO_RATINGS, |idents| idents.insert(RTYP_TRAKT, rating)); } let mut credits = Vec::new(); for (crcat, ap) in &appearances { if let Some(traktid) = ap.person.ids.trakt { let slug = format!("trakt-person-{traktid}"); let role = ap .characters .iter() .cloned() .chain(ap.jobs.iter().cloned()) .collect::>() .join(", "); let row = get_or_insert_slug(txn, &slug)?; let mut c = txn.get(row)?.unwrap(); c = c.as_object().insert_s(ct.is, NO_KIND, KIND_PERSON); c = c.as_object().insert_s(ct.is, NO_VISIBILITY, VISI_VISIBLE); c = c.as_object().insert_s(ct.is, NO_TITLE, &ap.person.name); c = c.as_object().update(NO_IDENTIFIERS, |ids| { let mut ids = ids.insert(IDENT_TRAKT_PERSON, &traktid.to_string()); if let Some(tmdbid) = ap.person.ids.tmdb { ids = ids.as_object().insert_s( ct.is, IDENT_TMDB_PERSON, &tmdbid.to_string(), ); } if let Some(imdbid) = &ap.person.ids.imdb { ids = ids.as_object().insert_s(ct.is, IDENT_IMDB_PERSON, imdbid); } ids }); txn.update(row, c)?; credits.push(ObjectBuffer::new(&mut [ (CR_KIND.0, crcat), (CR_ROLE.0, &role.as_str()), (CR_NODE.0, &row), ])); ct.pending_nodes.lock().unwrap().insert(row); } } node = node .as_object() .extend_object(NO_CREDIT, CR_NODE.0, credits); txn.update(node_row, node)?; Ok(()) })?; Ok(()) } fn process_episode(&self, ct: &PluginContext, node: RowNum) -> Result<()> { let node_data = ct.ic.get_node(node)?.unwrap(); let node_data = node_data.as_object(); let (Some(episode), Some(season)) = (node_data.get(NO_INDEX), node_data.get(NO_SEASON_INDEX)) else { return Ok(()); }; let mut show_id = None; ct.ic.db.transaction(&mut |txn| { for parent in node_data.iter(NO_PARENT) { let parent_data = txn.get(parent)?.ok_or(anyhow!("parent missing"))?; if let Some(id) = parent_data .as_object() .get(NO_IDENTIFIERS) .unwrap_or_default() .get(IDENT_TRAKT_SHOW) { show_id = Some(id.parse::()?); break; } } Ok(()) })?; let Some(show_id) = show_id else { return Ok(()); }; let seasons = self.show_seasons(&ct.ic.cache, show_id, ct.rt)?; if seasons.iter().any(|x| x.number == season) { let episodes = self.show_season_episodes(&ct.ic.cache, show_id, season, ct.rt)?; if let Some(episode) = episodes.get(episode.saturating_sub(1) as usize) { ct.ic.update_node(node, |mut node| { node = node.as_object().insert_s(ct.is, NO_KIND, KIND_EPISODE); node = node.as_object().insert_s(ct.is, NO_INDEX, episode.number); node = node.as_object().insert_s(ct.is, NO_TITLE, &episode.title); if let Some(overview) = &episode.overview { node = node.as_object().insert_s(ct.is, NO_DESCRIPTION, &overview); } if let Some(r) = episode.rating { node = node .as_object() .update(NO_RATINGS, |rats| rats.insert_s(ct.is, RTYP_TRAKT, r)); } node })?; } } Ok(()) } }