diff options
author | metamuffin <metamuffin@disroot.org> | 2025-04-27 19:25:11 +0200 |
---|---|---|
committer | metamuffin <metamuffin@disroot.org> | 2025-04-27 19:25:11 +0200 |
commit | 11a585b3dbe620dcc8772e713b22f1d9ba80d598 (patch) | |
tree | 44f8d97137412aefc79a2425a489c34fa3e5f6c5 /server/src/ui | |
parent | d871aa7c5bba49ff55170b5d2dac9cd440ae7170 (diff) | |
download | jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.bz2 jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.zst |
move files around
Diffstat (limited to 'server/src/ui')
-rw-r--r-- | server/src/ui/account/mod.rs | 256 | ||||
-rw-r--r-- | server/src/ui/account/settings.rs | 185 | ||||
-rw-r--r-- | server/src/ui/admin/log.rs | 258 | ||||
-rw-r--r-- | server/src/ui/admin/mod.rs | 288 | ||||
-rw-r--r-- | server/src/ui/admin/user.rs | 176 | ||||
-rw-r--r-- | server/src/ui/assets.rs | 201 | ||||
-rw-r--r-- | server/src/ui/browser.rs | 80 | ||||
-rw-r--r-- | server/src/ui/error.rs | 104 | ||||
-rw-r--r-- | server/src/ui/home.rs | 173 | ||||
-rw-r--r-- | server/src/ui/layout.rs | 182 | ||||
-rw-r--r-- | server/src/ui/mod.rs | 136 | ||||
-rw-r--r-- | server/src/ui/node.rs | 558 | ||||
-rw-r--r-- | server/src/ui/player.rs | 198 | ||||
-rw-r--r-- | server/src/ui/search.rs | 69 | ||||
-rw-r--r-- | server/src/ui/sort.rs | 297 | ||||
-rw-r--r-- | server/src/ui/stats.rs | 131 | ||||
-rw-r--r-- | server/src/ui/style.rs | 90 |
17 files changed, 3382 insertions, 0 deletions
diff --git a/server/src/ui/account/mod.rs b/server/src/ui/account/mod.rs new file mode 100644 index 0000000..312b40c --- /dev/null +++ b/server/src/ui/account/mod.rs @@ -0,0 +1,256 @@ +/* + 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> +*/ +pub mod settings; + +use super::{ + error::MyError, + layout::{trs, LayoutPage}, +}; +use crate::{ + database::Database, + locale::AcceptLanguage, + logic::session::{self, Session}, + ui::{error::MyResult, home::rocket_uri_macro_r_home, layout::DynLayoutPage}, + 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}, + get, + http::{Cookie, CookieJar}, + post, + response::Redirect, + FromForm, State, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +#[derive(FromForm)] +pub struct RegisterForm { + #[field(validate = len(8..128))] + pub invitation: String, + #[field(validate = len(4..32))] + pub username: String, + #[field(validate = len(4..64))] + pub password: String, +} + +#[get("/account/register")] +pub async fn r_account_register(lang: AcceptLanguage) -> DynLayoutPage<'static> { + let AcceptLanguage(lang) = lang; + LayoutPage { + title: tr(lang, "account.register").to_string(), + content: markup::new! { + form.account[method="POST", action=""] { + h1 { @trs(&lang, "account.register") } + + label[for="inp-invitation"] { @trs(&lang, "account.register.invitation") } + input[type="text", id="inp-invitation", name="invitation"]; br; + + label[for="inp-username"] { @trs(&lang, "account.username") } + input[type="text", id="inp-username", name="username"]; br; + label[for="inp-password"] { @trs(&lang, "account.password") } + input[type="password", id="inp-password", name="password"]; br; + + input[type="submit", value=&*tr(lang, "account.register.submit")]; + + p { @trs(&lang, "account.register.login") " " a[href=uri!(r_account_login())] { @trs(&lang, "account.register.login_here") } } + } + }, + ..Default::default() + } +} + +#[derive(FromForm, Serialize, Deserialize)] +pub struct LoginForm { + #[field(validate = len(4..32))] + pub username: String, + #[field(validate = len(..64))] + pub password: String, + #[field(default = 604800)] // one week + pub expire: u64, +} + +#[get("/account/login")] +pub fn r_account_login(sess: Option<Session>, lang: AcceptLanguage) -> DynLayoutPage<'static> { + let AcceptLanguage(lang) = lang; + let logged_in = sess.is_some(); + let title = tr( + lang, + if logged_in { + "account.login.switch" + } else { + "account.login" + }, + ); + LayoutPage { + title: title.to_string(), + content: markup::new! { + form.account[method="POST", action=""] { + h1 { @title.to_string() } + + label[for="inp-username"] { @trs(&lang, "account.username") } + input[type="text", id="inp-username", name="username"]; br; + label[for="inp-password"] { @trs(&lang, "account.password") } + input[type="password", id="inp-password", name="password"]; br; + + input[type="submit", value=&*tr(lang, if logged_in { "account.login.submit.switch" } else { "account.login.submit" })]; + + @if logged_in { + p { @trs(&lang, "account.login.register.switch") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } } + } else { + p { @trs(&lang, "account.login.cookie_note") } + p { @trs(&lang, "account.login.register") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } } + } + } + }, + ..Default::default() + } +} + +#[get("/account/logout")] +pub fn r_account_logout(lang: AcceptLanguage) -> DynLayoutPage<'static> { + let AcceptLanguage(lang) = lang; + LayoutPage { + title: tr(lang, "account.logout").to_string(), + content: markup::new! { + form.account[method="POST", action=""] { + h1 { @trs(&lang, "account.logout") } + input[type="submit", value=&*tr(lang, "account.logout.submit")]; + } + }, + ..Default::default() + } +} + +#[post("/account/register", data = "<form>")] +pub fn r_account_register_post<'a>( + database: &'a State<Database>, + _sess: Option<Session>, + form: Form<Contextual<'a, RegisterForm>>, + lang: AcceptLanguage, +) -> MyResult<DynLayoutPage<'a>> { + let AcceptLanguage(lang) = lang; + let logged_in = _sess.is_some(); + let form = match &form.value { + Some(v) => v, + None => return Err(format_form_error(form)), + }; + + database.register_user( + &form.invitation, + &form.username, + User { + display_name: form.username.clone(), + name: form.username.clone(), + password: hash_password(&form.username, &form.password), + ..Default::default() + }, + )?; + + Ok(LayoutPage { + title: tr(lang, "account.register.success.title").to_string(), + content: markup::new! { + h1 { @trs(&lang, if logged_in { + "account.register.success.switch" + } else { + "account.register.success" + })} + }, + ..Default::default() + }) +} + +#[post("/account/login", data = "<form>")] +pub fn r_account_login_post( + database: &State<Database>, + jar: &CookieJar, + form: Form<Contextual<LoginForm>>, +) -> MyResult<Redirect> { + let form = match &form.value { + Some(v) => v, + None => return Err(format_form_error(form)), + }; + jar.add( + Cookie::build(( + "session", + login_logic(database, &form.username, &form.password, None, None)?, + )) + .permanent() + .build(), + ); + + Ok(Redirect::found(rocket::uri!(r_home()))) +} + +#[post("/account/logout")] +pub fn r_account_logout_post(jar: &CookieJar) -> MyResult<Redirect> { + jar.remove_private(Cookie::build("session")); + Ok(Redirect::found(rocket::uri!(r_home()))) +} + +pub fn login_logic( + database: &Database, + username: &str, + password: &str, + expire: Option<i64>, + drop_permissions: Option<HashSet<UserPermission>>, +) -> MyResult<String> { + // hashing the password regardless if the accounts exists to better resist timing attacks + let password = hash_password(username, password); + + let mut user = database + .get_user(username)? + .ok_or(anyhow!("invalid password"))?; + + if user.password != password { + Err(anyhow!("invalid password"))? + } + + if let Some(ep) = drop_permissions { + // remove all grant perms that are in `ep` + user.permissions + .0 + .retain(|p, val| if *val { !ep.contains(p) } else { true }) + } + + Ok(session::create( + user.name, + user.permissions, + Duration::days(CONF.login_expire.min(expire.unwrap_or(i64::MAX))), + )) +} + +pub fn format_form_error<T>(form: Form<Contextual<T>>) -> MyError { + let mut k = String::from("form validation failed:"); + for e in form.context.errors() { + k += &format!( + "\n\t{}: {e}", + e.name + .as_ref() + .map(|e| e.to_string()) + .unwrap_or("<unknown>".to_string()) + ) + } + MyError(anyhow!(k)) +} + +pub fn hash_password(username: &str, password: &str) -> Vec<u8> { + Argon2::default() + .hash_password( + format!("{username}\0{password}").as_bytes(), + <&str as TryInto<Salt>>::try_into("IYMa13osbNeLJKnQ1T8LlA").unwrap(), + ) + .unwrap() + .hash + .unwrap() + .as_bytes() + .to_vec() +} diff --git a/server/src/ui/account/settings.rs b/server/src/ui/account/settings.rs new file mode 100644 index 0000000..4047e4f --- /dev/null +++ b/server/src/ui/account/settings.rs @@ -0,0 +1,185 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +use super::{format_form_error, hash_password}; +use crate::{ + database::Database, + locale::AcceptLanguage, + ui::{ + account::{rocket_uri_macro_r_account_login, session::Session}, + error::MyResult, + layout::{trs, DynLayoutPage, LayoutPage}, + }, + uri, +}; +use jellybase::{ + locale::{tr, Language}, + permission::PermissionSetExt, +}; +use jellycommon::user::{PlayerKind, Theme, UserPermission}; +use markup::{Render, RenderAttributeValue}; +use rocket::{ + form::{self, validate::len, Contextual, Form}, + get, + http::uri::fmt::{Query, UriDisplay}, + post, FromForm, State, +}; +use std::ops::Range; + +#[derive(FromForm)] +pub struct SettingsForm { + #[field(validate = option_len(4..64))] + password: Option<String>, + #[field(validate = option_len(4..32))] + display_name: Option<String>, + theme: Option<Theme>, + player_preference: Option<PlayerKind>, + native_secret: Option<String>, +} + +fn option_len<'v>(value: &Option<String>, range: Range<usize>) -> form::Result<'v, ()> { + value.as_ref().map(|v| len(v, range)).unwrap_or(Ok(())) +} + +fn settings_page( + session: Session, + flash: Option<MyResult<String>>, + lang: Language, +) -> DynLayoutPage<'static> { + LayoutPage { + title: "Settings".to_string(), + class: Some("settings"), + content: markup::new! { + h1 { "Settings" } + @if let Some(flash) = &flash { + @match flash { + Ok(mesg) => { section.message { p.success { @mesg } } } + Err(err) => { section.message { p.error { @format!("{err}") } } } + } + } + h2 { @trs(&lang, "account") } + a.switch_account[href=uri!(r_account_login())] { "Switch Account" } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="username"] { @trs(&lang, "account.username") } + input[type="text", id="username", disabled, value=&session.user.name]; + input[type="submit", disabled, value=&*tr(lang, "settings.immutable")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="display_name"] { @trs(&lang, "account.display_name") } + input[type="text", id="display_name", name="display_name", value=&session.user.display_name]; + input[type="submit", value=&*tr(lang, "settings.update")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="password"] { @trs(&lang, "account.password") } + input[type="password", id="password", name="password"]; + input[type="submit", value=&*tr(lang, "settings.update")]; + } + h2 { @trs(&lang, "settings.appearance") } + form[method="POST", action=uri!(r_account_settings_post())] { + fieldset { + legend { @trs(&lang, "settings.appearance.theme") } + @for (t, tlabel) in Theme::LIST { + label { input[type="radio", name="theme", value=A(*t), checked=session.user.theme==*t]; @tlabel } br; + } + } + input[type="submit", value=&*tr(lang, "settings.apply")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + fieldset { + legend { @trs(&lang, "settings.player_preference") } + @for (t, tlabel) in PlayerKind::LIST { + label { input[type="radio", name="player_preference", value=A(*t), checked=session.user.player_preference==*t]; @tlabel } br; + } + } + input[type="submit", value=&*tr(lang, "settings.apply")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="native_secret"] { "Native Secret" } + input[type="password", id="native_secret", name="native_secret"]; + input[type="submit", value=&*tr(lang, "settings.update")]; + p { "The secret can be found in " code{"$XDG_CONFIG_HOME/jellynative_secret"} " or by clicking " a.button[href="jellynative://show-secret-v1"] { "Show Secret" } "." } + } + }, + } +} + +struct A<T>(pub T); +impl<T: UriDisplay<Query>> RenderAttributeValue for A<T> {} +impl<T: UriDisplay<Query>> Render for A<T> { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_fmt(format_args!("{}", &self.0 as &dyn UriDisplay<Query>)) + } +} + +#[get("/account/settings")] +pub fn r_account_settings(session: Session, lang: AcceptLanguage) -> DynLayoutPage<'static> { + let AcceptLanguage(lang) = lang; + settings_page(session, None, lang) +} + +#[post("/account/settings", data = "<form>")] +pub fn r_account_settings_post( + session: Session, + database: &State<Database>, + form: Form<Contextual<SettingsForm>>, + lang: AcceptLanguage, +) -> MyResult<DynLayoutPage<'static>> { + let AcceptLanguage(lang) = lang; + session + .user + .permissions + .assert(&UserPermission::ManageSelf)?; + + let form = match &form.value { + Some(v) => v, + None => { + return Ok(settings_page( + session, + Some(Err(format_form_error(form))), + lang, + )) + } + }; + + let mut out = String::new(); + + database.update_user(&session.user.name, |user| { + if let Some(password) = &form.password { + user.password = hash_password(&session.user.name, password); + out += &*tr(lang, "settings.account.password.changed"); + out += "\n"; + } + if let Some(display_name) = &form.display_name { + user.display_name = display_name.clone(); + out += &*tr(lang, "settings.account.display_name.changed"); + out += "\n"; + } + if let Some(theme) = form.theme { + user.theme = theme; + out += &*tr(lang, "settings.account.theme.changed"); + out += "\n"; + } + if let Some(player_preference) = form.player_preference { + user.player_preference = player_preference; + out += &*tr(lang, "settings.player_preference.changed"); + out += "\n"; + } + if let Some(native_secret) = &form.native_secret { + user.native_secret = native_secret.to_owned(); + out += "Native secret updated.\n"; + } + Ok(()) + })?; + + Ok(settings_page( + session, // using the old session here, results in outdated theme being displayed + Some(Ok(if out.is_empty() { + tr(lang, "settings.no_change").to_string() + } else { + out + })), + lang, + )) +} diff --git a/server/src/ui/admin/log.rs b/server/src/ui/admin/log.rs new file mode 100644 index 0000000..dff6d1b --- /dev/null +++ b/server/src/ui/admin/log.rs @@ -0,0 +1,258 @@ +/* + 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::{ + logic::session::AdminSession, + ui::{ + error::MyResult, + layout::{DynLayoutPage, LayoutPage}, + }, + uri, +}; +use chrono::{DateTime, Utc}; +use log::Level; +use markup::Render; +use rocket::get; +use rocket_ws::{Message, Stream, WebSocket}; +use serde_json::json; +use std::{ + collections::VecDeque, + fmt::Write, + sync::{Arc, LazyLock, RwLock}, +}; +use tokio::sync::broadcast; + +const MAX_LOG_LEN: usize = 4096; + +static LOGGER: LazyLock<Log> = LazyLock::new(Log::default); + +pub fn enable_logging() { + log::set_logger(&*LOGGER).unwrap(); + log::set_max_level(log::LevelFilter::Debug); +} + +type LogBuffer = VecDeque<Arc<LogLine>>; + +pub struct Log { + inner: env_logger::Logger, + stream: ( + broadcast::Sender<Arc<LogLine>>, + broadcast::Sender<Arc<LogLine>>, + ), + log: RwLock<(LogBuffer, LogBuffer)>, +} + +pub struct LogLine { + time: DateTime<Utc>, + module: Option<&'static str>, + level: Level, + message: String, +} + +#[get("/admin/log?<warnonly>", rank = 2)] +pub fn r_admin_log<'a>(_session: AdminSession, warnonly: bool) -> MyResult<DynLayoutPage<'a>> { + Ok(LayoutPage { + title: "Log".into(), + class: Some("admin_log"), + content: markup::new! { + h1 { "Server Log" } + a[href=uri!(r_admin_log(!warnonly))] { @if warnonly { "Show everything" } else { "Show only warnings" }} + code.log[id="log"] { + @let g = LOGGER.log.read().unwrap(); + table { @for e in if warnonly { g.1.iter() } else { g.0.iter() } { + tr[class=format!("level-{:?}", e.level).to_ascii_lowercase()] { + td.time { @e.time.to_rfc3339() } + td.loglevel { @format_level(e.level) } + td.module { @e.module } + td { @markup::raw(vt100_to_html(&e.message)) } + } + }} + } + }, + }) +} + +#[get("/admin/log?stream&<warnonly>", rank = 1)] +pub fn r_admin_log_stream( + _session: AdminSession, + ws: WebSocket, + warnonly: bool, +) -> Stream!['static] { + let mut stream = if warnonly { + LOGGER.stream.1.subscribe() + } else { + LOGGER.stream.0.subscribe() + }; + Stream! { ws => + let _ = ws; + while let Ok(line) = stream.recv().await { + yield Message::Text(json!({ + "time": line.time, + "level_class": format!("level-{:?}", line.level).to_ascii_lowercase(), + "level_html": format_level_string(line.level), + "module": line.module, + "message": vt100_to_html(&line.message), + }).to_string()); + } + } +} + +impl Default for Log { + fn default() -> Self { + Self { + inner: env_logger::builder() + .filter_level(log::LevelFilter::Warn) + .parse_env("LOG") + .build(), + stream: ( + tokio::sync::broadcast::channel(1024).0, + tokio::sync::broadcast::channel(1024).0, + ), + log: Default::default(), + } + } +} +impl Log { + fn should_log(&self, metadata: &log::Metadata) -> bool { + let level = metadata.level(); + level + <= match metadata.target() { + x if x.starts_with("jelly") => Level::Debug, + x if x.starts_with("rocket::") => Level::Info, + _ => Level::Warn, + } + } + fn do_log(&self, record: &log::Record) { + let time = Utc::now(); + let line = Arc::new(LogLine { + time, + module: record.module_path_static(), + level: record.level(), + message: record.args().to_string(), + }); + let mut w = self.log.write().unwrap(); + w.0.push_back(line.clone()); + let _ = self.stream.0.send(line.clone()); + while w.0.len() > MAX_LOG_LEN { + w.0.pop_front(); + } + if record.level() <= Level::Warn { + let _ = self.stream.1.send(line.clone()); + w.1.push_back(line); + while w.1.len() > MAX_LOG_LEN { + w.1.pop_front(); + } + } + } +} + +impl log::Log for Log { + fn enabled(&self, metadata: &log::Metadata) -> bool { + self.inner.enabled(metadata) || self.should_log(metadata) + } + fn log(&self, record: &log::Record) { + match (record.module_path_static(), record.line()) { + // TODO is there a better way to ignore those? + (Some("rocket::rocket"), Some(670)) => return, + (Some("rocket::server"), Some(401)) => return, + _ => {} + } + if self.inner.enabled(record.metadata()) { + self.inner.log(record); + } + if self.should_log(record.metadata()) { + self.do_log(record) + } + } + fn flush(&self) { + self.inner.flush(); + } +} + +fn vt100_to_html(s: &str) -> String { + let mut out = HtmlOut::default(); + let mut st = vte::Parser::new(); + st.advance(&mut out, s.as_bytes()); + out.s +} + +fn format_level(level: Level) -> impl markup::Render { + let (s, c) = match level { + Level::Debug => ("DEBUG", "blue"), + Level::Error => ("ERROR", "red"), + Level::Warn => ("WARN", "yellow"), + Level::Info => ("INFO", "green"), + Level::Trace => ("TRACE", "lightblue"), + }; + markup::new! { span[style=format!("color:{c}")] {@s} } +} +fn format_level_string(level: Level) -> String { + let mut w = String::new(); + format_level(level).render(&mut w).unwrap(); + w +} + +#[derive(Default)] +pub struct HtmlOut { + s: String, + color: bool, +} +impl HtmlOut { + pub fn set_color(&mut self, [r, g, b]: [u8; 3]) { + self.reset_color(); + self.color = true; + write!(self.s, "<span style=color:#{:02x}{:02x}{:02x}>", r, g, b).unwrap() + } + pub fn reset_color(&mut self) { + if self.color { + write!(self.s, "</span>").unwrap(); + self.color = false; + } + } +} +impl vte::Perform for HtmlOut { + fn print(&mut self, c: char) { + match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | ' ' => self.s.push(c), + x => write!(self.s, "&#{};", x as u32).unwrap(), + } + } + fn execute(&mut self, _byte: u8) {} + fn hook(&mut self, _params: &vte::Params, _i: &[u8], _ignore: bool, _a: char) {} + fn put(&mut self, _byte: u8) {} + fn unhook(&mut self) {} + fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {} + fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} + fn csi_dispatch( + &mut self, + params: &vte::Params, + _intermediates: &[u8], + _ignore: bool, + action: char, + ) { + let mut k = params.iter(); + #[allow(clippy::single_match)] + match action { + 'm' => match k.next().unwrap_or(&[0]).first().unwrap_or(&0) { + c @ (30..=37 | 40..=47) => { + let c = if *c >= 40 { *c - 10 } else { *c }; + self.set_color(match c { + 30 => [0, 0, 0], + 31 => [255, 0, 0], + 32 => [0, 255, 0], + 33 => [255, 255, 0], + 34 => [0, 0, 255], + 35 => [255, 0, 255], + 36 => [0, 255, 255], + 37 => [255, 255, 255], + _ => unreachable!(), + }); + } + _ => (), + }, + _ => (), + } + } +} diff --git a/server/src/ui/admin/mod.rs b/server/src/ui/admin/mod.rs new file mode 100644 index 0000000..de06610 --- /dev/null +++ b/server/src/ui/admin/mod.rs @@ -0,0 +1,288 @@ +/* + 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> +*/ +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 anyhow::{anyhow, Context}; +use jellybase::{assetfed::AssetInner, federation::Federation, CONF}; +use jellyimport::{import_wrap, is_importing, IMPORT_ERRORS}; +use markup::DynRender; +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( + _session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + admin_dashboard(database, None).await +} + +pub async fn admin_dashboard<'a>( + database: &Database, + flash: Option<MyResult<String>>, +) -> MyResult<DynLayoutPage<'a>> { + let invites = database.list_invites()?; + let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); + + let last_import_err = IMPORT_ERRORS.read().await.to_owned(); + + let database = database.to_owned(); + Ok(LayoutPage { + title: "Admin Dashboard".to_string(), + content: markup::new! { + h1 { "Admin Panel" } + @FlashDisplay { flash: flash.clone() } + @if !last_import_err.is_empty() { + section.message.error { + details { + summary { p.error { @format!("The last import resulted in {} errors:", last_import_err.len()) } } + ol { @for e in &last_import_err { + li.error { pre.error { @e } } + }} + } + } + } + ul { + li{a[href=uri!(r_admin_log(true))] { "Server Log (Warnings only)" }} + li{a[href=uri!(r_admin_log(false))] { "Server Log (Full) " }} + } + h2 { "Library" } + @if is_importing() { + section.message { p.warn { "An import is currently running." } } + } + @if is_transcoding() { + section.message { p.warn { "Currently transcoding posters." } } + } + form[method="POST", action=uri!(r_admin_import(true))] { + input[type="submit", disabled=is_importing(), value="Start incremental import"]; + } + form[method="POST", action=uri!(r_admin_import(false))] { + input[type="submit", disabled=is_importing(), value="Start full import"]; + } + form[method="POST", action=uri!(r_admin_transcode_posters())] { + input[type="submit", disabled=is_transcoding(), value="Transcode all posters with low resolution"]; + } + form[method="POST", action=uri!(r_admin_update_search())] { + input[type="submit", value="Regenerate full-text search index"]; + } + form[method="POST", action=uri!(r_admin_delete_cache())] { + input.danger[type="submit", value="Delete Cache"]; + } + h2 { "Users" } + p { a[href=uri!(r_admin_users())] "Manage Users" } + h2 { "Invitations" } + form[method="POST", action=uri!(r_admin_invite())] { + input[type="submit", value="Generate new invite code"]; + } + ul { @for t in &invites { + li { + form[method="POST", action=uri!(r_admin_remove_invite())] { + span { @t } + input[type="text", name="invite", value=&t, hidden]; + input[type="submit", value="Invalidate"]; + } + } + }} + + h2 { "Database" } + @match db_stats(&database) { + Ok(s) => { @s } + Err(e) => { pre.error { @format!("{e:?}") } } + } + }, + ..Default::default() + }) +} + +#[post("/admin/generate_invite")] +pub async fn r_admin_invite( + _session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + let i = format!("{}", rand::rng().random::<u128>()); + database.create_invite(&i)?; + admin_dashboard(database, Some(Ok(format!("Invite: {}", i)))).await +} + +#[derive(FromForm)] +pub struct DeleteInvite { + invite: String, +} + +#[post("/admin/remove_invite", data = "<form>")] +pub async fn r_admin_remove_invite( + session: AdminSession, + database: &State<Database>, + form: Form<DeleteInvite>, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + if !database.delete_invite(&form.invite)? { + Err(anyhow!("invite does not exist"))?; + }; + admin_dashboard(database, Some(Ok("Invite invalidated".into()))).await +} + +#[post("/admin/import?<incremental>")] +pub async fn r_admin_import( + session: AdminSession, + database: &State<Database>, + _federation: &State<Federation>, + incremental: bool, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + let t = Instant::now(); + if !incremental { + database.clear_nodes()?; + } + let r = import_wrap((*database).clone(), incremental).await; + let flash = r + .map_err(|e| e.into()) + .map(|_| format!("Import successful; took {:?}", t.elapsed())); + admin_dashboard(database, Some(flash)).await +} + +#[post("/admin/update_search")] +pub async fn r_admin_update_search( + _session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + let db2 = (*database).clone(); + let r = spawn_blocking(move || db2.search_create_index()) + .await + .unwrap(); + admin_dashboard( + database, + Some( + r.map_err(|e| e.into()) + .map(|_| "Search index updated".to_string()), + ), + ) + .await +} + +#[post("/admin/delete_cache")] +pub async fn r_admin_delete_cache( + session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + let t = Instant::now(); + let r = tokio::fs::remove_dir_all(&CONF.cache_path).await; + tokio::fs::create_dir(&CONF.cache_path).await?; + admin_dashboard( + database, + Some( + r.map_err(|e| e.into()) + .map(|_| format!("Cache deleted; took {:?}", t.elapsed())), + ), + ) + .await +} + +static SEM_TRANSCODING: Semaphore = Semaphore::const_new(1); +fn is_transcoding() -> bool { + SEM_TRANSCODING.available_permits() == 0 +} + +#[post("/admin/transcode_posters")] +pub async fn r_admin_transcode_posters( + session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + let _permit = SEM_TRANSCODING + .try_acquire() + .context("transcoding in progress")?; + + let t = Instant::now(); + + { + let nodes = database.list_nodes_with_udata("")?; + for (node, _) in nodes { + if let Some(poster) = &node.poster { + let asset = AssetInner::deser(&poster.0)?; + if asset.is_federated() { + continue; + } + let source = resolve_asset(asset).await.context("resolving asset")?; + jellytranscoder::image::transcode(&source, AVIF_QUALITY, AVIF_SPEED, 1024) + .await + .context("transcoding asset")?; + } + } + } + drop(_permit); + + admin_dashboard( + database, + Some(Ok(format!( + "All posters pre-transcoded; took {:?}", + t.elapsed() + ))), + ) + .await +} + +fn db_stats(_db: &Database) -> anyhow::Result<DynRender> { + // TODO + // let txn = db.inner.begin_read()?; + // let stats = [ + // ("node", txn.open_table(T_NODE)?.stats()?), + // ("user", txn.open_table(T_USER_NODE)?.stats()?), + // ("user-node", txn.open_table(T_USER_NODE)?.stats()?), + // ("invite", txn.open_table(T_INVITE)?.stats()?), + // ]; + + // let cache_stats = db.node_index.reader.searcher().doc_store_cache_stats(); + // let ft_total_docs = db.node_index.reader.searcher().total_num_docs()?; + + Ok(markup::new! { + // h3 { "Key-Value-Store Statistics" } + // table.border { + // tbody { + // tr { + // th { "table name" } + // th { "tree height" } + // th { "stored bytes" } + // th { "metadata bytes" } + // th { "fragmented bytes" } + // th { "branch pages" } + // th { "leaf pages" } + // } + // @for (name, stats) in &stats { tr { + // td { @name } + // td { @stats.tree_height() } + // td { @format_size(stats.stored_bytes(), DECIMAL) } + // td { @format_size(stats.metadata_bytes(), DECIMAL) } + // td { @format_size(stats.fragmented_bytes(), DECIMAL) } + // td { @stats.branch_pages() } + // td { @stats.leaf_pages() } + // }} + // } + // } + // h3 { "Search Engine Statistics" } + // ul { + // li { "Total documents: " @ft_total_docs } + // li { "Cache misses: " @cache_stats.cache_misses } + // li { "Cache hits: " @cache_stats.cache_hits } + // } + }) +} diff --git a/server/src/ui/admin/user.rs b/server/src/ui/admin/user.rs new file mode 100644 index 0000000..c5239f7 --- /dev/null +++ b/server/src/ui/admin/user.rs @@ -0,0 +1,176 @@ +/* + 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::{ + database::Database, + logic::session::AdminSession, + ui::{ + error::MyResult, + layout::{DynLayoutPage, FlashDisplay, LayoutPage}, + }, + uri, +}; +use anyhow::{anyhow, Context}; +use jellycommon::user::{PermissionSet, UserPermission}; +use rocket::{form::Form, get, post, FromForm, FromFormField, State}; + +#[get("/admin/users")] +pub fn r_admin_users( + _session: AdminSession, + database: &State<Database>, +) -> MyResult<DynLayoutPage<'static>> { + user_management(database, None) +} + +fn user_management<'a>( + database: &Database, + flash: Option<MyResult<String>>, +) -> MyResult<DynLayoutPage<'a>> { + // TODO this doesnt scale, pagination! + let users = database.list_users()?; + let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); + + Ok(LayoutPage { + title: "User management".to_string(), + content: markup::new! { + h1 { "User Management" } + @FlashDisplay { flash: flash.clone() } + h2 { "All Users" } + ul { @for u in &users { + li { + a[href=uri!(r_admin_user(&u.name))] { @format!("{:?}", u.display_name) " (" @u.name ")" } + } + }} + }, + ..Default::default() + }) +} + +#[get("/admin/user/<name>")] +pub fn r_admin_user<'a>( + _session: AdminSession, + database: &State<Database>, + name: &'a str, +) -> MyResult<DynLayoutPage<'a>> { + manage_single_user(database, None, name.to_string()) +} + +fn manage_single_user<'a>( + database: &Database, + flash: Option<MyResult<String>>, + name: String, +) -> MyResult<DynLayoutPage<'a>> { + let user = database + .get_user(&name)? + .ok_or(anyhow!("user does not exist"))?; + let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); + + Ok(LayoutPage { + title: "User management".to_string(), + content: markup::new! { + h1 { @format!("{:?}", user.display_name) " (" @user.name ")" } + a[href=uri!(r_admin_users())] "Back to the User List" + @FlashDisplay { flash: flash.clone() } + form[method="POST", action=uri!(r_admin_remove_user())] { + input[type="text", name="name", value=&user.name, hidden]; + input.danger[type="submit", value="Remove user(!)"]; + } + + h2 { "Permissions" } + @PermissionDisplay { perms: &user.permissions } + + form[method="POST", action=uri!(r_admin_user_permission())] { + input[type="text", name="name", value=&user.name, hidden]; + fieldset.perms { + legend { "Permission" } + @for p in UserPermission::ALL_ENUMERABLE { + label { + input[type="radio", name="permission", value=serde_json::to_string(p).unwrap()]; + @format!("{p}") + } br; + } + } + fieldset.perms { + legend { "Permission" } + label { input[type="radio", name="action", value="unset"]; "Unset" } br; + label { input[type="radio", name="action", value="grant"]; "Grant" } br; + label { input[type="radio", name="action", value="revoke"]; "Revoke" } br; + } + input[type="submit", value="Update"]; + } + + }, + ..Default::default() + }) +} + +markup::define! { + PermissionDisplay<'a>(perms: &'a PermissionSet) { + ul { @for (perm,grant) in &perms.0 { + @if *grant { + li[class="perm-grant"] { @format!("Allow {}", perm) } + } else { + li[class="perm-revoke"] { @format!("Deny {}", perm) } + } + }} + } +} + +#[derive(FromForm)] +pub struct DeleteUser { + name: String, +} +#[derive(FromForm)] +pub struct UserPermissionForm { + name: String, + permission: String, + action: GrantState, +} + +#[derive(FromFormField)] +pub enum GrantState { + Grant, + Revoke, + Unset, +} + +#[post("/admin/update_user_permission", data = "<form>")] +pub fn r_admin_user_permission( + session: AdminSession, + database: &State<Database>, + form: Form<UserPermissionForm>, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + let perm = serde_json::from_str::<UserPermission>(&form.permission) + .context("parsing provided permission")?; + + database.update_user(&form.name, |user| { + match form.action { + GrantState::Grant => drop(user.permissions.0.insert(perm.clone(), true)), + GrantState::Revoke => drop(user.permissions.0.insert(perm.clone(), false)), + GrantState::Unset => drop(user.permissions.0.remove(&perm)), + } + Ok(()) + })?; + + manage_single_user( + database, + Some(Ok("Permissions update".into())), + form.name.clone(), + ) +} + +#[post("/admin/remove_user", data = "<form>")] +pub fn r_admin_remove_user( + session: AdminSession, + database: &State<Database>, + form: Form<DeleteUser>, +) -> MyResult<DynLayoutPage<'static>> { + drop(session); + if !database.delete_user(&form.name)? { + Err(anyhow!("user did not exist"))?; + } + user_management(database, Some(Ok("User removed".into()))) +} diff --git a/server/src/ui/assets.rs b/server/src/ui/assets.rs new file mode 100644 index 0000000..ce2a8e2 --- /dev/null +++ b/server/src/ui/assets.rs @@ -0,0 +1,201 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +use super::{error::MyResult, CacheControlFile}; +use crate::logic::session::Session; +use anyhow::{anyhow, bail, Context}; +use base64::Engine; +use jellybase::{ + assetfed::AssetInner, cache::async_cache_file, database::Database, federation::Federation, CONF, +}; +use jellycommon::{LocalTrack, NodeID, PeopleGroup, SourceTrackKind, TrackSource}; +use log::info; +use rocket::{get, http::ContentType, response::Redirect, State}; +use std::{path::PathBuf, str::FromStr}; + +pub const AVIF_QUALITY: f32 = 50.; +pub const AVIF_SPEED: u8 = 5; + +#[get("/asset/<token>?<width>")] +pub async fn r_asset( + _session: Session, + fed: &State<Federation>, + token: &str, + width: Option<usize>, +) -> MyResult<(ContentType, CacheControlFile)> { + let width = width.unwrap_or(2048); + let asset = AssetInner::deser(token)?; + + let path = if let AssetInner::Federated { host, asset } = asset { + let session = fed.get_session(&host).await?; + + let asset = base64::engine::general_purpose::URL_SAFE.encode(asset); + async_cache_file("fed-asset", &asset, |out| async { + session.asset(out, &asset, width).await + }) + .await? + } else { + let source = resolve_asset(asset).await.context("resolving asset")?; + + // fit the resolution into a finite set so the maximum cache is finite too. + let width = 2usize.pow(width.clamp(128, 2048).ilog2()); + jellytranscoder::image::transcode(&source, AVIF_QUALITY, AVIF_SPEED, width) + .await + .context("transcoding asset")? + }; + info!("loading asset from {path:?}"); + Ok(( + ContentType::AVIF, + CacheControlFile::new_cachekey(&path.abs()).await?, + )) +} + +pub async fn resolve_asset(asset: AssetInner) -> anyhow::Result<PathBuf> { + match asset { + AssetInner::Cache(c) => Ok(c.abs()), + AssetInner::Assets(c) => Ok(CONF.asset_path.join(c)), + AssetInner::Media(c) => Ok(CONF.media_path.join(c)), + _ => bail!("wrong asset type"), + } +} + +#[get("/n/<id>/poster?<width>")] +pub async fn r_item_poster( + _session: Session, + db: &State<Database>, + id: NodeID, + width: Option<usize>, +) -> MyResult<Redirect> { + // TODO perm + let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; + + let mut asset = node.poster.clone(); + if asset.is_none() { + if let Some(parent) = node.parents.last().copied() { + let parent = db.get_node(parent)?.ok_or(anyhow!("node does not exist"))?; + asset = parent.poster.clone(); + } + }; + let asset = asset.unwrap_or_else(|| { + AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser() + }); + Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) +} + +#[get("/n/<id>/backdrop?<width>")] +pub async fn r_item_backdrop( + _session: Session, + db: &State<Database>, + id: NodeID, + width: Option<usize>, +) -> MyResult<Redirect> { + // TODO perm + let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; + + let mut asset = node.backdrop.clone(); + if asset.is_none() { + if let Some(parent) = node.parents.last().copied() { + let parent = db.get_node(parent)?.ok_or(anyhow!("node does not exist"))?; + asset = parent.backdrop.clone(); + } + }; + let asset = asset.unwrap_or_else(|| { + AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser() + }); + Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) +} + +#[get("/n/<id>/person/<index>/asset?<group>&<width>")] +pub async fn r_person_asset( + _session: Session, + db: &State<Database>, + id: NodeID, + index: usize, + group: String, + width: Option<usize>, +) -> MyResult<Redirect> { + // TODO perm + + let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; + let app = node + .people + .get(&PeopleGroup::from_str(&group).map_err(|()| anyhow!("unknown people group"))?) + .ok_or(anyhow!("group has no members"))? + .get(index) + .ok_or(anyhow!("person does not exist"))?; + + let asset = app + .person + .headshot + .to_owned() + .unwrap_or(AssetInner::Assets("fallback-Person.avif".into()).ser()); + 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, + db: &State<Database>, + fed: &State<Federation>, + id: NodeID, + t: f64, + width: Option<usize>, +) -> MyResult<Redirect> { + let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; + + let media = node.media.as_ref().ok_or(anyhow!("no media"))?; + let (thumb_track_index, thumb_track) = media + .tracks + .iter() + .enumerate() + .find(|(_i, t)| matches!(t.kind, SourceTrackKind::Video { .. })) + .ok_or(anyhow!("no video track to create a thumbnail of"))?; + let source = media + .tracks + .get(thumb_track_index) + .ok_or(anyhow!("no source"))?; + let thumb_track_source = source.source.clone(); + + if t < 0. || t > media.duration { + Err(anyhow!("thumbnail instant not within media duration"))? + } + + let step = 8.; + let t = (t / step).floor() * step; + + let asset = match thumb_track_source { + TrackSource::Local(a) => { + let AssetInner::LocalTrack(LocalTrack { path, .. }) = AssetInner::deser(&a.0)? else { + return Err(anyhow!("track set to wrong asset type").into()); + }; + // the track selected might be different from thumb_track + jellytranscoder::thumbnail::create_thumbnail(&CONF.media_path.join(path), t).await? + } + TrackSource::Remote(_) => { + // TODO in the new system this is preferrably a property of node ext for regular fed + let session = fed + .get_session( + thumb_track + .federated + .last() + .ok_or(anyhow!("federation broken"))?, + ) + .await?; + + async_cache_file("fed-thumb", (id, t as i64), |out| { + session.node_thumbnail(out, id.into(), 2048, t) + }) + .await? + } + }; + + Ok(Redirect::temporary(rocket::uri!(r_asset( + AssetInner::Cache(asset).ser().0, + width + )))) +} diff --git a/server/src/ui/browser.rs b/server/src/ui/browser.rs new file mode 100644 index 0000000..f7eac93 --- /dev/null +++ b/server/src/ui/browser.rs @@ -0,0 +1,80 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +use super::{ + error::MyError, + layout::{trs, DynLayoutPage, LayoutPage}, + node::NodeCard, + sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty}, +}; +use crate::{ + api::AcceptJson, database::Database, locale::AcceptLanguage, logic::session::Session, uri, +}; +use jellybase::locale::tr; +use jellycommon::{api::ApiItemsResponse, Visibility}; +use rocket::{get, serde::json::Json, Either, State}; + +/// This function is a stub and only useful for use in the uri! macro. +#[get("/items")] +pub fn r_all_items() {} + +#[get("/items?<page>&<filter..>")] +pub fn r_all_items_filter( + sess: Session, + db: &State<Database>, + aj: AcceptJson, + page: Option<usize>, + filter: NodeFilterSort, + lang: AcceptLanguage, +) -> Result<Either<DynLayoutPage<'_>, Json<ApiItemsResponse>>, MyError> { + let AcceptLanguage(lang) = lang; + let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?; + + items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible)); + + filter_and_sort_nodes( + &filter, + (SortProperty::Title, SortOrder::Ascending), + &mut items, + ); + + let page_size = 100; + let page = page.unwrap_or(0); + let offset = page * page_size; + let from = offset.min(items.len()); + let to = (offset + page_size).min(items.len()); + let max_page = items.len().div_ceil(page_size); + + Ok(if *aj { + Either::Right(Json(ApiItemsResponse { + count: items.len(), + pages: max_page, + items: items[from..to].to_vec(), + })) + } else { + Either::Left(LayoutPage { + title: "All Items".to_owned(), + content: markup::new! { + .page.dir { + h1 { "All Items" } + @NodeFilterSortForm { f: &filter, lang: &lang } + ul.children { @for (node, udata) in &items[from..to] { + li {@NodeCard { node, udata, lang: &lang }} + }} + p.pagecontrols { + span.current { @tr(lang, "page.curr").replace("{cur}", &(page + 1).to_string()).replace("{max}", &max_page.to_string()) " " } + @if page > 0 { + a.prev[href=uri!(r_all_items_filter(Some(page - 1), filter.clone()))] { @trs(&lang, "page.prev") } " " + } + @if page + 1 < max_page { + a.next[href=uri!(r_all_items_filter(Some(page + 1), filter.clone()))] { @trs(&lang, "page.next") } + } + } + } + }, + ..Default::default() + }) + }) +} diff --git a/server/src/ui/error.rs b/server/src/ui/error.rs new file mode 100644 index 0000000..c9620bb --- /dev/null +++ b/server/src/ui/error.rs @@ -0,0 +1,104 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +use super::layout::{DynLayoutPage, LayoutPage}; +use crate::{ui::account::rocket_uri_macro_r_account_login, uri}; +use jellybase::CONF; +use log::info; +use rocket::{ + catch, + http::{ContentType, Status}, + response::{self, Responder}, + Request, +}; +use serde_json::{json, Value}; +use std::{fmt::Display, fs::File, io::Read, sync::LazyLock}; + +static ERROR_IMAGE: LazyLock<Vec<u8>> = LazyLock::new(|| { + info!("loading error image"); + let mut f = File::open(CONF.asset_path.join("error.avif")) + .expect("please create error.avif in the asset dir"); + let mut o = Vec::new(); + f.read_to_end(&mut o).unwrap(); + o +}); + +#[catch(default)] +pub fn r_catch<'a>(status: Status, _request: &Request) -> DynLayoutPage<'a> { + LayoutPage { + title: "Not found".to_string(), + content: markup::new! { + h2 { "Error" } + p { @format!("{status}") } + @if status == Status::NotFound { + p { "You might need to " a[href=uri!(r_account_login())] { "log in" } ", to see this page" } + } + }, + ..Default::default() + } +} + +#[catch(default)] +pub fn r_api_catch(status: Status, _request: &Request) -> Value { + json!({ "error": format!("{status}") }) +} + +pub type MyResult<T> = Result<T, MyError>; + +// TODO an actual error enum would be useful for status codes + +pub struct MyError(pub anyhow::Error); + +impl<'r> Responder<'r, 'static> for MyError { + fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { + match req.accept().map(|a| a.preferred()) { + Some(x) if x.is_json() => json!({ "error": format!("{}", self.0) }).respond_to(req), + Some(x) if x.is_avif() || x.is_png() || x.is_jpeg() => { + (ContentType::AVIF, ERROR_IMAGE.as_slice()).respond_to(req) + } + _ => LayoutPage { + title: "Error".to_string(), + content: markup::new! { + h2 { "An error occured. Nobody is sorry"} + pre.error { @format!("{:?}", self.0) } + }, + ..Default::default() + } + .respond_to(req), + } + } +} + +impl std::fmt::Debug for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{:?}", self.0)) + } +} + +impl Display for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} +impl From<anyhow::Error> for MyError { + fn from(err: anyhow::Error) -> MyError { + MyError(err) + } +} +impl From<std::fmt::Error> for MyError { + fn from(err: std::fmt::Error) -> MyError { + MyError(anyhow::anyhow!("{err}")) + } +} +impl From<std::io::Error> for MyError { + fn from(err: std::io::Error) -> Self { + MyError(anyhow::anyhow!("{err}")) + } +} +impl From<serde_json::Error> for MyError { + fn from(err: serde_json::Error) -> Self { + MyError(anyhow::anyhow!("{err}")) + } +} diff --git a/server/src/ui/home.rs b/server/src/ui/home.rs new file mode 100644 index 0000000..fbce99b --- /dev/null +++ b/server/src/ui/home.rs @@ -0,0 +1,173 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +use super::{ + error::MyResult, + layout::{trs, DynLayoutPage, LayoutPage}, + node::{DatabaseNodeUserDataExt, NodeCard}, +}; +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}; + +#[get("/home")] +pub fn r_home( + sess: Session, + db: &State<Database>, + aj: AcceptJson, + 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 { + toplevel, + 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() + }) + }) +} + +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 new file mode 100644 index 0000000..0e8d7b9 --- /dev/null +++ b/server/src/ui/layout.rs @@ -0,0 +1,182 @@ +/* + 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 new file mode 100644 index 0000000..b98fbec --- /dev/null +++ b/server/src/ui/mod.rs @@ -0,0 +1,136 @@ +/* + 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::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, + http::{ContentType, Header, Status}, + response::{self, Redirect, Responder}, + Either, Request, Response, +}; +use std::{ + collections::hash_map::DefaultHasher, + future::Future, + hash::{Hash, Hasher}, + io::Cursor, + os::unix::prelude::MetadataExt, + path::Path, + pin::Pin, +}; +use tokio::{ + fs::{read_to_string, File}, + io::AsyncRead, +}; + +pub mod account; +pub mod admin; +pub mod assets; +pub mod browser; +pub mod error; +pub mod home; +pub mod layout; +pub mod node; +pub mod player; +pub mod search; +pub mod sort; +pub mod stats; +pub mod style; + +#[get("/")] +pub async fn r_index(sess: Option<Session>) -> MyResult<Either<Redirect, DynLayoutPage<'static>>> { + if sess.is_some() { + Ok(Either::Left(Redirect::temporary(rocket::uri!(r_home())))) + } else { + let front = read_to_string(CONF.asset_path.join("front.htm")).await?; + Ok(Either::Right(LayoutPage { + title: "Home".to_string(), + content: markup::new! { + @markup::raw(&front) + }, + ..Default::default() + })) + } +} + +#[get("/favicon.ico")] +pub async fn r_favicon() -> MyResult<File> { + Ok(File::open(CONF.asset_path.join("favicon.ico")).await?) +} + +pub struct HtmlTemplate<'a>(pub markup::DynRender<'a>); + +impl<'r> Responder<'r, 'static> for HtmlTemplate<'_> { + fn respond_to(self, _req: &'r Request<'_>) -> response::Result<'static> { + let mut out = String::new(); + self.0.render(&mut out).unwrap(); + Response::build() + .header(ContentType::HTML) + .sized_body(out.len(), Cursor::new(out)) + .ok() + } +} + +pub struct Defer(Pin<Box<dyn Future<Output = String> + Send>>); + +impl AsyncRead for Defer { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll<std::io::Result<()>> { + match self.0.poll_unpin(cx) { + std::task::Poll::Ready(r) => { + buf.put_slice(r.as_bytes()); + std::task::Poll::Ready(Ok(())) + } + std::task::Poll::Pending => std::task::Poll::Pending, + } + } +} + +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 new file mode 100644 index 0000000..bf65a3e --- /dev/null +++ b/server/src/ui/node.rs @@ -0,0 +1,558 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +use super::{ + assets::{ + rocket_uri_macro_r_item_backdrop, rocket_uri_macro_r_item_poster, + rocket_uri_macro_r_node_thumbnail, + }, + error::MyResult, + layout::{trs, TrString}, + sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty}, +}; +use crate::{ + api::AcceptJson, + database::Database, + locale::AcceptLanguage, + logic::{ + session::Session, + userdata::{ + rocket_uri_macro_r_node_userdata_rating, rocket_uri_macro_r_node_userdata_watched, + UrlWatchedState, + }, + }, + ui::{ + assets::rocket_uri_macro_r_person_asset, + layout::{DynLayoutPage, LayoutPage}, + player::{rocket_uri_macro_r_player, PlayerConfig}, + }, + uri, +}; +use anyhow::{anyhow, Result}; +use chrono::DateTime; +use jellybase::locale::{tr, Language}; +use jellycommon::{ + api::ApiNodeResponse, + user::{NodeUserData, WatchedState}, + Chapter, MediaInfo, Node, NodeID, NodeKind, PeopleGroup, Rating, SourceTrackKind, Visibility, +}; +use rocket::{get, serde::json::Json, Either, State}; +use std::{cmp::Reverse, collections::BTreeMap, fmt::Write, sync::Arc}; + +/// This function is a stub and only useful for use in the uri! macro. +#[get("/n/<id>")] +pub fn r_library_node(id: NodeID) { + let _ = id; +} + +#[get("/n/<id>?<parents>&<children>&<filter..>")] +pub async fn r_library_node_filter<'a>( + session: Session, + id: NodeID, + db: &'a State<Database>, + aj: AcceptJson, + filter: NodeFilterSort, + lang: AcceptLanguage, + parents: bool, + children: bool, +) -> MyResult<Either<DynLayoutPage<'a>, Json<ApiNodeResponse>>> { + let AcceptLanguage(lang) = lang; + let (node, udata) = db.get_node_with_userdata(id, &session)?; + + let mut children = if !*aj || children { + db.get_node_children(id)? + .into_iter() + .map(|c| db.get_node_with_userdata(c, &session)) + .collect::<anyhow::Result<Vec<_>>>()? + } else { + Vec::new() + }; + + let mut parents = if !*aj || parents { + node.parents + .iter() + .map(|pid| db.get_node_with_userdata(*pid, &session)) + .collect::<anyhow::Result<Vec<_>>>()? + } else { + Vec::new() + }; + + let mut similar = get_similar_media(&node, db, &session)?; + + similar.retain(|(n, _)| n.visibility >= Visibility::Reduced); + children.retain(|(n, _)| n.visibility >= Visibility::Reduced); + parents.retain(|(n, _)| n.visibility >= Visibility::Reduced); + + filter_and_sort_nodes( + &filter, + match node.kind { + NodeKind::Channel => (SortProperty::ReleaseDate, SortOrder::Descending), + NodeKind::Season | NodeKind::Show => (SortProperty::Index, SortOrder::Ascending), + _ => (SortProperty::Title, SortOrder::Ascending), + }, + &mut children, + ); + + Ok(if *aj { + Either::Right(Json(ApiNodeResponse { + children, + parents, + node, + userdata: udata, + })) + } else { + Either::Left(LayoutPage { + title: node.title.clone().unwrap_or_default(), + content: markup::new!(@NodePage { + node: &node, + udata: &udata, + children: &children, + parents: &parents, + filter: &filter, + player: false, + similar: &similar, + lang: &lang, + }), + ..Default::default() + }) + }) +} + +pub fn get_similar_media( + node: &Node, + db: &Database, + session: &Session, +) -> Result<Vec<(Arc<Node>, NodeUserData)>> { + let this_id = NodeID::from_slug(&node.slug); + let mut ranking = BTreeMap::<NodeID, usize>::new(); + for tag in &node.tags { + let nodes = db.get_tag_nodes(tag)?; + let weight = 1_000_000 / nodes.len(); + for n in nodes { + if n != this_id { + *ranking.entry(n).or_default() += weight; + } + } + } + let mut ranking = ranking.into_iter().collect::<Vec<_>>(); + ranking.sort_by_key(|(_, k)| Reverse(*k)); + ranking + .into_iter() + .take(32) + .map(|(pid, _)| db.get_node_with_userdata(pid, session)) + .collect::<anyhow::Result<Vec<_>>>() +} + +markup::define! { + NodeCard<'a>(node: &'a Node, udata: &'a NodeUserData, lang: &'a Language) { + @let cls = format!("node card poster {}", aspect_class(node.kind)); + div[class=cls] { + .poster { + a[href=uri!(r_library_node(&node.slug))] { + img[src=uri!(r_item_poster(&node.slug, Some(1024))), loading="lazy"]; + } + .cardhover.item { + @if node.media.is_some() { + a.play.icon[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { "play_arrow" } + } + @Props { node, udata, full: false, lang } + } + } + div.title { + a[href=uri!(r_library_node(&node.slug))] { + @node.title + } + } + div.subtitle { + span { + @node.subtitle + } + } + } + } + NodeCardWide<'a>(node: &'a Node, udata: &'a NodeUserData, lang: &'a Language) { + div[class="node card widecard poster"] { + div[class=&format!("poster {}", aspect_class(node.kind))] { + a[href=uri!(r_library_node(&node.slug))] { + img[src=uri!(r_item_poster(&node.slug, Some(1024))), loading="lazy"]; + } + .cardhover.item { + @if node.media.is_some() { + a.play.icon[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { "play_arrow" } + } + } + } + div.details { + a.title[href=uri!(r_library_node(&node.slug))] { @node.title } + @Props { node, udata, full: false, lang } + span.overview { @node.description } + } + } + } + NodePage<'a>( + node: &'a Node, + udata: &'a NodeUserData, + children: &'a [(Arc<Node>, NodeUserData)], + parents: &'a [(Arc<Node>, NodeUserData)], + similar: &'a [(Arc<Node>, NodeUserData)], + filter: &'a NodeFilterSort, + lang: &'a Language, + player: bool, + ) { + @if !matches!(node.kind, NodeKind::Collection) && !player { + img.backdrop[src=uri!(r_item_backdrop(&node.slug, Some(2048))), loading="lazy"]; + } + .page.node { + @if !matches!(node.kind, NodeKind::Collection) && !player { + @let cls = format!("bigposter {}", aspect_class(node.kind)); + div[class=cls] { img[src=uri!(r_item_poster(&node.slug, Some(2048))), loading="lazy"]; } + } + .title { + h1 { @node.title } + ul.parents { @for (node, _) in *parents { li { + a.component[href=uri!(r_library_node(&node.slug))] { @node.title } + }}} + @if node.media.is_some() { + a.play[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { @trs(lang, "node.player_link") } + } + @if !matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { + @if matches!(udata.watched, WatchedState::None | WatchedState::Pending | WatchedState::Progress(_)) { + form.mark_watched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Watched))] { + input[type="submit", value=trs(lang, "node.watched.set")]; + } + } + @if matches!(udata.watched, WatchedState::Watched) { + form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] { + input[type="submit", value=trs(lang, "node.watched.unset")]; + } + } + @if matches!(udata.watched, WatchedState::None) { + form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Pending))] { + input[type="submit", value=trs(lang, "node.watchlist.set")]; + } + } + @if matches!(udata.watched, WatchedState::Pending) { + form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] { + input[type="submit", value=trs(lang, "node.watchlist.unset")]; + } + } + form.rating[method="POST", action=uri!(r_node_userdata_rating(&node.slug))] { + input[type="range", name="rating", min=-10, max=10, step=1, value=udata.rating]; + input[type="submit", value=trs(lang, "node.update_rating")]; + } + } + } + .details { + @Props { node, udata, full: true, lang } + h3 { @node.tagline } + @if let Some(description) = &node.description { + p { @for line in description.lines() { @line br; } } + } + @if let Some(media) = &node.media { + @if !media.chapters.is_empty() { + h2 { @trs(lang, "node.chapters") } + ul.children.hlist { @for chap in &media.chapters { + @let (inl, sub) = format_chapter(chap); + li { .card."aspect-thumb" { + .poster { + a[href=&uri!(r_player(&node.slug, PlayerConfig::seek(chap.time_start.unwrap_or(0.))))] { + img[src=&uri!(r_node_thumbnail(&node.slug, chapter_key_time(chap, media.duration), Some(1024))), loading="lazy"]; + } + .cardhover { .props { p { @inl } } } + } + .title { span { @sub } } + }} + }} + } + @if !node.people.is_empty() { + h2 { @trs(lang, "node.people") } + @for (group, people) in &node.people { + details[open=group==&PeopleGroup::Cast] { + summary { h3 { @format!("{}", group) } } + ul.children.hlist { @for (i, pe) in people.iter().enumerate() { + li { .card."aspect-port" { + .poster { + a[href="#"] { + img[src=&uri!(r_person_asset(&node.slug, i, group.to_string(), Some(1024))), loading="lazy"]; + } + } + .title { + span { @pe.person.name } br; + @if let Some(c) = pe.characters.first() { + span.subtitle { @c } + } + @if let Some(c) = pe.jobs.first() { + span.subtitle { @c } + } + } + }} + }} + } + } + } + details { + summary { @trs(lang, "media.tracks") } + ol { @for track in &media.tracks { + li { @format!("{track}") } + }} + } + } + @if !node.external_ids.is_empty() { + details { + summary { @trs(lang, "node.external_ids") } + table { + @for (key, value) in &node.external_ids { tr { + tr { + td { @trs(lang, &format!("eid.{}", key)) } + @if let Some(url) = external_id_url(key, value) { + td { a[href=url] { pre { @value } } } + } else { + td { pre { @value } } + } + } + }} + } + } + } + @if !node.tags.is_empty() { + details { + summary { @trs(lang, "node.tags") } + ol { @for tag in &node.tags { + li { @tag } + }} + } + } + } + @if matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { + @NodeFilterSortForm { f: filter, lang } + } + @if !similar.is_empty() { + h2 { @trs(lang, "node.similar") } + ul.children.hlist {@for (node, udata) in similar.iter() { + li { @NodeCard { node, udata, lang } } + }} + } + @match node.kind { + NodeKind::Show | NodeKind::Series | NodeKind::Season => { + ol { @for (node, udata) in children.iter() { + li { @NodeCardWide { node, udata, lang } } + }} + } + NodeKind::Collection | NodeKind::Channel | _ => { + ul.children {@for (node, udata) in children.iter() { + li { @NodeCard { node, udata, lang } } + }} + } + } + } + } + + Props<'a>(node: &'a Node, udata: &'a NodeUserData, full: bool, lang: &'a Language) { + .props { + @if let Some(m) = &node.media { + p { @format_duration(m.duration) } + p { @m.resolution_name() } + } + @if let Some(d) = &node.release_date { + p { @if *full { + @DateTime::from_timestamp_millis(*d).unwrap().naive_utc().to_string() + } else { + @DateTime::from_timestamp_millis(*d).unwrap().date_naive().to_string() + }} + } + @match node.visibility { + Visibility::Visible => {} + Visibility::Reduced => {p.visibility{@trs(lang, "prop.vis.reduced")}} + Visibility::Hidden => {p.visibility{@trs(lang, "prop.vis.hidden")}} + } + // TODO + // @if !node.children.is_empty() { + // p { @format!("{} items", node.children.len()) } + // } + @for (kind, value) in &node.ratings { + @match kind { + Rating::YoutubeLikes => {p.likes{ @format_count(*value as usize) " Likes" }} + Rating::YoutubeViews => {p{ @format_count(*value as usize) " Views" }} + Rating::YoutubeFollowers => {p{ @format_count(*value as usize) " Subscribers" }} + Rating::RottenTomatoes => {p.rating{ @value " Tomatoes" }} + Rating::Metacritic if *full => {p{ "Metacritic Score: " @value }} + Rating::Imdb => {p.rating{ "IMDb " @value }} + Rating::Tmdb => {p.rating{ "TMDB " @value }} + Rating::Trakt if *full => {p.rating{ "Trakt " @value }} + _ => {} + } + } + @if let Some(f) = &node.federated { + p.federation { @f } + } + @match udata.watched { + WatchedState::None => {} + WatchedState::Pending => { p.pending { @trs(lang, "prop.watched.pending") } } + WatchedState::Progress(x) => { p.progress { @tr(**lang, "prop.watched.progress").replace("{time}", &format_duration(x)) } } + WatchedState::Watched => { p.watched { @trs(lang, "prop.watched.watched") } } + } + } + } +} + +pub fn aspect_class(kind: NodeKind) -> &'static str { + use NodeKind::*; + match kind { + Video | Episode => "aspect-thumb", + Collection => "aspect-land", + Season | Show | Series | Movie | ShortFormVideo => "aspect-port", + Channel | Music | Unknown => "aspect-square", + } +} + +pub fn format_duration(d: f64) -> String { + format_duration_mode(d, false, Language::English) +} +pub fn format_duration_long(d: f64, lang: Language) -> String { + format_duration_mode(d, true, lang) +} +fn format_duration_mode(mut d: f64, long_units: bool, lang: Language) -> String { + let mut s = String::new(); + let sign = if d > 0. { "" } else { "-" }; + d = d.abs(); + for (short, long, long_pl, k) in [ + ("d", "time.day", "time.days", 60. * 60. * 24.), + ("h", "time.hour", "time.hours", 60. * 60.), + ("m", "time.minute", "time.minutes", 60.), + ("s", "time.second", "time.seconds", 1.), + ] { + let h = (d / k).floor(); + d -= h * k; + if h > 0. { + if long_units { + let long = tr(lang, if h != 1. { long_pl } else { long }); + let and = format!(" {} ", tr(lang, "time.and_join")); + // TODO breaks if seconds is zero + write!( + s, + "{}{h} {long}{}", + if k != 1. { "" } else { &and }, + if k > 60. { ", " } else { "" }, + ) + .unwrap(); + } else { + write!(s, "{h}{short} ").unwrap(); + } + } + } + format!("{sign}{}", s.trim()) +} +pub fn format_size(size: u64) -> String { + humansize::format_size(size, humansize::DECIMAL) +} +pub fn format_kind(k: NodeKind, lang: Language) -> TrString<'static> { + trs( + &lang, + match k { + NodeKind::Unknown => "kind.unknown", + NodeKind::Movie => "kind.movie", + NodeKind::Video => "kind.video", + NodeKind::Music => "kind.music", + NodeKind::ShortFormVideo => "kind.short_form_video", + NodeKind::Collection => "kind.collection", + NodeKind::Channel => "kind.channel", + NodeKind::Show => "kind.show", + NodeKind::Series => "kind.series", + NodeKind::Season => "kind.season", + NodeKind::Episode => "kind.episode", + }, + ) +} + +pub trait DatabaseNodeUserDataExt { + fn get_node_with_userdata( + &self, + id: NodeID, + session: &Session, + ) -> Result<(Arc<Node>, NodeUserData)>; +} +impl DatabaseNodeUserDataExt for Database { + fn get_node_with_userdata( + &self, + id: NodeID, + session: &Session, + ) -> Result<(Arc<Node>, NodeUserData)> { + Ok(( + self.get_node(id)?.ok_or(anyhow!("node does not exist"))?, + self.get_node_udata(id, &session.user.name)? + .unwrap_or_default(), + )) + } +} + +trait MediaInfoExt { + fn resolution_name(&self) -> &'static str; +} +impl MediaInfoExt for MediaInfo { + fn resolution_name(&self) -> &'static str { + let mut maxdim = 0; + for t in &self.tracks { + if let SourceTrackKind::Video { width, height, .. } = &t.kind { + maxdim = maxdim.max(*width.max(height)) + } + } + + match maxdim { + 30720.. => "32K", + 15360.. => "16K", + 7680.. => "8K UHD", + 5120.. => "5K UHD", + 3840.. => "4K UHD", + 2560.. => "QHD 1440p", + 1920.. => "FHD 1080p", + 1280.. => "HD 720p", + 854.. => "SD 480p", + _ => "Unkown", + } + } +} + +fn format_count(n: impl Into<usize>) -> String { + let n: usize = n.into(); + + if n >= 1_000_000 { + format!("{:.1}M", n as f32 / 1_000_000.) + } else if n >= 1_000 { + format!("{:.1}k", n as f32 / 1_000.) + } else { + format!("{n}") + } +} + +fn format_chapter(c: &Chapter) -> (String, String) { + ( + format!( + "{}-{}", + c.time_start.map(format_duration).unwrap_or_default(), + c.time_end.map(format_duration).unwrap_or_default(), + ), + c.labels.first().map(|l| l.1.clone()).unwrap_or_default(), + ) +} + +fn chapter_key_time(c: &Chapter, dur: f64) -> f64 { + let start = c.time_start.unwrap_or(0.); + let end = c.time_end.unwrap_or(dur); + start * 0.8 + end * 0.2 +} + +fn external_id_url(key: &str, value: &str) -> Option<String> { + Some(match key { + "youtube.video" => format!("https://youtube.com/watch?v={value}"), + "youtube.channel" => format!("https://youtube.com/channel/{value}"), + "youtube.channelname" => format!("https://youtube.com/channel/@{value}"), + "musicbrainz.release" => format!("https://musicbrainz.org/release/{value}"), + "musicbrainz.albumartist" => format!("https://musicbrainz.org/artist/{value}"), + "musicbrainz.artist" => format!("https://musicbrainz.org/artist/{value}"), + "musicbrainz.releasegroup" => format!("https://musicbrainz.org/release-group/{value}"), + "musicbrainz.recording" => format!("https://musicbrainz.org/recording/{value}"), + _ => return None, + }) +} diff --git a/server/src/ui/player.rs b/server/src/ui/player.rs new file mode 100644 index 0000000..cd4d03c --- /dev/null +++ b/server/src/ui/player.rs @@ -0,0 +1,198 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +use super::{ + layout::LayoutPage, + node::{get_similar_media, DatabaseNodeUserDataExt, NodePage}, + 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, +}; +use markup::DynRender; +use rocket::{get, response::Redirect, Either, FromForm, State, UriDisplayQuery}; +use std::sync::Arc; + +#[derive(FromForm, Default, Clone, Debug, UriDisplayQuery)] +pub struct PlayerConfig { + pub a: Option<TrackID>, + pub v: Option<TrackID>, + pub s: Option<TrackID>, + pub t: Option<f64>, + pub kind: Option<PlayerKind>, +} + +impl PlayerConfig { + pub fn seek(t: f64) -> Self { + Self { + t: Some(t), + ..Default::default() + } + } +} + +fn jellynative_url(action: &str, seek: f64, secret: &str, node: &str, session: &str) -> String { + let protocol = if CONF.tls { "https" } else { "http" }; + let host = &CONF.hostname; + let stream_url = format!( + "/n/{node}/stream{}", + StreamSpec::HlsMultiVariant { + segment: 0, + container: StreamContainer::Matroska + } + .to_query() + ); + format!("jellynative://{action}/{secret}/{session}/{seek}/{protocol}://{host}{stream_url}",) +} + +#[get("/n/<id>/player?<conf..>", rank = 4)] +pub fn r_player( + session: Session, + lang: AcceptLanguage, + db: &State<Database>, + id: NodeID, + conf: PlayerConfig, +) -> MyResult<Either<DynLayoutPage<'_>, Redirect>> { + let AcceptLanguage(lang) = lang; + let (node, udata) = db.get_node_with_userdata(id, &session)?; + + let mut parents = node + .parents + .iter() + .map(|pid| db.get_node_with_userdata(*pid, &session)) + .collect::<anyhow::Result<Vec<_>>>()?; + + let mut similar = get_similar_media(&node, db, &session)?; + + similar.retain(|(n, _)| n.visibility >= Visibility::Reduced); + parents.retain(|(n, _)| n.visibility >= Visibility::Reduced); + + let native_session = |action: &str| { + Ok(Either::Right(Redirect::temporary(jellynative_url( + action, + conf.t.unwrap_or(0.), + &session.user.native_secret, + &id.to_string(), + &session::create( + session.user.name, + PermissionSet::default(), // TODO + chrono::Duration::hours(24), + ), + )))) + }; + + match conf.kind.unwrap_or(session.user.player_preference) { + PlayerKind::Browser => (), + PlayerKind::Native => { + return native_session("player-v2"); + } + PlayerKind::NativeFullscreen => { + return native_session("player-fullscreen-v2"); + } + } + + // TODO + // let spec = StreamSpec { + // track: None + // .into_iter() + // .chain(conf.v) + // .chain(conf.a) + // .chain(conf.s) + // .collect::<Vec<_>>(), + // format: StreamFormat::Matroska, + // webm: Some(true), + // ..Default::default() + // }; + // let playing = false; // !spec.track.is_empty(); + // let conf = player_conf(node.clone(), playing)?; + + Ok(Either::Left(LayoutPage { + title: node.title.to_owned().unwrap_or_default(), + class: Some("player"), + content: markup::new! { + // @if playing { + // // video[src=uri!(r_stream(&node.slug, &spec)), controls, preload="auto"]{} + // } + // @conf + @NodePage { + children: &[], + parents: &parents, + filter: &NodeFilterSort::default(), + node: &node, + udata: &udata, + player: true, + similar: &similar, + lang: &lang + } + }, + })) +} + +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 } + + 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.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" }]; + } + }) +} diff --git a/server/src/ui/search.rs b/server/src/ui/search.rs new file mode 100644 index 0000000..96be3a6 --- /dev/null +++ b/server/src/ui/search.rs @@ -0,0 +1,69 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +use super::{ + error::MyResult, + layout::{trs, DynLayoutPage, LayoutPage}, + node::{DatabaseNodeUserDataExt, NodeCard}, +}; +use crate::{api::AcceptJson, locale::AcceptLanguage, logic::session::Session}; +use anyhow::anyhow; +use jellybase::{database::Database, locale::tr}; +use jellycommon::{api::ApiSearchResponse, Visibility}; +use rocket::{get, serde::json::Json, Either, State}; +use std::time::Instant; + +#[get("/search?<query>&<page>")] +pub async fn r_search<'a>( + session: Session, + db: &State<Database>, + aj: AcceptJson, + query: Option<&str>, + page: Option<usize>, + lang: AcceptLanguage, +) -> MyResult<Either<DynLayoutPage<'a>, Json<ApiSearchResponse>>> { + let AcceptLanguage(lang) = lang; + let results = if let Some(query) = query { + let timing = Instant::now(); + let (count, ids) = db.search(query, 32, page.unwrap_or_default() * 32)?; + let mut nodes = ids + .into_iter() + .map(|id| db.get_node_with_userdata(id, &session)) + .collect::<Result<Vec<_>, anyhow::Error>>()?; + nodes.retain(|(n, _)| n.visibility >= Visibility::Reduced); + let search_dur = timing.elapsed(); + Some((count, nodes, search_dur)) + } else { + None + }; + let query = query.unwrap_or_default().to_string(); + + Ok(if *aj { + let Some((count, results, _)) = results else { + Err(anyhow!("no query"))? + }; + 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 + } + }, + }) + }) +} diff --git a/server/src/ui/sort.rs b/server/src/ui/sort.rs new file mode 100644 index 0000000..a241030 --- /dev/null +++ b/server/src/ui/sort.rs @@ -0,0 +1,297 @@ +/* + 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::ui::layout::trs; +use jellybase::locale::Language; +use jellycommon::{helpers::SortAnyway, user::NodeUserData, Node, NodeKind, Rating}; +use markup::RenderAttributeValue; +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), + nodes: &mut Vec<(Arc<Node>, NodeUserData)>, +) { + let sort_prop = f.sort_by.unwrap_or(default_sort.0); + nodes.retain(|(node, _udata)| { + let mut o = true; + if let Some(prop) = &f.filter_kind { + o = false; + for p in prop { + o |= match p { + // FilterProperty::FederationLocal => node.federated.is_none(), + // FilterProperty::FederationRemote => node.federated.is_some(), + FilterProperty::KindMovie => node.kind == NodeKind::Movie, + FilterProperty::KindVideo => node.kind == NodeKind::Video, + FilterProperty::KindShortFormVideo => node.kind == NodeKind::ShortFormVideo, + FilterProperty::KindMusic => node.kind == NodeKind::Music, + FilterProperty::KindCollection => node.kind == NodeKind::Collection, + FilterProperty::KindChannel => node.kind == NodeKind::Channel, + FilterProperty::KindShow => node.kind == NodeKind::Show, + FilterProperty::KindSeries => node.kind == NodeKind::Series, + FilterProperty::KindSeason => node.kind == NodeKind::Season, + FilterProperty::KindEpisode => node.kind == NodeKind::Episode, + // FilterProperty::Watched => udata.watched == WatchedState::Watched, + // FilterProperty::Unwatched => udata.watched == WatchedState::None, + // FilterProperty::WatchProgress => { + // matches!(udata.watched, WatchedState::Progress(_)) + // } + _ => false, // TODO + } + } + } + match sort_prop { + SortProperty::ReleaseDate => o &= node.release_date.is_some(), + SortProperty::Duration => o &= node.media.is_some(), + _ => (), + } + o + }); + match sort_prop { + SortProperty::Duration => { + nodes.sort_by_key(|(n, _)| (n.media.as_ref().unwrap().duration * 1000.) as i64) + } + SortProperty::ReleaseDate => { + nodes.sort_by_key(|(n, _)| n.release_date.expect("asserted above")) + } + SortProperty::Title => nodes.sort_by(|(a, _), (b, _)| a.title.cmp(&b.title)), + SortProperty::Index => nodes.sort_by(|(a, _), (b, _)| { + a.index + .unwrap_or(usize::MAX) + .cmp(&b.index.unwrap_or(usize::MAX)) + }), + SortProperty::RatingRottenTomatoes => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::RottenTomatoes).unwrap_or(&0.)) + }), + SortProperty::RatingMetacritic => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::Metacritic).unwrap_or(&0.)) + }), + SortProperty::RatingImdb => nodes + .sort_by_cached_key(|(n, _)| SortAnyway(*n.ratings.get(&Rating::Imdb).unwrap_or(&0.))), + SortProperty::RatingTmdb => nodes + .sort_by_cached_key(|(n, _)| SortAnyway(*n.ratings.get(&Rating::Tmdb).unwrap_or(&0.))), + SortProperty::RatingYoutubeViews => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::YoutubeViews).unwrap_or(&0.)) + }), + SortProperty::RatingYoutubeLikes => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::YoutubeLikes).unwrap_or(&0.)) + }), + SortProperty::RatingYoutubeFollowers => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::YoutubeFollowers).unwrap_or(&0.)) + }), + SortProperty::RatingLikesDivViews => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway( + *n.ratings.get(&Rating::YoutubeLikes).unwrap_or(&0.) + / (1. + *n.ratings.get(&Rating::YoutubeViews).unwrap_or(&0.)), + ) + }), + SortProperty::RatingUser => nodes.sort_by_cached_key(|(_, u)| u.rating), + } + + match f.sort_order.unwrap_or(default_sort.1) { + SortOrder::Ascending => (), + 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 new file mode 100644 index 0000000..4c5bed8 --- /dev/null +++ b/server/src/ui/stats.rs @@ -0,0 +1,131 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin <metamuffin.org> +*/ +use super::{ + error::MyError, + layout::{DynLayoutPage, LayoutPage}, +}; +use crate::{ + api::AcceptJson, + database::Database, + locale::AcceptLanguage, + logic::session::Session, + ui::{ + layout::trs, + node::{ + format_duration, format_duration_long, format_kind, format_size, + rocket_uri_macro_r_library_node, + }, + }, + uri, +}; +use jellybase::locale::tr; +use jellycommon::{Node, NodeID, NodeKind, Visibility}; +use markup::raw; +use rocket::{get, serde::json::Json, Either, State}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +#[get("/stats")] +pub fn r_stats( + sess: Session, + db: &State<Database>, + aj: AcceptJson, + lang: AcceptLanguage, +) -> Result<Either<DynLayoutPage<'_>, Json<Value>>, MyError> { + let AcceptLanguage(lang) = lang; + let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?; + items.retain(|(n, _)| n.visibility >= Visibility::Reduced); + + #[derive(Default, Serialize)] + struct Bin { + runtime: f64, + size: u64, + count: usize, + max_runtime: (f64, String), + max_size: (u64, String), + } + impl Bin { + fn update(&mut self, node: &Node) { + self.count += 1; + self.size += node.storage_size; + if node.storage_size > self.max_size.0 { + self.max_size = (node.storage_size, node.slug.clone()) + } + if let Some(m) = &node.media { + self.runtime += m.duration; + if m.duration > self.max_runtime.0 { + self.max_runtime = (m.duration, node.slug.clone()) + } + } + } + fn average_runtime(&self) -> f64 { + self.runtime / self.count as f64 + } + fn average_size(&self) -> f64 { + self.size as f64 / self.count as f64 + } + } + + let mut all = Bin::default(); + let mut kinds = BTreeMap::<NodeKind, Bin>::new(); + for (i, _) in items { + all.update(&i); + kinds.entry(i.kind).or_default().update(&i); + } + + Ok(if *aj { + Either::Right(Json(json!({ + "all": all, + "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() + }) + }) +} diff --git a/server/src/ui/style.rs b/server/src/ui/style.rs new file mode 100644 index 0000000..77f0fe1 --- /dev/null +++ b/server/src/ui/style.rs @@ -0,0 +1,90 @@ +/* + 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> + Copyright (C) 2023 tpart +*/ +use rocket::{ + get, + http::{ContentType, Header}, + response::Responder, +}; + +macro_rules! concat_files { + ([$base: expr], $($files:literal),*) => {{ + #[cfg(any(debug_assertions, feature = "hot-css"))] + { + use std::{fs::read_to_string, path::PathBuf, str::FromStr}; + [ $($files),* ] + .into_iter() + .map(|n| { + read_to_string({ + let p = PathBuf::from_str(file!()).unwrap().parent().unwrap().join($base).join(n); + log::info!("load {p:?}"); + p + }) + .unwrap() + }) + .collect::<Vec<_>>() + .join("\n") + } + #[cfg(not(any(debug_assertions, feature = "hot-css")))] + concat!($(include_str!(concat!($base, "/", $files))),*).to_string() + }}; +} + +fn css_bundle() -> String { + concat_files!( + ["../../../web/style"], + "layout.css", + "player.css", + "nodepage.css", + "nodecard.css", + "js-player.css", + "js-transition.css", + "forms.css", + "props.css", + "themes.css", + "navbar.css" + ) +} + +pub struct CachedAsset<T>(pub T); +impl<'r, 'o: 'r, T: Responder<'r, 'o>> Responder<'r, 'o> for CachedAsset<T> { + fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'o> { + let mut res = self.0.respond_to(request)?; + if cfg!(not(debug_assertions)) { + res.set_header(Header::new("cache-control", "max-age=86400")); + } + Ok(res) + } +} + +fn js_bundle() -> String { + concat_files!([env!("OUT_DIR")], "bundle.js") +} +fn js_bundle_map() -> String { + concat_files!([env!("OUT_DIR")], "bundle.js.map") +} + +#[get("/assets/style.css")] +pub fn r_assets_style() -> CachedAsset<(ContentType, String)> { + CachedAsset((ContentType::CSS, css_bundle())) +} + +#[get("/assets/cantarell.woff2")] +pub fn r_assets_font() -> CachedAsset<(ContentType, &'static [u8])> { + CachedAsset(( + ContentType::WOFF2, + include_bytes!("../../../web/cantarell.woff2"), + )) +} + +#[get("/assets/bundle.js")] +pub fn r_assets_js() -> CachedAsset<(ContentType, String)> { + CachedAsset((ContentType::JavaScript, js_bundle())) +} +#[get("/assets/bundle.js.map")] +pub fn r_assets_js_map() -> CachedAsset<(ContentType, String)> { + CachedAsset((ContentType::JSON, js_bundle_map())) +} |