diff options
author | metamuffin <metamuffin@disroot.org> | 2025-04-28 00:48:52 +0200 |
---|---|---|
committer | metamuffin <metamuffin@disroot.org> | 2025-04-28 00:48:52 +0200 |
commit | 80d28b764c95891551e28c395783f5ff9d065743 (patch) | |
tree | f25898b1c939a939c63236ca4e8e843e81069947 /server | |
parent | 335ba978dbaf203f3603a815147fd75dbf205723 (diff) | |
download | jellything-80d28b764c95891551e28c395783f5ff9d065743.tar jellything-80d28b764c95891551e28c395783f5ff9d065743.tar.bz2 jellything-80d28b764c95891551e28c395783f5ff9d065743.tar.zst |
start with splitting server
Diffstat (limited to 'server')
-rw-r--r-- | server/Cargo.toml | 2 | ||||
-rw-r--r-- | server/src/helper/cache.rs | 56 | ||||
-rw-r--r-- | server/src/helper/mod.rs | 1 | ||||
-rw-r--r-- | server/src/ui/account/mod.rs | 8 | ||||
-rw-r--r-- | server/src/ui/admin/mod.rs | 18 | ||||
-rw-r--r-- | server/src/ui/admin/user.rs | 12 | ||||
-rw-r--r-- | server/src/ui/assets.rs | 7 | ||||
-rw-r--r-- | server/src/ui/error.rs | 2 | ||||
-rw-r--r-- | server/src/ui/home.rs | 148 | ||||
-rw-r--r-- | server/src/ui/layout.rs | 182 | ||||
-rw-r--r-- | server/src/ui/mod.rs | 73 | ||||
-rw-r--r-- | server/src/ui/node.rs | 430 | ||||
-rw-r--r-- | server/src/ui/player.rs | 113 | ||||
-rw-r--r-- | server/src/ui/search.rs | 20 | ||||
-rw-r--r-- | server/src/ui/sort.rs | 206 | ||||
-rw-r--r-- | server/src/ui/stats.rs | 45 |
16 files changed, 170 insertions, 1153 deletions
diff --git a/server/Cargo.toml b/server/Cargo.toml index 17aeeb4..669194a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -23,7 +23,6 @@ base64 = "0.22.1" chrono = { version = "0.4.39", features = ["serde"] } vte = "0.14.1" chashmap = "2.2.2" -humansize = "2.1.3" argon2 = "0.5.3" aes-gcm-siv = "0.11.1" @@ -33,7 +32,6 @@ futures = "0.3.31" tokio = { workspace = true } tokio-util = { version = "0.7.13", features = ["io", "io-util"] } -markup = "0.15.0" rocket = { workspace = true, features = ["secrets", "json"] } rocket_ws = { workspace = true } diff --git a/server/src/helper/cache.rs b/server/src/helper/cache.rs new file mode 100644 index 0000000..d4c0595 --- /dev/null +++ b/server/src/helper/cache.rs @@ -0,0 +1,56 @@ +/* + 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 log::debug; +use rocket::{ + http::{Header, Status}, + response::{self, Responder}, + Request, Response, +}; +use std::{ + hash::{DefaultHasher, Hash, Hasher}, + os::unix::fs::MetadataExt, + path::Path, +}; +use tokio::fs::File; + +pub struct CacheControlFile(File, String); +impl CacheControlFile { + pub async fn new_cachekey(p: &Path) -> anyhow::Result<Self> { + let tag = p.file_name().unwrap().to_str().unwrap().to_owned(); + let f = File::open(p).await?; + Ok(Self(f, tag)) + } + pub async fn new_mtime(f: File) -> Self { + let meta = f.metadata().await.unwrap(); + let modified = meta.mtime(); + let mut h = DefaultHasher::new(); + modified.hash(&mut h); + let tag = format!("{:0>16x}", h.finish()); + Self(f, tag) + } +} +impl<'r> Responder<'r, 'static> for CacheControlFile { + fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { + let Self(file, tag) = self; + if req.headers().get_one("if-none-match") == Some(&tag) { + debug!("file cache: not modified"); + Response::build() + .status(Status::NotModified) + .header(Header::new("cache-control", "private")) + .header(Header::new("etag", tag)) + .ok() + } else { + debug!("file cache: transfer"); + Response::build() + .status(Status::Ok) + .header(Header::new("cache-control", "private")) + .header(Header::new("etag", tag)) + .streamed_body(file) + .ok() + } + } +} diff --git a/server/src/helper/mod.rs b/server/src/helper/mod.rs index 946e8fa..856f6b7 100644 --- a/server/src/helper/mod.rs +++ b/server/src/helper/mod.rs @@ -4,3 +4,4 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ pub mod cors; +pub mod cache; diff --git a/server/src/ui/account/mod.rs b/server/src/ui/account/mod.rs index 312b40c..c1e5479 100644 --- a/server/src/ui/account/mod.rs +++ b/server/src/ui/account/mod.rs @@ -5,21 +5,17 @@ */ pub mod settings; -use super::{ - error::MyError, - layout::{trs, LayoutPage}, -}; +use super::error::MyError; use crate::{ database::Database, locale::AcceptLanguage, logic::session::{self, Session}, - ui::{error::MyResult, home::rocket_uri_macro_r_home, layout::DynLayoutPage}, + ui::{error::MyResult, home::rocket_uri_macro_r_home}, uri, }; use anyhow::anyhow; use argon2::{password_hash::Salt, Argon2, PasswordHasher}; use chrono::Duration; -use jellybase::{locale::tr, CONF}; use jellycommon::user::{User, UserPermission}; use rocket::{ form::{Contextual, Form}, diff --git a/server/src/ui/admin/mod.rs b/server/src/ui/admin/mod.rs index de06610..d380ae2 100644 --- a/server/src/ui/admin/mod.rs +++ b/server/src/ui/admin/mod.rs @@ -6,26 +6,18 @@ pub mod log; pub mod user; -use super::assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED}; -use crate::{ - database::Database, - logic::session::AdminSession, - ui::{ - admin::log::rocket_uri_macro_r_admin_log, - error::MyResult, - layout::{DynLayoutPage, FlashDisplay, LayoutPage}, - }, - uri, +use super::{ + assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED}, + error::MyResult, }; +use crate::{database::Database, logic::session::AdminSession}; use anyhow::{anyhow, Context}; use jellybase::{assetfed::AssetInner, federation::Federation, CONF}; -use jellyimport::{import_wrap, is_importing, IMPORT_ERRORS}; -use markup::DynRender; +use jellyimport::{import_wrap, IMPORT_ERRORS}; use rand::Rng; use rocket::{form::Form, get, post, FromForm, State}; use std::time::Instant; use tokio::{sync::Semaphore, task::spawn_blocking}; -use user::rocket_uri_macro_r_admin_users; #[get("/admin/dashboard")] pub async fn r_admin_dashboard( diff --git a/server/src/ui/admin/user.rs b/server/src/ui/admin/user.rs index c5239f7..818e416 100644 --- a/server/src/ui/admin/user.rs +++ b/server/src/ui/admin/user.rs @@ -3,17 +3,9 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::{ - database::Database, - logic::session::AdminSession, - ui::{ - error::MyResult, - layout::{DynLayoutPage, FlashDisplay, LayoutPage}, - }, - uri, -}; +use crate::{database::Database, logic::session::AdminSession, ui::error::MyResult, uri}; use anyhow::{anyhow, Context}; -use jellycommon::user::{PermissionSet, UserPermission}; +use jellycommon::user::UserPermission; use rocket::{form::Form, get, post, FromForm, FromFormField, State}; #[get("/admin/users")] diff --git a/server/src/ui/assets.rs b/server/src/ui/assets.rs index 69f6bbc..63d8525 100644 --- a/server/src/ui/assets.rs +++ b/server/src/ui/assets.rs @@ -3,8 +3,8 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use super::{error::MyResult, CacheControlFile}; -use crate::logic::session::Session; +use super::error::MyResult; +use crate::{helper::cache::CacheControlFile, logic::session::Session}; use anyhow::{anyhow, bail, Context}; use base64::Engine; use jellybase::{assetfed::AssetInner, database::Database, federation::Federation, CONF}; @@ -133,9 +133,6 @@ pub async fn r_person_asset( Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) } -// TODO this can create "federation recursion" because track selection cannot be relied on. -//? TODO is this still relevant? - #[get("/n/<id>/thumbnail?<t>&<width>")] pub async fn r_node_thumbnail( _session: Session, diff --git a/server/src/ui/error.rs b/server/src/ui/error.rs index c9620bb..6ba2ba9 100644 --- a/server/src/ui/error.rs +++ b/server/src/ui/error.rs @@ -3,8 +3,6 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use super::layout::{DynLayoutPage, LayoutPage}; -use crate::{ui::account::rocket_uri_macro_r_account_login, uri}; use jellybase::CONF; use log::info; use rocket::{ diff --git a/server/src/ui/home.rs b/server/src/ui/home.rs index fbce99b..96b1dc2 100644 --- a/server/src/ui/home.rs +++ b/server/src/ui/home.rs @@ -3,15 +3,10 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use super::{ - error::MyResult, - layout::{trs, DynLayoutPage, LayoutPage}, - node::{DatabaseNodeUserDataExt, NodeCard}, -}; +use super::{error::MyResult, node::DatabaseNodeUserDataExt}; use crate::{api::AcceptJson, database::Database, locale::AcceptLanguage, logic::session::Session}; use anyhow::Context; use chrono::{Datelike, Utc}; -use jellybase::{locale::tr, CONF}; use jellycommon::{api::ApiHomeResponse, user::WatchedState, NodeID, NodeKind, Rating, Visibility}; use rocket::{get, serde::json::Json, Either, State}; @@ -23,116 +18,7 @@ pub fn r_home( lang: AcceptLanguage, ) -> MyResult<Either<DynLayoutPage, Json<ApiHomeResponse>>> { let AcceptLanguage(lang) = lang; - let mut items = db.list_nodes_with_udata(&sess.user.name)?; - - let mut toplevel = db - .get_node_children(NodeID::from_slug("library")) - .context("root node missing")? - .into_iter() - .map(|n| db.get_node_with_userdata(n, &sess)) - .collect::<anyhow::Result<Vec<_>>>()?; - toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX)); - - let mut categories = Vec::<(String, Vec<_>)>::new(); - - categories.push(( - "home.bin.continue_watching".to_string(), - items - .iter() - .filter(|(_, u)| matches!(u.watched, WatchedState::Progress(_))) - .cloned() - .collect(), - )); - categories.push(( - "home.bin.watchlist".to_string(), - items - .iter() - .filter(|(_, u)| matches!(u.watched, WatchedState::Pending)) - .cloned() - .collect(), - )); - - items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible)); - - items.sort_by_key(|(n, _)| n.release_date.map(|d| -d).unwrap_or(i64::MAX)); - - categories.push(( - "home.bin.latest_video".to_string(), - items - .iter() - .filter(|(n, _)| matches!(n.kind, NodeKind::Video)) - .take(16) - .cloned() - .collect(), - )); - categories.push(( - "home.bin.latest_music".to_string(), - items - .iter() - .filter(|(n, _)| matches!(n.kind, NodeKind::Music)) - .take(16) - .cloned() - .collect(), - )); - categories.push(( - "home.bin.latest_short_form".to_string(), - items - .iter() - .filter(|(n, _)| matches!(n.kind, NodeKind::ShortFormVideo)) - .take(16) - .cloned() - .collect(), - )); - - items.sort_by_key(|(n, _)| { - n.ratings - .get(&Rating::Tmdb) - .map(|x| (*x * -1000.) as i32) - .unwrap_or(0) - }); - - categories.push(( - "home.bin.max_rating".to_string(), - items - .iter() - .take(16) - .filter(|(n, _)| n.ratings.contains_key(&Rating::Tmdb)) - .cloned() - .collect(), - )); - - items.retain(|(n, _)| { - matches!( - n.kind, - NodeKind::Video | NodeKind::Movie | NodeKind::Episode | NodeKind::Music - ) - }); - - categories.push(( - "home.bin.daily_random".to_string(), - (0..16) - .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) - .collect(), - )); - - { - let mut items = items.clone(); - items.retain(|(_, u)| matches!(u.watched, WatchedState::Watched)); - categories.push(( - "home.bin.watch_again".to_string(), - (0..16) - .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) - .collect(), - )); - } - - items.retain(|(n, _)| matches!(n.kind, NodeKind::Music)); - categories.push(( - "home.bin.daily_random_music".to_string(), - (0..16) - .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) - .collect(), - )); + Ok(if *aj { Either::Right(Json(ApiHomeResponse { @@ -140,34 +26,6 @@ pub fn r_home( categories, })) } else { - Either::Left(LayoutPage { - title: tr(lang, "home").to_string(), - content: markup::new! { - h2 { @tr(lang, "home.bin.root").replace("{title}", &CONF.brand) } - ul.children.hlist {@for (node, udata) in &toplevel { - li { @NodeCard { node, udata, lang: &lang } } - }} - @for (name, nodes) in &categories { - @if !nodes.is_empty() { - h2 { @trs(&lang, &name) } - ul.children.hlist {@for (node, udata) in nodes { - li { @NodeCard { node, udata, lang: &lang } } - }} - } - } - }, - ..Default::default() - }) + Either::Left() }) } - -fn cheap_daily_random(i: usize) -> usize { - xorshift(xorshift(Utc::now().num_days_from_ce() as u64) + i as u64) as usize -} - -fn xorshift(mut x: u64) -> u64 { - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - x -} diff --git a/server/src/ui/layout.rs b/server/src/ui/layout.rs deleted file mode 100644 index 0e8d7b9..0000000 --- a/server/src/ui/layout.rs +++ /dev/null @@ -1,182 +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 crate::{ - locale::lang_from_request, - logic::session::Session, - ui::{ - account::{ - rocket_uri_macro_r_account_login, rocket_uri_macro_r_account_logout, - rocket_uri_macro_r_account_register, settings::rocket_uri_macro_r_account_settings, - }, - admin::rocket_uri_macro_r_admin_dashboard, - browser::rocket_uri_macro_r_all_items, - node::rocket_uri_macro_r_library_node, - search::rocket_uri_macro_r_search, - stats::rocket_uri_macro_r_stats, - }, - uri, -}; -use futures::executor::block_on; -use jellybase::{ - locale::{tr, Language}, - CONF, -}; -use jellycommon::user::Theme; -use jellycommon::NodeID; -use jellyimport::is_importing; -use markup::{raw, DynRender, Render, RenderAttributeValue}; -use rocket::{ - http::ContentType, - response::{self, Responder}, - Request, Response, -}; -use std::{borrow::Cow, io::Cursor, sync::LazyLock}; - -static LOGO_ENABLED: LazyLock<bool> = LazyLock::new(|| CONF.asset_path.join("logo.svg").exists()); - -pub struct TrString<'a>(Cow<'a, str>); -impl Render for TrString<'_> { - fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { - self.0.as_str().render(writer) - } -} -impl RenderAttributeValue for TrString<'_> { - fn is_none(&self) -> bool { - false - } - fn is_true(&self) -> bool { - false - } - fn is_false(&self) -> bool { - false - } -} - -pub fn escape(str: &str) -> String { - let mut o = String::with_capacity(str.len()); - let mut last = 0; - for (index, byte) in str.bytes().enumerate() { - if let Some(esc) = match byte { - b'<' => Some("<"), - b'>' => Some(">"), - b'&' => Some("&"), - b'"' => Some("""), - _ => None, - } { - o += &str[last..index]; - o += esc; - last = index + 1; - } - } - o += &str[last..]; - o -} - -pub fn trs<'a>(lang: &Language, key: &str) -> TrString<'a> { - TrString(tr(*lang, key)) -} - -markup::define! { - Layout<'a, Main: Render>(title: String, main: Main, class: &'a str, session: Option<Session>, lang: Language) { - @markup::doctype() - html { - head { - title { @title " - " @CONF.brand } - meta[name="viewport", content="width=device-width, initial-scale=1.0"]; - link[rel="stylesheet", href="/assets/style.css"]; - script[src="/assets/bundle.js"] {} - } - body[class=class] { - nav { - h1 { a[href=if session.is_some() {"/home"} else {"/"}] { @if *LOGO_ENABLED { img.logo[src="/assets/logo.svg"]; } else { @CONF.brand } } } " " - @if let Some(_) = session { - a.library[href=uri!(r_library_node("library"))] { @trs(lang, "nav.root") } " " - a.library[href=uri!(r_all_items())] { @trs(lang, "nav.all") } " " - a.library[href=uri!(r_search(None::<&'static str>, None::<usize>))] { @trs(lang, "nav.search") } " " - a.library[href=uri!(r_stats())] { @trs(lang, "nav.stats") } " " - } - @if is_importing() { span.warn { "Library database is updating..." } } - div.account { - @if let Some(session) = session { - span { @raw(tr(*lang, "nav.username").replace("{name}", &format!("<b class=\"username\">{}</b>", escape(&session.user.display_name)))) } " " - @if session.user.admin { - a.admin.hybrid_button[href=uri!(r_admin_dashboard())] { p {@trs(lang, "nav.admin")} } " " - } - a.settings.hybrid_button[href=uri!(r_account_settings())] { p {@trs(lang, "nav.settings")} } " " - a.logout.hybrid_button[href=uri!(r_account_logout())] { p {@trs(lang, "nav.logout")} } - } else { - a.register.hybrid_button[href=uri!(r_account_register())] { p {@trs(lang, "nav.register")} } " " - a.login.hybrid_button[href=uri!(r_account_login())] { p {@trs(lang, "nav.login")} } - } - } - } - #main { @main } - footer { - p { @CONF.brand " - " @CONF.slogan " | powered by " a[href="https://codeberg.org/metamuffin/jellything"]{"Jellything"} } - } - } - } - } - - FlashDisplay(flash: Option<Result<String, String>>) { - @if let Some(flash) = &flash { - @match flash { - Ok(mesg) => { section.message { p.success { @mesg } } } - Err(err) => { section.message { p.error { @err } } } - } - } - } -} - -pub type DynLayoutPage<'a> = LayoutPage<markup::DynRender<'a>>; - -pub struct LayoutPage<T> { - pub title: String, - pub class: Option<&'static str>, - pub content: T, -} - -impl Default for LayoutPage<DynRender<'_>> { - fn default() -> Self { - Self { - class: None, - content: markup::new!(), - title: String::new(), - } - } -} - -impl<'r, Main: Render> Responder<'r, 'static> for LayoutPage<Main> { - fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { - // TODO blocking the event loop here. it seems like there is no other way to - // TODO offload this, since the guard references `req` which has a lifetime. - // TODO therefore we just block. that is fine since the database is somewhat fast. - let lang = lang_from_request(&req); - let session = block_on(req.guard::<Option<Session>>()).unwrap(); - let mut out = String::new(); - Layout { - main: self.content, - title: self.title, - class: &format!( - "{} theme-{:?}", - self.class.unwrap_or(""), - session - .as_ref() - .map(|s| s.user.theme) - .unwrap_or(Theme::Dark) - ), - session, - lang, - } - .render(&mut out) - .unwrap(); - - Response::build() - .header(ContentType::HTML) - .streamed_body(Cursor::new(out)) - .ok() - } -} diff --git a/server/src/ui/mod.rs b/server/src/ui/mod.rs index b98fbec..89c0e9a 100644 --- a/server/src/ui/mod.rs +++ b/server/src/ui/mod.rs @@ -7,9 +7,7 @@ use crate::logic::session::Session; use error::MyResult; use home::rocket_uri_macro_r_home; use jellybase::CONF; -use layout::{DynLayoutPage, LayoutPage}; use log::debug; -use markup::Render; use rocket::{ futures::FutureExt, get, @@ -37,7 +35,6 @@ pub mod assets; pub mod browser; pub mod error; pub mod home; -pub mod layout; pub mod node; pub mod player; pub mod search; @@ -45,6 +42,38 @@ pub mod sort; pub mod stats; pub mod style; +impl<'r, Main: Render> Responder<'r, 'static> for LayoutPage<Main> { + fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { + // TODO blocking the event loop here. it seems like there is no other way to + // TODO offload this, since the guard references `req` which has a lifetime. + // TODO therefore we just block. that is fine since the database is somewhat fast. + let lang = lang_from_request(&req); + let session = block_on(req.guard::<Option<Session>>()).unwrap(); + let mut out = String::new(); + Scaffold { + main: self.content, + title: self.title, + class: &format!( + "{} theme-{:?}", + self.class.unwrap_or(""), + session + .as_ref() + .map(|s| s.user.theme) + .unwrap_or(Theme::Dark) + ), + session, + lang, + } + .render(&mut out) + .unwrap(); + + Response::build() + .header(ContentType::HTML) + .streamed_body(Cursor::new(out)) + .ok() + } +} + #[get("/")] pub async fn r_index(sess: Option<Session>) -> MyResult<Either<Redirect, DynLayoutPage<'static>>> { if sess.is_some() { @@ -96,41 +125,3 @@ impl AsyncRead for Defer { } } } - -pub struct CacheControlFile(File, String); -impl CacheControlFile { - pub async fn new_cachekey(p: &Path) -> anyhow::Result<Self> { - let tag = p.file_name().unwrap().to_str().unwrap().to_owned(); - let f = File::open(p).await?; - Ok(Self(f, tag)) - } - pub async fn new_mtime(f: File) -> Self { - let meta = f.metadata().await.unwrap(); - let modified = meta.mtime(); - let mut h = DefaultHasher::new(); - modified.hash(&mut h); - let tag = format!("{:0>16x}", h.finish()); - Self(f, tag) - } -} -impl<'r> Responder<'r, 'static> for CacheControlFile { - fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { - let Self(file, tag) = self; - if req.headers().get_one("if-none-match") == Some(&tag) { - debug!("file cache: not modified"); - Response::build() - .status(Status::NotModified) - .header(Header::new("cache-control", "private")) - .header(Header::new("etag", tag)) - .ok() - } else { - debug!("file cache: transfer"); - Response::build() - .status(Status::Ok) - .header(Header::new("cache-control", "private")) - .header(Header::new("etag", tag)) - .streamed_body(file) - .ok() - } - } -} 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 <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 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/<id>")] @@ -145,327 +118,6 @@ pub fn get_similar_media( .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, @@ -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<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, - }) -} diff --git a/server/src/ui/player.rs b/server/src/ui/player.rs index cd4d03c..db2f665 100644 --- a/server/src/ui/player.rs +++ b/server/src/ui/player.rs @@ -3,25 +3,18 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use super::{ - layout::LayoutPage, - node::{get_similar_media, DatabaseNodeUserDataExt, NodePage}, - sort::NodeFilterSort, -}; +use super::sort::NodeFilterSort; use crate::{ database::Database, locale::AcceptLanguage, logic::session::{self, Session}, - ui::{error::MyResult, layout::DynLayoutPage}, }; -use anyhow::anyhow; use jellybase::CONF; use jellycommon::{ stream::{StreamContainer, StreamSpec}, user::{PermissionSet, PlayerKind}, - Node, NodeID, SourceTrackKind, TrackID, Visibility, + NodeID, TrackID, Visibility, }; -use markup::DynRender; use rocket::{get, response::Redirect, Either, FromForm, State, UriDisplayQuery}; use std::sync::Arc; @@ -140,59 +133,59 @@ pub fn r_player( })) } -pub fn player_conf<'a>(item: Arc<Node>, playing: bool) -> anyhow::Result<DynRender<'a>> { - let mut audio_tracks = vec![]; - let mut video_tracks = vec![]; - let mut sub_tracks = vec![]; - let tracks = item - .media - .clone() - .ok_or(anyhow!("node does not have media"))? - .tracks - .clone(); - for (tid, track) in tracks.into_iter().enumerate() { - match &track.kind { - SourceTrackKind::Audio { .. } => audio_tracks.push((tid, track)), - SourceTrackKind::Video { .. } => video_tracks.push((tid, track)), - SourceTrackKind::Subtitles => sub_tracks.push((tid, track)), - } - } +// pub fn player_conf<'a>(item: Arc<Node>, playing: bool) -> anyhow::Result<DynRender<'a>> { +// let mut audio_tracks = vec![]; +// let mut video_tracks = vec![]; +// let mut sub_tracks = vec![]; +// let tracks = item +// .media +// .clone() +// .ok_or(anyhow!("node does not have media"))? +// .tracks +// .clone(); +// for (tid, track) in tracks.into_iter().enumerate() { +// match &track.kind { +// SourceTrackKind::Audio { .. } => audio_tracks.push((tid, track)), +// SourceTrackKind::Video { .. } => video_tracks.push((tid, track)), +// SourceTrackKind::Subtitles => sub_tracks.push((tid, track)), +// } +// } - Ok(markup::new! { - form.playerconf[method = "GET", action = ""] { - h2 { "Select tracks for " @item.title } +// Ok(markup::new! { +// form.playerconf[method = "GET", action = ""] { +// h2 { "Select tracks for " @item.title } - fieldset.video { - legend { "Video" } - @for (i, (tid, track)) in video_tracks.iter().enumerate() { - input[type="radio", id=tid, name="v", value=tid, checked=i==0]; - label[for=tid] { @format!("{track}") } br; - } - input[type="radio", id="v-none", name="v", value=""]; - label[for="v-none"] { "No video" } - } +// fieldset.video { +// legend { "Video" } +// @for (i, (tid, track)) in video_tracks.iter().enumerate() { +// input[type="radio", id=tid, name="v", value=tid, checked=i==0]; +// label[for=tid] { @format!("{track}") } br; +// } +// input[type="radio", id="v-none", name="v", value=""]; +// label[for="v-none"] { "No video" } +// } - fieldset.audio { - legend { "Audio" } - @for (i, (tid, track)) in audio_tracks.iter().enumerate() { - input[type="radio", id=tid, name="a", value=tid, checked=i==0]; - label[for=tid] { @format!("{track}") } br; - } - input[type="radio", id="a-none", name="a", value=""]; - label[for="a-none"] { "No audio" } - } +// fieldset.audio { +// legend { "Audio" } +// @for (i, (tid, track)) in audio_tracks.iter().enumerate() { +// input[type="radio", id=tid, name="a", value=tid, checked=i==0]; +// label[for=tid] { @format!("{track}") } br; +// } +// input[type="radio", id="a-none", name="a", value=""]; +// label[for="a-none"] { "No audio" } +// } - fieldset.subtitles { - legend { "Subtitles" } - @for (_i, (tid, track)) in sub_tracks.iter().enumerate() { - input[type="radio", id=tid, name="s", value=tid]; - label[for=tid] { @format!("{track}") } br; - } - input[type="radio", id="s-none", name="s", value="", checked=true]; - label[for="s-none"] { "No subtitles" } - } +// fieldset.subtitles { +// legend { "Subtitles" } +// @for (_i, (tid, track)) in sub_tracks.iter().enumerate() { +// input[type="radio", id=tid, name="s", value=tid]; +// label[for=tid] { @format!("{track}") } br; +// } +// input[type="radio", id="s-none", name="s", value="", checked=true]; +// label[for="s-none"] { "No subtitles" } +// } - input[type="submit", value=if playing { "Change tracks" } else { "Start playback" }]; - } - }) -} +// input[type="submit", value=if playing { "Change tracks" } else { "Start playback" }]; +// } +// }) +// } diff --git a/server/src/ui/search.rs b/server/src/ui/search.rs index 96be3a6..bfe51a8 100644 --- a/server/src/ui/search.rs +++ b/server/src/ui/search.rs @@ -46,24 +46,6 @@ pub async fn r_search<'a>( }; Either::Right(Json(ApiSearchResponse { count, results })) } else { - Either::Left(LayoutPage { - title: tr(lang, "search.title").to_string(), - class: Some("search"), - content: markup::new! { - h1 { @trs(&lang, "search.title") } - form[action="", method="GET"] { - input[type="text", name="query", placeholder=&*tr(lang, "search.placeholder"), value=&query]; - input[type="submit", value="Search"]; - } - @if let Some((count, results, search_dur)) = &results { - h2 { @trs(&lang, "search.results.title") } - p.stats { @tr(lang, "search.results.stats").replace("{count}", &count.to_string()).replace("{dur}", &format!("{search_dur:?}")) } - ul.children {@for (node, udata) in results.iter() { - li { @NodeCard { node, udata, lang: &lang } } - }} - // TODO pagination - } - }, - }) + Either::Left() }) } diff --git a/server/src/ui/sort.rs b/server/src/ui/sort.rs index a241030..441bac6 100644 --- a/server/src/ui/sort.rs +++ b/server/src/ui/sort.rs @@ -3,149 +3,18 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ -use crate::ui::layout::trs; -use jellybase::locale::Language; -use jellycommon::{helpers::SortAnyway, user::NodeUserData, Node, NodeKind, Rating}; -use markup::RenderAttributeValue; +use jellycommon::{ + api::{FilterProperty, NodeFilterSort, SortOrder, SortProperty}, + helpers::SortAnyway, + user::NodeUserData, + Node, NodeKind, Rating, +}; use rocket::{ http::uri::fmt::{Query, UriDisplay}, FromForm, FromFormField, UriDisplayQuery, }; use std::sync::Arc; -#[derive(FromForm, UriDisplayQuery, Default, Clone)] -pub struct NodeFilterSort { - pub sort_by: Option<SortProperty>, - pub filter_kind: Option<Vec<FilterProperty>>, - pub sort_order: Option<SortOrder>, -} - -macro_rules! form_enum { - (enum $i:ident { $($vi:ident = $vk:literal),*, }) => { - #[derive(Debug, FromFormField, UriDisplayQuery, Clone, Copy, PartialEq, Eq)] - pub enum $i { $(#[field(value = $vk)] $vi),* } - impl $i { #[allow(unused)] const ALL: &'static [$i] = &[$($i::$vi),*]; } - }; -} - -form_enum!( - enum FilterProperty { - FederationLocal = "fed_local", - FederationRemote = "fed_remote", - Watched = "watched", - Unwatched = "unwatched", - WatchProgress = "watch_progress", - KindMovie = "kind_movie", - KindVideo = "kind_video", - KindShortFormVideo = "kind_short_form_video", - KindMusic = "kind_music", - KindCollection = "kind_collection", - KindChannel = "kind_channel", - KindShow = "kind_show", - KindSeries = "kind_series", - KindSeason = "kind_season", - KindEpisode = "kind_episode", - } -); - -form_enum!( - enum SortProperty { - ReleaseDate = "release_date", - Title = "title", - Index = "index", - Duration = "duration", - RatingRottenTomatoes = "rating_rt", - RatingMetacritic = "rating_mc", - RatingImdb = "rating_imdb", - RatingTmdb = "rating_tmdb", - RatingYoutubeViews = "rating_yt_views", - RatingYoutubeLikes = "rating_yt_likes", - RatingYoutubeFollowers = "rating_yt_followers", - RatingUser = "rating_user", - RatingLikesDivViews = "rating_loved", - } -); - -impl SortProperty { - const CATS: &'static [(&'static str, &'static [(SortProperty, &'static str)])] = { - use SortProperty::*; - &[ - ( - "filter_sort.sort.general", - &[(Title, "node.title"), (ReleaseDate, "node.release_date")], - ), - ("filter_sort.sort.media", &[(Duration, "media.runtime")]), - ( - "filter_sort.sort.rating", - &[ - (RatingImdb, "rating.imdb"), - (RatingTmdb, "rating.tmdb"), - (RatingMetacritic, "rating.metacritic"), - (RatingRottenTomatoes, "rating.rotten_tomatoes"), - (RatingYoutubeFollowers, "rating.youtube_followers"), - (RatingYoutubeLikes, "rating.youtube_likes"), - (RatingYoutubeViews, "rating.youtube_views"), - (RatingUser, "filter_sort.sort.rating.user"), - ( - RatingLikesDivViews, - "filter_sort.sort.rating.likes_div_views", - ), - ], - ), - ] - }; -} -impl FilterProperty { - const CATS: &'static [(&'static str, &'static [(FilterProperty, &'static str)])] = { - use FilterProperty::*; - &[ - ( - "filter_sort.filter.kind", - &[ - (KindMovie, "kind.movie"), - (KindVideo, "kind.video"), - (KindShortFormVideo, "kind.short_form_video"), - (KindMusic, "kind.music"), - (KindCollection, "kind.collection"), - (KindChannel, "kind.channel"), - (KindShow, "kind.show"), - (KindSeries, "kind.series"), - (KindSeason, "kind.season"), - (KindEpisode, "kind.episode"), - ], - ), - ( - "filter_sort.filter.federation", - &[ - (FederationLocal, "federation.local"), - (FederationRemote, "federation.remote"), - ], - ), - ( - "filter_sort.filter.watched", - &[ - (Watched, "watched.watched"), - (Unwatched, "watched.none"), - (WatchProgress, "watched.progress"), - ], - ), - ] - }; -} - -impl NodeFilterSort { - pub fn is_open(&self) -> bool { - self.filter_kind.is_some() || self.sort_by.is_some() - } -} - -#[rustfmt::skip] -#[derive(FromFormField, UriDisplayQuery, Clone, Copy, PartialEq, Eq)] -pub enum SortOrder { - #[field(value = "ascending")] Ascending, - #[field(value = "descending")] Descending, -} - pub fn filter_and_sort_nodes( f: &NodeFilterSort, default_sort: (SortProperty, SortOrder), @@ -232,66 +101,3 @@ pub fn filter_and_sort_nodes( SortOrder::Descending => nodes.reverse(), } } - -markup::define! { - NodeFilterSortForm<'a>(f: &'a NodeFilterSort, lang: &'a Language) { - details.filtersort[open=f.is_open()] { - summary { "Filter and Sort" } - form[method="GET", action=""] { - fieldset.filter { - legend { "Filter" } - .categories { - @for (cname, cat) in FilterProperty::CATS { - .category { - h3 { @trs(lang, cname) } - @for (value, label) in *cat { - label { input[type="checkbox", name="filter_kind", value=value, checked=f.filter_kind.as_ref().map(|k|k.contains(value)).unwrap_or(true)]; @trs(lang, label) } br; - } - } - } - } - } - fieldset.sortby { - legend { "Sort" } - .categories { - @for (cname, cat) in SortProperty::CATS { - .category { - h3 { @trs(lang, cname) } - @for (value, label) in *cat { - label { input[type="radio", name="sort_by", value=value, checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br; - } - } - } - } - } - fieldset.sortorder { - legend { "Sort Order" } - @use SortOrder::*; - @for (value, label) in [(Ascending, "filter_sort.order.asc"), (Descending, "filter_sort.order.desc")] { - label { input[type="radio", name="sort_order", value=value, checked=Some(value)==f.sort_order]; @trs(lang, label) } br; - } - } - input[type="submit", value="Apply"]; a[href="?"] { "Clear" } - } - } - } -} - -impl markup::Render for SortProperty { - fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { - writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) - } -} -impl markup::Render for SortOrder { - fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { - writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) - } -} -impl markup::Render for FilterProperty { - fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { - writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) - } -} -impl RenderAttributeValue for SortOrder {} -impl RenderAttributeValue for FilterProperty {} -impl RenderAttributeValue for SortProperty {} diff --git a/server/src/ui/stats.rs b/server/src/ui/stats.rs index 4c5bed8..345586a 100644 --- a/server/src/ui/stats.rs +++ b/server/src/ui/stats.rs @@ -83,49 +83,6 @@ pub fn r_stats( "kinds": kinds, }))) } else { - Either::Left(LayoutPage { - title: tr(lang, "stats.title").to_string(), - content: markup::new! { - .page.stats { - h1 { @trs(&lang, "stats.title") } - p { @raw(tr(lang, "stats.count") - .replace("{count}", &format!("<b>{}</b>", all.count)) - )} - p { @raw(tr(lang, "stats.runtime") - .replace("{dur}", &format!("<b>{}</b>", format_duration_long(all.runtime, lang))) - .replace("{size}", &format!("<b>{}</b>", format_size(all.size))) - )} - p { @raw(tr(lang, "stats.average") - .replace("{dur}", &format!("<b>{}</b>", format_duration(all.average_runtime()))) - .replace("{size}", &format!("<b>{}</b>", format_size(all.average_size() as u64))) - )} - - h2 { @trs(&lang, "stats.by_kind.title") } - table.striped { - tr { - th { @trs(&lang, "stats.by_kind.kind") } - th { @trs(&lang, "stats.by_kind.count") } - th { @trs(&lang, "stats.by_kind.total_size") } - th { @trs(&lang, "stats.by_kind.total_runtime") } - th { @trs(&lang, "stats.by_kind.average_size") } - th { @trs(&lang, "stats.by_kind.average_runtime") } - th { @trs(&lang, "stats.by_kind.max_size") } - th { @trs(&lang, "stats.by_kind.max_runtime") } - } - @for (k,b) in &kinds { tr { - td { @format_kind(*k, lang) } - td { @b.count } - td { @format_size(b.size) } - td { @format_duration(b.runtime) } - td { @format_size(b.average_size() as u64) } - td { @format_duration(b.average_runtime()) } - td { @if b.max_size.0 > 0 { a[href=uri!(r_library_node(&b.max_size.1))]{ @format_size(b.max_size.0) }}} - td { @if b.max_runtime.0 > 0. { a[href=uri!(r_library_node(&b.max_runtime.1))]{ @format_duration(b.max_runtime.0) }}} - }} - } - } - }, - ..Default::default() - }) + Either::Left() }) } |