diff options
37 files changed, 1572 insertions, 1123 deletions
@@ -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" @@ -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("<"), + b'>' => Some(">"), + b'&' => Some("&"), + b'"' => Some("""), + _ => None, + } { + o += &str[last..index]; + o += esc; + last = index + 1; + } + } + o += &str[last..]; + o +} + +pub fn trs<'a>(lang: &Language, key: &str) -> TrString<'a> { + TrString(tr(*lang, key)) +} 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("<"), - b'>' => Some(">"), - b'&' => Some("&"), - b'"' => Some("""), - _ => None, - } { - o += &str[last..index]; - o += esc; - last = index + 1; - } - } - o += &str[last..]; - o -} - -pub fn trs<'a>(lang: &Language, key: &str) -> TrString<'a> { - TrString(tr(*lang, key)) -} +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() + } +} |