aboutsummaryrefslogtreecommitdiff
path: root/server/src/routes/ui/node.rs
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-04-27 19:25:11 +0200
committermetamuffin <metamuffin@disroot.org>2025-04-27 19:25:11 +0200
commit11a585b3dbe620dcc8772e713b22f1d9ba80d598 (patch)
tree44f8d97137412aefc79a2425a489c34fa3e5f6c5 /server/src/routes/ui/node.rs
parentd871aa7c5bba49ff55170b5d2dac9cd440ae7170 (diff)
downloadjellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar
jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.bz2
jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.zst
move files around
Diffstat (limited to 'server/src/routes/ui/node.rs')
-rw-r--r--server/src/routes/ui/node.rs558
1 files changed, 0 insertions, 558 deletions
diff --git a/server/src/routes/ui/node.rs b/server/src/routes/ui/node.rs
deleted file mode 100644
index d968f0a..0000000
--- a/server/src/routes/ui/node.rs
+++ /dev/null
@@ -1,558 +0,0 @@
-/*
- 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::{
- 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/<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,
- })
-}