aboutsummaryrefslogtreecommitdiff
path: root/import/src/plugins/trakt.rs
diff options
context:
space:
mode:
Diffstat (limited to 'import/src/plugins/trakt.rs')
-rw-r--r--import/src/plugins/trakt.rs403
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(())
+ }
+}