aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--base/src/database.rs7
-rw-r--r--base/src/federation.rs1
-rw-r--r--common/src/lib.rs74
-rw-r--r--import/src/lib.rs110
-rw-r--r--import/src/tmdb.rs194
-rw-r--r--import/src/trakt.rs138
-rw-r--r--server/src/routes/mod.rs8
-rw-r--r--server/src/routes/ui/assets.rs36
-rw-r--r--server/src/routes/ui/node.rs52
-rw-r--r--tool/src/add.rs2
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 {