diff options
author | metamuffin <metamuffin@disroot.org> | 2024-01-21 23:38:28 +0100 |
---|---|---|
committer | metamuffin <metamuffin@disroot.org> | 2024-01-21 23:38:28 +0100 |
commit | b127ee51925f59b306b032dbacc11464ed175a60 (patch) | |
tree | b7097c20a560019f90394de9e21da4c498aadabb | |
parent | a8fe841aaefe904121d936e608572a1422191167 (diff) | |
download | jellything-b127ee51925f59b306b032dbacc11464ed175a60.tar jellything-b127ee51925f59b306b032dbacc11464ed175a60.tar.bz2 jellything-b127ee51925f59b306b032dbacc11464ed175a60.tar.zst |
refactor tmdb api, cast&crew, node ext
-rw-r--r-- | base/src/database.rs | 7 | ||||
-rw-r--r-- | base/src/federation.rs | 1 | ||||
-rw-r--r-- | common/src/lib.rs | 74 | ||||
-rw-r--r-- | import/src/lib.rs | 110 | ||||
-rw-r--r-- | import/src/tmdb.rs | 194 | ||||
-rw-r--r-- | import/src/trakt.rs | 138 | ||||
-rw-r--r-- | server/src/routes/mod.rs | 8 | ||||
-rw-r--r-- | server/src/routes/ui/assets.rs | 36 | ||||
-rw-r--r-- | server/src/routes/ui/node.rs | 52 | ||||
-rw-r--r-- | tool/src/add.rs | 2 |
10 files changed, 473 insertions, 149 deletions
diff --git a/base/src/database.rs b/base/src/database.rs index f6f21bd..49645bc 100644 --- a/base/src/database.rs +++ b/base/src/database.rs @@ -6,7 +6,7 @@ use bincode::{Decode, Encode}; use jellycommon::{ user::{NodeUserData, User}, - Node, + ExtendedNode, Node, }; use log::info; use serde::{Deserialize, Serialize}; @@ -19,8 +19,10 @@ pub const T_USER_NODE: TableDefinition<(&str, &str), Ser<NodeUserData>> = TableDefinition::new("user_node"); pub const T_INVITE: TableDefinition<&str, Ser<()>> = TableDefinition::new("invite"); pub const T_NODE: TableDefinition<&str, Ser<Node>> = TableDefinition::new("node"); +pub const T_NODE_EXTENDED: TableDefinition<&str, Ser<ExtendedNode>> = + TableDefinition::new("node-ext"); pub const T_NODE_IMPORT: TableDefinition<&str, Ser<Vec<(Vec<usize>, Node)>>> = - TableDefinition::new("node_import"); + TableDefinition::new("node-import"); pub struct DataAcid { pub inner: redb::Database, @@ -40,6 +42,7 @@ impl DataAcid { drop(txn.open_table(T_USER_NODE)?); drop(txn.open_table(T_NODE)?); drop(txn.open_table(T_NODE_IMPORT)?); + drop(txn.open_table(T_NODE_EXTENDED)?); txn.commit()?; } diff --git a/base/src/federation.rs b/base/src/federation.rs index 0041e26..542c535 100644 --- a/base/src/federation.rs +++ b/base/src/federation.rs @@ -37,6 +37,7 @@ impl Federation { .get(host) .ok_or(anyhow!("unknown instance"))?) } + pub async fn get_session(&self, host: &String) -> anyhow::Result<Arc<Session>> { let mut w = self.sessions.write().await; if let Some(s) = w.get(host) { diff --git a/common/src/lib.rs b/common/src/lib.rs index 57b210b..f16338b 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -18,7 +18,7 @@ use bincode::{Decode, Encode}; #[cfg(feature = "rocket")] use rocket::{FromFormField, UriDisplayQuery}; use serde::{Deserialize, Serialize}; -use std::{collections::BTreeMap, path::PathBuf}; +use std::{collections::BTreeMap, fmt::Display, path::PathBuf}; #[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] pub struct Node { @@ -55,6 +55,57 @@ pub struct NodePublic { } #[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] +pub struct ExtendedNode { + pub ids: ObjectIds, + pub people: BTreeMap<PeopleGroup, Vec<Appearance>>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] +pub struct Appearance { + #[serde(default)] + pub jobs: Vec<String>, + #[serde(default)] + pub characters: Vec<String>, + pub person: Person, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] +pub struct Person { + pub name: String, + pub asset: Option<AssetLocation>, + pub ids: ObjectIds, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] +pub struct ObjectIds { + pub trakt: Option<u64>, + pub slug: Option<String>, + pub imdb: Option<String>, + pub tmdb: Option<u64>, + pub omdb: Option<u64>, + pub tvdb: Option<u64>, +} + +#[rustfmt::skip] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, Encode, Decode)] +#[cfg_attr(feature = "rocket", derive(FromFormField, UriDisplayQuery))] +#[serde(rename_all = "snake_case")] +pub enum PeopleGroup { + #[cfg_attr(feature = "rocket", field(value = "cast"))] Cast, + #[cfg_attr(feature = "rocket", field(value = "writing"))] Writing, + #[cfg_attr(feature = "rocket", field(value = "directing"))] Directing, + #[cfg_attr(feature = "rocket", field(value = "art"))] Art, + #[cfg_attr(feature = "rocket", field(value = "sound"))] Sound, + #[cfg_attr(feature = "rocket", field(value = "camera"))] Camera, + #[cfg_attr(feature = "rocket", field(value = "lighting"))] Lighting, + #[cfg_attr(feature = "rocket", field(value = "crew"))] Crew, + #[cfg_attr(feature = "rocket", field(value = "editing"))] Editing, + #[cfg_attr(feature = "rocket", field(value = "production"))] Production, + #[cfg_attr(feature = "rocket", field(value = "vfx"))] Vfx, + #[cfg_attr(feature = "rocket", field(value = "costume"))] CostumeMakeup, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] pub struct ImportOptions { pub id: String, pub sources: Vec<ImportSource>, @@ -240,3 +291,24 @@ impl ToString for TraktKind { .to_string() } } +impl Display for ObjectIds { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("trakt")?; + if self.slug.is_some() { + f.write_str(",slug")?; + } + if self.tmdb.is_some() { + f.write_str(",tmdb")?; + } + if self.imdb.is_some() { + f.write_str(",imdb")?; + } + if self.tvdb.is_some() { + f.write_str(",tvdb")?; + } + if self.omdb.is_some() { + f.write_str(",omdb")?; + } + Ok(()) + } +} diff --git a/import/src/lib.rs b/import/src/lib.rs index d6eb54f..8347833 100644 --- a/import/src/lib.rs +++ b/import/src/lib.rs @@ -11,17 +11,17 @@ pub mod trakt; use crate::tmdb::TmdbKind; use anyhow::{anyhow, bail, Context, Ok}; use async_recursion::async_recursion; -use futures::{executor::block_on, stream::FuturesUnordered, StreamExt}; +use futures::{stream::FuturesUnordered, StreamExt}; use jellybase::{ cache::{async_cache_file, cache_memory}, - database::{DataAcid, ReadableTable, Ser, T_NODE, T_NODE_IMPORT}, + database::{DataAcid, ReadableTable, Ser, T_NODE, T_NODE_EXTENDED, T_NODE_IMPORT}, federation::Federation, AssetLocationExt, CONF, SECRETS, }; use jellyclient::Session; use jellycommon::{ - AssetLocation, AssetRole, ImportOptions, ImportSource, MediaInfo, Node, NodeKind, NodePrivate, - NodePublic, Rating, TrackSource, + AssetLocation, AssetRole, ExtendedNode, ImportOptions, ImportSource, MediaInfo, Node, NodeKind, + NodePrivate, NodePublic, PeopleGroup, Rating, TrackSource, }; use jellymatroska::read::EbmlReader; use jellyremuxer::import::import_metadata; @@ -38,7 +38,7 @@ use std::{ path::{Path, PathBuf}, sync::{Arc, LazyLock}, }; -use tmdb::{parse_release_date, tmdb_image}; +use tmdb::{parse_release_date, Tmdb}; use tokio::{io::AsyncWriteExt, sync::Semaphore, task::spawn_blocking}; use trakt::Trakt; @@ -46,6 +46,7 @@ static IMPORT_SEM: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(1)); struct Apis { trakt: Option<Trakt>, + tmdb: Option<Tmdb>, } pub async fn import(db: &DataAcid, fed: &Federation) -> anyhow::Result<()> { @@ -64,6 +65,7 @@ pub async fn import(db: &DataAcid, fed: &Federation) -> anyhow::Result<()> { let ap = Apis { trakt: SECRETS.api.trakt.as_ref().map(|key| Trakt::new(key)), + tmdb: SECRETS.api.tmdb.as_ref().map(|key| Tmdb::new(key)), }; info!("loading sources..."); @@ -251,28 +253,72 @@ async fn process_source( txn.commit()?; Ok(()) }; + let insert_node_ext = move |id: &str, n: ExtendedNode| -> anyhow::Result<()> { + // TODO merge this + let txn = db.inner.begin_write()?; + let mut table = txn.open_table(T_NODE_EXTENDED)?; + table.insert(id, Ser(n))?; + drop(table); + txn.commit()?; + Ok(()) + }; match s { ImportSource::Override(n) => insert_node(&id, n)?, ImportSource::Trakt { id: tid, kind } => { - let trakt_object = ap + info!("trakt {id}"); + let trakt = ap .trakt .as_ref() - .ok_or(anyhow!("trakt api key is required"))? + .ok_or(anyhow!("trakt api key is required"))?; + let trakt_object = trakt .lookup(kind, tid, true) - .await?; + .await + .context("looking up metadata")?; + let trakt_people = trakt + .people(kind, tid, true) + .await + .context("looking up people")?; let mut node = Node::default(); - node.public.title = Some(trakt_object.title.to_owned()); - if let Some(overview) = &trakt_object.overview { - node.public.description = Some(overview.to_owned()) - } - if let Some(tagline) = &trakt_object.tagline { - node.public.tagline = Some(tagline.to_owned()) - } - if let Some(rating) = &trakt_object.rating { - node.public.ratings.insert(Rating::Trakt, *rating); + let mut node_ext = ExtendedNode::default(); + { + node.public.title = Some(trakt_object.title.to_owned()); + if let Some(overview) = &trakt_object.overview { + node.public.description = Some(overview.to_owned()) + } + if let Some(tagline) = &trakt_object.tagline { + node.public.tagline = Some(tagline.to_owned()) + } + if let Some(rating) = &trakt_object.rating { + node.public.ratings.insert(Rating::Trakt, *rating); + } + for p in trakt_people.cast.iter() { + node_ext + .people + .entry(PeopleGroup::Cast) + .or_default() + .push(p.a()) + } + for (group, people) in trakt_people.crew.iter() { + for p in people { + node_ext.people.entry(group.a()).or_default().push(p.a()) + } + } + for (_, ps) in &mut node_ext.people { + for p in ps { + if let Some(id) = p.person.ids.tmdb { + if let Some(tmdb) = &ap.tmdb { + let k = tmdb.person_image(id).await?; + if let Some(prof) = k.profiles.get(0) { + p.person.asset = Some(tmdb.image(&prof.file_path).await?); + } + } + } + } + } } insert_node(&id, node)?; + insert_node_ext(&id, node_ext)?; if let Some(tid) = trakt_object.ids.tmdb { let mut index_path = index_path.to_vec(); @@ -290,39 +336,21 @@ async fn process_source( } } ImportSource::Tmdb { id: tid } => { - info!("tmdb lookup {id}"); - let key = SECRETS - .api + info!("tmdb {id}"); + let tmdb = ap .tmdb .as_ref() - .ok_or(anyhow!("no tmdb api key"))?; + .ok_or(anyhow!("tmdb api key is required"))?; - let details = tokio::task::spawn_blocking(move || { - cache_memory(&["tmdb-details", &format!("{tid}")], || { - block_on(tmdb::tmdb_details(TmdbKind::Movie, tid, key)) - }) - }) - .await??; + let details = tmdb.details(TmdbKind::Movie, tid).await?; let mut node = Node::default(); if let Some(poster) = &details.poster_path { - node.private.poster = Some( - async_cache_file( - &["tmdb-asset", "poster", &format!("{tid}")], - |mut f| async move { Ok(f.write_all(&tmdb_image(&poster).await?).await?) }, - ) - .await?, - ); + node.private.poster = Some(tmdb.image(&poster).await?); } if let Some(backdrop) = &details.backdrop_path { - node.private.backdrop = Some( - async_cache_file( - &["tmdb-asset", "backdrop", &format!("{tid}")], - |mut f| async move { Ok(f.write_all(&tmdb_image(&backdrop).await?).await?) }, - ) - .await?, - ); + node.private.backdrop = Some(tmdb.image(&backdrop).await?); } node.public.tagline = details.tagline.clone(); diff --git a/import/src/tmdb.rs b/import/src/tmdb.rs index 95ebef4..b651223 100644 --- a/import/src/tmdb.rs +++ b/import/src/tmdb.rs @@ -1,3 +1,5 @@ +use std::{fmt::Display, sync::Arc}; + /* 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. @@ -5,11 +7,143 @@ */ use anyhow::Context; use bincode::{Decode, Encode}; -use jellycommon::chrono::{format::Parsed, Utc}; +use jellybase::cache::{async_cache_file, async_cache_memory}; +use jellycommon::{ + chrono::{format::Parsed, Utc}, + AssetLocation, +}; use log::info; +use reqwest::{ + header::{HeaderMap, HeaderName, HeaderValue}, + Client, ClientBuilder, +}; use serde::Deserialize; +use tokio::io::AsyncWriteExt; + +pub struct Tmdb { + client: Client, + key: String, +} + +impl Tmdb { + pub fn new(api_key: &str) -> Self { + let client = ClientBuilder::new() + .default_headers(HeaderMap::from_iter([( + HeaderName::from_static("accept"), + HeaderValue::from_static("application/json"), + )])) + .build() + .unwrap(); + Self { + client, + key: api_key.to_owned(), + } + } + pub async fn search(&self, kind: TmdbKind, query: &str) -> anyhow::Result<Arc<TmdbQuery>> { + async_cache_memory( + &["api-tmdb-search", query, &format!("{kind}")], + || async move { + info!("searching tmdb: {query:?}"); + Ok(self + .client + .get(&format!( + "https://api.themoviedb.org/3/search/{kind}?query={}?api_key={}", + query.replace(" ", "+"), + self.key + )) + .send() + .await? + .error_for_status()? + .json::<TmdbQuery>() + .await?) + }, + ) + .await + } + pub async fn details(&self, kind: TmdbKind, id: u64) -> anyhow::Result<Arc<TmdbDetails>> { + async_cache_memory( + &["api-tmdb-details", &format!("{kind} {id}")], + || async move { + info!("fetching details: {id:?}"); + Ok(self + .client + .get(&format!( + "https://api.themoviedb.org/3/{kind}/{id}?api_key={}", + self.key, + )) + .send() + .await? + .error_for_status()? + .json() + .await?) + }, + ) + .await + } + pub async fn person_image(&self, id: u64) -> anyhow::Result<Arc<TmdbPersonImage>> { + async_cache_memory(&["api-tmdb-search", &format!("{id}")], || async move { + Ok(self + .client + .get(&format!( + "https://api.themoviedb.org/3/person/{id}/images?api_key={}", + self.key, + )) + .send() + .await? + .error_for_status()? + .json() + .await?) + }) + .await + } + pub async fn image(&self, path: &str) -> anyhow::Result<AssetLocation> { + async_cache_file(&["api-tmdb-image", path], |mut file| async move { + info!("downloading image {path:?}"); + let mut res = reqwest::get(&format!("https://image.tmdb.org/t/p/original{path}")) + .await? + .error_for_status()?; + while let Some(chunk) = res.chunk().await? { + file.write_all(&chunk).await?; + } + Ok(()) + }) + .await + } +} + +pub fn parse_release_date(d: &str) -> anyhow::Result<i64> { + let (year, month, day) = (&d[0..4], &d[5..7], &d[8..10]); + let (year, month, day) = ( + year.parse().context("parsing year")?, + month.parse().context("parsing month")?, + day.parse().context("parsing day")?, + ); + + let mut p = Parsed::new(); + p.year = Some(year); + p.month = Some(month); + p.day = Some(day); + p.hour_div_12 = Some(0); + p.hour_mod_12 = Some(0); + p.minute = Some(0); + p.second = Some(0); + Ok(p.to_datetime_with_timezone(&Utc)?.timestamp_millis()) +} -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Encode, Decode)] +pub struct TmdbPersonImage { + pub id: u64, + pub profiles: Vec<TmdbPersonImageProfile>, +} +#[derive(Debug, Clone, Deserialize, Encode, Decode)] +pub struct TmdbPersonImageProfile { + pub aspect_ratio: f64, + pub height: u32, + pub width: u32, + pub file_path: String, +} + +#[derive(Debug, Clone, Deserialize, Encode, Decode)] pub struct TmdbQuery { pub page: usize, pub results: Vec<TmdbQueryResult>, @@ -77,59 +211,11 @@ pub enum TmdbKind { Tv, Movie, } -impl TmdbKind { - pub fn as_str(&self) -> &'static str { - match self { +impl Display for TmdbKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { TmdbKind::Tv => "tv", TmdbKind::Movie => "movie", - } + }) } } - -pub async fn tmdb_search(kind: TmdbKind, query: &str, key: &str) -> anyhow::Result<TmdbQuery> { - info!("searching tmdb: {query:?}"); - Ok(reqwest::get(&format!( - "https://api.themoviedb.org/3/search/{}?query={}&api_key={key}", - kind.as_str(), - query.replace(" ", "+") - )) - .await? - .json::<TmdbQuery>() - .await?) -} - -pub async fn tmdb_details(kind: TmdbKind, id: u64, key: &str) -> anyhow::Result<TmdbDetails> { - info!("fetching details: {id:?}"); - Ok(reqwest::get(&format!( - "https://api.themoviedb.org/3/{}/{id}?api_key={key}", - kind.as_str() - )) - .await? - .json() - .await?) -} - -pub async fn tmdb_image(path: &str) -> anyhow::Result<Vec<u8>> { - info!("downloading image {path:?}"); - let res = reqwest::get(&format!("https://image.tmdb.org/t/p/original{path}")).await?; - Ok(res.bytes().await?.to_vec()) -} - -pub fn parse_release_date(d: &str) -> anyhow::Result<i64> { - let (year, month, day) = (&d[0..4], &d[5..7], &d[8..10]); - let (year, month, day) = ( - year.parse().context("parsing year")?, - month.parse().context("parsing month")?, - day.parse().context("parsing day")?, - ); - - let mut p = Parsed::new(); - p.year = Some(year); - p.month = Some(month); - p.day = Some(day); - p.hour_div_12 = Some(0); - p.hour_mod_12 = Some(0); - p.minute = Some(0); - p.second = Some(0); - Ok(p.to_datetime_with_timezone(&Utc)?.timestamp_millis()) -} diff --git a/import/src/trakt.rs b/import/src/trakt.rs index 9674351..0441ad0 100644 --- a/import/src/trakt.rs +++ b/import/src/trakt.rs @@ -1,12 +1,12 @@ use bincode::{Decode, Encode}; use jellybase::cache::async_cache_memory; -use jellycommon::TraktKind; +use jellycommon::{Appearance, ObjectIds, PeopleGroup, Person, TraktKind}; use reqwest::{ header::{HeaderMap, HeaderName, HeaderValue}, Client, ClientBuilder, }; use serde::{Deserialize, Serialize}; -use std::{fmt::Display, sync::Arc}; +use std::{collections::BTreeMap, fmt::Display, sync::Arc}; pub struct Trakt { client: Client, @@ -81,6 +81,49 @@ impl Trakt { ) .await } + + pub async fn people( + &self, + kind: TraktKind, + id: u64, + extended: bool, + ) -> anyhow::Result<Arc<TraktPeople>> { + async_cache_memory( + &["api-trakt-people", &format!("{id} {extended}")], + || async move { + 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 { + pub cast: Vec<TraktAppearance>, + pub crew: BTreeMap<TraktPeopleGroup, Vec<TraktAppearance>>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] +pub struct TraktAppearance { + #[serde(default)] + pub jobs: Vec<String>, + #[serde(default)] + pub characters: Vec<String>, + 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 { @@ -130,11 +173,69 @@ impl TraktKindObject { } } +#[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")] + 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, +} +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, + } + } +} +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(), + asset: None, + ids: self.person.ids.to_owned(), + }, + } + } +} + #[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] pub struct TraktMediaObject { pub title: String, pub year: Option<u32>, - pub ids: TraktMediaObjectIds, + pub ids: ObjectIds, pub tagline: Option<String>, pub overview: Option<String>, @@ -152,16 +253,6 @@ pub struct TraktMediaObject { pub genres: Option<Vec<String>>, } -#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] -pub struct TraktMediaObjectIds { - pub trakt: u64, - pub slug: Option<String>, - pub imdb: Option<String>, - pub tmdb: Option<u64>, - pub omdb: Option<u64>, - pub tvdb: Option<u64>, -} - impl Display for TraktSearchResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!( @@ -173,24 +264,3 @@ impl Display for TraktSearchResult { )) } } -impl Display for TraktMediaObjectIds { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("trakt")?; - if self.slug.is_some() { - f.write_str(",slug")?; - } - if self.tmdb.is_some() { - f.write_str(",tmdb")?; - } - if self.imdb.is_some() { - f.write_str(",imdb")?; - } - if self.tvdb.is_some() { - f.write_str(",tvdb")?; - } - if self.omdb.is_some() { - f.write_str(",omdb")?; - } - Ok(()) - } -} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index a6a086f..d607eef 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -33,11 +33,11 @@ use ui::{ r_admin_remove_invite, user::{r_admin_remove_user, r_admin_user, r_admin_user_permission, r_admin_users}, }, - assets::{r_item_assets, r_node_thumbnail}, + assets::{r_item_assets, r_node_thumbnail, r_person_asset}, browser::r_all_items_filter, error::{r_api_catch, r_catch}, home::{r_home, r_home_unpriv}, - node::r_library_node_filter, + node::{r_library_node_ext, r_library_node_filter}, player::r_player, style::{r_assets_font, r_assets_js, r_assets_js_map, r_assets_style}, }; @@ -46,8 +46,8 @@ use userdata::{r_node_userdata, r_player_progress, r_player_watched}; use self::playersync::{r_streamsync, PlayersyncChannels}; pub mod api; -pub mod stream; pub mod playersync; +pub mod stream; pub mod ui; pub mod userdata; @@ -98,8 +98,10 @@ pub fn build_rocket(database: DataAcid, federation: Federation) -> Rocket<Build> r_streamsync, r_favicon, r_item_assets, + r_person_asset, r_all_items_filter, r_library_node_filter, + r_library_node_ext, r_assets_style, r_assets_font, r_assets_js, diff --git a/server/src/routes/ui/assets.rs b/server/src/routes/ui/assets.rs index 05ddc7c..ebd9453 100644 --- a/server/src/routes/ui/assets.rs +++ b/server/src/routes/ui/assets.rs @@ -10,13 +10,13 @@ use crate::{ use anyhow::{anyhow, Context}; use jellybase::{ cache::async_cache_file, - database::{TableExt, T_NODE}, + database::{TableExt, T_NODE, T_NODE_EXTENDED}, federation::Federation, permission::NodePermissionExt, AssetLocationExt, }; pub use jellycommon::AssetRole; -use jellycommon::{AssetLocation, LocalTrack, SourceTrackKind, TrackSource}; +use jellycommon::{AssetLocation, LocalTrack, PeopleGroup, SourceTrackKind, TrackSource}; use log::info; use rocket::{get, http::ContentType, State}; use std::{path::PathBuf, str::FromStr}; @@ -56,6 +56,38 @@ pub async fn r_item_assets( Ok(asset_with_res(asset, width).await?) } +#[get("/n/<id>/person/<index>/asset?<group>&<width>")] +pub async fn r_person_asset( + session: Session, + db: &State<DataAcid>, + id: &str, + index: usize, + group: PeopleGroup, + width: Option<usize>, +) -> MyResult<(ContentType, CacheControlFile)> { + T_NODE + .get(&db, id)? + .only_if_permitted(&session.user.permissions) + .ok_or(anyhow!("node does not exist"))?; + + let ext = T_NODE_EXTENDED.get(db, id)?.unwrap_or_default(); + let app = ext + .people + .get(&group) + .ok_or(anyhow!("group has no members"))? + .get(index) + .ok_or(anyhow!("person does not exist"))?; + + let asset = app + .person + .asset + .as_ref() + .ok_or(anyhow!("no asset"))? + .to_owned(); + + Ok(asset_with_res(asset, width).await?) +} + // TODO this can create "federation recursion" because track selection cannot be relied on. #[get("/n/<id>/thumbnail?<t>&<width>")] pub async fn r_node_thumbnail( diff --git a/server/src/routes/ui/node.rs b/server/src/routes/ui/node.rs index 3820b14..fc12db3 100644 --- a/server/src/routes/ui/node.rs +++ b/server/src/routes/ui/node.rs @@ -5,7 +5,7 @@ */ use super::{ assets::{rocket_uri_macro_r_item_assets, rocket_uri_macro_r_node_thumbnail}, - error::MyError, + error::MyResult, sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm}, }; use crate::{ @@ -14,7 +14,7 @@ use crate::{ api::AcceptJson, ui::{ account::session::Session, - assets::AssetRole, + assets::{rocket_uri_macro_r_person_asset, AssetRole}, layout::{DynLayoutPage, LayoutPage}, player::{rocket_uri_macro_r_player, PlayerConfig}, }, @@ -25,12 +25,12 @@ use crate::{ use anyhow::{anyhow, Result}; use chrono::NaiveDateTime; use jellybase::{ - database::{TableExt, T_NODE, T_USER_NODE}, + database::{TableExt, T_NODE, T_NODE_EXTENDED, T_USER_NODE}, permission::NodePermissionExt, }; use jellycommon::{ user::{NodeUserData, WatchedState}, - Chapter, MediaInfo, NodeKind, NodePublic, Rating, SourceTrackKind, + Chapter, ExtendedNode, MediaInfo, NodeKind, NodePublic, PeopleGroup, Rating, SourceTrackKind, }; use rocket::{get, serde::json::Json, Either, State}; @@ -40,6 +40,20 @@ pub fn r_library_node(id: String) { drop(id) } +#[get("/n/<id>/extended")] +pub async fn r_library_node_ext<'a>( + session: Session, + id: &'a str, + db: &'a State<DataAcid>, +) -> MyResult<Json<ExtendedNode>> { + T_NODE + .get(&db, id)? + .only_if_permitted(&session.user.permissions) + .ok_or(anyhow!("node does not exist"))?; + + Ok(Json(T_NODE_EXTENDED.get(db, id)?.unwrap_or_default())) +} + #[get("/n/<id>?<filter..>")] pub async fn r_library_node_filter<'a>( session: Session, @@ -47,12 +61,13 @@ pub async fn r_library_node_filter<'a>( db: &'a State<DataAcid>, aj: AcceptJson, filter: NodeFilterSort, -) -> Result<Either<DynLayoutPage<'a>, Json<NodePublic>>, MyError> { +) -> MyResult<Either<DynLayoutPage<'a>, Json<NodePublic>>> { let node = T_NODE .get(&db, id)? .only_if_permitted(&session.user.permissions) .ok_or(anyhow!("node does not exist"))? .public; + let node_ext = T_NODE_EXTENDED.get(db, id)?.unwrap_or_default(); let udata = T_USER_NODE .get(&db, &(session.user.name.as_str(), id))? @@ -75,7 +90,7 @@ pub async fn r_library_node_filter<'a>( Ok(Either::Left(LayoutPage { title: node.title.clone().unwrap_or_default(), content: markup::new! { - @NodePage { node: &node, id: &id, udata: &udata, children: &children, filter: &filter } + @NodePage { node: &node, id: &id, udata: &udata, children: &children, filter: &filter, node_ext: &node_ext } }, ..Default::default() })) @@ -87,7 +102,7 @@ markup::define! { div[class=cls] { .poster { a[href=uri!(r_library_node(id))] { - img[src=uri!(r_item_assets(id, AssetRole::Poster, Some(1024)))]; + img[src=uri!(r_item_assets(id, AssetRole::Poster, Some(1024))), loading="lazy"]; } .cardhover.item { @if !(matches!(node.kind.unwrap_or_default(), NodeKind::Collection | NodeKind::Channel)) { @@ -103,13 +118,13 @@ markup::define! { } } } - NodePage<'a>(id: &'a str, node: &'a NodePublic, udata: &'a NodeUserData, children: &'a Vec<(String, NodePublic, NodeUserData)>, filter: &'a NodeFilterSort) { + NodePage<'a>(id: &'a str, node: &'a NodePublic, node_ext: &'a ExtendedNode, udata: &'a NodeUserData, children: &'a Vec<(String, NodePublic, NodeUserData)>, filter: &'a NodeFilterSort) { @if !matches!(node.kind.unwrap_or_default(), NodeKind::Collection) { - img.backdrop[src=uri!(r_item_assets(id, AssetRole::Backdrop, Some(2048)))]; + img.backdrop[src=uri!(r_item_assets(id, AssetRole::Backdrop, Some(2048))), loading="lazy"]; } .page.node { @if !matches!(node.kind.unwrap_or_default(), NodeKind::Collection) { - div.bigposter { img[src=uri!(r_item_assets(id, AssetRole::Poster, Some(2048)))]; } + div.bigposter { img[src=uri!(r_item_assets(id, AssetRole::Poster, Some(2048))), loading="lazy"]; } } .title { h1 { @node.title } @@ -151,7 +166,7 @@ markup::define! { li { .card."aspect-thumb" { .poster { a[href=&uri!(r_player(id, PlayerConfig::seek(chap.time_start.unwrap_or(0.))))] { - img[src=&uri!(r_node_thumbnail(id, chapter_key_time(chap, media.duration), Some(1024)))]; + img[src=&uri!(r_node_thumbnail(id, chapter_key_time(chap, media.duration), Some(1024))), loading="lazy"]; } .cardhover { .props { p { @inl } } } } @@ -159,6 +174,21 @@ markup::define! { }} }}} } + h2 { "Cast & Crew" } + @for (group, people) in &node_ext.people { + h3 { @format!("{:?}", group) } + .hlist { ul.children { @for (i, pe) in people.iter().enumerate() { + li { .card."aspect-port" { + .poster { + a[href="#"] { + img[src=&uri!(r_person_asset(id, i, group, Some(1024))), loading="lazy"]; + } + // .cardhover { .props { p { @pe.person.name } } } + } + .title { @pe.person.name } + }} + }}} + } details { summary { "Tracks" } ol { @for track in &media.tracks { diff --git a/tool/src/add.rs b/tool/src/add.rs index 9cb1180..4645846 100644 --- a/tool/src/add.rs +++ b/tool/src/add.rs @@ -93,7 +93,7 @@ pub(crate) async fn add(action: Action) -> anyhow::Result<()> { let mut sources = Vec::new(); sources.push(ImportSource::Trakt { - id: trakt_object.ids.trakt, + id: trakt_object.ids.trakt.unwrap(), kind: trakt_kind, }); if let Some(media) = media { |