aboutsummaryrefslogtreecommitdiff
path: root/server/src/ui/node.rs
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/ui/node.rs')
-rw-r--r--server/src/ui/node.rs558
1 files changed, 558 insertions, 0 deletions
diff --git a/server/src/ui/node.rs b/server/src/ui/node.rs
new file mode 100644
index 0000000..bf65a3e
--- /dev/null
+++ b/server/src/ui/node.rs
@@ -0,0 +1,558 @@
+/*
+ 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 <metamuffin.org>
+*/
+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 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/<id>")]
+pub fn r_library_node(id: NodeID) {
+ let _ = id;
+}
+
+#[get("/n/<id>?<parents>&<children>&<filter..>")]
+pub async fn r_library_node_filter<'a>(
+ session: Session,
+ id: NodeID,
+ db: &'a State<Database>,
+ aj: AcceptJson,
+ filter: NodeFilterSort,
+ lang: AcceptLanguage,
+ parents: bool,
+ children: bool,
+) -> MyResult<Either<DynLayoutPage<'a>, Json<ApiNodeResponse>>> {
+ 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::<anyhow::Result<Vec<_>>>()?
+ } else {
+ Vec::new()
+ };
+
+ let mut parents = if !*aj || parents {
+ node.parents
+ .iter()
+ .map(|pid| db.get_node_with_userdata(*pid, &session))
+ .collect::<anyhow::Result<Vec<_>>>()?
+ } 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<Vec<(Arc<Node>, NodeUserData)>> {
+ let this_id = NodeID::from_slug(&node.slug);
+ let mut ranking = BTreeMap::<NodeID, usize>::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::<Vec<_>>();
+ ranking.sort_by_key(|(_, k)| Reverse(*k));
+ ranking
+ .into_iter()
+ .take(32)
+ .map(|(pid, _)| db.get_node_with_userdata(pid, session))
+ .collect::<anyhow::Result<Vec<_>>>()
+}
+
+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<Node>, NodeUserData)],
+ parents: &'a [(Arc<Node>, NodeUserData)],
+ similar: &'a [(Arc<Node>, 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,
+ id: NodeID,
+ session: &Session,
+ ) -> Result<(Arc<Node>, NodeUserData)>;
+}
+impl DatabaseNodeUserDataExt for Database {
+ fn get_node_with_userdata(
+ &self,
+ id: NodeID,
+ session: &Session,
+ ) -> Result<(Arc<Node>, 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<usize>) -> 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<String> {
+ 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,
+ })
+}