diff options
Diffstat (limited to 'import/src/plugins/trakt.rs')
| -rw-r--r-- | import/src/plugins/trakt.rs | 403 |
1 files changed, 403 insertions, 0 deletions
diff --git a/import/src/plugins/trakt.rs b/import/src/plugins/trakt.rs new file mode 100644 index 0000000..5a1aa8e --- /dev/null +++ b/import/src/plugins/trakt.rs @@ -0,0 +1,403 @@ +/* + 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 <metamuffin.org> +*/ +use crate::{ + USER_AGENT, + plugins::{ImportContext, ImportPlugin}, +}; +use anyhow::{Context, Result, bail}; +use jellycache::{HashKey, cache_memory}; +use jellycommon::{Appearance, CreditCategory, IdentifierType, NodeID, NodeKind}; +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<Arc<Vec<TraktSearchResult>>> { + 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::<Vec<_>>() + .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<Arc<TraktMediaObject>> { + 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<Arc<TraktPeople>> { + 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<Arc<Vec<TraktSeason>>> { + 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<Arc<Vec<TraktEpisode>>> { + 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<usize>, + pub title: String, + pub overview: Option<String>, + pub network: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct TraktEpisode { + pub season: Option<usize>, + pub number: usize, + pub number_abs: Option<usize>, + pub ids: TraktIds, + pub rating: f64, + pub votes: usize, + pub title: String, + pub runtime: f64, + pub overview: Option<String>, + pub available_translations: Vec<String>, + pub first_aired: Option<String>, + pub episode_type: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct TraktPeople { + #[serde(default)] + pub cast: Vec<TraktAppearance>, + #[serde(default)] + pub crew: BTreeMap<TraktPeopleGroup, Vec<TraktAppearance>>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct TraktAppearance { + #[serde(default)] + pub jobs: Vec<String>, + #[serde(default)] + pub characters: Vec<String>, + 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<u32>, + pub ids: TraktIds, + + pub tagline: Option<String>, + pub overview: Option<String>, + pub released: Option<String>, + pub runtime: Option<usize>, + pub country: Option<String>, + pub trailer: Option<String>, + pub homepage: Option<String>, + pub status: Option<String>, + pub rating: Option<f64>, + pub votes: Option<usize>, + pub comment_count: Option<usize>, + pub language: Option<String>, + pub available_translations: Option<Vec<String>>, + pub genres: Option<Vec<String>>, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct TraktIds { + pub trakt: Option<u64>, + pub slug: Option<String>, + pub tvdb: Option<u64>, + pub imdb: Option<String>, + pub tmdb: Option<u64>, +} + +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 + )) + } +} + +#[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 import_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(()) + })?; + } + Ok(()) + } +} |