aboutsummaryrefslogtreecommitdiff
path: root/server/src/routes/ui
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-04-27 19:25:11 +0200
committermetamuffin <metamuffin@disroot.org>2025-04-27 19:25:11 +0200
commit11a585b3dbe620dcc8772e713b22f1d9ba80d598 (patch)
tree44f8d97137412aefc79a2425a489c34fa3e5f6c5 /server/src/routes/ui
parentd871aa7c5bba49ff55170b5d2dac9cd440ae7170 (diff)
downloadjellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar
jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.bz2
jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.zst
move files around
Diffstat (limited to 'server/src/routes/ui')
-rw-r--r--server/src/routes/ui/account/mod.rs261
-rw-r--r--server/src/routes/ui/account/session/guard.rs106
-rw-r--r--server/src/routes/ui/account/session/mod.rs24
-rw-r--r--server/src/routes/ui/account/session/token.rs97
-rw-r--r--server/src/routes/ui/account/settings.rs187
-rw-r--r--server/src/routes/ui/admin/log.rs258
-rw-r--r--server/src/routes/ui/admin/mod.rs290
-rw-r--r--server/src/routes/ui/admin/user.rs176
-rw-r--r--server/src/routes/ui/assets.rs200
-rw-r--r--server/src/routes/ui/browser.rs83
-rw-r--r--server/src/routes/ui/error.rs104
-rw-r--r--server/src/routes/ui/home.rs180
-rw-r--r--server/src/routes/ui/layout.rs184
-rw-r--r--server/src/routes/ui/mod.rs131
-rw-r--r--server/src/routes/ui/node.rs558
-rw-r--r--server/src/routes/ui/player.rs200
-rw-r--r--server/src/routes/ui/search.rs70
-rw-r--r--server/src/routes/ui/sort.rs290
-rw-r--r--server/src/routes/ui/stats.rs133
-rw-r--r--server/src/routes/ui/style.rs90
20 files changed, 0 insertions, 3622 deletions
diff --git a/server/src/routes/ui/account/mod.rs b/server/src/routes/ui/account/mod.rs
deleted file mode 100644
index 83a1447..0000000
--- a/server/src/routes/ui/account/mod.rs
+++ /dev/null
@@ -1,261 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-pub mod session;
-pub mod settings;
-
-use super::{
- error::MyError,
- layout::{trs, LayoutPage},
-};
-use crate::{
- database::Database,
- routes::{
- locale::AcceptLanguage,
- ui::{
- account::session::Session, 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::token::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/routes/ui/account/session/guard.rs b/server/src/routes/ui/account/session/guard.rs
deleted file mode 100644
index 295c2d4..0000000
--- a/server/src/routes/ui/account/session/guard.rs
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{AdminSession, Session};
-use crate::{database::Database, routes::ui::error::MyError};
-use anyhow::anyhow;
-use log::warn;
-use rocket::{
- async_trait,
- http::Status,
- outcome::Outcome,
- request::{self, FromRequest},
- Request, State,
-};
-
-impl Session {
- pub async fn from_request_ut(req: &Request<'_>) -> Result<Self, MyError> {
- let username;
-
- #[cfg(not(feature = "bypass-auth"))]
- {
- let token = req
- .query_value("session")
- .map(|e| e.unwrap())
- .or_else(|| req.query_value("api_key").map(|e| e.unwrap()))
- .or_else(|| req.headers().get_one("X-MediaBrowser-Token"))
- .or_else(|| {
- req.headers()
- .get_one("Authorization")
- .and_then(parse_jellyfin_auth)
- }) // for jellyfin compat
- .or(req.cookies().get("session").map(|cookie| cookie.value()))
- .ok_or(anyhow!("not logged in"))?;
-
- // jellyfin urlescapes the token for *some* requests
- let token = token.replace("%3D", "=");
- username = super::token::validate(&token)?;
- };
-
- #[cfg(feature = "bypass-auth")]
- {
- parse_jellyfin_auth("a"); // unused warning is annoying
- username = "admin".to_string();
- }
-
- let db = req.guard::<&State<Database>>().await.unwrap();
-
- let user = db.get_user(&username)?.ok_or(anyhow!("user not found"))?;
-
- Ok(Session { user })
- }
-}
-
-fn parse_jellyfin_auth(h: &str) -> Option<&str> {
- for tok in h.split(" ") {
- if let Some(tok) = tok.strip_prefix("Token=\"") {
- if let Some(tok) = tok.strip_suffix("\"") {
- return Some(tok);
- }
- }
- }
- None
-}
-
-#[async_trait]
-impl<'r> FromRequest<'r> for Session {
- type Error = MyError;
- async fn from_request<'life0>(
- request: &'r Request<'life0>,
- ) -> request::Outcome<Self, Self::Error> {
- match Session::from_request_ut(request).await {
- Ok(x) => Outcome::Success(x),
- Err(e) => {
- warn!("authentificated route rejected: {e:?}");
- Outcome::Forward(Status::Unauthorized)
- }
- }
- }
-}
-
-#[async_trait]
-impl<'r> FromRequest<'r> for AdminSession {
- type Error = MyError;
- async fn from_request<'life0>(
- request: &'r Request<'life0>,
- ) -> request::Outcome<Self, Self::Error> {
- match Session::from_request_ut(request).await {
- Ok(x) => {
- if x.user.admin {
- Outcome::Success(AdminSession(x))
- } else {
- Outcome::Error((
- Status::Unauthorized,
- MyError(anyhow!("you are not an admin")),
- ))
- }
- }
- Err(e) => {
- warn!("authentificated route rejected: {e:?}");
- Outcome::Forward(Status::Unauthorized)
- }
- }
- }
-}
diff --git a/server/src/routes/ui/account/session/mod.rs b/server/src/routes/ui/account/session/mod.rs
deleted file mode 100644
index cb06255..0000000
--- a/server/src/routes/ui/account/session/mod.rs
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use chrono::{DateTime, Utc};
-use jellycommon::user::{PermissionSet, User};
-use serde::{Deserialize, Serialize};
-
-pub mod guard;
-pub mod token;
-
-pub struct Session {
- pub user: User,
-}
-
-pub struct AdminSession(pub Session);
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct SessionData {
- username: String,
- expire: DateTime<Utc>,
- permissions: PermissionSet,
-}
diff --git a/server/src/routes/ui/account/session/token.rs b/server/src/routes/ui/account/session/token.rs
deleted file mode 100644
index 3ada0ec..0000000
--- a/server/src/routes/ui/account/session/token.rs
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::SessionData;
-use aes_gcm_siv::{
- aead::{generic_array::GenericArray, Aead},
- KeyInit,
-};
-use anyhow::anyhow;
-use base64::Engine;
-use chrono::{Duration, Utc};
-use jellybase::SECRETS;
-use jellycommon::user::PermissionSet;
-use log::warn;
-use std::sync::LazyLock;
-
-static SESSION_KEY: LazyLock<[u8; 32]> = LazyLock::new(|| {
- if let Some(sk) = &SECRETS.session_key {
- let r = base64::engine::general_purpose::STANDARD
- .decode(sk)
- .expect("key invalid; should be valid base64");
- r.try_into()
- .expect("key has the wrong length; should be 32 bytes")
- } else {
- warn!("session_key not configured; generating a random one.");
- [(); 32].map(|_| rand::random())
- }
-});
-
-pub fn create(username: String, permissions: PermissionSet, expire: Duration) -> String {
- let session_data = SessionData {
- expire: Utc::now() + expire,
- username: username.to_owned(),
- permissions,
- };
- let mut plaintext =
- bincode::serde::encode_to_vec(&session_data, bincode::config::standard()).unwrap();
-
- while plaintext.len() % 16 == 0 {
- plaintext.push(0);
- }
-
- let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap();
- let nonce = [(); 12].map(|_| rand::random());
- let mut ciphertext = cipher
- .encrypt(&GenericArray::from(nonce), plaintext.as_slice())
- .unwrap();
- ciphertext.extend(nonce);
-
- base64::engine::general_purpose::URL_SAFE.encode(&ciphertext)
-}
-
-pub fn validate(token: &str) -> anyhow::Result<String> {
- let ciphertext = base64::engine::general_purpose::URL_SAFE.decode(token)?;
- let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap();
- let (ciphertext, nonce) = ciphertext.split_at(ciphertext.len() - 12);
- let plaintext = cipher
- .decrypt(nonce.into(), ciphertext)
- .map_err(|e| anyhow!("decryption failed: {e:?}"))?;
-
- let (session_data, _): (SessionData, _) =
- bincode::serde::decode_from_slice(&plaintext, bincode::config::standard())?;
-
- if session_data.expire < Utc::now() {
- Err(anyhow!("session expired"))?
- }
-
- Ok(session_data.username)
-}
-
-#[test]
-fn test() {
- jellybase::use_test_config();
- let tok = create(
- "blub".to_string(),
- jellycommon::user::PermissionSet::default(),
- Duration::days(1),
- );
- validate(&tok).unwrap();
-}
-
-#[test]
-fn test_crypto() {
- jellybase::use_test_config();
- let nonce = [(); 12].map(|_| rand::random());
- let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap();
- let plaintext = b"testing stuff---";
- let ciphertext = cipher
- .encrypt(&GenericArray::from(nonce), plaintext.as_slice())
- .unwrap();
- let plaintext2 = cipher
- .decrypt((&nonce).into(), ciphertext.as_slice())
- .unwrap();
- assert_eq!(plaintext, plaintext2.as_slice());
-}
diff --git a/server/src/routes/ui/account/settings.rs b/server/src/routes/ui/account/settings.rs
deleted file mode 100644
index 2e170b0..0000000
--- a/server/src/routes/ui/account/settings.rs
+++ /dev/null
@@ -1,187 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{format_form_error, hash_password};
-use crate::{
- database::Database,
- routes::{
- 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/routes/ui/admin/log.rs b/server/src/routes/ui/admin/log.rs
deleted file mode 100644
index fc85b37..0000000
--- a/server/src/routes/ui/admin/log.rs
+++ /dev/null
@@ -1,258 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use crate::{
- routes::ui::{
- account::session::AdminSession,
- 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/routes/ui/admin/mod.rs b/server/src/routes/ui/admin/mod.rs
deleted file mode 100644
index f44b36c..0000000
--- a/server/src/routes/ui/admin/mod.rs
+++ /dev/null
@@ -1,290 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-pub mod log;
-pub mod user;
-
-use super::{
- account::session::AdminSession,
- assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED},
-};
-use crate::{
- database::Database,
- routes::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/routes/ui/admin/user.rs b/server/src/routes/ui/admin/user.rs
deleted file mode 100644
index 7ba6d4e..0000000
--- a/server/src/routes/ui/admin/user.rs
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use crate::{
- database::Database,
- routes::ui::{
- account::session::AdminSession,
- 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/routes/ui/assets.rs b/server/src/routes/ui/assets.rs
deleted file mode 100644
index c661771..0000000
--- a/server/src/routes/ui/assets.rs
+++ /dev/null
@@ -1,200 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use crate::routes::ui::{account::session::Session, error::MyResult, CacheControlFile};
-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/routes/ui/browser.rs b/server/src/routes/ui/browser.rs
deleted file mode 100644
index 96c005d..0000000
--- a/server/src/routes/ui/browser.rs
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- account::session::Session,
- error::MyError,
- layout::{trs, DynLayoutPage, LayoutPage},
- node::NodeCard,
- sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty},
-};
-use crate::{
- database::Database,
- routes::{api::AcceptJson, locale::AcceptLanguage},
- 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/routes/ui/error.rs b/server/src/routes/ui/error.rs
deleted file mode 100644
index ee593a2..0000000
--- a/server/src/routes/ui/error.rs
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::layout::{DynLayoutPage, LayoutPage};
-use crate::{routes::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/routes/ui/home.rs b/server/src/routes/ui/home.rs
deleted file mode 100644
index 8f8a876..0000000
--- a/server/src/routes/ui/home.rs
+++ /dev/null
@@ -1,180 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- account::session::Session,
- layout::{trs, LayoutPage},
- node::{DatabaseNodeUserDataExt, NodeCard},
-};
-use crate::{
- database::Database,
- routes::{
- api::AcceptJson,
- locale::AcceptLanguage,
- ui::{error::MyResult, layout::DynLayoutPage},
- },
-};
-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/routes/ui/layout.rs b/server/src/routes/ui/layout.rs
deleted file mode 100644
index 0a0d036..0000000
--- a/server/src/routes/ui/layout.rs
+++ /dev/null
@@ -1,184 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use crate::{
- routes::{
- locale::lang_from_request,
- ui::{
- account::{
- rocket_uri_macro_r_account_login, rocket_uri_macro_r_account_logout,
- rocket_uri_macro_r_account_register, session::Session,
- 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/routes/ui/mod.rs b/server/src/routes/ui/mod.rs
deleted file mode 100644
index d61ef9e..0000000
--- a/server/src/routes/ui/mod.rs
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use account::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()
- }))
- }
-}
-
-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/routes/ui/node.rs b/server/src/routes/ui/node.rs
deleted file mode 100644
index d968f0a..0000000
--- a/server/src/routes/ui/node.rs
+++ /dev/null
@@ -1,558 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- assets::{
- rocket_uri_macro_r_item_backdrop, rocket_uri_macro_r_item_poster,
- rocket_uri_macro_r_node_thumbnail,
- },
- error::MyResult,
- layout::{trs, TrString},
- sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty},
-};
-use crate::{
- database::Database,
- routes::{
- api::AcceptJson,
- locale::AcceptLanguage,
- ui::{
- account::session::Session,
- assets::rocket_uri_macro_r_person_asset,
- layout::{DynLayoutPage, LayoutPage},
- player::{rocket_uri_macro_r_player, PlayerConfig},
- },
- userdata::{
- rocket_uri_macro_r_node_userdata_rating, rocket_uri_macro_r_node_userdata_watched,
- UrlWatchedState,
- },
- },
- uri,
-};
-use anyhow::{anyhow, Result};
-use chrono::DateTime;
-use jellybase::locale::{tr, Language};
-use jellycommon::{
- api::ApiNodeResponse,
- user::{NodeUserData, WatchedState},
- Chapter, MediaInfo, Node, NodeID, NodeKind, PeopleGroup, Rating, SourceTrackKind, Visibility,
-};
-use rocket::{get, serde::json::Json, Either, State};
-use std::{cmp::Reverse, collections::BTreeMap, fmt::Write, sync::Arc};
-
-/// This function is a stub and only useful for use in the uri! macro.
-#[get("/n/<id>")]
-pub fn r_library_node(id: NodeID) {
- let _ = id;
-}
-
-#[get("/n/<id>?<parents>&<children>&<filter..>")]
-pub async fn r_library_node_filter<'a>(
- session: Session,
- id: NodeID,
- db: &'a State<Database>,
- aj: AcceptJson,
- filter: NodeFilterSort,
- lang: AcceptLanguage,
- parents: bool,
- children: bool,
-) -> MyResult<Either<DynLayoutPage<'a>, Json<ApiNodeResponse>>> {
- let AcceptLanguage(lang) = lang;
- let (node, udata) = db.get_node_with_userdata(id, &session)?;
-
- let mut children = if !*aj || children {
- db.get_node_children(id)?
- .into_iter()
- .map(|c| db.get_node_with_userdata(c, &session))
- .collect::<anyhow::Result<Vec<_>>>()?
- } else {
- Vec::new()
- };
-
- let mut parents = if !*aj || parents {
- node.parents
- .iter()
- .map(|pid| db.get_node_with_userdata(*pid, &session))
- .collect::<anyhow::Result<Vec<_>>>()?
- } else {
- Vec::new()
- };
-
- let mut similar = get_similar_media(&node, db, &session)?;
-
- similar.retain(|(n, _)| n.visibility >= Visibility::Reduced);
- children.retain(|(n, _)| n.visibility >= Visibility::Reduced);
- parents.retain(|(n, _)| n.visibility >= Visibility::Reduced);
-
- filter_and_sort_nodes(
- &filter,
- match node.kind {
- NodeKind::Channel => (SortProperty::ReleaseDate, SortOrder::Descending),
- NodeKind::Season | NodeKind::Show => (SortProperty::Index, SortOrder::Ascending),
- _ => (SortProperty::Title, SortOrder::Ascending),
- },
- &mut children,
- );
-
- Ok(if *aj {
- Either::Right(Json(ApiNodeResponse {
- children,
- parents,
- node,
- userdata: udata,
- }))
- } else {
- Either::Left(LayoutPage {
- title: node.title.clone().unwrap_or_default(),
- content: markup::new!(@NodePage {
- node: &node,
- udata: &udata,
- children: &children,
- parents: &parents,
- filter: &filter,
- player: false,
- similar: &similar,
- lang: &lang,
- }),
- ..Default::default()
- })
- })
-}
-
-pub fn get_similar_media(
- node: &Node,
- db: &Database,
- session: &Session,
-) -> Result<Vec<(Arc<Node>, NodeUserData)>> {
- let this_id = NodeID::from_slug(&node.slug);
- let mut ranking = BTreeMap::<NodeID, usize>::new();
- for tag in &node.tags {
- let nodes = db.get_tag_nodes(tag)?;
- let weight = 1_000_000 / nodes.len();
- for n in nodes {
- if n != this_id {
- *ranking.entry(n).or_default() += weight;
- }
- }
- }
- let mut ranking = ranking.into_iter().collect::<Vec<_>>();
- ranking.sort_by_key(|(_, k)| Reverse(*k));
- ranking
- .into_iter()
- .take(32)
- .map(|(pid, _)| db.get_node_with_userdata(pid, session))
- .collect::<anyhow::Result<Vec<_>>>()
-}
-
-markup::define! {
- NodeCard<'a>(node: &'a Node, udata: &'a NodeUserData, lang: &'a Language) {
- @let cls = format!("node card poster {}", aspect_class(node.kind));
- div[class=cls] {
- .poster {
- a[href=uri!(r_library_node(&node.slug))] {
- img[src=uri!(r_item_poster(&node.slug, Some(1024))), loading="lazy"];
- }
- .cardhover.item {
- @if node.media.is_some() {
- a.play.icon[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { "play_arrow" }
- }
- @Props { node, udata, full: false, lang }
- }
- }
- div.title {
- a[href=uri!(r_library_node(&node.slug))] {
- @node.title
- }
- }
- div.subtitle {
- span {
- @node.subtitle
- }
- }
- }
- }
- NodeCardWide<'a>(node: &'a Node, udata: &'a NodeUserData, lang: &'a Language) {
- div[class="node card widecard poster"] {
- div[class=&format!("poster {}", aspect_class(node.kind))] {
- a[href=uri!(r_library_node(&node.slug))] {
- img[src=uri!(r_item_poster(&node.slug, Some(1024))), loading="lazy"];
- }
- .cardhover.item {
- @if node.media.is_some() {
- a.play.icon[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { "play_arrow" }
- }
- }
- }
- div.details {
- a.title[href=uri!(r_library_node(&node.slug))] { @node.title }
- @Props { node, udata, full: false, lang }
- span.overview { @node.description }
- }
- }
- }
- NodePage<'a>(
- node: &'a Node,
- udata: &'a NodeUserData,
- children: &'a [(Arc<Node>, NodeUserData)],
- parents: &'a [(Arc<Node>, NodeUserData)],
- similar: &'a [(Arc<Node>, NodeUserData)],
- filter: &'a NodeFilterSort,
- lang: &'a Language,
- player: bool,
- ) {
- @if !matches!(node.kind, NodeKind::Collection) && !player {
- img.backdrop[src=uri!(r_item_backdrop(&node.slug, Some(2048))), loading="lazy"];
- }
- .page.node {
- @if !matches!(node.kind, NodeKind::Collection) && !player {
- @let cls = format!("bigposter {}", aspect_class(node.kind));
- div[class=cls] { img[src=uri!(r_item_poster(&node.slug, Some(2048))), loading="lazy"]; }
- }
- .title {
- h1 { @node.title }
- ul.parents { @for (node, _) in *parents { li {
- a.component[href=uri!(r_library_node(&node.slug))] { @node.title }
- }}}
- @if node.media.is_some() {
- a.play[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { @trs(lang, "node.player_link") }
- }
- @if !matches!(node.kind, NodeKind::Collection | NodeKind::Channel) {
- @if matches!(udata.watched, WatchedState::None | WatchedState::Pending | WatchedState::Progress(_)) {
- form.mark_watched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Watched))] {
- input[type="submit", value=trs(lang, "node.watched.set")];
- }
- }
- @if matches!(udata.watched, WatchedState::Watched) {
- form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] {
- input[type="submit", value=trs(lang, "node.watched.unset")];
- }
- }
- @if matches!(udata.watched, WatchedState::None) {
- form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Pending))] {
- input[type="submit", value=trs(lang, "node.watchlist.set")];
- }
- }
- @if matches!(udata.watched, WatchedState::Pending) {
- form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] {
- input[type="submit", value=trs(lang, "node.watchlist.unset")];
- }
- }
- form.rating[method="POST", action=uri!(r_node_userdata_rating(&node.slug))] {
- input[type="range", name="rating", min=-10, max=10, step=1, value=udata.rating];
- input[type="submit", value=trs(lang, "node.update_rating")];
- }
- }
- }
- .details {
- @Props { node, udata, full: true, lang }
- h3 { @node.tagline }
- @if let Some(description) = &node.description {
- p { @for line in description.lines() { @line br; } }
- }
- @if let Some(media) = &node.media {
- @if !media.chapters.is_empty() {
- h2 { @trs(lang, "node.chapters") }
- ul.children.hlist { @for chap in &media.chapters {
- @let (inl, sub) = format_chapter(chap);
- li { .card."aspect-thumb" {
- .poster {
- a[href=&uri!(r_player(&node.slug, PlayerConfig::seek(chap.time_start.unwrap_or(0.))))] {
- img[src=&uri!(r_node_thumbnail(&node.slug, chapter_key_time(chap, media.duration), Some(1024))), loading="lazy"];
- }
- .cardhover { .props { p { @inl } } }
- }
- .title { span { @sub } }
- }}
- }}
- }
- @if !node.people.is_empty() {
- h2 { @trs(lang, "node.people") }
- @for (group, people) in &node.people {
- details[open=group==&PeopleGroup::Cast] {
- summary { h3 { @format!("{}", group) } }
- ul.children.hlist { @for (i, pe) in people.iter().enumerate() {
- li { .card."aspect-port" {
- .poster {
- a[href="#"] {
- img[src=&uri!(r_person_asset(&node.slug, i, group.to_string(), Some(1024))), loading="lazy"];
- }
- }
- .title {
- span { @pe.person.name } br;
- @if let Some(c) = pe.characters.first() {
- span.subtitle { @c }
- }
- @if let Some(c) = pe.jobs.first() {
- span.subtitle { @c }
- }
- }
- }}
- }}
- }
- }
- }
- details {
- summary { @trs(lang, "media.tracks") }
- ol { @for track in &media.tracks {
- li { @format!("{track}") }
- }}
- }
- }
- @if !node.external_ids.is_empty() {
- details {
- summary { @trs(lang, "node.external_ids") }
- table {
- @for (key, value) in &node.external_ids { tr {
- tr {
- td { @trs(lang, &format!("eid.{}", key)) }
- @if let Some(url) = external_id_url(key, value) {
- td { a[href=url] { pre { @value } } }
- } else {
- td { pre { @value } }
- }
- }
- }}
- }
- }
- }
- @if !node.tags.is_empty() {
- details {
- summary { @trs(lang, "node.tags") }
- ol { @for tag in &node.tags {
- li { @tag }
- }}
- }
- }
- }
- @if matches!(node.kind, NodeKind::Collection | NodeKind::Channel) {
- @NodeFilterSortForm { f: filter, lang }
- }
- @if !similar.is_empty() {
- h2 { @trs(lang, "node.similar") }
- ul.children.hlist {@for (node, udata) in similar.iter() {
- li { @NodeCard { node, udata, lang } }
- }}
- }
- @match node.kind {
- NodeKind::Show | NodeKind::Series | NodeKind::Season => {
- ol { @for (node, udata) in children.iter() {
- li { @NodeCardWide { node, udata, lang } }
- }}
- }
- NodeKind::Collection | NodeKind::Channel | _ => {
- ul.children {@for (node, udata) in children.iter() {
- li { @NodeCard { node, udata, lang } }
- }}
- }
- }
- }
- }
-
- Props<'a>(node: &'a Node, udata: &'a NodeUserData, full: bool, lang: &'a Language) {
- .props {
- @if let Some(m) = &node.media {
- p { @format_duration(m.duration) }
- p { @m.resolution_name() }
- }
- @if let Some(d) = &node.release_date {
- p { @if *full {
- @DateTime::from_timestamp_millis(*d).unwrap().naive_utc().to_string()
- } else {
- @DateTime::from_timestamp_millis(*d).unwrap().date_naive().to_string()
- }}
- }
- @match node.visibility {
- Visibility::Visible => {}
- Visibility::Reduced => {p.visibility{@trs(lang, "prop.vis.reduced")}}
- Visibility::Hidden => {p.visibility{@trs(lang, "prop.vis.hidden")}}
- }
- // TODO
- // @if !node.children.is_empty() {
- // p { @format!("{} items", node.children.len()) }
- // }
- @for (kind, value) in &node.ratings {
- @match kind {
- Rating::YoutubeLikes => {p.likes{ @format_count(*value as usize) " Likes" }}
- Rating::YoutubeViews => {p{ @format_count(*value as usize) " Views" }}
- Rating::YoutubeFollowers => {p{ @format_count(*value as usize) " Subscribers" }}
- Rating::RottenTomatoes => {p.rating{ @value " Tomatoes" }}
- Rating::Metacritic if *full => {p{ "Metacritic Score: " @value }}
- Rating::Imdb => {p.rating{ "IMDb " @value }}
- Rating::Tmdb => {p.rating{ "TMDB " @value }}
- Rating::Trakt if *full => {p.rating{ "Trakt " @value }}
- _ => {}
- }
- }
- @if let Some(f) = &node.federated {
- p.federation { @f }
- }
- @match udata.watched {
- WatchedState::None => {}
- WatchedState::Pending => { p.pending { @trs(lang, "prop.watched.pending") } }
- WatchedState::Progress(x) => { p.progress { @tr(**lang, "prop.watched.progress").replace("{time}", &format_duration(x)) } }
- WatchedState::Watched => { p.watched { @trs(lang, "prop.watched.watched") } }
- }
- }
- }
-}
-
-pub fn aspect_class(kind: NodeKind) -> &'static str {
- use NodeKind::*;
- match kind {
- Video | Episode => "aspect-thumb",
- Collection => "aspect-land",
- Season | Show | Series | Movie | ShortFormVideo => "aspect-port",
- Channel | Music | Unknown => "aspect-square",
- }
-}
-
-pub fn format_duration(d: f64) -> String {
- format_duration_mode(d, false, Language::English)
-}
-pub fn format_duration_long(d: f64, lang: Language) -> String {
- format_duration_mode(d, true, lang)
-}
-fn format_duration_mode(mut d: f64, long_units: bool, lang: Language) -> String {
- let mut s = String::new();
- let sign = if d > 0. { "" } else { "-" };
- d = d.abs();
- for (short, long, long_pl, k) in [
- ("d", "time.day", "time.days", 60. * 60. * 24.),
- ("h", "time.hour", "time.hours", 60. * 60.),
- ("m", "time.minute", "time.minutes", 60.),
- ("s", "time.second", "time.seconds", 1.),
- ] {
- let h = (d / k).floor();
- d -= h * k;
- if h > 0. {
- if long_units {
- let long = tr(lang, if h != 1. { long_pl } else { long });
- let and = format!(" {} ", tr(lang, "time.and_join"));
- // TODO breaks if seconds is zero
- write!(
- s,
- "{}{h} {long}{}",
- if k != 1. { "" } else { &and },
- if k > 60. { ", " } else { "" },
- )
- .unwrap();
- } else {
- write!(s, "{h}{short} ").unwrap();
- }
- }
- }
- format!("{sign}{}", s.trim())
-}
-pub fn format_size(size: u64) -> String {
- humansize::format_size(size, humansize::DECIMAL)
-}
-pub fn format_kind(k: NodeKind, lang: Language) -> TrString<'static> {
- trs(
- &lang,
- match k {
- NodeKind::Unknown => "kind.unknown",
- NodeKind::Movie => "kind.movie",
- NodeKind::Video => "kind.video",
- NodeKind::Music => "kind.music",
- NodeKind::ShortFormVideo => "kind.short_form_video",
- NodeKind::Collection => "kind.collection",
- NodeKind::Channel => "kind.channel",
- NodeKind::Show => "kind.show",
- NodeKind::Series => "kind.series",
- NodeKind::Season => "kind.season",
- NodeKind::Episode => "kind.episode",
- },
- )
-}
-
-pub trait DatabaseNodeUserDataExt {
- fn get_node_with_userdata(
- &self,
- id: NodeID,
- session: &Session,
- ) -> Result<(Arc<Node>, NodeUserData)>;
-}
-impl DatabaseNodeUserDataExt for Database {
- fn get_node_with_userdata(
- &self,
- id: NodeID,
- session: &Session,
- ) -> Result<(Arc<Node>, NodeUserData)> {
- Ok((
- self.get_node(id)?.ok_or(anyhow!("node does not exist"))?,
- self.get_node_udata(id, &session.user.name)?
- .unwrap_or_default(),
- ))
- }
-}
-
-trait MediaInfoExt {
- fn resolution_name(&self) -> &'static str;
-}
-impl MediaInfoExt for MediaInfo {
- fn resolution_name(&self) -> &'static str {
- let mut maxdim = 0;
- for t in &self.tracks {
- if let SourceTrackKind::Video { width, height, .. } = &t.kind {
- maxdim = maxdim.max(*width.max(height))
- }
- }
-
- match maxdim {
- 30720.. => "32K",
- 15360.. => "16K",
- 7680.. => "8K UHD",
- 5120.. => "5K UHD",
- 3840.. => "4K UHD",
- 2560.. => "QHD 1440p",
- 1920.. => "FHD 1080p",
- 1280.. => "HD 720p",
- 854.. => "SD 480p",
- _ => "Unkown",
- }
- }
-}
-
-fn format_count(n: impl Into<usize>) -> String {
- let n: usize = n.into();
-
- if n >= 1_000_000 {
- format!("{:.1}M", n as f32 / 1_000_000.)
- } else if n >= 1_000 {
- format!("{:.1}k", n as f32 / 1_000.)
- } else {
- format!("{n}")
- }
-}
-
-fn format_chapter(c: &Chapter) -> (String, String) {
- (
- format!(
- "{}-{}",
- c.time_start.map(format_duration).unwrap_or_default(),
- c.time_end.map(format_duration).unwrap_or_default(),
- ),
- c.labels.first().map(|l| l.1.clone()).unwrap_or_default(),
- )
-}
-
-fn chapter_key_time(c: &Chapter, dur: f64) -> f64 {
- let start = c.time_start.unwrap_or(0.);
- let end = c.time_end.unwrap_or(dur);
- start * 0.8 + end * 0.2
-}
-
-fn external_id_url(key: &str, value: &str) -> Option<String> {
- Some(match key {
- "youtube.video" => format!("https://youtube.com/watch?v={value}"),
- "youtube.channel" => format!("https://youtube.com/channel/{value}"),
- "youtube.channelname" => format!("https://youtube.com/channel/@{value}"),
- "musicbrainz.release" => format!("https://musicbrainz.org/release/{value}"),
- "musicbrainz.albumartist" => format!("https://musicbrainz.org/artist/{value}"),
- "musicbrainz.artist" => format!("https://musicbrainz.org/artist/{value}"),
- "musicbrainz.releasegroup" => format!("https://musicbrainz.org/release-group/{value}"),
- "musicbrainz.recording" => format!("https://musicbrainz.org/recording/{value}"),
- _ => return None,
- })
-}
diff --git a/server/src/routes/ui/player.rs b/server/src/routes/ui/player.rs
deleted file mode 100644
index 2bb439b..0000000
--- a/server/src/routes/ui/player.rs
+++ /dev/null
@@ -1,200 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- account::session::{token, Session},
- layout::LayoutPage,
- node::{get_similar_media, DatabaseNodeUserDataExt, NodePage},
- sort::NodeFilterSort,
-};
-use crate::{
- database::Database,
- routes::{
- locale::AcceptLanguage,
- 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(),
- &token::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/routes/ui/search.rs b/server/src/routes/ui/search.rs
deleted file mode 100644
index bc84e57..0000000
--- a/server/src/routes/ui/search.rs
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- account::session::Session,
- error::MyResult,
- layout::{trs, DynLayoutPage, LayoutPage},
- node::{DatabaseNodeUserDataExt, NodeCard},
-};
-use crate::routes::{api::AcceptJson, locale::AcceptLanguage};
-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/routes/ui/sort.rs b/server/src/routes/ui/sort.rs
deleted file mode 100644
index 6d38e11..0000000
--- a/server/src/routes/ui/sort.rs
+++ /dev/null
@@ -1,290 +0,0 @@
-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;
-
-use crate::routes::ui::layout::trs;
-
-#[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/routes/ui/stats.rs b/server/src/routes/ui/stats.rs
deleted file mode 100644
index 4e4eef1..0000000
--- a/server/src/routes/ui/stats.rs
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- account::session::Session,
- error::MyError,
- layout::{DynLayoutPage, LayoutPage},
-};
-use crate::{
- database::Database,
- routes::{
- api::AcceptJson,
- locale::AcceptLanguage,
- 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/routes/ui/style.rs b/server/src/routes/ui/style.rs
deleted file mode 100644
index c935c8a..0000000
--- a/server/src/routes/ui/style.rs
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
- 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()))
-}