/* 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) 2025 metamuffin */ use super::{ assets::{ rocket_uri_macro_r_item_backdrop, rocket_uri_macro_r_item_poster, rocket_uri_macro_r_node_thumbnail, }, error::MyResult, sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty}, }; use crate::{ database::Database, routes::{ api::AcceptJson, ui::{ account::session::Session, assets::rocket_uri_macro_r_person_asset, layout::{DynLayoutPage, LayoutPage}, player::{rocket_uri_macro_r_player, PlayerConfig}, }, userdata::{ rocket_uri_macro_r_node_userdata_rating, rocket_uri_macro_r_node_userdata_watched, UrlWatchedState, }, }, uri, }; use anyhow::{anyhow, Result}; use chrono::DateTime; use jellycommon::{ user::{NodeUserData, WatchedState}, Chapter, MediaInfo, Node, NodeID, NodeKind, PeopleGroup, Rating, SourceTrackKind, Visibility, }; use rocket::{get, serde::json::Json, Either, State}; use std::sync::Arc; /// 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/?")] pub async fn r_library_node_filter<'a>( session: Session, slug: &'a str, db: &'a State, aj: AcceptJson, filter: NodeFilterSort, ) -> MyResult, Json>> { let id = NodeID::from_slug(slug); let (node, udata) = db.get_node_with_userdata(id, &session)?; if *aj { return Ok(Either::Right(Json((*node).clone()))); } let mut children = db .get_node_children(id)? .into_iter() .map(|c| db.get_node_with_userdata(c, &session)) .collect::>>()?; let parents = node .parents .iter() .flat_map(|pid| db.get_node(*pid).transpose()) .collect::, _>>()?; filter_and_sort_nodes( &filter, match node.kind { NodeKind::Channel => (SortProperty::ReleaseDate, SortOrder::Descending), _ => (SortProperty::Title, SortOrder::Ascending), }, // TODO &mut children, ); Ok(Either::Left(LayoutPage { title: node.title.clone().unwrap_or_default(), content: markup::new! { @NodePage { node: &node, id: slug, udata: &udata, children: &children, parents: &parents, filter: &filter } }, ..Default::default() })) } markup::define! { NodeCard<'a>(node: &'a Node, udata: &'a NodeUserData) { @let cls = format!("node card 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" } } @Props { node, udata, full: false } } } div.title { a[href=uri!(r_library_node(&node.slug))] { @node.title } } div.subtitle { span { @node.subtitle } } } } NodePage<'a>(id: &'a str, node: &'a Node, udata: &'a NodeUserData, children: &'a [(Arc, NodeUserData)], parents: &'a [Arc], filter: &'a NodeFilterSort) { @if !matches!(node.kind, NodeKind::Collection) { img.backdrop[src=uri!(r_item_backdrop(id, Some(2048))), loading="lazy"]; } .page.node { @if !matches!(node.kind, NodeKind::Collection) { @let cls = format!("bigposter {}", aspect_class(node.kind)); div[class=cls] { img[src=uri!(r_item_poster(id, Some(2048))), loading="lazy"]; } } .title { h1 { @node.title } ul.parents { @for node in *parents { li { a.component[href=uri!(r_library_node(&node.slug))] { @node.title } }}} @if node.media.is_some() { a.play[href=&uri!(r_player(id, PlayerConfig::default()))] { "Watch now" }} @if !matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { @if matches!(udata.watched, WatchedState::None | WatchedState::Pending | WatchedState::Progress(_)) { form.mark_watched[method="POST", action=uri!(r_node_userdata_watched(id, UrlWatchedState::Watched))] { input[type="submit", value="Mark Watched"]; } } @if matches!(udata.watched, WatchedState::Watched) { form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(id, UrlWatchedState::None))] { input[type="submit", value="Mark Unwatched"]; } } @if matches!(udata.watched, WatchedState::None) { form.mark_unwatched[method="POST", action=uri!(r_node_userdata_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_node_userdata_watched(id, UrlWatchedState::None))] { input[type="submit", value="Remove from Watchlist"]; } } form.rating[method="POST", action=uri!(r_node_userdata_rating(id))] { input[type="range", name="rating", min=-10, max=10, step=1, value=udata.rating]; input[type="submit", value="Update Rating"]; } } } .details { @Props { node, udata, full: true } 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" } ul.children.hlist { @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 } }} }} } @if !node.people.is_empty() { h2 { "Cast & Crew" } @for (group, people) in &node.people { details[open=group==&PeopleGroup::Cast] { summary { h3 { @format!("{}", group) } } ul.children.hlist { @for (i, pe) in people.iter().enumerate() { li { .card."aspect-port" { .poster { a[href="#"] { img[src=&uri!(r_person_asset(id, i, group.to_string(), Some(1024))), loading="lazy"]; } } .title { @pe.person.name br; @if let Some(c) = pe.characters.first() { .subtitle { @c } } @if let Some(c) = pe.jobs.first() { .subtitle { @c } } } }} }} } } } details { summary { "Tracks" } ol { @for track in &media.tracks { li { @format!("{track}") } }} } } } @if matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { @NodeFilterSortForm { f: filter } } @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 } } }} } NodeKind::Collection | NodeKind::Channel | _ => { ul.children {@for (node, udata) in children.iter() { @if node.visibility != Visibility::Hidden { li { @NodeCard { node, udata } } } }} } } } } Props<'a>(node: &'a Node, udata: &'a NodeUserData, full: bool) { .props { @if let Some(m) = &node.media { p { @format_duration(m.duration) } p { @m.resolution_name() } } @if let Some(d) = &node.release_date { p { @if *full { @DateTime::from_timestamp_millis(*d).unwrap().naive_utc().to_string() } else { @DateTime::from_timestamp_millis(*d).unwrap().date_naive().to_string() }} } @match node.visibility { Visibility::Visible => {} Visibility::Reduced => {p{"Reduced visibility"}} Visibility::Hidden => {p{"Hidden"}} } // TODO // @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 if *full => {p{ "Metacritic Score: " @value }} Rating::Imdb => {p.rating{ "IMDb " @value }} Rating::Tmdb => {p.rating{ "TMDB " @value }} Rating::Trakt if *full => {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 aspect_class(kind: NodeKind) -> &'static str { match kind { NodeKind::Channel | NodeKind::Music => "aspect-square", NodeKind::Video => "aspect-thumb", NodeKind::Collection => "aspect-land", _ => "aspect-port", } } pub fn format_duration(mut d: f64) -> String { let mut s = String::new(); let sign = if d > 0. { "" } else { "-" }; d = d.abs(); 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}") } } format!("{sign}{s}") } pub trait DatabaseNodeUserDataExt { fn get_node_with_userdata( &self, id: NodeID, session: &Session, ) -> Result<(Arc, NodeUserData)>; } impl DatabaseNodeUserDataExt for Database { fn get_node_with_userdata( &self, id: NodeID, session: &Session, ) -> Result<(Arc, NodeUserData)> { Ok(( self.get_node(id)?.ok_or(anyhow!("node does not exist"))?, self.get_node_udata(id, &session.user.name)? .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 { if let SourceTrackKind::Video { width, height, .. } = &t.kind { maxdim = maxdim.max(*width.max(height)) } } match maxdim { 30720.. => "32K", 15360.. => "16K", 7680.. => "8K UHD", 5120.. => "5K UHD", 3840.. => "4K UHD", 2560.. => "QHD 1440p", 1920.. => "FHD 1080p", 1280.. => "HD 720p", 854.. => "SD 480p", _ => "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 }