From 80d28b764c95891551e28c395783f5ff9d065743 Mon Sep 17 00:00:00 2001 From: metamuffin Date: Mon, 28 Apr 2025 00:48:52 +0200 Subject: start with splitting server --- server/src/ui/node.rs | 430 +------------------------------------------------- 1 file changed, 6 insertions(+), 424 deletions(-) (limited to 'server/src/ui/node.rs') diff --git a/server/src/ui/node.rs b/server/src/ui/node.rs index bf65a3e..1efcc10 100644 --- a/server/src/ui/node.rs +++ b/server/src/ui/node.rs @@ -3,43 +3,16 @@ 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, TrString}, - sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty}, -}; -use crate::{ - api::AcceptJson, - database::Database, - locale::AcceptLanguage, - logic::{ - session::Session, - userdata::{ - rocket_uri_macro_r_node_userdata_rating, rocket_uri_macro_r_node_userdata_watched, - UrlWatchedState, - }, - }, - ui::{ - assets::rocket_uri_macro_r_person_asset, - layout::{DynLayoutPage, LayoutPage}, - player::{rocket_uri_macro_r_player, PlayerConfig}, - }, - uri, -}; +use super::{error::MyResult, sort::filter_and_sort_nodes}; +use crate::{api::AcceptJson, database::Database, locale::AcceptLanguage, logic::session::Session}; 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, + api::{ApiNodeResponse, NodeFilterSort, SortOrder, SortProperty}, + user::NodeUserData, + Node, NodeID, NodeKind, Visibility, }; use rocket::{get, serde::json::Json, Either, State}; -use std::{cmp::Reverse, collections::BTreeMap, fmt::Write, sync::Arc}; +use std::{cmp::Reverse, collections::BTreeMap, sync::Arc}; /// This function is a stub and only useful for use in the uri! macro. #[get("/n/")] @@ -145,327 +118,6 @@ pub fn get_similar_media( .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=trs(lang, "node.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.external_ids.is_empty() { - details { - summary { @trs(lang, "node.external_ids") } - table { - @for (key, value) in &node.external_ids { tr { - tr { - td { @trs(lang, &format!("eid.{}", key)) } - @if let Some(url) = external_id_url(key, value) { - td { a[href=url] { pre { @value } } } - } else { - td { pre { @value } } - } - } - }} - } - } - } - @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 }); - let and = format!(" {} ", tr(lang, "time.and_join")); - // 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 fn format_kind(k: NodeKind, lang: Language) -> TrString<'static> { - trs( - &lang, - match k { - NodeKind::Unknown => "kind.unknown", - NodeKind::Movie => "kind.movie", - NodeKind::Video => "kind.video", - NodeKind::Music => "kind.music", - NodeKind::ShortFormVideo => "kind.short_form_video", - NodeKind::Collection => "kind.collection", - NodeKind::Channel => "kind.channel", - NodeKind::Show => "kind.show", - NodeKind::Series => "kind.series", - NodeKind::Season => "kind.season", - NodeKind::Episode => "kind.episode", - }, - ) -} - pub trait DatabaseNodeUserDataExt { fn get_node_with_userdata( &self, @@ -486,73 +138,3 @@ impl DatabaseNodeUserDataExt for Database { )) } } - -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 -} - -fn external_id_url(key: &str, value: &str) -> Option { - Some(match key { - "youtube.video" => format!("https://youtube.com/watch?v={value}"), - "youtube.channel" => format!("https://youtube.com/channel/{value}"), - "youtube.channelname" => format!("https://youtube.com/channel/@{value}"), - "musicbrainz.release" => format!("https://musicbrainz.org/release/{value}"), - "musicbrainz.albumartist" => format!("https://musicbrainz.org/artist/{value}"), - "musicbrainz.artist" => format!("https://musicbrainz.org/artist/{value}"), - "musicbrainz.releasegroup" => format!("https://musicbrainz.org/release-group/{value}"), - "musicbrainz.recording" => format!("https://musicbrainz.org/recording/{value}"), - _ => return None, - }) -} -- cgit v1.2.3-70-g09d2