aboutsummaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-04-28 00:48:52 +0200
committermetamuffin <metamuffin@disroot.org>2025-04-28 00:48:52 +0200
commit80d28b764c95891551e28c395783f5ff9d065743 (patch)
treef25898b1c939a939c63236ca4e8e843e81069947 /server
parent335ba978dbaf203f3603a815147fd75dbf205723 (diff)
downloadjellything-80d28b764c95891551e28c395783f5ff9d065743.tar
jellything-80d28b764c95891551e28c395783f5ff9d065743.tar.bz2
jellything-80d28b764c95891551e28c395783f5ff9d065743.tar.zst
start with splitting server
Diffstat (limited to 'server')
-rw-r--r--server/Cargo.toml2
-rw-r--r--server/src/helper/cache.rs56
-rw-r--r--server/src/helper/mod.rs1
-rw-r--r--server/src/ui/account/mod.rs8
-rw-r--r--server/src/ui/admin/mod.rs18
-rw-r--r--server/src/ui/admin/user.rs12
-rw-r--r--server/src/ui/assets.rs7
-rw-r--r--server/src/ui/error.rs2
-rw-r--r--server/src/ui/home.rs148
-rw-r--r--server/src/ui/layout.rs182
-rw-r--r--server/src/ui/mod.rs73
-rw-r--r--server/src/ui/node.rs430
-rw-r--r--server/src/ui/player.rs113
-rw-r--r--server/src/ui/search.rs20
-rw-r--r--server/src/ui/sort.rs206
-rw-r--r--server/src/ui/stats.rs45
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("&lt;"),
- b'>' => Some("&gt;"),
- b'&' => Some("&amp;"),
- b'"' => Some("&quot;"),
- _ => 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()
})
}