From 11a585b3dbe620dcc8772e713b22f1d9ba80d598 Mon Sep 17 00:00:00 2001 From: metamuffin Date: Sun, 27 Apr 2025 19:25:11 +0200 Subject: move files around --- server/src/api.rs | 105 +++ server/src/compat/jellyfin/mod.rs | 881 ++++++++++++++++++++++++++ server/src/compat/jellyfin/models.rs | 198 ++++++ server/src/compat/mod.rs | 7 + server/src/compat/youtube.rs | 62 ++ server/src/helper/cors.rs | 20 + server/src/helper/mod.rs | 6 + server/src/locale.rs | 55 ++ server/src/logic/mod.rs | 9 + server/src/logic/playersync.rs | 109 ++++ server/src/logic/session.rs | 208 ++++++ server/src/logic/stream.rs | 246 +++++++ server/src/logic/userdata.rs | 96 +++ server/src/main.rs | 8 +- server/src/routes.rs | 212 +++++++ server/src/routes/api.rs | 108 ---- server/src/routes/compat/jellyfin/mod.rs | 877 ------------------------- server/src/routes/compat/jellyfin/models.rs | 199 ------ server/src/routes/compat/mod.rs | 7 - server/src/routes/compat/youtube.rs | 60 -- server/src/routes/locale.rs | 55 -- server/src/routes/mod.rs | 238 ------- server/src/routes/playersync.rs | 108 ---- server/src/routes/stream.rs | 246 ------- server/src/routes/ui/account/mod.rs | 261 -------- server/src/routes/ui/account/session/guard.rs | 106 ---- server/src/routes/ui/account/session/mod.rs | 24 - server/src/routes/ui/account/session/token.rs | 97 --- server/src/routes/ui/account/settings.rs | 187 ------ server/src/routes/ui/admin/log.rs | 258 -------- server/src/routes/ui/admin/mod.rs | 290 --------- server/src/routes/ui/admin/user.rs | 176 ----- server/src/routes/ui/assets.rs | 200 ------ server/src/routes/ui/browser.rs | 83 --- server/src/routes/ui/error.rs | 104 --- server/src/routes/ui/home.rs | 180 ------ server/src/routes/ui/layout.rs | 184 ------ server/src/routes/ui/mod.rs | 131 ---- server/src/routes/ui/node.rs | 558 ---------------- server/src/routes/ui/player.rs | 200 ------ server/src/routes/ui/search.rs | 70 -- server/src/routes/ui/sort.rs | 290 --------- server/src/routes/ui/stats.rs | 133 ---- server/src/routes/ui/style.rs | 90 --- server/src/routes/userdata.rs | 95 --- server/src/ui/account/mod.rs | 256 ++++++++ server/src/ui/account/settings.rs | 185 ++++++ server/src/ui/admin/log.rs | 258 ++++++++ server/src/ui/admin/mod.rs | 288 +++++++++ server/src/ui/admin/user.rs | 176 +++++ server/src/ui/assets.rs | 201 ++++++ server/src/ui/browser.rs | 80 +++ server/src/ui/error.rs | 104 +++ server/src/ui/home.rs | 173 +++++ server/src/ui/layout.rs | 182 ++++++ server/src/ui/mod.rs | 136 ++++ server/src/ui/node.rs | 558 ++++++++++++++++ server/src/ui/player.rs | 198 ++++++ server/src/ui/search.rs | 69 ++ server/src/ui/sort.rs | 297 +++++++++ server/src/ui/stats.rs | 131 ++++ server/src/ui/style.rs | 90 +++ 62 files changed, 5603 insertions(+), 5616 deletions(-) create mode 100644 server/src/api.rs create mode 100644 server/src/compat/jellyfin/mod.rs create mode 100644 server/src/compat/jellyfin/models.rs create mode 100644 server/src/compat/mod.rs create mode 100644 server/src/compat/youtube.rs create mode 100644 server/src/helper/cors.rs create mode 100644 server/src/helper/mod.rs create mode 100644 server/src/locale.rs create mode 100644 server/src/logic/mod.rs create mode 100644 server/src/logic/playersync.rs create mode 100644 server/src/logic/session.rs create mode 100644 server/src/logic/stream.rs create mode 100644 server/src/logic/userdata.rs create mode 100644 server/src/routes.rs delete mode 100644 server/src/routes/api.rs delete mode 100644 server/src/routes/compat/jellyfin/mod.rs delete mode 100644 server/src/routes/compat/jellyfin/models.rs delete mode 100644 server/src/routes/compat/mod.rs delete mode 100644 server/src/routes/compat/youtube.rs delete mode 100644 server/src/routes/locale.rs delete mode 100644 server/src/routes/mod.rs delete mode 100644 server/src/routes/playersync.rs delete mode 100644 server/src/routes/stream.rs delete mode 100644 server/src/routes/ui/account/mod.rs delete mode 100644 server/src/routes/ui/account/session/guard.rs delete mode 100644 server/src/routes/ui/account/session/mod.rs delete mode 100644 server/src/routes/ui/account/session/token.rs delete mode 100644 server/src/routes/ui/account/settings.rs delete mode 100644 server/src/routes/ui/admin/log.rs delete mode 100644 server/src/routes/ui/admin/mod.rs delete mode 100644 server/src/routes/ui/admin/user.rs delete mode 100644 server/src/routes/ui/assets.rs delete mode 100644 server/src/routes/ui/browser.rs delete mode 100644 server/src/routes/ui/error.rs delete mode 100644 server/src/routes/ui/home.rs delete mode 100644 server/src/routes/ui/layout.rs delete mode 100644 server/src/routes/ui/mod.rs delete mode 100644 server/src/routes/ui/node.rs delete mode 100644 server/src/routes/ui/player.rs delete mode 100644 server/src/routes/ui/search.rs delete mode 100644 server/src/routes/ui/sort.rs delete mode 100644 server/src/routes/ui/stats.rs delete mode 100644 server/src/routes/ui/style.rs delete mode 100644 server/src/routes/userdata.rs create mode 100644 server/src/ui/account/mod.rs create mode 100644 server/src/ui/account/settings.rs create mode 100644 server/src/ui/admin/log.rs create mode 100644 server/src/ui/admin/mod.rs create mode 100644 server/src/ui/admin/user.rs create mode 100644 server/src/ui/assets.rs create mode 100644 server/src/ui/browser.rs create mode 100644 server/src/ui/error.rs create mode 100644 server/src/ui/home.rs create mode 100644 server/src/ui/layout.rs create mode 100644 server/src/ui/mod.rs create mode 100644 server/src/ui/node.rs create mode 100644 server/src/ui/player.rs create mode 100644 server/src/ui/search.rs create mode 100644 server/src/ui/sort.rs create mode 100644 server/src/ui/stats.rs create mode 100644 server/src/ui/style.rs (limited to 'server/src') diff --git a/server/src/api.rs b/server/src/api.rs new file mode 100644 index 0000000..f246eab --- /dev/null +++ b/server/src/api.rs @@ -0,0 +1,105 @@ +/* + 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 +*/ +use super::ui::{account::login_logic, error::MyResult}; +use crate::{ + database::Database, + logic::session::{AdminSession, Session}, +}; +use jellybase::assetfed::AssetInner; +use jellycommon::{user::CreateSessionParams, NodeID, Visibility}; +use rocket::{ + get, + http::MediaType, + outcome::Outcome, + post, + request::{self, FromRequest}, + response::Redirect, + serde::json::Json, + Request, State, +}; +use serde_json::{json, Value}; +use std::ops::Deref; + +#[get("/api")] +pub fn r_api_root() -> Redirect { + Redirect::moved("https://jellything.metamuffin.org/book/api.html#jellything-http-api") +} + +#[get("/api/version")] +pub fn r_api_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +#[post("/api/create_session", data = "")] +pub fn r_api_account_login( + database: &State, + data: Json, +) -> MyResult { + let token = login_logic( + database, + &data.username, + &data.password, + data.expire, + data.drop_permissions.clone(), + )?; + Ok(json!(token)) +} + +#[get("/api/asset_token_raw/")] +pub fn r_api_asset_token_raw(_admin: AdminSession, token: &str) -> MyResult> { + Ok(Json(AssetInner::deser(token)?)) +} + +#[get("/api/nodes_modified?")] +pub fn r_api_nodes_modified_since( + _session: Session, + database: &State, + since: u64, +) -> MyResult>> { + let mut nodes = database.get_nodes_modified_since(since)?; + nodes.retain(|id| { + database.get_node(*id).is_ok_and(|n| { + n.as_ref() + .is_some_and(|n| n.visibility >= Visibility::Reduced) + }) + }); + Ok(Json(nodes)) +} + +pub struct AcceptJson(bool); +impl Deref for AcceptJson { + type Target = bool; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl<'r> FromRequest<'r> for AcceptJson { + type Error = (); + + fn from_request<'life0, 'async_trait>( + request: &'r Request<'life0>, + ) -> ::core::pin::Pin< + Box< + dyn ::core::future::Future> + + ::core::marker::Send + + 'async_trait, + >, + > + where + 'r: 'async_trait, + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + Outcome::Success(AcceptJson( + request + .accept() + .map(|a| a.preferred().exact_eq(&MediaType::JSON)) + .unwrap_or(false), + )) + }) + } +} diff --git a/server/src/compat/jellyfin/mod.rs b/server/src/compat/jellyfin/mod.rs new file mode 100644 index 0000000..9d5c93e --- /dev/null +++ b/server/src/compat/jellyfin/mod.rs @@ -0,0 +1,881 @@ +/* + 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 +*/ +pub mod models; + +use crate::{ + logic::session::Session, + ui::{ + account::login_logic, + assets::{ + rocket_uri_macro_r_asset, rocket_uri_macro_r_item_backdrop, + rocket_uri_macro_r_item_poster, + }, + error::MyResult, + node::{aspect_class, DatabaseNodeUserDataExt}, + sort::{filter_and_sort_nodes, FilterProperty, NodeFilterSort, SortOrder, SortProperty}, + }, +}; +use anyhow::{anyhow, Context}; +use jellybase::{database::Database, CONF}; +use jellycommon::{ + stream::{StreamContainer, StreamSpec}, + user::{NodeUserData, WatchedState}, + MediaInfo, Node, NodeID, NodeKind, SourceTrack, SourceTrackKind, Visibility, +}; +use models::*; +use rocket::{ + get, + http::{Cookie, CookieJar}, + post, + response::Redirect, + serde::json::Json, + FromForm, State, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{collections::BTreeMap, net::IpAddr}; + +const SERVER_ID: &str = "1694a95daf70708147f16103ce7b7566"; +const USER_ID: &str = "33f772aae6c2495ca89fe00340dbd17c"; +const VERSION: &str = "10.10.0"; +const LOCAL_ADDRESS: &str = "http://127.0.0.1:8000"; + +#[get("/System/Info/Public")] +pub fn r_jellyfin_system_info_public_case() -> Json { + r_jellyfin_system_info_public() +} + +#[get("/system/info/public")] +pub fn r_jellyfin_system_info_public() -> Json { + Json(json!({ + "LocalAddress": LOCAL_ADDRESS, + "ServerName": CONF.brand.clone(), + "Version": VERSION, + "ProductName": "Jellything", + "OperatingSystem": "", + "Id": SERVER_ID, + "StartupWizardCompleted": true, + })) +} + +#[get("/Branding/Configuration")] +pub fn r_jellyfin_branding_configuration() -> Json { + Json(json!({ + "LoginDisclaimer": format!("{} - {}", CONF.brand, CONF.slogan), + "CustomCss": "", + "SplashscreenEnabled": false, + })) +} + +#[get("/users/public")] +pub fn r_jellyfin_users_public() -> Json { + Json(json!([])) +} + +#[get("/Branding/Css")] +pub fn r_jellyfin_branding_css() -> String { + "".to_string() +} + +#[get("/QuickConnect/Enabled")] +pub fn r_jellyfin_quickconnect_enabled() -> Json { + Json(json!(false)) +} + +#[get("/System/Endpoint")] +pub fn r_jellyfin_system_endpoint(_session: Session) -> Json { + Json(json!({ + "IsLocal": false, + "IsInNetwork": false, + })) +} + +use rocket_ws::{Message, Stream, WebSocket}; +#[get("/socket")] +pub fn r_jellyfin_socket(_session: Session, ws: WebSocket) -> Stream!['static] { + Stream! { ws => + for await message in ws { + eprintln!("{message:?}"); + } + yield Message::Text("test".to_string()) + } +} + +#[get("/System/Info")] +pub fn r_jellyfin_system_info(_session: Session) -> Json { + Json(json!({ + "OperatingSystemDisplayName": "", + "HasPendingRestart": false, + "IsShuttingDown": false, + "SupportsLibraryMonitor": true, + "WebSocketPortNumber": 8096, + "CompletedInstallations": [], + "CanSelfRestart": true, + "CanLaunchWebBrowser": false, + "ProgramDataPath": "/path/to/data", + "WebPath": "/path/to/web", + "ItemsByNamePath": "/path/to/items", + "CachePath": "/path/to/cache", + "LogPath": "/path/to/log", + "InternalMetadataPath": "/path/to/metadata", + "TranscodingTempPath": "/path/to/transcodes", + "CastReceiverApplications": [], + "HasUpdateAvailable": false, + "EncoderLocation": "System", + "SystemArchitecture": "X64", + "LocalAddress": LOCAL_ADDRESS, + "ServerName": CONF.brand, + "Version": VERSION, + "OperatingSystem": "", + "Id": SERVER_ID + })) +} + +#[get("/DisplayPreferences/usersettings")] +pub fn r_jellyfin_displaypreferences_usersettings(_session: Session) -> Json { + Json(json!({ + "Id": "3ce5b65d-e116-d731-65d1-efc4a30ec35c", + "SortBy": "SortName", + "RememberIndexing": false, + "PrimaryImageHeight": 250, + "PrimaryImageWidth": 250, + "CustomPrefs": false, + "ScrollDirection": "Horizontal", + "ShowBackdrop": true, + "RememberSorting": false, + "SortOrder": "Ascending", + "ShowSidebar": false, + "Client": "emby", + })) +} + +#[post("/DisplayPreferences/usersettings")] +pub fn r_jellyfin_displaypreferences_usersettings_post(_session: Session) {} + +#[get("/Users/")] +pub fn r_jellyfin_users_id(session: Session, id: &str) -> Json { + let _ = id; + Json(user_object(session.user.name)) +} + +#[get("/Items//Images/Primary?&")] +#[allow(non_snake_case)] +pub fn r_jellyfin_items_image_primary( + _session: Session, + id: &str, + fillWidth: Option, + tag: String, +) -> Redirect { + if tag == "poster" { + Redirect::permanent(rocket::uri!(r_item_poster(id, fillWidth))) + } else { + Redirect::permanent(rocket::uri!(r_asset(tag, fillWidth))) + } +} + +#[get("/Items//Images/Backdrop/0?")] +#[allow(non_snake_case)] +pub fn r_jellyfin_items_images_backdrop( + _session: Session, + id: &str, + maxWidth: Option, +) -> Redirect { + Redirect::permanent(rocket::uri!(r_item_backdrop(id, maxWidth))) +} + +#[get("/Items/")] +#[allow(private_interfaces)] +pub fn r_jellyfin_items_item( + session: Session, + database: &State, + id: &str, +) -> MyResult> { + let (n, ud) = database.get_node_with_userdata(NodeID::from_slug(id), &session)?; + Ok(Json(item_object(&n, &ud))) +} +#[get("/Users//Items/")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_items_item( + session: Session, + database: &State, + uid: &str, + id: &str, +) -> MyResult> { + let _ = uid; + r_jellyfin_items_item(session, database, id) +} + +#[derive(Debug, FromForm)] +struct JellyfinItemQuery { + #[field(name = uncased("searchterm"))] + search_term: Option, + #[field(name = uncased("limit"))] + limit: usize, + #[field(name = uncased("parentid"))] + parent_id: Option, + #[field(name = uncased("startindex"))] + start_index: Option, + #[field(name = uncased("includeitemtypes"))] + include_item_types: Option, + + internal_artists: bool, + internal_persons: bool, +} + +#[get("/Users//Items?")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_items( + session: Session, + database: &State, + uid: &str, + query: JellyfinItemQuery, +) -> MyResult> { + let _ = uid; + r_jellyfin_items(session, database, query) +} + +#[get("/Artists?")] +#[allow(private_interfaces)] +pub fn r_jellyfin_artists( + session: Session, + database: &State, + mut query: JellyfinItemQuery, +) -> MyResult> { + query.internal_artists = true; + r_jellyfin_items(session, database, query)?; // TODO + Ok(Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + }))) +} + +#[get("/Persons?")] +#[allow(private_interfaces)] +pub fn r_jellyfin_persons( + session: Session, + database: &State, + mut query: JellyfinItemQuery, +) -> MyResult> { + query.internal_persons = true; + r_jellyfin_items(session, database, query)?; // TODO + Ok(Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + }))) +} + +#[get("/Items?")] +#[allow(private_interfaces)] +pub fn r_jellyfin_items( + session: Session, + database: &State, + query: JellyfinItemQuery, +) -> MyResult> { + let (nodes, parent_kind) = if let Some(q) = query.search_term { + ( + database + .search(&q, query.limit, query.start_index.unwrap_or_default())? + .1, + None, + ) + } else if let Some(parent) = query.parent_id { + let parent = NodeID::from_slug(&parent); + ( + database + .get_node_children(parent)? + .into_iter() + .skip(query.start_index.unwrap_or_default()) + .take(query.limit) + .collect(), + database.get_node(parent)?.map(|n| n.kind), + ) + } else { + (vec![], None) + }; + + let filter_kind = query + .include_item_types + .map(|n| match n.as_str() { + "Movie" => vec![FilterProperty::KindMovie], + "Audio" => vec![FilterProperty::KindMusic], + "Video" => vec![FilterProperty::KindVideo], + "TvChannel" => vec![FilterProperty::KindChannel], + _ => vec![], + }) + .or(if query.internal_artists { + Some(vec![]) + } else { + None + }) + .or(if query.internal_persons { + Some(vec![]) + } else { + None + }); + + let mut nodes = nodes + .into_iter() + .map(|nid| database.get_node_with_userdata(nid, &session)) + .collect::, anyhow::Error>>()?; + + filter_and_sort_nodes( + &NodeFilterSort { + sort_by: None, + filter_kind, + sort_order: None, + }, + match parent_kind { + Some(NodeKind::Channel) => (SortProperty::ReleaseDate, SortOrder::Descending), + _ => (SortProperty::Title, SortOrder::Ascending), + }, + &mut nodes, + ); + + let items = nodes + .into_iter() + .filter(|(n, _)| n.visibility >= Visibility::Reduced) + .map(|(n, ud)| item_object(&n, &ud)) + .collect::>(); + + Ok(Json(json!({ + "Items": items, + "TotalRecordCount": items.len(), + "StartIndex": query.start_index.unwrap_or_default() + }))) +} + +#[get("/UserViews?")] +#[allow(non_snake_case)] +pub fn r_jellyfin_users_views( + session: Session, + database: &State, + userId: &str, +) -> MyResult> { + let _ = userId; + + let mut toplevel = database + .get_node_children(NodeID::from_slug("library")) + .context("root node missing")? + .into_iter() + .map(|nid| database.get_node_with_userdata(nid, &session)) + .collect::, anyhow::Error>>()?; + + toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX)); + + let mut items = Vec::new(); + for (n, ud) in toplevel { + if n.visibility >= Visibility::Reduced { + items.push(item_object(&n, &ud)) + } + } + + Ok(Json(json!({ + "Items": items, + "TotalRecordCount": items.len(), + "StartIndex": 0 + }))) +} + +#[get("/Items//Similar")] +pub fn r_jellyfin_items_similar(_session: Session, id: &str) -> Json { + let _ = id; + Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + })) +} + +#[get("/LiveTv/Programs/Recommended")] +pub fn r_jellyfin_livetv_programs_recommended(_session: Session) -> Json { + Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + })) +} + +#[get("/Users//Items//Intros")] +pub fn r_jellyfin_items_intros(_session: Session, uid: &str, id: &str) -> Json { + let _ = (uid, id); + Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + })) +} + +#[get("/Shows/NextUp")] +pub fn r_jellyfin_shows_nextup(_session: Session) -> Json { + Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + })) +} + +#[post("/Items//PlaybackInfo")] +pub fn r_jellyfin_items_playbackinfo( + _session: Session, + database: &State, + id: &str, +) -> MyResult> { + let node = database + .get_node_slug(id)? + .ok_or(anyhow!("node does not exist"))?; + let media = node.media.as_ref().ok_or(anyhow!("node has no media"))?; + let ms = media_source_object(&node, media); + Ok(Json(json!({ + "MediaSources": [ms], + "PlaySessionId": "why do we need this id?" + }))) +} + +#[get("/Videos//stream.webm")] +pub fn r_jellyfin_video_stream( + _session: Session, + database: &State, + id: &str, +) -> MyResult { + let node = database + .get_node_slug(id)? + .ok_or(anyhow!("node does not exist"))?; + let media = node.media.as_ref().ok_or(anyhow!("node has no media"))?; + let params = StreamSpec::Remux { + tracks: (0..media.tracks.len()).collect(), + container: StreamContainer::WebM, + } + .to_query(); + Ok(Redirect::temporary(format!("/n/{id}/stream{params}"))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct JellyfinProgressData { + item_id: String, + position_ticks: f64, +} +#[post("/Sessions/Playing/Progress", data = "")] +#[allow(private_interfaces)] +pub fn r_jellyfin_sessions_playing_progress( + session: Session, + database: &State, + data: Json, +) -> MyResult<()> { + let position = data.position_ticks / 10_000_000.; + database.update_node_udata( + NodeID::from_slug(&data.item_id), + &session.user.name, + |udata| { + udata.watched = match udata.watched { + WatchedState::None | WatchedState::Pending | WatchedState::Progress(_) => { + WatchedState::Progress(position) + } + WatchedState::Watched => WatchedState::Watched, + }; + Ok(()) + }, + )?; + Ok(()) +} + +#[post("/Sessions/Playing")] +pub fn r_jellyfin_sessions_playing(_session: Session) {} + +#[get("/Playback/BitrateTest?")] +#[allow(non_snake_case)] +pub fn r_jellyfin_playback_bitratetest(_session: Session, Size: usize) -> Vec { + vec![0; Size.min(1_000_000)] +} + +#[post("/Sessions/Capabilities/Full")] +pub fn r_jellyfin_sessions_capabilities_full(_session: Session) {} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct AuthData { + pw: String, + username: String, +} + +#[post("/Users/AuthenticateByName", data = "")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_authenticatebyname_case( + client_addr: IpAddr, + database: &State, + data: Json, + jar: &CookieJar, +) -> MyResult> { + r_jellyfin_users_authenticatebyname(client_addr, database, data, jar) +} + +#[post("/Users/authenticatebyname", data = "")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_authenticatebyname( + client_addr: IpAddr, + database: &State, + data: Json, + jar: &CookieJar, +) -> MyResult> { + let token = login_logic(database, &data.username, &data.pw, None, None)?; + + // setting the session cookie too because image requests carry no auth headers for some reason. + // TODO find alternative, non-web clients might not understand cookies + jar.add( + Cookie::build(("session", token.clone())) + .permanent() + .build(), + ); + + Ok(Json(json!({ + "User": user_object(data.username.clone()), + "SessionInfo": { + "PlayState": { + "CanSeek": false, + "IsPaused": false, + "IsMuted": false, + "RepeatMode": "RepeatNone", + "PlaybackOrder": "Default" + }, + "AdditionalUsers": [], + "Capabilities": { + "PlayableMediaTypes": [ + "Audio", + "Video" + ], + "SupportedCommands": [], + // "MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "SetShuffleQueue", "ChannelUp", "ChannelDown", "PlayMediaSource", "PlayTrailers" + "SupportsMediaControl": true, + "SupportsPersistentIdentifier": false + }, + "RemoteEndPoint": client_addr, + "PlayableMediaTypes": [ + "Audio", + "Video" + ], + "Id": "6e05fbb4fe33477b991455c97a57e25d", + "UserId": USER_ID, + "UserName": data.username.clone(), + "Client": "Jellyfin Web", + "LastActivityDate": "0001-01-01T00:00:00.0000000Z", + "LastPlaybackCheckIn": "0001-01-01T00:00:00.0000000Z", + "DeviceName": "blub blub blub", + "DeviceId": "wagening", + "ApplicationVersion": VERSION, + "IsActive": true, + "SupportsMediaControl": false, + "SupportsRemoteControl": false, + "NowPlayingQueue": [], + "NowPlayingQueueFullItems": [], + "HasCustomDeviceName": false, + "ServerId": SERVER_ID, + "SupportedCommands": [] + // "MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "SetShuffleQueue", "ChannelUp", "ChannelDown", "PlayMediaSource", "PlayTrailers" + }, + "AccessToken": token, + "ServerId": SERVER_ID + }))) +} + +fn track_object(index: usize, track: &SourceTrack) -> JellyfinMediaStream { + let fr = if let SourceTrackKind::Video { fps, .. } = &track.kind { + Some(fps.unwrap_or_default()) + } else { + None + }; + JellyfinMediaStream { + codec: match track.codec.as_str() { + "V_HEVC" => "hevc", + "V_AV1" => "av1", + "V_VP8" => "vp8", + "V_VP9" => "vp9", + "A_AAC" => "aac", + "A_OPUS" => "opus", + _ => "unknown", + } + .to_string(), + time_base: "1/1000".to_string(), // TODO unsure what that means + video_range: if track.kind.letter() == 'v' { + "SDR" + } else { + "Unknown" + } + .to_string(), + video_range_type: if track.kind.letter() == 'v' { + "SDR" + } else { + "Unknown" + } + .to_string(), + audio_spatial_format: "None".to_string(), + display_title: track.to_string(), + is_interlaced: false, // TODO assuming that + is_avc: track.codec.as_str() == "V_AVC", + bit_rate: 5_000_000, // TODO totally + bit_depth: 8, + ref_frames: 1, + is_default: true, + is_forced: false, + is_hearing_impaired: false, + height: if let SourceTrackKind::Video { height, .. } = &track.kind { + Some(*height) + } else { + None + }, + width: if let SourceTrackKind::Video { width, .. } = &track.kind { + Some(*width) + } else { + None + }, + average_frame_rate: fr, + real_frame_rate: fr, + reference_frame_rate: fr, + profile: "Main".to_string(), + r#type: match track.kind { + SourceTrackKind::Audio { .. } => JellyfinMediaStreamType::Audio, + SourceTrackKind::Video { .. } => JellyfinMediaStreamType::Video, + SourceTrackKind::Subtitles => JellyfinMediaStreamType::Subtitle, + }, + aspect_ratio: "1:1".to_string(), // TODO aaa + index, + is_external: false, + is_text_subtitle_stream: false, + supports_external_stream: false, + pixel_format: "yuv420p".to_string(), + level: 150, // TODO what this mean? + is_anamorphic: false, + channel_layout: if let SourceTrackKind::Audio { .. } = &track.kind { + Some("5.1".to_string()) // TODO aaa + } else { + None + }, + channels: if let SourceTrackKind::Audio { channels, .. } = &track.kind { + Some(*channels) + } else { + None + }, + sample_rate: if let SourceTrackKind::Audio { sample_rate, .. } = &track.kind { + Some(*sample_rate) + } else { + None + }, + localized_default: "Default".to_string(), + localized_external: "External".to_string(), + } +} + +fn media_source_object(node: &Node, m: &MediaInfo) -> JellyfinMediaSource { + JellyfinMediaSource { + protocol: JellyfinMediaSourceProtocol::File, + id: node.slug.clone(), + path: format!("/path/to/{}.webm", node.slug), + r#type: JellyfinMediaSourceType::Default, + container: "webm".to_string(), + size: 1_000_000_000, + name: node.slug.clone(), + is_remote: false, + e_tag: "blub".to_string(), + run_time_ticks: m.duration * 10_000_000., + read_at_native_framerate: false, + ignore_dts: false, + ignore_index: false, + gen_pts_input: false, + supports_transcoding: true, + supports_direct_stream: true, + supports_direct_play: true, + is_infinite_stream: false, + use_most_compatible_transcoding_profile: false, + requires_opening: false, + requires_closing: false, + requires_looping: false, + supports_probing: true, + video_type: JellyfinVideoType::VideoFile, + media_streams: m + .tracks + .iter() + .enumerate() + .map(|(i, t)| track_object(i, t)) + .collect::>(), + media_attachments: Vec::new(), + formats: Vec::new(), + bitrate: 10_000_000, + required_http_headers: BTreeMap::new(), + transcoding_sub_protocol: "http".to_string(), + default_audio_stream_index: 1, // TODO + default_subtitle_stream_index: 2, // TODO + has_segments: false, + } +} + +fn item_object(node: &Node, userdata: &NodeUserData) -> JellyfinItem { + let media_source = node.media.as_ref().map(|m| media_source_object(node, m)); + + JellyfinItem { + name: node.title.clone().unwrap_or_default(), + server_id: SERVER_ID.to_owned(), + id: node.slug.clone(), + e_tag: "blob".to_owned(), + date_created: "0001-01-01T00:00:00.0000000Z".to_owned(), + can_delete: false, + can_download: true, + preferred_metadata_language: "".to_owned(), + preferred_metadata_country_code: "".to_owned(), + sort_name: node.slug.clone(), + forced_sort_name: "".to_owned(), + external_urls: vec![], + enable_media_source_display: true, + custom_rating: "".to_owned(), + channel_id: None, + overview: node.description.clone().unwrap_or_default(), + taglines: vec![node.tagline.clone().unwrap_or_default()], + genres: vec![], + remote_trailers: vec![], + provider_ids: BTreeMap::new(), + is_folder: node.media.is_none(), + parent_id: "todo-parent".to_owned(), // TODO + r#type: match node.kind { + NodeKind::Movie | NodeKind::Video | NodeKind::ShortFormVideo => JellyfinItemType::Movie, + NodeKind::Collection => JellyfinItemType::CollectionFolder, + _ => JellyfinItemType::CollectionFolder, + }, + people: node + .people + .iter() + .flat_map(|(_pg, ps)| { + ps.iter().map(|p| JellyfinPerson { + name: p.person.name.clone(), + id: p.person.ids.tmdb.unwrap_or_default().to_string(), + primary_image_tag: p.person.headshot.clone().map(|a| a.0).unwrap_or_default(), + role: p.characters.join(","), + r#type: JellyfinPersonType::Actor, + }) + }) + .collect(), + studios: vec![], + genre_items: vec![], + local_trailer_count: 0, + special_feature_count: 0, + child_count: 0, + locked_fields: vec![], + lock_data: false, + tags: vec![], + user_data: json!({ + "PlaybackPositionTicks": 0, + "PlayCount": if userdata.watched == WatchedState::Watched { 1 } else { 0 }, + "IsFavorite": userdata.rating > 0, + "Played": userdata.watched == WatchedState::Watched, + "Key": "7a2175bc-cb1f-1a94-152c-bd2b2bae8f6d", + "ItemId": "00000000000000000000000000000000" + }), + display_preferences_id: node.slug.clone(), + primary_image_aspect_ratio: match aspect_class(node.kind) { + "aspect-thumb" => 16. / 9., + "aspect-land" => 2f64.sqrt(), + "aspect-port" => 1. / 2f64.sqrt(), + "aspect-square" => 1., + _ => 1., + }, + collection_type: "unknown".to_owned(), + image_tags: BTreeMap::from_iter([("Primary".to_string(), "poster".to_string())]), + backdrop_image_tags: vec!["backdrop".to_string()], + media_type: if node.media.is_some() { + "Video".to_owned() + } else { + "Unknown".to_owned() + }, + video_type: node.media.as_ref().map(|_| "VideoFile".to_owned()), + location_type: node.media.as_ref().map(|_| "FileSystem".to_owned()), + play_access: node.media.as_ref().map(|_| "Full".to_owned()), + container: node.media.as_ref().map(|_| "webm".to_owned()), + run_time_ticks: node + .media + .as_ref() + .map(|m| (m.duration * 10_000_000.) as i64), + media_sources: media_source.as_ref().map(|s| vec![s.clone()]), + media_streams: media_source.as_ref().map(|s| s.media_streams.clone()), + path: node + .media + .as_ref() + .map(|_| format!("/path/to/{}.webm", node.slug)), + } +} + +fn user_object(username: String) -> Value { + json!({ + "Name": username, + "ServerId": SERVER_ID, + "Id": USER_ID, + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastLoginDate": "0001-01-01T00:00:00.0000000Z", + "LastActivityDate": "0001-01-01T00:00:00.0000000Z", + "Configuration": { + "PlayDefaultAudioTrack": true, + "SubtitleLanguagePreference": "", + "DisplayMissingEpisodes": false, + "GroupedFolders": [], + "SubtitleMode": "Default", + "DisplayCollectionsView": false, + "EnableLocalPassword": false, + "OrderedViews": [], + "LatestItemsExcludes": [], + "MyMediaExcludes": [], + "HidePlayedInLatest": true, + "RememberAudioSelections": true, + "RememberSubtitleSelections": true, + "EnableNextEpisodeAutoPlay": true, + "CastReceiverId": "F007D354" + }, + "Policy": { + "IsAdministrator": false, + "IsHidden": true, + "EnableCollectionManagement": false, + "EnableSubtitleManagement": false, + "EnableLyricManagement": false, + "IsDisabled": false, + "BlockedTags": [], + "AllowedTags": [], + "EnableUserPreferenceAccess": true, + "AccessSchedules": [], + "BlockUnratedItems": [], + "EnableRemoteControlOfOtherUsers": false, + "EnableSharedDeviceControl": false, + "EnableRemoteAccess": true, + "EnableLiveTvManagement": false, + "EnableLiveTvAccess": true, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "ForceRemoteSourceTranscoding": false, + "EnableContentDeletion": false, + "EnableContentDeletionFromFolders": [], + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnabledDevices": [], + "EnableAllDevices": true, + "EnabledChannels": [], + "EnableAllChannels": false, + "EnabledFolders": [], + "EnableAllFolders": true, + "InvalidLoginAttemptCount": 0, + "LoginAttemptsBeforeLockout": -1, + "MaxActiveSessions": 0, + "EnablePublicSharing": true, + "BlockedMediaFolders": [], + "BlockedChannels": [], + "RemoteClientBitrateLimit": 0, + "AuthenticationProviderId": "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", + "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", + "SyncPlayAccess": "CreateAndJoinGroups" + } + }) +} diff --git a/server/src/compat/jellyfin/models.rs b/server/src/compat/jellyfin/models.rs new file mode 100644 index 0000000..6a68455 --- /dev/null +++ b/server/src/compat/jellyfin/models.rs @@ -0,0 +1,198 @@ +/* + 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 +*/ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Serialize, Deserialize)] +pub(super) enum JellyfinItemType { + AudioBook, + Movie, + BoxSet, + Book, + Photo, + PhotoAlbum, + TvChannel, + LiveTvProgram, + Video, + Audio, + MusicAlbum, + CollectionFolder, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) enum JellyfinMediaStreamType { + Video, + Audio, + Subtitle, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct JellyfinMediaStream { + pub codec: String, + pub time_base: String, + pub video_range: String, + pub video_range_type: String, + pub audio_spatial_format: String, + pub display_title: String, + pub is_interlaced: bool, + pub is_avc: bool, + pub bit_rate: usize, + pub bit_depth: usize, + pub ref_frames: usize, + pub is_default: bool, + pub is_forced: bool, + pub is_hearing_impaired: bool, + pub height: Option, + pub width: Option, + pub average_frame_rate: Option, + pub real_frame_rate: Option, + pub reference_frame_rate: Option, + pub profile: String, + pub r#type: JellyfinMediaStreamType, + pub aspect_ratio: String, + pub index: usize, + pub is_external: bool, + pub is_text_subtitle_stream: bool, + pub supports_external_stream: bool, + pub pixel_format: String, + pub level: usize, + pub is_anamorphic: bool, + pub channel_layout: Option, + pub channels: Option, + pub sample_rate: Option, + pub localized_default: String, + pub localized_external: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) enum JellyfinMediaSourceProtocol { + File, +} +#[derive(Debug, Clone, Serialize)] +pub(super) enum JellyfinMediaSourceType { + Default, +} + +#[derive(Debug, Clone, Serialize)] +pub(super) enum JellyfinVideoType { + VideoFile, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct JellyfinMediaSource { + pub protocol: JellyfinMediaSourceProtocol, + pub id: String, + pub path: String, + pub r#type: JellyfinMediaSourceType, + pub container: String, + pub size: usize, + pub name: String, + pub is_remote: bool, + pub e_tag: String, + pub run_time_ticks: f64, + pub read_at_native_framerate: bool, + pub ignore_dts: bool, + pub ignore_index: bool, + pub gen_pts_input: bool, + pub supports_transcoding: bool, + pub supports_direct_stream: bool, + pub supports_direct_play: bool, + pub is_infinite_stream: bool, + pub use_most_compatible_transcoding_profile: bool, + pub requires_opening: bool, + pub requires_closing: bool, + pub requires_looping: bool, + pub supports_probing: bool, + pub video_type: JellyfinVideoType, + pub media_streams: Vec, + pub media_attachments: Vec<()>, + pub formats: Vec<()>, + pub bitrate: usize, + pub required_http_headers: BTreeMap<(), ()>, + pub transcoding_sub_protocol: String, + pub default_audio_stream_index: usize, + pub default_subtitle_stream_index: usize, + pub has_segments: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct JellyfinItem { + pub name: String, + pub server_id: String, + pub id: String, + pub e_tag: String, + pub date_created: String, + pub can_delete: bool, + pub can_download: bool, + pub preferred_metadata_language: String, + pub preferred_metadata_country_code: String, + pub sort_name: String, + pub forced_sort_name: String, + pub external_urls: Vec<()>, + pub enable_media_source_display: bool, + pub custom_rating: String, + pub channel_id: Option, + pub overview: String, + pub taglines: Vec, + pub genres: Vec<()>, + pub play_access: Option, + pub remote_trailers: Vec<()>, + pub provider_ids: BTreeMap<(), ()>, + pub is_folder: bool, + pub parent_id: String, + pub r#type: JellyfinItemType, + pub people: Vec, + pub studios: Vec, + pub genre_items: Vec<()>, + pub local_trailer_count: usize, + pub special_feature_count: usize, + pub child_count: usize, + pub locked_fields: Vec<()>, + pub lock_data: bool, + pub tags: Vec, + pub user_data: Value, + pub display_preferences_id: String, + pub primary_image_aspect_ratio: f64, + pub collection_type: String, + pub image_tags: BTreeMap, + pub backdrop_image_tags: Vec, + pub location_type: Option, + pub media_type: String, + pub video_type: Option, + pub container: Option, + pub run_time_ticks: Option, + pub media_sources: Option>, + pub media_streams: Option>, + pub path: Option, +} + +#[derive(Debug, Serialize)] +pub(super) enum JellyfinPersonType { + Actor, + // Writer, + // Producer, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct JellyfinPerson { + pub name: String, + pub id: String, + pub role: String, + pub r#type: JellyfinPersonType, + pub primary_image_tag: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub(super) struct JellyfinStudio { + pub name: String, + pub id: String, +} diff --git a/server/src/compat/mod.rs b/server/src/compat/mod.rs new file mode 100644 index 0000000..a7b8c0d --- /dev/null +++ b/server/src/compat/mod.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 +*/ +pub mod jellyfin; +pub mod youtube; diff --git a/server/src/compat/youtube.rs b/server/src/compat/youtube.rs new file mode 100644 index 0000000..1df2751 --- /dev/null +++ b/server/src/compat/youtube.rs @@ -0,0 +1,62 @@ +/* + 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 +*/ +use crate::{ + logic::session::Session, + ui::{ + error::MyResult, + node::rocket_uri_macro_r_library_node, + player::{rocket_uri_macro_r_player, PlayerConfig}, + }, +}; +use anyhow::anyhow; +use jellybase::database::Database; +use jellycommon::NodeID; +use rocket::{get, response::Redirect, State}; + +#[get("/watch?")] +pub fn r_youtube_watch(_session: Session, db: &State, v: &str) -> MyResult { + if v.len() != 11 { + Err(anyhow!("video id length incorrect"))? + } + let Some(id) = db.get_node_external_id("youtube:video", v)? else { + Err(anyhow!("element not found"))? + }; + let node = db.get_node(id)?.ok_or(anyhow!("node missing"))?; + Ok(Redirect::to(rocket::uri!(r_player( + &node.slug, + PlayerConfig::default() + )))) +} + +#[get("/channel/")] +pub fn r_youtube_channel(_session: Session, db: &State, id: &str) -> MyResult { + let Some(id) = (if id.starts_with("UC") { + db.get_node_external_id("youtube:channel", id)? + } else if id.starts_with("@") { + db.get_node_external_id("youtube:channel-name", id)? + } else { + Err(anyhow!("unknown channel id format"))? + }) else { + Err(anyhow!("channel not found"))? + }; + let node = db.get_node(id)?.ok_or(anyhow!("node missing"))?; + Ok(Redirect::to(rocket::uri!(r_library_node(&node.slug)))) +} + +#[get("/embed/")] +pub fn r_youtube_embed(_session: Session, db: &State, v: &str) -> MyResult { + if v.len() != 11 { + Err(anyhow!("video id length incorrect"))? + } + let Some(id) = db.get_node_external_id("youtube:video", v)? else { + Err(anyhow!("element not found"))? + }; + let node = db.get_node(id)?.ok_or(anyhow!("node missing"))?; + Ok(Redirect::to(rocket::uri!(r_player( + &node.slug, + PlayerConfig::default() + )))) +} diff --git a/server/src/helper/cors.rs b/server/src/helper/cors.rs new file mode 100644 index 0000000..ca513e3 --- /dev/null +++ b/server/src/helper/cors.rs @@ -0,0 +1,20 @@ +/* + 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 +*/ + +use rocket::{ + http::Header, + response::{self, Responder}, + Request, +}; + +pub struct Cors(pub T); +impl<'r, T: Responder<'r, 'static>> Responder<'r, 'static> for Cors { + fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { + let mut r = self.0.respond_to(request)?; + r.adjoin_header(Header::new("access-controll-allow-origin", "*")); + Ok(r) + } +} diff --git a/server/src/helper/mod.rs b/server/src/helper/mod.rs new file mode 100644 index 0000000..946e8fa --- /dev/null +++ b/server/src/helper/mod.rs @@ -0,0 +1,6 @@ +/* + 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 +*/ +pub mod cors; diff --git a/server/src/locale.rs b/server/src/locale.rs new file mode 100644 index 0000000..6d16c17 --- /dev/null +++ b/server/src/locale.rs @@ -0,0 +1,55 @@ +use jellybase::locale::Language; +use rocket::{ + outcome::Outcome, + request::{self, FromRequest}, + Request, +}; +use std::ops::Deref; + +pub struct AcceptLanguage(pub Language); +impl Deref for AcceptLanguage { + type Target = Language; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl<'r> FromRequest<'r> for AcceptLanguage { + type Error = (); + + fn from_request<'life0, 'async_trait>( + request: &'r Request<'life0>, + ) -> ::core::pin::Pin< + Box< + dyn ::core::future::Future> + + ::core::marker::Send + + 'async_trait, + >, + > + where + 'r: 'async_trait, + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { Outcome::Success(AcceptLanguage(lang_from_request(request))) }) + } +} + +pub(crate) fn lang_from_request(request: &Request) -> Language { + request + .headers() + .get_one("accept-language") + .and_then(|h| { + h.split(",") + .filter_map(|e| { + let code = e.split(";").next()?; + let code = code.split_once("-").unwrap_or((code, "")).0; + match code { + "en" => Some(Language::English), + "de" => Some(Language::German), + _ => None, + } + }) + .next() + }) + .unwrap_or(Language::English) +} diff --git a/server/src/logic/mod.rs b/server/src/logic/mod.rs new file mode 100644 index 0000000..745d11b --- /dev/null +++ b/server/src/logic/mod.rs @@ -0,0 +1,9 @@ +/* + 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 +*/ +pub mod playersync; +pub mod session; +pub mod stream; +pub mod userdata; diff --git a/server/src/logic/playersync.rs b/server/src/logic/playersync.rs new file mode 100644 index 0000000..b4cc51b --- /dev/null +++ b/server/src/logic/playersync.rs @@ -0,0 +1,109 @@ +use anyhow::bail; +use chashmap::CHashMap; +use futures::{SinkExt, StreamExt}; +use log::warn; +use rocket::{get, State}; +use rocket_ws::{stream::DuplexStream, Channel, Message, WebSocket}; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast::{self, Sender}; + +use crate::helper::cors::Cors; + +#[derive(Default)] +pub struct PlayersyncChannels { + channels: CHashMap>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum Packet { + Time(f64), + Playing(bool), + Join(String), + Leave(String), +} + +#[get("/playersync/")] +pub fn r_playersync( + ws: WebSocket, + state: &State, + channel: &str, +) -> Cors> { + let sender = state + .channels + .get(&channel.to_owned()) + .map(|x| x.to_owned()) + .unwrap_or_else(|| { + let ch = broadcast::channel(16).0; + state.channels.insert(channel.to_owned(), ch.clone()); + ch + }); + Cors(ws.channel(move |ws| { + Box::pin(async move { + let mut state = ClientState { + username: "unknown user".into(), + }; + if let Err(e) = handle_socket(&sender, ws, &mut state).await { + warn!("streamsync websocket error: {e:?}") + } + let _ = sender.send(Message::Text( + serde_json::to_string(&Packet::Leave(state.username)).unwrap(), + )); + Ok(()) + }) + })) +} + +struct ClientState { + username: String, +} + +async fn handle_socket( + broadcast: &Sender, + mut ws: DuplexStream, + state: &mut ClientState, +) -> anyhow::Result<()> { + let mut sub = broadcast.subscribe(); + loop { + tokio::select! { + message = ws.next() => { + match handle_packet(broadcast, message,state) { + Err(e) => Err(e)?, + Ok(true) => return Ok(()), + Ok(false) => () + } + }, + message = sub.recv() => { + ws.send(message?).await?; + } + }; + } +} + +fn handle_packet( + broadcast: &Sender, + message: Option>, + state: &mut ClientState, +) -> anyhow::Result { + let Some(message) = message else { + return Ok(true); + }; + let message = message?.into_text()?; + let packet: Packet = serde_json::from_str(&message)?; + + let broadcast = |p: Packet| -> anyhow::Result<()> { + broadcast.send(Message::Text(serde_json::to_string(&p)?))?; + Ok(()) + }; + + match packet { + Packet::Join(username) => { + broadcast(Packet::Join(username.clone()))?; + state.username = username; + } + Packet::Leave(_) => bail!("illegal packet"), + p => broadcast(p)?, + }; + + Ok(false) +} diff --git a/server/src/logic/session.rs b/server/src/logic/session.rs new file mode 100644 index 0000000..790e070 --- /dev/null +++ b/server/src/logic/session.rs @@ -0,0 +1,208 @@ +/* + 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 +*/ +use crate::ui::error::MyError; +use aes_gcm_siv::{ + aead::{generic_array::GenericArray, Aead}, + KeyInit, +}; +use anyhow::anyhow; +use base64::Engine; +use chrono::{DateTime, Duration, Utc}; +use jellybase::{database::Database, SECRETS}; +use jellycommon::user::{PermissionSet, User}; +use log::warn; +use rocket::{ + async_trait, + http::Status, + outcome::Outcome, + request::{self, FromRequest}, + Request, State, +}; +use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; + +pub struct Session { + pub user: User, +} + +pub struct AdminSession(pub Session); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionData { + username: String, + expire: DateTime, + permissions: PermissionSet, +} + +impl Session { + pub async fn from_request_ut(req: &Request<'_>) -> Result { + let username; + + #[cfg(not(feature = "bypass-auth"))] + { + let token = req + .query_value("session") + .map(|e| e.unwrap()) + .or_else(|| req.query_value("api_key").map(|e| e.unwrap())) + .or_else(|| req.headers().get_one("X-MediaBrowser-Token")) + .or_else(|| { + req.headers() + .get_one("Authorization") + .and_then(parse_jellyfin_auth) + }) // for jellyfin compat + .or(req.cookies().get("session").map(|cookie| cookie.value())) + .ok_or(anyhow!("not logged in"))?; + + // jellyfin urlescapes the token for *some* requests + let token = token.replace("%3D", "="); + username = validate(&token)?; + }; + + #[cfg(feature = "bypass-auth")] + { + parse_jellyfin_auth("a"); // unused warning is annoying + username = "admin".to_string(); + } + + let db = req.guard::<&State>().await.unwrap(); + + let user = db.get_user(&username)?.ok_or(anyhow!("user not found"))?; + + Ok(Session { user }) + } +} + +fn parse_jellyfin_auth(h: &str) -> Option<&str> { + for tok in h.split(" ") { + if let Some(tok) = tok.strip_prefix("Token=\"") { + if let Some(tok) = tok.strip_suffix("\"") { + return Some(tok); + } + } + } + None +} + +#[async_trait] +impl<'r> FromRequest<'r> for Session { + type Error = MyError; + async fn from_request<'life0>( + request: &'r Request<'life0>, + ) -> request::Outcome { + match Session::from_request_ut(request).await { + Ok(x) => Outcome::Success(x), + Err(e) => { + warn!("authentificated route rejected: {e:?}"); + Outcome::Forward(Status::Unauthorized) + } + } + } +} + +#[async_trait] +impl<'r> FromRequest<'r> for AdminSession { + type Error = MyError; + async fn from_request<'life0>( + request: &'r Request<'life0>, + ) -> request::Outcome { + match Session::from_request_ut(request).await { + Ok(x) => { + if x.user.admin { + Outcome::Success(AdminSession(x)) + } else { + Outcome::Error(( + Status::Unauthorized, + MyError(anyhow!("you are not an admin")), + )) + } + } + Err(e) => { + warn!("authentificated route rejected: {e:?}"); + Outcome::Forward(Status::Unauthorized) + } + } + } +} + +static SESSION_KEY: LazyLock<[u8; 32]> = LazyLock::new(|| { + if let Some(sk) = &SECRETS.session_key { + let r = base64::engine::general_purpose::STANDARD + .decode(sk) + .expect("key invalid; should be valid base64"); + r.try_into() + .expect("key has the wrong length; should be 32 bytes") + } else { + warn!("session_key not configured; generating a random one."); + [(); 32].map(|_| rand::random()) + } +}); + +pub fn create(username: String, permissions: PermissionSet, expire: Duration) -> String { + let session_data = SessionData { + expire: Utc::now() + expire, + username: username.to_owned(), + permissions, + }; + let mut plaintext = + bincode::serde::encode_to_vec(&session_data, bincode::config::standard()).unwrap(); + + while plaintext.len() % 16 == 0 { + plaintext.push(0); + } + + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let nonce = [(); 12].map(|_| rand::random()); + let mut ciphertext = cipher + .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) + .unwrap(); + ciphertext.extend(nonce); + + base64::engine::general_purpose::URL_SAFE.encode(&ciphertext) +} + +pub fn validate(token: &str) -> anyhow::Result { + let ciphertext = base64::engine::general_purpose::URL_SAFE.decode(token)?; + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let (ciphertext, nonce) = ciphertext.split_at(ciphertext.len() - 12); + let plaintext = cipher + .decrypt(nonce.into(), ciphertext) + .map_err(|e| anyhow!("decryption failed: {e:?}"))?; + + let (session_data, _): (SessionData, _) = + bincode::serde::decode_from_slice(&plaintext, bincode::config::standard())?; + + if session_data.expire < Utc::now() { + Err(anyhow!("session expired"))? + } + + Ok(session_data.username) +} + +#[test] +fn test() { + jellybase::use_test_config(); + let tok = create( + "blub".to_string(), + jellycommon::user::PermissionSet::default(), + Duration::days(1), + ); + validate(&tok).unwrap(); +} + +#[test] +fn test_crypto() { + jellybase::use_test_config(); + let nonce = [(); 12].map(|_| rand::random()); + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let plaintext = b"testing stuff---"; + let ciphertext = cipher + .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) + .unwrap(); + let plaintext2 = cipher + .decrypt((&nonce).into(), ciphertext.as_slice()) + .unwrap(); + assert_eq!(plaintext, plaintext2.as_slice()); +} diff --git a/server/src/logic/stream.rs b/server/src/logic/stream.rs new file mode 100644 index 0000000..5bba9c2 --- /dev/null +++ b/server/src/logic/stream.rs @@ -0,0 +1,246 @@ +/* + 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 +*/ +use super::session::Session; +use crate::{database::Database, ui::error::MyError}; +use anyhow::{anyhow, Result}; +use jellybase::{assetfed::AssetInner, federation::Federation}; +use jellycommon::{stream::StreamSpec, TrackSource}; +use jellystream::SMediaInfo; +use log::{info, warn}; +use rocket::{ + get, head, + http::{Header, Status}, + request::{self, FromRequest}, + response::{self, Redirect, Responder}, + Either, Request, Response, State, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + ops::Range, + sync::Arc, +}; +use tokio::io::{duplex, DuplexStream}; + +#[head("/n/<_id>/stream?")] +pub async fn r_stream_head( + _sess: Session, + _id: &str, + spec: BTreeMap, +) -> Result, MyError> { + let spec = StreamSpec::from_query_kv(&spec).map_err(|x| anyhow!("spec invalid: {x}"))?; + let head = jellystream::stream_head(&spec); + Ok(Either::Left(StreamResponse { + stream: duplex(0).0, + advertise_range: head.range_supported, + content_type: head.content_type, + range: None, + })) +} + +#[get("/n//stream?")] +pub async fn r_stream( + _session: Session, + _federation: &State, + db: &State, + id: &str, + range: Option, + spec: BTreeMap, +) -> Result, MyError> { + let spec = StreamSpec::from_query_kv(&spec).map_err(|x| anyhow!("spec invalid: {x}"))?; + // TODO perm + let node = db + .get_node_slug(id)? + .ok_or(anyhow!("node does not exist"))?; + + let media = Arc::new( + node.media + .clone() + .ok_or(anyhow!("item does not contain media"))?, + ); + + // TODO its unclear how requests with multiple tracks should be handled. + // if spec.track.len() == 1 { + // let ti = spec.track[0]; + // if let TrackSource::Remote(remote_index) = media.tracks[ti].source { + // session + // .user + // .permissions + // .assert(&UserPermission::FederatedContent)?; + + // let track = &node.media.as_ref().ok_or(anyhow!("no media"))?.tracks[ti]; + // let host = track + // .federated + // .last() + // .ok_or(anyhow!("federation inconsistent"))?; + + // let FederationAccount { + // password, username, .. + // } = SECRETS + // .federation + // .get(host) + // .ok_or(anyhow!("no credentials on the server-side"))?; + + // info!("creating session on {host}"); + // let instance = federation.get_instance(host)?.to_owned(); + // let session = instance + // .login(CreateSessionParams { + // username: username.to_owned(), + // password: password.to_owned(), + // expire: Some(60), + // drop_permissions: Some(HashSet::from_iter([ + // UserPermission::ManageSelf, + // UserPermission::Admin, // in case somebody federated the admin :))) + // ])), + // }) + // .await?; + + // let uri = session.stream_url( + // node.slug.clone().into(), + // &StreamSpec { + // track: vec![remote_index], + // ..spec + // }, + // ); + // info!("federation redirect"); + // return Ok(Either::Right(RedirectResponse(uri))); + // } + // } + + info!( + "stream request (range={})", + range + .as_ref() + .map(|r| r.to_cr_hv()) + .unwrap_or("none".to_string()) + ); + + let urange = match &range { + Some(r) => { + let r = r.0.first().unwrap_or(&(None..None)); + r.start.unwrap_or(0)..r.end.unwrap_or(isize::MAX as usize) + } + None => 0..(isize::MAX as usize), + }; + + let head = jellystream::stream_head(&spec); + + let mut sources = BTreeSet::new(); + for t in &media.tracks { + if let TrackSource::Local(x) = &t.source { + if let AssetInner::LocalTrack(m) = AssetInner::deser(&x.0)? { + sources.insert(m.path); + } + } + } + let media = Arc::new(SMediaInfo { + files: sources, + info: node, + }); + + match jellystream::stream(media, spec, urange).await { + Ok(stream) => Ok(Either::Left(StreamResponse { + stream, + range, + advertise_range: head.range_supported, + content_type: head.content_type, + })), + Err(e) => { + warn!("stream error: {e}"); + Err(MyError(e)) + } + } +} + +pub struct RedirectResponse(String); + +#[rocket::async_trait] +impl<'r> Responder<'r, 'static> for RedirectResponse { + fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { + let mut b = Response::build(); + b.status(Status::Found); + b.header(Header::new("access-control-allow-origin", "*")); + b.header(Header::new("location", self.0)); + Ok(b.finalize()) + } +} + +pub struct StreamResponse { + stream: DuplexStream, + advertise_range: bool, + content_type: &'static str, + range: Option, +} + +#[rocket::async_trait] +impl<'r> Responder<'r, 'static> for StreamResponse { + fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { + let mut b = Response::build(); + b.status(Status::Ok); + b.header(Header::new("access-control-allow-origin", "*")); + if self.advertise_range { + //* it is very important here to not reply with content range if we didnt advertise. + //* mpv requests range but will crash if we dont pretend to not support it. + if let Some(range) = self.range { + b.status(Status::PartialContent); + b.header(Header::new("content-range", range.to_cr_hv())); + } + b.header(Header::new("accept-ranges", "bytes")); + } + b.header(Header::new("content-type", self.content_type)) + .streamed_body(self.stream) + .ok() + } +} + +#[derive(Debug)] +pub struct RequestRange(Vec>>); + +impl RequestRange { + pub fn to_cr_hv(&self) -> String { + assert_eq!(self.0.len(), 1); + format!( + "bytes {}-{}/*", + self.0[0].start.map(|e| e.to_string()).unwrap_or_default(), + self.0[0].end.map(|e| e.to_string()).unwrap_or_default() + ) + } + pub fn from_hv(s: &str) -> Result { + Ok(Self( + s.strip_prefix("bytes=") + .ok_or(anyhow!("prefix expected"))? + .split(',') + .map(|s| { + let (l, r) = s + .split_once('-') + .ok_or(anyhow!("range delimeter missing"))?; + let km = |s: &str| { + if s.is_empty() { + Ok::<_, anyhow::Error>(None) + } else { + Ok(Some(s.parse()?)) + } + }; + Ok(km(l)?..km(r)?) + }) + .collect::>>()?, + )) + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for RequestRange { + type Error = anyhow::Error; + + async fn from_request(req: &'r Request<'_>) -> request::Outcome { + match req.headers().get("range").next() { + Some(v) => match Self::from_hv(v) { + Ok(v) => rocket::outcome::Outcome::Success(v), + Err(e) => rocket::outcome::Outcome::Error((Status::BadRequest, e)), + }, + None => rocket::outcome::Outcome::Forward(Status::Ok), + } + } +} diff --git a/server/src/logic/userdata.rs b/server/src/logic/userdata.rs new file mode 100644 index 0000000..64a136f --- /dev/null +++ b/server/src/logic/userdata.rs @@ -0,0 +1,96 @@ +/* + 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 +*/ +use crate::{ui::error::MyResult, ui::node::rocket_uri_macro_r_library_node}; +use jellybase::database::Database; +use jellycommon::{ + user::{NodeUserData, WatchedState}, + NodeID, +}; +use rocket::{ + form::Form, get, post, response::Redirect, serde::json::Json, FromForm, FromFormField, State, + UriDisplayQuery, +}; + +use super::session::Session; + +#[derive(Debug, FromFormField, UriDisplayQuery)] +pub enum UrlWatchedState { + None, + Watched, + Pending, +} + +#[get("/n//userdata")] +pub fn r_node_userdata( + session: Session, + db: &State, + id: NodeID, +) -> MyResult> { + let u = db + .get_node_udata(id, &session.user.name)? + .unwrap_or_default(); + Ok(Json(u)) +} + +#[post("/n//watched?")] +pub async fn r_node_userdata_watched( + session: Session, + db: &State, + id: NodeID, + state: UrlWatchedState, +) -> MyResult { + // TODO perm + db.update_node_udata(id, &session.user.name, |udata| { + udata.watched = match state { + UrlWatchedState::None => WatchedState::None, + UrlWatchedState::Watched => WatchedState::Watched, + UrlWatchedState::Pending => WatchedState::Pending, + }; + Ok(()) + })?; + Ok(Redirect::found(rocket::uri!(r_library_node(id)))) +} + +#[derive(FromForm)] +pub struct UpdateRating { + #[field(validate = range(-10..=10))] + rating: i32, +} + +#[post("/n//update_rating", data = "
")] +pub async fn r_node_userdata_rating( + session: Session, + db: &State, + id: NodeID, + form: Form, +) -> MyResult { + // TODO perm + db.update_node_udata(id, &session.user.name, |udata| { + udata.rating = form.rating; + Ok(()) + })?; + Ok(Redirect::found(rocket::uri!(r_library_node(id)))) +} + +#[post("/n//progress?")] +pub async fn r_node_userdata_progress( + session: Session, + db: &State, + id: NodeID, + t: f64, +) -> MyResult<()> { + // TODO perm + db.update_node_udata(id, &session.user.name, |udata| { + udata.watched = match udata.watched { + WatchedState::None | WatchedState::Pending | WatchedState::Progress(_) => { + WatchedState::Progress(t) + } + WatchedState::Watched => WatchedState::Watched, + }; + Ok(()) + })?; + Ok(()) +} diff --git a/server/src/main.rs b/server/src/main.rs index ced2f02..b583823 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -7,16 +7,22 @@ #![allow(clippy::needless_borrows_for_generic_args)] #![recursion_limit = "4096"] -use crate::routes::ui::{account::hash_password, admin::log::enable_logging}; use anyhow::Context; use database::Database; use jellybase::{federation::Federation, CONF, SECRETS}; use log::{error, info, warn}; use routes::build_rocket; use tokio::fs::create_dir_all; +use ui::{account::hash_password, admin::log::enable_logging}; pub use jellybase::database; +pub mod api; +pub mod compat; +pub mod helper; +pub mod locale; +pub mod logic; pub mod routes; +pub mod ui; #[rocket::main] async fn main() { diff --git a/server/src/routes.rs b/server/src/routes.rs new file mode 100644 index 0000000..4e452c3 --- /dev/null +++ b/server/src/routes.rs @@ -0,0 +1,212 @@ +/* + 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 +*/ +use crate::database::Database; +use crate::logic::playersync::{r_playersync, PlayersyncChannels}; +use crate::ui::{ + account::{ + r_account_login, r_account_login_post, r_account_logout, r_account_logout_post, + r_account_register, r_account_register_post, + settings::{r_account_settings, r_account_settings_post}, + }, + admin::{ + log::{r_admin_log, r_admin_log_stream}, + r_admin_dashboard, r_admin_delete_cache, r_admin_import, r_admin_invite, + r_admin_remove_invite, r_admin_transcode_posters, r_admin_update_search, + user::{r_admin_remove_user, r_admin_user, r_admin_user_permission, r_admin_users}, + }, + assets::{r_asset, r_item_backdrop, r_item_poster, r_node_thumbnail, r_person_asset}, + browser::r_all_items_filter, + error::{r_api_catch, r_catch}, + home::r_home, + node::r_library_node_filter, + player::r_player, + r_favicon, r_index, + search::r_search, + stats::r_stats, + style::{r_assets_font, r_assets_js, r_assets_js_map, r_assets_style}, +}; +use crate::{ + api::{ + r_api_account_login, r_api_asset_token_raw, r_api_nodes_modified_since, r_api_root, + r_api_version, + }, + compat::{ + jellyfin::{ + r_jellyfin_artists, r_jellyfin_branding_configuration, r_jellyfin_branding_css, + r_jellyfin_displaypreferences_usersettings, + r_jellyfin_displaypreferences_usersettings_post, r_jellyfin_items, + r_jellyfin_items_image_primary, r_jellyfin_items_images_backdrop, + r_jellyfin_items_intros, r_jellyfin_items_item, r_jellyfin_items_playbackinfo, + r_jellyfin_items_similar, r_jellyfin_livetv_programs_recommended, r_jellyfin_persons, + r_jellyfin_playback_bitratetest, r_jellyfin_quickconnect_enabled, + r_jellyfin_sessions_capabilities_full, r_jellyfin_sessions_playing, + r_jellyfin_sessions_playing_progress, r_jellyfin_shows_nextup, r_jellyfin_socket, + r_jellyfin_system_endpoint, r_jellyfin_system_info, r_jellyfin_system_info_public, + r_jellyfin_system_info_public_case, r_jellyfin_users_authenticatebyname, + r_jellyfin_users_authenticatebyname_case, r_jellyfin_users_id, r_jellyfin_users_items, + r_jellyfin_users_items_item, r_jellyfin_users_public, r_jellyfin_users_views, + r_jellyfin_video_stream, + }, + youtube::{r_youtube_channel, r_youtube_embed, r_youtube_watch}, + }, + logic::{ + stream::r_stream, + userdata::{ + r_node_userdata, r_node_userdata_progress, r_node_userdata_rating, + r_node_userdata_watched, + }, + }, +}; +use base64::Engine; +use jellybase::{federation::Federation, CONF, SECRETS}; +use log::warn; +use rand::random; +use rocket::{ + catchers, config::SecretKey, fairing::AdHoc, fs::FileServer, http::Header, routes, + shield::Shield, Build, Config, Rocket, +}; + +#[macro_export] +macro_rules! uri { + ($kk:stmt) => { + &rocket::uri!($kk).to_string() + }; +} + +pub fn build_rocket(database: Database, federation: Federation) -> Rocket { + rocket::build() + .configure(Config { + address: std::env::var("BIND_ADDR") + .map(|e| e.parse().unwrap()) + .unwrap_or("127.0.0.1".parse().unwrap()), + port: std::env::var("PORT") + .map(|e| e.parse().unwrap()) + .unwrap_or(8000), + secret_key: SecretKey::derive_from( + SECRETS + .cookie_key + .clone() + .unwrap_or_else(|| { + warn!("cookie_key not configured, generating a random one."); + base64::engine::general_purpose::STANDARD.encode([(); 32].map(|_| random())) + }) + .as_bytes(), + ), + ip_header: Some("x-forwarded-for".into()), + ..Default::default() + }) + .manage(database) + .manage(federation) + .manage(PlayersyncChannels::default()) + .attach(AdHoc::on_response("set server header", |_req, res| { + res.set_header(Header::new("server", "jellything")); + Box::pin(async {}) + })) + // TODO this would be useful but needs to handle not only the entry-point + // .attach(AdHoc::on_response("frame options", |req, resp| { + // if !req.uri().path().as_str().starts_with("/embed") { + // resp.set_raw_header("X-Frame-Options", "SAMEORIGIN"); + // } + // Box::pin(async {}) + // })) + .attach(Shield::new()) + .register("/", catchers![r_catch]) + .register("/api", catchers![r_api_catch]) + .mount("/assets", FileServer::from(&CONF.asset_path)) + .mount( + "/", + routes![ + // Frontend + r_account_login_post, + r_account_login, + r_account_logout_post, + r_account_logout, + r_account_register_post, + r_account_register, + r_account_settings_post, + r_account_settings, + r_admin_dashboard, + r_admin_delete_cache, + r_admin_import, + r_admin_invite, + r_admin_log_stream, + r_admin_log, + r_admin_remove_invite, + r_admin_remove_user, + r_admin_transcode_posters, + r_admin_update_search, + r_admin_user_permission, + r_admin_user, + r_admin_users, + r_all_items_filter, + r_asset, + r_assets_font, + r_assets_js_map, + r_assets_js, + r_assets_style, + r_favicon, + r_home, + r_index, + r_item_backdrop, + r_item_poster, + r_library_node_filter, + r_node_thumbnail, + r_node_userdata_progress, + r_node_userdata_rating, + r_node_userdata_watched, + r_node_userdata, + r_person_asset, + r_player, + r_playersync, + r_search, + r_stats, + r_stream, + // API + r_api_account_login, + r_api_asset_token_raw, + r_api_nodes_modified_since, + r_api_root, + r_api_version, + // Compat + r_jellyfin_artists, + r_jellyfin_branding_configuration, + r_jellyfin_branding_css, + r_jellyfin_displaypreferences_usersettings_post, + r_jellyfin_displaypreferences_usersettings, + r_jellyfin_items_image_primary, + r_jellyfin_items_images_backdrop, + r_jellyfin_items_intros, + r_jellyfin_items_item, + r_jellyfin_items_playbackinfo, + r_jellyfin_items_similar, + r_jellyfin_items, + r_jellyfin_livetv_programs_recommended, + r_jellyfin_persons, + r_jellyfin_playback_bitratetest, + r_jellyfin_quickconnect_enabled, + r_jellyfin_sessions_capabilities_full, + r_jellyfin_sessions_playing_progress, + r_jellyfin_sessions_playing, + r_jellyfin_shows_nextup, + r_jellyfin_socket, + r_jellyfin_system_endpoint, + r_jellyfin_system_info_public_case, + r_jellyfin_system_info_public, + r_jellyfin_system_info, + r_jellyfin_users_authenticatebyname, + r_jellyfin_users_authenticatebyname_case, + r_jellyfin_users_id, + r_jellyfin_users_items_item, + r_jellyfin_users_items, + r_jellyfin_users_public, + r_jellyfin_users_views, + r_jellyfin_video_stream, + r_youtube_channel, + r_youtube_embed, + r_youtube_watch, + ], + ) +} diff --git a/server/src/routes/api.rs b/server/src/routes/api.rs deleted file mode 100644 index 13708ce..0000000 --- a/server/src/routes/api.rs +++ /dev/null @@ -1,108 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::ui::{ - account::{ - login_logic, - session::{AdminSession, Session}, - }, - error::MyResult, -}; -use crate::database::Database; -use jellybase::assetfed::AssetInner; -use jellycommon::{user::CreateSessionParams, NodeID, Visibility}; -use rocket::{ - get, - http::MediaType, - outcome::Outcome, - post, - request::{self, FromRequest}, - response::Redirect, - serde::json::Json, - Request, State, -}; -use serde_json::{json, Value}; -use std::ops::Deref; - -#[get("/api")] -pub fn r_api_root() -> Redirect { - Redirect::moved("https://jellything.metamuffin.org/book/api.html#jellything-http-api") -} - -#[get("/api/version")] -pub fn r_api_version() -> &'static str { - env!("CARGO_PKG_VERSION") -} - -#[post("/api/create_session", data = "")] -pub fn r_api_account_login( - database: &State, - data: Json, -) -> MyResult { - let token = login_logic( - database, - &data.username, - &data.password, - data.expire, - data.drop_permissions.clone(), - )?; - Ok(json!(token)) -} - -#[get("/api/asset_token_raw/")] -pub fn r_api_asset_token_raw(_admin: AdminSession, token: &str) -> MyResult> { - Ok(Json(AssetInner::deser(token)?)) -} - -#[get("/api/nodes_modified?")] -pub fn r_api_nodes_modified_since( - _session: Session, - database: &State, - since: u64, -) -> MyResult>> { - let mut nodes = database.get_nodes_modified_since(since)?; - nodes.retain(|id| { - database.get_node(*id).is_ok_and(|n| { - n.as_ref() - .is_some_and(|n| n.visibility >= Visibility::Reduced) - }) - }); - Ok(Json(nodes)) -} - -pub struct AcceptJson(bool); -impl Deref for AcceptJson { - type Target = bool; - fn deref(&self) -> &Self::Target { - &self.0 - } -} -impl<'r> FromRequest<'r> for AcceptJson { - type Error = (); - - fn from_request<'life0, 'async_trait>( - request: &'r Request<'life0>, - ) -> ::core::pin::Pin< - Box< - dyn ::core::future::Future> - + ::core::marker::Send - + 'async_trait, - >, - > - where - 'r: 'async_trait, - 'life0: 'async_trait, - Self: 'async_trait, - { - Box::pin(async move { - Outcome::Success(AcceptJson( - request - .accept() - .map(|a| a.preferred().exact_eq(&MediaType::JSON)) - .unwrap_or(false), - )) - }) - } -} diff --git a/server/src/routes/compat/jellyfin/mod.rs b/server/src/routes/compat/jellyfin/mod.rs deleted file mode 100644 index e37d7d1..0000000 --- a/server/src/routes/compat/jellyfin/mod.rs +++ /dev/null @@ -1,877 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -pub mod models; - -use crate::routes::ui::{ - account::{login_logic, session::Session}, - assets::{ - rocket_uri_macro_r_asset, rocket_uri_macro_r_item_backdrop, rocket_uri_macro_r_item_poster, - }, - error::MyResult, - node::{aspect_class, DatabaseNodeUserDataExt}, - sort::{filter_and_sort_nodes, FilterProperty, NodeFilterSort, SortOrder, SortProperty}, -}; -use anyhow::{anyhow, Context}; -use jellybase::{database::Database, CONF}; -use jellycommon::{ - stream::{StreamContainer, StreamSpec}, - user::{NodeUserData, WatchedState}, - MediaInfo, Node, NodeID, NodeKind, SourceTrack, SourceTrackKind, Visibility, -}; -use models::*; -use rocket::{ - get, - http::{Cookie, CookieJar}, - post, - response::Redirect, - serde::json::Json, - FromForm, State, -}; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::{collections::BTreeMap, net::IpAddr}; - -const SERVER_ID: &str = "1694a95daf70708147f16103ce7b7566"; -const USER_ID: &str = "33f772aae6c2495ca89fe00340dbd17c"; -const VERSION: &str = "10.10.0"; -const LOCAL_ADDRESS: &str = "http://127.0.0.1:8000"; - -#[get("/System/Info/Public")] -pub fn r_jellyfin_system_info_public_case() -> Json { - r_jellyfin_system_info_public() -} - -#[get("/system/info/public")] -pub fn r_jellyfin_system_info_public() -> Json { - Json(json!({ - "LocalAddress": LOCAL_ADDRESS, - "ServerName": CONF.brand.clone(), - "Version": VERSION, - "ProductName": "Jellything", - "OperatingSystem": "", - "Id": SERVER_ID, - "StartupWizardCompleted": true, - })) -} - -#[get("/Branding/Configuration")] -pub fn r_jellyfin_branding_configuration() -> Json { - Json(json!({ - "LoginDisclaimer": format!("{} - {}", CONF.brand, CONF.slogan), - "CustomCss": "", - "SplashscreenEnabled": false, - })) -} - -#[get("/users/public")] -pub fn r_jellyfin_users_public() -> Json { - Json(json!([])) -} - -#[get("/Branding/Css")] -pub fn r_jellyfin_branding_css() -> String { - "".to_string() -} - -#[get("/QuickConnect/Enabled")] -pub fn r_jellyfin_quickconnect_enabled() -> Json { - Json(json!(false)) -} - -#[get("/System/Endpoint")] -pub fn r_jellyfin_system_endpoint(_session: Session) -> Json { - Json(json!({ - "IsLocal": false, - "IsInNetwork": false, - })) -} - -use rocket_ws::{Message, Stream, WebSocket}; -#[get("/socket")] -pub fn r_jellyfin_socket(_session: Session, ws: WebSocket) -> Stream!['static] { - Stream! { ws => - for await message in ws { - eprintln!("{message:?}"); - } - yield Message::Text("test".to_string()) - } -} - -#[get("/System/Info")] -pub fn r_jellyfin_system_info(_session: Session) -> Json { - Json(json!({ - "OperatingSystemDisplayName": "", - "HasPendingRestart": false, - "IsShuttingDown": false, - "SupportsLibraryMonitor": true, - "WebSocketPortNumber": 8096, - "CompletedInstallations": [], - "CanSelfRestart": true, - "CanLaunchWebBrowser": false, - "ProgramDataPath": "/path/to/data", - "WebPath": "/path/to/web", - "ItemsByNamePath": "/path/to/items", - "CachePath": "/path/to/cache", - "LogPath": "/path/to/log", - "InternalMetadataPath": "/path/to/metadata", - "TranscodingTempPath": "/path/to/transcodes", - "CastReceiverApplications": [], - "HasUpdateAvailable": false, - "EncoderLocation": "System", - "SystemArchitecture": "X64", - "LocalAddress": LOCAL_ADDRESS, - "ServerName": CONF.brand, - "Version": VERSION, - "OperatingSystem": "", - "Id": SERVER_ID - })) -} - -#[get("/DisplayPreferences/usersettings")] -pub fn r_jellyfin_displaypreferences_usersettings(_session: Session) -> Json { - Json(json!({ - "Id": "3ce5b65d-e116-d731-65d1-efc4a30ec35c", - "SortBy": "SortName", - "RememberIndexing": false, - "PrimaryImageHeight": 250, - "PrimaryImageWidth": 250, - "CustomPrefs": false, - "ScrollDirection": "Horizontal", - "ShowBackdrop": true, - "RememberSorting": false, - "SortOrder": "Ascending", - "ShowSidebar": false, - "Client": "emby", - })) -} - -#[post("/DisplayPreferences/usersettings")] -pub fn r_jellyfin_displaypreferences_usersettings_post(_session: Session) {} - -#[get("/Users/")] -pub fn r_jellyfin_users_id(session: Session, id: &str) -> Json { - let _ = id; - Json(user_object(session.user.name)) -} - -#[get("/Items//Images/Primary?&")] -#[allow(non_snake_case)] -pub fn r_jellyfin_items_image_primary( - _session: Session, - id: &str, - fillWidth: Option, - tag: String, -) -> Redirect { - if tag == "poster" { - Redirect::permanent(rocket::uri!(r_item_poster(id, fillWidth))) - } else { - Redirect::permanent(rocket::uri!(r_asset(tag, fillWidth))) - } -} - -#[get("/Items//Images/Backdrop/0?")] -#[allow(non_snake_case)] -pub fn r_jellyfin_items_images_backdrop( - _session: Session, - id: &str, - maxWidth: Option, -) -> Redirect { - Redirect::permanent(rocket::uri!(r_item_backdrop(id, maxWidth))) -} - -#[get("/Items/")] -#[allow(private_interfaces)] -pub fn r_jellyfin_items_item( - session: Session, - database: &State, - id: &str, -) -> MyResult> { - let (n, ud) = database.get_node_with_userdata(NodeID::from_slug(id), &session)?; - Ok(Json(item_object(&n, &ud))) -} -#[get("/Users//Items/")] -#[allow(private_interfaces)] -pub fn r_jellyfin_users_items_item( - session: Session, - database: &State, - uid: &str, - id: &str, -) -> MyResult> { - let _ = uid; - r_jellyfin_items_item(session, database, id) -} - -#[derive(Debug, FromForm)] -struct JellyfinItemQuery { - #[field(name = uncased("searchterm"))] - search_term: Option, - #[field(name = uncased("limit"))] - limit: usize, - #[field(name = uncased("parentid"))] - parent_id: Option, - #[field(name = uncased("startindex"))] - start_index: Option, - #[field(name = uncased("includeitemtypes"))] - include_item_types: Option, - - internal_artists: bool, - internal_persons: bool, -} - -#[get("/Users//Items?")] -#[allow(private_interfaces)] -pub fn r_jellyfin_users_items( - session: Session, - database: &State, - uid: &str, - query: JellyfinItemQuery, -) -> MyResult> { - let _ = uid; - r_jellyfin_items(session, database, query) -} - -#[get("/Artists?")] -#[allow(private_interfaces)] -pub fn r_jellyfin_artists( - session: Session, - database: &State, - mut query: JellyfinItemQuery, -) -> MyResult> { - query.internal_artists = true; - r_jellyfin_items(session, database, query)?; // TODO - Ok(Json(json!({ - "Items": [], - "TotalRecordCount": 0, - "StartIndex": 0 - }))) -} - -#[get("/Persons?")] -#[allow(private_interfaces)] -pub fn r_jellyfin_persons( - session: Session, - database: &State, - mut query: JellyfinItemQuery, -) -> MyResult> { - query.internal_persons = true; - r_jellyfin_items(session, database, query)?; // TODO - Ok(Json(json!({ - "Items": [], - "TotalRecordCount": 0, - "StartIndex": 0 - }))) -} - -#[get("/Items?")] -#[allow(private_interfaces)] -pub fn r_jellyfin_items( - session: Session, - database: &State, - query: JellyfinItemQuery, -) -> MyResult> { - let (nodes, parent_kind) = if let Some(q) = query.search_term { - ( - database - .search(&q, query.limit, query.start_index.unwrap_or_default())? - .1, - None, - ) - } else if let Some(parent) = query.parent_id { - let parent = NodeID::from_slug(&parent); - ( - database - .get_node_children(parent)? - .into_iter() - .skip(query.start_index.unwrap_or_default()) - .take(query.limit) - .collect(), - database.get_node(parent)?.map(|n| n.kind), - ) - } else { - (vec![], None) - }; - - let filter_kind = query - .include_item_types - .map(|n| match n.as_str() { - "Movie" => vec![FilterProperty::KindMovie], - "Audio" => vec![FilterProperty::KindMusic], - "Video" => vec![FilterProperty::KindVideo], - "TvChannel" => vec![FilterProperty::KindChannel], - _ => vec![], - }) - .or(if query.internal_artists { - Some(vec![]) - } else { - None - }) - .or(if query.internal_persons { - Some(vec![]) - } else { - None - }); - - let mut nodes = nodes - .into_iter() - .map(|nid| database.get_node_with_userdata(nid, &session)) - .collect::, anyhow::Error>>()?; - - filter_and_sort_nodes( - &NodeFilterSort { - sort_by: None, - filter_kind, - sort_order: None, - }, - match parent_kind { - Some(NodeKind::Channel) => (SortProperty::ReleaseDate, SortOrder::Descending), - _ => (SortProperty::Title, SortOrder::Ascending), - }, - &mut nodes, - ); - - let items = nodes - .into_iter() - .filter(|(n, _)| n.visibility >= Visibility::Reduced) - .map(|(n, ud)| item_object(&n, &ud)) - .collect::>(); - - Ok(Json(json!({ - "Items": items, - "TotalRecordCount": items.len(), - "StartIndex": query.start_index.unwrap_or_default() - }))) -} - -#[get("/UserViews?")] -#[allow(non_snake_case)] -pub fn r_jellyfin_users_views( - session: Session, - database: &State, - userId: &str, -) -> MyResult> { - let _ = userId; - - let mut toplevel = database - .get_node_children(NodeID::from_slug("library")) - .context("root node missing")? - .into_iter() - .map(|nid| database.get_node_with_userdata(nid, &session)) - .collect::, anyhow::Error>>()?; - - toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX)); - - let mut items = Vec::new(); - for (n, ud) in toplevel { - if n.visibility >= Visibility::Reduced { - items.push(item_object(&n, &ud)) - } - } - - Ok(Json(json!({ - "Items": items, - "TotalRecordCount": items.len(), - "StartIndex": 0 - }))) -} - -#[get("/Items//Similar")] -pub fn r_jellyfin_items_similar(_session: Session, id: &str) -> Json { - let _ = id; - Json(json!({ - "Items": [], - "TotalRecordCount": 0, - "StartIndex": 0 - })) -} - -#[get("/LiveTv/Programs/Recommended")] -pub fn r_jellyfin_livetv_programs_recommended(_session: Session) -> Json { - Json(json!({ - "Items": [], - "TotalRecordCount": 0, - "StartIndex": 0 - })) -} - -#[get("/Users//Items//Intros")] -pub fn r_jellyfin_items_intros(_session: Session, uid: &str, id: &str) -> Json { - let _ = (uid, id); - Json(json!({ - "Items": [], - "TotalRecordCount": 0, - "StartIndex": 0 - })) -} - -#[get("/Shows/NextUp")] -pub fn r_jellyfin_shows_nextup(_session: Session) -> Json { - Json(json!({ - "Items": [], - "TotalRecordCount": 0, - "StartIndex": 0 - })) -} - -#[post("/Items//PlaybackInfo")] -pub fn r_jellyfin_items_playbackinfo( - _session: Session, - database: &State, - id: &str, -) -> MyResult> { - let node = database - .get_node_slug(id)? - .ok_or(anyhow!("node does not exist"))?; - let media = node.media.as_ref().ok_or(anyhow!("node has no media"))?; - let ms = media_source_object(&node, media); - Ok(Json(json!({ - "MediaSources": [ms], - "PlaySessionId": "why do we need this id?" - }))) -} - -#[get("/Videos//stream.webm")] -pub fn r_jellyfin_video_stream( - _session: Session, - database: &State, - id: &str, -) -> MyResult { - let node = database - .get_node_slug(id)? - .ok_or(anyhow!("node does not exist"))?; - let media = node.media.as_ref().ok_or(anyhow!("node has no media"))?; - let params = StreamSpec::Remux { - tracks: (0..media.tracks.len()).collect(), - container: StreamContainer::WebM, - } - .to_query(); - Ok(Redirect::temporary(format!("/n/{id}/stream{params}"))) -} - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct JellyfinProgressData { - item_id: String, - position_ticks: f64, -} -#[post("/Sessions/Playing/Progress", data = "")] -#[allow(private_interfaces)] -pub fn r_jellyfin_sessions_playing_progress( - session: Session, - database: &State, - data: Json, -) -> MyResult<()> { - let position = data.position_ticks / 10_000_000.; - database.update_node_udata( - NodeID::from_slug(&data.item_id), - &session.user.name, - |udata| { - udata.watched = match udata.watched { - WatchedState::None | WatchedState::Pending | WatchedState::Progress(_) => { - WatchedState::Progress(position) - } - WatchedState::Watched => WatchedState::Watched, - }; - Ok(()) - }, - )?; - Ok(()) -} - -#[post("/Sessions/Playing")] -pub fn r_jellyfin_sessions_playing(_session: Session) {} - -#[get("/Playback/BitrateTest?")] -#[allow(non_snake_case)] -pub fn r_jellyfin_playback_bitratetest(_session: Session, Size: usize) -> Vec { - vec![0; Size.min(1_000_000)] -} - -#[post("/Sessions/Capabilities/Full")] -pub fn r_jellyfin_sessions_capabilities_full(_session: Session) {} - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct AuthData { - pw: String, - username: String, -} - -#[post("/Users/AuthenticateByName", data = "")] -#[allow(private_interfaces)] -pub fn r_jellyfin_users_authenticatebyname_case( - client_addr: IpAddr, - database: &State, - data: Json, - jar: &CookieJar, -) -> MyResult> { - r_jellyfin_users_authenticatebyname(client_addr, database, data, jar) -} - -#[post("/Users/authenticatebyname", data = "")] -#[allow(private_interfaces)] -pub fn r_jellyfin_users_authenticatebyname( - client_addr: IpAddr, - database: &State, - data: Json, - jar: &CookieJar, -) -> MyResult> { - let token = login_logic(database, &data.username, &data.pw, None, None)?; - - // setting the session cookie too because image requests carry no auth headers for some reason. - // TODO find alternative, non-web clients might not understand cookies - jar.add( - Cookie::build(("session", token.clone())) - .permanent() - .build(), - ); - - Ok(Json(json!({ - "User": user_object(data.username.clone()), - "SessionInfo": { - "PlayState": { - "CanSeek": false, - "IsPaused": false, - "IsMuted": false, - "RepeatMode": "RepeatNone", - "PlaybackOrder": "Default" - }, - "AdditionalUsers": [], - "Capabilities": { - "PlayableMediaTypes": [ - "Audio", - "Video" - ], - "SupportedCommands": [], - // "MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "SetShuffleQueue", "ChannelUp", "ChannelDown", "PlayMediaSource", "PlayTrailers" - "SupportsMediaControl": true, - "SupportsPersistentIdentifier": false - }, - "RemoteEndPoint": client_addr, - "PlayableMediaTypes": [ - "Audio", - "Video" - ], - "Id": "6e05fbb4fe33477b991455c97a57e25d", - "UserId": USER_ID, - "UserName": data.username.clone(), - "Client": "Jellyfin Web", - "LastActivityDate": "0001-01-01T00:00:00.0000000Z", - "LastPlaybackCheckIn": "0001-01-01T00:00:00.0000000Z", - "DeviceName": "blub blub blub", - "DeviceId": "wagening", - "ApplicationVersion": VERSION, - "IsActive": true, - "SupportsMediaControl": false, - "SupportsRemoteControl": false, - "NowPlayingQueue": [], - "NowPlayingQueueFullItems": [], - "HasCustomDeviceName": false, - "ServerId": SERVER_ID, - "SupportedCommands": [] - // "MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "SetShuffleQueue", "ChannelUp", "ChannelDown", "PlayMediaSource", "PlayTrailers" - }, - "AccessToken": token, - "ServerId": SERVER_ID - }))) -} - -fn track_object(index: usize, track: &SourceTrack) -> JellyfinMediaStream { - let fr = if let SourceTrackKind::Video { fps, .. } = &track.kind { - Some(fps.unwrap_or_default()) - } else { - None - }; - JellyfinMediaStream { - codec: match track.codec.as_str() { - "V_HEVC" => "hevc", - "V_AV1" => "av1", - "V_VP8" => "vp8", - "V_VP9" => "vp9", - "A_AAC" => "aac", - "A_OPUS" => "opus", - _ => "unknown", - } - .to_string(), - time_base: "1/1000".to_string(), // TODO unsure what that means - video_range: if track.kind.letter() == 'v' { - "SDR" - } else { - "Unknown" - } - .to_string(), - video_range_type: if track.kind.letter() == 'v' { - "SDR" - } else { - "Unknown" - } - .to_string(), - audio_spatial_format: "None".to_string(), - display_title: track.to_string(), - is_interlaced: false, // TODO assuming that - is_avc: track.codec.as_str() == "V_AVC", - bit_rate: 5_000_000, // TODO totally - bit_depth: 8, - ref_frames: 1, - is_default: true, - is_forced: false, - is_hearing_impaired: false, - height: if let SourceTrackKind::Video { height, .. } = &track.kind { - Some(*height) - } else { - None - }, - width: if let SourceTrackKind::Video { width, .. } = &track.kind { - Some(*width) - } else { - None - }, - average_frame_rate: fr, - real_frame_rate: fr, - reference_frame_rate: fr, - profile: "Main".to_string(), - r#type: match track.kind { - SourceTrackKind::Audio { .. } => JellyfinMediaStreamType::Audio, - SourceTrackKind::Video { .. } => JellyfinMediaStreamType::Video, - SourceTrackKind::Subtitles => JellyfinMediaStreamType::Subtitle, - }, - aspect_ratio: "1:1".to_string(), // TODO aaa - index, - is_external: false, - is_text_subtitle_stream: false, - supports_external_stream: false, - pixel_format: "yuv420p".to_string(), - level: 150, // TODO what this mean? - is_anamorphic: false, - channel_layout: if let SourceTrackKind::Audio { .. } = &track.kind { - Some("5.1".to_string()) // TODO aaa - } else { - None - }, - channels: if let SourceTrackKind::Audio { channels, .. } = &track.kind { - Some(*channels) - } else { - None - }, - sample_rate: if let SourceTrackKind::Audio { sample_rate, .. } = &track.kind { - Some(*sample_rate) - } else { - None - }, - localized_default: "Default".to_string(), - localized_external: "External".to_string(), - } -} - -fn media_source_object(node: &Node, m: &MediaInfo) -> JellyfinMediaSource { - JellyfinMediaSource { - protocol: JellyfinMediaSourceProtocol::File, - id: node.slug.clone(), - path: format!("/path/to/{}.webm", node.slug), - r#type: JellyfinMediaSourceType::Default, - container: "webm".to_string(), - size: 1_000_000_000, - name: node.slug.clone(), - is_remote: false, - e_tag: "blub".to_string(), - run_time_ticks: m.duration * 10_000_000., - read_at_native_framerate: false, - ignore_dts: false, - ignore_index: false, - gen_pts_input: false, - supports_transcoding: true, - supports_direct_stream: true, - supports_direct_play: true, - is_infinite_stream: false, - use_most_compatible_transcoding_profile: false, - requires_opening: false, - requires_closing: false, - requires_looping: false, - supports_probing: true, - video_type: JellyfinVideoType::VideoFile, - media_streams: m - .tracks - .iter() - .enumerate() - .map(|(i, t)| track_object(i, t)) - .collect::>(), - media_attachments: Vec::new(), - formats: Vec::new(), - bitrate: 10_000_000, - required_http_headers: BTreeMap::new(), - transcoding_sub_protocol: "http".to_string(), - default_audio_stream_index: 1, // TODO - default_subtitle_stream_index: 2, // TODO - has_segments: false, - } -} - -fn item_object(node: &Node, userdata: &NodeUserData) -> JellyfinItem { - let media_source = node.media.as_ref().map(|m| media_source_object(node, m)); - - JellyfinItem { - name: node.title.clone().unwrap_or_default(), - server_id: SERVER_ID.to_owned(), - id: node.slug.clone(), - e_tag: "blob".to_owned(), - date_created: "0001-01-01T00:00:00.0000000Z".to_owned(), - can_delete: false, - can_download: true, - preferred_metadata_language: "".to_owned(), - preferred_metadata_country_code: "".to_owned(), - sort_name: node.slug.clone(), - forced_sort_name: "".to_owned(), - external_urls: vec![], - enable_media_source_display: true, - custom_rating: "".to_owned(), - channel_id: None, - overview: node.description.clone().unwrap_or_default(), - taglines: vec![node.tagline.clone().unwrap_or_default()], - genres: vec![], - remote_trailers: vec![], - provider_ids: BTreeMap::new(), - is_folder: node.media.is_none(), - parent_id: "todo-parent".to_owned(), // TODO - r#type: match node.kind { - NodeKind::Movie | NodeKind::Video | NodeKind::ShortFormVideo => JellyfinItemType::Movie, - NodeKind::Collection => JellyfinItemType::CollectionFolder, - _ => JellyfinItemType::CollectionFolder, - }, - people: node - .people - .iter() - .flat_map(|(_pg, ps)| { - ps.iter().map(|p| JellyfinPerson { - name: p.person.name.clone(), - id: p.person.ids.tmdb.unwrap_or_default().to_string(), - primary_image_tag: p.person.headshot.clone().map(|a| a.0).unwrap_or_default(), - role: p.characters.join(","), - r#type: JellyfinPersonType::Actor, - }) - }) - .collect(), - studios: vec![], - genre_items: vec![], - local_trailer_count: 0, - special_feature_count: 0, - child_count: 0, - locked_fields: vec![], - lock_data: false, - tags: vec![], - user_data: json!({ - "PlaybackPositionTicks": 0, - "PlayCount": if userdata.watched == WatchedState::Watched { 1 } else { 0 }, - "IsFavorite": userdata.rating > 0, - "Played": userdata.watched == WatchedState::Watched, - "Key": "7a2175bc-cb1f-1a94-152c-bd2b2bae8f6d", - "ItemId": "00000000000000000000000000000000" - }), - display_preferences_id: node.slug.clone(), - primary_image_aspect_ratio: match aspect_class(node.kind) { - "aspect-thumb" => 16. / 9., - "aspect-land" => 2f64.sqrt(), - "aspect-port" => 1. / 2f64.sqrt(), - "aspect-square" => 1., - _ => 1., - }, - collection_type: "unknown".to_owned(), - image_tags: BTreeMap::from_iter([("Primary".to_string(), "poster".to_string())]), - backdrop_image_tags: vec!["backdrop".to_string()], - media_type: if node.media.is_some() { - "Video".to_owned() - } else { - "Unknown".to_owned() - }, - video_type: node.media.as_ref().map(|_| "VideoFile".to_owned()), - location_type: node.media.as_ref().map(|_| "FileSystem".to_owned()), - play_access: node.media.as_ref().map(|_| "Full".to_owned()), - container: node.media.as_ref().map(|_| "webm".to_owned()), - run_time_ticks: node - .media - .as_ref() - .map(|m| (m.duration * 10_000_000.) as i64), - media_sources: media_source.as_ref().map(|s| vec![s.clone()]), - media_streams: media_source.as_ref().map(|s| s.media_streams.clone()), - path: node - .media - .as_ref() - .map(|_| format!("/path/to/{}.webm", node.slug)), - } -} - -fn user_object(username: String) -> Value { - json!({ - "Name": username, - "ServerId": SERVER_ID, - "Id": USER_ID, - "HasPassword": true, - "HasConfiguredPassword": true, - "HasConfiguredEasyPassword": false, - "EnableAutoLogin": false, - "LastLoginDate": "0001-01-01T00:00:00.0000000Z", - "LastActivityDate": "0001-01-01T00:00:00.0000000Z", - "Configuration": { - "PlayDefaultAudioTrack": true, - "SubtitleLanguagePreference": "", - "DisplayMissingEpisodes": false, - "GroupedFolders": [], - "SubtitleMode": "Default", - "DisplayCollectionsView": false, - "EnableLocalPassword": false, - "OrderedViews": [], - "LatestItemsExcludes": [], - "MyMediaExcludes": [], - "HidePlayedInLatest": true, - "RememberAudioSelections": true, - "RememberSubtitleSelections": true, - "EnableNextEpisodeAutoPlay": true, - "CastReceiverId": "F007D354" - }, - "Policy": { - "IsAdministrator": false, - "IsHidden": true, - "EnableCollectionManagement": false, - "EnableSubtitleManagement": false, - "EnableLyricManagement": false, - "IsDisabled": false, - "BlockedTags": [], - "AllowedTags": [], - "EnableUserPreferenceAccess": true, - "AccessSchedules": [], - "BlockUnratedItems": [], - "EnableRemoteControlOfOtherUsers": false, - "EnableSharedDeviceControl": false, - "EnableRemoteAccess": true, - "EnableLiveTvManagement": false, - "EnableLiveTvAccess": true, - "EnableMediaPlayback": true, - "EnableAudioPlaybackTranscoding": true, - "EnableVideoPlaybackTranscoding": true, - "EnablePlaybackRemuxing": true, - "ForceRemoteSourceTranscoding": false, - "EnableContentDeletion": false, - "EnableContentDeletionFromFolders": [], - "EnableContentDownloading": true, - "EnableSyncTranscoding": true, - "EnableMediaConversion": true, - "EnabledDevices": [], - "EnableAllDevices": true, - "EnabledChannels": [], - "EnableAllChannels": false, - "EnabledFolders": [], - "EnableAllFolders": true, - "InvalidLoginAttemptCount": 0, - "LoginAttemptsBeforeLockout": -1, - "MaxActiveSessions": 0, - "EnablePublicSharing": true, - "BlockedMediaFolders": [], - "BlockedChannels": [], - "RemoteClientBitrateLimit": 0, - "AuthenticationProviderId": "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", - "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", - "SyncPlayAccess": "CreateAndJoinGroups" - } - }) -} diff --git a/server/src/routes/compat/jellyfin/models.rs b/server/src/routes/compat/jellyfin/models.rs deleted file mode 100644 index be41835..0000000 --- a/server/src/routes/compat/jellyfin/models.rs +++ /dev/null @@ -1,199 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; - -#[derive(Debug, Serialize, Deserialize)] -pub(super) enum JellyfinItemType { - AudioBook, - Movie, - BoxSet, - Book, - Photo, - PhotoAlbum, - TvChannel, - LiveTvProgram, - Video, - Audio, - MusicAlbum, - CollectionFolder, -} - -#[derive(Debug, Clone, Serialize)] -pub(super) enum JellyfinMediaStreamType { - Video, - Audio, - Subtitle, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct JellyfinMediaStream { - pub codec: String, - pub time_base: String, - pub video_range: String, - pub video_range_type: String, - pub audio_spatial_format: String, - pub display_title: String, - pub is_interlaced: bool, - pub is_avc: bool, - pub bit_rate: usize, - pub bit_depth: usize, - pub ref_frames: usize, - pub is_default: bool, - pub is_forced: bool, - pub is_hearing_impaired: bool, - pub height: Option, - pub width: Option, - pub average_frame_rate: Option, - pub real_frame_rate: Option, - pub reference_frame_rate: Option, - pub profile: String, - pub r#type: JellyfinMediaStreamType, - pub aspect_ratio: String, - pub index: usize, - pub is_external: bool, - pub is_text_subtitle_stream: bool, - pub supports_external_stream: bool, - pub pixel_format: String, - pub level: usize, - pub is_anamorphic: bool, - pub channel_layout: Option, - pub channels: Option, - pub sample_rate: Option, - pub localized_default: String, - pub localized_external: String, -} - -#[derive(Debug, Clone, Serialize)] -pub(super) enum JellyfinMediaSourceProtocol { - File, -} -#[derive(Debug, Clone, Serialize)] -pub(super) enum JellyfinMediaSourceType { - Default, -} - -#[derive(Debug, Clone, Serialize)] -pub(super) enum JellyfinVideoType { - VideoFile, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct JellyfinMediaSource { - pub protocol: JellyfinMediaSourceProtocol, - pub id: String, - pub path: String, - pub r#type: JellyfinMediaSourceType, - pub container: String, - pub size: usize, - pub name: String, - pub is_remote: bool, - pub e_tag: String, - pub run_time_ticks: f64, - pub read_at_native_framerate: bool, - pub ignore_dts: bool, - pub ignore_index: bool, - pub gen_pts_input: bool, - pub supports_transcoding: bool, - pub supports_direct_stream: bool, - pub supports_direct_play: bool, - pub is_infinite_stream: bool, - pub use_most_compatible_transcoding_profile: bool, - pub requires_opening: bool, - pub requires_closing: bool, - pub requires_looping: bool, - pub supports_probing: bool, - pub video_type: JellyfinVideoType, - pub media_streams: Vec, - pub media_attachments: Vec<()>, - pub formats: Vec<()>, - pub bitrate: usize, - pub required_http_headers: BTreeMap<(), ()>, - pub transcoding_sub_protocol: String, - pub default_audio_stream_index: usize, - pub default_subtitle_stream_index: usize, - pub has_segments: bool, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct JellyfinItem { - pub name: String, - pub server_id: String, - pub id: String, - pub e_tag: String, - pub date_created: String, - pub can_delete: bool, - pub can_download: bool, - pub preferred_metadata_language: String, - pub preferred_metadata_country_code: String, - pub sort_name: String, - pub forced_sort_name: String, - pub external_urls: Vec<()>, - pub enable_media_source_display: bool, - pub custom_rating: String, - pub channel_id: Option, - pub overview: String, - pub taglines: Vec, - pub genres: Vec<()>, - pub play_access: Option, - pub remote_trailers: Vec<()>, - pub provider_ids: BTreeMap<(), ()>, - pub is_folder: bool, - pub parent_id: String, - pub r#type: JellyfinItemType, - pub people: Vec, - pub studios: Vec, - pub genre_items: Vec<()>, - pub local_trailer_count: usize, - pub special_feature_count: usize, - pub child_count: usize, - pub locked_fields: Vec<()>, - pub lock_data: bool, - pub tags: Vec, - pub user_data: Value, - pub display_preferences_id: String, - pub primary_image_aspect_ratio: f64, - pub collection_type: String, - pub image_tags: BTreeMap, - pub backdrop_image_tags: Vec, - pub location_type: Option, - pub media_type: String, - pub video_type: Option, - pub container: Option, - pub run_time_ticks: Option, - pub media_sources: Option>, - pub media_streams: Option>, - pub path: Option, -} - -#[derive(Debug, Serialize)] -pub(super) enum JellyfinPersonType { - Actor, - // Writer, - // Producer, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct JellyfinPerson { - pub name: String, - pub id: String, - pub role: String, - pub r#type: JellyfinPersonType, - pub primary_image_tag: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct JellyfinStudio { - pub name: String, - pub id: String, -} diff --git a/server/src/routes/compat/mod.rs b/server/src/routes/compat/mod.rs deleted file mode 100644 index a7b8c0d..0000000 --- a/server/src/routes/compat/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -pub mod jellyfin; -pub mod youtube; diff --git a/server/src/routes/compat/youtube.rs b/server/src/routes/compat/youtube.rs deleted file mode 100644 index 78eee8a..0000000 --- a/server/src/routes/compat/youtube.rs +++ /dev/null @@ -1,60 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use crate::routes::ui::{ - account::session::Session, - error::MyResult, - node::rocket_uri_macro_r_library_node, - player::{rocket_uri_macro_r_player, PlayerConfig}, -}; -use anyhow::anyhow; -use jellybase::database::Database; -use jellycommon::NodeID; -use rocket::{get, response::Redirect, State}; - -#[get("/watch?")] -pub fn r_youtube_watch(_session: Session, db: &State, v: &str) -> MyResult { - if v.len() != 11 { - Err(anyhow!("video id length incorrect"))? - } - let Some(id) = db.get_node_external_id("youtube:video", v)? else { - Err(anyhow!("element not found"))? - }; - let node = db.get_node(id)?.ok_or(anyhow!("node missing"))?; - Ok(Redirect::to(rocket::uri!(r_player( - &node.slug, - PlayerConfig::default() - )))) -} - -#[get("/channel/")] -pub fn r_youtube_channel(_session: Session, db: &State, id: &str) -> MyResult { - let Some(id) = (if id.starts_with("UC") { - db.get_node_external_id("youtube:channel", id)? - } else if id.starts_with("@") { - db.get_node_external_id("youtube:channel-name", id)? - } else { - Err(anyhow!("unknown channel id format"))? - }) else { - Err(anyhow!("channel not found"))? - }; - let node = db.get_node(id)?.ok_or(anyhow!("node missing"))?; - Ok(Redirect::to(rocket::uri!(r_library_node(&node.slug)))) -} - -#[get("/embed/")] -pub fn r_youtube_embed(_session: Session, db: &State, v: &str) -> MyResult { - if v.len() != 11 { - Err(anyhow!("video id length incorrect"))? - } - let Some(id) = db.get_node_external_id("youtube:video", v)? else { - Err(anyhow!("element not found"))? - }; - let node = db.get_node(id)?.ok_or(anyhow!("node missing"))?; - Ok(Redirect::to(rocket::uri!(r_player( - &node.slug, - PlayerConfig::default() - )))) -} diff --git a/server/src/routes/locale.rs b/server/src/routes/locale.rs deleted file mode 100644 index 6d16c17..0000000 --- a/server/src/routes/locale.rs +++ /dev/null @@ -1,55 +0,0 @@ -use jellybase::locale::Language; -use rocket::{ - outcome::Outcome, - request::{self, FromRequest}, - Request, -}; -use std::ops::Deref; - -pub struct AcceptLanguage(pub Language); -impl Deref for AcceptLanguage { - type Target = Language; - fn deref(&self) -> &Self::Target { - &self.0 - } -} -impl<'r> FromRequest<'r> for AcceptLanguage { - type Error = (); - - fn from_request<'life0, 'async_trait>( - request: &'r Request<'life0>, - ) -> ::core::pin::Pin< - Box< - dyn ::core::future::Future> - + ::core::marker::Send - + 'async_trait, - >, - > - where - 'r: 'async_trait, - 'life0: 'async_trait, - Self: 'async_trait, - { - Box::pin(async move { Outcome::Success(AcceptLanguage(lang_from_request(request))) }) - } -} - -pub(crate) fn lang_from_request(request: &Request) -> Language { - request - .headers() - .get_one("accept-language") - .and_then(|h| { - h.split(",") - .filter_map(|e| { - let code = e.split(";").next()?; - let code = code.split_once("-").unwrap_or((code, "")).0; - match code { - "en" => Some(Language::English), - "de" => Some(Language::German), - _ => None, - } - }) - .next() - }) - .unwrap_or(Language::English) -} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs deleted file mode 100644 index e7e0a60..0000000 --- a/server/src/routes/mod.rs +++ /dev/null @@ -1,238 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use self::playersync::{r_playersync, PlayersyncChannels}; -use crate::{database::Database, routes::ui::error::MyResult}; -use api::{ - r_api_account_login, r_api_asset_token_raw, r_api_nodes_modified_since, r_api_root, - r_api_version, -}; -use base64::Engine; -use compat::{ - jellyfin::{ - r_jellyfin_artists, r_jellyfin_branding_configuration, r_jellyfin_branding_css, - r_jellyfin_displaypreferences_usersettings, - r_jellyfin_displaypreferences_usersettings_post, r_jellyfin_items, - r_jellyfin_items_image_primary, r_jellyfin_items_images_backdrop, r_jellyfin_items_intros, - r_jellyfin_items_item, r_jellyfin_items_playbackinfo, r_jellyfin_items_similar, - r_jellyfin_livetv_programs_recommended, r_jellyfin_persons, - r_jellyfin_playback_bitratetest, r_jellyfin_quickconnect_enabled, - r_jellyfin_sessions_capabilities_full, r_jellyfin_sessions_playing, - r_jellyfin_sessions_playing_progress, r_jellyfin_shows_nextup, r_jellyfin_socket, - r_jellyfin_system_endpoint, r_jellyfin_system_info, r_jellyfin_system_info_public, - r_jellyfin_system_info_public_case, r_jellyfin_users_authenticatebyname, - r_jellyfin_users_authenticatebyname_case, r_jellyfin_users_id, r_jellyfin_users_items, - r_jellyfin_users_items_item, r_jellyfin_users_public, r_jellyfin_users_views, - r_jellyfin_video_stream, - }, - youtube::{r_youtube_channel, r_youtube_embed, r_youtube_watch}, -}; -use jellybase::{federation::Federation, CONF, SECRETS}; -use log::warn; -use rand::random; -use rocket::{ - catchers, - config::SecretKey, - fairing::AdHoc, - fs::FileServer, - get, - http::Header, - response::{self, Responder}, - routes, - shield::Shield, - Build, Config, Request, Rocket, -}; -use std::fs::File; -use stream::r_stream; -use ui::{ - account::{ - r_account_login, r_account_login_post, r_account_logout, r_account_logout_post, - r_account_register, r_account_register_post, - settings::{r_account_settings, r_account_settings_post}, - }, - admin::{ - log::{r_admin_log, r_admin_log_stream}, - r_admin_dashboard, r_admin_delete_cache, r_admin_import, r_admin_invite, - r_admin_remove_invite, r_admin_transcode_posters, r_admin_update_search, - user::{r_admin_remove_user, r_admin_user, r_admin_user_permission, r_admin_users}, - }, - assets::{r_asset, r_item_backdrop, r_item_poster, r_node_thumbnail, r_person_asset}, - browser::r_all_items_filter, - error::{r_api_catch, r_catch}, - home::r_home, - node::r_library_node_filter, - player::r_player, - r_index, - search::r_search, - stats::r_stats, - style::{r_assets_font, r_assets_js, r_assets_js_map, r_assets_style}, -}; -use userdata::{ - r_node_userdata, r_node_userdata_progress, r_node_userdata_rating, r_node_userdata_watched, -}; - -pub mod api; -pub mod compat; -pub mod locale; -pub mod playersync; -pub mod stream; -pub mod ui; -pub mod userdata; - -#[macro_export] -macro_rules! uri { - ($kk:stmt) => { - &rocket::uri!($kk).to_string() - }; -} - -pub fn build_rocket(database: Database, federation: Federation) -> Rocket { - rocket::build() - .configure(Config { - address: std::env::var("BIND_ADDR") - .map(|e| e.parse().unwrap()) - .unwrap_or("127.0.0.1".parse().unwrap()), - port: std::env::var("PORT") - .map(|e| e.parse().unwrap()) - .unwrap_or(8000), - secret_key: SecretKey::derive_from( - SECRETS - .cookie_key - .clone() - .unwrap_or_else(|| { - warn!("cookie_key not configured, generating a random one."); - base64::engine::general_purpose::STANDARD.encode([(); 32].map(|_| random())) - }) - .as_bytes(), - ), - ip_header: Some("x-forwarded-for".into()), - ..Default::default() - }) - .manage(database) - .manage(federation) - .manage(PlayersyncChannels::default()) - .attach(AdHoc::on_response("set server header", |_req, res| { - res.set_header(Header::new("server", "jellything")); - Box::pin(async {}) - })) - // TODO this would be useful but needs to handle not only the entry-point - // .attach(AdHoc::on_response("frame options", |req, resp| { - // if !req.uri().path().as_str().starts_with("/embed") { - // resp.set_raw_header("X-Frame-Options", "SAMEORIGIN"); - // } - // Box::pin(async {}) - // })) - .attach(Shield::new()) - .register("/", catchers![r_catch]) - .register("/api", catchers![r_api_catch]) - .mount("/assets", FileServer::from(&CONF.asset_path)) - .mount( - "/", - routes![ - // Frontend - r_account_login_post, - r_account_login, - r_account_logout_post, - r_account_logout, - r_account_register_post, - r_account_register, - r_account_settings_post, - r_account_settings, - r_admin_dashboard, - r_admin_delete_cache, - r_admin_import, - r_admin_invite, - r_admin_log_stream, - r_admin_log, - r_admin_remove_invite, - r_admin_remove_user, - r_admin_transcode_posters, - r_admin_update_search, - r_admin_user_permission, - r_admin_user, - r_admin_users, - r_all_items_filter, - r_asset, - r_assets_font, - r_assets_js_map, - r_assets_js, - r_assets_style, - r_favicon, - r_home, - r_index, - r_item_backdrop, - r_item_poster, - r_library_node_filter, - r_node_thumbnail, - r_node_userdata_progress, - r_node_userdata_rating, - r_node_userdata_watched, - r_node_userdata, - r_person_asset, - r_player, - r_playersync, - r_search, - r_stats, - r_stream, - // API - r_api_account_login, - r_api_asset_token_raw, - r_api_nodes_modified_since, - r_api_root, - r_api_version, - // Compat - r_jellyfin_artists, - r_jellyfin_branding_configuration, - r_jellyfin_branding_css, - r_jellyfin_displaypreferences_usersettings_post, - r_jellyfin_displaypreferences_usersettings, - r_jellyfin_items_image_primary, - r_jellyfin_items_images_backdrop, - r_jellyfin_items_intros, - r_jellyfin_items_item, - r_jellyfin_items_playbackinfo, - r_jellyfin_items_similar, - r_jellyfin_items, - r_jellyfin_livetv_programs_recommended, - r_jellyfin_persons, - r_jellyfin_playback_bitratetest, - r_jellyfin_quickconnect_enabled, - r_jellyfin_sessions_capabilities_full, - r_jellyfin_sessions_playing_progress, - r_jellyfin_sessions_playing, - r_jellyfin_shows_nextup, - r_jellyfin_socket, - r_jellyfin_system_endpoint, - r_jellyfin_system_info_public_case, - r_jellyfin_system_info_public, - r_jellyfin_system_info, - r_jellyfin_users_authenticatebyname, - r_jellyfin_users_authenticatebyname_case, - r_jellyfin_users_id, - r_jellyfin_users_items_item, - r_jellyfin_users_items, - r_jellyfin_users_public, - r_jellyfin_users_views, - r_jellyfin_video_stream, - r_youtube_channel, - r_youtube_embed, - r_youtube_watch, - ], - ) -} - -#[get("/favicon.ico")] -fn r_favicon() -> MyResult { - Ok(File::open(CONF.asset_path.join("favicon.ico"))?) -} - -pub struct Cors(pub T); -impl<'r, T: Responder<'r, 'static>> Responder<'r, 'static> for Cors { - fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { - let mut r = self.0.respond_to(request)?; - r.adjoin_header(Header::new("access-controll-allow-origin", "*")); - Ok(r) - } -} diff --git a/server/src/routes/playersync.rs b/server/src/routes/playersync.rs deleted file mode 100644 index 9eb6175..0000000 --- a/server/src/routes/playersync.rs +++ /dev/null @@ -1,108 +0,0 @@ -use super::Cors; -use anyhow::bail; -use chashmap::CHashMap; -use futures::{SinkExt, StreamExt}; -use log::warn; -use rocket::{get, State}; -use rocket_ws::{stream::DuplexStream, Channel, Message, WebSocket}; -use serde::{Deserialize, Serialize}; -use tokio::sync::broadcast::{self, Sender}; - -#[derive(Default)] -pub struct PlayersyncChannels { - channels: CHashMap>, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "snake_case")] -pub enum Packet { - Time(f64), - Playing(bool), - Join(String), - Leave(String), -} - -#[get("/playersync/")] -pub fn r_playersync( - ws: WebSocket, - state: &State, - channel: &str, -) -> Cors> { - let sender = state - .channels - .get(&channel.to_owned()) - .map(|x| x.to_owned()) - .unwrap_or_else(|| { - let ch = broadcast::channel(16).0; - state.channels.insert(channel.to_owned(), ch.clone()); - ch - }); - Cors(ws.channel(move |ws| { - Box::pin(async move { - let mut state = ClientState { - username: "unknown user".into(), - }; - if let Err(e) = handle_socket(&sender, ws, &mut state).await { - warn!("streamsync websocket error: {e:?}") - } - let _ = sender.send(Message::Text( - serde_json::to_string(&Packet::Leave(state.username)).unwrap(), - )); - Ok(()) - }) - })) -} - -struct ClientState { - username: String, -} - -async fn handle_socket( - broadcast: &Sender, - mut ws: DuplexStream, - state: &mut ClientState, -) -> anyhow::Result<()> { - let mut sub = broadcast.subscribe(); - loop { - tokio::select! { - message = ws.next() => { - match handle_packet(broadcast, message,state) { - Err(e) => Err(e)?, - Ok(true) => return Ok(()), - Ok(false) => () - } - }, - message = sub.recv() => { - ws.send(message?).await?; - } - }; - } -} - -fn handle_packet( - broadcast: &Sender, - message: Option>, - state: &mut ClientState, -) -> anyhow::Result { - let Some(message) = message else { - return Ok(true); - }; - let message = message?.into_text()?; - let packet: Packet = serde_json::from_str(&message)?; - - let broadcast = |p: Packet| -> anyhow::Result<()> { - broadcast.send(Message::Text(serde_json::to_string(&p)?))?; - Ok(()) - }; - - match packet { - Packet::Join(username) => { - broadcast(Packet::Join(username.clone()))?; - state.username = username; - } - Packet::Leave(_) => bail!("illegal packet"), - p => broadcast(p)?, - }; - - Ok(false) -} diff --git a/server/src/routes/stream.rs b/server/src/routes/stream.rs deleted file mode 100644 index 0fbeb3a..0000000 --- a/server/src/routes/stream.rs +++ /dev/null @@ -1,246 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::ui::{account::session::Session, error::MyError}; -use crate::database::Database; -use anyhow::{anyhow, Result}; -use jellybase::{assetfed::AssetInner, federation::Federation}; -use jellycommon::{stream::StreamSpec, TrackSource}; -use jellystream::SMediaInfo; -use log::{info, warn}; -use rocket::{ - get, head, - http::{Header, Status}, - request::{self, FromRequest}, - response::{self, Redirect, Responder}, - Either, Request, Response, State, -}; -use std::{ - collections::{BTreeMap, BTreeSet}, - ops::Range, - sync::Arc, -}; -use tokio::io::{duplex, DuplexStream}; - -#[head("/n/<_id>/stream?")] -pub async fn r_stream_head( - _sess: Session, - _id: &str, - spec: BTreeMap, -) -> Result, MyError> { - let spec = StreamSpec::from_query_kv(&spec).map_err(|x| anyhow!("spec invalid: {x}"))?; - let head = jellystream::stream_head(&spec); - Ok(Either::Left(StreamResponse { - stream: duplex(0).0, - advertise_range: head.range_supported, - content_type: head.content_type, - range: None, - })) -} - -#[get("/n//stream?")] -pub async fn r_stream( - _session: Session, - _federation: &State, - db: &State, - id: &str, - range: Option, - spec: BTreeMap, -) -> Result, MyError> { - let spec = StreamSpec::from_query_kv(&spec).map_err(|x| anyhow!("spec invalid: {x}"))?; - // TODO perm - let node = db - .get_node_slug(id)? - .ok_or(anyhow!("node does not exist"))?; - - let media = Arc::new( - node.media - .clone() - .ok_or(anyhow!("item does not contain media"))?, - ); - - // TODO its unclear how requests with multiple tracks should be handled. - // if spec.track.len() == 1 { - // let ti = spec.track[0]; - // if let TrackSource::Remote(remote_index) = media.tracks[ti].source { - // session - // .user - // .permissions - // .assert(&UserPermission::FederatedContent)?; - - // let track = &node.media.as_ref().ok_or(anyhow!("no media"))?.tracks[ti]; - // let host = track - // .federated - // .last() - // .ok_or(anyhow!("federation inconsistent"))?; - - // let FederationAccount { - // password, username, .. - // } = SECRETS - // .federation - // .get(host) - // .ok_or(anyhow!("no credentials on the server-side"))?; - - // info!("creating session on {host}"); - // let instance = federation.get_instance(host)?.to_owned(); - // let session = instance - // .login(CreateSessionParams { - // username: username.to_owned(), - // password: password.to_owned(), - // expire: Some(60), - // drop_permissions: Some(HashSet::from_iter([ - // UserPermission::ManageSelf, - // UserPermission::Admin, // in case somebody federated the admin :))) - // ])), - // }) - // .await?; - - // let uri = session.stream_url( - // node.slug.clone().into(), - // &StreamSpec { - // track: vec![remote_index], - // ..spec - // }, - // ); - // info!("federation redirect"); - // return Ok(Either::Right(RedirectResponse(uri))); - // } - // } - - info!( - "stream request (range={})", - range - .as_ref() - .map(|r| r.to_cr_hv()) - .unwrap_or("none".to_string()) - ); - - let urange = match &range { - Some(r) => { - let r = r.0.first().unwrap_or(&(None..None)); - r.start.unwrap_or(0)..r.end.unwrap_or(isize::MAX as usize) - } - None => 0..(isize::MAX as usize), - }; - - let head = jellystream::stream_head(&spec); - - let mut sources = BTreeSet::new(); - for t in &media.tracks { - if let TrackSource::Local(x) = &t.source { - if let AssetInner::LocalTrack(m) = AssetInner::deser(&x.0)? { - sources.insert(m.path); - } - } - } - let media = Arc::new(SMediaInfo { - files: sources, - info: node, - }); - - match jellystream::stream(media, spec, urange).await { - Ok(stream) => Ok(Either::Left(StreamResponse { - stream, - range, - advertise_range: head.range_supported, - content_type: head.content_type, - })), - Err(e) => { - warn!("stream error: {e}"); - Err(MyError(e)) - } - } -} - -pub struct RedirectResponse(String); - -#[rocket::async_trait] -impl<'r> Responder<'r, 'static> for RedirectResponse { - fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { - let mut b = Response::build(); - b.status(Status::Found); - b.header(Header::new("access-control-allow-origin", "*")); - b.header(Header::new("location", self.0)); - Ok(b.finalize()) - } -} - -pub struct StreamResponse { - stream: DuplexStream, - advertise_range: bool, - content_type: &'static str, - range: Option, -} - -#[rocket::async_trait] -impl<'r> Responder<'r, 'static> for StreamResponse { - fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { - let mut b = Response::build(); - b.status(Status::Ok); - b.header(Header::new("access-control-allow-origin", "*")); - if self.advertise_range { - //* it is very important here to not reply with content range if we didnt advertise. - //* mpv requests range but will crash if we dont pretend to not support it. - if let Some(range) = self.range { - b.status(Status::PartialContent); - b.header(Header::new("content-range", range.to_cr_hv())); - } - b.header(Header::new("accept-ranges", "bytes")); - } - b.header(Header::new("content-type", self.content_type)) - .streamed_body(self.stream) - .ok() - } -} - -#[derive(Debug)] -pub struct RequestRange(Vec>>); - -impl RequestRange { - pub fn to_cr_hv(&self) -> String { - assert_eq!(self.0.len(), 1); - format!( - "bytes {}-{}/*", - self.0[0].start.map(|e| e.to_string()).unwrap_or_default(), - self.0[0].end.map(|e| e.to_string()).unwrap_or_default() - ) - } - pub fn from_hv(s: &str) -> Result { - Ok(Self( - s.strip_prefix("bytes=") - .ok_or(anyhow!("prefix expected"))? - .split(',') - .map(|s| { - let (l, r) = s - .split_once('-') - .ok_or(anyhow!("range delimeter missing"))?; - let km = |s: &str| { - if s.is_empty() { - Ok::<_, anyhow::Error>(None) - } else { - Ok(Some(s.parse()?)) - } - }; - Ok(km(l)?..km(r)?) - }) - .collect::>>()?, - )) - } -} - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for RequestRange { - type Error = anyhow::Error; - - async fn from_request(req: &'r Request<'_>) -> request::Outcome { - match req.headers().get("range").next() { - Some(v) => match Self::from_hv(v) { - Ok(v) => rocket::outcome::Outcome::Success(v), - Err(e) => rocket::outcome::Outcome::Error((Status::BadRequest, e)), - }, - None => rocket::outcome::Outcome::Forward(Status::Ok), - } - } -} diff --git a/server/src/routes/ui/account/mod.rs b/server/src/routes/ui/account/mod.rs deleted file mode 100644 index 83a1447..0000000 --- a/server/src/routes/ui/account/mod.rs +++ /dev/null @@ -1,261 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -pub mod session; -pub mod settings; - -use super::{ - error::MyError, - layout::{trs, LayoutPage}, -}; -use crate::{ - database::Database, - routes::{ - locale::AcceptLanguage, - ui::{ - account::session::Session, error::MyResult, home::rocket_uri_macro_r_home, - layout::DynLayoutPage, - }, - }, - uri, -}; -use anyhow::anyhow; -use argon2::{password_hash::Salt, Argon2, PasswordHasher}; -use chrono::Duration; -use jellybase::{locale::tr, CONF}; -use jellycommon::user::{User, UserPermission}; -use rocket::{ - form::{Contextual, Form}, - get, - http::{Cookie, CookieJar}, - post, - response::Redirect, - FromForm, State, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; - -#[derive(FromForm)] -pub struct RegisterForm { - #[field(validate = len(8..128))] - pub invitation: String, - #[field(validate = len(4..32))] - pub username: String, - #[field(validate = len(4..64))] - pub password: String, -} - -#[get("/account/register")] -pub async fn r_account_register(lang: AcceptLanguage) -> DynLayoutPage<'static> { - let AcceptLanguage(lang) = lang; - LayoutPage { - title: tr(lang, "account.register").to_string(), - content: markup::new! { - form.account[method="POST", action=""] { - h1 { @trs(&lang, "account.register") } - - label[for="inp-invitation"] { @trs(&lang, "account.register.invitation") } - input[type="text", id="inp-invitation", name="invitation"]; br; - - label[for="inp-username"] { @trs(&lang, "account.username") } - input[type="text", id="inp-username", name="username"]; br; - label[for="inp-password"] { @trs(&lang, "account.password") } - input[type="password", id="inp-password", name="password"]; br; - - input[type="submit", value=&*tr(lang, "account.register.submit")]; - - p { @trs(&lang, "account.register.login") " " a[href=uri!(r_account_login())] { @trs(&lang, "account.register.login_here") } } - } - }, - ..Default::default() - } -} - -#[derive(FromForm, Serialize, Deserialize)] -pub struct LoginForm { - #[field(validate = len(4..32))] - pub username: String, - #[field(validate = len(..64))] - pub password: String, - #[field(default = 604800)] // one week - pub expire: u64, -} - -#[get("/account/login")] -pub fn r_account_login(sess: Option, lang: AcceptLanguage) -> DynLayoutPage<'static> { - let AcceptLanguage(lang) = lang; - let logged_in = sess.is_some(); - let title = tr( - lang, - if logged_in { - "account.login.switch" - } else { - "account.login" - }, - ); - LayoutPage { - title: title.to_string(), - content: markup::new! { - form.account[method="POST", action=""] { - h1 { @title.to_string() } - - label[for="inp-username"] { @trs(&lang, "account.username") } - input[type="text", id="inp-username", name="username"]; br; - label[for="inp-password"] { @trs(&lang, "account.password") } - input[type="password", id="inp-password", name="password"]; br; - - input[type="submit", value=&*tr(lang, if logged_in { "account.login.submit.switch" } else { "account.login.submit" })]; - - @if logged_in { - p { @trs(&lang, "account.login.register.switch") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } } - } else { - p { @trs(&lang, "account.login.cookie_note") } - p { @trs(&lang, "account.login.register") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } } - } - } - }, - ..Default::default() - } -} - -#[get("/account/logout")] -pub fn r_account_logout(lang: AcceptLanguage) -> DynLayoutPage<'static> { - let AcceptLanguage(lang) = lang; - LayoutPage { - title: tr(lang, "account.logout").to_string(), - content: markup::new! { - form.account[method="POST", action=""] { - h1 { @trs(&lang, "account.logout") } - input[type="submit", value=&*tr(lang, "account.logout.submit")]; - } - }, - ..Default::default() - } -} - -#[post("/account/register", data = "")] -pub fn r_account_register_post<'a>( - database: &'a State, - _sess: Option, - form: Form>, - lang: AcceptLanguage, -) -> MyResult> { - let AcceptLanguage(lang) = lang; - let logged_in = _sess.is_some(); - let form = match &form.value { - Some(v) => v, - None => return Err(format_form_error(form)), - }; - - database.register_user( - &form.invitation, - &form.username, - User { - display_name: form.username.clone(), - name: form.username.clone(), - password: hash_password(&form.username, &form.password), - ..Default::default() - }, - )?; - - Ok(LayoutPage { - title: tr(lang, "account.register.success.title").to_string(), - content: markup::new! { - h1 { @trs(&lang, if logged_in { - "account.register.success.switch" - } else { - "account.register.success" - })} - }, - ..Default::default() - }) -} - -#[post("/account/login", data = "")] -pub fn r_account_login_post( - database: &State, - jar: &CookieJar, - form: Form>, -) -> MyResult { - let form = match &form.value { - Some(v) => v, - None => return Err(format_form_error(form)), - }; - jar.add( - Cookie::build(( - "session", - login_logic(database, &form.username, &form.password, None, None)?, - )) - .permanent() - .build(), - ); - - Ok(Redirect::found(rocket::uri!(r_home()))) -} - -#[post("/account/logout")] -pub fn r_account_logout_post(jar: &CookieJar) -> MyResult { - jar.remove_private(Cookie::build("session")); - Ok(Redirect::found(rocket::uri!(r_home()))) -} - -pub fn login_logic( - database: &Database, - username: &str, - password: &str, - expire: Option, - drop_permissions: Option>, -) -> MyResult { - // hashing the password regardless if the accounts exists to better resist timing attacks - let password = hash_password(username, password); - - let mut user = database - .get_user(username)? - .ok_or(anyhow!("invalid password"))?; - - if user.password != password { - Err(anyhow!("invalid password"))? - } - - if let Some(ep) = drop_permissions { - // remove all grant perms that are in `ep` - user.permissions - .0 - .retain(|p, val| if *val { !ep.contains(p) } else { true }) - } - - Ok(session::token::create( - user.name, - user.permissions, - Duration::days(CONF.login_expire.min(expire.unwrap_or(i64::MAX))), - )) -} - -pub fn format_form_error(form: Form>) -> MyError { - let mut k = String::from("form validation failed:"); - for e in form.context.errors() { - k += &format!( - "\n\t{}: {e}", - e.name - .as_ref() - .map(|e| e.to_string()) - .unwrap_or("".to_string()) - ) - } - MyError(anyhow!(k)) -} - -pub fn hash_password(username: &str, password: &str) -> Vec { - Argon2::default() - .hash_password( - format!("{username}\0{password}").as_bytes(), - <&str as TryInto>::try_into("IYMa13osbNeLJKnQ1T8LlA").unwrap(), - ) - .unwrap() - .hash - .unwrap() - .as_bytes() - .to_vec() -} diff --git a/server/src/routes/ui/account/session/guard.rs b/server/src/routes/ui/account/session/guard.rs deleted file mode 100644 index 295c2d4..0000000 --- a/server/src/routes/ui/account/session/guard.rs +++ /dev/null @@ -1,106 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::{AdminSession, Session}; -use crate::{database::Database, routes::ui::error::MyError}; -use anyhow::anyhow; -use log::warn; -use rocket::{ - async_trait, - http::Status, - outcome::Outcome, - request::{self, FromRequest}, - Request, State, -}; - -impl Session { - pub async fn from_request_ut(req: &Request<'_>) -> Result { - let username; - - #[cfg(not(feature = "bypass-auth"))] - { - let token = req - .query_value("session") - .map(|e| e.unwrap()) - .or_else(|| req.query_value("api_key").map(|e| e.unwrap())) - .or_else(|| req.headers().get_one("X-MediaBrowser-Token")) - .or_else(|| { - req.headers() - .get_one("Authorization") - .and_then(parse_jellyfin_auth) - }) // for jellyfin compat - .or(req.cookies().get("session").map(|cookie| cookie.value())) - .ok_or(anyhow!("not logged in"))?; - - // jellyfin urlescapes the token for *some* requests - let token = token.replace("%3D", "="); - username = super::token::validate(&token)?; - }; - - #[cfg(feature = "bypass-auth")] - { - parse_jellyfin_auth("a"); // unused warning is annoying - username = "admin".to_string(); - } - - let db = req.guard::<&State>().await.unwrap(); - - let user = db.get_user(&username)?.ok_or(anyhow!("user not found"))?; - - Ok(Session { user }) - } -} - -fn parse_jellyfin_auth(h: &str) -> Option<&str> { - for tok in h.split(" ") { - if let Some(tok) = tok.strip_prefix("Token=\"") { - if let Some(tok) = tok.strip_suffix("\"") { - return Some(tok); - } - } - } - None -} - -#[async_trait] -impl<'r> FromRequest<'r> for Session { - type Error = MyError; - async fn from_request<'life0>( - request: &'r Request<'life0>, - ) -> request::Outcome { - match Session::from_request_ut(request).await { - Ok(x) => Outcome::Success(x), - Err(e) => { - warn!("authentificated route rejected: {e:?}"); - Outcome::Forward(Status::Unauthorized) - } - } - } -} - -#[async_trait] -impl<'r> FromRequest<'r> for AdminSession { - type Error = MyError; - async fn from_request<'life0>( - request: &'r Request<'life0>, - ) -> request::Outcome { - match Session::from_request_ut(request).await { - Ok(x) => { - if x.user.admin { - Outcome::Success(AdminSession(x)) - } else { - Outcome::Error(( - Status::Unauthorized, - MyError(anyhow!("you are not an admin")), - )) - } - } - Err(e) => { - warn!("authentificated route rejected: {e:?}"); - Outcome::Forward(Status::Unauthorized) - } - } - } -} diff --git a/server/src/routes/ui/account/session/mod.rs b/server/src/routes/ui/account/session/mod.rs deleted file mode 100644 index cb06255..0000000 --- a/server/src/routes/ui/account/session/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use chrono::{DateTime, Utc}; -use jellycommon::user::{PermissionSet, User}; -use serde::{Deserialize, Serialize}; - -pub mod guard; -pub mod token; - -pub struct Session { - pub user: User, -} - -pub struct AdminSession(pub Session); - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionData { - username: String, - expire: DateTime, - permissions: PermissionSet, -} diff --git a/server/src/routes/ui/account/session/token.rs b/server/src/routes/ui/account/session/token.rs deleted file mode 100644 index 3ada0ec..0000000 --- a/server/src/routes/ui/account/session/token.rs +++ /dev/null @@ -1,97 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::SessionData; -use aes_gcm_siv::{ - aead::{generic_array::GenericArray, Aead}, - KeyInit, -}; -use anyhow::anyhow; -use base64::Engine; -use chrono::{Duration, Utc}; -use jellybase::SECRETS; -use jellycommon::user::PermissionSet; -use log::warn; -use std::sync::LazyLock; - -static SESSION_KEY: LazyLock<[u8; 32]> = LazyLock::new(|| { - if let Some(sk) = &SECRETS.session_key { - let r = base64::engine::general_purpose::STANDARD - .decode(sk) - .expect("key invalid; should be valid base64"); - r.try_into() - .expect("key has the wrong length; should be 32 bytes") - } else { - warn!("session_key not configured; generating a random one."); - [(); 32].map(|_| rand::random()) - } -}); - -pub fn create(username: String, permissions: PermissionSet, expire: Duration) -> String { - let session_data = SessionData { - expire: Utc::now() + expire, - username: username.to_owned(), - permissions, - }; - let mut plaintext = - bincode::serde::encode_to_vec(&session_data, bincode::config::standard()).unwrap(); - - while plaintext.len() % 16 == 0 { - plaintext.push(0); - } - - let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); - let nonce = [(); 12].map(|_| rand::random()); - let mut ciphertext = cipher - .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) - .unwrap(); - ciphertext.extend(nonce); - - base64::engine::general_purpose::URL_SAFE.encode(&ciphertext) -} - -pub fn validate(token: &str) -> anyhow::Result { - let ciphertext = base64::engine::general_purpose::URL_SAFE.decode(token)?; - let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); - let (ciphertext, nonce) = ciphertext.split_at(ciphertext.len() - 12); - let plaintext = cipher - .decrypt(nonce.into(), ciphertext) - .map_err(|e| anyhow!("decryption failed: {e:?}"))?; - - let (session_data, _): (SessionData, _) = - bincode::serde::decode_from_slice(&plaintext, bincode::config::standard())?; - - if session_data.expire < Utc::now() { - Err(anyhow!("session expired"))? - } - - Ok(session_data.username) -} - -#[test] -fn test() { - jellybase::use_test_config(); - let tok = create( - "blub".to_string(), - jellycommon::user::PermissionSet::default(), - Duration::days(1), - ); - validate(&tok).unwrap(); -} - -#[test] -fn test_crypto() { - jellybase::use_test_config(); - let nonce = [(); 12].map(|_| rand::random()); - let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); - let plaintext = b"testing stuff---"; - let ciphertext = cipher - .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) - .unwrap(); - let plaintext2 = cipher - .decrypt((&nonce).into(), ciphertext.as_slice()) - .unwrap(); - assert_eq!(plaintext, plaintext2.as_slice()); -} diff --git a/server/src/routes/ui/account/settings.rs b/server/src/routes/ui/account/settings.rs deleted file mode 100644 index 2e170b0..0000000 --- a/server/src/routes/ui/account/settings.rs +++ /dev/null @@ -1,187 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::{format_form_error, hash_password}; -use crate::{ - database::Database, - routes::{ - locale::AcceptLanguage, - ui::{ - account::{rocket_uri_macro_r_account_login, session::Session}, - error::MyResult, - layout::{trs, DynLayoutPage, LayoutPage}, - }, - }, - uri, -}; -use jellybase::{ - locale::{tr, Language}, - permission::PermissionSetExt, -}; -use jellycommon::user::{PlayerKind, Theme, UserPermission}; -use markup::{Render, RenderAttributeValue}; -use rocket::{ - form::{self, validate::len, Contextual, Form}, - get, - http::uri::fmt::{Query, UriDisplay}, - post, FromForm, State, -}; -use std::ops::Range; - -#[derive(FromForm)] -pub struct SettingsForm { - #[field(validate = option_len(4..64))] - password: Option, - #[field(validate = option_len(4..32))] - display_name: Option, - theme: Option, - player_preference: Option, - native_secret: Option, -} - -fn option_len<'v>(value: &Option, range: Range) -> form::Result<'v, ()> { - value.as_ref().map(|v| len(v, range)).unwrap_or(Ok(())) -} - -fn settings_page( - session: Session, - flash: Option>, - lang: Language, -) -> DynLayoutPage<'static> { - LayoutPage { - title: "Settings".to_string(), - class: Some("settings"), - content: markup::new! { - h1 { "Settings" } - @if let Some(flash) = &flash { - @match flash { - Ok(mesg) => { section.message { p.success { @mesg } } } - Err(err) => { section.message { p.error { @format!("{err}") } } } - } - } - h2 { @trs(&lang, "account") } - a.switch_account[href=uri!(r_account_login())] { "Switch Account" } - form[method="POST", action=uri!(r_account_settings_post())] { - label[for="username"] { @trs(&lang, "account.username") } - input[type="text", id="username", disabled, value=&session.user.name]; - input[type="submit", disabled, value=&*tr(lang, "settings.immutable")]; - } - form[method="POST", action=uri!(r_account_settings_post())] { - label[for="display_name"] { @trs(&lang, "account.display_name") } - input[type="text", id="display_name", name="display_name", value=&session.user.display_name]; - input[type="submit", value=&*tr(lang, "settings.update")]; - } - form[method="POST", action=uri!(r_account_settings_post())] { - label[for="password"] { @trs(&lang, "account.password") } - input[type="password", id="password", name="password"]; - input[type="submit", value=&*tr(lang, "settings.update")]; - } - h2 { @trs(&lang, "settings.appearance") } - form[method="POST", action=uri!(r_account_settings_post())] { - fieldset { - legend { @trs(&lang, "settings.appearance.theme") } - @for (t, tlabel) in Theme::LIST { - label { input[type="radio", name="theme", value=A(*t), checked=session.user.theme==*t]; @tlabel } br; - } - } - input[type="submit", value=&*tr(lang, "settings.apply")]; - } - form[method="POST", action=uri!(r_account_settings_post())] { - fieldset { - legend { @trs(&lang, "settings.player_preference") } - @for (t, tlabel) in PlayerKind::LIST { - label { input[type="radio", name="player_preference", value=A(*t), checked=session.user.player_preference==*t]; @tlabel } br; - } - } - input[type="submit", value=&*tr(lang, "settings.apply")]; - } - form[method="POST", action=uri!(r_account_settings_post())] { - label[for="native_secret"] { "Native Secret" } - input[type="password", id="native_secret", name="native_secret"]; - input[type="submit", value=&*tr(lang, "settings.update")]; - p { "The secret can be found in " code{"$XDG_CONFIG_HOME/jellynative_secret"} " or by clicking " a.button[href="jellynative://show-secret-v1"] { "Show Secret" } "." } - } - }, - } -} - -struct A(pub T); -impl> RenderAttributeValue for A {} -impl> Render for A { - fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { - writer.write_fmt(format_args!("{}", &self.0 as &dyn UriDisplay)) - } -} - -#[get("/account/settings")] -pub fn r_account_settings(session: Session, lang: AcceptLanguage) -> DynLayoutPage<'static> { - let AcceptLanguage(lang) = lang; - settings_page(session, None, lang) -} - -#[post("/account/settings", data = "")] -pub fn r_account_settings_post( - session: Session, - database: &State, - form: Form>, - lang: AcceptLanguage, -) -> MyResult> { - let AcceptLanguage(lang) = lang; - session - .user - .permissions - .assert(&UserPermission::ManageSelf)?; - - let form = match &form.value { - Some(v) => v, - None => { - return Ok(settings_page( - session, - Some(Err(format_form_error(form))), - lang, - )) - } - }; - - let mut out = String::new(); - - database.update_user(&session.user.name, |user| { - if let Some(password) = &form.password { - user.password = hash_password(&session.user.name, password); - out += &*tr(lang, "settings.account.password.changed"); - out += "\n"; - } - if let Some(display_name) = &form.display_name { - user.display_name = display_name.clone(); - out += &*tr(lang, "settings.account.display_name.changed"); - out += "\n"; - } - if let Some(theme) = form.theme { - user.theme = theme; - out += &*tr(lang, "settings.account.theme.changed"); - out += "\n"; - } - if let Some(player_preference) = form.player_preference { - user.player_preference = player_preference; - out += &*tr(lang, "settings.player_preference.changed"); - out += "\n"; - } - if let Some(native_secret) = &form.native_secret { - user.native_secret = native_secret.to_owned(); - out += "Native secret updated.\n"; - } - Ok(()) - })?; - - Ok(settings_page( - session, // using the old session here, results in outdated theme being displayed - Some(Ok(if out.is_empty() { - tr(lang, "settings.no_change").to_string() - } else { - out - })), - lang, - )) -} diff --git a/server/src/routes/ui/admin/log.rs b/server/src/routes/ui/admin/log.rs deleted file mode 100644 index fc85b37..0000000 --- a/server/src/routes/ui/admin/log.rs +++ /dev/null @@ -1,258 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use crate::{ - routes::ui::{ - account::session::AdminSession, - error::MyResult, - layout::{DynLayoutPage, LayoutPage}, - }, - uri, -}; -use chrono::{DateTime, Utc}; -use log::Level; -use markup::Render; -use rocket::get; -use rocket_ws::{Message, Stream, WebSocket}; -use serde_json::json; -use std::{ - collections::VecDeque, - fmt::Write, - sync::{Arc, LazyLock, RwLock}, -}; -use tokio::sync::broadcast; - -const MAX_LOG_LEN: usize = 4096; - -static LOGGER: LazyLock = LazyLock::new(Log::default); - -pub fn enable_logging() { - log::set_logger(&*LOGGER).unwrap(); - log::set_max_level(log::LevelFilter::Debug); -} - -type LogBuffer = VecDeque>; - -pub struct Log { - inner: env_logger::Logger, - stream: ( - broadcast::Sender>, - broadcast::Sender>, - ), - log: RwLock<(LogBuffer, LogBuffer)>, -} - -pub struct LogLine { - time: DateTime, - module: Option<&'static str>, - level: Level, - message: String, -} - -#[get("/admin/log?", rank = 2)] -pub fn r_admin_log<'a>(_session: AdminSession, warnonly: bool) -> MyResult> { - Ok(LayoutPage { - title: "Log".into(), - class: Some("admin_log"), - content: markup::new! { - h1 { "Server Log" } - a[href=uri!(r_admin_log(!warnonly))] { @if warnonly { "Show everything" } else { "Show only warnings" }} - code.log[id="log"] { - @let g = LOGGER.log.read().unwrap(); - table { @for e in if warnonly { g.1.iter() } else { g.0.iter() } { - tr[class=format!("level-{:?}", e.level).to_ascii_lowercase()] { - td.time { @e.time.to_rfc3339() } - td.loglevel { @format_level(e.level) } - td.module { @e.module } - td { @markup::raw(vt100_to_html(&e.message)) } - } - }} - } - }, - }) -} - -#[get("/admin/log?stream&", rank = 1)] -pub fn r_admin_log_stream( - _session: AdminSession, - ws: WebSocket, - warnonly: bool, -) -> Stream!['static] { - let mut stream = if warnonly { - LOGGER.stream.1.subscribe() - } else { - LOGGER.stream.0.subscribe() - }; - Stream! { ws => - let _ = ws; - while let Ok(line) = stream.recv().await { - yield Message::Text(json!({ - "time": line.time, - "level_class": format!("level-{:?}", line.level).to_ascii_lowercase(), - "level_html": format_level_string(line.level), - "module": line.module, - "message": vt100_to_html(&line.message), - }).to_string()); - } - } -} - -impl Default for Log { - fn default() -> Self { - Self { - inner: env_logger::builder() - .filter_level(log::LevelFilter::Warn) - .parse_env("LOG") - .build(), - stream: ( - tokio::sync::broadcast::channel(1024).0, - tokio::sync::broadcast::channel(1024).0, - ), - log: Default::default(), - } - } -} -impl Log { - fn should_log(&self, metadata: &log::Metadata) -> bool { - let level = metadata.level(); - level - <= match metadata.target() { - x if x.starts_with("jelly") => Level::Debug, - x if x.starts_with("rocket::") => Level::Info, - _ => Level::Warn, - } - } - fn do_log(&self, record: &log::Record) { - let time = Utc::now(); - let line = Arc::new(LogLine { - time, - module: record.module_path_static(), - level: record.level(), - message: record.args().to_string(), - }); - let mut w = self.log.write().unwrap(); - w.0.push_back(line.clone()); - let _ = self.stream.0.send(line.clone()); - while w.0.len() > MAX_LOG_LEN { - w.0.pop_front(); - } - if record.level() <= Level::Warn { - let _ = self.stream.1.send(line.clone()); - w.1.push_back(line); - while w.1.len() > MAX_LOG_LEN { - w.1.pop_front(); - } - } - } -} - -impl log::Log for Log { - fn enabled(&self, metadata: &log::Metadata) -> bool { - self.inner.enabled(metadata) || self.should_log(metadata) - } - fn log(&self, record: &log::Record) { - match (record.module_path_static(), record.line()) { - // TODO is there a better way to ignore those? - (Some("rocket::rocket"), Some(670)) => return, - (Some("rocket::server"), Some(401)) => return, - _ => {} - } - if self.inner.enabled(record.metadata()) { - self.inner.log(record); - } - if self.should_log(record.metadata()) { - self.do_log(record) - } - } - fn flush(&self) { - self.inner.flush(); - } -} - -fn vt100_to_html(s: &str) -> String { - let mut out = HtmlOut::default(); - let mut st = vte::Parser::new(); - st.advance(&mut out, s.as_bytes()); - out.s -} - -fn format_level(level: Level) -> impl markup::Render { - let (s, c) = match level { - Level::Debug => ("DEBUG", "blue"), - Level::Error => ("ERROR", "red"), - Level::Warn => ("WARN", "yellow"), - Level::Info => ("INFO", "green"), - Level::Trace => ("TRACE", "lightblue"), - }; - markup::new! { span[style=format!("color:{c}")] {@s} } -} -fn format_level_string(level: Level) -> String { - let mut w = String::new(); - format_level(level).render(&mut w).unwrap(); - w -} - -#[derive(Default)] -pub struct HtmlOut { - s: String, - color: bool, -} -impl HtmlOut { - pub fn set_color(&mut self, [r, g, b]: [u8; 3]) { - self.reset_color(); - self.color = true; - write!(self.s, "", r, g, b).unwrap() - } - pub fn reset_color(&mut self) { - if self.color { - write!(self.s, "").unwrap(); - self.color = false; - } - } -} -impl vte::Perform for HtmlOut { - fn print(&mut self, c: char) { - match c { - 'a'..='z' | 'A'..='Z' | '0'..='9' | ' ' => self.s.push(c), - x => write!(self.s, "&#{};", x as u32).unwrap(), - } - } - fn execute(&mut self, _byte: u8) {} - fn hook(&mut self, _params: &vte::Params, _i: &[u8], _ignore: bool, _a: char) {} - fn put(&mut self, _byte: u8) {} - fn unhook(&mut self) {} - fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {} - fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} - fn csi_dispatch( - &mut self, - params: &vte::Params, - _intermediates: &[u8], - _ignore: bool, - action: char, - ) { - let mut k = params.iter(); - #[allow(clippy::single_match)] - match action { - 'm' => match k.next().unwrap_or(&[0]).first().unwrap_or(&0) { - c @ (30..=37 | 40..=47) => { - let c = if *c >= 40 { *c - 10 } else { *c }; - self.set_color(match c { - 30 => [0, 0, 0], - 31 => [255, 0, 0], - 32 => [0, 255, 0], - 33 => [255, 255, 0], - 34 => [0, 0, 255], - 35 => [255, 0, 255], - 36 => [0, 255, 255], - 37 => [255, 255, 255], - _ => unreachable!(), - }); - } - _ => (), - }, - _ => (), - } - } -} diff --git a/server/src/routes/ui/admin/mod.rs b/server/src/routes/ui/admin/mod.rs deleted file mode 100644 index f44b36c..0000000 --- a/server/src/routes/ui/admin/mod.rs +++ /dev/null @@ -1,290 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -pub mod log; -pub mod user; - -use super::{ - account::session::AdminSession, - assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED}, -}; -use crate::{ - database::Database, - routes::ui::{ - admin::log::rocket_uri_macro_r_admin_log, - error::MyResult, - layout::{DynLayoutPage, FlashDisplay, LayoutPage}, - }, - uri, -}; -use anyhow::{anyhow, Context}; -use jellybase::{assetfed::AssetInner, federation::Federation, CONF}; -use jellyimport::{import_wrap, is_importing, IMPORT_ERRORS}; -use markup::DynRender; -use rand::Rng; -use rocket::{form::Form, get, post, FromForm, State}; -use std::time::Instant; -use tokio::{sync::Semaphore, task::spawn_blocking}; -use user::rocket_uri_macro_r_admin_users; - -#[get("/admin/dashboard")] -pub async fn r_admin_dashboard( - _session: AdminSession, - database: &State, -) -> MyResult> { - admin_dashboard(database, None).await -} - -pub async fn admin_dashboard<'a>( - database: &Database, - flash: Option>, -) -> MyResult> { - let invites = database.list_invites()?; - let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); - - let last_import_err = IMPORT_ERRORS.read().await.to_owned(); - - let database = database.to_owned(); - Ok(LayoutPage { - title: "Admin Dashboard".to_string(), - content: markup::new! { - h1 { "Admin Panel" } - @FlashDisplay { flash: flash.clone() } - @if !last_import_err.is_empty() { - section.message.error { - details { - summary { p.error { @format!("The last import resulted in {} errors:", last_import_err.len()) } } - ol { @for e in &last_import_err { - li.error { pre.error { @e } } - }} - } - } - } - ul { - li{a[href=uri!(r_admin_log(true))] { "Server Log (Warnings only)" }} - li{a[href=uri!(r_admin_log(false))] { "Server Log (Full) " }} - } - h2 { "Library" } - @if is_importing() { - section.message { p.warn { "An import is currently running." } } - } - @if is_transcoding() { - section.message { p.warn { "Currently transcoding posters." } } - } - form[method="POST", action=uri!(r_admin_import(true))] { - input[type="submit", disabled=is_importing(), value="Start incremental import"]; - } - form[method="POST", action=uri!(r_admin_import(false))] { - input[type="submit", disabled=is_importing(), value="Start full import"]; - } - form[method="POST", action=uri!(r_admin_transcode_posters())] { - input[type="submit", disabled=is_transcoding(), value="Transcode all posters with low resolution"]; - } - form[method="POST", action=uri!(r_admin_update_search())] { - input[type="submit", value="Regenerate full-text search index"]; - } - form[method="POST", action=uri!(r_admin_delete_cache())] { - input.danger[type="submit", value="Delete Cache"]; - } - h2 { "Users" } - p { a[href=uri!(r_admin_users())] "Manage Users" } - h2 { "Invitations" } - form[method="POST", action=uri!(r_admin_invite())] { - input[type="submit", value="Generate new invite code"]; - } - ul { @for t in &invites { - li { - form[method="POST", action=uri!(r_admin_remove_invite())] { - span { @t } - input[type="text", name="invite", value=&t, hidden]; - input[type="submit", value="Invalidate"]; - } - } - }} - - h2 { "Database" } - @match db_stats(&database) { - Ok(s) => { @s } - Err(e) => { pre.error { @format!("{e:?}") } } - } - }, - ..Default::default() - }) -} - -#[post("/admin/generate_invite")] -pub async fn r_admin_invite( - _session: AdminSession, - database: &State, -) -> MyResult> { - let i = format!("{}", rand::rng().random::()); - database.create_invite(&i)?; - admin_dashboard(database, Some(Ok(format!("Invite: {}", i)))).await -} - -#[derive(FromForm)] -pub struct DeleteInvite { - invite: String, -} - -#[post("/admin/remove_invite", data = "")] -pub async fn r_admin_remove_invite( - session: AdminSession, - database: &State, - form: Form, -) -> MyResult> { - drop(session); - if !database.delete_invite(&form.invite)? { - Err(anyhow!("invite does not exist"))?; - }; - admin_dashboard(database, Some(Ok("Invite invalidated".into()))).await -} - -#[post("/admin/import?")] -pub async fn r_admin_import( - session: AdminSession, - database: &State, - _federation: &State, - incremental: bool, -) -> MyResult> { - drop(session); - let t = Instant::now(); - if !incremental { - database.clear_nodes()?; - } - let r = import_wrap((*database).clone(), incremental).await; - let flash = r - .map_err(|e| e.into()) - .map(|_| format!("Import successful; took {:?}", t.elapsed())); - admin_dashboard(database, Some(flash)).await -} - -#[post("/admin/update_search")] -pub async fn r_admin_update_search( - _session: AdminSession, - database: &State, -) -> MyResult> { - let db2 = (*database).clone(); - let r = spawn_blocking(move || db2.search_create_index()) - .await - .unwrap(); - admin_dashboard( - database, - Some( - r.map_err(|e| e.into()) - .map(|_| "Search index updated".to_string()), - ), - ) - .await -} - -#[post("/admin/delete_cache")] -pub async fn r_admin_delete_cache( - session: AdminSession, - database: &State, -) -> MyResult> { - drop(session); - let t = Instant::now(); - let r = tokio::fs::remove_dir_all(&CONF.cache_path).await; - tokio::fs::create_dir(&CONF.cache_path).await?; - admin_dashboard( - database, - Some( - r.map_err(|e| e.into()) - .map(|_| format!("Cache deleted; took {:?}", t.elapsed())), - ), - ) - .await -} - -static SEM_TRANSCODING: Semaphore = Semaphore::const_new(1); -fn is_transcoding() -> bool { - SEM_TRANSCODING.available_permits() == 0 -} - -#[post("/admin/transcode_posters")] -pub async fn r_admin_transcode_posters( - session: AdminSession, - database: &State, -) -> MyResult> { - drop(session); - let _permit = SEM_TRANSCODING - .try_acquire() - .context("transcoding in progress")?; - - let t = Instant::now(); - - { - let nodes = database.list_nodes_with_udata("")?; - for (node, _) in nodes { - if let Some(poster) = &node.poster { - let asset = AssetInner::deser(&poster.0)?; - if asset.is_federated() { - continue; - } - let source = resolve_asset(asset).await.context("resolving asset")?; - jellytranscoder::image::transcode(&source, AVIF_QUALITY, AVIF_SPEED, 1024) - .await - .context("transcoding asset")?; - } - } - } - drop(_permit); - - admin_dashboard( - database, - Some(Ok(format!( - "All posters pre-transcoded; took {:?}", - t.elapsed() - ))), - ) - .await -} - -fn db_stats(_db: &Database) -> anyhow::Result { - // TODO - // let txn = db.inner.begin_read()?; - // let stats = [ - // ("node", txn.open_table(T_NODE)?.stats()?), - // ("user", txn.open_table(T_USER_NODE)?.stats()?), - // ("user-node", txn.open_table(T_USER_NODE)?.stats()?), - // ("invite", txn.open_table(T_INVITE)?.stats()?), - // ]; - - // let cache_stats = db.node_index.reader.searcher().doc_store_cache_stats(); - // let ft_total_docs = db.node_index.reader.searcher().total_num_docs()?; - - Ok(markup::new! { - // h3 { "Key-Value-Store Statistics" } - // table.border { - // tbody { - // tr { - // th { "table name" } - // th { "tree height" } - // th { "stored bytes" } - // th { "metadata bytes" } - // th { "fragmented bytes" } - // th { "branch pages" } - // th { "leaf pages" } - // } - // @for (name, stats) in &stats { tr { - // td { @name } - // td { @stats.tree_height() } - // td { @format_size(stats.stored_bytes(), DECIMAL) } - // td { @format_size(stats.metadata_bytes(), DECIMAL) } - // td { @format_size(stats.fragmented_bytes(), DECIMAL) } - // td { @stats.branch_pages() } - // td { @stats.leaf_pages() } - // }} - // } - // } - // h3 { "Search Engine Statistics" } - // ul { - // li { "Total documents: " @ft_total_docs } - // li { "Cache misses: " @cache_stats.cache_misses } - // li { "Cache hits: " @cache_stats.cache_hits } - // } - }) -} diff --git a/server/src/routes/ui/admin/user.rs b/server/src/routes/ui/admin/user.rs deleted file mode 100644 index 7ba6d4e..0000000 --- a/server/src/routes/ui/admin/user.rs +++ /dev/null @@ -1,176 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use crate::{ - database::Database, - routes::ui::{ - account::session::AdminSession, - error::MyResult, - layout::{DynLayoutPage, FlashDisplay, LayoutPage}, - }, - uri, -}; -use anyhow::{anyhow, Context}; -use jellycommon::user::{PermissionSet, UserPermission}; -use rocket::{form::Form, get, post, FromForm, FromFormField, State}; - -#[get("/admin/users")] -pub fn r_admin_users( - _session: AdminSession, - database: &State, -) -> MyResult> { - user_management(database, None) -} - -fn user_management<'a>( - database: &Database, - flash: Option>, -) -> MyResult> { - // TODO this doesnt scale, pagination! - let users = database.list_users()?; - let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); - - Ok(LayoutPage { - title: "User management".to_string(), - content: markup::new! { - h1 { "User Management" } - @FlashDisplay { flash: flash.clone() } - h2 { "All Users" } - ul { @for u in &users { - li { - a[href=uri!(r_admin_user(&u.name))] { @format!("{:?}", u.display_name) " (" @u.name ")" } - } - }} - }, - ..Default::default() - }) -} - -#[get("/admin/user/")] -pub fn r_admin_user<'a>( - _session: AdminSession, - database: &State, - name: &'a str, -) -> MyResult> { - manage_single_user(database, None, name.to_string()) -} - -fn manage_single_user<'a>( - database: &Database, - flash: Option>, - name: String, -) -> MyResult> { - let user = database - .get_user(&name)? - .ok_or(anyhow!("user does not exist"))?; - let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); - - Ok(LayoutPage { - title: "User management".to_string(), - content: markup::new! { - h1 { @format!("{:?}", user.display_name) " (" @user.name ")" } - a[href=uri!(r_admin_users())] "Back to the User List" - @FlashDisplay { flash: flash.clone() } - form[method="POST", action=uri!(r_admin_remove_user())] { - input[type="text", name="name", value=&user.name, hidden]; - input.danger[type="submit", value="Remove user(!)"]; - } - - h2 { "Permissions" } - @PermissionDisplay { perms: &user.permissions } - - form[method="POST", action=uri!(r_admin_user_permission())] { - input[type="text", name="name", value=&user.name, hidden]; - fieldset.perms { - legend { "Permission" } - @for p in UserPermission::ALL_ENUMERABLE { - label { - input[type="radio", name="permission", value=serde_json::to_string(p).unwrap()]; - @format!("{p}") - } br; - } - } - fieldset.perms { - legend { "Permission" } - label { input[type="radio", name="action", value="unset"]; "Unset" } br; - label { input[type="radio", name="action", value="grant"]; "Grant" } br; - label { input[type="radio", name="action", value="revoke"]; "Revoke" } br; - } - input[type="submit", value="Update"]; - } - - }, - ..Default::default() - }) -} - -markup::define! { - PermissionDisplay<'a>(perms: &'a PermissionSet) { - ul { @for (perm,grant) in &perms.0 { - @if *grant { - li[class="perm-grant"] { @format!("Allow {}", perm) } - } else { - li[class="perm-revoke"] { @format!("Deny {}", perm) } - } - }} - } -} - -#[derive(FromForm)] -pub struct DeleteUser { - name: String, -} -#[derive(FromForm)] -pub struct UserPermissionForm { - name: String, - permission: String, - action: GrantState, -} - -#[derive(FromFormField)] -pub enum GrantState { - Grant, - Revoke, - Unset, -} - -#[post("/admin/update_user_permission", data = "")] -pub fn r_admin_user_permission( - session: AdminSession, - database: &State, - form: Form, -) -> MyResult> { - drop(session); - let perm = serde_json::from_str::(&form.permission) - .context("parsing provided permission")?; - - database.update_user(&form.name, |user| { - match form.action { - GrantState::Grant => drop(user.permissions.0.insert(perm.clone(), true)), - GrantState::Revoke => drop(user.permissions.0.insert(perm.clone(), false)), - GrantState::Unset => drop(user.permissions.0.remove(&perm)), - } - Ok(()) - })?; - - manage_single_user( - database, - Some(Ok("Permissions update".into())), - form.name.clone(), - ) -} - -#[post("/admin/remove_user", data = "")] -pub fn r_admin_remove_user( - session: AdminSession, - database: &State, - form: Form, -) -> MyResult> { - drop(session); - if !database.delete_user(&form.name)? { - Err(anyhow!("user did not exist"))?; - } - user_management(database, Some(Ok("User removed".into()))) -} diff --git a/server/src/routes/ui/assets.rs b/server/src/routes/ui/assets.rs deleted file mode 100644 index c661771..0000000 --- a/server/src/routes/ui/assets.rs +++ /dev/null @@ -1,200 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use crate::routes::ui::{account::session::Session, error::MyResult, CacheControlFile}; -use anyhow::{anyhow, bail, Context}; -use base64::Engine; -use jellybase::{ - assetfed::AssetInner, cache::async_cache_file, database::Database, federation::Federation, CONF, -}; -use jellycommon::{LocalTrack, NodeID, PeopleGroup, SourceTrackKind, TrackSource}; -use log::info; -use rocket::{get, http::ContentType, response::Redirect, State}; -use std::{path::PathBuf, str::FromStr}; - -pub const AVIF_QUALITY: f32 = 50.; -pub const AVIF_SPEED: u8 = 5; - -#[get("/asset/?")] -pub async fn r_asset( - _session: Session, - fed: &State, - token: &str, - width: Option, -) -> MyResult<(ContentType, CacheControlFile)> { - let width = width.unwrap_or(2048); - let asset = AssetInner::deser(token)?; - - let path = if let AssetInner::Federated { host, asset } = asset { - let session = fed.get_session(&host).await?; - - let asset = base64::engine::general_purpose::URL_SAFE.encode(asset); - async_cache_file("fed-asset", &asset, |out| async { - session.asset(out, &asset, width).await - }) - .await? - } else { - let source = resolve_asset(asset).await.context("resolving asset")?; - - // fit the resolution into a finite set so the maximum cache is finite too. - let width = 2usize.pow(width.clamp(128, 2048).ilog2()); - jellytranscoder::image::transcode(&source, AVIF_QUALITY, AVIF_SPEED, width) - .await - .context("transcoding asset")? - }; - info!("loading asset from {path:?}"); - Ok(( - ContentType::AVIF, - CacheControlFile::new_cachekey(&path.abs()).await?, - )) -} - -pub async fn resolve_asset(asset: AssetInner) -> anyhow::Result { - match asset { - AssetInner::Cache(c) => Ok(c.abs()), - AssetInner::Assets(c) => Ok(CONF.asset_path.join(c)), - AssetInner::Media(c) => Ok(CONF.media_path.join(c)), - _ => bail!("wrong asset type"), - } -} - -#[get("/n//poster?")] -pub async fn r_item_poster( - _session: Session, - db: &State, - id: NodeID, - width: Option, -) -> MyResult { - // TODO perm - let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; - - let mut asset = node.poster.clone(); - if asset.is_none() { - if let Some(parent) = node.parents.last().copied() { - let parent = db.get_node(parent)?.ok_or(anyhow!("node does not exist"))?; - asset = parent.poster.clone(); - } - }; - let asset = asset.unwrap_or_else(|| { - AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser() - }); - Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) -} - -#[get("/n//backdrop?")] -pub async fn r_item_backdrop( - _session: Session, - db: &State, - id: NodeID, - width: Option, -) -> MyResult { - // TODO perm - let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; - - let mut asset = node.backdrop.clone(); - if asset.is_none() { - if let Some(parent) = node.parents.last().copied() { - let parent = db.get_node(parent)?.ok_or(anyhow!("node does not exist"))?; - asset = parent.backdrop.clone(); - } - }; - let asset = asset.unwrap_or_else(|| { - AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser() - }); - Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) -} - -#[get("/n//person//asset?&")] -pub async fn r_person_asset( - _session: Session, - db: &State, - id: NodeID, - index: usize, - group: String, - width: Option, -) -> MyResult { - // TODO perm - - let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; - let app = node - .people - .get(&PeopleGroup::from_str(&group).map_err(|()| anyhow!("unknown people group"))?) - .ok_or(anyhow!("group has no members"))? - .get(index) - .ok_or(anyhow!("person does not exist"))?; - - let asset = app - .person - .headshot - .to_owned() - .unwrap_or(AssetInner::Assets("fallback-Person.avif".into()).ser()); - Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) -} - -// TODO this can create "federation recursion" because track selection cannot be relied on. -//? TODO is this still relevant? - -#[get("/n//thumbnail?&")] -pub async fn r_node_thumbnail( - _session: Session, - db: &State, - fed: &State, - id: NodeID, - t: f64, - width: Option, -) -> MyResult { - let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; - - let media = node.media.as_ref().ok_or(anyhow!("no media"))?; - let (thumb_track_index, thumb_track) = media - .tracks - .iter() - .enumerate() - .find(|(_i, t)| matches!(t.kind, SourceTrackKind::Video { .. })) - .ok_or(anyhow!("no video track to create a thumbnail of"))?; - let source = media - .tracks - .get(thumb_track_index) - .ok_or(anyhow!("no source"))?; - let thumb_track_source = source.source.clone(); - - if t < 0. || t > media.duration { - Err(anyhow!("thumbnail instant not within media duration"))? - } - - let step = 8.; - let t = (t / step).floor() * step; - - let asset = match thumb_track_source { - TrackSource::Local(a) => { - let AssetInner::LocalTrack(LocalTrack { path, .. }) = AssetInner::deser(&a.0)? else { - return Err(anyhow!("track set to wrong asset type").into()); - }; - // the track selected might be different from thumb_track - jellytranscoder::thumbnail::create_thumbnail(&CONF.media_path.join(path), t).await? - } - TrackSource::Remote(_) => { - // TODO in the new system this is preferrably a property of node ext for regular fed - let session = fed - .get_session( - thumb_track - .federated - .last() - .ok_or(anyhow!("federation broken"))?, - ) - .await?; - - async_cache_file("fed-thumb", (id, t as i64), |out| { - session.node_thumbnail(out, id.into(), 2048, t) - }) - .await? - } - }; - - Ok(Redirect::temporary(rocket::uri!(r_asset( - AssetInner::Cache(asset).ser().0, - width - )))) -} diff --git a/server/src/routes/ui/browser.rs b/server/src/routes/ui/browser.rs deleted file mode 100644 index 96c005d..0000000 --- a/server/src/routes/ui/browser.rs +++ /dev/null @@ -1,83 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::{ - account::session::Session, - error::MyError, - layout::{trs, DynLayoutPage, LayoutPage}, - node::NodeCard, - sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty}, -}; -use crate::{ - database::Database, - routes::{api::AcceptJson, locale::AcceptLanguage}, - uri, -}; -use jellybase::locale::tr; -use jellycommon::{api::ApiItemsResponse, Visibility}; -use rocket::{get, serde::json::Json, Either, State}; - -/// This function is a stub and only useful for use in the uri! macro. -#[get("/items")] -pub fn r_all_items() {} - -#[get("/items?&")] -pub fn r_all_items_filter( - sess: Session, - db: &State, - aj: AcceptJson, - page: Option, - filter: NodeFilterSort, - lang: AcceptLanguage, -) -> Result, Json>, MyError> { - let AcceptLanguage(lang) = lang; - let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?; - - items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible)); - - filter_and_sort_nodes( - &filter, - (SortProperty::Title, SortOrder::Ascending), - &mut items, - ); - - let page_size = 100; - let page = page.unwrap_or(0); - let offset = page * page_size; - let from = offset.min(items.len()); - let to = (offset + page_size).min(items.len()); - let max_page = items.len().div_ceil(page_size); - - Ok(if *aj { - Either::Right(Json(ApiItemsResponse { - count: items.len(), - pages: max_page, - items: items[from..to].to_vec(), - })) - } else { - Either::Left(LayoutPage { - title: "All Items".to_owned(), - content: markup::new! { - .page.dir { - h1 { "All Items" } - @NodeFilterSortForm { f: &filter, lang: &lang } - ul.children { @for (node, udata) in &items[from..to] { - li {@NodeCard { node, udata, lang: &lang }} - }} - p.pagecontrols { - span.current { @tr(lang, "page.curr").replace("{cur}", &(page + 1).to_string()).replace("{max}", &max_page.to_string()) " " } - @if page > 0 { - a.prev[href=uri!(r_all_items_filter(Some(page - 1), filter.clone()))] { @trs(&lang, "page.prev") } " " - } - @if page + 1 < max_page { - a.next[href=uri!(r_all_items_filter(Some(page + 1), filter.clone()))] { @trs(&lang, "page.next") } - } - } - } - }, - ..Default::default() - }) - }) -} diff --git a/server/src/routes/ui/error.rs b/server/src/routes/ui/error.rs deleted file mode 100644 index ee593a2..0000000 --- a/server/src/routes/ui/error.rs +++ /dev/null @@ -1,104 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::layout::{DynLayoutPage, LayoutPage}; -use crate::{routes::ui::account::rocket_uri_macro_r_account_login, uri}; -use jellybase::CONF; -use log::info; -use rocket::{ - catch, - http::{ContentType, Status}, - response::{self, Responder}, - Request, -}; -use serde_json::{json, Value}; -use std::{fmt::Display, fs::File, io::Read, sync::LazyLock}; - -static ERROR_IMAGE: LazyLock> = LazyLock::new(|| { - info!("loading error image"); - let mut f = File::open(CONF.asset_path.join("error.avif")) - .expect("please create error.avif in the asset dir"); - let mut o = Vec::new(); - f.read_to_end(&mut o).unwrap(); - o -}); - -#[catch(default)] -pub fn r_catch<'a>(status: Status, _request: &Request) -> DynLayoutPage<'a> { - LayoutPage { - title: "Not found".to_string(), - content: markup::new! { - h2 { "Error" } - p { @format!("{status}") } - @if status == Status::NotFound { - p { "You might need to " a[href=uri!(r_account_login())] { "log in" } ", to see this page" } - } - }, - ..Default::default() - } -} - -#[catch(default)] -pub fn r_api_catch(status: Status, _request: &Request) -> Value { - json!({ "error": format!("{status}") }) -} - -pub type MyResult = Result; - -// TODO an actual error enum would be useful for status codes - -pub struct MyError(pub anyhow::Error); - -impl<'r> Responder<'r, 'static> for MyError { - fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { - match req.accept().map(|a| a.preferred()) { - Some(x) if x.is_json() => json!({ "error": format!("{}", self.0) }).respond_to(req), - Some(x) if x.is_avif() || x.is_png() || x.is_jpeg() => { - (ContentType::AVIF, ERROR_IMAGE.as_slice()).respond_to(req) - } - _ => LayoutPage { - title: "Error".to_string(), - content: markup::new! { - h2 { "An error occured. Nobody is sorry"} - pre.error { @format!("{:?}", self.0) } - }, - ..Default::default() - } - .respond_to(req), - } - } -} - -impl std::fmt::Debug for MyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_fmt(format_args!("{:?}", self.0)) - } -} - -impl Display for MyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} -impl From for MyError { - fn from(err: anyhow::Error) -> MyError { - MyError(err) - } -} -impl From for MyError { - fn from(err: std::fmt::Error) -> MyError { - MyError(anyhow::anyhow!("{err}")) - } -} -impl From for MyError { - fn from(err: std::io::Error) -> Self { - MyError(anyhow::anyhow!("{err}")) - } -} -impl From for MyError { - fn from(err: serde_json::Error) -> Self { - MyError(anyhow::anyhow!("{err}")) - } -} diff --git a/server/src/routes/ui/home.rs b/server/src/routes/ui/home.rs deleted file mode 100644 index 8f8a876..0000000 --- a/server/src/routes/ui/home.rs +++ /dev/null @@ -1,180 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::{ - account::session::Session, - layout::{trs, LayoutPage}, - node::{DatabaseNodeUserDataExt, NodeCard}, -}; -use crate::{ - database::Database, - routes::{ - api::AcceptJson, - locale::AcceptLanguage, - ui::{error::MyResult, layout::DynLayoutPage}, - }, -}; -use anyhow::Context; -use chrono::{Datelike, Utc}; -use jellybase::{locale::tr, CONF}; -use jellycommon::{api::ApiHomeResponse, user::WatchedState, NodeID, NodeKind, Rating, Visibility}; -use rocket::{get, serde::json::Json, Either, State}; - -#[get("/home")] -pub fn r_home( - sess: Session, - db: &State, - aj: AcceptJson, - lang: AcceptLanguage, -) -> MyResult>> { - 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::>>()?; - toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX)); - - let mut categories = Vec::<(String, Vec<_>)>::new(); - - categories.push(( - "home.bin.continue_watching".to_string(), - items - .iter() - .filter(|(_, u)| matches!(u.watched, WatchedState::Progress(_))) - .cloned() - .collect(), - )); - categories.push(( - "home.bin.watchlist".to_string(), - items - .iter() - .filter(|(_, u)| matches!(u.watched, WatchedState::Pending)) - .cloned() - .collect(), - )); - - items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible)); - - items.sort_by_key(|(n, _)| n.release_date.map(|d| -d).unwrap_or(i64::MAX)); - - categories.push(( - "home.bin.latest_video".to_string(), - items - .iter() - .filter(|(n, _)| matches!(n.kind, NodeKind::Video)) - .take(16) - .cloned() - .collect(), - )); - categories.push(( - "home.bin.latest_music".to_string(), - items - .iter() - .filter(|(n, _)| matches!(n.kind, NodeKind::Music)) - .take(16) - .cloned() - .collect(), - )); - categories.push(( - "home.bin.latest_short_form".to_string(), - items - .iter() - .filter(|(n, _)| matches!(n.kind, NodeKind::ShortFormVideo)) - .take(16) - .cloned() - .collect(), - )); - - items.sort_by_key(|(n, _)| { - n.ratings - .get(&Rating::Tmdb) - .map(|x| (*x * -1000.) as i32) - .unwrap_or(0) - }); - - categories.push(( - "home.bin.max_rating".to_string(), - items - .iter() - .take(16) - .filter(|(n, _)| n.ratings.contains_key(&Rating::Tmdb)) - .cloned() - .collect(), - )); - - items.retain(|(n, _)| { - matches!( - n.kind, - NodeKind::Video | NodeKind::Movie | NodeKind::Episode | NodeKind::Music - ) - }); - - categories.push(( - "home.bin.daily_random".to_string(), - (0..16) - .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) - .collect(), - )); - - { - let mut items = items.clone(); - items.retain(|(_, u)| matches!(u.watched, WatchedState::Watched)); - categories.push(( - "home.bin.watch_again".to_string(), - (0..16) - .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) - .collect(), - )); - } - - items.retain(|(n, _)| matches!(n.kind, NodeKind::Music)); - categories.push(( - "home.bin.daily_random_music".to_string(), - (0..16) - .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) - .collect(), - )); - - Ok(if *aj { - Either::Right(Json(ApiHomeResponse { - toplevel, - categories, - })) - } else { - Either::Left(LayoutPage { - title: tr(lang, "home").to_string(), - content: markup::new! { - h2 { @tr(lang, "home.bin.root").replace("{title}", &CONF.brand) } - ul.children.hlist {@for (node, udata) in &toplevel { - li { @NodeCard { node, udata, lang: &lang } } - }} - @for (name, nodes) in &categories { - @if !nodes.is_empty() { - h2 { @trs(&lang, &name) } - ul.children.hlist {@for (node, udata) in nodes { - li { @NodeCard { node, udata, lang: &lang } } - }} - } - } - }, - ..Default::default() - }) - }) -} - -fn cheap_daily_random(i: usize) -> usize { - xorshift(xorshift(Utc::now().num_days_from_ce() as u64) + i as u64) as usize -} - -fn xorshift(mut x: u64) -> u64 { - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - x -} diff --git a/server/src/routes/ui/layout.rs b/server/src/routes/ui/layout.rs deleted file mode 100644 index 0a0d036..0000000 --- a/server/src/routes/ui/layout.rs +++ /dev/null @@ -1,184 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use crate::{ - routes::{ - locale::lang_from_request, - ui::{ - account::{ - rocket_uri_macro_r_account_login, rocket_uri_macro_r_account_logout, - rocket_uri_macro_r_account_register, session::Session, - settings::rocket_uri_macro_r_account_settings, - }, - admin::rocket_uri_macro_r_admin_dashboard, - browser::rocket_uri_macro_r_all_items, - node::rocket_uri_macro_r_library_node, - search::rocket_uri_macro_r_search, - stats::rocket_uri_macro_r_stats, - }, - }, - uri, -}; -use futures::executor::block_on; -use jellybase::{ - locale::{tr, Language}, - CONF, -}; -use jellycommon::user::Theme; -use jellycommon::NodeID; -use jellyimport::is_importing; -use markup::{raw, DynRender, Render, RenderAttributeValue}; -use rocket::{ - http::ContentType, - response::{self, Responder}, - Request, Response, -}; -use std::{borrow::Cow, io::Cursor, sync::LazyLock}; - -static LOGO_ENABLED: LazyLock = LazyLock::new(|| CONF.asset_path.join("logo.svg").exists()); - -pub struct TrString<'a>(Cow<'a, str>); -impl Render for TrString<'_> { - fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { - self.0.as_str().render(writer) - } -} -impl RenderAttributeValue for TrString<'_> { - fn is_none(&self) -> bool { - false - } - fn is_true(&self) -> bool { - false - } - fn is_false(&self) -> bool { - false - } -} - -pub fn escape(str: &str) -> String { - let mut o = String::with_capacity(str.len()); - let mut last = 0; - for (index, byte) in str.bytes().enumerate() { - if let Some(esc) = match byte { - b'<' => Some("<"), - b'>' => Some(">"), - b'&' => Some("&"), - b'"' => Some("""), - _ => None, - } { - o += &str[last..index]; - o += esc; - last = index + 1; - } - } - o += &str[last..]; - o -} - -pub fn trs<'a>(lang: &Language, key: &str) -> TrString<'a> { - TrString(tr(*lang, key)) -} - -markup::define! { - Layout<'a, Main: Render>(title: String, main: Main, class: &'a str, session: Option, lang: Language) { - @markup::doctype() - html { - head { - title { @title " - " @CONF.brand } - meta[name="viewport", content="width=device-width, initial-scale=1.0"]; - link[rel="stylesheet", href="/assets/style.css"]; - script[src="/assets/bundle.js"] {} - } - body[class=class] { - nav { - h1 { a[href=if session.is_some() {"/home"} else {"/"}] { @if *LOGO_ENABLED { img.logo[src="/assets/logo.svg"]; } else { @CONF.brand } } } " " - @if let Some(_) = session { - a.library[href=uri!(r_library_node("library"))] { @trs(lang, "nav.root") } " " - a.library[href=uri!(r_all_items())] { @trs(lang, "nav.all") } " " - a.library[href=uri!(r_search(None::<&'static str>, None::))] { @trs(lang, "nav.search") } " " - a.library[href=uri!(r_stats())] { @trs(lang, "nav.stats") } " " - } - @if is_importing() { span.warn { "Library database is updating..." } } - div.account { - @if let Some(session) = session { - span { @raw(tr(*lang, "nav.username").replace("{name}", &format!("{}", escape(&session.user.display_name)))) } " " - @if session.user.admin { - a.admin.hybrid_button[href=uri!(r_admin_dashboard())] { p {@trs(lang, "nav.admin")} } " " - } - a.settings.hybrid_button[href=uri!(r_account_settings())] { p {@trs(lang, "nav.settings")} } " " - a.logout.hybrid_button[href=uri!(r_account_logout())] { p {@trs(lang, "nav.logout")} } - } else { - a.register.hybrid_button[href=uri!(r_account_register())] { p {@trs(lang, "nav.register")} } " " - a.login.hybrid_button[href=uri!(r_account_login())] { p {@trs(lang, "nav.login")} } - } - } - } - #main { @main } - footer { - p { @CONF.brand " - " @CONF.slogan " | powered by " a[href="https://codeberg.org/metamuffin/jellything"]{"Jellything"} } - } - } - } - } - - FlashDisplay(flash: Option>) { - @if let Some(flash) = &flash { - @match flash { - Ok(mesg) => { section.message { p.success { @mesg } } } - Err(err) => { section.message { p.error { @err } } } - } - } - } -} - -pub type DynLayoutPage<'a> = LayoutPage>; - -pub struct LayoutPage { - pub title: String, - pub class: Option<&'static str>, - pub content: T, -} - -impl Default for LayoutPage> { - fn default() -> Self { - Self { - class: None, - content: markup::new!(), - title: String::new(), - } - } -} - -impl<'r, Main: Render> Responder<'r, 'static> for LayoutPage
{ - 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::>()).unwrap(); - let mut out = String::new(); - Layout { - main: self.content, - title: self.title, - class: &format!( - "{} theme-{:?}", - self.class.unwrap_or(""), - session - .as_ref() - .map(|s| s.user.theme) - .unwrap_or(Theme::Dark) - ), - session, - lang, - } - .render(&mut out) - .unwrap(); - - Response::build() - .header(ContentType::HTML) - .streamed_body(Cursor::new(out)) - .ok() - } -} diff --git a/server/src/routes/ui/mod.rs b/server/src/routes/ui/mod.rs deleted file mode 100644 index d61ef9e..0000000 --- a/server/src/routes/ui/mod.rs +++ /dev/null @@ -1,131 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use account::session::Session; -use error::MyResult; -use home::rocket_uri_macro_r_home; -use jellybase::CONF; -use layout::{DynLayoutPage, LayoutPage}; -use log::debug; -use markup::Render; -use rocket::{ - futures::FutureExt, - get, - http::{ContentType, Header, Status}, - response::{self, Redirect, Responder}, - Either, Request, Response, -}; -use std::{ - collections::hash_map::DefaultHasher, - future::Future, - hash::{Hash, Hasher}, - io::Cursor, - os::unix::prelude::MetadataExt, - path::Path, - pin::Pin, -}; -use tokio::{ - fs::{read_to_string, File}, - io::AsyncRead, -}; - -pub mod account; -pub mod admin; -pub mod assets; -pub mod browser; -pub mod error; -pub mod home; -pub mod layout; -pub mod node; -pub mod player; -pub mod search; -pub mod sort; -pub mod stats; -pub mod style; - -#[get("/")] -pub async fn r_index(sess: Option) -> MyResult>> { - if sess.is_some() { - Ok(Either::Left(Redirect::temporary(rocket::uri!(r_home())))) - } else { - let front = read_to_string(CONF.asset_path.join("front.htm")).await?; - Ok(Either::Right(LayoutPage { - title: "Home".to_string(), - content: markup::new! { - @markup::raw(&front) - }, - ..Default::default() - })) - } -} - -pub struct HtmlTemplate<'a>(pub markup::DynRender<'a>); - -impl<'r> Responder<'r, 'static> for HtmlTemplate<'_> { - fn respond_to(self, _req: &'r Request<'_>) -> response::Result<'static> { - let mut out = String::new(); - self.0.render(&mut out).unwrap(); - Response::build() - .header(ContentType::HTML) - .sized_body(out.len(), Cursor::new(out)) - .ok() - } -} - -pub struct Defer(Pin + Send>>); - -impl AsyncRead for Defer { - fn poll_read( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - buf: &mut tokio::io::ReadBuf<'_>, - ) -> std::task::Poll> { - match self.0.poll_unpin(cx) { - std::task::Poll::Ready(r) => { - buf.put_slice(r.as_bytes()); - std::task::Poll::Ready(Ok(())) - } - std::task::Poll::Pending => std::task::Poll::Pending, - } - } -} - -pub struct CacheControlFile(File, String); -impl CacheControlFile { - pub async fn new_cachekey(p: &Path) -> anyhow::Result { - let tag = p.file_name().unwrap().to_str().unwrap().to_owned(); - let f = File::open(p).await?; - Ok(Self(f, tag)) - } - pub async fn new_mtime(f: File) -> Self { - let meta = f.metadata().await.unwrap(); - let modified = meta.mtime(); - let mut h = DefaultHasher::new(); - modified.hash(&mut h); - let tag = format!("{:0>16x}", h.finish()); - Self(f, tag) - } -} -impl<'r> Responder<'r, 'static> for CacheControlFile { - fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { - let Self(file, tag) = self; - if req.headers().get_one("if-none-match") == Some(&tag) { - debug!("file cache: not modified"); - Response::build() - .status(Status::NotModified) - .header(Header::new("cache-control", "private")) - .header(Header::new("etag", tag)) - .ok() - } else { - debug!("file cache: transfer"); - Response::build() - .status(Status::Ok) - .header(Header::new("cache-control", "private")) - .header(Header::new("etag", tag)) - .streamed_body(file) - .ok() - } - } -} diff --git a/server/src/routes/ui/node.rs b/server/src/routes/ui/node.rs deleted file mode 100644 index d968f0a..0000000 --- a/server/src/routes/ui/node.rs +++ /dev/null @@ -1,558 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::{ - assets::{ - rocket_uri_macro_r_item_backdrop, rocket_uri_macro_r_item_poster, - rocket_uri_macro_r_node_thumbnail, - }, - error::MyResult, - layout::{trs, TrString}, - sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty}, -}; -use crate::{ - database::Database, - routes::{ - api::AcceptJson, - locale::AcceptLanguage, - ui::{ - account::session::Session, - assets::rocket_uri_macro_r_person_asset, - layout::{DynLayoutPage, LayoutPage}, - player::{rocket_uri_macro_r_player, PlayerConfig}, - }, - userdata::{ - rocket_uri_macro_r_node_userdata_rating, rocket_uri_macro_r_node_userdata_watched, - UrlWatchedState, - }, - }, - uri, -}; -use anyhow::{anyhow, Result}; -use chrono::DateTime; -use jellybase::locale::{tr, Language}; -use jellycommon::{ - api::ApiNodeResponse, - user::{NodeUserData, WatchedState}, - Chapter, MediaInfo, Node, NodeID, NodeKind, PeopleGroup, Rating, SourceTrackKind, Visibility, -}; -use rocket::{get, serde::json::Json, Either, State}; -use std::{cmp::Reverse, collections::BTreeMap, fmt::Write, sync::Arc}; - -/// This function is a stub and only useful for use in the uri! macro. -#[get("/n/")] -pub fn r_library_node(id: NodeID) { - let _ = id; -} - -#[get("/n/?&&")] -pub async fn r_library_node_filter<'a>( - session: Session, - id: NodeID, - db: &'a State, - aj: AcceptJson, - filter: NodeFilterSort, - lang: AcceptLanguage, - parents: bool, - children: bool, -) -> MyResult, Json>> { - let AcceptLanguage(lang) = lang; - let (node, udata) = db.get_node_with_userdata(id, &session)?; - - let mut children = if !*aj || children { - db.get_node_children(id)? - .into_iter() - .map(|c| db.get_node_with_userdata(c, &session)) - .collect::>>()? - } else { - Vec::new() - }; - - let mut parents = if !*aj || parents { - node.parents - .iter() - .map(|pid| db.get_node_with_userdata(*pid, &session)) - .collect::>>()? - } else { - Vec::new() - }; - - let mut similar = get_similar_media(&node, db, &session)?; - - similar.retain(|(n, _)| n.visibility >= Visibility::Reduced); - children.retain(|(n, _)| n.visibility >= Visibility::Reduced); - parents.retain(|(n, _)| n.visibility >= Visibility::Reduced); - - filter_and_sort_nodes( - &filter, - match node.kind { - NodeKind::Channel => (SortProperty::ReleaseDate, SortOrder::Descending), - NodeKind::Season | NodeKind::Show => (SortProperty::Index, SortOrder::Ascending), - _ => (SortProperty::Title, SortOrder::Ascending), - }, - &mut children, - ); - - Ok(if *aj { - Either::Right(Json(ApiNodeResponse { - children, - parents, - node, - userdata: udata, - })) - } else { - Either::Left(LayoutPage { - title: node.title.clone().unwrap_or_default(), - content: markup::new!(@NodePage { - node: &node, - udata: &udata, - children: &children, - parents: &parents, - filter: &filter, - player: false, - similar: &similar, - lang: &lang, - }), - ..Default::default() - }) - }) -} - -pub fn get_similar_media( - node: &Node, - db: &Database, - session: &Session, -) -> Result, NodeUserData)>> { - let this_id = NodeID::from_slug(&node.slug); - let mut ranking = BTreeMap::::new(); - for tag in &node.tags { - let nodes = db.get_tag_nodes(tag)?; - let weight = 1_000_000 / nodes.len(); - for n in nodes { - if n != this_id { - *ranking.entry(n).or_default() += weight; - } - } - } - let mut ranking = ranking.into_iter().collect::>(); - ranking.sort_by_key(|(_, k)| Reverse(*k)); - ranking - .into_iter() - .take(32) - .map(|(pid, _)| db.get_node_with_userdata(pid, session)) - .collect::>>() -} - -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, NodeUserData)], - parents: &'a [(Arc, NodeUserData)], - similar: &'a [(Arc, NodeUserData)], - filter: &'a NodeFilterSort, - lang: &'a Language, - player: bool, - ) { - @if !matches!(node.kind, NodeKind::Collection) && !player { - img.backdrop[src=uri!(r_item_backdrop(&node.slug, Some(2048))), loading="lazy"]; - } - .page.node { - @if !matches!(node.kind, NodeKind::Collection) && !player { - @let cls = format!("bigposter {}", aspect_class(node.kind)); - div[class=cls] { img[src=uri!(r_item_poster(&node.slug, Some(2048))), loading="lazy"]; } - } - .title { - h1 { @node.title } - ul.parents { @for (node, _) in *parents { li { - a.component[href=uri!(r_library_node(&node.slug))] { @node.title } - }}} - @if node.media.is_some() { - a.play[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { @trs(lang, "node.player_link") } - } - @if !matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { - @if matches!(udata.watched, WatchedState::None | WatchedState::Pending | WatchedState::Progress(_)) { - form.mark_watched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Watched))] { - input[type="submit", value=trs(lang, "node.watched.set")]; - } - } - @if matches!(udata.watched, WatchedState::Watched) { - form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] { - input[type="submit", value=trs(lang, "node.watched.unset")]; - } - } - @if matches!(udata.watched, WatchedState::None) { - form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Pending))] { - input[type="submit", value=trs(lang, "node.watchlist.set")]; - } - } - @if matches!(udata.watched, WatchedState::Pending) { - form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] { - input[type="submit", value=trs(lang, "node.watchlist.unset")]; - } - } - form.rating[method="POST", action=uri!(r_node_userdata_rating(&node.slug))] { - input[type="range", name="rating", min=-10, max=10, step=1, value=udata.rating]; - input[type="submit", value=trs(lang, "node.update_rating")]; - } - } - } - .details { - @Props { node, udata, full: true, lang } - h3 { @node.tagline } - @if let Some(description) = &node.description { - p { @for line in description.lines() { @line br; } } - } - @if let Some(media) = &node.media { - @if !media.chapters.is_empty() { - h2 { @trs(lang, "node.chapters") } - ul.children.hlist { @for chap in &media.chapters { - @let (inl, sub) = format_chapter(chap); - li { .card."aspect-thumb" { - .poster { - a[href=&uri!(r_player(&node.slug, PlayerConfig::seek(chap.time_start.unwrap_or(0.))))] { - img[src=&uri!(r_node_thumbnail(&node.slug, chapter_key_time(chap, media.duration), Some(1024))), loading="lazy"]; - } - .cardhover { .props { p { @inl } } } - } - .title { span { @sub } } - }} - }} - } - @if !node.people.is_empty() { - h2 { @trs(lang, "node.people") } - @for (group, people) in &node.people { - details[open=group==&PeopleGroup::Cast] { - summary { h3 { @format!("{}", group) } } - ul.children.hlist { @for (i, pe) in people.iter().enumerate() { - li { .card."aspect-port" { - .poster { - a[href="#"] { - img[src=&uri!(r_person_asset(&node.slug, i, group.to_string(), Some(1024))), loading="lazy"]; - } - } - .title { - span { @pe.person.name } br; - @if let Some(c) = pe.characters.first() { - span.subtitle { @c } - } - @if let Some(c) = pe.jobs.first() { - span.subtitle { @c } - } - } - }} - }} - } - } - } - details { - summary { @trs(lang, "media.tracks") } - ol { @for track in &media.tracks { - li { @format!("{track}") } - }} - } - } - @if !node.external_ids.is_empty() { - details { - summary { @trs(lang, "node.external_ids") } - table { - @for (key, value) in &node.external_ids { tr { - tr { - td { @trs(lang, &format!("eid.{}", key)) } - @if let Some(url) = external_id_url(key, value) { - td { a[href=url] { pre { @value } } } - } else { - td { pre { @value } } - } - } - }} - } - } - } - @if !node.tags.is_empty() { - details { - summary { @trs(lang, "node.tags") } - ol { @for tag in &node.tags { - li { @tag } - }} - } - } - } - @if matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { - @NodeFilterSortForm { f: filter, lang } - } - @if !similar.is_empty() { - h2 { @trs(lang, "node.similar") } - ul.children.hlist {@for (node, udata) in similar.iter() { - li { @NodeCard { node, udata, lang } } - }} - } - @match node.kind { - NodeKind::Show | NodeKind::Series | NodeKind::Season => { - ol { @for (node, udata) in children.iter() { - li { @NodeCardWide { node, udata, lang } } - }} - } - NodeKind::Collection | NodeKind::Channel | _ => { - ul.children {@for (node, udata) in children.iter() { - li { @NodeCard { node, udata, lang } } - }} - } - } - } - } - - Props<'a>(node: &'a Node, udata: &'a NodeUserData, full: bool, lang: &'a Language) { - .props { - @if let Some(m) = &node.media { - p { @format_duration(m.duration) } - p { @m.resolution_name() } - } - @if let Some(d) = &node.release_date { - p { @if *full { - @DateTime::from_timestamp_millis(*d).unwrap().naive_utc().to_string() - } else { - @DateTime::from_timestamp_millis(*d).unwrap().date_naive().to_string() - }} - } - @match node.visibility { - Visibility::Visible => {} - Visibility::Reduced => {p.visibility{@trs(lang, "prop.vis.reduced")}} - Visibility::Hidden => {p.visibility{@trs(lang, "prop.vis.hidden")}} - } - // TODO - // @if !node.children.is_empty() { - // p { @format!("{} items", node.children.len()) } - // } - @for (kind, value) in &node.ratings { - @match kind { - Rating::YoutubeLikes => {p.likes{ @format_count(*value as usize) " Likes" }} - Rating::YoutubeViews => {p{ @format_count(*value as usize) " Views" }} - Rating::YoutubeFollowers => {p{ @format_count(*value as usize) " Subscribers" }} - Rating::RottenTomatoes => {p.rating{ @value " Tomatoes" }} - Rating::Metacritic if *full => {p{ "Metacritic Score: " @value }} - Rating::Imdb => {p.rating{ "IMDb " @value }} - Rating::Tmdb => {p.rating{ "TMDB " @value }} - Rating::Trakt if *full => {p.rating{ "Trakt " @value }} - _ => {} - } - } - @if let Some(f) = &node.federated { - p.federation { @f } - } - @match udata.watched { - WatchedState::None => {} - WatchedState::Pending => { p.pending { @trs(lang, "prop.watched.pending") } } - WatchedState::Progress(x) => { p.progress { @tr(**lang, "prop.watched.progress").replace("{time}", &format_duration(x)) } } - WatchedState::Watched => { p.watched { @trs(lang, "prop.watched.watched") } } - } - } - } -} - -pub fn aspect_class(kind: NodeKind) -> &'static str { - use NodeKind::*; - match kind { - Video | Episode => "aspect-thumb", - Collection => "aspect-land", - Season | Show | Series | Movie | ShortFormVideo => "aspect-port", - Channel | Music | Unknown => "aspect-square", - } -} - -pub fn format_duration(d: f64) -> String { - format_duration_mode(d, false, Language::English) -} -pub fn format_duration_long(d: f64, lang: Language) -> String { - format_duration_mode(d, true, lang) -} -fn format_duration_mode(mut d: f64, long_units: bool, lang: Language) -> String { - let mut s = String::new(); - let sign = if d > 0. { "" } else { "-" }; - d = d.abs(); - for (short, long, long_pl, k) in [ - ("d", "time.day", "time.days", 60. * 60. * 24.), - ("h", "time.hour", "time.hours", 60. * 60.), - ("m", "time.minute", "time.minutes", 60.), - ("s", "time.second", "time.seconds", 1.), - ] { - let h = (d / k).floor(); - d -= h * k; - if h > 0. { - if long_units { - let long = tr(lang, if h != 1. { long_pl } else { long }); - let and = format!(" {} ", tr(lang, "time.and_join")); - // TODO breaks if seconds is zero - write!( - s, - "{}{h} {long}{}", - if k != 1. { "" } else { &and }, - if k > 60. { ", " } else { "" }, - ) - .unwrap(); - } else { - write!(s, "{h}{short} ").unwrap(); - } - } - } - format!("{sign}{}", s.trim()) -} -pub fn format_size(size: u64) -> String { - humansize::format_size(size, humansize::DECIMAL) -} -pub fn format_kind(k: NodeKind, lang: Language) -> TrString<'static> { - trs( - &lang, - match k { - NodeKind::Unknown => "kind.unknown", - NodeKind::Movie => "kind.movie", - NodeKind::Video => "kind.video", - NodeKind::Music => "kind.music", - NodeKind::ShortFormVideo => "kind.short_form_video", - NodeKind::Collection => "kind.collection", - NodeKind::Channel => "kind.channel", - NodeKind::Show => "kind.show", - NodeKind::Series => "kind.series", - NodeKind::Season => "kind.season", - NodeKind::Episode => "kind.episode", - }, - ) -} - -pub trait DatabaseNodeUserDataExt { - fn get_node_with_userdata( - &self, - id: NodeID, - session: &Session, - ) -> Result<(Arc, NodeUserData)>; -} -impl DatabaseNodeUserDataExt for Database { - fn get_node_with_userdata( - &self, - id: NodeID, - session: &Session, - ) -> Result<(Arc, NodeUserData)> { - Ok(( - self.get_node(id)?.ok_or(anyhow!("node does not exist"))?, - self.get_node_udata(id, &session.user.name)? - .unwrap_or_default(), - )) - } -} - -trait MediaInfoExt { - fn resolution_name(&self) -> &'static str; -} -impl MediaInfoExt for MediaInfo { - fn resolution_name(&self) -> &'static str { - let mut maxdim = 0; - for t in &self.tracks { - if let SourceTrackKind::Video { width, height, .. } = &t.kind { - maxdim = maxdim.max(*width.max(height)) - } - } - - match maxdim { - 30720.. => "32K", - 15360.. => "16K", - 7680.. => "8K UHD", - 5120.. => "5K UHD", - 3840.. => "4K UHD", - 2560.. => "QHD 1440p", - 1920.. => "FHD 1080p", - 1280.. => "HD 720p", - 854.. => "SD 480p", - _ => "Unkown", - } - } -} - -fn format_count(n: impl Into) -> 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 { - Some(match key { - "youtube.video" => format!("https://youtube.com/watch?v={value}"), - "youtube.channel" => format!("https://youtube.com/channel/{value}"), - "youtube.channelname" => format!("https://youtube.com/channel/@{value}"), - "musicbrainz.release" => format!("https://musicbrainz.org/release/{value}"), - "musicbrainz.albumartist" => format!("https://musicbrainz.org/artist/{value}"), - "musicbrainz.artist" => format!("https://musicbrainz.org/artist/{value}"), - "musicbrainz.releasegroup" => format!("https://musicbrainz.org/release-group/{value}"), - "musicbrainz.recording" => format!("https://musicbrainz.org/recording/{value}"), - _ => return None, - }) -} diff --git a/server/src/routes/ui/player.rs b/server/src/routes/ui/player.rs deleted file mode 100644 index 2bb439b..0000000 --- a/server/src/routes/ui/player.rs +++ /dev/null @@ -1,200 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::{ - account::session::{token, Session}, - layout::LayoutPage, - node::{get_similar_media, DatabaseNodeUserDataExt, NodePage}, - sort::NodeFilterSort, -}; -use crate::{ - database::Database, - routes::{ - locale::AcceptLanguage, - ui::{error::MyResult, layout::DynLayoutPage}, - }, -}; -use anyhow::anyhow; -use jellybase::CONF; -use jellycommon::{ - stream::{StreamContainer, StreamSpec}, - user::{PermissionSet, PlayerKind}, - Node, NodeID, SourceTrackKind, TrackID, Visibility, -}; -use markup::DynRender; -use rocket::{get, response::Redirect, Either, FromForm, State, UriDisplayQuery}; -use std::sync::Arc; - -#[derive(FromForm, Default, Clone, Debug, UriDisplayQuery)] -pub struct PlayerConfig { - pub a: Option, - pub v: Option, - pub s: Option, - pub t: Option, - pub kind: Option, -} - -impl PlayerConfig { - pub fn seek(t: f64) -> Self { - Self { - t: Some(t), - ..Default::default() - } - } -} - -fn jellynative_url(action: &str, seek: f64, secret: &str, node: &str, session: &str) -> String { - let protocol = if CONF.tls { "https" } else { "http" }; - let host = &CONF.hostname; - let stream_url = format!( - "/n/{node}/stream{}", - StreamSpec::HlsMultiVariant { - segment: 0, - container: StreamContainer::Matroska - } - .to_query() - ); - format!("jellynative://{action}/{secret}/{session}/{seek}/{protocol}://{host}{stream_url}",) -} - -#[get("/n//player?", rank = 4)] -pub fn r_player( - session: Session, - lang: AcceptLanguage, - db: &State, - id: NodeID, - conf: PlayerConfig, -) -> MyResult, Redirect>> { - let AcceptLanguage(lang) = lang; - let (node, udata) = db.get_node_with_userdata(id, &session)?; - - let mut parents = node - .parents - .iter() - .map(|pid| db.get_node_with_userdata(*pid, &session)) - .collect::>>()?; - - let mut similar = get_similar_media(&node, db, &session)?; - - similar.retain(|(n, _)| n.visibility >= Visibility::Reduced); - parents.retain(|(n, _)| n.visibility >= Visibility::Reduced); - - let native_session = |action: &str| { - Ok(Either::Right(Redirect::temporary(jellynative_url( - action, - conf.t.unwrap_or(0.), - &session.user.native_secret, - &id.to_string(), - &token::create( - session.user.name, - PermissionSet::default(), // TODO - chrono::Duration::hours(24), - ), - )))) - }; - - match conf.kind.unwrap_or(session.user.player_preference) { - PlayerKind::Browser => (), - PlayerKind::Native => { - return native_session("player-v2"); - } - PlayerKind::NativeFullscreen => { - return native_session("player-fullscreen-v2"); - } - } - - // TODO - // let spec = StreamSpec { - // track: None - // .into_iter() - // .chain(conf.v) - // .chain(conf.a) - // .chain(conf.s) - // .collect::>(), - // format: StreamFormat::Matroska, - // webm: Some(true), - // ..Default::default() - // }; - // let playing = false; // !spec.track.is_empty(); - // let conf = player_conf(node.clone(), playing)?; - - Ok(Either::Left(LayoutPage { - title: node.title.to_owned().unwrap_or_default(), - class: Some("player"), - content: markup::new! { - // @if playing { - // // video[src=uri!(r_stream(&node.slug, &spec)), controls, preload="auto"]{} - // } - // @conf - @NodePage { - children: &[], - parents: &parents, - filter: &NodeFilterSort::default(), - node: &node, - udata: &udata, - player: true, - similar: &similar, - lang: &lang - } - }, - })) -} - -pub fn player_conf<'a>(item: Arc, playing: bool) -> anyhow::Result> { - let mut audio_tracks = vec![]; - let mut video_tracks = vec![]; - let mut sub_tracks = vec![]; - let tracks = item - .media - .clone() - .ok_or(anyhow!("node does not have media"))? - .tracks - .clone(); - for (tid, track) in tracks.into_iter().enumerate() { - match &track.kind { - SourceTrackKind::Audio { .. } => audio_tracks.push((tid, track)), - SourceTrackKind::Video { .. } => video_tracks.push((tid, track)), - SourceTrackKind::Subtitles => sub_tracks.push((tid, track)), - } - } - - Ok(markup::new! { - form.playerconf[method = "GET", action = ""] { - h2 { "Select tracks for " @item.title } - - fieldset.video { - legend { "Video" } - @for (i, (tid, track)) in video_tracks.iter().enumerate() { - input[type="radio", id=tid, name="v", value=tid, checked=i==0]; - label[for=tid] { @format!("{track}") } br; - } - input[type="radio", id="v-none", name="v", value=""]; - label[for="v-none"] { "No video" } - } - - fieldset.audio { - legend { "Audio" } - @for (i, (tid, track)) in audio_tracks.iter().enumerate() { - input[type="radio", id=tid, name="a", value=tid, checked=i==0]; - label[for=tid] { @format!("{track}") } br; - } - input[type="radio", id="a-none", name="a", value=""]; - label[for="a-none"] { "No audio" } - } - - fieldset.subtitles { - legend { "Subtitles" } - @for (_i, (tid, track)) in sub_tracks.iter().enumerate() { - input[type="radio", id=tid, name="s", value=tid]; - label[for=tid] { @format!("{track}") } br; - } - input[type="radio", id="s-none", name="s", value="", checked=true]; - label[for="s-none"] { "No subtitles" } - } - - input[type="submit", value=if playing { "Change tracks" } else { "Start playback" }]; - } - }) -} diff --git a/server/src/routes/ui/search.rs b/server/src/routes/ui/search.rs deleted file mode 100644 index bc84e57..0000000 --- a/server/src/routes/ui/search.rs +++ /dev/null @@ -1,70 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::{ - account::session::Session, - error::MyResult, - layout::{trs, DynLayoutPage, LayoutPage}, - node::{DatabaseNodeUserDataExt, NodeCard}, -}; -use crate::routes::{api::AcceptJson, locale::AcceptLanguage}; -use anyhow::anyhow; -use jellybase::{database::Database, locale::tr}; -use jellycommon::{api::ApiSearchResponse, Visibility}; -use rocket::{get, serde::json::Json, Either, State}; -use std::time::Instant; - -#[get("/search?&")] -pub async fn r_search<'a>( - session: Session, - db: &State, - aj: AcceptJson, - query: Option<&str>, - page: Option, - lang: AcceptLanguage, -) -> MyResult, Json>> { - let AcceptLanguage(lang) = lang; - let results = if let Some(query) = query { - let timing = Instant::now(); - let (count, ids) = db.search(query, 32, page.unwrap_or_default() * 32)?; - let mut nodes = ids - .into_iter() - .map(|id| db.get_node_with_userdata(id, &session)) - .collect::, anyhow::Error>>()?; - nodes.retain(|(n, _)| n.visibility >= Visibility::Reduced); - let search_dur = timing.elapsed(); - Some((count, nodes, search_dur)) - } else { - None - }; - let query = query.unwrap_or_default().to_string(); - - Ok(if *aj { - let Some((count, results, _)) = results else { - Err(anyhow!("no query"))? - }; - Either::Right(Json(ApiSearchResponse { count, results })) - } else { - Either::Left(LayoutPage { - title: tr(lang, "search.title").to_string(), - class: Some("search"), - content: markup::new! { - h1 { @trs(&lang, "search.title") } - form[action="", method="GET"] { - input[type="text", name="query", placeholder=&*tr(lang, "search.placeholder"), value=&query]; - input[type="submit", value="Search"]; - } - @if let Some((count, results, search_dur)) = &results { - h2 { @trs(&lang, "search.results.title") } - p.stats { @tr(lang, "search.results.stats").replace("{count}", &count.to_string()).replace("{dur}", &format!("{search_dur:?}")) } - ul.children {@for (node, udata) in results.iter() { - li { @NodeCard { node, udata, lang: &lang } } - }} - // TODO pagination - } - }, - }) - }) -} diff --git a/server/src/routes/ui/sort.rs b/server/src/routes/ui/sort.rs deleted file mode 100644 index 6d38e11..0000000 --- a/server/src/routes/ui/sort.rs +++ /dev/null @@ -1,290 +0,0 @@ -use jellybase::locale::Language; -use jellycommon::{helpers::SortAnyway, user::NodeUserData, Node, NodeKind, Rating}; -use markup::RenderAttributeValue; -use rocket::{ - http::uri::fmt::{Query, UriDisplay}, - FromForm, FromFormField, UriDisplayQuery, -}; -use std::sync::Arc; - -use crate::routes::ui::layout::trs; - -#[derive(FromForm, UriDisplayQuery, Default, Clone)] -pub struct NodeFilterSort { - pub sort_by: Option, - pub filter_kind: Option>, - pub sort_order: Option, -} - -macro_rules! form_enum { - (enum $i:ident { $($vi:ident = $vk:literal),*, }) => { - #[derive(Debug, FromFormField, UriDisplayQuery, Clone, Copy, PartialEq, Eq)] - pub enum $i { $(#[field(value = $vk)] $vi),* } - impl $i { #[allow(unused)] const ALL: &'static [$i] = &[$($i::$vi),*]; } - }; -} - -form_enum!( - enum FilterProperty { - FederationLocal = "fed_local", - FederationRemote = "fed_remote", - Watched = "watched", - Unwatched = "unwatched", - WatchProgress = "watch_progress", - KindMovie = "kind_movie", - KindVideo = "kind_video", - KindShortFormVideo = "kind_short_form_video", - KindMusic = "kind_music", - KindCollection = "kind_collection", - KindChannel = "kind_channel", - KindShow = "kind_show", - KindSeries = "kind_series", - KindSeason = "kind_season", - KindEpisode = "kind_episode", - } -); - -form_enum!( - enum SortProperty { - ReleaseDate = "release_date", - Title = "title", - Index = "index", - Duration = "duration", - RatingRottenTomatoes = "rating_rt", - RatingMetacritic = "rating_mc", - RatingImdb = "rating_imdb", - RatingTmdb = "rating_tmdb", - RatingYoutubeViews = "rating_yt_views", - RatingYoutubeLikes = "rating_yt_likes", - RatingYoutubeFollowers = "rating_yt_followers", - RatingUser = "rating_user", - RatingLikesDivViews = "rating_loved", - } -); - -impl SortProperty { - const CATS: &'static [(&'static str, &'static [(SortProperty, &'static str)])] = { - use SortProperty::*; - &[ - ( - "filter_sort.sort.general", - &[(Title, "node.title"), (ReleaseDate, "node.release_date")], - ), - ("filter_sort.sort.media", &[(Duration, "media.runtime")]), - ( - "filter_sort.sort.rating", - &[ - (RatingImdb, "rating.imdb"), - (RatingTmdb, "rating.tmdb"), - (RatingMetacritic, "rating.metacritic"), - (RatingRottenTomatoes, "rating.rotten_tomatoes"), - (RatingYoutubeFollowers, "rating.youtube_followers"), - (RatingYoutubeLikes, "rating.youtube_likes"), - (RatingYoutubeViews, "rating.youtube_views"), - (RatingUser, "filter_sort.sort.rating.user"), - ( - RatingLikesDivViews, - "filter_sort.sort.rating.likes_div_views", - ), - ], - ), - ] - }; -} -impl FilterProperty { - const CATS: &'static [(&'static str, &'static [(FilterProperty, &'static str)])] = { - use FilterProperty::*; - &[ - ( - "filter_sort.filter.kind", - &[ - (KindMovie, "kind.movie"), - (KindVideo, "kind.video"), - (KindShortFormVideo, "kind.short_form_video"), - (KindMusic, "kind.music"), - (KindCollection, "kind.collection"), - (KindChannel, "kind.channel"), - (KindShow, "kind.show"), - (KindSeries, "kind.series"), - (KindSeason, "kind.season"), - (KindEpisode, "kind.episode"), - ], - ), - ( - "filter_sort.filter.federation", - &[(FederationLocal, "federation.local"), (FederationRemote, "federation.remote")], - ), - ( - "filter_sort.filter.watched", - &[ - (Watched, "watched.watched"), - (Unwatched, "watched.none"), - (WatchProgress, "watched.progress"), - ], - ), - ] - }; -} - -impl NodeFilterSort { - pub fn is_open(&self) -> bool { - self.filter_kind.is_some() || self.sort_by.is_some() - } -} - -#[rustfmt::skip] -#[derive(FromFormField, UriDisplayQuery, Clone, Copy, PartialEq, Eq)] -pub enum SortOrder { - #[field(value = "ascending")] Ascending, - #[field(value = "descending")] Descending, -} - -pub fn filter_and_sort_nodes( - f: &NodeFilterSort, - default_sort: (SortProperty, SortOrder), - nodes: &mut Vec<(Arc, NodeUserData)>, -) { - let sort_prop = f.sort_by.unwrap_or(default_sort.0); - nodes.retain(|(node, _udata)| { - let mut o = true; - if let Some(prop) = &f.filter_kind { - o = false; - for p in prop { - o |= match p { - // FilterProperty::FederationLocal => node.federated.is_none(), - // FilterProperty::FederationRemote => node.federated.is_some(), - FilterProperty::KindMovie => node.kind == NodeKind::Movie, - FilterProperty::KindVideo => node.kind == NodeKind::Video, - FilterProperty::KindShortFormVideo => node.kind == NodeKind::ShortFormVideo, - FilterProperty::KindMusic => node.kind == NodeKind::Music, - FilterProperty::KindCollection => node.kind == NodeKind::Collection, - FilterProperty::KindChannel => node.kind == NodeKind::Channel, - FilterProperty::KindShow => node.kind == NodeKind::Show, - FilterProperty::KindSeries => node.kind == NodeKind::Series, - FilterProperty::KindSeason => node.kind == NodeKind::Season, - FilterProperty::KindEpisode => node.kind == NodeKind::Episode, - // FilterProperty::Watched => udata.watched == WatchedState::Watched, - // FilterProperty::Unwatched => udata.watched == WatchedState::None, - // FilterProperty::WatchProgress => { - // matches!(udata.watched, WatchedState::Progress(_)) - // } - _ => false, // TODO - } - } - } - match sort_prop { - SortProperty::ReleaseDate => o &= node.release_date.is_some(), - SortProperty::Duration => o &= node.media.is_some(), - _ => (), - } - o - }); - match sort_prop { - SortProperty::Duration => { - nodes.sort_by_key(|(n, _)| (n.media.as_ref().unwrap().duration * 1000.) as i64) - } - SortProperty::ReleaseDate => { - nodes.sort_by_key(|(n, _)| n.release_date.expect("asserted above")) - } - SortProperty::Title => nodes.sort_by(|(a, _), (b, _)| a.title.cmp(&b.title)), - SortProperty::Index => nodes.sort_by(|(a, _), (b, _)| { - a.index - .unwrap_or(usize::MAX) - .cmp(&b.index.unwrap_or(usize::MAX)) - }), - SortProperty::RatingRottenTomatoes => nodes.sort_by_cached_key(|(n, _)| { - SortAnyway(*n.ratings.get(&Rating::RottenTomatoes).unwrap_or(&0.)) - }), - SortProperty::RatingMetacritic => nodes.sort_by_cached_key(|(n, _)| { - SortAnyway(*n.ratings.get(&Rating::Metacritic).unwrap_or(&0.)) - }), - SortProperty::RatingImdb => nodes - .sort_by_cached_key(|(n, _)| SortAnyway(*n.ratings.get(&Rating::Imdb).unwrap_or(&0.))), - SortProperty::RatingTmdb => nodes - .sort_by_cached_key(|(n, _)| SortAnyway(*n.ratings.get(&Rating::Tmdb).unwrap_or(&0.))), - SortProperty::RatingYoutubeViews => nodes.sort_by_cached_key(|(n, _)| { - SortAnyway(*n.ratings.get(&Rating::YoutubeViews).unwrap_or(&0.)) - }), - SortProperty::RatingYoutubeLikes => nodes.sort_by_cached_key(|(n, _)| { - SortAnyway(*n.ratings.get(&Rating::YoutubeLikes).unwrap_or(&0.)) - }), - SortProperty::RatingYoutubeFollowers => nodes.sort_by_cached_key(|(n, _)| { - SortAnyway(*n.ratings.get(&Rating::YoutubeFollowers).unwrap_or(&0.)) - }), - SortProperty::RatingLikesDivViews => nodes.sort_by_cached_key(|(n, _)| { - SortAnyway( - *n.ratings.get(&Rating::YoutubeLikes).unwrap_or(&0.) - / (1. + *n.ratings.get(&Rating::YoutubeViews).unwrap_or(&0.)), - ) - }), - SortProperty::RatingUser => nodes.sort_by_cached_key(|(_, u)| u.rating), - } - - match f.sort_order.unwrap_or(default_sort.1) { - SortOrder::Ascending => (), - SortOrder::Descending => nodes.reverse(), - } -} - -markup::define! { - NodeFilterSortForm<'a>(f: &'a NodeFilterSort, lang: &'a Language) { - details.filtersort[open=f.is_open()] { - summary { "Filter and Sort" } - form[method="GET", action=""] { - fieldset.filter { - legend { "Filter" } - .categories { - @for (cname, cat) in FilterProperty::CATS { - .category { - h3 { @trs(lang, cname) } - @for (value, label) in *cat { - label { input[type="checkbox", name="filter_kind", value=value, checked=f.filter_kind.as_ref().map(|k|k.contains(value)).unwrap_or(true)]; @trs(lang, label) } br; - } - } - } - } - } - fieldset.sortby { - legend { "Sort" } - .categories { - @for (cname, cat) in SortProperty::CATS { - .category { - h3 { @trs(lang, cname) } - @for (value, label) in *cat { - label { input[type="radio", name="sort_by", value=value, checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br; - } - } - } - } - } - fieldset.sortorder { - legend { "Sort Order" } - @use SortOrder::*; - @for (value, label) in [(Ascending, "filter_sort.order.asc"), (Descending, "filter_sort.order.desc")] { - label { input[type="radio", name="sort_order", value=value, checked=Some(value)==f.sort_order]; @trs(lang, label) } br; - } - } - input[type="submit", value="Apply"]; a[href="?"] { "Clear" } - } - } - } -} - -impl markup::Render for SortProperty { - fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { - writer.write_fmt(format_args!("{}", self as &dyn UriDisplay)) - } -} -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)) - } -} -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)) - } -} -impl RenderAttributeValue for SortOrder {} -impl RenderAttributeValue for FilterProperty {} -impl RenderAttributeValue for SortProperty {} diff --git a/server/src/routes/ui/stats.rs b/server/src/routes/ui/stats.rs deleted file mode 100644 index 4e4eef1..0000000 --- a/server/src/routes/ui/stats.rs +++ /dev/null @@ -1,133 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::{ - account::session::Session, - error::MyError, - layout::{DynLayoutPage, LayoutPage}, -}; -use crate::{ - database::Database, - routes::{ - api::AcceptJson, - locale::AcceptLanguage, - ui::{ - layout::trs, - node::{ - format_duration, format_duration_long, format_kind, format_size, - rocket_uri_macro_r_library_node, - }, - }, - }, - uri, -}; -use jellybase::locale::tr; -use jellycommon::{Node, NodeID, NodeKind, Visibility}; -use markup::raw; -use rocket::{get, serde::json::Json, Either, State}; -use serde::Serialize; -use serde_json::{json, Value}; -use std::collections::BTreeMap; - -#[get("/stats")] -pub fn r_stats( - sess: Session, - db: &State, - aj: AcceptJson, - lang: AcceptLanguage, -) -> Result, Json>, MyError> { - let AcceptLanguage(lang) = lang; - let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?; - items.retain(|(n, _)| n.visibility >= Visibility::Reduced); - - #[derive(Default, Serialize)] - struct Bin { - runtime: f64, - size: u64, - count: usize, - max_runtime: (f64, String), - max_size: (u64, String), - } - impl Bin { - fn update(&mut self, node: &Node) { - self.count += 1; - self.size += node.storage_size; - if node.storage_size > self.max_size.0 { - self.max_size = (node.storage_size, node.slug.clone()) - } - if let Some(m) = &node.media { - self.runtime += m.duration; - if m.duration > self.max_runtime.0 { - self.max_runtime = (m.duration, node.slug.clone()) - } - } - } - fn average_runtime(&self) -> f64 { - self.runtime / self.count as f64 - } - fn average_size(&self) -> f64 { - self.size as f64 / self.count as f64 - } - } - - let mut all = Bin::default(); - let mut kinds = BTreeMap::::new(); - for (i, _) in items { - all.update(&i); - kinds.entry(i.kind).or_default().update(&i); - } - - Ok(if *aj { - Either::Right(Json(json!({ - "all": all, - "kinds": kinds, - }))) - } else { - Either::Left(LayoutPage { - title: tr(lang, "stats.title").to_string(), - content: markup::new! { - .page.stats { - h1 { @trs(&lang, "stats.title") } - p { @raw(tr(lang, "stats.count") - .replace("{count}", &format!("{}", all.count)) - )} - p { @raw(tr(lang, "stats.runtime") - .replace("{dur}", &format!("{}", format_duration_long(all.runtime, lang))) - .replace("{size}", &format!("{}", format_size(all.size))) - )} - p { @raw(tr(lang, "stats.average") - .replace("{dur}", &format!("{}", format_duration(all.average_runtime()))) - .replace("{size}", &format!("{}", format_size(all.average_size() as u64))) - )} - - h2 { @trs(&lang, "stats.by_kind.title") } - table.striped { - tr { - th { @trs(&lang, "stats.by_kind.kind") } - th { @trs(&lang, "stats.by_kind.count") } - th { @trs(&lang, "stats.by_kind.total_size") } - th { @trs(&lang, "stats.by_kind.total_runtime") } - th { @trs(&lang, "stats.by_kind.average_size") } - th { @trs(&lang, "stats.by_kind.average_runtime") } - th { @trs(&lang, "stats.by_kind.max_size") } - th { @trs(&lang, "stats.by_kind.max_runtime") } - } - @for (k,b) in &kinds { tr { - td { @format_kind(*k, lang) } - td { @b.count } - td { @format_size(b.size) } - td { @format_duration(b.runtime) } - td { @format_size(b.average_size() as u64) } - td { @format_duration(b.average_runtime()) } - td { @if b.max_size.0 > 0 { a[href=uri!(r_library_node(&b.max_size.1))]{ @format_size(b.max_size.0) }}} - td { @if b.max_runtime.0 > 0. { a[href=uri!(r_library_node(&b.max_runtime.1))]{ @format_duration(b.max_runtime.0) }}} - }} - } - } - }, - ..Default::default() - }) - }) -} diff --git a/server/src/routes/ui/style.rs b/server/src/routes/ui/style.rs deleted file mode 100644 index c935c8a..0000000 --- a/server/src/routes/ui/style.rs +++ /dev/null @@ -1,90 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin - Copyright (C) 2023 tpart -*/ -use rocket::{ - get, - http::{ContentType, Header}, - response::Responder, -}; - -macro_rules! concat_files { - ([$base: expr], $($files:literal),*) => {{ - #[cfg(any(debug_assertions, feature = "hot-css"))] - { - use std::{fs::read_to_string, path::PathBuf, str::FromStr}; - [ $($files),* ] - .into_iter() - .map(|n| { - read_to_string({ - let p = PathBuf::from_str(file!()).unwrap().parent().unwrap().join($base).join(n); - log::info!("load {p:?}"); - p - }) - .unwrap() - }) - .collect::>() - .join("\n") - } - #[cfg(not(any(debug_assertions, feature = "hot-css")))] - concat!($(include_str!(concat!($base, "/", $files))),*).to_string() - }}; -} - -fn css_bundle() -> String { - concat_files!( - ["../../../../web/style"], - "layout.css", - "player.css", - "nodepage.css", - "nodecard.css", - "js-player.css", - "js-transition.css", - "forms.css", - "props.css", - "themes.css", - "navbar.css" - ) -} - -pub struct CachedAsset(pub T); -impl<'r, 'o: 'r, T: Responder<'r, 'o>> Responder<'r, 'o> for CachedAsset { - fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'o> { - let mut res = self.0.respond_to(request)?; - if cfg!(not(debug_assertions)) { - res.set_header(Header::new("cache-control", "max-age=86400")); - } - Ok(res) - } -} - -fn js_bundle() -> String { - concat_files!([env!("OUT_DIR")], "bundle.js") -} -fn js_bundle_map() -> String { - concat_files!([env!("OUT_DIR")], "bundle.js.map") -} - -#[get("/assets/style.css")] -pub fn r_assets_style() -> CachedAsset<(ContentType, String)> { - CachedAsset((ContentType::CSS, css_bundle())) -} - -#[get("/assets/cantarell.woff2")] -pub fn r_assets_font() -> CachedAsset<(ContentType, &'static [u8])> { - CachedAsset(( - ContentType::WOFF2, - include_bytes!("../../../../web/cantarell.woff2"), - )) -} - -#[get("/assets/bundle.js")] -pub fn r_assets_js() -> CachedAsset<(ContentType, String)> { - CachedAsset((ContentType::JavaScript, js_bundle())) -} -#[get("/assets/bundle.js.map")] -pub fn r_assets_js_map() -> CachedAsset<(ContentType, String)> { - CachedAsset((ContentType::JSON, js_bundle_map())) -} diff --git a/server/src/routes/userdata.rs b/server/src/routes/userdata.rs deleted file mode 100644 index 01776da..0000000 --- a/server/src/routes/userdata.rs +++ /dev/null @@ -1,95 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::ui::{account::session::Session, error::MyResult}; -use crate::routes::ui::node::rocket_uri_macro_r_library_node; -use jellybase::database::Database; -use jellycommon::{ - user::{NodeUserData, WatchedState}, - NodeID, -}; -use rocket::{ - form::Form, get, post, response::Redirect, serde::json::Json, FromForm, FromFormField, State, - UriDisplayQuery, -}; - -#[derive(Debug, FromFormField, UriDisplayQuery)] -pub enum UrlWatchedState { - None, - Watched, - Pending, -} - -#[get("/n//userdata")] -pub fn r_node_userdata( - session: Session, - db: &State, - id: NodeID, -) -> MyResult> { - let u = db - .get_node_udata(id, &session.user.name)? - .unwrap_or_default(); - Ok(Json(u)) -} - -#[post("/n//watched?")] -pub async fn r_node_userdata_watched( - session: Session, - db: &State, - id: NodeID, - state: UrlWatchedState, -) -> MyResult { - // TODO perm - db.update_node_udata(id, &session.user.name, |udata| { - udata.watched = match state { - UrlWatchedState::None => WatchedState::None, - UrlWatchedState::Watched => WatchedState::Watched, - UrlWatchedState::Pending => WatchedState::Pending, - }; - Ok(()) - })?; - Ok(Redirect::found(rocket::uri!(r_library_node(id)))) -} - -#[derive(FromForm)] -pub struct UpdateRating { - #[field(validate = range(-10..=10))] - rating: i32, -} - -#[post("/n//update_rating", data = "")] -pub async fn r_node_userdata_rating( - session: Session, - db: &State, - id: NodeID, - form: Form, -) -> MyResult { - // TODO perm - db.update_node_udata(id, &session.user.name, |udata| { - udata.rating = form.rating; - Ok(()) - })?; - Ok(Redirect::found(rocket::uri!(r_library_node(id)))) -} - -#[post("/n//progress?")] -pub async fn r_node_userdata_progress( - session: Session, - db: &State, - id: NodeID, - t: f64, -) -> MyResult<()> { - // TODO perm - db.update_node_udata(id, &session.user.name, |udata| { - udata.watched = match udata.watched { - WatchedState::None | WatchedState::Pending | WatchedState::Progress(_) => { - WatchedState::Progress(t) - } - WatchedState::Watched => WatchedState::Watched, - }; - Ok(()) - })?; - Ok(()) -} diff --git a/server/src/ui/account/mod.rs b/server/src/ui/account/mod.rs new file mode 100644 index 0000000..312b40c --- /dev/null +++ b/server/src/ui/account/mod.rs @@ -0,0 +1,256 @@ +/* + 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 +*/ +pub mod settings; + +use super::{ + error::MyError, + layout::{trs, LayoutPage}, +}; +use crate::{ + database::Database, + locale::AcceptLanguage, + logic::session::{self, Session}, + ui::{error::MyResult, home::rocket_uri_macro_r_home, layout::DynLayoutPage}, + uri, +}; +use anyhow::anyhow; +use argon2::{password_hash::Salt, Argon2, PasswordHasher}; +use chrono::Duration; +use jellybase::{locale::tr, CONF}; +use jellycommon::user::{User, UserPermission}; +use rocket::{ + form::{Contextual, Form}, + get, + http::{Cookie, CookieJar}, + post, + response::Redirect, + FromForm, State, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +#[derive(FromForm)] +pub struct RegisterForm { + #[field(validate = len(8..128))] + pub invitation: String, + #[field(validate = len(4..32))] + pub username: String, + #[field(validate = len(4..64))] + pub password: String, +} + +#[get("/account/register")] +pub async fn r_account_register(lang: AcceptLanguage) -> DynLayoutPage<'static> { + let AcceptLanguage(lang) = lang; + LayoutPage { + title: tr(lang, "account.register").to_string(), + content: markup::new! { + form.account[method="POST", action=""] { + h1 { @trs(&lang, "account.register") } + + label[for="inp-invitation"] { @trs(&lang, "account.register.invitation") } + input[type="text", id="inp-invitation", name="invitation"]; br; + + label[for="inp-username"] { @trs(&lang, "account.username") } + input[type="text", id="inp-username", name="username"]; br; + label[for="inp-password"] { @trs(&lang, "account.password") } + input[type="password", id="inp-password", name="password"]; br; + + input[type="submit", value=&*tr(lang, "account.register.submit")]; + + p { @trs(&lang, "account.register.login") " " a[href=uri!(r_account_login())] { @trs(&lang, "account.register.login_here") } } + } + }, + ..Default::default() + } +} + +#[derive(FromForm, Serialize, Deserialize)] +pub struct LoginForm { + #[field(validate = len(4..32))] + pub username: String, + #[field(validate = len(..64))] + pub password: String, + #[field(default = 604800)] // one week + pub expire: u64, +} + +#[get("/account/login")] +pub fn r_account_login(sess: Option, lang: AcceptLanguage) -> DynLayoutPage<'static> { + let AcceptLanguage(lang) = lang; + let logged_in = sess.is_some(); + let title = tr( + lang, + if logged_in { + "account.login.switch" + } else { + "account.login" + }, + ); + LayoutPage { + title: title.to_string(), + content: markup::new! { + form.account[method="POST", action=""] { + h1 { @title.to_string() } + + label[for="inp-username"] { @trs(&lang, "account.username") } + input[type="text", id="inp-username", name="username"]; br; + label[for="inp-password"] { @trs(&lang, "account.password") } + input[type="password", id="inp-password", name="password"]; br; + + input[type="submit", value=&*tr(lang, if logged_in { "account.login.submit.switch" } else { "account.login.submit" })]; + + @if logged_in { + p { @trs(&lang, "account.login.register.switch") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } } + } else { + p { @trs(&lang, "account.login.cookie_note") } + p { @trs(&lang, "account.login.register") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } } + } + } + }, + ..Default::default() + } +} + +#[get("/account/logout")] +pub fn r_account_logout(lang: AcceptLanguage) -> DynLayoutPage<'static> { + let AcceptLanguage(lang) = lang; + LayoutPage { + title: tr(lang, "account.logout").to_string(), + content: markup::new! { + form.account[method="POST", action=""] { + h1 { @trs(&lang, "account.logout") } + input[type="submit", value=&*tr(lang, "account.logout.submit")]; + } + }, + ..Default::default() + } +} + +#[post("/account/register", data = "")] +pub fn r_account_register_post<'a>( + database: &'a State, + _sess: Option, + form: Form>, + lang: AcceptLanguage, +) -> MyResult> { + let AcceptLanguage(lang) = lang; + let logged_in = _sess.is_some(); + let form = match &form.value { + Some(v) => v, + None => return Err(format_form_error(form)), + }; + + database.register_user( + &form.invitation, + &form.username, + User { + display_name: form.username.clone(), + name: form.username.clone(), + password: hash_password(&form.username, &form.password), + ..Default::default() + }, + )?; + + Ok(LayoutPage { + title: tr(lang, "account.register.success.title").to_string(), + content: markup::new! { + h1 { @trs(&lang, if logged_in { + "account.register.success.switch" + } else { + "account.register.success" + })} + }, + ..Default::default() + }) +} + +#[post("/account/login", data = "")] +pub fn r_account_login_post( + database: &State, + jar: &CookieJar, + form: Form>, +) -> MyResult { + let form = match &form.value { + Some(v) => v, + None => return Err(format_form_error(form)), + }; + jar.add( + Cookie::build(( + "session", + login_logic(database, &form.username, &form.password, None, None)?, + )) + .permanent() + .build(), + ); + + Ok(Redirect::found(rocket::uri!(r_home()))) +} + +#[post("/account/logout")] +pub fn r_account_logout_post(jar: &CookieJar) -> MyResult { + jar.remove_private(Cookie::build("session")); + Ok(Redirect::found(rocket::uri!(r_home()))) +} + +pub fn login_logic( + database: &Database, + username: &str, + password: &str, + expire: Option, + drop_permissions: Option>, +) -> MyResult { + // hashing the password regardless if the accounts exists to better resist timing attacks + let password = hash_password(username, password); + + let mut user = database + .get_user(username)? + .ok_or(anyhow!("invalid password"))?; + + if user.password != password { + Err(anyhow!("invalid password"))? + } + + if let Some(ep) = drop_permissions { + // remove all grant perms that are in `ep` + user.permissions + .0 + .retain(|p, val| if *val { !ep.contains(p) } else { true }) + } + + Ok(session::create( + user.name, + user.permissions, + Duration::days(CONF.login_expire.min(expire.unwrap_or(i64::MAX))), + )) +} + +pub fn format_form_error(form: Form>) -> MyError { + let mut k = String::from("form validation failed:"); + for e in form.context.errors() { + k += &format!( + "\n\t{}: {e}", + e.name + .as_ref() + .map(|e| e.to_string()) + .unwrap_or("".to_string()) + ) + } + MyError(anyhow!(k)) +} + +pub fn hash_password(username: &str, password: &str) -> Vec { + Argon2::default() + .hash_password( + format!("{username}\0{password}").as_bytes(), + <&str as TryInto>::try_into("IYMa13osbNeLJKnQ1T8LlA").unwrap(), + ) + .unwrap() + .hash + .unwrap() + .as_bytes() + .to_vec() +} diff --git a/server/src/ui/account/settings.rs b/server/src/ui/account/settings.rs new file mode 100644 index 0000000..4047e4f --- /dev/null +++ b/server/src/ui/account/settings.rs @@ -0,0 +1,185 @@ +/* + 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 +*/ +use super::{format_form_error, hash_password}; +use crate::{ + database::Database, + locale::AcceptLanguage, + ui::{ + account::{rocket_uri_macro_r_account_login, session::Session}, + error::MyResult, + layout::{trs, DynLayoutPage, LayoutPage}, + }, + uri, +}; +use jellybase::{ + locale::{tr, Language}, + permission::PermissionSetExt, +}; +use jellycommon::user::{PlayerKind, Theme, UserPermission}; +use markup::{Render, RenderAttributeValue}; +use rocket::{ + form::{self, validate::len, Contextual, Form}, + get, + http::uri::fmt::{Query, UriDisplay}, + post, FromForm, State, +}; +use std::ops::Range; + +#[derive(FromForm)] +pub struct SettingsForm { + #[field(validate = option_len(4..64))] + password: Option, + #[field(validate = option_len(4..32))] + display_name: Option, + theme: Option, + player_preference: Option, + native_secret: Option, +} + +fn option_len<'v>(value: &Option, range: Range) -> form::Result<'v, ()> { + value.as_ref().map(|v| len(v, range)).unwrap_or(Ok(())) +} + +fn settings_page( + session: Session, + flash: Option>, + lang: Language, +) -> DynLayoutPage<'static> { + LayoutPage { + title: "Settings".to_string(), + class: Some("settings"), + content: markup::new! { + h1 { "Settings" } + @if let Some(flash) = &flash { + @match flash { + Ok(mesg) => { section.message { p.success { @mesg } } } + Err(err) => { section.message { p.error { @format!("{err}") } } } + } + } + h2 { @trs(&lang, "account") } + a.switch_account[href=uri!(r_account_login())] { "Switch Account" } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="username"] { @trs(&lang, "account.username") } + input[type="text", id="username", disabled, value=&session.user.name]; + input[type="submit", disabled, value=&*tr(lang, "settings.immutable")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="display_name"] { @trs(&lang, "account.display_name") } + input[type="text", id="display_name", name="display_name", value=&session.user.display_name]; + input[type="submit", value=&*tr(lang, "settings.update")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="password"] { @trs(&lang, "account.password") } + input[type="password", id="password", name="password"]; + input[type="submit", value=&*tr(lang, "settings.update")]; + } + h2 { @trs(&lang, "settings.appearance") } + form[method="POST", action=uri!(r_account_settings_post())] { + fieldset { + legend { @trs(&lang, "settings.appearance.theme") } + @for (t, tlabel) in Theme::LIST { + label { input[type="radio", name="theme", value=A(*t), checked=session.user.theme==*t]; @tlabel } br; + } + } + input[type="submit", value=&*tr(lang, "settings.apply")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + fieldset { + legend { @trs(&lang, "settings.player_preference") } + @for (t, tlabel) in PlayerKind::LIST { + label { input[type="radio", name="player_preference", value=A(*t), checked=session.user.player_preference==*t]; @tlabel } br; + } + } + input[type="submit", value=&*tr(lang, "settings.apply")]; + } + form[method="POST", action=uri!(r_account_settings_post())] { + label[for="native_secret"] { "Native Secret" } + input[type="password", id="native_secret", name="native_secret"]; + input[type="submit", value=&*tr(lang, "settings.update")]; + p { "The secret can be found in " code{"$XDG_CONFIG_HOME/jellynative_secret"} " or by clicking " a.button[href="jellynative://show-secret-v1"] { "Show Secret" } "." } + } + }, + } +} + +struct A(pub T); +impl> RenderAttributeValue for A {} +impl> Render for A { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_fmt(format_args!("{}", &self.0 as &dyn UriDisplay)) + } +} + +#[get("/account/settings")] +pub fn r_account_settings(session: Session, lang: AcceptLanguage) -> DynLayoutPage<'static> { + let AcceptLanguage(lang) = lang; + settings_page(session, None, lang) +} + +#[post("/account/settings", data = "")] +pub fn r_account_settings_post( + session: Session, + database: &State, + form: Form>, + lang: AcceptLanguage, +) -> MyResult> { + let AcceptLanguage(lang) = lang; + session + .user + .permissions + .assert(&UserPermission::ManageSelf)?; + + let form = match &form.value { + Some(v) => v, + None => { + return Ok(settings_page( + session, + Some(Err(format_form_error(form))), + lang, + )) + } + }; + + let mut out = String::new(); + + database.update_user(&session.user.name, |user| { + if let Some(password) = &form.password { + user.password = hash_password(&session.user.name, password); + out += &*tr(lang, "settings.account.password.changed"); + out += "\n"; + } + if let Some(display_name) = &form.display_name { + user.display_name = display_name.clone(); + out += &*tr(lang, "settings.account.display_name.changed"); + out += "\n"; + } + if let Some(theme) = form.theme { + user.theme = theme; + out += &*tr(lang, "settings.account.theme.changed"); + out += "\n"; + } + if let Some(player_preference) = form.player_preference { + user.player_preference = player_preference; + out += &*tr(lang, "settings.player_preference.changed"); + out += "\n"; + } + if let Some(native_secret) = &form.native_secret { + user.native_secret = native_secret.to_owned(); + out += "Native secret updated.\n"; + } + Ok(()) + })?; + + Ok(settings_page( + session, // using the old session here, results in outdated theme being displayed + Some(Ok(if out.is_empty() { + tr(lang, "settings.no_change").to_string() + } else { + out + })), + lang, + )) +} diff --git a/server/src/ui/admin/log.rs b/server/src/ui/admin/log.rs new file mode 100644 index 0000000..dff6d1b --- /dev/null +++ b/server/src/ui/admin/log.rs @@ -0,0 +1,258 @@ +/* + 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 +*/ +use crate::{ + logic::session::AdminSession, + ui::{ + error::MyResult, + layout::{DynLayoutPage, LayoutPage}, + }, + uri, +}; +use chrono::{DateTime, Utc}; +use log::Level; +use markup::Render; +use rocket::get; +use rocket_ws::{Message, Stream, WebSocket}; +use serde_json::json; +use std::{ + collections::VecDeque, + fmt::Write, + sync::{Arc, LazyLock, RwLock}, +}; +use tokio::sync::broadcast; + +const MAX_LOG_LEN: usize = 4096; + +static LOGGER: LazyLock = LazyLock::new(Log::default); + +pub fn enable_logging() { + log::set_logger(&*LOGGER).unwrap(); + log::set_max_level(log::LevelFilter::Debug); +} + +type LogBuffer = VecDeque>; + +pub struct Log { + inner: env_logger::Logger, + stream: ( + broadcast::Sender>, + broadcast::Sender>, + ), + log: RwLock<(LogBuffer, LogBuffer)>, +} + +pub struct LogLine { + time: DateTime, + module: Option<&'static str>, + level: Level, + message: String, +} + +#[get("/admin/log?", rank = 2)] +pub fn r_admin_log<'a>(_session: AdminSession, warnonly: bool) -> MyResult> { + Ok(LayoutPage { + title: "Log".into(), + class: Some("admin_log"), + content: markup::new! { + h1 { "Server Log" } + a[href=uri!(r_admin_log(!warnonly))] { @if warnonly { "Show everything" } else { "Show only warnings" }} + code.log[id="log"] { + @let g = LOGGER.log.read().unwrap(); + table { @for e in if warnonly { g.1.iter() } else { g.0.iter() } { + tr[class=format!("level-{:?}", e.level).to_ascii_lowercase()] { + td.time { @e.time.to_rfc3339() } + td.loglevel { @format_level(e.level) } + td.module { @e.module } + td { @markup::raw(vt100_to_html(&e.message)) } + } + }} + } + }, + }) +} + +#[get("/admin/log?stream&", rank = 1)] +pub fn r_admin_log_stream( + _session: AdminSession, + ws: WebSocket, + warnonly: bool, +) -> Stream!['static] { + let mut stream = if warnonly { + LOGGER.stream.1.subscribe() + } else { + LOGGER.stream.0.subscribe() + }; + Stream! { ws => + let _ = ws; + while let Ok(line) = stream.recv().await { + yield Message::Text(json!({ + "time": line.time, + "level_class": format!("level-{:?}", line.level).to_ascii_lowercase(), + "level_html": format_level_string(line.level), + "module": line.module, + "message": vt100_to_html(&line.message), + }).to_string()); + } + } +} + +impl Default for Log { + fn default() -> Self { + Self { + inner: env_logger::builder() + .filter_level(log::LevelFilter::Warn) + .parse_env("LOG") + .build(), + stream: ( + tokio::sync::broadcast::channel(1024).0, + tokio::sync::broadcast::channel(1024).0, + ), + log: Default::default(), + } + } +} +impl Log { + fn should_log(&self, metadata: &log::Metadata) -> bool { + let level = metadata.level(); + level + <= match metadata.target() { + x if x.starts_with("jelly") => Level::Debug, + x if x.starts_with("rocket::") => Level::Info, + _ => Level::Warn, + } + } + fn do_log(&self, record: &log::Record) { + let time = Utc::now(); + let line = Arc::new(LogLine { + time, + module: record.module_path_static(), + level: record.level(), + message: record.args().to_string(), + }); + let mut w = self.log.write().unwrap(); + w.0.push_back(line.clone()); + let _ = self.stream.0.send(line.clone()); + while w.0.len() > MAX_LOG_LEN { + w.0.pop_front(); + } + if record.level() <= Level::Warn { + let _ = self.stream.1.send(line.clone()); + w.1.push_back(line); + while w.1.len() > MAX_LOG_LEN { + w.1.pop_front(); + } + } + } +} + +impl log::Log for Log { + fn enabled(&self, metadata: &log::Metadata) -> bool { + self.inner.enabled(metadata) || self.should_log(metadata) + } + fn log(&self, record: &log::Record) { + match (record.module_path_static(), record.line()) { + // TODO is there a better way to ignore those? + (Some("rocket::rocket"), Some(670)) => return, + (Some("rocket::server"), Some(401)) => return, + _ => {} + } + if self.inner.enabled(record.metadata()) { + self.inner.log(record); + } + if self.should_log(record.metadata()) { + self.do_log(record) + } + } + fn flush(&self) { + self.inner.flush(); + } +} + +fn vt100_to_html(s: &str) -> String { + let mut out = HtmlOut::default(); + let mut st = vte::Parser::new(); + st.advance(&mut out, s.as_bytes()); + out.s +} + +fn format_level(level: Level) -> impl markup::Render { + let (s, c) = match level { + Level::Debug => ("DEBUG", "blue"), + Level::Error => ("ERROR", "red"), + Level::Warn => ("WARN", "yellow"), + Level::Info => ("INFO", "green"), + Level::Trace => ("TRACE", "lightblue"), + }; + markup::new! { span[style=format!("color:{c}")] {@s} } +} +fn format_level_string(level: Level) -> String { + let mut w = String::new(); + format_level(level).render(&mut w).unwrap(); + w +} + +#[derive(Default)] +pub struct HtmlOut { + s: String, + color: bool, +} +impl HtmlOut { + pub fn set_color(&mut self, [r, g, b]: [u8; 3]) { + self.reset_color(); + self.color = true; + write!(self.s, "", r, g, b).unwrap() + } + pub fn reset_color(&mut self) { + if self.color { + write!(self.s, "").unwrap(); + self.color = false; + } + } +} +impl vte::Perform for HtmlOut { + fn print(&mut self, c: char) { + match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | ' ' => self.s.push(c), + x => write!(self.s, "&#{};", x as u32).unwrap(), + } + } + fn execute(&mut self, _byte: u8) {} + fn hook(&mut self, _params: &vte::Params, _i: &[u8], _ignore: bool, _a: char) {} + fn put(&mut self, _byte: u8) {} + fn unhook(&mut self) {} + fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {} + fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} + fn csi_dispatch( + &mut self, + params: &vte::Params, + _intermediates: &[u8], + _ignore: bool, + action: char, + ) { + let mut k = params.iter(); + #[allow(clippy::single_match)] + match action { + 'm' => match k.next().unwrap_or(&[0]).first().unwrap_or(&0) { + c @ (30..=37 | 40..=47) => { + let c = if *c >= 40 { *c - 10 } else { *c }; + self.set_color(match c { + 30 => [0, 0, 0], + 31 => [255, 0, 0], + 32 => [0, 255, 0], + 33 => [255, 255, 0], + 34 => [0, 0, 255], + 35 => [255, 0, 255], + 36 => [0, 255, 255], + 37 => [255, 255, 255], + _ => unreachable!(), + }); + } + _ => (), + }, + _ => (), + } + } +} diff --git a/server/src/ui/admin/mod.rs b/server/src/ui/admin/mod.rs new file mode 100644 index 0000000..de06610 --- /dev/null +++ b/server/src/ui/admin/mod.rs @@ -0,0 +1,288 @@ +/* + 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 +*/ +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 anyhow::{anyhow, Context}; +use jellybase::{assetfed::AssetInner, federation::Federation, CONF}; +use jellyimport::{import_wrap, is_importing, IMPORT_ERRORS}; +use markup::DynRender; +use rand::Rng; +use rocket::{form::Form, get, post, FromForm, State}; +use std::time::Instant; +use tokio::{sync::Semaphore, task::spawn_blocking}; +use user::rocket_uri_macro_r_admin_users; + +#[get("/admin/dashboard")] +pub async fn r_admin_dashboard( + _session: AdminSession, + database: &State, +) -> MyResult> { + admin_dashboard(database, None).await +} + +pub async fn admin_dashboard<'a>( + database: &Database, + flash: Option>, +) -> MyResult> { + let invites = database.list_invites()?; + let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); + + let last_import_err = IMPORT_ERRORS.read().await.to_owned(); + + let database = database.to_owned(); + Ok(LayoutPage { + title: "Admin Dashboard".to_string(), + content: markup::new! { + h1 { "Admin Panel" } + @FlashDisplay { flash: flash.clone() } + @if !last_import_err.is_empty() { + section.message.error { + details { + summary { p.error { @format!("The last import resulted in {} errors:", last_import_err.len()) } } + ol { @for e in &last_import_err { + li.error { pre.error { @e } } + }} + } + } + } + ul { + li{a[href=uri!(r_admin_log(true))] { "Server Log (Warnings only)" }} + li{a[href=uri!(r_admin_log(false))] { "Server Log (Full) " }} + } + h2 { "Library" } + @if is_importing() { + section.message { p.warn { "An import is currently running." } } + } + @if is_transcoding() { + section.message { p.warn { "Currently transcoding posters." } } + } + form[method="POST", action=uri!(r_admin_import(true))] { + input[type="submit", disabled=is_importing(), value="Start incremental import"]; + } + form[method="POST", action=uri!(r_admin_import(false))] { + input[type="submit", disabled=is_importing(), value="Start full import"]; + } + form[method="POST", action=uri!(r_admin_transcode_posters())] { + input[type="submit", disabled=is_transcoding(), value="Transcode all posters with low resolution"]; + } + form[method="POST", action=uri!(r_admin_update_search())] { + input[type="submit", value="Regenerate full-text search index"]; + } + form[method="POST", action=uri!(r_admin_delete_cache())] { + input.danger[type="submit", value="Delete Cache"]; + } + h2 { "Users" } + p { a[href=uri!(r_admin_users())] "Manage Users" } + h2 { "Invitations" } + form[method="POST", action=uri!(r_admin_invite())] { + input[type="submit", value="Generate new invite code"]; + } + ul { @for t in &invites { + li { + form[method="POST", action=uri!(r_admin_remove_invite())] { + span { @t } + input[type="text", name="invite", value=&t, hidden]; + input[type="submit", value="Invalidate"]; + } + } + }} + + h2 { "Database" } + @match db_stats(&database) { + Ok(s) => { @s } + Err(e) => { pre.error { @format!("{e:?}") } } + } + }, + ..Default::default() + }) +} + +#[post("/admin/generate_invite")] +pub async fn r_admin_invite( + _session: AdminSession, + database: &State, +) -> MyResult> { + let i = format!("{}", rand::rng().random::()); + database.create_invite(&i)?; + admin_dashboard(database, Some(Ok(format!("Invite: {}", i)))).await +} + +#[derive(FromForm)] +pub struct DeleteInvite { + invite: String, +} + +#[post("/admin/remove_invite", data = "")] +pub async fn r_admin_remove_invite( + session: AdminSession, + database: &State, + form: Form, +) -> MyResult> { + drop(session); + if !database.delete_invite(&form.invite)? { + Err(anyhow!("invite does not exist"))?; + }; + admin_dashboard(database, Some(Ok("Invite invalidated".into()))).await +} + +#[post("/admin/import?")] +pub async fn r_admin_import( + session: AdminSession, + database: &State, + _federation: &State, + incremental: bool, +) -> MyResult> { + drop(session); + let t = Instant::now(); + if !incremental { + database.clear_nodes()?; + } + let r = import_wrap((*database).clone(), incremental).await; + let flash = r + .map_err(|e| e.into()) + .map(|_| format!("Import successful; took {:?}", t.elapsed())); + admin_dashboard(database, Some(flash)).await +} + +#[post("/admin/update_search")] +pub async fn r_admin_update_search( + _session: AdminSession, + database: &State, +) -> MyResult> { + let db2 = (*database).clone(); + let r = spawn_blocking(move || db2.search_create_index()) + .await + .unwrap(); + admin_dashboard( + database, + Some( + r.map_err(|e| e.into()) + .map(|_| "Search index updated".to_string()), + ), + ) + .await +} + +#[post("/admin/delete_cache")] +pub async fn r_admin_delete_cache( + session: AdminSession, + database: &State, +) -> MyResult> { + drop(session); + let t = Instant::now(); + let r = tokio::fs::remove_dir_all(&CONF.cache_path).await; + tokio::fs::create_dir(&CONF.cache_path).await?; + admin_dashboard( + database, + Some( + r.map_err(|e| e.into()) + .map(|_| format!("Cache deleted; took {:?}", t.elapsed())), + ), + ) + .await +} + +static SEM_TRANSCODING: Semaphore = Semaphore::const_new(1); +fn is_transcoding() -> bool { + SEM_TRANSCODING.available_permits() == 0 +} + +#[post("/admin/transcode_posters")] +pub async fn r_admin_transcode_posters( + session: AdminSession, + database: &State, +) -> MyResult> { + drop(session); + let _permit = SEM_TRANSCODING + .try_acquire() + .context("transcoding in progress")?; + + let t = Instant::now(); + + { + let nodes = database.list_nodes_with_udata("")?; + for (node, _) in nodes { + if let Some(poster) = &node.poster { + let asset = AssetInner::deser(&poster.0)?; + if asset.is_federated() { + continue; + } + let source = resolve_asset(asset).await.context("resolving asset")?; + jellytranscoder::image::transcode(&source, AVIF_QUALITY, AVIF_SPEED, 1024) + .await + .context("transcoding asset")?; + } + } + } + drop(_permit); + + admin_dashboard( + database, + Some(Ok(format!( + "All posters pre-transcoded; took {:?}", + t.elapsed() + ))), + ) + .await +} + +fn db_stats(_db: &Database) -> anyhow::Result { + // TODO + // let txn = db.inner.begin_read()?; + // let stats = [ + // ("node", txn.open_table(T_NODE)?.stats()?), + // ("user", txn.open_table(T_USER_NODE)?.stats()?), + // ("user-node", txn.open_table(T_USER_NODE)?.stats()?), + // ("invite", txn.open_table(T_INVITE)?.stats()?), + // ]; + + // let cache_stats = db.node_index.reader.searcher().doc_store_cache_stats(); + // let ft_total_docs = db.node_index.reader.searcher().total_num_docs()?; + + Ok(markup::new! { + // h3 { "Key-Value-Store Statistics" } + // table.border { + // tbody { + // tr { + // th { "table name" } + // th { "tree height" } + // th { "stored bytes" } + // th { "metadata bytes" } + // th { "fragmented bytes" } + // th { "branch pages" } + // th { "leaf pages" } + // } + // @for (name, stats) in &stats { tr { + // td { @name } + // td { @stats.tree_height() } + // td { @format_size(stats.stored_bytes(), DECIMAL) } + // td { @format_size(stats.metadata_bytes(), DECIMAL) } + // td { @format_size(stats.fragmented_bytes(), DECIMAL) } + // td { @stats.branch_pages() } + // td { @stats.leaf_pages() } + // }} + // } + // } + // h3 { "Search Engine Statistics" } + // ul { + // li { "Total documents: " @ft_total_docs } + // li { "Cache misses: " @cache_stats.cache_misses } + // li { "Cache hits: " @cache_stats.cache_hits } + // } + }) +} diff --git a/server/src/ui/admin/user.rs b/server/src/ui/admin/user.rs new file mode 100644 index 0000000..c5239f7 --- /dev/null +++ b/server/src/ui/admin/user.rs @@ -0,0 +1,176 @@ +/* + 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 +*/ +use crate::{ + database::Database, + logic::session::AdminSession, + ui::{ + error::MyResult, + layout::{DynLayoutPage, FlashDisplay, LayoutPage}, + }, + uri, +}; +use anyhow::{anyhow, Context}; +use jellycommon::user::{PermissionSet, UserPermission}; +use rocket::{form::Form, get, post, FromForm, FromFormField, State}; + +#[get("/admin/users")] +pub fn r_admin_users( + _session: AdminSession, + database: &State, +) -> MyResult> { + user_management(database, None) +} + +fn user_management<'a>( + database: &Database, + flash: Option>, +) -> MyResult> { + // TODO this doesnt scale, pagination! + let users = database.list_users()?; + let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); + + Ok(LayoutPage { + title: "User management".to_string(), + content: markup::new! { + h1 { "User Management" } + @FlashDisplay { flash: flash.clone() } + h2 { "All Users" } + ul { @for u in &users { + li { + a[href=uri!(r_admin_user(&u.name))] { @format!("{:?}", u.display_name) " (" @u.name ")" } + } + }} + }, + ..Default::default() + }) +} + +#[get("/admin/user/")] +pub fn r_admin_user<'a>( + _session: AdminSession, + database: &State, + name: &'a str, +) -> MyResult> { + manage_single_user(database, None, name.to_string()) +} + +fn manage_single_user<'a>( + database: &Database, + flash: Option>, + name: String, +) -> MyResult> { + let user = database + .get_user(&name)? + .ok_or(anyhow!("user does not exist"))?; + let flash = flash.map(|f| f.map_err(|e| format!("{e:?}"))); + + Ok(LayoutPage { + title: "User management".to_string(), + content: markup::new! { + h1 { @format!("{:?}", user.display_name) " (" @user.name ")" } + a[href=uri!(r_admin_users())] "Back to the User List" + @FlashDisplay { flash: flash.clone() } + form[method="POST", action=uri!(r_admin_remove_user())] { + input[type="text", name="name", value=&user.name, hidden]; + input.danger[type="submit", value="Remove user(!)"]; + } + + h2 { "Permissions" } + @PermissionDisplay { perms: &user.permissions } + + form[method="POST", action=uri!(r_admin_user_permission())] { + input[type="text", name="name", value=&user.name, hidden]; + fieldset.perms { + legend { "Permission" } + @for p in UserPermission::ALL_ENUMERABLE { + label { + input[type="radio", name="permission", value=serde_json::to_string(p).unwrap()]; + @format!("{p}") + } br; + } + } + fieldset.perms { + legend { "Permission" } + label { input[type="radio", name="action", value="unset"]; "Unset" } br; + label { input[type="radio", name="action", value="grant"]; "Grant" } br; + label { input[type="radio", name="action", value="revoke"]; "Revoke" } br; + } + input[type="submit", value="Update"]; + } + + }, + ..Default::default() + }) +} + +markup::define! { + PermissionDisplay<'a>(perms: &'a PermissionSet) { + ul { @for (perm,grant) in &perms.0 { + @if *grant { + li[class="perm-grant"] { @format!("Allow {}", perm) } + } else { + li[class="perm-revoke"] { @format!("Deny {}", perm) } + } + }} + } +} + +#[derive(FromForm)] +pub struct DeleteUser { + name: String, +} +#[derive(FromForm)] +pub struct UserPermissionForm { + name: String, + permission: String, + action: GrantState, +} + +#[derive(FromFormField)] +pub enum GrantState { + Grant, + Revoke, + Unset, +} + +#[post("/admin/update_user_permission", data = "")] +pub fn r_admin_user_permission( + session: AdminSession, + database: &State, + form: Form, +) -> MyResult> { + drop(session); + let perm = serde_json::from_str::(&form.permission) + .context("parsing provided permission")?; + + database.update_user(&form.name, |user| { + match form.action { + GrantState::Grant => drop(user.permissions.0.insert(perm.clone(), true)), + GrantState::Revoke => drop(user.permissions.0.insert(perm.clone(), false)), + GrantState::Unset => drop(user.permissions.0.remove(&perm)), + } + Ok(()) + })?; + + manage_single_user( + database, + Some(Ok("Permissions update".into())), + form.name.clone(), + ) +} + +#[post("/admin/remove_user", data = "")] +pub fn r_admin_remove_user( + session: AdminSession, + database: &State, + form: Form, +) -> MyResult> { + drop(session); + if !database.delete_user(&form.name)? { + Err(anyhow!("user did not exist"))?; + } + user_management(database, Some(Ok("User removed".into()))) +} diff --git a/server/src/ui/assets.rs b/server/src/ui/assets.rs new file mode 100644 index 0000000..ce2a8e2 --- /dev/null +++ b/server/src/ui/assets.rs @@ -0,0 +1,201 @@ +/* + 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 +*/ +use super::{error::MyResult, CacheControlFile}; +use crate::logic::session::Session; +use anyhow::{anyhow, bail, Context}; +use base64::Engine; +use jellybase::{ + assetfed::AssetInner, cache::async_cache_file, database::Database, federation::Federation, CONF, +}; +use jellycommon::{LocalTrack, NodeID, PeopleGroup, SourceTrackKind, TrackSource}; +use log::info; +use rocket::{get, http::ContentType, response::Redirect, State}; +use std::{path::PathBuf, str::FromStr}; + +pub const AVIF_QUALITY: f32 = 50.; +pub const AVIF_SPEED: u8 = 5; + +#[get("/asset/?")] +pub async fn r_asset( + _session: Session, + fed: &State, + token: &str, + width: Option, +) -> MyResult<(ContentType, CacheControlFile)> { + let width = width.unwrap_or(2048); + let asset = AssetInner::deser(token)?; + + let path = if let AssetInner::Federated { host, asset } = asset { + let session = fed.get_session(&host).await?; + + let asset = base64::engine::general_purpose::URL_SAFE.encode(asset); + async_cache_file("fed-asset", &asset, |out| async { + session.asset(out, &asset, width).await + }) + .await? + } else { + let source = resolve_asset(asset).await.context("resolving asset")?; + + // fit the resolution into a finite set so the maximum cache is finite too. + let width = 2usize.pow(width.clamp(128, 2048).ilog2()); + jellytranscoder::image::transcode(&source, AVIF_QUALITY, AVIF_SPEED, width) + .await + .context("transcoding asset")? + }; + info!("loading asset from {path:?}"); + Ok(( + ContentType::AVIF, + CacheControlFile::new_cachekey(&path.abs()).await?, + )) +} + +pub async fn resolve_asset(asset: AssetInner) -> anyhow::Result { + match asset { + AssetInner::Cache(c) => Ok(c.abs()), + AssetInner::Assets(c) => Ok(CONF.asset_path.join(c)), + AssetInner::Media(c) => Ok(CONF.media_path.join(c)), + _ => bail!("wrong asset type"), + } +} + +#[get("/n//poster?")] +pub async fn r_item_poster( + _session: Session, + db: &State, + id: NodeID, + width: Option, +) -> MyResult { + // TODO perm + let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; + + let mut asset = node.poster.clone(); + if asset.is_none() { + if let Some(parent) = node.parents.last().copied() { + let parent = db.get_node(parent)?.ok_or(anyhow!("node does not exist"))?; + asset = parent.poster.clone(); + } + }; + let asset = asset.unwrap_or_else(|| { + AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser() + }); + Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) +} + +#[get("/n//backdrop?")] +pub async fn r_item_backdrop( + _session: Session, + db: &State, + id: NodeID, + width: Option, +) -> MyResult { + // TODO perm + let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; + + let mut asset = node.backdrop.clone(); + if asset.is_none() { + if let Some(parent) = node.parents.last().copied() { + let parent = db.get_node(parent)?.ok_or(anyhow!("node does not exist"))?; + asset = parent.backdrop.clone(); + } + }; + let asset = asset.unwrap_or_else(|| { + AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser() + }); + Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) +} + +#[get("/n//person//asset?&")] +pub async fn r_person_asset( + _session: Session, + db: &State, + id: NodeID, + index: usize, + group: String, + width: Option, +) -> MyResult { + // TODO perm + + let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; + let app = node + .people + .get(&PeopleGroup::from_str(&group).map_err(|()| anyhow!("unknown people group"))?) + .ok_or(anyhow!("group has no members"))? + .get(index) + .ok_or(anyhow!("person does not exist"))?; + + let asset = app + .person + .headshot + .to_owned() + .unwrap_or(AssetInner::Assets("fallback-Person.avif".into()).ser()); + Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width)))) +} + +// TODO this can create "federation recursion" because track selection cannot be relied on. +//? TODO is this still relevant? + +#[get("/n//thumbnail?&")] +pub async fn r_node_thumbnail( + _session: Session, + db: &State, + fed: &State, + id: NodeID, + t: f64, + width: Option, +) -> MyResult { + let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?; + + let media = node.media.as_ref().ok_or(anyhow!("no media"))?; + let (thumb_track_index, thumb_track) = media + .tracks + .iter() + .enumerate() + .find(|(_i, t)| matches!(t.kind, SourceTrackKind::Video { .. })) + .ok_or(anyhow!("no video track to create a thumbnail of"))?; + let source = media + .tracks + .get(thumb_track_index) + .ok_or(anyhow!("no source"))?; + let thumb_track_source = source.source.clone(); + + if t < 0. || t > media.duration { + Err(anyhow!("thumbnail instant not within media duration"))? + } + + let step = 8.; + let t = (t / step).floor() * step; + + let asset = match thumb_track_source { + TrackSource::Local(a) => { + let AssetInner::LocalTrack(LocalTrack { path, .. }) = AssetInner::deser(&a.0)? else { + return Err(anyhow!("track set to wrong asset type").into()); + }; + // the track selected might be different from thumb_track + jellytranscoder::thumbnail::create_thumbnail(&CONF.media_path.join(path), t).await? + } + TrackSource::Remote(_) => { + // TODO in the new system this is preferrably a property of node ext for regular fed + let session = fed + .get_session( + thumb_track + .federated + .last() + .ok_or(anyhow!("federation broken"))?, + ) + .await?; + + async_cache_file("fed-thumb", (id, t as i64), |out| { + session.node_thumbnail(out, id.into(), 2048, t) + }) + .await? + } + }; + + Ok(Redirect::temporary(rocket::uri!(r_asset( + AssetInner::Cache(asset).ser().0, + width + )))) +} diff --git a/server/src/ui/browser.rs b/server/src/ui/browser.rs new file mode 100644 index 0000000..f7eac93 --- /dev/null +++ b/server/src/ui/browser.rs @@ -0,0 +1,80 @@ +/* + 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 +*/ +use super::{ + error::MyError, + layout::{trs, DynLayoutPage, LayoutPage}, + node::NodeCard, + sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty}, +}; +use crate::{ + api::AcceptJson, database::Database, locale::AcceptLanguage, logic::session::Session, uri, +}; +use jellybase::locale::tr; +use jellycommon::{api::ApiItemsResponse, Visibility}; +use rocket::{get, serde::json::Json, Either, State}; + +/// This function is a stub and only useful for use in the uri! macro. +#[get("/items")] +pub fn r_all_items() {} + +#[get("/items?&")] +pub fn r_all_items_filter( + sess: Session, + db: &State, + aj: AcceptJson, + page: Option, + filter: NodeFilterSort, + lang: AcceptLanguage, +) -> Result, Json>, MyError> { + let AcceptLanguage(lang) = lang; + let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?; + + items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible)); + + filter_and_sort_nodes( + &filter, + (SortProperty::Title, SortOrder::Ascending), + &mut items, + ); + + let page_size = 100; + let page = page.unwrap_or(0); + let offset = page * page_size; + let from = offset.min(items.len()); + let to = (offset + page_size).min(items.len()); + let max_page = items.len().div_ceil(page_size); + + Ok(if *aj { + Either::Right(Json(ApiItemsResponse { + count: items.len(), + pages: max_page, + items: items[from..to].to_vec(), + })) + } else { + Either::Left(LayoutPage { + title: "All Items".to_owned(), + content: markup::new! { + .page.dir { + h1 { "All Items" } + @NodeFilterSortForm { f: &filter, lang: &lang } + ul.children { @for (node, udata) in &items[from..to] { + li {@NodeCard { node, udata, lang: &lang }} + }} + p.pagecontrols { + span.current { @tr(lang, "page.curr").replace("{cur}", &(page + 1).to_string()).replace("{max}", &max_page.to_string()) " " } + @if page > 0 { + a.prev[href=uri!(r_all_items_filter(Some(page - 1), filter.clone()))] { @trs(&lang, "page.prev") } " " + } + @if page + 1 < max_page { + a.next[href=uri!(r_all_items_filter(Some(page + 1), filter.clone()))] { @trs(&lang, "page.next") } + } + } + } + }, + ..Default::default() + }) + }) +} diff --git a/server/src/ui/error.rs b/server/src/ui/error.rs new file mode 100644 index 0000000..c9620bb --- /dev/null +++ b/server/src/ui/error.rs @@ -0,0 +1,104 @@ +/* + 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 +*/ +use super::layout::{DynLayoutPage, LayoutPage}; +use crate::{ui::account::rocket_uri_macro_r_account_login, uri}; +use jellybase::CONF; +use log::info; +use rocket::{ + catch, + http::{ContentType, Status}, + response::{self, Responder}, + Request, +}; +use serde_json::{json, Value}; +use std::{fmt::Display, fs::File, io::Read, sync::LazyLock}; + +static ERROR_IMAGE: LazyLock> = LazyLock::new(|| { + info!("loading error image"); + let mut f = File::open(CONF.asset_path.join("error.avif")) + .expect("please create error.avif in the asset dir"); + let mut o = Vec::new(); + f.read_to_end(&mut o).unwrap(); + o +}); + +#[catch(default)] +pub fn r_catch<'a>(status: Status, _request: &Request) -> DynLayoutPage<'a> { + LayoutPage { + title: "Not found".to_string(), + content: markup::new! { + h2 { "Error" } + p { @format!("{status}") } + @if status == Status::NotFound { + p { "You might need to " a[href=uri!(r_account_login())] { "log in" } ", to see this page" } + } + }, + ..Default::default() + } +} + +#[catch(default)] +pub fn r_api_catch(status: Status, _request: &Request) -> Value { + json!({ "error": format!("{status}") }) +} + +pub type MyResult = Result; + +// TODO an actual error enum would be useful for status codes + +pub struct MyError(pub anyhow::Error); + +impl<'r> Responder<'r, 'static> for MyError { + fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { + match req.accept().map(|a| a.preferred()) { + Some(x) if x.is_json() => json!({ "error": format!("{}", self.0) }).respond_to(req), + Some(x) if x.is_avif() || x.is_png() || x.is_jpeg() => { + (ContentType::AVIF, ERROR_IMAGE.as_slice()).respond_to(req) + } + _ => LayoutPage { + title: "Error".to_string(), + content: markup::new! { + h2 { "An error occured. Nobody is sorry"} + pre.error { @format!("{:?}", self.0) } + }, + ..Default::default() + } + .respond_to(req), + } + } +} + +impl std::fmt::Debug for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{:?}", self.0)) + } +} + +impl Display for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} +impl From for MyError { + fn from(err: anyhow::Error) -> MyError { + MyError(err) + } +} +impl From for MyError { + fn from(err: std::fmt::Error) -> MyError { + MyError(anyhow::anyhow!("{err}")) + } +} +impl From for MyError { + fn from(err: std::io::Error) -> Self { + MyError(anyhow::anyhow!("{err}")) + } +} +impl From for MyError { + fn from(err: serde_json::Error) -> Self { + MyError(anyhow::anyhow!("{err}")) + } +} diff --git a/server/src/ui/home.rs b/server/src/ui/home.rs new file mode 100644 index 0000000..fbce99b --- /dev/null +++ b/server/src/ui/home.rs @@ -0,0 +1,173 @@ +/* + 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 +*/ +use super::{ + error::MyResult, + layout::{trs, DynLayoutPage, LayoutPage}, + node::{DatabaseNodeUserDataExt, NodeCard}, +}; +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}; + +#[get("/home")] +pub fn r_home( + sess: Session, + db: &State, + aj: AcceptJson, + lang: AcceptLanguage, +) -> MyResult>> { + 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::>>()?; + toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX)); + + let mut categories = Vec::<(String, Vec<_>)>::new(); + + categories.push(( + "home.bin.continue_watching".to_string(), + items + .iter() + .filter(|(_, u)| matches!(u.watched, WatchedState::Progress(_))) + .cloned() + .collect(), + )); + categories.push(( + "home.bin.watchlist".to_string(), + items + .iter() + .filter(|(_, u)| matches!(u.watched, WatchedState::Pending)) + .cloned() + .collect(), + )); + + items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible)); + + items.sort_by_key(|(n, _)| n.release_date.map(|d| -d).unwrap_or(i64::MAX)); + + categories.push(( + "home.bin.latest_video".to_string(), + items + .iter() + .filter(|(n, _)| matches!(n.kind, NodeKind::Video)) + .take(16) + .cloned() + .collect(), + )); + categories.push(( + "home.bin.latest_music".to_string(), + items + .iter() + .filter(|(n, _)| matches!(n.kind, NodeKind::Music)) + .take(16) + .cloned() + .collect(), + )); + categories.push(( + "home.bin.latest_short_form".to_string(), + items + .iter() + .filter(|(n, _)| matches!(n.kind, NodeKind::ShortFormVideo)) + .take(16) + .cloned() + .collect(), + )); + + items.sort_by_key(|(n, _)| { + n.ratings + .get(&Rating::Tmdb) + .map(|x| (*x * -1000.) as i32) + .unwrap_or(0) + }); + + categories.push(( + "home.bin.max_rating".to_string(), + items + .iter() + .take(16) + .filter(|(n, _)| n.ratings.contains_key(&Rating::Tmdb)) + .cloned() + .collect(), + )); + + items.retain(|(n, _)| { + matches!( + n.kind, + NodeKind::Video | NodeKind::Movie | NodeKind::Episode | NodeKind::Music + ) + }); + + categories.push(( + "home.bin.daily_random".to_string(), + (0..16) + .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) + .collect(), + )); + + { + let mut items = items.clone(); + items.retain(|(_, u)| matches!(u.watched, WatchedState::Watched)); + categories.push(( + "home.bin.watch_again".to_string(), + (0..16) + .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) + .collect(), + )); + } + + items.retain(|(n, _)| matches!(n.kind, NodeKind::Music)); + categories.push(( + "home.bin.daily_random_music".to_string(), + (0..16) + .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) + .collect(), + )); + + Ok(if *aj { + Either::Right(Json(ApiHomeResponse { + toplevel, + categories, + })) + } else { + Either::Left(LayoutPage { + title: tr(lang, "home").to_string(), + content: markup::new! { + h2 { @tr(lang, "home.bin.root").replace("{title}", &CONF.brand) } + ul.children.hlist {@for (node, udata) in &toplevel { + li { @NodeCard { node, udata, lang: &lang } } + }} + @for (name, nodes) in &categories { + @if !nodes.is_empty() { + h2 { @trs(&lang, &name) } + ul.children.hlist {@for (node, udata) in nodes { + li { @NodeCard { node, udata, lang: &lang } } + }} + } + } + }, + ..Default::default() + }) + }) +} + +fn cheap_daily_random(i: usize) -> usize { + xorshift(xorshift(Utc::now().num_days_from_ce() as u64) + i as u64) as usize +} + +fn xorshift(mut x: u64) -> u64 { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + x +} diff --git a/server/src/ui/layout.rs b/server/src/ui/layout.rs new file mode 100644 index 0000000..0e8d7b9 --- /dev/null +++ b/server/src/ui/layout.rs @@ -0,0 +1,182 @@ +/* + 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 +*/ +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 = LazyLock::new(|| CONF.asset_path.join("logo.svg").exists()); + +pub struct TrString<'a>(Cow<'a, str>); +impl Render for TrString<'_> { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + self.0.as_str().render(writer) + } +} +impl RenderAttributeValue for TrString<'_> { + fn is_none(&self) -> bool { + false + } + fn is_true(&self) -> bool { + false + } + fn is_false(&self) -> bool { + false + } +} + +pub fn escape(str: &str) -> String { + let mut o = String::with_capacity(str.len()); + let mut last = 0; + for (index, byte) in str.bytes().enumerate() { + if let Some(esc) = match byte { + b'<' => Some("<"), + b'>' => Some(">"), + b'&' => Some("&"), + b'"' => Some("""), + _ => None, + } { + o += &str[last..index]; + o += esc; + last = index + 1; + } + } + o += &str[last..]; + o +} + +pub fn trs<'a>(lang: &Language, key: &str) -> TrString<'a> { + TrString(tr(*lang, key)) +} + +markup::define! { + Layout<'a, Main: Render>(title: String, main: Main, class: &'a str, session: Option, lang: Language) { + @markup::doctype() + html { + head { + title { @title " - " @CONF.brand } + meta[name="viewport", content="width=device-width, initial-scale=1.0"]; + link[rel="stylesheet", href="/assets/style.css"]; + script[src="/assets/bundle.js"] {} + } + body[class=class] { + nav { + h1 { a[href=if session.is_some() {"/home"} else {"/"}] { @if *LOGO_ENABLED { img.logo[src="/assets/logo.svg"]; } else { @CONF.brand } } } " " + @if let Some(_) = session { + a.library[href=uri!(r_library_node("library"))] { @trs(lang, "nav.root") } " " + a.library[href=uri!(r_all_items())] { @trs(lang, "nav.all") } " " + a.library[href=uri!(r_search(None::<&'static str>, None::))] { @trs(lang, "nav.search") } " " + a.library[href=uri!(r_stats())] { @trs(lang, "nav.stats") } " " + } + @if is_importing() { span.warn { "Library database is updating..." } } + div.account { + @if let Some(session) = session { + span { @raw(tr(*lang, "nav.username").replace("{name}", &format!("{}", escape(&session.user.display_name)))) } " " + @if session.user.admin { + a.admin.hybrid_button[href=uri!(r_admin_dashboard())] { p {@trs(lang, "nav.admin")} } " " + } + a.settings.hybrid_button[href=uri!(r_account_settings())] { p {@trs(lang, "nav.settings")} } " " + a.logout.hybrid_button[href=uri!(r_account_logout())] { p {@trs(lang, "nav.logout")} } + } else { + a.register.hybrid_button[href=uri!(r_account_register())] { p {@trs(lang, "nav.register")} } " " + a.login.hybrid_button[href=uri!(r_account_login())] { p {@trs(lang, "nav.login")} } + } + } + } + #main { @main } + footer { + p { @CONF.brand " - " @CONF.slogan " | powered by " a[href="https://codeberg.org/metamuffin/jellything"]{"Jellything"} } + } + } + } + } + + FlashDisplay(flash: Option>) { + @if let Some(flash) = &flash { + @match flash { + Ok(mesg) => { section.message { p.success { @mesg } } } + Err(err) => { section.message { p.error { @err } } } + } + } + } +} + +pub type DynLayoutPage<'a> = LayoutPage>; + +pub struct LayoutPage { + pub title: String, + pub class: Option<&'static str>, + pub content: T, +} + +impl Default for LayoutPage> { + fn default() -> Self { + Self { + class: None, + content: markup::new!(), + title: String::new(), + } + } +} + +impl<'r, Main: Render> Responder<'r, 'static> for LayoutPage
{ + 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::>()).unwrap(); + let mut out = String::new(); + Layout { + main: self.content, + title: self.title, + class: &format!( + "{} theme-{:?}", + self.class.unwrap_or(""), + session + .as_ref() + .map(|s| s.user.theme) + .unwrap_or(Theme::Dark) + ), + session, + lang, + } + .render(&mut out) + .unwrap(); + + Response::build() + .header(ContentType::HTML) + .streamed_body(Cursor::new(out)) + .ok() + } +} diff --git a/server/src/ui/mod.rs b/server/src/ui/mod.rs new file mode 100644 index 0000000..b98fbec --- /dev/null +++ b/server/src/ui/mod.rs @@ -0,0 +1,136 @@ +/* + 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 +*/ +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, + http::{ContentType, Header, Status}, + response::{self, Redirect, Responder}, + Either, Request, Response, +}; +use std::{ + collections::hash_map::DefaultHasher, + future::Future, + hash::{Hash, Hasher}, + io::Cursor, + os::unix::prelude::MetadataExt, + path::Path, + pin::Pin, +}; +use tokio::{ + fs::{read_to_string, File}, + io::AsyncRead, +}; + +pub mod account; +pub mod admin; +pub mod assets; +pub mod browser; +pub mod error; +pub mod home; +pub mod layout; +pub mod node; +pub mod player; +pub mod search; +pub mod sort; +pub mod stats; +pub mod style; + +#[get("/")] +pub async fn r_index(sess: Option) -> MyResult>> { + if sess.is_some() { + Ok(Either::Left(Redirect::temporary(rocket::uri!(r_home())))) + } else { + let front = read_to_string(CONF.asset_path.join("front.htm")).await?; + Ok(Either::Right(LayoutPage { + title: "Home".to_string(), + content: markup::new! { + @markup::raw(&front) + }, + ..Default::default() + })) + } +} + +#[get("/favicon.ico")] +pub async fn r_favicon() -> MyResult { + Ok(File::open(CONF.asset_path.join("favicon.ico")).await?) +} + +pub struct HtmlTemplate<'a>(pub markup::DynRender<'a>); + +impl<'r> Responder<'r, 'static> for HtmlTemplate<'_> { + fn respond_to(self, _req: &'r Request<'_>) -> response::Result<'static> { + let mut out = String::new(); + self.0.render(&mut out).unwrap(); + Response::build() + .header(ContentType::HTML) + .sized_body(out.len(), Cursor::new(out)) + .ok() + } +} + +pub struct Defer(Pin + Send>>); + +impl AsyncRead for Defer { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + match self.0.poll_unpin(cx) { + std::task::Poll::Ready(r) => { + buf.put_slice(r.as_bytes()); + std::task::Poll::Ready(Ok(())) + } + std::task::Poll::Pending => std::task::Poll::Pending, + } + } +} + +pub struct CacheControlFile(File, String); +impl CacheControlFile { + pub async fn new_cachekey(p: &Path) -> anyhow::Result { + 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 new file mode 100644 index 0000000..bf65a3e --- /dev/null +++ b/server/src/ui/node.rs @@ -0,0 +1,558 @@ +/* + 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 +*/ +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 anyhow::{anyhow, Result}; +use chrono::DateTime; +use jellybase::locale::{tr, Language}; +use jellycommon::{ + api::ApiNodeResponse, + user::{NodeUserData, WatchedState}, + Chapter, MediaInfo, Node, NodeID, NodeKind, PeopleGroup, Rating, SourceTrackKind, Visibility, +}; +use rocket::{get, serde::json::Json, Either, State}; +use std::{cmp::Reverse, collections::BTreeMap, fmt::Write, sync::Arc}; + +/// This function is a stub and only useful for use in the uri! macro. +#[get("/n/")] +pub fn r_library_node(id: NodeID) { + let _ = id; +} + +#[get("/n/?&&")] +pub async fn r_library_node_filter<'a>( + session: Session, + id: NodeID, + db: &'a State, + aj: AcceptJson, + filter: NodeFilterSort, + lang: AcceptLanguage, + parents: bool, + children: bool, +) -> MyResult, Json>> { + let AcceptLanguage(lang) = lang; + let (node, udata) = db.get_node_with_userdata(id, &session)?; + + let mut children = if !*aj || children { + db.get_node_children(id)? + .into_iter() + .map(|c| db.get_node_with_userdata(c, &session)) + .collect::>>()? + } else { + Vec::new() + }; + + let mut parents = if !*aj || parents { + node.parents + .iter() + .map(|pid| db.get_node_with_userdata(*pid, &session)) + .collect::>>()? + } else { + Vec::new() + }; + + let mut similar = get_similar_media(&node, db, &session)?; + + similar.retain(|(n, _)| n.visibility >= Visibility::Reduced); + children.retain(|(n, _)| n.visibility >= Visibility::Reduced); + parents.retain(|(n, _)| n.visibility >= Visibility::Reduced); + + filter_and_sort_nodes( + &filter, + match node.kind { + NodeKind::Channel => (SortProperty::ReleaseDate, SortOrder::Descending), + NodeKind::Season | NodeKind::Show => (SortProperty::Index, SortOrder::Ascending), + _ => (SortProperty::Title, SortOrder::Ascending), + }, + &mut children, + ); + + Ok(if *aj { + Either::Right(Json(ApiNodeResponse { + children, + parents, + node, + userdata: udata, + })) + } else { + Either::Left(LayoutPage { + title: node.title.clone().unwrap_or_default(), + content: markup::new!(@NodePage { + node: &node, + udata: &udata, + children: &children, + parents: &parents, + filter: &filter, + player: false, + similar: &similar, + lang: &lang, + }), + ..Default::default() + }) + }) +} + +pub fn get_similar_media( + node: &Node, + db: &Database, + session: &Session, +) -> Result, NodeUserData)>> { + let this_id = NodeID::from_slug(&node.slug); + let mut ranking = BTreeMap::::new(); + for tag in &node.tags { + let nodes = db.get_tag_nodes(tag)?; + let weight = 1_000_000 / nodes.len(); + for n in nodes { + if n != this_id { + *ranking.entry(n).or_default() += weight; + } + } + } + let mut ranking = ranking.into_iter().collect::>(); + ranking.sort_by_key(|(_, k)| Reverse(*k)); + ranking + .into_iter() + .take(32) + .map(|(pid, _)| db.get_node_with_userdata(pid, session)) + .collect::>>() +} + +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, NodeUserData)], + parents: &'a [(Arc, NodeUserData)], + similar: &'a [(Arc, NodeUserData)], + filter: &'a NodeFilterSort, + lang: &'a Language, + player: bool, + ) { + @if !matches!(node.kind, NodeKind::Collection) && !player { + img.backdrop[src=uri!(r_item_backdrop(&node.slug, Some(2048))), loading="lazy"]; + } + .page.node { + @if !matches!(node.kind, NodeKind::Collection) && !player { + @let cls = format!("bigposter {}", aspect_class(node.kind)); + div[class=cls] { img[src=uri!(r_item_poster(&node.slug, Some(2048))), loading="lazy"]; } + } + .title { + h1 { @node.title } + ul.parents { @for (node, _) in *parents { li { + a.component[href=uri!(r_library_node(&node.slug))] { @node.title } + }}} + @if node.media.is_some() { + a.play[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { @trs(lang, "node.player_link") } + } + @if !matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { + @if matches!(udata.watched, WatchedState::None | WatchedState::Pending | WatchedState::Progress(_)) { + form.mark_watched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Watched))] { + input[type="submit", value=trs(lang, "node.watched.set")]; + } + } + @if matches!(udata.watched, WatchedState::Watched) { + form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] { + input[type="submit", value=trs(lang, "node.watched.unset")]; + } + } + @if matches!(udata.watched, WatchedState::None) { + form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Pending))] { + input[type="submit", value=trs(lang, "node.watchlist.set")]; + } + } + @if matches!(udata.watched, WatchedState::Pending) { + form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] { + input[type="submit", value=trs(lang, "node.watchlist.unset")]; + } + } + form.rating[method="POST", action=uri!(r_node_userdata_rating(&node.slug))] { + input[type="range", name="rating", min=-10, max=10, step=1, value=udata.rating]; + input[type="submit", value=trs(lang, "node.update_rating")]; + } + } + } + .details { + @Props { node, udata, full: true, lang } + h3 { @node.tagline } + @if let Some(description) = &node.description { + p { @for line in description.lines() { @line br; } } + } + @if let Some(media) = &node.media { + @if !media.chapters.is_empty() { + h2 { @trs(lang, "node.chapters") } + ul.children.hlist { @for chap in &media.chapters { + @let (inl, sub) = format_chapter(chap); + li { .card."aspect-thumb" { + .poster { + a[href=&uri!(r_player(&node.slug, PlayerConfig::seek(chap.time_start.unwrap_or(0.))))] { + img[src=&uri!(r_node_thumbnail(&node.slug, chapter_key_time(chap, media.duration), Some(1024))), loading="lazy"]; + } + .cardhover { .props { p { @inl } } } + } + .title { span { @sub } } + }} + }} + } + @if !node.people.is_empty() { + h2 { @trs(lang, "node.people") } + @for (group, people) in &node.people { + details[open=group==&PeopleGroup::Cast] { + summary { h3 { @format!("{}", group) } } + ul.children.hlist { @for (i, pe) in people.iter().enumerate() { + li { .card."aspect-port" { + .poster { + a[href="#"] { + img[src=&uri!(r_person_asset(&node.slug, i, group.to_string(), Some(1024))), loading="lazy"]; + } + } + .title { + span { @pe.person.name } br; + @if let Some(c) = pe.characters.first() { + span.subtitle { @c } + } + @if let Some(c) = pe.jobs.first() { + span.subtitle { @c } + } + } + }} + }} + } + } + } + details { + summary { @trs(lang, "media.tracks") } + ol { @for track in &media.tracks { + li { @format!("{track}") } + }} + } + } + @if !node.external_ids.is_empty() { + details { + summary { @trs(lang, "node.external_ids") } + table { + @for (key, value) in &node.external_ids { tr { + tr { + td { @trs(lang, &format!("eid.{}", key)) } + @if let Some(url) = external_id_url(key, value) { + td { a[href=url] { pre { @value } } } + } else { + td { pre { @value } } + } + } + }} + } + } + } + @if !node.tags.is_empty() { + details { + summary { @trs(lang, "node.tags") } + ol { @for tag in &node.tags { + li { @tag } + }} + } + } + } + @if matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { + @NodeFilterSortForm { f: filter, lang } + } + @if !similar.is_empty() { + h2 { @trs(lang, "node.similar") } + ul.children.hlist {@for (node, udata) in similar.iter() { + li { @NodeCard { node, udata, lang } } + }} + } + @match node.kind { + NodeKind::Show | NodeKind::Series | NodeKind::Season => { + ol { @for (node, udata) in children.iter() { + li { @NodeCardWide { node, udata, lang } } + }} + } + NodeKind::Collection | NodeKind::Channel | _ => { + ul.children {@for (node, udata) in children.iter() { + li { @NodeCard { node, udata, lang } } + }} + } + } + } + } + + Props<'a>(node: &'a Node, udata: &'a NodeUserData, full: bool, lang: &'a Language) { + .props { + @if let Some(m) = &node.media { + p { @format_duration(m.duration) } + p { @m.resolution_name() } + } + @if let Some(d) = &node.release_date { + p { @if *full { + @DateTime::from_timestamp_millis(*d).unwrap().naive_utc().to_string() + } else { + @DateTime::from_timestamp_millis(*d).unwrap().date_naive().to_string() + }} + } + @match node.visibility { + Visibility::Visible => {} + Visibility::Reduced => {p.visibility{@trs(lang, "prop.vis.reduced")}} + Visibility::Hidden => {p.visibility{@trs(lang, "prop.vis.hidden")}} + } + // TODO + // @if !node.children.is_empty() { + // p { @format!("{} items", node.children.len()) } + // } + @for (kind, value) in &node.ratings { + @match kind { + Rating::YoutubeLikes => {p.likes{ @format_count(*value as usize) " Likes" }} + Rating::YoutubeViews => {p{ @format_count(*value as usize) " Views" }} + Rating::YoutubeFollowers => {p{ @format_count(*value as usize) " Subscribers" }} + Rating::RottenTomatoes => {p.rating{ @value " Tomatoes" }} + Rating::Metacritic if *full => {p{ "Metacritic Score: " @value }} + Rating::Imdb => {p.rating{ "IMDb " @value }} + Rating::Tmdb => {p.rating{ "TMDB " @value }} + Rating::Trakt if *full => {p.rating{ "Trakt " @value }} + _ => {} + } + } + @if let Some(f) = &node.federated { + p.federation { @f } + } + @match udata.watched { + WatchedState::None => {} + WatchedState::Pending => { p.pending { @trs(lang, "prop.watched.pending") } } + WatchedState::Progress(x) => { p.progress { @tr(**lang, "prop.watched.progress").replace("{time}", &format_duration(x)) } } + WatchedState::Watched => { p.watched { @trs(lang, "prop.watched.watched") } } + } + } + } +} + +pub fn aspect_class(kind: NodeKind) -> &'static str { + use NodeKind::*; + match kind { + Video | Episode => "aspect-thumb", + Collection => "aspect-land", + Season | Show | Series | Movie | ShortFormVideo => "aspect-port", + Channel | Music | Unknown => "aspect-square", + } +} + +pub fn format_duration(d: f64) -> String { + format_duration_mode(d, false, Language::English) +} +pub fn format_duration_long(d: f64, lang: Language) -> String { + format_duration_mode(d, true, lang) +} +fn format_duration_mode(mut d: f64, long_units: bool, lang: Language) -> String { + let mut s = String::new(); + let sign = if d > 0. { "" } else { "-" }; + d = d.abs(); + for (short, long, long_pl, k) in [ + ("d", "time.day", "time.days", 60. * 60. * 24.), + ("h", "time.hour", "time.hours", 60. * 60.), + ("m", "time.minute", "time.minutes", 60.), + ("s", "time.second", "time.seconds", 1.), + ] { + let h = (d / k).floor(); + d -= h * k; + if h > 0. { + if long_units { + let long = tr(lang, if h != 1. { long_pl } else { long }); + let and = format!(" {} ", tr(lang, "time.and_join")); + // TODO breaks if seconds is zero + write!( + s, + "{}{h} {long}{}", + if k != 1. { "" } else { &and }, + if k > 60. { ", " } else { "" }, + ) + .unwrap(); + } else { + write!(s, "{h}{short} ").unwrap(); + } + } + } + format!("{sign}{}", s.trim()) +} +pub fn format_size(size: u64) -> String { + humansize::format_size(size, humansize::DECIMAL) +} +pub fn format_kind(k: NodeKind, lang: Language) -> TrString<'static> { + trs( + &lang, + match k { + NodeKind::Unknown => "kind.unknown", + NodeKind::Movie => "kind.movie", + NodeKind::Video => "kind.video", + NodeKind::Music => "kind.music", + NodeKind::ShortFormVideo => "kind.short_form_video", + NodeKind::Collection => "kind.collection", + NodeKind::Channel => "kind.channel", + NodeKind::Show => "kind.show", + NodeKind::Series => "kind.series", + NodeKind::Season => "kind.season", + NodeKind::Episode => "kind.episode", + }, + ) +} + +pub trait DatabaseNodeUserDataExt { + fn get_node_with_userdata( + &self, + id: NodeID, + session: &Session, + ) -> Result<(Arc, NodeUserData)>; +} +impl DatabaseNodeUserDataExt for Database { + fn get_node_with_userdata( + &self, + id: NodeID, + session: &Session, + ) -> Result<(Arc, NodeUserData)> { + Ok(( + self.get_node(id)?.ok_or(anyhow!("node does not exist"))?, + self.get_node_udata(id, &session.user.name)? + .unwrap_or_default(), + )) + } +} + +trait MediaInfoExt { + fn resolution_name(&self) -> &'static str; +} +impl MediaInfoExt for MediaInfo { + fn resolution_name(&self) -> &'static str { + let mut maxdim = 0; + for t in &self.tracks { + if let SourceTrackKind::Video { width, height, .. } = &t.kind { + maxdim = maxdim.max(*width.max(height)) + } + } + + match maxdim { + 30720.. => "32K", + 15360.. => "16K", + 7680.. => "8K UHD", + 5120.. => "5K UHD", + 3840.. => "4K UHD", + 2560.. => "QHD 1440p", + 1920.. => "FHD 1080p", + 1280.. => "HD 720p", + 854.. => "SD 480p", + _ => "Unkown", + } + } +} + +fn format_count(n: impl Into) -> 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 { + 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 new file mode 100644 index 0000000..cd4d03c --- /dev/null +++ b/server/src/ui/player.rs @@ -0,0 +1,198 @@ +/* + 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 +*/ +use super::{ + layout::LayoutPage, + node::{get_similar_media, DatabaseNodeUserDataExt, NodePage}, + 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, +}; +use markup::DynRender; +use rocket::{get, response::Redirect, Either, FromForm, State, UriDisplayQuery}; +use std::sync::Arc; + +#[derive(FromForm, Default, Clone, Debug, UriDisplayQuery)] +pub struct PlayerConfig { + pub a: Option, + pub v: Option, + pub s: Option, + pub t: Option, + pub kind: Option, +} + +impl PlayerConfig { + pub fn seek(t: f64) -> Self { + Self { + t: Some(t), + ..Default::default() + } + } +} + +fn jellynative_url(action: &str, seek: f64, secret: &str, node: &str, session: &str) -> String { + let protocol = if CONF.tls { "https" } else { "http" }; + let host = &CONF.hostname; + let stream_url = format!( + "/n/{node}/stream{}", + StreamSpec::HlsMultiVariant { + segment: 0, + container: StreamContainer::Matroska + } + .to_query() + ); + format!("jellynative://{action}/{secret}/{session}/{seek}/{protocol}://{host}{stream_url}",) +} + +#[get("/n//player?", rank = 4)] +pub fn r_player( + session: Session, + lang: AcceptLanguage, + db: &State, + id: NodeID, + conf: PlayerConfig, +) -> MyResult, Redirect>> { + let AcceptLanguage(lang) = lang; + let (node, udata) = db.get_node_with_userdata(id, &session)?; + + let mut parents = node + .parents + .iter() + .map(|pid| db.get_node_with_userdata(*pid, &session)) + .collect::>>()?; + + let mut similar = get_similar_media(&node, db, &session)?; + + similar.retain(|(n, _)| n.visibility >= Visibility::Reduced); + parents.retain(|(n, _)| n.visibility >= Visibility::Reduced); + + let native_session = |action: &str| { + Ok(Either::Right(Redirect::temporary(jellynative_url( + action, + conf.t.unwrap_or(0.), + &session.user.native_secret, + &id.to_string(), + &session::create( + session.user.name, + PermissionSet::default(), // TODO + chrono::Duration::hours(24), + ), + )))) + }; + + match conf.kind.unwrap_or(session.user.player_preference) { + PlayerKind::Browser => (), + PlayerKind::Native => { + return native_session("player-v2"); + } + PlayerKind::NativeFullscreen => { + return native_session("player-fullscreen-v2"); + } + } + + // TODO + // let spec = StreamSpec { + // track: None + // .into_iter() + // .chain(conf.v) + // .chain(conf.a) + // .chain(conf.s) + // .collect::>(), + // format: StreamFormat::Matroska, + // webm: Some(true), + // ..Default::default() + // }; + // let playing = false; // !spec.track.is_empty(); + // let conf = player_conf(node.clone(), playing)?; + + Ok(Either::Left(LayoutPage { + title: node.title.to_owned().unwrap_or_default(), + class: Some("player"), + content: markup::new! { + // @if playing { + // // video[src=uri!(r_stream(&node.slug, &spec)), controls, preload="auto"]{} + // } + // @conf + @NodePage { + children: &[], + parents: &parents, + filter: &NodeFilterSort::default(), + node: &node, + udata: &udata, + player: true, + similar: &similar, + lang: &lang + } + }, + })) +} + +pub fn player_conf<'a>(item: Arc, playing: bool) -> anyhow::Result> { + let mut audio_tracks = vec![]; + let mut video_tracks = vec![]; + let mut sub_tracks = vec![]; + let tracks = item + .media + .clone() + .ok_or(anyhow!("node does not have media"))? + .tracks + .clone(); + for (tid, track) in tracks.into_iter().enumerate() { + match &track.kind { + SourceTrackKind::Audio { .. } => audio_tracks.push((tid, track)), + SourceTrackKind::Video { .. } => video_tracks.push((tid, track)), + SourceTrackKind::Subtitles => sub_tracks.push((tid, track)), + } + } + + Ok(markup::new! { + form.playerconf[method = "GET", action = ""] { + h2 { "Select tracks for " @item.title } + + fieldset.video { + legend { "Video" } + @for (i, (tid, track)) in video_tracks.iter().enumerate() { + input[type="radio", id=tid, name="v", value=tid, checked=i==0]; + label[for=tid] { @format!("{track}") } br; + } + input[type="radio", id="v-none", name="v", value=""]; + label[for="v-none"] { "No video" } + } + + fieldset.audio { + legend { "Audio" } + @for (i, (tid, track)) in audio_tracks.iter().enumerate() { + input[type="radio", id=tid, name="a", value=tid, checked=i==0]; + label[for=tid] { @format!("{track}") } br; + } + input[type="radio", id="a-none", name="a", value=""]; + label[for="a-none"] { "No audio" } + } + + fieldset.subtitles { + legend { "Subtitles" } + @for (_i, (tid, track)) in sub_tracks.iter().enumerate() { + input[type="radio", id=tid, name="s", value=tid]; + label[for=tid] { @format!("{track}") } br; + } + input[type="radio", id="s-none", name="s", value="", checked=true]; + label[for="s-none"] { "No subtitles" } + } + + input[type="submit", value=if playing { "Change tracks" } else { "Start playback" }]; + } + }) +} diff --git a/server/src/ui/search.rs b/server/src/ui/search.rs new file mode 100644 index 0000000..96be3a6 --- /dev/null +++ b/server/src/ui/search.rs @@ -0,0 +1,69 @@ +/* + 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 +*/ +use super::{ + error::MyResult, + layout::{trs, DynLayoutPage, LayoutPage}, + node::{DatabaseNodeUserDataExt, NodeCard}, +}; +use crate::{api::AcceptJson, locale::AcceptLanguage, logic::session::Session}; +use anyhow::anyhow; +use jellybase::{database::Database, locale::tr}; +use jellycommon::{api::ApiSearchResponse, Visibility}; +use rocket::{get, serde::json::Json, Either, State}; +use std::time::Instant; + +#[get("/search?&")] +pub async fn r_search<'a>( + session: Session, + db: &State, + aj: AcceptJson, + query: Option<&str>, + page: Option, + lang: AcceptLanguage, +) -> MyResult, Json>> { + let AcceptLanguage(lang) = lang; + let results = if let Some(query) = query { + let timing = Instant::now(); + let (count, ids) = db.search(query, 32, page.unwrap_or_default() * 32)?; + let mut nodes = ids + .into_iter() + .map(|id| db.get_node_with_userdata(id, &session)) + .collect::, anyhow::Error>>()?; + nodes.retain(|(n, _)| n.visibility >= Visibility::Reduced); + let search_dur = timing.elapsed(); + Some((count, nodes, search_dur)) + } else { + None + }; + let query = query.unwrap_or_default().to_string(); + + Ok(if *aj { + let Some((count, results, _)) = results else { + Err(anyhow!("no query"))? + }; + Either::Right(Json(ApiSearchResponse { count, results })) + } else { + Either::Left(LayoutPage { + title: tr(lang, "search.title").to_string(), + class: Some("search"), + content: markup::new! { + h1 { @trs(&lang, "search.title") } + form[action="", method="GET"] { + input[type="text", name="query", placeholder=&*tr(lang, "search.placeholder"), value=&query]; + input[type="submit", value="Search"]; + } + @if let Some((count, results, search_dur)) = &results { + h2 { @trs(&lang, "search.results.title") } + p.stats { @tr(lang, "search.results.stats").replace("{count}", &count.to_string()).replace("{dur}", &format!("{search_dur:?}")) } + ul.children {@for (node, udata) in results.iter() { + li { @NodeCard { node, udata, lang: &lang } } + }} + // TODO pagination + } + }, + }) + }) +} diff --git a/server/src/ui/sort.rs b/server/src/ui/sort.rs new file mode 100644 index 0000000..a241030 --- /dev/null +++ b/server/src/ui/sort.rs @@ -0,0 +1,297 @@ +/* + 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 +*/ +use crate::ui::layout::trs; +use jellybase::locale::Language; +use jellycommon::{helpers::SortAnyway, user::NodeUserData, Node, NodeKind, Rating}; +use markup::RenderAttributeValue; +use rocket::{ + http::uri::fmt::{Query, UriDisplay}, + FromForm, FromFormField, UriDisplayQuery, +}; +use std::sync::Arc; + +#[derive(FromForm, UriDisplayQuery, Default, Clone)] +pub struct NodeFilterSort { + pub sort_by: Option, + pub filter_kind: Option>, + pub sort_order: Option, +} + +macro_rules! form_enum { + (enum $i:ident { $($vi:ident = $vk:literal),*, }) => { + #[derive(Debug, FromFormField, UriDisplayQuery, Clone, Copy, PartialEq, Eq)] + pub enum $i { $(#[field(value = $vk)] $vi),* } + impl $i { #[allow(unused)] const ALL: &'static [$i] = &[$($i::$vi),*]; } + }; +} + +form_enum!( + enum FilterProperty { + FederationLocal = "fed_local", + FederationRemote = "fed_remote", + Watched = "watched", + Unwatched = "unwatched", + WatchProgress = "watch_progress", + KindMovie = "kind_movie", + KindVideo = "kind_video", + KindShortFormVideo = "kind_short_form_video", + KindMusic = "kind_music", + KindCollection = "kind_collection", + KindChannel = "kind_channel", + KindShow = "kind_show", + KindSeries = "kind_series", + KindSeason = "kind_season", + KindEpisode = "kind_episode", + } +); + +form_enum!( + enum SortProperty { + ReleaseDate = "release_date", + Title = "title", + Index = "index", + Duration = "duration", + RatingRottenTomatoes = "rating_rt", + RatingMetacritic = "rating_mc", + RatingImdb = "rating_imdb", + RatingTmdb = "rating_tmdb", + RatingYoutubeViews = "rating_yt_views", + RatingYoutubeLikes = "rating_yt_likes", + RatingYoutubeFollowers = "rating_yt_followers", + RatingUser = "rating_user", + RatingLikesDivViews = "rating_loved", + } +); + +impl SortProperty { + const CATS: &'static [(&'static str, &'static [(SortProperty, &'static str)])] = { + use SortProperty::*; + &[ + ( + "filter_sort.sort.general", + &[(Title, "node.title"), (ReleaseDate, "node.release_date")], + ), + ("filter_sort.sort.media", &[(Duration, "media.runtime")]), + ( + "filter_sort.sort.rating", + &[ + (RatingImdb, "rating.imdb"), + (RatingTmdb, "rating.tmdb"), + (RatingMetacritic, "rating.metacritic"), + (RatingRottenTomatoes, "rating.rotten_tomatoes"), + (RatingYoutubeFollowers, "rating.youtube_followers"), + (RatingYoutubeLikes, "rating.youtube_likes"), + (RatingYoutubeViews, "rating.youtube_views"), + (RatingUser, "filter_sort.sort.rating.user"), + ( + RatingLikesDivViews, + "filter_sort.sort.rating.likes_div_views", + ), + ], + ), + ] + }; +} +impl FilterProperty { + const CATS: &'static [(&'static str, &'static [(FilterProperty, &'static str)])] = { + use FilterProperty::*; + &[ + ( + "filter_sort.filter.kind", + &[ + (KindMovie, "kind.movie"), + (KindVideo, "kind.video"), + (KindShortFormVideo, "kind.short_form_video"), + (KindMusic, "kind.music"), + (KindCollection, "kind.collection"), + (KindChannel, "kind.channel"), + (KindShow, "kind.show"), + (KindSeries, "kind.series"), + (KindSeason, "kind.season"), + (KindEpisode, "kind.episode"), + ], + ), + ( + "filter_sort.filter.federation", + &[ + (FederationLocal, "federation.local"), + (FederationRemote, "federation.remote"), + ], + ), + ( + "filter_sort.filter.watched", + &[ + (Watched, "watched.watched"), + (Unwatched, "watched.none"), + (WatchProgress, "watched.progress"), + ], + ), + ] + }; +} + +impl NodeFilterSort { + pub fn is_open(&self) -> bool { + self.filter_kind.is_some() || self.sort_by.is_some() + } +} + +#[rustfmt::skip] +#[derive(FromFormField, UriDisplayQuery, Clone, Copy, PartialEq, Eq)] +pub enum SortOrder { + #[field(value = "ascending")] Ascending, + #[field(value = "descending")] Descending, +} + +pub fn filter_and_sort_nodes( + f: &NodeFilterSort, + default_sort: (SortProperty, SortOrder), + nodes: &mut Vec<(Arc, NodeUserData)>, +) { + let sort_prop = f.sort_by.unwrap_or(default_sort.0); + nodes.retain(|(node, _udata)| { + let mut o = true; + if let Some(prop) = &f.filter_kind { + o = false; + for p in prop { + o |= match p { + // FilterProperty::FederationLocal => node.federated.is_none(), + // FilterProperty::FederationRemote => node.federated.is_some(), + FilterProperty::KindMovie => node.kind == NodeKind::Movie, + FilterProperty::KindVideo => node.kind == NodeKind::Video, + FilterProperty::KindShortFormVideo => node.kind == NodeKind::ShortFormVideo, + FilterProperty::KindMusic => node.kind == NodeKind::Music, + FilterProperty::KindCollection => node.kind == NodeKind::Collection, + FilterProperty::KindChannel => node.kind == NodeKind::Channel, + FilterProperty::KindShow => node.kind == NodeKind::Show, + FilterProperty::KindSeries => node.kind == NodeKind::Series, + FilterProperty::KindSeason => node.kind == NodeKind::Season, + FilterProperty::KindEpisode => node.kind == NodeKind::Episode, + // FilterProperty::Watched => udata.watched == WatchedState::Watched, + // FilterProperty::Unwatched => udata.watched == WatchedState::None, + // FilterProperty::WatchProgress => { + // matches!(udata.watched, WatchedState::Progress(_)) + // } + _ => false, // TODO + } + } + } + match sort_prop { + SortProperty::ReleaseDate => o &= node.release_date.is_some(), + SortProperty::Duration => o &= node.media.is_some(), + _ => (), + } + o + }); + match sort_prop { + SortProperty::Duration => { + nodes.sort_by_key(|(n, _)| (n.media.as_ref().unwrap().duration * 1000.) as i64) + } + SortProperty::ReleaseDate => { + nodes.sort_by_key(|(n, _)| n.release_date.expect("asserted above")) + } + SortProperty::Title => nodes.sort_by(|(a, _), (b, _)| a.title.cmp(&b.title)), + SortProperty::Index => nodes.sort_by(|(a, _), (b, _)| { + a.index + .unwrap_or(usize::MAX) + .cmp(&b.index.unwrap_or(usize::MAX)) + }), + SortProperty::RatingRottenTomatoes => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::RottenTomatoes).unwrap_or(&0.)) + }), + SortProperty::RatingMetacritic => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::Metacritic).unwrap_or(&0.)) + }), + SortProperty::RatingImdb => nodes + .sort_by_cached_key(|(n, _)| SortAnyway(*n.ratings.get(&Rating::Imdb).unwrap_or(&0.))), + SortProperty::RatingTmdb => nodes + .sort_by_cached_key(|(n, _)| SortAnyway(*n.ratings.get(&Rating::Tmdb).unwrap_or(&0.))), + SortProperty::RatingYoutubeViews => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::YoutubeViews).unwrap_or(&0.)) + }), + SortProperty::RatingYoutubeLikes => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::YoutubeLikes).unwrap_or(&0.)) + }), + SortProperty::RatingYoutubeFollowers => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::YoutubeFollowers).unwrap_or(&0.)) + }), + SortProperty::RatingLikesDivViews => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway( + *n.ratings.get(&Rating::YoutubeLikes).unwrap_or(&0.) + / (1. + *n.ratings.get(&Rating::YoutubeViews).unwrap_or(&0.)), + ) + }), + SortProperty::RatingUser => nodes.sort_by_cached_key(|(_, u)| u.rating), + } + + match f.sort_order.unwrap_or(default_sort.1) { + SortOrder::Ascending => (), + SortOrder::Descending => nodes.reverse(), + } +} + +markup::define! { + NodeFilterSortForm<'a>(f: &'a NodeFilterSort, lang: &'a Language) { + details.filtersort[open=f.is_open()] { + summary { "Filter and Sort" } + form[method="GET", action=""] { + fieldset.filter { + legend { "Filter" } + .categories { + @for (cname, cat) in FilterProperty::CATS { + .category { + h3 { @trs(lang, cname) } + @for (value, label) in *cat { + label { input[type="checkbox", name="filter_kind", value=value, checked=f.filter_kind.as_ref().map(|k|k.contains(value)).unwrap_or(true)]; @trs(lang, label) } br; + } + } + } + } + } + fieldset.sortby { + legend { "Sort" } + .categories { + @for (cname, cat) in SortProperty::CATS { + .category { + h3 { @trs(lang, cname) } + @for (value, label) in *cat { + label { input[type="radio", name="sort_by", value=value, checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br; + } + } + } + } + } + fieldset.sortorder { + legend { "Sort Order" } + @use SortOrder::*; + @for (value, label) in [(Ascending, "filter_sort.order.asc"), (Descending, "filter_sort.order.desc")] { + label { input[type="radio", name="sort_order", value=value, checked=Some(value)==f.sort_order]; @trs(lang, label) } br; + } + } + input[type="submit", value="Apply"]; a[href="?"] { "Clear" } + } + } + } +} + +impl markup::Render for SortProperty { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_fmt(format_args!("{}", self as &dyn UriDisplay)) + } +} +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)) + } +} +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)) + } +} +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 new file mode 100644 index 0000000..4c5bed8 --- /dev/null +++ b/server/src/ui/stats.rs @@ -0,0 +1,131 @@ +/* + 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 +*/ +use super::{ + error::MyError, + layout::{DynLayoutPage, LayoutPage}, +}; +use crate::{ + api::AcceptJson, + database::Database, + locale::AcceptLanguage, + logic::session::Session, + ui::{ + layout::trs, + node::{ + format_duration, format_duration_long, format_kind, format_size, + rocket_uri_macro_r_library_node, + }, + }, + uri, +}; +use jellybase::locale::tr; +use jellycommon::{Node, NodeID, NodeKind, Visibility}; +use markup::raw; +use rocket::{get, serde::json::Json, Either, State}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +#[get("/stats")] +pub fn r_stats( + sess: Session, + db: &State, + aj: AcceptJson, + lang: AcceptLanguage, +) -> Result, Json>, MyError> { + let AcceptLanguage(lang) = lang; + let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?; + items.retain(|(n, _)| n.visibility >= Visibility::Reduced); + + #[derive(Default, Serialize)] + struct Bin { + runtime: f64, + size: u64, + count: usize, + max_runtime: (f64, String), + max_size: (u64, String), + } + impl Bin { + fn update(&mut self, node: &Node) { + self.count += 1; + self.size += node.storage_size; + if node.storage_size > self.max_size.0 { + self.max_size = (node.storage_size, node.slug.clone()) + } + if let Some(m) = &node.media { + self.runtime += m.duration; + if m.duration > self.max_runtime.0 { + self.max_runtime = (m.duration, node.slug.clone()) + } + } + } + fn average_runtime(&self) -> f64 { + self.runtime / self.count as f64 + } + fn average_size(&self) -> f64 { + self.size as f64 / self.count as f64 + } + } + + let mut all = Bin::default(); + let mut kinds = BTreeMap::::new(); + for (i, _) in items { + all.update(&i); + kinds.entry(i.kind).or_default().update(&i); + } + + Ok(if *aj { + Either::Right(Json(json!({ + "all": all, + "kinds": kinds, + }))) + } else { + Either::Left(LayoutPage { + title: tr(lang, "stats.title").to_string(), + content: markup::new! { + .page.stats { + h1 { @trs(&lang, "stats.title") } + p { @raw(tr(lang, "stats.count") + .replace("{count}", &format!("{}", all.count)) + )} + p { @raw(tr(lang, "stats.runtime") + .replace("{dur}", &format!("{}", format_duration_long(all.runtime, lang))) + .replace("{size}", &format!("{}", format_size(all.size))) + )} + p { @raw(tr(lang, "stats.average") + .replace("{dur}", &format!("{}", format_duration(all.average_runtime()))) + .replace("{size}", &format!("{}", format_size(all.average_size() as u64))) + )} + + h2 { @trs(&lang, "stats.by_kind.title") } + table.striped { + tr { + th { @trs(&lang, "stats.by_kind.kind") } + th { @trs(&lang, "stats.by_kind.count") } + th { @trs(&lang, "stats.by_kind.total_size") } + th { @trs(&lang, "stats.by_kind.total_runtime") } + th { @trs(&lang, "stats.by_kind.average_size") } + th { @trs(&lang, "stats.by_kind.average_runtime") } + th { @trs(&lang, "stats.by_kind.max_size") } + th { @trs(&lang, "stats.by_kind.max_runtime") } + } + @for (k,b) in &kinds { tr { + td { @format_kind(*k, lang) } + td { @b.count } + td { @format_size(b.size) } + td { @format_duration(b.runtime) } + td { @format_size(b.average_size() as u64) } + td { @format_duration(b.average_runtime()) } + td { @if b.max_size.0 > 0 { a[href=uri!(r_library_node(&b.max_size.1))]{ @format_size(b.max_size.0) }}} + td { @if b.max_runtime.0 > 0. { a[href=uri!(r_library_node(&b.max_runtime.1))]{ @format_duration(b.max_runtime.0) }}} + }} + } + } + }, + ..Default::default() + }) + }) +} diff --git a/server/src/ui/style.rs b/server/src/ui/style.rs new file mode 100644 index 0000000..77f0fe1 --- /dev/null +++ b/server/src/ui/style.rs @@ -0,0 +1,90 @@ +/* + 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 + Copyright (C) 2023 tpart +*/ +use rocket::{ + get, + http::{ContentType, Header}, + response::Responder, +}; + +macro_rules! concat_files { + ([$base: expr], $($files:literal),*) => {{ + #[cfg(any(debug_assertions, feature = "hot-css"))] + { + use std::{fs::read_to_string, path::PathBuf, str::FromStr}; + [ $($files),* ] + .into_iter() + .map(|n| { + read_to_string({ + let p = PathBuf::from_str(file!()).unwrap().parent().unwrap().join($base).join(n); + log::info!("load {p:?}"); + p + }) + .unwrap() + }) + .collect::>() + .join("\n") + } + #[cfg(not(any(debug_assertions, feature = "hot-css")))] + concat!($(include_str!(concat!($base, "/", $files))),*).to_string() + }}; +} + +fn css_bundle() -> String { + concat_files!( + ["../../../web/style"], + "layout.css", + "player.css", + "nodepage.css", + "nodecard.css", + "js-player.css", + "js-transition.css", + "forms.css", + "props.css", + "themes.css", + "navbar.css" + ) +} + +pub struct CachedAsset(pub T); +impl<'r, 'o: 'r, T: Responder<'r, 'o>> Responder<'r, 'o> for CachedAsset { + fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'o> { + let mut res = self.0.respond_to(request)?; + if cfg!(not(debug_assertions)) { + res.set_header(Header::new("cache-control", "max-age=86400")); + } + Ok(res) + } +} + +fn js_bundle() -> String { + concat_files!([env!("OUT_DIR")], "bundle.js") +} +fn js_bundle_map() -> String { + concat_files!([env!("OUT_DIR")], "bundle.js.map") +} + +#[get("/assets/style.css")] +pub fn r_assets_style() -> CachedAsset<(ContentType, String)> { + CachedAsset((ContentType::CSS, css_bundle())) +} + +#[get("/assets/cantarell.woff2")] +pub fn r_assets_font() -> CachedAsset<(ContentType, &'static [u8])> { + CachedAsset(( + ContentType::WOFF2, + include_bytes!("../../../web/cantarell.woff2"), + )) +} + +#[get("/assets/bundle.js")] +pub fn r_assets_js() -> CachedAsset<(ContentType, String)> { + CachedAsset((ContentType::JavaScript, js_bundle())) +} +#[get("/assets/bundle.js.map")] +pub fn r_assets_js_map() -> CachedAsset<(ContentType, String)> { + CachedAsset((ContentType::JSON, js_bundle_map())) +} -- cgit v1.2.3-70-g09d2