diff options
author | metamuffin <metamuffin@disroot.org> | 2025-04-27 19:25:11 +0200 |
---|---|---|
committer | metamuffin <metamuffin@disroot.org> | 2025-04-27 19:25:11 +0200 |
commit | 11a585b3dbe620dcc8772e713b22f1d9ba80d598 (patch) | |
tree | 44f8d97137412aefc79a2425a489c34fa3e5f6c5 /server/src/routes/ui | |
parent | d871aa7c5bba49ff55170b5d2dac9cd440ae7170 (diff) | |
download | jellything-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.rs | 261 | ||||
-rw-r--r-- | server/src/routes/ui/account/session/guard.rs | 106 | ||||
-rw-r--r-- | server/src/routes/ui/account/session/mod.rs | 24 | ||||
-rw-r--r-- | server/src/routes/ui/account/session/token.rs | 97 | ||||
-rw-r--r-- | server/src/routes/ui/account/settings.rs | 187 | ||||
-rw-r--r-- | server/src/routes/ui/admin/log.rs | 258 | ||||
-rw-r--r-- | server/src/routes/ui/admin/mod.rs | 290 | ||||
-rw-r--r-- | server/src/routes/ui/admin/user.rs | 176 | ||||
-rw-r--r-- | server/src/routes/ui/assets.rs | 200 | ||||
-rw-r--r-- | server/src/routes/ui/browser.rs | 83 | ||||
-rw-r--r-- | server/src/routes/ui/error.rs | 104 | ||||
-rw-r--r-- | server/src/routes/ui/home.rs | 180 | ||||
-rw-r--r-- | server/src/routes/ui/layout.rs | 184 | ||||
-rw-r--r-- | server/src/routes/ui/mod.rs | 131 | ||||
-rw-r--r-- | server/src/routes/ui/node.rs | 558 | ||||
-rw-r--r-- | server/src/routes/ui/player.rs | 200 | ||||
-rw-r--r-- | server/src/routes/ui/search.rs | 70 | ||||
-rw-r--r-- | server/src/routes/ui/sort.rs | 290 | ||||
-rw-r--r-- | server/src/routes/ui/stats.rs | 133 | ||||
-rw-r--r-- | server/src/routes/ui/style.rs | 90 |
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("<"), - b'>' => Some(">"), - b'&' => Some("&"), - b'"' => Some("""), - _ => None, - } { - o += &str[last..index]; - o += esc; - last = index + 1; - } - } - o += &str[last..]; - o -} - -pub fn trs<'a>(lang: &Language, key: &str) -> TrString<'a> { - TrString(tr(*lang, key)) -} - -markup::define! { - Layout<'a, Main: Render>(title: String, main: Main, class: &'a str, session: Option<Session>, lang: Language) { - @markup::doctype() - html { - head { - title { @title " - " @CONF.brand } - meta[name="viewport", content="width=device-width, initial-scale=1.0"]; - link[rel="stylesheet", href="/assets/style.css"]; - script[src="/assets/bundle.js"] {} - } - body[class=class] { - nav { - h1 { a[href=if session.is_some() {"/home"} else {"/"}] { @if *LOGO_ENABLED { img.logo[src="/assets/logo.svg"]; } else { @CONF.brand } } } " " - @if let Some(_) = session { - a.library[href=uri!(r_library_node("library"))] { @trs(lang, "nav.root") } " " - a.library[href=uri!(r_all_items())] { @trs(lang, "nav.all") } " " - a.library[href=uri!(r_search(None::<&'static str>, None::<usize>))] { @trs(lang, "nav.search") } " " - a.library[href=uri!(r_stats())] { @trs(lang, "nav.stats") } " " - } - @if is_importing() { span.warn { "Library database is updating..." } } - div.account { - @if let Some(session) = session { - span { @raw(tr(*lang, "nav.username").replace("{name}", &format!("<b class=\"username\">{}</b>", escape(&session.user.display_name)))) } " " - @if session.user.admin { - a.admin.hybrid_button[href=uri!(r_admin_dashboard())] { p {@trs(lang, "nav.admin")} } " " - } - a.settings.hybrid_button[href=uri!(r_account_settings())] { p {@trs(lang, "nav.settings")} } " " - a.logout.hybrid_button[href=uri!(r_account_logout())] { p {@trs(lang, "nav.logout")} } - } else { - a.register.hybrid_button[href=uri!(r_account_register())] { p {@trs(lang, "nav.register")} } " " - a.login.hybrid_button[href=uri!(r_account_login())] { p {@trs(lang, "nav.login")} } - } - } - } - #main { @main } - footer { - p { @CONF.brand " - " @CONF.slogan " | powered by " a[href="https://codeberg.org/metamuffin/jellything"]{"Jellything"} } - } - } - } - } - - FlashDisplay(flash: Option<Result<String, String>>) { - @if let Some(flash) = &flash { - @match flash { - Ok(mesg) => { section.message { p.success { @mesg } } } - Err(err) => { section.message { p.error { @err } } } - } - } - } -} - -pub type DynLayoutPage<'a> = LayoutPage<markup::DynRender<'a>>; - -pub struct LayoutPage<T> { - pub title: String, - pub class: Option<&'static str>, - pub content: T, -} - -impl Default for LayoutPage<DynRender<'_>> { - fn default() -> Self { - Self { - class: None, - content: markup::new!(), - title: String::new(), - } - } -} - -impl<'r, Main: Render> Responder<'r, 'static> for LayoutPage<Main> { - fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { - // TODO blocking the event loop here. it seems like there is no other way to - // TODO offload this, since the guard references `req` which has a lifetime. - // TODO therefore we just block. that is fine since the database is somewhat fast. - let lang = lang_from_request(&req); - let session = block_on(req.guard::<Option<Session>>()).unwrap(); - let mut out = String::new(); - Layout { - main: self.content, - title: self.title, - class: &format!( - "{} theme-{:?}", - self.class.unwrap_or(""), - session - .as_ref() - .map(|s| s.user.theme) - .unwrap_or(Theme::Dark) - ), - session, - lang, - } - .render(&mut out) - .unwrap(); - - Response::build() - .header(ContentType::HTML) - .streamed_body(Cursor::new(out)) - .ok() - } -} diff --git a/server/src/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())) -} |