/* 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, layout::trs, sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty}, }; use crate::{ database::Database, routes::{ api::AcceptJson, locale::AcceptLanguage, 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 jellybase::locale::{tr, Language}; use jellycommon::{ api::ApiNodeResponse, user::{NodeUserData, WatchedState}, Chapter, MediaInfo, Node, NodeID, NodeKind, PeopleGroup, Rating, SourceTrackKind, Visibility, }; use rocket::{get, serde::json::Json, Either, State}; use std::{cmp::Reverse, collections::BTreeMap, fmt::Write, sync::Arc}; /// This function is a stub and only useful for use in the uri! macro. #[get("/n/")] pub fn r_library_node(id: NodeID) { let _ = id; } #[get("/n/?&&")] pub async fn r_library_node_filter<'a>( session: Session, id: NodeID, db: &'a State, aj: AcceptJson, filter: NodeFilterSort, lang: AcceptLanguage, parents: bool, children: bool, ) -> MyResult, Json>> { let AcceptLanguage(lang) = lang; let (node, udata) = db.get_node_with_userdata(id, &session)?; let mut children = if !*aj || children { db.get_node_children(id)? .into_iter() .map(|c| db.get_node_with_userdata(c, &session)) .collect::>>()? } else { Vec::new() }; let mut parents = if !*aj || parents { node.parents .iter() .map(|pid| db.get_node_with_userdata(*pid, &session)) .collect::>>()? } else { Vec::new() }; let mut similar = get_similar_media(&node, db, &session)?; similar.retain(|(n, _)| n.visibility >= Visibility::Reduced); children.retain(|(n, _)| n.visibility >= Visibility::Reduced); 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, ); Ok(if *aj { Either::Right(Json(ApiNodeResponse { children, parents, node, userdata: udata, })) } else { Either::Left(LayoutPage { title: node.title.clone().unwrap_or_default(), content: markup::new!(@NodePage { node: &node, udata: &udata, children: &children, parents: &parents, filter: &filter, player: false, similar: &similar, lang: &lang, }), ..Default::default() }) }) } pub fn get_similar_media( node: &Node, db: &Database, session: &Session, ) -> Result, NodeUserData)>> { let this_id = NodeID::from_slug(&node.slug); let mut ranking = BTreeMap::::new(); for tag in &node.tags { let nodes = db.get_tag_nodes(tag)?; let weight = 1_000_000 / nodes.len(); for n in nodes { if n != this_id { *ranking.entry(n).or_default() += weight; } } } let mut ranking = ranking.into_iter().collect::>(); ranking.sort_by_key(|(_, k)| Reverse(*k)); ranking .into_iter() .take(32) .map(|(pid, _)| db.get_node_with_userdata(pid, session)) .collect::>>() } markup::define! { NodeCard<'a>(node: &'a Node, udata: &'a NodeUserData, lang: &'a Language) { @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, lang } } } div.title { a[href=uri!(r_library_node(&node.slug))] { @node.title } } div.subtitle { span { @node.subtitle } } } } NodeCardWide<'a>(node: &'a Node, udata: &'a NodeUserData, lang: &'a Language) { div[class="node card widecard poster"] { div[class=&format!("poster {}", aspect_class(node.kind))] { 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, lang } span.overview { @node.description } } } } NodePage<'a>( node: &'a Node, udata: &'a NodeUserData, children: &'a [(Arc, NodeUserData)], parents: &'a [(Arc, NodeUserData)], similar: &'a [(Arc, NodeUserData)], filter: &'a NodeFilterSort, lang: &'a Language, player: bool, ) { @if !matches!(node.kind, NodeKind::Collection) && !player { img.backdrop[src=uri!(r_item_backdrop(&node.slug, Some(2048))), loading="lazy"]; } .page.node { @if !matches!(node.kind, NodeKind::Collection) && !player { @let cls = format!("bigposter {}", aspect_class(node.kind)); div[class=cls] { img[src=uri!(r_item_poster(&node.slug, 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(&node.slug, PlayerConfig::default()))] { @trs(lang, "node.player_link") } } @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(&node.slug, UrlWatchedState::Watched))] { input[type="submit", value=trs(lang, "node.watched.set")]; } } @if matches!(udata.watched, WatchedState::Watched) { form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] { input[type="submit", value=trs(lang, "node.watched.unset")]; } } @if matches!(udata.watched, WatchedState::None) { form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Pending))] { input[type="submit", value=trs(lang, "node.watchlist.set")]; } } @if matches!(udata.watched, WatchedState::Pending) { form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] { input[type="submit", value=trs(lang, "node.watchlist.unset")]; } } form.rating[method="POST", action=uri!(r_node_userdata_rating(&node.slug))] { 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, lang } 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 { @trs(lang, "node.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(&node.slug, PlayerConfig::seek(chap.time_start.unwrap_or(0.))))] { img[src=&uri!(r_node_thumbnail(&node.slug, chapter_key_time(chap, media.duration), Some(1024))), loading="lazy"]; } .cardhover { .props { p { @inl } } } } .title { span { @sub } } }} }} } @if !node.people.is_empty() { h2 { @trs(lang, "node.people") } @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(&node.slug, i, group.to_string(), Some(1024))), loading="lazy"]; } } .title { span { @pe.person.name } br; @if let Some(c) = pe.characters.first() { span.subtitle { @c } } @if let Some(c) = pe.jobs.first() { span.subtitle { @c } } } }} }} } } } details { summary { @trs(lang, "media.tracks") } ol { @for track in &media.tracks { li { @format!("{track}") } }} } } @if !node.tags.is_empty() { details { summary { @trs(lang, "node.tags") } ol { @for tag in &node.tags { li { @tag } }} } } } @if matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { @NodeFilterSortForm { f: filter, lang } } @if !similar.is_empty() { h2 { @trs(lang, "node.similar") } ul.children.hlist {@for (node, udata) in similar.iter() { li { @NodeCard { node, udata, lang } } }} } @match node.kind { NodeKind::Show | NodeKind::Series | NodeKind::Season => { ol { @for (node, udata) in children.iter() { li { @NodeCardWide { node, udata, lang } } }} } NodeKind::Collection | NodeKind::Channel | _ => { ul.children {@for (node, udata) in children.iter() { li { @NodeCard { node, udata, lang } } }} } } } } Props<'a>(node: &'a Node, udata: &'a NodeUserData, full: bool, lang: &'a Language) { .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.visibility{@trs(lang, "prop.vis.reduced")}} Visibility::Hidden => {p.visibility{@trs(lang, "prop.vis.hidden")}} } // TODO // @if !node.children.is_empty() { // p { @format!("{} items", node.children.len()) } // } @for (kind, value) in &node.ratings { @match kind { Rating::YoutubeLikes => {p.likes{ @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 { @trs(lang, "prop.watched.pending") } } WatchedState::Progress(x) => { p.progress { @tr(**lang, "prop.watched.progress").replace("{time}", &format_duration(x)) } } WatchedState::Watched => { p.watched { @trs(lang, "prop.watched.watched") } } } } } } pub fn aspect_class(kind: NodeKind) -> &'static str { use NodeKind::*; match kind { Video | Episode => "aspect-thumb", Collection => "aspect-land", Season | Show | Series | Movie | ShortFormVideo => "aspect-port", Channel | Music | Unknown => "aspect-square", } } pub fn format_duration(d: f64) -> String { format_duration_mode(d, false, Language::English) } pub fn format_duration_long(d: f64, lang: Language) -> String { format_duration_mode(d, true, lang) } fn format_duration_mode(mut d: f64, long_units: bool, lang: Language) -> String { let mut s = String::new(); let sign = if d > 0. { "" } else { "-" }; d = d.abs(); for (short, long, long_pl, k) in [ ("d", "time.day", "time.days", 60. * 60. * 24.), ("h", "time.hour", "time.hours", 60. * 60.), ("m", "time.minute", "time.minutes", 60.), ("s", "time.second", "time.seconds", 1.), ] { let h = (d / k).floor(); d -= h * k; if h > 0. { if long_units { let long = tr(lang, if h != 1. { long_pl } else { long }); // TODO breaks if seconds is zero write!( s, "{}{h} {long}{}", if k != 1. { "" } else { " and " }, if k > 60. { ", " } else { "" }, ) .unwrap(); } else { write!(s, "{h}{short} ").unwrap(); } } } format!("{sign}{}", s.trim()) } pub fn format_size(size: u64) -> String { humansize::format_size(size, humansize::DECIMAL) } 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 }