diff options
-rw-r--r-- | import/src/lib.rs | 102 | ||||
-rw-r--r-- | import/src/tmdb.rs | 32 | ||||
-rw-r--r-- | import/src/trakt.rs | 127 | ||||
-rw-r--r-- | server/src/routes/ui/node.rs | 33 | ||||
-rw-r--r-- | server/src/routes/ui/sort.rs | 6 | ||||
-rw-r--r-- | tool/src/add.rs | 2 | ||||
-rw-r--r-- | web/style/nodecard.css | 4 |
7 files changed, 235 insertions, 71 deletions
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<Semaphore> = LazyLock::new(|| Semaphore::new(1)); pub static IMPORT_ERRORS: RwLock<Vec<String>> = RwLock::const_new(Vec::new()); -// static RE_EPISODE_FILENAME: LazyLock<Regex> = -// LazyLock::new(|| Regex::new(r#"([sS](\d+))?([eE](\d+))( (.+))?"#).unwrap()); +static RE_EPISODE_FILENAME: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r#"([sS](?<season>\d+))?([eE](?<episode>\d+))( (.+))?"#).unwrap()); struct Apis { trakt: Option<Trakt>, @@ -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::<usize>().context("parse episode num")?; + let season = season + .unwrap_or("1") + .parse::<usize>() + .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::<u64>()?); + } + } + } + } + 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::<PeopleGroup, Vec<Appearance>>::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<Arc<TmdbEpisode>> { + 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<Option<i64>> { @@ -143,6 +163,18 @@ pub fn parse_release_date(d: &str) -> anyhow::Result<Option<i64>> { } #[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<String>, + pub vote_average: f64, + pub vote_count: usize, +} + +#[derive(Debug, Clone, Deserialize, Encode, Decode)] pub struct TmdbPersonImage { pub id: u64, pub profiles: Vec<TmdbPersonImageProfile>, 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,76 +47,115 @@ impl Trakt { &self, kinds: &[TraktKind], query: &str, - extended: bool, ) -> anyhow::Result<Arc<Vec<TraktSearchResult>>> { + 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::<Vec<_>>() + .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<Arc<TraktMediaObject>> { 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::<Vec<_>>() - .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<Arc<TraktMediaObject>> { + pub async fn people(&self, kind: TraktKind, id: u64) -> anyhow::Result<Arc<TraktPeople>> { 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<Arc<Vec<TraktSeason>>> { + 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<Arc<TraktPeople>> { + season: usize, + ) -> anyhow::Result<Arc<Vec<TraktEpisode>>> { 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<usize>, + pub title: String, + pub overview: Option<String>, + pub network: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] +pub struct TraktEpisode { + pub season: Option<usize>, + pub number: usize, + pub number_abs: Option<usize>, + pub ids: ObjectIds, + 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, Encode, Decode)] pub struct TraktPeople { #[serde(default)] pub cast: Vec<TraktAppearance>, @@ -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, diff --git a/server/src/routes/ui/node.rs b/server/src/routes/ui/node.rs index 337d249..f8b448f 100644 --- a/server/src/routes/ui/node.rs +++ b/server/src/routes/ui/node.rs @@ -63,17 +63,20 @@ pub async fn r_library_node_filter<'a>( .into_iter() .map(|c| db.get_node_with_userdata(c, &session)) .collect::<anyhow::Result<Vec<_>>>()?; + children.retain(|(n, _)| n.visibility >= Visibility::Reduced); - let parents = node + let mut parents = node .parents .iter() .flat_map(|pid| db.get_node(*pid).transpose()) .collect::<Result<Vec<_>, _>>()?; + parents.retain(|n| n.visibility >= Visibility::Reduced); filter_and_sort_nodes( &filter, match node.kind { NodeKind::Channel => (SortProperty::ReleaseDate, SortOrder::Descending), + NodeKind::Season | NodeKind::Show => (SortProperty::Index, SortOrder::Ascending), _ => (SortProperty::Title, SortOrder::Ascending), }, &mut children, @@ -115,6 +118,26 @@ markup::define! { } } } + NodeCardWide<'a>(node: &'a Node, udata: &'a NodeUserData) { + @let cls = format!("node card widecard poster {}", aspect_class(node.kind)); + div[class=cls] { + .poster { + a[href=uri!(r_library_node(&node.slug))] { + img[src=uri!(r_item_poster(&node.slug, Some(1024))), loading="lazy"]; + } + .cardhover.item { + @if node.media.is_some() { + a.play.icon[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { "play_arrow" } + } + } + } + div.details { + a.title[href=uri!(r_library_node(&node.slug))] { @node.title } + @Props { node, udata, full: false } + span.overview { @node.description } + } + } + } NodePage<'a>(id: &'a str, node: &'a Node, udata: &'a NodeUserData, children: &'a [(Arc<Node>, NodeUserData)], parents: &'a [Arc<Node>], filter: &'a NodeFilterSort) { @if !matches!(node.kind, NodeKind::Collection) { img.backdrop[src=uri!(r_item_backdrop(id, Some(2048))), loading="lazy"]; @@ -218,15 +241,13 @@ markup::define! { } @match node.kind { NodeKind::Show | NodeKind::Series | NodeKind::Season => { - ol { @for (c, _) in children.iter() { - li { a[href=uri!(r_library_node(&c.slug))] { @c.title } } + ol { @for (node, udata) in children.iter() { + li { @NodeCardWide { node, udata } } }} } NodeKind::Collection | NodeKind::Channel | _ => { ul.children {@for (node, udata) in children.iter() { - @if node.visibility != Visibility::Hidden { - li { @NodeCard { node, udata } } - } + li { @NodeCard { node, udata } } }} } } diff --git a/server/src/routes/ui/sort.rs b/server/src/routes/ui/sort.rs index 36250a9..6bee2ef 100644 --- a/server/src/routes/ui/sort.rs +++ b/server/src/routes/ui/sort.rs @@ -45,6 +45,7 @@ form_enum!( enum SortProperty { ReleaseDate = "release_date", Title = "title", + Index = "index", Duration = "duration", RatingRottenTomatoes = "rating_rt", RatingMetacritic = "rating_mc", @@ -180,6 +181,11 @@ pub fn filter_and_sort_nodes( nodes.sort_by_key(|(n, _)| n.release_date.expect("asserted above")) } SortProperty::Title => nodes.sort_by(|(a, _), (b, _)| a.title.cmp(&b.title)), + SortProperty::Index => nodes.sort_by(|(a, _), (b, _)| { + a.index + .unwrap_or(usize::MAX) + .cmp(&b.index.unwrap_or(usize::MAX)) + }), SortProperty::RatingRottenTomatoes => nodes.sort_by_cached_key(|(n, _)| { SortAnyway(*n.ratings.get(&Rating::RottenTomatoes).unwrap_or(&0.)) }), diff --git a/tool/src/add.rs b/tool/src/add.rs index a0a3951..2179a40 100644 --- a/tool/src/add.rs +++ b/tool/src/add.rs @@ -45,7 +45,7 @@ pub async fn add(action: Action) -> anyhow::Result<()> { .ok_or(anyhow!("no trakt api key configured"))?, ); - let results = trakt.search(search_kinds, &name, false).await?; + let results = trakt.search(search_kinds, &name).await?; if results.is_empty() { warn!("no search results"); diff --git a/web/style/nodecard.css b/web/style/nodecard.css index 4fd039e..011e415 100644 --- a/web/style/nodecard.css +++ b/web/style/nodecard.css @@ -118,6 +118,10 @@ left: 0px; } +.widecard { + +} + @media (max-width: 750px) { nav .library { display: none; |