aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock25
-rw-r--r--Cargo.toml5
-rw-r--r--base/src/lib.rs1
-rw-r--r--base/src/locale.rs34
-rw-r--r--cache/Cargo.toml15
-rw-r--r--cache/src/lib.rs319
-rw-r--r--common/src/api.rs66
-rw-r--r--logic/Cargo.toml10
-rw-r--r--logic/src/home.rs143
-rw-r--r--logic/src/lib.rs7
-rw-r--r--server/Cargo.toml2
-rw-r--r--server/src/helper/cache.rs56
-rw-r--r--server/src/helper/mod.rs1
-rw-r--r--server/src/ui/account/mod.rs8
-rw-r--r--server/src/ui/admin/mod.rs18
-rw-r--r--server/src/ui/admin/user.rs12
-rw-r--r--server/src/ui/assets.rs7
-rw-r--r--server/src/ui/error.rs2
-rw-r--r--server/src/ui/home.rs148
-rw-r--r--server/src/ui/mod.rs73
-rw-r--r--server/src/ui/node.rs430
-rw-r--r--server/src/ui/player.rs113
-rw-r--r--server/src/ui/search.rs20
-rw-r--r--server/src/ui/sort.rs206
-rw-r--r--server/src/ui/stats.rs45
-rw-r--r--ui/Cargo.toml9
-rw-r--r--ui/src/filter_sort.rs135
-rw-r--r--ui/src/format.rs117
-rw-r--r--ui/src/home.rs36
-rw-r--r--ui/src/lib.rs15
-rw-r--r--ui/src/locale.rs82
-rw-r--r--ui/src/node_card.rs56
-rw-r--r--ui/src/node_page.rs209
-rw-r--r--ui/src/props.rs63
-rw-r--r--ui/src/scaffold.rs (renamed from server/src/ui/layout.rs)113
-rw-r--r--ui/src/search.rs31
-rw-r--r--ui/src/stats.rs63
37 files changed, 1572 insertions, 1123 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 563b2c2..c2705b7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1904,7 +1904,6 @@ dependencies = [
"env_logger",
"futures",
"glob",
- "humansize",
"jellybase",
"jellycache",
"jellycommon",
@@ -1912,7 +1911,6 @@ dependencies = [
"jellystream",
"jellytranscoder",
"log",
- "markup",
"rand 0.9.1",
"rocket",
"rocket_ws",
@@ -1969,6 +1967,15 @@ dependencies = [
]
[[package]]
+name = "jellyui"
+version = "0.1.0"
+dependencies = [
+ "humansize",
+ "jellycommon",
+ "markup",
+]
+
+[[package]]
name = "jobserver"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2096,13 +2103,19 @@ dependencies = [
[[package]]
name = "log"
-version = "0.4.25"
+version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f"
+checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "logic"
version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "jellybase",
+ "jellycommon",
+ "log",
+]
[[package]]
name = "loom"
@@ -4117,10 +4130,6 @@ dependencies = [
]
[[package]]
-name = "ui"
-version = "0.1.0"
-
-[[package]]
name = "uncased"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 6329846..133055a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,7 +10,10 @@ members = [
"transcoder",
"base",
"import",
- "import/fallback_generator", "ui", "logic", "cache",
+ "import/fallback_generator",
+ "ui",
+ "logic",
+ "cache",
]
resolver = "2"
diff --git a/base/src/lib.rs b/base/src/lib.rs
index c897754..010e908 100644
--- a/base/src/lib.rs
+++ b/base/src/lib.rs
@@ -6,7 +6,6 @@
pub mod assetfed;
pub mod database;
pub mod federation;
-pub mod locale;
pub mod permission;
pub use jellycommon as common;
diff --git a/base/src/locale.rs b/base/src/locale.rs
deleted file mode 100644
index e7f1592..0000000
--- a/base/src/locale.rs
+++ /dev/null
@@ -1,34 +0,0 @@
-use std::{borrow::Cow, collections::HashMap, sync::LazyLock};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-pub enum Language {
- English,
- German,
-}
-
-static LANG_TABLES: LazyLock<HashMap<Language, HashMap<&'static str, &'static str>>> =
- LazyLock::new(|| {
- let mut k = HashMap::new();
- for (lang, source) in [
- (Language::English, include_str!("../../locale/en.ini")),
- (Language::German, include_str!("../../locale/de.ini")),
- ] {
- let tr_map = source
- .lines()
- .filter_map(|line| {
- let (key, value) = line.split_once("=")?;
- Some((key.trim(), value.trim()))
- })
- .collect::<HashMap<&'static str, &'static str>>();
- k.insert(lang, tr_map);
- }
- k
- });
-
-pub fn tr(lang: Language, key: &str) -> Cow<'static, str> {
- let tr_map = LANG_TABLES.get(&lang).unwrap();
- match tr_map.get(key) {
- Some(value) => Cow::Borrowed(value),
- None => Cow::Owned(format!("TR[{key}]")),
- }
-}
diff --git a/cache/Cargo.toml b/cache/Cargo.toml
new file mode 100644
index 0000000..42f5e00
--- /dev/null
+++ b/cache/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "jellycache"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+base64 = "0.22.1"
+bincode = "2.0.0-rc.3"
+humansize = "2.1.3"
+anyhow = "1.0.95"
+log = { workspace = true }
+tokio = { workspace = true }
+sha2 = "0.10.8"
+rand = "0.9.1"
+serde = "1.0.217"
diff --git a/cache/src/lib.rs b/cache/src/lib.rs
new file mode 100644
index 0000000..2d2cfa3
--- /dev/null
+++ b/cache/src/lib.rs
@@ -0,0 +1,319 @@
+/*
+ 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 anyhow::{Context, anyhow};
+use base64::Engine;
+use bincode::{Decode, Encode};
+use log::{info, warn};
+use rand::random;
+use serde::{Deserialize, Serialize};
+use sha2::Sha512;
+use std::{
+ any::Any,
+ collections::{BTreeMap, HashMap},
+ fs::rename,
+ future::Future,
+ hash::{Hash, Hasher},
+ io::{Seek, Write},
+ path::PathBuf,
+ sync::{
+ Arc, LazyLock, RwLock,
+ atomic::{AtomicBool, AtomicUsize, Ordering},
+ },
+ time::Instant,
+};
+use tokio::{
+ io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt},
+ sync::Mutex,
+};
+
+#[derive(Debug, Deserialize)]
+pub struct Config {
+ path: PathBuf,
+ max_in_memory_cache_size: usize,
+}
+
+static CONF: LazyLock<Config> = LazyLock::new(|| {
+ CONF_PRELOAD
+ .blocking_lock()
+ .take()
+ .expect("cache config not preloaded. logic error")
+});
+static CONF_PRELOAD: Mutex<Option<Config>> = Mutex::const_new(None);
+
+#[derive(Debug, Encode, Decode, Serialize, Clone)]
+pub struct CachePath(pub PathBuf);
+impl CachePath {
+ pub fn abs(&self) -> PathBuf {
+ CONF.path.join(&self.0)
+ }
+}
+
+pub fn cache_location(kind: &str, key: impl Hash) -> (usize, CachePath) {
+ use sha2::Digest;
+ struct ShaHasher(Sha512);
+ impl Hasher for ShaHasher {
+ fn finish(&self) -> u64 {
+ unreachable!()
+ }
+ fn write(&mut self, bytes: &[u8]) {
+ self.0.update(bytes);
+ }
+ }
+ let mut d = ShaHasher(sha2::Sha512::new());
+ d.0.update(kind);
+ d.0.update(b"\0");
+ key.hash(&mut d);
+
+ let d = d.0.finalize();
+ let n =
+ d[0] as usize | ((d[1] as usize) << 8) | ((d[2] as usize) << 16) | ((d[3] as usize) << 24);
+ let fname = base64::engine::general_purpose::URL_SAFE.encode(d);
+ let fname = &fname[..30]; // 180 bits
+ let fname = format!("{}/{}", kind, fname);
+ (n, CachePath(fname.into()))
+}
+
+const CACHE_GENERATION_BUCKET_COUNT: usize = 1024;
+pub static CACHE_GENERATION_LOCKS: LazyLock<[Mutex<()>; CACHE_GENERATION_BUCKET_COUNT]> =
+ LazyLock::new(|| [(); CACHE_GENERATION_BUCKET_COUNT].map(|_| Mutex::new(())));
+
+pub async fn async_cache_file<Fun, Fut>(
+ kind: &str,
+ key: impl Hash,
+ generate: Fun,
+) -> Result<CachePath, anyhow::Error>
+where
+ Fun: FnOnce(tokio::fs::File) -> Fut,
+ Fut: Future<Output = Result<(), anyhow::Error>>,
+{
+ let (bucket, location) = cache_location(kind, key);
+ let loc_abs = location.abs();
+ // we need a lock even if it exists since somebody might be still in the process of writing.
+ let _guard = CACHE_GENERATION_LOCKS[bucket % CACHE_GENERATION_BUCKET_COUNT]
+ .lock()
+ .await;
+ let exists = tokio::fs::try_exists(&loc_abs)
+ .await
+ .context("unable to test for cache file existance")?;
+ if !exists {
+ let temp_path = CONF.path.join(format!("temp-{:x}", random::<u128>()));
+ let f = tokio::fs::File::create(&temp_path)
+ .await
+ .context("creating new cache file")?;
+ match generate(f).await {
+ Ok(()) => (),
+ Err(e) => {
+ warn!("cache generation failed, unlinking entry");
+ tokio::fs::remove_file(temp_path).await?;
+ return Err(e);
+ }
+ }
+ tokio::fs::create_dir_all(loc_abs.parent().unwrap())
+ .await
+ .context("create kind dir")?;
+ tokio::fs::rename(temp_path, &loc_abs)
+ .await
+ .context("rename cache")?;
+ }
+ drop(_guard);
+ Ok(location)
+}
+
+thread_local! { pub static WITHIN_CACHE_FILE: AtomicBool = const { AtomicBool::new(false) }; }
+
+pub fn cache_file<Fun>(
+ kind: &str,
+ key: impl Hash,
+ mut generate: Fun,
+) -> Result<CachePath, anyhow::Error>
+where
+ Fun: FnMut(std::fs::File) -> Result<(), anyhow::Error>,
+{
+ let (bucket, location) = cache_location(kind, key);
+ let loc_abs = location.abs();
+ // we need a lock even if it exists since somebody might be still in the process of writing.
+ let already_within = WITHIN_CACHE_FILE.with(|a| a.swap(true, Ordering::Relaxed));
+ let _guard = if already_within {
+ // TODO stupid hack to avoid deadlock for nested cache_file. proper solution needed
+ CACHE_GENERATION_LOCKS[bucket % CACHE_GENERATION_BUCKET_COUNT]
+ .try_lock()
+ .ok()
+ } else {
+ Some(CACHE_GENERATION_LOCKS[bucket % CACHE_GENERATION_BUCKET_COUNT].blocking_lock())
+ };
+ if !loc_abs.exists() {
+ let temp_path = CONF.path.join(format!("temp-{:x}", random::<u128>()));
+ let f = std::fs::File::create(&temp_path).context("creating new cache file")?;
+ match generate(f) {
+ Ok(()) => (),
+ Err(e) => {
+ warn!("cache generation failed, unlinking entry");
+ std::fs::remove_file(temp_path)?;
+ return Err(e);
+ }
+ }
+ std::fs::create_dir_all(loc_abs.parent().unwrap()).context("create kind dir")?;
+ rename(temp_path, loc_abs).context("rename cache")?;
+ }
+ if !already_within {
+ WITHIN_CACHE_FILE.with(|a| a.swap(false, Ordering::Relaxed));
+ }
+ drop(_guard);
+ Ok(location)
+}
+
+pub struct InMemoryCacheEntry {
+ size: usize,
+ last_access: Instant,
+ object: Arc<dyn Any + Send + Sync + 'static>,
+}
+pub static CACHE_IN_MEMORY_OBJECTS: LazyLock<RwLock<HashMap<PathBuf, InMemoryCacheEntry>>> =
+ LazyLock::new(|| RwLock::new(HashMap::new()));
+pub static CACHE_IN_MEMORY_SIZE: AtomicUsize = AtomicUsize::new(0);
+
+pub fn cache_memory<Fun, T>(
+ kind: &str,
+ key: impl Hash,
+ mut generate: Fun,
+) -> Result<Arc<T>, anyhow::Error>
+where
+ Fun: FnMut() -> Result<T, anyhow::Error>,
+ T: Encode + Decode + Send + Sync + 'static,
+{
+ let (_, location) = cache_location(kind, &key);
+ {
+ let mut g = CACHE_IN_MEMORY_OBJECTS.write().unwrap();
+ if let Some(entry) = g.get_mut(&location.abs()) {
+ entry.last_access = Instant::now();
+ let object = entry
+ .object
+ .clone()
+ .downcast::<T>()
+ .map_err(|_| anyhow!("inconsistent types for in-memory cache"))?;
+ return Ok(object);
+ }
+ }
+
+ let location = cache_file(kind, &key, move |file| {
+ let object = generate()?;
+ let mut file = std::io::BufWriter::new(file);
+ bincode::encode_into_std_write(&object, &mut file, bincode::config::standard())
+ .context("encoding cache object")?;
+ file.flush()?;
+ Ok(())
+ })?;
+ let mut file = std::io::BufReader::new(std::fs::File::open(location.abs())?);
+ let object = bincode::decode_from_std_read::<T, _, _>(&mut file, bincode::config::standard())
+ .context("decoding cache object")?;
+ let object = Arc::new(object);
+ let size = file.stream_position()? as usize; // this is an approximation mainly since varint is used in bincode
+
+ {
+ let mut g = CACHE_IN_MEMORY_OBJECTS.write().unwrap();
+ g.insert(
+ location.abs(),
+ InMemoryCacheEntry {
+ size,
+ last_access: Instant::now(),
+ object: object.clone(),
+ },
+ );
+ CACHE_IN_MEMORY_SIZE.fetch_add(size, Ordering::Relaxed);
+ }
+
+ cleanup_cache();
+
+ Ok(object)
+}
+
+pub async fn async_cache_memory<Fun, Fut, T>(
+ kind: &str,
+ key: impl Hash,
+ generate: Fun,
+) -> Result<Arc<T>, anyhow::Error>
+where
+ Fun: FnOnce() -> Fut,
+ Fut: Future<Output = Result<T, anyhow::Error>>,
+ T: Encode + Decode + Send + Sync + 'static,
+{
+ let (_, location) = cache_location(kind, &key);
+ {
+ let mut g = CACHE_IN_MEMORY_OBJECTS.write().unwrap();
+ if let Some(entry) = g.get_mut(&location.abs()) {
+ entry.last_access = Instant::now();
+ let object = entry
+ .object
+ .clone()
+ .downcast::<T>()
+ .map_err(|_| anyhow!("inconsistent types for in-memory cache"))?;
+ return Ok(object);
+ }
+ }
+
+ let location = async_cache_file(kind, &key, move |mut file| async move {
+ let object = generate().await?;
+ let data = bincode::encode_to_vec(&object, bincode::config::standard())
+ .context("encoding cache object")?;
+
+ file.write_all(&data).await?;
+
+ Ok(())
+ })
+ .await?;
+ let mut file = tokio::fs::File::open(&location.abs()).await?;
+ let mut data = Vec::new();
+ file.read_to_end(&mut data)
+ .await
+ .context("reading cache object")?;
+ let (object, _) = bincode::decode_from_slice::<T, _>(&data, bincode::config::standard())
+ .context("decoding cache object")?;
+ let object = Arc::new(object);
+ let size = file.stream_position().await? as usize; // this is an approximation mainly since varint is used in bincode
+
+ {
+ let mut g = CACHE_IN_MEMORY_OBJECTS.write().unwrap();
+ g.insert(
+ location.abs(),
+ InMemoryCacheEntry {
+ size,
+ last_access: Instant::now(),
+ object: object.clone(),
+ },
+ );
+ CACHE_IN_MEMORY_SIZE.fetch_add(size, Ordering::Relaxed);
+ }
+
+ cleanup_cache();
+
+ Ok(object)
+}
+
+pub fn cleanup_cache() {
+ let current_size = CACHE_IN_MEMORY_SIZE.load(Ordering::Relaxed);
+ if current_size < CONF.max_in_memory_cache_size {
+ return;
+ }
+ info!("running cache eviction");
+ let mut g = CACHE_IN_MEMORY_OBJECTS.write().unwrap();
+
+ // TODO: if two entries have *exactly* the same size, only one of the will be remove; this is fine for now
+ let mut k = BTreeMap::new();
+ for (loc, entry) in g.iter() {
+ k.insert(entry.last_access.elapsed(), (loc.to_owned(), entry.size));
+ }
+ let mut reduction = 0;
+ for (loc, size) in k.values().rev().take(k.len().div_ceil(2)) {
+ g.remove(loc);
+ reduction += size;
+ }
+ CACHE_IN_MEMORY_SIZE.fetch_sub(reduction, Ordering::Relaxed);
+ drop(g);
+
+ info!(
+ "done, {} freed",
+ humansize::format_size(reduction, humansize::DECIMAL)
+ );
+}
diff --git a/common/src/api.rs b/common/src/api.rs
index 111ae57..6982e76 100644
--- a/common/src/api.rs
+++ b/common/src/api.rs
@@ -3,8 +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 serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -36,3 +37,66 @@ pub struct ApiHomeResponse {
pub toplevel: NodesWithUdata,
pub categories: Vec<(String, NodesWithUdata)>,
}
+
+#[derive(Debug, Default, Clone)]
+#[cfg_attr(feature = "rocket", derive(FromForm, UriDisplayQuery))]
+pub struct NodeFilterSort {
+ pub sort_by: Option<SortProperty>,
+ pub filter_kind: Option<Vec<FilterProperty>>,
+ 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 {
+ (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),*]; }
+ };
+}
+
+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",
+ }
+);
diff --git a/logic/Cargo.toml b/logic/Cargo.toml
new file mode 100644
index 0000000..4107c14
--- /dev/null
+++ b/logic/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "logic"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+jellybase = { path = "../base" }
+jellycommon = { path = "../common" }
+log = "0.4.27"
+anyhow = "1.0.98"
diff --git a/logic/src/home.rs b/logic/src/home.rs
new file mode 100644
index 0000000..f03173e
--- /dev/null
+++ b/logic/src/home.rs
@@ -0,0 +1,143 @@
+/*
+ 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 anyhow::{Context, Result};
+use jellybase::database::Database;
+use jellycommon::{
+ NodeID, NodeKind, Rating, Visibility,
+ api::ApiHomeResponse,
+ chrono::{Datelike, Utc},
+ user::WatchedState,
+};
+
+pub fn home(db: Database) -> Result<ApiHomeResponse> {
+ 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(ApiHomeResponse {
+ toplevel,
+ categories,
+ })
+}
+
+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/logic/src/lib.rs b/logic/src/lib.rs
new file mode 100644
index 0000000..cc988d7
--- /dev/null
+++ b/logic/src/lib.rs
@@ -0,0 +1,7 @@
+/*
+ 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 home;
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 17aeeb4..669194a 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -23,7 +23,6 @@ base64 = "0.22.1"
chrono = { version = "0.4.39", features = ["serde"] }
vte = "0.14.1"
chashmap = "2.2.2"
-humansize = "2.1.3"
argon2 = "0.5.3"
aes-gcm-siv = "0.11.1"
@@ -33,7 +32,6 @@ futures = "0.3.31"
tokio = { workspace = true }
tokio-util = { version = "0.7.13", features = ["io", "io-util"] }
-markup = "0.15.0"
rocket = { workspace = true, features = ["secrets", "json"] }
rocket_ws = { workspace = true }
diff --git a/server/src/helper/cache.rs b/server/src/helper/cache.rs
new file mode 100644
index 0000000..d4c0595
--- /dev/null
+++ b/server/src/helper/cache.rs
@@ -0,0 +1,56 @@
+/*
+ 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 log::debug;
+use rocket::{
+ http::{Header, Status},
+ response::{self, Responder},
+ Request, Response,
+};
+use std::{
+ hash::{DefaultHasher, Hash, Hasher},
+ os::unix::fs::MetadataExt,
+ path::Path,
+};
+use tokio::fs::File;
+
+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/helper/mod.rs b/server/src/helper/mod.rs
index 946e8fa..856f6b7 100644
--- a/server/src/helper/mod.rs
+++ b/server/src/helper/mod.rs
@@ -4,3 +4,4 @@
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
pub mod cors;
+pub mod cache;
diff --git a/server/src/ui/account/mod.rs b/server/src/ui/account/mod.rs
index 312b40c..c1e5479 100644
--- a/server/src/ui/account/mod.rs
+++ b/server/src/ui/account/mod.rs
@@ -5,21 +5,17 @@
*/
pub mod settings;
-use super::{
- error::MyError,
- layout::{trs, LayoutPage},
-};
+use super::error::MyError;
use crate::{
database::Database,
locale::AcceptLanguage,
logic::session::{self, Session},
- ui::{error::MyResult, home::rocket_uri_macro_r_home, layout::DynLayoutPage},
+ ui::{error::MyResult, home::rocket_uri_macro_r_home},
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},
diff --git a/server/src/ui/admin/mod.rs b/server/src/ui/admin/mod.rs
index de06610..d380ae2 100644
--- a/server/src/ui/admin/mod.rs
+++ b/server/src/ui/admin/mod.rs
@@ -6,26 +6,18 @@
pub mod log;
pub mod user;
-use super::assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED};
-use crate::{
- database::Database,
- logic::session::AdminSession,
- ui::{
- admin::log::rocket_uri_macro_r_admin_log,
- error::MyResult,
- layout::{DynLayoutPage, FlashDisplay, LayoutPage},
- },
- uri,
+use super::{
+ assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED},
+ error::MyResult,
};
+use crate::{database::Database, logic::session::AdminSession};
use anyhow::{anyhow, Context};
use jellybase::{assetfed::AssetInner, federation::Federation, CONF};
-use jellyimport::{import_wrap, is_importing, IMPORT_ERRORS};
-use markup::DynRender;
+use jellyimport::{import_wrap, IMPORT_ERRORS};
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(
diff --git a/server/src/ui/admin/user.rs b/server/src/ui/admin/user.rs
index c5239f7..818e416 100644
--- a/server/src/ui/admin/user.rs
+++ b/server/src/ui/admin/user.rs
@@ -3,17 +3,9 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use crate::{
- database::Database,
- logic::session::AdminSession,
- ui::{
- error::MyResult,
- layout::{DynLayoutPage, FlashDisplay, LayoutPage},
- },
- uri,
-};
+use crate::{database::Database, logic::session::AdminSession, ui::error::MyResult, uri};
use anyhow::{anyhow, Context};
-use jellycommon::user::{PermissionSet, UserPermission};
+use jellycommon::user::UserPermission;
use rocket::{form::Form, get, post, FromForm, FromFormField, State};
#[get("/admin/users")]
diff --git a/server/src/ui/assets.rs b/server/src/ui/assets.rs
index 69f6bbc..63d8525 100644
--- a/server/src/ui/assets.rs
+++ b/server/src/ui/assets.rs
@@ -3,8 +3,8 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use super::{error::MyResult, CacheControlFile};
-use crate::logic::session::Session;
+use super::error::MyResult;
+use crate::{helper::cache::CacheControlFile, logic::session::Session};
use anyhow::{anyhow, bail, Context};
use base64::Engine;
use jellybase::{assetfed::AssetInner, database::Database, federation::Federation, CONF};
@@ -133,9 +133,6 @@ pub async fn r_person_asset(
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,
diff --git a/server/src/ui/error.rs b/server/src/ui/error.rs
index c9620bb..6ba2ba9 100644
--- a/server/src/ui/error.rs
+++ b/server/src/ui/error.rs
@@ -3,8 +3,6 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use super::layout::{DynLayoutPage, LayoutPage};
-use crate::{ui::account::rocket_uri_macro_r_account_login, uri};
use jellybase::CONF;
use log::info;
use rocket::{
diff --git a/server/src/ui/home.rs b/server/src/ui/home.rs
index fbce99b..96b1dc2 100644
--- a/server/src/ui/home.rs
+++ b/server/src/ui/home.rs
@@ -3,15 +3,10 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use super::{
- error::MyResult,
- layout::{trs, DynLayoutPage, LayoutPage},
- node::{DatabaseNodeUserDataExt, NodeCard},
-};
+use super::{error::MyResult, node::DatabaseNodeUserDataExt};
use crate::{api::AcceptJson, database::Database, locale::AcceptLanguage, logic::session::Session};
use anyhow::Context;
use chrono::{Datelike, Utc};
-use jellybase::{locale::tr, CONF};
use jellycommon::{api::ApiHomeResponse, user::WatchedState, NodeID, NodeKind, Rating, Visibility};
use rocket::{get, serde::json::Json, Either, State};
@@ -23,116 +18,7 @@ pub fn r_home(
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 {
@@ -140,34 +26,6 @@ pub fn r_home(
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()
- })
+ Either::Left()
})
}
-
-fn cheap_daily_random(i: usize) -> usize {
- xorshift(xorshift(Utc::now().num_days_from_ce() as u64) + i as u64) as usize
-}
-
-fn xorshift(mut x: u64) -> u64 {
- x ^= x << 13;
- x ^= x >> 7;
- x ^= x << 17;
- x
-}
diff --git a/server/src/ui/mod.rs b/server/src/ui/mod.rs
index b98fbec..89c0e9a 100644
--- a/server/src/ui/mod.rs
+++ b/server/src/ui/mod.rs
@@ -7,9 +7,7 @@ use crate::logic::session::Session;
use error::MyResult;
use home::rocket_uri_macro_r_home;
use jellybase::CONF;
-use layout::{DynLayoutPage, LayoutPage};
use log::debug;
-use markup::Render;
use rocket::{
futures::FutureExt,
get,
@@ -37,7 +35,6 @@ pub mod assets;
pub mod browser;
pub mod error;
pub mod home;
-pub mod layout;
pub mod node;
pub mod player;
pub mod search;
@@ -45,6 +42,38 @@ pub mod sort;
pub mod stats;
pub mod style;
+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();
+ Scaffold {
+ 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()
+ }
+}
+
#[get("/")]
pub async fn r_index(sess: Option<Session>) -> MyResult<Either<Redirect, DynLayoutPage<'static>>> {
if sess.is_some() {
@@ -96,41 +125,3 @@ impl AsyncRead for Defer {
}
}
}
-
-pub struct CacheControlFile(File, String);
-impl CacheControlFile {
- pub async fn new_cachekey(p: &Path) -> anyhow::Result<Self> {
- let tag = p.file_name().unwrap().to_str().unwrap().to_owned();
- let f = File::open(p).await?;
- Ok(Self(f, tag))
- }
- pub async fn new_mtime(f: File) -> Self {
- let meta = f.metadata().await.unwrap();
- let modified = meta.mtime();
- let mut h = DefaultHasher::new();
- modified.hash(&mut h);
- let tag = format!("{:0>16x}", h.finish());
- Self(f, tag)
- }
-}
-impl<'r> Responder<'r, 'static> for CacheControlFile {
- fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> {
- let Self(file, tag) = self;
- if req.headers().get_one("if-none-match") == Some(&tag) {
- debug!("file cache: not modified");
- Response::build()
- .status(Status::NotModified)
- .header(Header::new("cache-control", "private"))
- .header(Header::new("etag", tag))
- .ok()
- } else {
- debug!("file cache: transfer");
- Response::build()
- .status(Status::Ok)
- .header(Header::new("cache-control", "private"))
- .header(Header::new("etag", tag))
- .streamed_body(file)
- .ok()
- }
- }
-}
diff --git a/server/src/ui/node.rs b/server/src/ui/node.rs
index bf65a3e..1efcc10 100644
--- a/server/src/ui/node.rs
+++ b/server/src/ui/node.rs
@@ -3,43 +3,16 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use super::{
- assets::{
- rocket_uri_macro_r_item_backdrop, rocket_uri_macro_r_item_poster,
- rocket_uri_macro_r_node_thumbnail,
- },
- error::MyResult,
- layout::{trs, TrString},
- sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty},
-};
-use crate::{
- api::AcceptJson,
- database::Database,
- locale::AcceptLanguage,
- logic::{
- session::Session,
- userdata::{
- rocket_uri_macro_r_node_userdata_rating, rocket_uri_macro_r_node_userdata_watched,
- UrlWatchedState,
- },
- },
- ui::{
- assets::rocket_uri_macro_r_person_asset,
- layout::{DynLayoutPage, LayoutPage},
- player::{rocket_uri_macro_r_player, PlayerConfig},
- },
- uri,
-};
+use super::{error::MyResult, sort::filter_and_sort_nodes};
+use crate::{api::AcceptJson, database::Database, locale::AcceptLanguage, logic::session::Session};
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,
+ api::{ApiNodeResponse, NodeFilterSort, SortOrder, SortProperty},
+ user::NodeUserData,
+ Node, NodeID, NodeKind, Visibility,
};
use rocket::{get, serde::json::Json, Either, State};
-use std::{cmp::Reverse, collections::BTreeMap, fmt::Write, sync::Arc};
+use std::{cmp::Reverse, collections::BTreeMap, sync::Arc};
/// This function is a stub and only useful for use in the uri! macro.
#[get("/n/<id>")]
@@ -145,327 +118,6 @@ pub fn get_similar_media(
.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,
@@ -486,73 +138,3 @@ impl DatabaseNodeUserDataExt for Database {
))
}
}
-
-trait MediaInfoExt {
- fn resolution_name(&self) -> &'static str;
-}
-impl MediaInfoExt for MediaInfo {
- fn resolution_name(&self) -> &'static str {
- let mut maxdim = 0;
- for t in &self.tracks {
- if let SourceTrackKind::Video { width, height, .. } = &t.kind {
- maxdim = maxdim.max(*width.max(height))
- }
- }
-
- match maxdim {
- 30720.. => "32K",
- 15360.. => "16K",
- 7680.. => "8K UHD",
- 5120.. => "5K UHD",
- 3840.. => "4K UHD",
- 2560.. => "QHD 1440p",
- 1920.. => "FHD 1080p",
- 1280.. => "HD 720p",
- 854.. => "SD 480p",
- _ => "Unkown",
- }
- }
-}
-
-fn format_count(n: impl Into<usize>) -> String {
- let n: usize = n.into();
-
- if n >= 1_000_000 {
- format!("{:.1}M", n as f32 / 1_000_000.)
- } else if n >= 1_000 {
- format!("{:.1}k", n as f32 / 1_000.)
- } else {
- format!("{n}")
- }
-}
-
-fn format_chapter(c: &Chapter) -> (String, String) {
- (
- format!(
- "{}-{}",
- c.time_start.map(format_duration).unwrap_or_default(),
- c.time_end.map(format_duration).unwrap_or_default(),
- ),
- c.labels.first().map(|l| l.1.clone()).unwrap_or_default(),
- )
-}
-
-fn chapter_key_time(c: &Chapter, dur: f64) -> f64 {
- let start = c.time_start.unwrap_or(0.);
- let end = c.time_end.unwrap_or(dur);
- start * 0.8 + end * 0.2
-}
-
-fn external_id_url(key: &str, value: &str) -> Option<String> {
- Some(match key {
- "youtube.video" => format!("https://youtube.com/watch?v={value}"),
- "youtube.channel" => format!("https://youtube.com/channel/{value}"),
- "youtube.channelname" => format!("https://youtube.com/channel/@{value}"),
- "musicbrainz.release" => format!("https://musicbrainz.org/release/{value}"),
- "musicbrainz.albumartist" => format!("https://musicbrainz.org/artist/{value}"),
- "musicbrainz.artist" => format!("https://musicbrainz.org/artist/{value}"),
- "musicbrainz.releasegroup" => format!("https://musicbrainz.org/release-group/{value}"),
- "musicbrainz.recording" => format!("https://musicbrainz.org/recording/{value}"),
- _ => return None,
- })
-}
diff --git a/server/src/ui/player.rs b/server/src/ui/player.rs
index cd4d03c..db2f665 100644
--- a/server/src/ui/player.rs
+++ b/server/src/ui/player.rs
@@ -3,25 +3,18 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use super::{
- layout::LayoutPage,
- node::{get_similar_media, DatabaseNodeUserDataExt, NodePage},
- sort::NodeFilterSort,
-};
+use super::sort::NodeFilterSort;
use crate::{
database::Database,
locale::AcceptLanguage,
logic::session::{self, Session},
- ui::{error::MyResult, layout::DynLayoutPage},
};
-use anyhow::anyhow;
use jellybase::CONF;
use jellycommon::{
stream::{StreamContainer, StreamSpec},
user::{PermissionSet, PlayerKind},
- Node, NodeID, SourceTrackKind, TrackID, Visibility,
+ NodeID, TrackID, Visibility,
};
-use markup::DynRender;
use rocket::{get, response::Redirect, Either, FromForm, State, UriDisplayQuery};
use std::sync::Arc;
@@ -140,59 +133,59 @@ pub fn r_player(
}))
}
-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)),
- }
- }
+// 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 }
+// 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.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.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" }
- }
+// 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" }];
- }
- })
-}
+// input[type="submit", value=if playing { "Change tracks" } else { "Start playback" }];
+// }
+// })
+// }
diff --git a/server/src/ui/search.rs b/server/src/ui/search.rs
index 96be3a6..bfe51a8 100644
--- a/server/src/ui/search.rs
+++ b/server/src/ui/search.rs
@@ -46,24 +46,6 @@ pub async fn r_search<'a>(
};
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
- }
- },
- })
+ Either::Left()
})
}
diff --git a/server/src/ui/sort.rs b/server/src/ui/sort.rs
index a241030..441bac6 100644
--- a/server/src/ui/sort.rs
+++ b/server/src/ui/sort.rs
@@ -3,149 +3,18 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use crate::ui::layout::trs;
-use jellybase::locale::Language;
-use jellycommon::{helpers::SortAnyway, user::NodeUserData, Node, NodeKind, Rating};
-use markup::RenderAttributeValue;
+use jellycommon::{
+ 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;
-#[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),
@@ -232,66 +101,3 @@ pub fn filter_and_sort_nodes(
SortOrder::Descending => nodes.reverse(),
}
}
-
-markup::define! {
- NodeFilterSortForm<'a>(f: &'a NodeFilterSort, lang: &'a Language) {
- details.filtersort[open=f.is_open()] {
- summary { "Filter and Sort" }
- form[method="GET", action=""] {
- fieldset.filter {
- legend { "Filter" }
- .categories {
- @for (cname, cat) in FilterProperty::CATS {
- .category {
- h3 { @trs(lang, cname) }
- @for (value, label) in *cat {
- label { input[type="checkbox", name="filter_kind", value=value, checked=f.filter_kind.as_ref().map(|k|k.contains(value)).unwrap_or(true)]; @trs(lang, label) } br;
- }
- }
- }
- }
- }
- fieldset.sortby {
- legend { "Sort" }
- .categories {
- @for (cname, cat) in SortProperty::CATS {
- .category {
- h3 { @trs(lang, cname) }
- @for (value, label) in *cat {
- label { input[type="radio", name="sort_by", value=value, checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br;
- }
- }
- }
- }
- }
- fieldset.sortorder {
- legend { "Sort Order" }
- @use SortOrder::*;
- @for (value, label) in [(Ascending, "filter_sort.order.asc"), (Descending, "filter_sort.order.desc")] {
- label { input[type="radio", name="sort_order", value=value, checked=Some(value)==f.sort_order]; @trs(lang, label) } br;
- }
- }
- input[type="submit", value="Apply"]; a[href="?"] { "Clear" }
- }
- }
- }
-}
-
-impl markup::Render for SortProperty {
- fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
- writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>))
- }
-}
-impl markup::Render for SortOrder {
- fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
- writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>))
- }
-}
-impl markup::Render for FilterProperty {
- fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
- writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>))
- }
-}
-impl RenderAttributeValue for SortOrder {}
-impl RenderAttributeValue for FilterProperty {}
-impl RenderAttributeValue for SortProperty {}
diff --git a/server/src/ui/stats.rs b/server/src/ui/stats.rs
index 4c5bed8..345586a 100644
--- a/server/src/ui/stats.rs
+++ b/server/src/ui/stats.rs
@@ -83,49 +83,6 @@ pub fn r_stats(
"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()
- })
+ Either::Left()
})
}
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
new file mode 100644
index 0000000..0e8f0fd
--- /dev/null
+++ b/ui/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "jellyui"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+markup = "0.15.0"
+jellycommon = { path = "../common", features = ["rocket"] }
+humansize = "2.1.3"
diff --git a/ui/src/filter_sort.rs b/ui/src/filter_sort.rs
new file mode 100644
index 0000000..53d4ea3
--- /dev/null
+++ b/ui/src/filter_sort.rs
@@ -0,0 +1,135 @@
+/*
+ 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 jellycommon::api::{FilterProperty, NodeFilterSort, SortOrder, SortProperty};
+
+use crate::locale::{Language, trs};
+
+const SORT_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",
+ ),
+ ],
+ ),
+ ]
+};
+const FILTER_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"),
+ ],
+ ),
+ ]
+};
+
+markup::define! {
+ NodeFilterSortForm<'a>(f: &'a NodeFilterSort, lang: &'a Language) {
+ details.filtersort[open=f.filter_kind.is_some() || f.sort_by.is_some()] {
+ 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/ui/src/format.rs b/ui/src/format.rs
new file mode 100644
index 0000000..a374850
--- /dev/null
+++ b/ui/src/format.rs
@@ -0,0 +1,117 @@
+/*
+ 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, TrString, tr, trs};
+use jellycommon::{Chapter, MediaInfo, NodeKind, SourceTrackKind};
+
+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",
+ },
+ )
+}
+
+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",
+ }
+ }
+}
+
+pub 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}")
+ }
+}
+
+pub 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(),
+ )
+}
diff --git a/ui/src/home.rs b/ui/src/home.rs
new file mode 100644
index 0000000..7b58179
--- /dev/null
+++ b/ui/src/home.rs
@@ -0,0 +1,36 @@
+/*
+ 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},
+ node_card::NodeCard,
+ scaffold::LayoutPage,
+};
+
+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 {
+ 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 } }
+ }}
+ }
+ }
+ }
+}
+
+pub fn home_page() {
+ LayoutPage {
+ title: tr(lang, "home").to_string(),
+ content: HomePage { lang: &lang },
+ ..Default::default()
+ }
+}
diff --git a/ui/src/lib.rs b/ui/src/lib.rs
new file mode 100644
index 0000000..40d43dd
--- /dev/null
+++ b/ui/src/lib.rs
@@ -0,0 +1,15 @@
+/*
+ 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 format;
+pub mod locale;
+pub mod node_page;
+pub mod node_card;
+pub mod scaffold;
+pub mod props;
+pub mod filter_sort;
+pub mod search;
+pub mod stats;
+pub mod home;
diff --git a/ui/src/locale.rs b/ui/src/locale.rs
new file mode 100644
index 0000000..0179c66
--- /dev/null
+++ b/ui/src/locale.rs
@@ -0,0 +1,82 @@
+/*
+ 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 markup::{Render, RenderAttributeValue};
+use std::{borrow::Cow, collections::HashMap, sync::LazyLock};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum Language {
+ English,
+ German,
+}
+
+static LANG_TABLES: LazyLock<HashMap<Language, HashMap<&'static str, &'static str>>> =
+ LazyLock::new(|| {
+ let mut k = HashMap::new();
+ for (lang, source) in [
+ (Language::English, include_str!("../../locale/en.ini")),
+ (Language::German, include_str!("../../locale/de.ini")),
+ ] {
+ let tr_map = source
+ .lines()
+ .filter_map(|line| {
+ let (key, value) = line.split_once("=")?;
+ Some((key.trim(), value.trim()))
+ })
+ .collect::<HashMap<&'static str, &'static str>>();
+ k.insert(lang, tr_map);
+ }
+ k
+ });
+
+pub fn tr(lang: Language, key: &str) -> Cow<'static, str> {
+ let tr_map = LANG_TABLES.get(&lang).unwrap();
+ match tr_map.get(key) {
+ Some(value) => Cow::Borrowed(value),
+ None => Cow::Owned(format!("TR[{key}]")),
+ }
+}
+
+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.render(writer)
+ }
+}
+impl RenderAttributeValue for TrString<'_> {
+ fn is_none(&self) -> bool {
+ false
+ }
+ fn is_true(&self) -> bool {
+ false
+ }
+ fn is_false(&self) -> bool {
+ false
+ }
+}
+
+pub fn escape(str: &str) -> String {
+ let mut o = String::with_capacity(str.len());
+ let mut last = 0;
+ for (index, byte) in str.bytes().enumerate() {
+ if let Some(esc) = match byte {
+ b'<' => Some("&lt;"),
+ b'>' => Some("&gt;"),
+ b'&' => Some("&amp;"),
+ b'"' => Some("&quot;"),
+ _ => None,
+ } {
+ o += &str[last..index];
+ o += esc;
+ last = index + 1;
+ }
+ }
+ o += &str[last..];
+ o
+}
+
+pub fn trs<'a>(lang: &Language, key: &str) -> TrString<'a> {
+ TrString(tr(*lang, key))
+}
diff --git a/ui/src/node_card.rs b/ui/src/node_card.rs
new file mode 100644
index 0000000..cedb81e
--- /dev/null
+++ b/ui/src/node_card.rs
@@ -0,0 +1,56 @@
+/*
+ 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, node_page::aspect_class, props::Props};
+use jellycommon::{Node, user::NodeUserData};
+
+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 }
+ }
+ }
+ }
+}
diff --git a/ui/src/node_page.rs b/ui/src/node_page.rs
new file mode 100644
index 0000000..8848202
--- /dev/null
+++ b/ui/src/node_page.rs
@@ -0,0 +1,209 @@
+/*
+ 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::NodeFilterSortForm,
+ format::format_chapter,
+ locale::{Language, trs},
+ node_card::{NodeCard, NodeCardWide},
+ props::Props,
+};
+use jellycommon::{
+ Chapter, Node, NodeKind, PeopleGroup,
+ api::NodeFilterSort,
+ user::{NodeUserData, WatchedState},
+};
+use std::sync::Arc;
+
+markup::define! {
+ 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 } }
+ }}
+ }
+ }
+ }
+ }
+}
+
+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
+}
+
+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",
+ }
+}
+
+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/ui/src/props.rs b/ui/src/props.rs
new file mode 100644
index 0000000..7dbc0de
--- /dev/null
+++ b/ui/src/props.rs
@@ -0,0 +1,63 @@
+/*
+ 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::{
+ format::format_duration,
+ locale::{Language, trs},
+};
+use jellycommon::{
+ Node, Rating, Visibility,
+ chrono::DateTime,
+ user::{NodeUserData, WatchedState},
+};
+
+markup::define! {
+ 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") } }
+ }
+ }
+ }
+}
diff --git a/server/src/ui/layout.rs b/ui/src/scaffold.rs
index 0e8d7b9..ffd5fdf 100644
--- a/server/src/ui/layout.rs
+++ b/ui/src/scaffold.rs
@@ -3,84 +3,15 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2025 metamuffin <metamuffin.org>
*/
-use crate::{
- locale::lang_from_request,
- logic::session::Session,
- ui::{
- account::{
- rocket_uri_macro_r_account_login, rocket_uri_macro_r_account_logout,
- rocket_uri_macro_r_account_register, settings::rocket_uri_macro_r_account_settings,
- },
- admin::rocket_uri_macro_r_admin_dashboard,
- browser::rocket_uri_macro_r_all_items,
- node::rocket_uri_macro_r_library_node,
- search::rocket_uri_macro_r_search,
- stats::rocket_uri_macro_r_stats,
- },
- uri,
-};
-use futures::executor::block_on;
-use jellybase::{
- locale::{tr, Language},
- CONF,
-};
-use jellycommon::user::Theme;
-use jellycommon::NodeID;
-use jellyimport::is_importing;
-use markup::{raw, DynRender, Render, RenderAttributeValue};
-use rocket::{
- http::ContentType,
- response::{self, Responder},
- Request, Response,
-};
-use std::{borrow::Cow, io::Cursor, sync::LazyLock};
-static LOGO_ENABLED: LazyLock<bool> = LazyLock::new(|| CONF.asset_path.join("logo.svg").exists());
-
-pub struct TrString<'a>(Cow<'a, str>);
-impl Render for TrString<'_> {
- fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
- self.0.as_str().render(writer)
- }
-}
-impl RenderAttributeValue for TrString<'_> {
- fn is_none(&self) -> bool {
- false
- }
- fn is_true(&self) -> bool {
- false
- }
- fn is_false(&self) -> bool {
- false
- }
-}
+use crate::locale::{tr, trs, Language};
+use markup::{DynRender, Render};
+use std::sync::LazyLock;
-pub fn escape(str: &str) -> String {
- let mut o = String::with_capacity(str.len());
- let mut last = 0;
- for (index, byte) in str.bytes().enumerate() {
- if let Some(esc) = match byte {
- b'<' => Some("&lt;"),
- b'>' => Some("&gt;"),
- b'&' => Some("&amp;"),
- b'"' => Some("&quot;"),
- _ => None,
- } {
- o += &str[last..index];
- o += esc;
- last = index + 1;
- }
- }
- o += &str[last..];
- o
-}
-
-pub fn trs<'a>(lang: &Language, key: &str) -> TrString<'a> {
- TrString(tr(*lang, key))
-}
+static LOGO_ENABLED: LazyLock<bool> = LazyLock::new(|| CONF.asset_path.join("logo.svg").exists());
markup::define! {
- Layout<'a, Main: Render>(title: String, main: Main, class: &'a str, session: Option<Session>, lang: Language) {
+ Scaffold<'a, Main: Render>(title: String, main: Main, class: &'a str, session: Option<Session>, lang: Language) {
@markup::doctype()
html {
head {
@@ -131,7 +62,7 @@ markup::define! {
}
}
-pub type DynLayoutPage<'a> = LayoutPage<markup::DynRender<'a>>;
+pub type DynLayoutPage<'a> = LayoutPage<DynRender<'a>>;
pub struct LayoutPage<T> {
pub title: String,
@@ -148,35 +79,3 @@ impl Default for LayoutPage<DynRender<'_>> {
}
}
}
-
-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/ui/src/search.rs b/ui/src/search.rs
new file mode 100644
index 0000000..092ad57
--- /dev/null
+++ b/ui/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>
+*/
+
+markup::define! {
+ SearchPage {
+ 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
+ }
+ }
+}
+
+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/stats.rs b/ui/src/stats.rs
new file mode 100644
index 0000000..b4a2e23
--- /dev/null
+++ b/ui/src/stats.rs
@@ -0,0 +1,63 @@
+/*
+ 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::{
+ format::{format_duration, format_duration_long, format_kind, format_size},
+ locale::{Language, tr, trs},
+ scaffold::LayoutPage,
+};
+use markup::raw;
+
+markup::define! {
+ StatsPage<'a>(lang: &'a Language) {
+ .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) }}}
+ }}
+ }
+ }
+ }
+}
+
+pub fn stats_page() {
+ LayoutPage {
+ title: tr(lang, "stats.title").to_string(),
+ content: StatsPage { lang: &lang },
+ ..Default::default()
+ }
+}