/* 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) 2023 metamuffin */ use super::{ assets::{rocket_uri_macro_r_item_assets, rocket_uri_macro_r_node_thumbnail}, error::MyResult, sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm}, }; use crate::{ database::DataAcid, routes::{ api::AcceptJson, ui::{ account::session::Session, assets::{rocket_uri_macro_r_person_asset, AssetRole}, layout::{DynLayoutPage, LayoutPage}, player::{rocket_uri_macro_r_player, PlayerConfig}, }, userdata::{rocket_uri_macro_r_player_watched, UrlWatchedState}, }, uri, }; use anyhow::{anyhow, Result}; use chrono::NaiveDateTime; use jellybase::{ database::{TableExt, T_NODE, T_NODE_EXTENDED, T_USER_NODE}, permission::NodePermissionExt, }; use jellycommon::{ user::{NodeUserData, WatchedState}, Chapter, ExtendedNode, MediaInfo, NodeKind, NodePublic, PeopleGroup, Rating, SourceTrackKind, }; use rocket::{get, serde::json::Json, Either, State}; /// This function is a stub and only useful for use in the uri! macro. #[get("/n/")] pub fn r_library_node(id: String) { drop(id) } #[get("/n//extended")] pub async fn r_library_node_ext<'a>( session: Session, id: &'a str, db: &'a State, ) -> MyResult> { 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/?")] pub async fn r_library_node_filter<'a>( session: Session, id: &'a str, db: &'a State, aj: AcceptJson, filter: NodeFilterSort, ) -> MyResult, Json>> { 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))? .unwrap_or_default(); if *aj { return Ok(Either::Right(Json(node))); } let mut children = node .children .iter() .map(|c| Ok(db.get_node_with_userdata(c, &session)?)) .collect::>>()? .into_iter() .collect(); filter_and_sort_nodes(&filter, &mut children); 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, node_ext: &node_ext } }, ..Default::default() })) } markup::define! { NodeCard<'a>(id: &'a str, node: &'a NodePublic, udata: &'a NodeUserData) { @let cls = format!("node card poster {}", match node.kind.unwrap_or_default() {NodeKind::Channel => "aspect-square", NodeKind::Video => "aspect-thumb", NodeKind::Collection => "aspect-land", _ => "aspect-port"}); div[class=cls] { .poster { a[href=uri!(r_library_node(id))] { 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)) { a.play.icon[href=&uri!(r_player(id, PlayerConfig::default()))] { "play_arrow" } } @Props { node, udata } } } div.title { a[href=uri!(r_library_node(id))] { @node.title } } } } 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))), 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))), loading="lazy"]; } } .title { h1 { @node.title } @if node.media.is_some() { a.play[href=&uri!(r_player(id, PlayerConfig::default()))] { "Watch now" }} @if !matches!(node.kind.unwrap_or_default(), NodeKind::Collection | NodeKind::Channel) { @if matches!(udata.watched, WatchedState::None | WatchedState::Pending | WatchedState::Progress(_)) { form.mark_watched[method="POST", action=uri!(r_player_watched(id, UrlWatchedState::Watched))] { input[type="submit", value="Mark Watched"]; } } @if matches!(udata.watched, WatchedState::Watched) { form.mark_unwatched[method="POST", action=uri!(r_player_watched(id, UrlWatchedState::None))] { input[type="submit", value="Mark Unwatched"]; } } @if matches!(udata.watched, WatchedState::None) { form.mark_unwatched[method="POST", action=uri!(r_player_watched(id, UrlWatchedState::Pending))] { input[type="submit", value="Add to Watchlist"]; } } @if matches!(udata.watched, WatchedState::Pending) { form.mark_unwatched[method="POST", action=uri!(r_player_watched(id, UrlWatchedState::None))] { input[type="submit", value="Remove from Watchlist"]; } } } } .details { @Props { node, udata } h3 { @node.tagline } @if let Some(description) = &node.description { p { @for line in description.lines() { @line br; } } } @if let Some(media) = &node.media { @if !media.chapters.is_empty() { h2 { "Chapters" } .hlist { ul.children { @for chap in &media.chapters { @let (inl, sub) = format_chapter(chap); 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))), loading="lazy"]; } .cardhover { .props { p { @inl } } } } .title { @sub } }} }}} } h2 { "Cast & Crew" } @for (group, people) in &node_ext.people { details[open=group==&PeopleGroup::Cast] { summary { 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 br; @if let Some(c) = pe.characters.get(0) { .subtitle { @c } } @if let Some(c) = pe.jobs.get(0) { .subtitle { @c } } } }} }}} } } details { summary { "Tracks" } ol { @for track in &media.tracks { li { @format!("{track}") } }} } } } @if matches!(node.kind.unwrap_or_default(), NodeKind::Collection | NodeKind::Channel) { @NodeFilterSortForm { f: filter } } @match node.kind.unwrap_or_default() { NodeKind::Collection | NodeKind::Channel => { ul.children {@for (id, node, udata) in children.iter() { li { @NodeCard { id, node, udata } } }} } NodeKind::Series => { ol { @for (id, c, _) in children.iter() { li { a[href=uri!(r_library_node(id))] { @c.title } } }} } _ => {} } } } Props<'a>(node: &'a NodePublic, udata: &'a NodeUserData) { .props { @if let Some(m) = &node.media { p { @format_duration(m.duration) } p { @m.resolution_name() } } @if let Some(d) = &node.release_date { p { @NaiveDateTime::from_timestamp_millis(*d).unwrap().and_utc().to_string() } } @if !node.children.is_empty() { p { @format!("{} items", node.children.len()) } } @for (kind, value) in &node.ratings { @match kind { Rating::YoutubeLikes => {p{ @format_count(*value as usize) " Likes" }} Rating::YoutubeViews => {p{ @format_count(*value as usize) " Views" }} Rating::YoutubeFollowers => {p{ @format_count(*value as usize) " Subscribers" }} Rating::RottenTomatoes => {p.rating{ @value " Tomatoes" }} Rating::Metacritic => {p{ "Metacritic Score: " @value }} Rating::Imdb => {p.rating{ "IMDb " @value }} Rating::Tmdb => {p.rating{ "TMDB " @value }} Rating::Trakt => {p.rating{ "Trakt " @value }} } } @if let Some(f) = &node.federated { p.federation { @f } } @match udata.watched { WatchedState::None => {} WatchedState::Pending => { p.pending { "Watchlisted" } } WatchedState::Progress(x) => { p.progress { "Watched up to " @format_duration(x) } } WatchedState::Watched => { p.watched { "Watched" } } } } } } pub fn format_duration(mut d: f64) -> String { let mut s = String::new(); for (unit, k) in [("h", 60. * 60.), ("m", 60.), ("s", 1.)] { let mut h = 0; // TODO dont iterate like that. can be a simple rem and div while d > k { d -= k; h += 1; } if h > 0 { s += &format!("{h}{unit}") } } s } pub trait DatabaseNodeUserDataExt { fn get_node_with_userdata( &self, id: &str, session: &Session, ) -> Result<(String, NodePublic, NodeUserData)>; } impl DatabaseNodeUserDataExt for DataAcid { fn get_node_with_userdata( &self, id: &str, session: &Session, ) -> Result<(String, NodePublic, NodeUserData)> { Ok(( id.to_owned(), T_NODE .get(self, id)? .only_if_permitted(&session.user.permissions) .ok_or(anyhow!("node does not exist: {id}"))? .public, T_USER_NODE .get(self, &(session.user.name.as_str(), id))? .unwrap_or_default(), )) } } trait MediaInfoExt { fn resolution_name(&self) -> &'static str; } impl MediaInfoExt for MediaInfo { fn resolution_name(&self) -> &'static str { let mut maxdim = 0; for t in &self.tracks { match &t.kind { SourceTrackKind::Video { width, height, .. } => { maxdim = maxdim.max(*width.max(height)) } _ => (), } } match maxdim { 30720.. => "32K", 15360.. => "16K", 7680.. => "8K", 3840.. => "4K", 2560.. => "WQHD", 1920.. => "Full HD", 1280.. => "HD 720p", 640.. => "NTSC", _ => "Unkown", } } } fn format_count(n: impl Into) -> String { let n: usize = n.into(); if n >= 1_000_000 { format!("{:.1}M", n as f32 / 1_000_000.) } else if n >= 1_000 { format!("{:.1}k", n as f32 / 1_000.) } else { format!("{n}") } } fn format_chapter(c: &Chapter) -> (String, String) { ( format!( "{}-{}", c.time_start.map(format_duration).unwrap_or_default(), c.time_end.map(format_duration).unwrap_or_default(), ), c.labels.first().map(|l| l.1.clone()).unwrap_or_default(), ) } fn chapter_key_time(c: &Chapter, dur: f64) -> f64 { let start = c.time_start.unwrap_or(0.); let end = c.time_end.unwrap_or(dur); start * 0.8 + end * 0.2 }