aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock35
-rw-r--r--base/Cargo.toml1
-rw-r--r--common/Cargo.toml4
-rw-r--r--common/src/api.rs52
-rw-r--r--common/src/lib.rs1
-rw-r--r--common/src/routes.rs17
-rw-r--r--logic/Cargo.toml8
-rw-r--r--logic/src/filter_sort.rs (renamed from server/src/ui/sort.rs)6
-rw-r--r--logic/src/home.rs7
-rw-r--r--logic/src/items.rs42
-rw-r--r--logic/src/lib.rs7
-rw-r--r--logic/src/node.rs112
-rw-r--r--logic/src/search.rs31
-rw-r--r--logic/src/session.rs112
-rw-r--r--logic/src/stats.rs48
-rw-r--r--server/Cargo.toml7
-rw-r--r--server/src/logic/session.rs94
-rw-r--r--server/src/ui/account/settings.rs52
-rw-r--r--server/src/ui/browser.rs25
-rw-r--r--server/src/ui/home.rs10
-rw-r--r--server/src/ui/mod.rs1
-rw-r--r--server/src/ui/node.rs46
-rw-r--r--server/src/ui/search.rs16
-rw-r--r--server/src/ui/stats.rs68
-rw-r--r--ui/Cargo.toml2
-rw-r--r--ui/src/filter_sort.rs24
-rw-r--r--ui/src/format.rs5
-rw-r--r--ui/src/home.rs24
-rw-r--r--ui/src/lib.rs27
-rw-r--r--ui/src/node_page.rs7
-rw-r--r--ui/src/props.rs4
-rw-r--r--ui/src/scaffold.rs4
-rw-r--r--ui/src/search.rs38
-rw-r--r--ui/src/settings.rs76
-rw-r--r--ui/src/stats.rs45
35 files changed, 654 insertions, 404 deletions
diff --git a/Cargo.lock b/Cargo.lock
index c2705b7..98a4879 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -342,9 +342,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
-version = "1.6.0"
+version = "1.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
+checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3"
[[package]]
name = "binascii"
@@ -1807,7 +1807,6 @@ dependencies = [
"blake3",
"chrono",
"hex",
- "rocket",
"serde",
]
@@ -1848,6 +1847,22 @@ dependencies = [
]
[[package]]
+name = "jellylogic"
+version = "0.1.0"
+dependencies = [
+ "aes-gcm-siv",
+ "anyhow",
+ "argon2",
+ "base64",
+ "bincode",
+ "jellybase",
+ "jellycommon",
+ "log",
+ "rand 0.9.1",
+ "serde",
+]
+
+[[package]]
name = "jellymatroska"
version = "0.1.0"
dependencies = [
@@ -1893,9 +1908,7 @@ dependencies = [
name = "jellything"
version = "0.1.1"
dependencies = [
- "aes-gcm-siv",
"anyhow",
- "argon2",
"async-recursion",
"base64",
"bincode",
@@ -1908,8 +1921,10 @@ dependencies = [
"jellycache",
"jellycommon",
"jellyimport",
+ "jellylogic",
"jellystream",
"jellytranscoder",
+ "jellyui",
"log",
"rand 0.9.1",
"rocket",
@@ -2108,16 +2123,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
-name = "logic"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "jellybase",
- "jellycommon",
- "log",
-]
-
-[[package]]
name = "loom"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/base/Cargo.toml b/base/Cargo.toml
index 0ab4a4b..e19caff 100644
--- a/base/Cargo.toml
+++ b/base/Cargo.toml
@@ -24,4 +24,3 @@ humansize = "2.1.3"
[features]
db_json = []
-rocket = ["jellycommon/rocket"]
diff --git a/common/Cargo.toml b/common/Cargo.toml
index 775cd2a..90372bc 100644
--- a/common/Cargo.toml
+++ b/common/Cargo.toml
@@ -6,10 +6,6 @@ edition = "2021"
[dependencies]
serde = { version = "1.0.217", features = ["derive", "rc"] }
bincode = { version = "2.0.0-rc.3", features = ["derive"] }
-rocket = { workspace = true, optional = true }
chrono = { version = "0.4.39", features = ["serde"] }
blake3 = "1.5.5"
hex = "0.4.3"
-
-[features]
-rocket = ["dep:rocket"]
diff --git a/common/src/api.rs b/common/src/api.rs
index 6982e76..a58c445 100644
--- a/common/src/api.rs
+++ b/common/src/api.rs
@@ -3,11 +3,9 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use crate::{user::NodeUserData, Node};
-#[cfg(feature = "rocket")]
-use rocket::{FromForm, FromFormField, UriDisplayQuery};
+use crate::{user::NodeUserData, Node, NodeKind};
use serde::{Deserialize, Serialize};
-use std::sync::Arc;
+use std::{collections::BTreeMap, sync::Arc, time::Duration};
type NodesWithUdata = Vec<(Arc<Node>, NodeUserData)>;
@@ -23,6 +21,7 @@ pub struct ApiNodeResponse {
pub struct ApiSearchResponse {
pub count: usize,
pub results: NodesWithUdata,
+ pub duration: Duration,
}
#[derive(Serialize, Deserialize)]
@@ -38,6 +37,21 @@ pub struct ApiHomeResponse {
pub categories: Vec<(String, NodesWithUdata)>,
}
+#[derive(Serialize, Deserialize)]
+pub struct ApiStatsResponse {
+ pub kinds: BTreeMap<NodeKind, StatsBin>,
+ pub total: StatsBin,
+}
+
+#[derive(Default, Serialize, Deserialize)]
+pub struct StatsBin {
+ pub runtime: f64,
+ pub size: u64,
+ pub count: usize,
+ pub max_runtime: (f64, String),
+ pub max_size: (u64, String),
+}
+
#[derive(Debug, Default, Clone)]
#[cfg_attr(feature = "rocket", derive(FromForm, UriDisplayQuery))]
pub struct NodeFilterSort {
@@ -46,24 +60,25 @@ pub struct NodeFilterSort {
pub sort_order: Option<SortOrder>,
}
-#[rustfmt::skip]
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-#[cfg_attr(feature = "rocket", derive(FromFormField, UriDisplayQuery))]
-pub enum SortOrder {
- #[field(value = "ascending")] Ascending,
- #[field(value = "descending")] Descending,
-}
-macro_rules! form_enum {
+pub trait UrlEnum: Sized {
+ fn to_str(&self) -> &'static str;
+ fn from_str(s: &str) -> Option<Self>;
+}
+macro_rules! url_enum {
(enum $i:ident { $($vi:ident = $vk:literal),*, }) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "rocket", derive(FromFormField, UriDisplayQuery))]
pub enum $i { $(#[cfg_attr(feature = "rocket", field(value = $vk))] $vi),* }
- impl $i { #[allow(unused)] const ALL: &'static [$i] = &[$($i::$vi),*]; }
+ impl $i { pub const ALL: &'static [$i] = &[$($i::$vi),*]; }
+ impl UrlEnum for $i {
+ fn to_str(&self) -> &'static str { match self { $(Self::$vi => $vk),* } }
+ fn from_str(s: &str) -> Option<Self> { match s { $($vk => Some(Self::$vi) ),*, _ => None } }
+ }
};
}
-form_enum!(
+url_enum!(
enum FilterProperty {
FederationLocal = "fed_local",
FederationRemote = "fed_remote",
@@ -82,8 +97,7 @@ form_enum!(
KindEpisode = "kind_episode",
}
);
-
-form_enum!(
+url_enum!(
enum SortProperty {
ReleaseDate = "release_date",
Title = "title",
@@ -100,3 +114,9 @@ form_enum!(
RatingLikesDivViews = "rating_loved",
}
);
+url_enum!(
+ enum SortOrder {
+ Ascending = "ascending",
+ Descending = "descending",
+ }
+);
diff --git a/common/src/lib.rs b/common/src/lib.rs
index eaf5900..c606b86 100644
--- a/common/src/lib.rs
+++ b/common/src/lib.rs
@@ -11,6 +11,7 @@ pub mod r#impl;
pub mod jhls;
pub mod stream;
pub mod user;
+pub mod routes;
pub use chrono;
diff --git a/common/src/routes.rs b/common/src/routes.rs
new file mode 100644
index 0000000..71ca3fa
--- /dev/null
+++ b/common/src/routes.rs
@@ -0,0 +1,17 @@
+/*
+ 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::NodeID;
+
+pub fn u_home() -> String {
+ "/home".to_owned()
+}
+pub fn u_node_id(node: NodeID) -> String {
+ format!("/n/{}", node)
+}
+pub fn u_node_slug(node: &str) -> String {
+ format!("/n/{}", node)
+}
diff --git a/logic/Cargo.toml b/logic/Cargo.toml
index 4107c14..bd8a28d 100644
--- a/logic/Cargo.toml
+++ b/logic/Cargo.toml
@@ -1,5 +1,5 @@
[package]
-name = "logic"
+name = "jellylogic"
version = "0.1.0"
edition = "2024"
@@ -8,3 +8,9 @@ jellybase = { path = "../base" }
jellycommon = { path = "../common" }
log = "0.4.27"
anyhow = "1.0.98"
+base64 = "0.22.1"
+argon2 = "0.5.3"
+aes-gcm-siv = "0.11.1"
+serde = { version = "1.0.217", features = ["derive", "rc"] }
+bincode = { version = "2.0.0-rc.3", features = ["serde", "derive"] }
+rand = "0.9.0"
diff --git a/server/src/ui/sort.rs b/logic/src/filter_sort.rs
index 441bac6..3ccbc0d 100644
--- a/server/src/ui/sort.rs
+++ b/logic/src/filter_sort.rs
@@ -4,14 +4,10 @@
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
use jellycommon::{
+ Node, NodeKind, Rating,
api::{FilterProperty, NodeFilterSort, SortOrder, SortProperty},
helpers::SortAnyway,
user::NodeUserData,
- Node, NodeKind, Rating,
-};
-use rocket::{
- http::uri::fmt::{Query, UriDisplay},
- FromForm, FromFormField, UriDisplayQuery,
};
use std::sync::Arc;
diff --git a/logic/src/home.rs b/logic/src/home.rs
index f03173e..b774a9f 100644
--- a/logic/src/home.rs
+++ b/logic/src/home.rs
@@ -4,6 +4,7 @@
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
+use crate::{node::DatabaseNodeUserDataExt, session::Session};
use anyhow::{Context, Result};
use jellybase::database::Database;
use jellycommon::{
@@ -13,14 +14,14 @@ use jellycommon::{
user::WatchedState,
};
-pub fn home(db: Database) -> Result<ApiHomeResponse> {
- let mut items = db.list_nodes_with_udata(&sess.user.name)?;
+pub fn home(db: &Database, session: &Session) -> Result<ApiHomeResponse> {
+ let mut items = db.list_nodes_with_udata(&session.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))
+ .map(|n| db.get_node_with_userdata(n, &session))
.collect::<anyhow::Result<Vec<_>>>()?;
toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX));
diff --git a/logic/src/items.rs b/logic/src/items.rs
new file mode 100644
index 0000000..67c45cb
--- /dev/null
+++ b/logic/src/items.rs
@@ -0,0 +1,42 @@
+/*
+ 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::{filter_sort::filter_and_sort_nodes, session::Session};
+use anyhow::Result;
+use jellybase::database::Database;
+use jellycommon::{
+ Visibility,
+ api::{ApiItemsResponse, NodeFilterSort, SortOrder, SortProperty},
+};
+
+pub fn all_items(
+ db: &Database,
+ session: &Session,
+ page: Option<usize>,
+ filter: NodeFilterSort,
+) -> Result<ApiItemsResponse> {
+ let mut items = db.list_nodes_with_udata(session.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(ApiItemsResponse {
+ count: items.len(),
+ pages: max_page,
+ items: items[from..to].to_vec(),
+ })
+}
diff --git a/logic/src/lib.rs b/logic/src/lib.rs
index cc988d7..a47afc3 100644
--- a/logic/src/lib.rs
+++ b/logic/src/lib.rs
@@ -3,5 +3,12 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
+#![feature(duration_constructors)]
+pub mod filter_sort;
pub mod home;
+pub mod node;
+pub mod search;
+pub mod session;
+pub mod stats;
+pub mod items;
diff --git a/logic/src/node.rs b/logic/src/node.rs
new file mode 100644
index 0000000..8a53bec
--- /dev/null
+++ b/logic/src/node.rs
@@ -0,0 +1,112 @@
+/*
+ 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::{filter_sort::filter_and_sort_nodes, session::Session};
+use anyhow::{Result, anyhow};
+use jellybase::database::Database;
+use jellycommon::{
+ Node, NodeID, NodeKind, Visibility,
+ api::{ApiNodeResponse, NodeFilterSort, SortOrder, SortProperty},
+ user::NodeUserData,
+};
+use std::{cmp::Reverse, collections::BTreeMap, sync::Arc};
+
+pub fn get_node(
+ db: &Database,
+ id: NodeID,
+ session: &Session,
+ children: bool,
+ parents: bool,
+ filter: NodeFilterSort,
+) -> Result<ApiNodeResponse> {
+ let (node, udata) = db.get_node_with_userdata(id, &session)?;
+
+ let mut children = if 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 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(ApiNodeResponse {
+ children,
+ parents,
+ node,
+ userdata: udata,
+ })
+}
+
+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<_>>>()
+}
+
+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(),
+ ))
+ }
+}
diff --git a/logic/src/search.rs b/logic/src/search.rs
new file mode 100644
index 0000000..8e41e27
--- /dev/null
+++ b/logic/src/search.rs
@@ -0,0 +1,31 @@
+/*
+ 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::{node::DatabaseNodeUserDataExt, session::Session};
+use anyhow::Result;
+use jellybase::database::Database;
+use jellycommon::{Visibility, api::ApiSearchResponse};
+use std::time::Instant;
+
+pub fn search(
+ db: &Database,
+ session: &Session,
+ query: &str,
+ page: Option<usize>,
+) -> Result<ApiSearchResponse> {
+ let timing = Instant::now();
+ let (count, ids) = db.search(query, 32, page.unwrap_or_default() * 32)?;
+ let mut results = ids
+ .into_iter()
+ .map(|id| db.get_node_with_userdata(id, &session))
+ .collect::<Result<Vec<_>, anyhow::Error>>()?;
+ results.retain(|(n, _)| n.visibility >= Visibility::Reduced);
+ let duration = timing.elapsed();
+ Ok(ApiSearchResponse {
+ count,
+ results,
+ duration,
+ })
+}
diff --git a/logic/src/session.rs b/logic/src/session.rs
new file mode 100644
index 0000000..bc7f137
--- /dev/null
+++ b/logic/src/session.rs
@@ -0,0 +1,112 @@
+/*
+ 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 aes_gcm_siv::{
+ KeyInit,
+ aead::{Aead, generic_array::GenericArray},
+};
+use anyhow::anyhow;
+use base64::Engine;
+use jellybase::SECRETS;
+use jellycommon::{
+ chrono::{DateTime, Utc},
+ user::{PermissionSet, User},
+};
+use log::warn;
+use serde::{Deserialize, Serialize};
+use std::{sync::LazyLock, time::Duration};
+
+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,
+}
+
+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::from_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/logic/src/stats.rs b/logic/src/stats.rs
new file mode 100644
index 0000000..2569180
--- /dev/null
+++ b/logic/src/stats.rs
@@ -0,0 +1,48 @@
+/*
+ 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::session::Session;
+use anyhow::Result;
+use jellybase::database::Database;
+use jellycommon::{
+ Node, NodeKind, Visibility,
+ api::{ApiStatsResponse, StatsBin},
+};
+use std::collections::BTreeMap;
+
+pub fn stats(db: &Database, session: &Session) -> Result<ApiStatsResponse> {
+ let mut items = db.list_nodes_with_udata(session.user.name.as_str())?;
+ items.retain(|(n, _)| n.visibility >= Visibility::Reduced);
+
+ trait BinExt {
+ fn update(&mut self, node: &Node);
+ }
+ impl BinExt for StatsBin {
+ 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())
+ }
+ }
+ }
+
+ }
+
+ let mut total = StatsBin::default();
+ let mut kinds = BTreeMap::<NodeKind, StatsBin>::new();
+ for (i, _) in items {
+ total.update(&i);
+ kinds.entry(i.kind).or_default().update(&i);
+ }
+
+ Ok(ApiStatsResponse { kinds, total })
+}
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 669194a..b658e61 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -4,12 +4,14 @@ version = "0.1.1"
edition = "2021"
[dependencies]
-jellycommon = { path = "../common", features = ["rocket"] }
+jellycommon = { path = "../common" }
jellybase = { path = "../base" }
jellystream = { path = "../stream" }
jellytranscoder = { path = "../transcoder" }
jellyimport = { path = "../import" }
jellycache = { path = "../cache" }
+jellyui = { path = "../ui" }
+jellylogic = { path = "../logic" }
serde = { version = "1.0.217", features = ["derive", "rc"] }
bincode = { version = "2.0.0-rc.3", features = ["serde", "derive"] }
@@ -24,9 +26,6 @@ chrono = { version = "0.4.39", features = ["serde"] }
vte = "0.14.1"
chashmap = "2.2.2"
-argon2 = "0.5.3"
-aes-gcm-siv = "0.11.1"
-
async-recursion = "1.1.1"
futures = "0.3.31"
tokio = { workspace = true }
diff --git a/server/src/logic/session.rs b/server/src/logic/session.rs
index 790e070..d77c4fc 100644
--- a/server/src/logic/session.rs
+++ b/server/src/logic/session.rs
@@ -13,6 +13,7 @@ use base64::Engine;
use chrono::{DateTime, Duration, Utc};
use jellybase::{database::Database, SECRETS};
use jellycommon::user::{PermissionSet, User};
+use jellylogic::session::validate;
use log::warn;
use rocket::{
async_trait,
@@ -24,19 +25,6 @@ use rocket::{
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
-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,
-}
-
impl Session {
pub async fn from_request_ut(req: &Request<'_>) -> Result<Self, MyError> {
let username;
@@ -126,83 +114,3 @@ impl<'r> FromRequest<'r> for AdminSession {
}
}
}
-
-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/ui/account/settings.rs b/server/src/ui/account/settings.rs
index 4047e4f..3b53bc4 100644
--- a/server/src/ui/account/settings.rs
+++ b/server/src/ui/account/settings.rs
@@ -51,57 +51,7 @@ fn settings_page(
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" } "." }
- }
- },
+ content:
}
}
diff --git a/server/src/ui/browser.rs b/server/src/ui/browser.rs
index f7eac93..b780934 100644
--- a/server/src/ui/browser.rs
+++ b/server/src/ui/browser.rs
@@ -30,29 +30,10 @@ pub fn r_all_items_filter(
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);
-
+
+ let data = all_items()?;
Ok(if *aj {
- Either::Right(Json(ApiItemsResponse {
- count: items.len(),
- pages: max_page,
- items: items[from..to].to_vec(),
- }))
+ Either::Right(Json(data))
} else {
Either::Left(LayoutPage {
title: "All Items".to_owned(),
diff --git a/server/src/ui/home.rs b/server/src/ui/home.rs
index 96b1dc2..2a79965 100644
--- a/server/src/ui/home.rs
+++ b/server/src/ui/home.rs
@@ -18,14 +18,12 @@ pub fn r_home(
lang: AcceptLanguage,
) -> MyResult<Either<DynLayoutPage, Json<ApiHomeResponse>>> {
let AcceptLanguage(lang) = lang;
-
+
+ let resp = jellylogic::home::home(&db, sess)?;
Ok(if *aj {
- Either::Right(Json(ApiHomeResponse {
- toplevel,
- categories,
- }))
+ Either::Right(Json(resp))
} else {
- Either::Left()
+ Either::Left(jellyui::home::home_page(resp))
})
}
diff --git a/server/src/ui/mod.rs b/server/src/ui/mod.rs
index 89c0e9a..6728b81 100644
--- a/server/src/ui/mod.rs
+++ b/server/src/ui/mod.rs
@@ -38,7 +38,6 @@ pub mod home;
pub mod node;
pub mod player;
pub mod search;
-pub mod sort;
pub mod stats;
pub mod style;
diff --git a/server/src/ui/node.rs b/server/src/ui/node.rs
index 1efcc10..5d0f1ff 100644
--- a/server/src/ui/node.rs
+++ b/server/src/ui/node.rs
@@ -92,49 +92,3 @@ pub async fn r_library_node_filter<'a>(
})
})
}
-
-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<_>>>()
-}
-
-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(),
- ))
- }
-}
diff --git a/server/src/ui/search.rs b/server/src/ui/search.rs
index bfe51a8..51fdcb8 100644
--- a/server/src/ui/search.rs
+++ b/server/src/ui/search.rs
@@ -25,21 +25,7 @@ pub async fn r_search<'a>(
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"))?
diff --git a/server/src/ui/stats.rs b/server/src/ui/stats.rs
index 345586a..a91e670 100644
--- a/server/src/ui/stats.rs
+++ b/server/src/ui/stats.rs
@@ -8,21 +8,14 @@ use super::{
layout::{DynLayoutPage, LayoutPage},
};
use crate::{
- api::AcceptJson,
- database::Database,
- locale::AcceptLanguage,
- logic::session::Session,
- ui::{
- layout::trs,
- node::{
- format_duration, format_duration_long, format_kind, format_size,
- rocket_uri_macro_r_library_node,
- },
- },
- uri,
+ api::AcceptJson, database::Database, locale::AcceptLanguage, logic::session::Session, uri,
};
use jellybase::locale::tr;
-use jellycommon::{Node, NodeID, NodeKind, Visibility};
+use jellycommon::{
+ api::{ApiStatsResponse, StatsBin},
+ Node, NodeID, NodeKind, Visibility,
+};
+use jellylogic::stats::stats;
use markup::raw;
use rocket::{get, serde::json::Json, Either, State};
use serde::Serialize;
@@ -35,54 +28,13 @@ pub fn r_stats(
db: &State<Database>,
aj: AcceptJson,
lang: AcceptLanguage,
-) -> Result<Either<DynLayoutPage<'_>, Json<Value>>, MyError> {
+) -> Result<Either<DynLayoutPage<'_>, Json<ApiStatsResponse>>, 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);
- }
+ let data = stats(db)?;
Ok(if *aj {
- Either::Right(Json(json!({
- "all": all,
- "kinds": kinds,
- })))
+ Either::Right(Json(data))
} else {
- Either::Left()
+ Either::Left(1)
})
}
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index 0e8f0fd..86f336c 100644
--- a/ui/Cargo.toml
+++ b/ui/Cargo.toml
@@ -5,5 +5,5 @@ edition = "2024"
[dependencies]
markup = "0.15.0"
-jellycommon = { path = "../common", features = ["rocket"] }
+jellycommon = { path = "../common" }
humansize = "2.1.3"
diff --git a/ui/src/filter_sort.rs b/ui/src/filter_sort.rs
index 53d4ea3..ec83f6f 100644
--- a/ui/src/filter_sort.rs
+++ b/ui/src/filter_sort.rs
@@ -5,6 +5,7 @@
*/
use jellycommon::api::{FilterProperty, NodeFilterSort, SortOrder, SortProperty};
+use markup::RenderAttributeValue;
use crate::locale::{Language, trs};
@@ -79,11 +80,11 @@ markup::define! {
fieldset.filter {
legend { "Filter" }
.categories {
- @for (cname, cat) in FilterProperty::CATS {
+ @for (cname, cat) in FILTER_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;
+ label { input[type="checkbox", name="filter_kind", value=A(*value), checked=f.filter_kind.as_ref().map(|k|k.contains(value)).unwrap_or(true)]; @trs(lang, label) } br;
}
}
}
@@ -92,11 +93,11 @@ markup::define! {
fieldset.sortby {
legend { "Sort" }
.categories {
- @for (cname, cat) in SortProperty::CATS {
+ @for (cname, cat) in SORT_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;
+ label { input[type="radio", name="sort_by", value=A(*value), checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br;
}
}
}
@@ -106,7 +107,7 @@ markup::define! {
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;
+ label { input[type="radio", name="sort_order", value=A(value), checked=Some(value)==f.sort_order]; @trs(lang, label) } br;
}
}
input[type="submit", value="Apply"]; a[href="?"] { "Clear" }
@@ -115,21 +116,22 @@ markup::define! {
}
}
-impl markup::Render for SortProperty {
+struct A<T>(pub T);
+impl markup::Render for A<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 {
+impl markup::Render for A<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 {
+impl markup::Render for A<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 {}
+impl RenderAttributeValue for A<SortOrder> {}
+impl RenderAttributeValue for A<FilterProperty> {}
+impl RenderAttributeValue for A<SortProperty> {}
diff --git a/ui/src/format.rs b/ui/src/format.rs
index a374850..84e4c27 100644
--- a/ui/src/format.rs
+++ b/ui/src/format.rs
@@ -6,6 +6,7 @@
use crate::locale::{Language, TrString, tr, trs};
use jellycommon::{Chapter, MediaInfo, NodeKind, SourceTrackKind};
+use std::fmt::Write;
pub fn format_duration(d: f64) -> String {
format_duration_mode(d, false, Language::English)
@@ -66,10 +67,10 @@ pub fn format_kind(k: NodeKind, lang: Language) -> TrString<'static> {
)
}
-trait MediaInfoExt {
+pub trait MediaInfoExt {
fn resolution_name(&self) -> &'static str;
}
-impl MediaInfoExt for MediaInfo {
+impl MediaInfoExt for &MediaInfo {
fn resolution_name(&self) -> &'static str {
let mut maxdim = 0;
for t in &self.tracks {
diff --git a/ui/src/home.rs b/ui/src/home.rs
index 7b58179..ec0c634 100644
--- a/ui/src/home.rs
+++ b/ui/src/home.rs
@@ -3,20 +3,21 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-
use crate::{
+ Page,
locale::{Language, tr, trs},
node_card::NodeCard,
- scaffold::LayoutPage,
};
+use jellycommon::api::ApiHomeResponse;
+use markup::DynRender;
markup::define! {
- HomePage<'a>(lang: &'a Language) {
- h2 { @tr(lang, "home.bin.root").replace("{title}", &CONF.brand) }
- ul.children.hlist {@for (node, udata) in &toplevel {
+ HomePage<'a>(lang: &'a Language, r: &'a ApiHomeResponse) {
+ h2 { @trs(lang, "home.bin.root") } //.replace("{title}", &CONF.brand) }
+ ul.children.hlist {@for (node, udata) in &r.toplevel {
li { @NodeCard { node, udata, lang: &lang } }
}}
- @for (name, nodes) in &categories {
+ @for (name, nodes) in &r.categories {
@if !nodes.is_empty() {
h2 { @trs(&lang, &name) }
ul.children.hlist {@for (node, udata) in nodes {
@@ -27,10 +28,11 @@ markup::define! {
}
}
-pub fn home_page() {
- LayoutPage {
- title: tr(lang, "home").to_string(),
- content: HomePage { lang: &lang },
- ..Default::default()
+impl Page for HomePage<'_> {
+ fn title(&self) -> String {
+ tr(*self.lang, "home").to_string()
+ }
+ fn to_render(&self) -> DynRender {
+ markup::new!(@self)
}
}
diff --git a/ui/src/lib.rs b/ui/src/lib.rs
index 40d43dd..4298623 100644
--- a/ui/src/lib.rs
+++ b/ui/src/lib.rs
@@ -1,15 +1,34 @@
+use markup::DynRender;
+
/*
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 filter_sort;
pub mod format;
+pub mod home;
pub mod locale;
-pub mod node_page;
pub mod node_card;
-pub mod scaffold;
+pub mod node_page;
pub mod props;
-pub mod filter_sort;
+pub mod scaffold;
pub mod search;
+pub mod settings;
pub mod stats;
-pub mod home;
+
+/// render as supertrait would be possible but is not
+/// dyn compatible and I really dont want to expose generics
+/// that generate rendering code because of compile speed.
+pub trait Page {
+ fn title(&self) -> String;
+ fn to_render(&self) -> DynRender;
+ fn class(&self) -> Option<&'static str> {
+ None
+ }
+}
+
+pub fn render_page(page: &dyn Page) -> String {
+ // page.render()
+ "a".to_string()
+}
diff --git a/ui/src/node_page.rs b/ui/src/node_page.rs
index 8848202..b48fca2 100644
--- a/ui/src/node_page.rs
+++ b/ui/src/node_page.rs
@@ -5,6 +5,7 @@
*/
use crate::{
+ Page,
filter_sort::NodeFilterSortForm,
format::format_chapter,
locale::{Language, trs},
@@ -18,6 +19,12 @@ use jellycommon::{
};
use std::sync::Arc;
+impl Page for NodePage<'_> {
+ fn title(&self) -> String {
+ self.node.title.clone().unwrap_or_default()
+ }
+}
+
markup::define! {
NodePage<'a>(
node: &'a Node,
diff --git a/ui/src/props.rs b/ui/src/props.rs
index 7dbc0de..fbeddca 100644
--- a/ui/src/props.rs
+++ b/ui/src/props.rs
@@ -4,8 +4,8 @@
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
use crate::{
- format::format_duration,
- locale::{Language, trs},
+ format::{MediaInfoExt, format_count, format_duration},
+ locale::{Language, tr, trs},
};
use jellycommon::{
Node, Rating, Visibility,
diff --git a/ui/src/scaffold.rs b/ui/src/scaffold.rs
index ffd5fdf..cc5886b 100644
--- a/ui/src/scaffold.rs
+++ b/ui/src/scaffold.rs
@@ -4,8 +4,8 @@
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use crate::locale::{tr, trs, Language};
-use markup::{DynRender, Render};
+use crate::locale::{Language, escape, tr, trs};
+use markup::{DynRender, Render, raw};
use std::sync::LazyLock;
static LOGO_ENABLED: LazyLock<bool> = LazyLock::new(|| CONF.asset_path.join("logo.svg").exists());
diff --git a/ui/src/search.rs b/ui/src/search.rs
index 092ad57..f252620 100644
--- a/ui/src/search.rs
+++ b/ui/src/search.rs
@@ -4,28 +4,40 @@
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
+use crate::{
+ Page,
+ locale::{Language, tr, trs},
+ node_card::NodeCard,
+};
+use jellycommon::api::ApiSearchResponse;
+use markup::DynRender;
+
+impl Page for SearchPage<'_> {
+ fn title(&self) -> String {
+ tr(*self.lang, "search.title").to_string()
+ }
+ fn class(&self) -> Option<&'static str> {
+ Some("search")
+ }
+ fn to_render(&self) -> DynRender {
+ markup::new!(@self)
+ }
+}
+
markup::define! {
- SearchPage {
+ SearchPage<'a>(lang: &'a Language, r: Option<ApiSearchResponse>, query: &'a Option<String>) {
h1 { @trs(&lang, "search.title") }
form[action="", method="GET"] {
- input[type="text", name="query", placeholder=&*tr(lang, "search.placeholder"), value=&query];
+ 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 {
+ @if let Some(r) = &r {
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() {
+ p.stats { @tr(**lang, "search.results.stats").replace("{count}", &r.count.to_string()).replace("{dur}", &format!("{:?}", r.duration)) }
+ ul.children {@for (node, udata) in r.results.iter() {
li { @NodeCard { node, udata, lang: &lang } }
}}
// TODO pagination
}
}
}
-
-pub fn search_page() {
- LayoutPage {
- title: tr(lang, "search.title").to_string(),
- class: Some("search"),
- content: SearchPage,
- }
-} \ No newline at end of file
diff --git a/ui/src/settings.rs b/ui/src/settings.rs
new file mode 100644
index 0000000..9bc4b1d
--- /dev/null
+++ b/ui/src/settings.rs
@@ -0,0 +1,76 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2025 metamuffin <metamuffin.org>
+*/
+use crate::locale::{Language, tr, trs};
+use jellycommon::user::{PlayerKind, Theme};
+use markup::RenderAttributeValue;
+
+markup::define! {
+ Settings<'a>(flash: Option<Result<String, String>>, lang: &'a Language) {
+ 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 markup::Render for A<Theme> {
+ 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 A<PlayerKind> {
+ 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 A<Theme> {}
+impl RenderAttributeValue for A<PlayerKind> {}
diff --git a/ui/src/stats.rs b/ui/src/stats.rs
index b4a2e23..3655245 100644
--- a/ui/src/stats.rs
+++ b/ui/src/stats.rs
@@ -7,24 +7,24 @@
use crate::{
format::{format_duration, format_duration_long, format_kind, format_size},
locale::{Language, tr, trs},
- scaffold::LayoutPage,
};
+use jellycommon::api::{ApiStatsResponse, StatsBin};
use markup::raw;
markup::define! {
- StatsPage<'a>(lang: &'a Language) {
+ StatsPage<'a>(lang: &'a Language, r: ApiStatsResponse) {
.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.count")
+ .replace("{count}", &format!("<b>{}</b>", r.total.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.runtime")
+ .replace("{dur}", &format!("<b>{}</b>", format_duration_long(r.total.runtime, **lang)))
+ .replace("{size}", &format!("<b>{}</b>", format_size(r.total.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)))
+ p { @raw(tr(**lang, "stats.average")
+ .replace("{dur}", &format!("<b>{}</b>", format_duration(r.total.average_runtime())))
+ .replace("{size}", &format!("<b>{}</b>", format_size(r.total.average_size() as u64)))
)}
h2 { @trs(&lang, "stats.by_kind.title") }
@@ -39,8 +39,8 @@ markup::define! {
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) }
+ @for (k,b) in &r.kinds { tr {
+ td { @format_kind(*k, **lang) }
td { @b.count }
td { @format_size(b.size) }
td { @format_duration(b.runtime) }
@@ -54,10 +54,21 @@ markup::define! {
}
}
-pub fn stats_page() {
- LayoutPage {
- title: tr(lang, "stats.title").to_string(),
- content: StatsPage { lang: &lang },
- ..Default::default()
+impl StatsPage<'_> {
+ pub fn title(&self) -> String {
+ tr(*self.lang, "stats.title").to_string()
+ }
+}
+
+trait BinExt {
+ fn average_runtime(&self) -> f64;
+ fn average_size(&self) -> f64;
+}
+impl BinExt for StatsBin {
+ fn average_runtime(&self) -> f64 {
+ self.runtime / self.count as f64
+ }
+ fn average_size(&self) -> f64 {
+ self.size as f64 / self.count as f64
}
}