aboutsummaryrefslogtreecommitdiff
path: root/server/src/ui
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/ui')
-rw-r--r--server/src/ui/account/mod.rs256
-rw-r--r--server/src/ui/account/settings.rs185
-rw-r--r--server/src/ui/admin/log.rs258
-rw-r--r--server/src/ui/admin/mod.rs288
-rw-r--r--server/src/ui/admin/user.rs176
-rw-r--r--server/src/ui/assets.rs201
-rw-r--r--server/src/ui/browser.rs80
-rw-r--r--server/src/ui/error.rs104
-rw-r--r--server/src/ui/home.rs173
-rw-r--r--server/src/ui/layout.rs182
-rw-r--r--server/src/ui/mod.rs136
-rw-r--r--server/src/ui/node.rs558
-rw-r--r--server/src/ui/player.rs198
-rw-r--r--server/src/ui/search.rs69
-rw-r--r--server/src/ui/sort.rs297
-rw-r--r--server/src/ui/stats.rs131
-rw-r--r--server/src/ui/style.rs90
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("&lt;"),
+ b'>' => Some("&gt;"),
+ b'&' => Some("&amp;"),
+ b'"' => Some("&quot;"),
+ _ => None,
+ } {
+ o += &str[last..index];
+ o += esc;
+ last = index + 1;
+ }
+ }
+ o += &str[last..];
+ o
+}
+
+pub fn trs<'a>(lang: &Language, key: &str) -> TrString<'a> {
+ TrString(tr(*lang, key))
+}
+
+markup::define! {
+ Layout<'a, Main: Render>(title: String, main: Main, class: &'a str, session: Option<Session>, lang: Language) {
+ @markup::doctype()
+ html {
+ head {
+ title { @title " - " @CONF.brand }
+ meta[name="viewport", content="width=device-width, initial-scale=1.0"];
+ link[rel="stylesheet", href="/assets/style.css"];
+ script[src="/assets/bundle.js"] {}
+ }
+ body[class=class] {
+ nav {
+ h1 { a[href=if session.is_some() {"/home"} else {"/"}] { @if *LOGO_ENABLED { img.logo[src="/assets/logo.svg"]; } else { @CONF.brand } } } " "
+ @if let Some(_) = session {
+ a.library[href=uri!(r_library_node("library"))] { @trs(lang, "nav.root") } " "
+ a.library[href=uri!(r_all_items())] { @trs(lang, "nav.all") } " "
+ a.library[href=uri!(r_search(None::<&'static str>, None::<usize>))] { @trs(lang, "nav.search") } " "
+ a.library[href=uri!(r_stats())] { @trs(lang, "nav.stats") } " "
+ }
+ @if is_importing() { span.warn { "Library database is updating..." } }
+ div.account {
+ @if let Some(session) = session {
+ span { @raw(tr(*lang, "nav.username").replace("{name}", &format!("<b class=\"username\">{}</b>", escape(&session.user.display_name)))) } " "
+ @if session.user.admin {
+ a.admin.hybrid_button[href=uri!(r_admin_dashboard())] { p {@trs(lang, "nav.admin")} } " "
+ }
+ a.settings.hybrid_button[href=uri!(r_account_settings())] { p {@trs(lang, "nav.settings")} } " "
+ a.logout.hybrid_button[href=uri!(r_account_logout())] { p {@trs(lang, "nav.logout")} }
+ } else {
+ a.register.hybrid_button[href=uri!(r_account_register())] { p {@trs(lang, "nav.register")} } " "
+ a.login.hybrid_button[href=uri!(r_account_login())] { p {@trs(lang, "nav.login")} }
+ }
+ }
+ }
+ #main { @main }
+ footer {
+ p { @CONF.brand " - " @CONF.slogan " | powered by " a[href="https://codeberg.org/metamuffin/jellything"]{"Jellything"} }
+ }
+ }
+ }
+ }
+
+ FlashDisplay(flash: Option<Result<String, String>>) {
+ @if let Some(flash) = &flash {
+ @match flash {
+ Ok(mesg) => { section.message { p.success { @mesg } } }
+ Err(err) => { section.message { p.error { @err } } }
+ }
+ }
+ }
+}
+
+pub type DynLayoutPage<'a> = LayoutPage<markup::DynRender<'a>>;
+
+pub struct LayoutPage<T> {
+ pub title: String,
+ pub class: Option<&'static str>,
+ pub content: T,
+}
+
+impl Default for LayoutPage<DynRender<'_>> {
+ fn default() -> Self {
+ Self {
+ class: None,
+ content: markup::new!(),
+ title: String::new(),
+ }
+ }
+}
+
+impl<'r, Main: Render> Responder<'r, 'static> for LayoutPage<Main> {
+ fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> {
+ // TODO blocking the event loop here. it seems like there is no other way to
+ // TODO offload this, since the guard references `req` which has a lifetime.
+ // TODO therefore we just block. that is fine since the database is somewhat fast.
+ let lang = lang_from_request(&req);
+ let session = block_on(req.guard::<Option<Session>>()).unwrap();
+ let mut out = String::new();
+ Layout {
+ main: self.content,
+ title: self.title,
+ class: &format!(
+ "{} theme-{:?}",
+ self.class.unwrap_or(""),
+ session
+ .as_ref()
+ .map(|s| s.user.theme)
+ .unwrap_or(Theme::Dark)
+ ),
+ session,
+ lang,
+ }
+ .render(&mut out)
+ .unwrap();
+
+ Response::build()
+ .header(ContentType::HTML)
+ .streamed_body(Cursor::new(out))
+ .ok()
+ }
+}
diff --git a/server/src/ui/mod.rs b/server/src/ui/mod.rs
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()))
+}