/* 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::Context; use bincode::{Decode, Encode}; use jellycache::async_cache_memory; use jellycommon::{Appearance, ObjectIds, PeopleGroup, Person, TraktKind}; use log::info; use reqwest::{ header::{HeaderMap, HeaderName, HeaderValue}, Client, ClientBuilder, }; use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, fmt::Display, sync::Arc}; 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 async fn search( &self, kinds: &[TraktKind], query: &str, ) -> anyhow::Result>> { async_cache_memory("api-trakt-lookup", (kinds, query), || async move { 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?) }) .await .context("trakt search") } pub async fn lookup(&self, kind: TraktKind, id: u64) -> anyhow::Result> { async_cache_memory("api-trakt-lookup", (kind, id), || async move { 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?) }) .await .context("trakt lookup") } pub async fn people(&self, kind: TraktKind, id: u64) -> anyhow::Result> { async_cache_memory("api-trakt-people", (kind, id), || async move { 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?) }) .await .context("trakt people") } pub async fn show_seasons(&self, id: u64) -> anyhow::Result>> { async_cache_memory("api-trakt-seasons", id, || async move { 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?) }) .await .context("trakt show seasons") } pub async fn show_season_episodes( &self, id: u64, season: usize, ) -> anyhow::Result>> { async_cache_memory("api-trakt-episodes", (id, season), || async move { 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?) }) .await .context("trakt show season episodes") } } #[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] pub struct TraktSeason { pub number: usize, pub ids: ObjectIds, pub rating: f64, pub votes: usize, pub episode_count: usize, pub aired_count: Option, pub title: String, pub overview: Option, pub network: String, } #[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] pub struct TraktEpisode { pub season: Option, pub number: usize, pub number_abs: Option, pub ids: ObjectIds, pub rating: f64, pub votes: usize, pub title: String, pub runtime: f64, pub overview: Option, pub available_translations: Vec, pub first_aired: Option, pub episode_type: String, } #[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] pub struct TraktPeople { #[serde(default)] pub cast: Vec, #[serde(default)] pub crew: BTreeMap>, } #[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] pub struct TraktAppearance { #[serde(default)] pub jobs: Vec, #[serde(default)] pub characters: Vec, pub person: TraktPerson, } #[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] pub struct TraktPerson { pub name: String, pub ids: ObjectIds, } #[derive(Debug, Serialize, Deserialize, Encode, Decode)] pub struct TraktSearchResult { pub r#type: TraktKind, pub score: f64, #[serde(flatten)] pub inner: TraktKindObject, } #[derive(Debug, Serialize, Deserialize, Encode, Decode)] #[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, Encode, Decode, 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 a(self) -> PeopleGroup { match self { TraktPeopleGroup::Production => PeopleGroup::Production, TraktPeopleGroup::Art => PeopleGroup::Art, TraktPeopleGroup::Crew => PeopleGroup::Crew, TraktPeopleGroup::CostumeMakeup => PeopleGroup::CostumeMakeup, TraktPeopleGroup::Directing => PeopleGroup::Directing, TraktPeopleGroup::Writing => PeopleGroup::Writing, TraktPeopleGroup::Sound => PeopleGroup::Sound, TraktPeopleGroup::Camera => PeopleGroup::Camera, TraktPeopleGroup::VisualEffects => PeopleGroup::Vfx, TraktPeopleGroup::Lighting => PeopleGroup::Lighting, TraktPeopleGroup::Editing => PeopleGroup::Editing, TraktPeopleGroup::CreatedBy => PeopleGroup::CreatedBy, } } } impl TraktAppearance { pub fn a(&self) -> Appearance { Appearance { jobs: self.jobs.to_owned(), characters: self.characters.to_owned(), person: Person { name: self.person.name.to_owned(), headshot: None, ids: self.person.ids.to_owned(), }, } } } #[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] pub struct TraktMediaObject { pub title: String, pub year: Option, pub ids: ObjectIds, 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>, } 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 )) } }