From df36a85b54fd427cc0914320d29aa4f005e5aff7 Mon Sep 17 00:00:00 2001 From: metamuffin Date: Wed, 5 Feb 2025 23:10:15 +0100 Subject: trakt episode details --- import/src/lib.rs | 102 +++++++++++++++++++++++++++++++++++------ import/src/tmdb.rs | 32 +++++++++++++ import/src/trakt.rs | 127 +++++++++++++++++++++++++++++++--------------------- 3 files changed, 197 insertions(+), 64 deletions(-) (limited to 'import/src') diff --git a/import/src/lib.rs b/import/src/lib.rs index 0d8a10a..0990ba1 100644 --- a/import/src/lib.rs +++ b/import/src/lib.rs @@ -18,6 +18,7 @@ use jellyclient::{Appearance, PeopleGroup, TmdbKind, TraktKind, Visibility}; use log::info; use matroska::matroska_metadata; use rayon::iter::{ParallelBridge, ParallelIterator}; +use regex::Regex; use std::{ collections::{BTreeMap, HashMap}, fs::{read_to_string, File}, @@ -42,8 +43,8 @@ pub mod trakt; static IMPORT_SEM: LazyLock = LazyLock::new(|| Semaphore::new(1)); pub static IMPORT_ERRORS: RwLock> = RwLock::const_new(Vec::new()); -// static RE_EPISODE_FILENAME: LazyLock = -// LazyLock::new(|| Regex::new(r#"([sS](\d+))?([eE](\d+))( (.+))?"#).unwrap()); +static RE_EPISODE_FILENAME: LazyLock = + LazyLock::new(|| Regex::new(r#"([sS](?\d+))?([eE](?\d+))( (.+))?"#).unwrap()); struct Apis { trakt: Option, @@ -296,11 +297,20 @@ fn import_media_file( }) .unwrap_or_default(); - let filename = path - .file_name() - .ok_or(anyhow!("no file stem"))? - .to_string_lossy() - .to_string(); + let filename = path.file_name().unwrap().to_string_lossy().to_string(); + + let mut episode_index = None; + if let Some(cap) = RE_EPISODE_FILENAME.captures(&filename) { + if let Some(episode) = cap.name("episode").map(|m| m.as_str()) { + let season = cap.name("season").map(|m| m.as_str()); + let episode = episode.parse::().context("parse episode num")?; + let season = season + .unwrap_or("1") + .parse::() + .context("parse season num")?; + episode_index = Some((season, episode)) + } + } let mut filename_toks = filename.split("."); let filepath_stem = filename_toks.next().unwrap(); @@ -309,17 +319,36 @@ fn import_media_file( .infojson .as_ref() .map(|ij| format!("youtube-{}", ij.id)) - .unwrap_or(make_kebab(&filepath_stem)); + .unwrap_or_else(|| { + if let Some((s, e)) = episode_index { + format!( + "{}-s{s}e{e}", + make_kebab( + &path + .parent() + .unwrap() + .file_name() + .unwrap_or_default() + .to_string_lossy() + ) + ) + } else { + make_kebab(&filepath_stem) + } + }); let node = NodeID::from_slug(&slug); db.update_node_init(node, |node| { node.slug = slug; - node.title = info.title; + node.title = info.title.or(node.title.clone()); node.visibility = visibility; node.poster = m.cover.clone(); - node.description = tags.remove("DESCRIPTION").or(tags.remove("SYNOPSIS")); - node.tagline = tags.remove("COMMENT"); + node.description = tags + .remove("DESCRIPTION") + .or(tags.remove("SYNOPSIS")) + .or(node.description.clone()); + node.tagline = tags.remove("COMMENT").or(node.tagline.clone()); node.parents.insert(parent); if let Some(ct) = tags.get("CONTENT_TYPE") { @@ -440,6 +469,53 @@ fn import_media_file( Ok(()) })?; + if let Some((season, episode)) = episode_index { + let mut trakt_id = None; + let flagspath = path.parent().unwrap().join("flags"); + if flagspath.exists() { + for flag in read_to_string(flagspath)?.lines() { + if let Some(value) = flag.strip_prefix("trakt-").or(flag.strip_prefix("trakt=")) { + let (kind, id) = value.split_once(":").unwrap_or(("", value)); + if kind == "show" { + trakt_id = Some(id.parse::()?); + } + } + } + } + if let Some(trakt_id) = trakt_id { + let trakt = apis.trakt.as_ref().ok_or(anyhow!("trakt required"))?; + let seasons = rthandle.block_on(trakt.show_seasons(trakt_id))?; + if seasons.iter().any(|x| x.number == season) { + let episodes = rthandle.block_on(trakt.show_season_episodes(trakt_id, season))?; + let mut poster = None; + if let Some(tmdb) = &apis.tmdb { + let trakt_details = + rthandle.block_on(trakt.lookup(TraktKind::Show, trakt_id))?; + if let Some(tmdb_id) = trakt_details.ids.tmdb { + let tmdb_details = + rthandle.block_on(tmdb.episode_details(tmdb_id, season, episode))?; + if let Some(still) = &tmdb_details.still_path { + poster = Some( + AssetInner::Cache(rthandle.block_on(tmdb.image(&still))?).ser(), + ) + } + } + } + if let Some(episode) = episodes.get(episode.saturating_sub(1)) { + db.update_node_init(node, |node| { + node.kind = NodeKind::Episode; + node.index = Some(episode.number); + node.title = Some(episode.title.clone()); + node.poster = poster.or(node.poster.clone()); + node.description = episode.overview.clone().or(node.description.clone()); + node.ratings.insert(Rating::Trakt, episode.rating); + Ok(()) + })? + } + } + } + } + for tok in filename_toks { apply_node_flag(db, rthandle, apis, node, tok)?; } @@ -510,10 +586,10 @@ fn apply_trakt_tmdb( let trakt_id: u64 = trakt_id.parse().context("parse trakt id")?; if let (Some(trakt), Some(tmdb)) = (&apis.trakt, &apis.tmdb) { let data = rthandle - .block_on(trakt.lookup(trakt_kind, trakt_id, true)) + .block_on(trakt.lookup(trakt_kind, trakt_id)) .context("trakt lookup")?; let people = rthandle - .block_on(trakt.people(trakt_kind, trakt_id, true)) + .block_on(trakt.people(trakt_kind, trakt_id)) .context("trakt people lookup")?; let mut people_map = BTreeMap::>::new(); diff --git a/import/src/tmdb.rs b/import/src/tmdb.rs index 678ce61..70e95ce 100644 --- a/import/src/tmdb.rs +++ b/import/src/tmdb.rs @@ -116,6 +116,26 @@ impl Tmdb { }) .await } + + pub async fn episode_details( + &self, + series_id: u64, + season: usize, + episode: usize, + ) -> anyhow::Result> { + async_cache_memory(&["api-tmdb-episode-details", &format!("{series_id} {season} {episode}")], || async move { + info!("tmdb episode details {series_id} S={season} E={episode}"); + Ok(self + .image_client + .get(&format!("https://api.themoviedb.org/3/tv/{series_id}/season/{season}/episode/{episode}?api_key={}", self.key)) + .send() + .await? + .error_for_status()? + .json() + .await?) + }) + .await + } } pub fn parse_release_date(d: &str) -> anyhow::Result> { @@ -142,6 +162,18 @@ pub fn parse_release_date(d: &str) -> anyhow::Result> { Ok(Some(p.to_datetime_with_timezone(&Utc)?.timestamp_millis())) } +#[derive(Debug, Clone, Deserialize, Encode, Decode)] +pub struct TmdbEpisode { + pub air_date: String, + pub overview: String, + pub name: String, + pub id: u64, + pub runtime: f64, + pub still_path: Option, + pub vote_average: f64, + pub vote_count: usize, +} + #[derive(Debug, Clone, Deserialize, Encode, Decode)] pub struct TmdbPersonImage { pub id: u64, diff --git a/import/src/trakt.rs b/import/src/trakt.rs index c7a25ad..1daee77 100644 --- a/import/src/trakt.rs +++ b/import/src/trakt.rs @@ -1,3 +1,4 @@ +use anyhow::Context; /* 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. @@ -46,75 +47,114 @@ impl Trakt { &self, kinds: &[TraktKind], query: &str, - extended: bool, ) -> anyhow::Result>> { + async_cache_memory(&["api-trakt-lookup", 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", query, if extended { "a" } else { "b" }], + &["api-trakt-lookup", &format!("{kind} {id}")], || async move { - let url = format!( - "https://api.trakt.tv/search/{}?query={}{}", - kinds - .iter() - .map(|t| t.singular()) - .collect::>() - .join(","), - urlencoding::encode(query), - optext(extended) - ); + 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 lookup( - &self, - kind: TraktKind, - id: u64, - extended: bool, - ) -> anyhow::Result> { + pub async fn people(&self, kind: TraktKind, id: u64) -> anyhow::Result> { async_cache_memory( - &["api-trakt-lookup", &format!("{id} {extended}")], + &["api-trakt-people", &format!("{kind} {id}")], || async move { - info!("trakt lookup {kind:?}:{id:?}"); + info!("trakt people {kind:?}:{id:?}"); let url = format!( - "https://api.trakt.tv/{}/{}{}", - kind.plural(), - id, - optext2(extended) + "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.to_string()], || 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 people( + pub async fn show_season_episodes( &self, - kind: TraktKind, id: u64, - extended: bool, - ) -> anyhow::Result> { + season: usize, + ) -> anyhow::Result>> { async_cache_memory( - &["api-trakt-people", &format!("{id} {extended}")], + &["api-trakt-episodes", &id.to_string(), &season.to_string()], || async move { - info!("trakt people {kind:?}:{id:?}"); - let url = format!( - "https://api.trakt.tv/{}/{}/people{}", - kind.plural(), - id, - optext2(extended) - ); + 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)] @@ -138,21 +178,6 @@ pub struct TraktPerson { 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, -- cgit v1.2.3-70-g09d2