/* 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 bincode::{Decode, Encode}; use jellybase::{ cache::async_cache_memory, common::{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"), ), ])) .build() .unwrap(); Self { client } } pub async fn search( &self, kinds: &[TraktKind], query: &str, extended: bool, ) -> anyhow::Result>> { async_cache_memory( &["api-trakt-lookup", query, if extended { "a" } else { "b" }], || async move { let url = format!( "https://api.trakt.tv/search/{}?query={}{}", kinds .iter() .map(|t| t.singular()) .collect::>() .join(","), urlencoding::encode(query), optext(extended) ); let res = self.client.get(url).send().await?.error_for_status()?; Ok(res.json().await?) }, ) .await } pub async fn lookup( &self, kind: TraktKind, id: u64, extended: bool, ) -> anyhow::Result> { async_cache_memory( &["api-trakt-lookup", &format!("{id} {extended}")], || async move { info!("trakt lookup {kind:?}:{id:?}"); let url = format!( "https://api.trakt.tv/{}/{}{}", kind.plural(), id, optext2(extended) ); let res = self.client.get(url).send().await?.error_for_status()?; Ok(res.json().await?) }, ) .await } pub async fn people( &self, kind: TraktKind, id: u64, extended: bool, ) -> anyhow::Result> { async_cache_memory( &["api-trakt-people", &format!("{id} {extended}")], || async move { info!("trakt people {kind:?}:{id:?}"); let url = format!( "https://api.trakt.tv/{}/{}/people{}", kind.plural(), id, optext2(extended) ); let res = self.client.get(url).send().await?.error_for_status()?; Ok(res.json().await?) }, ) .await } } #[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, } fn optext(extended: bool) -> &'static str { if extended { "&extended=full" } else { "" } } fn optext2(extended: bool) -> &'static str { if extended { "?extended=full" } else { "" } } #[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 )) } }