aboutsummaryrefslogtreecommitdiff
path: root/server/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/routes')
-rw-r--r--server/src/routes/api.rs108
-rw-r--r--server/src/routes/compat/jellyfin/mod.rs877
-rw-r--r--server/src/routes/compat/jellyfin/models.rs199
-rw-r--r--server/src/routes/compat/mod.rs7
-rw-r--r--server/src/routes/compat/youtube.rs60
-rw-r--r--server/src/routes/locale.rs55
-rw-r--r--server/src/routes/mod.rs238
-rw-r--r--server/src/routes/playersync.rs108
-rw-r--r--server/src/routes/stream.rs246
-rw-r--r--server/src/routes/ui/account/mod.rs261
-rw-r--r--server/src/routes/ui/account/session/guard.rs106
-rw-r--r--server/src/routes/ui/account/session/mod.rs24
-rw-r--r--server/src/routes/ui/account/session/token.rs97
-rw-r--r--server/src/routes/ui/account/settings.rs187
-rw-r--r--server/src/routes/ui/admin/log.rs258
-rw-r--r--server/src/routes/ui/admin/mod.rs290
-rw-r--r--server/src/routes/ui/admin/user.rs176
-rw-r--r--server/src/routes/ui/assets.rs200
-rw-r--r--server/src/routes/ui/browser.rs83
-rw-r--r--server/src/routes/ui/error.rs104
-rw-r--r--server/src/routes/ui/home.rs180
-rw-r--r--server/src/routes/ui/layout.rs184
-rw-r--r--server/src/routes/ui/mod.rs131
-rw-r--r--server/src/routes/ui/node.rs558
-rw-r--r--server/src/routes/ui/player.rs200
-rw-r--r--server/src/routes/ui/search.rs70
-rw-r--r--server/src/routes/ui/sort.rs290
-rw-r--r--server/src/routes/ui/stats.rs133
-rw-r--r--server/src/routes/ui/style.rs90
-rw-r--r--server/src/routes/userdata.rs95
30 files changed, 0 insertions, 5615 deletions
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 <metamuffin.org>
-*/
-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 = "<data>")]
-pub fn r_api_account_login(
- database: &State<Database>,
- data: Json<CreateSessionParams>,
-) -> MyResult<Value> {
- let token = login_logic(
- database,
- &data.username,
- &data.password,
- data.expire,
- data.drop_permissions.clone(),
- )?;
- Ok(json!(token))
-}
-
-#[get("/api/asset_token_raw/<token>")]
-pub fn r_api_asset_token_raw(_admin: AdminSession, token: &str) -> MyResult<Json<AssetInner>> {
- Ok(Json(AssetInner::deser(token)?))
-}
-
-#[get("/api/nodes_modified?<since>")]
-pub fn r_api_nodes_modified_since(
- _session: Session,
- database: &State<Database>,
- since: u64,
-) -> MyResult<Json<Vec<NodeID>>> {
- 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<Output = request::Outcome<Self, Self::Error>>
- + ::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 <metamuffin.org>
-*/
-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<Value> {
- r_jellyfin_system_info_public()
-}
-
-#[get("/system/info/public")]
-pub fn r_jellyfin_system_info_public() -> Json<Value> {
- 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<Value> {
- Json(json!({
- "LoginDisclaimer": format!("{} - {}", CONF.brand, CONF.slogan),
- "CustomCss": "",
- "SplashscreenEnabled": false,
- }))
-}
-
-#[get("/users/public")]
-pub fn r_jellyfin_users_public() -> Json<Value> {
- Json(json!([]))
-}
-
-#[get("/Branding/Css")]
-pub fn r_jellyfin_branding_css() -> String {
- "".to_string()
-}
-
-#[get("/QuickConnect/Enabled")]
-pub fn r_jellyfin_quickconnect_enabled() -> Json<Value> {
- Json(json!(false))
-}
-
-#[get("/System/Endpoint")]
-pub fn r_jellyfin_system_endpoint(_session: Session) -> Json<Value> {
- 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<Value> {
- 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<Value> {
- 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/<id>")]
-pub fn r_jellyfin_users_id(session: Session, id: &str) -> Json<Value> {
- let _ = id;
- Json(user_object(session.user.name))
-}
-
-#[get("/Items/<id>/Images/Primary?<fillWidth>&<tag>")]
-#[allow(non_snake_case)]
-pub fn r_jellyfin_items_image_primary(
- _session: Session,
- id: &str,
- fillWidth: Option<usize>,
- 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/<id>/Images/Backdrop/0?<maxWidth>")]
-#[allow(non_snake_case)]
-pub fn r_jellyfin_items_images_backdrop(
- _session: Session,
- id: &str,
- maxWidth: Option<usize>,
-) -> Redirect {
- Redirect::permanent(rocket::uri!(r_item_backdrop(id, maxWidth)))
-}
-
-#[get("/Items/<id>")]
-#[allow(private_interfaces)]
-pub fn r_jellyfin_items_item(
- session: Session,
- database: &State<Database>,
- id: &str,
-) -> MyResult<Json<JellyfinItem>> {
- let (n, ud) = database.get_node_with_userdata(NodeID::from_slug(id), &session)?;
- Ok(Json(item_object(&n, &ud)))
-}
-#[get("/Users/<uid>/Items/<id>")]
-#[allow(private_interfaces)]
-pub fn r_jellyfin_users_items_item(
- session: Session,
- database: &State<Database>,
- uid: &str,
- id: &str,
-) -> MyResult<Json<JellyfinItem>> {
- let _ = uid;
- r_jellyfin_items_item(session, database, id)
-}
-
-#[derive(Debug, FromForm)]
-struct JellyfinItemQuery {
- #[field(name = uncased("searchterm"))]
- search_term: Option<String>,
- #[field(name = uncased("limit"))]
- limit: usize,
- #[field(name = uncased("parentid"))]
- parent_id: Option<String>,
- #[field(name = uncased("startindex"))]
- start_index: Option<usize>,
- #[field(name = uncased("includeitemtypes"))]
- include_item_types: Option<String>,
-
- internal_artists: bool,
- internal_persons: bool,
-}
-
-#[get("/Users/<uid>/Items?<query..>")]
-#[allow(private_interfaces)]
-pub fn r_jellyfin_users_items(
- session: Session,
- database: &State<Database>,
- uid: &str,
- query: JellyfinItemQuery,
-) -> MyResult<Json<Value>> {
- let _ = uid;
- r_jellyfin_items(session, database, query)
-}
-
-#[get("/Artists?<query..>")]
-#[allow(private_interfaces)]
-pub fn r_jellyfin_artists(
- session: Session,
- database: &State<Database>,
- mut query: JellyfinItemQuery,
-) -> MyResult<Json<Value>> {
- query.internal_artists = true;
- r_jellyfin_items(session, database, query)?; // TODO
- Ok(Json(json!({
- "Items": [],
- "TotalRecordCount": 0,
- "StartIndex": 0
- })))
-}
-
-#[get("/Persons?<query..>")]
-#[allow(private_interfaces)]
-pub fn r_jellyfin_persons(
- session: Session,
- database: &State<Database>,
- mut query: JellyfinItemQuery,
-) -> MyResult<Json<Value>> {
- query.internal_persons = true;
- r_jellyfin_items(session, database, query)?; // TODO
- Ok(Json(json!({
- "Items": [],
- "TotalRecordCount": 0,
- "StartIndex": 0
- })))
-}
-
-#[get("/Items?<query..>")]
-#[allow(private_interfaces)]
-pub fn r_jellyfin_items(
- session: Session,
- database: &State<Database>,
- query: JellyfinItemQuery,
-) -> MyResult<Json<Value>> {
- 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::<Result<Vec<_>, 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::<Vec<_>>();
-
- Ok(Json(json!({
- "Items": items,
- "TotalRecordCount": items.len(),
- "StartIndex": query.start_index.unwrap_or_default()
- })))
-}
-
-#[get("/UserViews?<userId>")]
-#[allow(non_snake_case)]
-pub fn r_jellyfin_users_views(
- session: Session,
- database: &State<Database>,
- userId: &str,
-) -> MyResult<Json<Value>> {
- 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::<Result<Vec<_>, 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/<id>/Similar")]
-pub fn r_jellyfin_items_similar(_session: Session, id: &str) -> Json<Value> {
- let _ = id;
- Json(json!({
- "Items": [],
- "TotalRecordCount": 0,
- "StartIndex": 0
- }))
-}
-
-#[get("/LiveTv/Programs/Recommended")]
-pub fn r_jellyfin_livetv_programs_recommended(_session: Session) -> Json<Value> {
- Json(json!({
- "Items": [],
- "TotalRecordCount": 0,
- "StartIndex": 0
- }))
-}
-
-#[get("/Users/<uid>/Items/<id>/Intros")]
-pub fn r_jellyfin_items_intros(_session: Session, uid: &str, id: &str) -> Json<Value> {
- let _ = (uid, id);
- Json(json!({
- "Items": [],
- "TotalRecordCount": 0,
- "StartIndex": 0
- }))
-}
-
-#[get("/Shows/NextUp")]
-pub fn r_jellyfin_shows_nextup(_session: Session) -> Json<Value> {
- Json(json!({
- "Items": [],
- "TotalRecordCount": 0,
- "StartIndex": 0
- }))
-}
-
-#[post("/Items/<id>/PlaybackInfo")]
-pub fn r_jellyfin_items_playbackinfo(
- _session: Session,
- database: &State<Database>,
- id: &str,
-) -> MyResult<Json<Value>> {
- 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/<id>/stream.webm")]
-pub fn r_jellyfin_video_stream(
- _session: Session,
- database: &State<Database>,
- id: &str,
-) -> MyResult<Redirect> {
- 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 = "<data>")]
-#[allow(private_interfaces)]
-pub fn r_jellyfin_sessions_playing_progress(
- session: Session,
- database: &State<Database>,
- data: Json<JellyfinProgressData>,
-) -> 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?<Size>")]
-#[allow(non_snake_case)]
-pub fn r_jellyfin_playback_bitratetest(_session: Session, Size: usize) -> Vec<u8> {
- 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 = "<data>")]
-#[allow(private_interfaces)]
-pub fn r_jellyfin_users_authenticatebyname_case(
- client_addr: IpAddr,
- database: &State<Database>,
- data: Json<AuthData>,
- jar: &CookieJar,
-) -> MyResult<Json<Value>> {
- r_jellyfin_users_authenticatebyname(client_addr, database, data, jar)
-}
-
-#[post("/Users/authenticatebyname", data = "<data>")]
-#[allow(private_interfaces)]
-pub fn r_jellyfin_users_authenticatebyname(
- client_addr: IpAddr,
- database: &State<Database>,
- data: Json<AuthData>,
- jar: &CookieJar,
-) -> MyResult<Json<Value>> {
- 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::<Vec<_>>(),
- 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 <metamuffin.org>
-*/
-
-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<u64>,
- pub width: Option<u64>,
- pub average_frame_rate: Option<f64>,
- pub real_frame_rate: Option<f64>,
- pub reference_frame_rate: Option<f64>,
- 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<String>,
- pub channels: Option<usize>,
- pub sample_rate: Option<f64>,
- 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<JellyfinMediaStream>,
- 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<String>,
- pub overview: String,
- pub taglines: Vec<String>,
- pub genres: Vec<()>,
- pub play_access: Option<String>,
- pub remote_trailers: Vec<()>,
- pub provider_ids: BTreeMap<(), ()>,
- pub is_folder: bool,
- pub parent_id: String,
- pub r#type: JellyfinItemType,
- pub people: Vec<JellyfinPerson>,
- pub studios: Vec<JellyfinStudio>,
- 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<String>,
- pub user_data: Value,
- pub display_preferences_id: String,
- pub primary_image_aspect_ratio: f64,
- pub collection_type: String,
- pub image_tags: BTreeMap<String, String>,
- pub backdrop_image_tags: Vec<String>,
- pub location_type: Option<String>,
- pub media_type: String,
- pub video_type: Option<String>,
- pub container: Option<String>,
- pub run_time_ticks: Option<i64>,
- pub media_sources: Option<Vec<JellyfinMediaSource>>,
- pub media_streams: Option<Vec<JellyfinMediaStream>>,
- pub path: Option<String>,
-}
-
-#[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 <metamuffin.org>
-*/
-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 <metamuffin.org>
-*/
-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?<v>")]
-pub fn r_youtube_watch(_session: Session, db: &State<Database>, v: &str) -> MyResult<Redirect> {
- 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/<id>")]
-pub fn r_youtube_channel(_session: Session, db: &State<Database>, id: &str) -> MyResult<Redirect> {
- 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/<v>")]
-pub fn r_youtube_embed(_session: Session, db: &State<Database>, v: &str) -> MyResult<Redirect> {
- 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<Output = request::Outcome<Self, Self::Error>>
- + ::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 <metamuffin.org>
-*/
-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<Build> {
- 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<File> {
- Ok(File::open(CONF.asset_path.join("favicon.ico"))?)
-}
-
-pub struct Cors<T>(pub T);
-impl<'r, T: Responder<'r, 'static>> Responder<'r, 'static> for Cors<T> {
- 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<String, broadcast::Sender<Message>>,
-}
-
-#[derive(Serialize, Deserialize, Debug, Clone)]
-#[serde(rename_all = "snake_case")]
-pub enum Packet {
- Time(f64),
- Playing(bool),
- Join(String),
- Leave(String),
-}
-
-#[get("/playersync/<channel>")]
-pub fn r_playersync(
- ws: WebSocket,
- state: &State<PlayersyncChannels>,
- channel: &str,
-) -> Cors<Channel<'static>> {
- 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<Message>,
- 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>,
- message: Option<rocket_ws::result::Result<Message>>,
- state: &mut ClientState,
-) -> anyhow::Result<bool> {
- 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 <metamuffin.org>
-*/
-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?<spec..>")]
-pub async fn r_stream_head(
- _sess: Session,
- _id: &str,
- spec: BTreeMap<String, String>,
-) -> Result<Either<StreamResponse, Redirect>, 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/<id>/stream?<spec..>")]
-pub async fn r_stream(
- _session: Session,
- _federation: &State<Federation>,
- db: &State<Database>,
- id: &str,
- range: Option<RequestRange>,
- spec: BTreeMap<String, String>,
-) -> Result<Either<StreamResponse, RedirectResponse>, 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<RequestRange>,
-}
-
-#[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<Range<Option<usize>>>);
-
-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<Self> {
- 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::<Result<Vec<_>>>()?,
- ))
- }
-}
-
-#[rocket::async_trait]
-impl<'r> FromRequest<'r> for RequestRange {
- type Error = anyhow::Error;
-
- async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
- 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 <metamuffin.org>
-*/
-pub mod session;
-pub mod settings;
-
-use super::{
- error::MyError,
- layout::{trs, LayoutPage},
-};
-use crate::{
- database::Database,
- routes::{
- locale::AcceptLanguage,
- ui::{
- account::session::Session, error::MyResult, home::rocket_uri_macro_r_home,
- layout::DynLayoutPage,
- },
- },
- uri,
-};
-use anyhow::anyhow;
-use argon2::{password_hash::Salt, Argon2, PasswordHasher};
-use chrono::Duration;
-use jellybase::{locale::tr, CONF};
-use jellycommon::user::{User, UserPermission};
-use rocket::{
- form::{Contextual, Form},
- get,
- http::{Cookie, CookieJar},
- post,
- response::Redirect,
- FromForm, State,
-};
-use serde::{Deserialize, Serialize};
-use std::collections::HashSet;
-
-#[derive(FromForm)]
-pub struct RegisterForm {
- #[field(validate = len(8..128))]
- pub invitation: String,
- #[field(validate = len(4..32))]
- pub username: String,
- #[field(validate = len(4..64))]
- pub password: String,
-}
-
-#[get("/account/register")]
-pub async fn r_account_register(lang: AcceptLanguage) -> DynLayoutPage<'static> {
- let AcceptLanguage(lang) = lang;
- LayoutPage {
- title: tr(lang, "account.register").to_string(),
- content: markup::new! {
- form.account[method="POST", action=""] {
- h1 { @trs(&lang, "account.register") }
-
- label[for="inp-invitation"] { @trs(&lang, "account.register.invitation") }
- input[type="text", id="inp-invitation", name="invitation"]; br;
-
- label[for="inp-username"] { @trs(&lang, "account.username") }
- input[type="text", id="inp-username", name="username"]; br;
- label[for="inp-password"] { @trs(&lang, "account.password") }
- input[type="password", id="inp-password", name="password"]; br;
-
- input[type="submit", value=&*tr(lang, "account.register.submit")];
-
- p { @trs(&lang, "account.register.login") " " a[href=uri!(r_account_login())] { @trs(&lang, "account.register.login_here") } }
- }
- },
- ..Default::default()
- }
-}
-
-#[derive(FromForm, Serialize, Deserialize)]
-pub struct LoginForm {
- #[field(validate = len(4..32))]
- pub username: String,
- #[field(validate = len(..64))]
- pub password: String,
- #[field(default = 604800)] // one week
- pub expire: u64,
-}
-
-#[get("/account/login")]
-pub fn r_account_login(sess: Option<Session>, lang: AcceptLanguage) -> DynLayoutPage<'static> {
- let AcceptLanguage(lang) = lang;
- let logged_in = sess.is_some();
- let title = tr(
- lang,
- if logged_in {
- "account.login.switch"
- } else {
- "account.login"
- },
- );
- LayoutPage {
- title: title.to_string(),
- content: markup::new! {
- form.account[method="POST", action=""] {
- h1 { @title.to_string() }
-
- label[for="inp-username"] { @trs(&lang, "account.username") }
- input[type="text", id="inp-username", name="username"]; br;
- label[for="inp-password"] { @trs(&lang, "account.password") }
- input[type="password", id="inp-password", name="password"]; br;
-
- input[type="submit", value=&*tr(lang, if logged_in { "account.login.submit.switch" } else { "account.login.submit" })];
-
- @if logged_in {
- p { @trs(&lang, "account.login.register.switch") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } }
- } else {
- p { @trs(&lang, "account.login.cookie_note") }
- p { @trs(&lang, "account.login.register") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } }
- }
- }
- },
- ..Default::default()
- }
-}
-
-#[get("/account/logout")]
-pub fn r_account_logout(lang: AcceptLanguage) -> DynLayoutPage<'static> {
- let AcceptLanguage(lang) = lang;
- LayoutPage {
- title: tr(lang, "account.logout").to_string(),
- content: markup::new! {
- form.account[method="POST", action=""] {
- h1 { @trs(&lang, "account.logout") }
- input[type="submit", value=&*tr(lang, "account.logout.submit")];
- }
- },
- ..Default::default()
- }
-}
-
-#[post("/account/register", data = "<form>")]
-pub fn r_account_register_post<'a>(
- database: &'a State<Database>,
- _sess: Option<Session>,
- form: Form<Contextual<'a, RegisterForm>>,
- lang: AcceptLanguage,
-) -> MyResult<DynLayoutPage<'a>> {
- let AcceptLanguage(lang) = lang;
- let logged_in = _sess.is_some();
- let form = match &form.value {
- Some(v) => v,
- None => return Err(format_form_error(form)),
- };
-
- database.register_user(
- &form.invitation,
- &form.username,
- User {
- display_name: form.username.clone(),
- name: form.username.clone(),
- password: hash_password(&form.username, &form.password),
- ..Default::default()
- },
- )?;
-
- Ok(LayoutPage {
- title: tr(lang, "account.register.success.title").to_string(),
- content: markup::new! {
- h1 { @trs(&lang, if logged_in {
- "account.register.success.switch"
- } else {
- "account.register.success"
- })}
- },
- ..Default::default()
- })
-}
-
-#[post("/account/login", data = "<form>")]
-pub fn r_account_login_post(
- database: &State<Database>,
- jar: &CookieJar,
- form: Form<Contextual<LoginForm>>,
-) -> MyResult<Redirect> {
- let form = match &form.value {
- Some(v) => v,
- None => return Err(format_form_error(form)),
- };
- jar.add(
- Cookie::build((
- "session",
- login_logic(database, &form.username, &form.password, None, None)?,
- ))
- .permanent()
- .build(),
- );
-
- Ok(Redirect::found(rocket::uri!(r_home())))
-}
-
-#[post("/account/logout")]
-pub fn r_account_logout_post(jar: &CookieJar) -> MyResult<Redirect> {
- jar.remove_private(Cookie::build("session"));
- Ok(Redirect::found(rocket::uri!(r_home())))
-}
-
-pub fn login_logic(
- database: &Database,
- username: &str,
- password: &str,
- expire: Option<i64>,
- drop_permissions: Option<HashSet<UserPermission>>,
-) -> MyResult<String> {
- // hashing the password regardless if the accounts exists to better resist timing attacks
- let password = hash_password(username, password);
-
- let mut user = database
- .get_user(username)?
- .ok_or(anyhow!("invalid password"))?;
-
- if user.password != password {
- Err(anyhow!("invalid password"))?
- }
-
- if let Some(ep) = drop_permissions {
- // remove all grant perms that are in `ep`
- user.permissions
- .0
- .retain(|p, val| if *val { !ep.contains(p) } else { true })
- }
-
- Ok(session::token::create(
- user.name,
- user.permissions,
- Duration::days(CONF.login_expire.min(expire.unwrap_or(i64::MAX))),
- ))
-}
-
-pub fn format_form_error<T>(form: Form<Contextual<T>>) -> MyError {
- let mut k = String::from("form validation failed:");
- for e in form.context.errors() {
- k += &format!(
- "\n\t{}: {e}",
- e.name
- .as_ref()
- .map(|e| e.to_string())
- .unwrap_or("<unknown>".to_string())
- )
- }
- MyError(anyhow!(k))
-}
-
-pub fn hash_password(username: &str, password: &str) -> Vec<u8> {
- Argon2::default()
- .hash_password(
- format!("{username}\0{password}").as_bytes(),
- <&str as TryInto<Salt>>::try_into("IYMa13osbNeLJKnQ1T8LlA").unwrap(),
- )
- .unwrap()
- .hash
- .unwrap()
- .as_bytes()
- .to_vec()
-}
diff --git a/server/src/routes/ui/account/session/guard.rs b/server/src/routes/ui/account/session/guard.rs
deleted file mode 100644
index 295c2d4..0000000
--- a/server/src/routes/ui/account/session/guard.rs
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{AdminSession, Session};
-use crate::{database::Database, routes::ui::error::MyError};
-use anyhow::anyhow;
-use log::warn;
-use rocket::{
- async_trait,
- http::Status,
- outcome::Outcome,
- request::{self, FromRequest},
- Request, State,
-};
-
-impl Session {
- pub async fn from_request_ut(req: &Request<'_>) -> Result<Self, MyError> {
- let username;
-
- #[cfg(not(feature = "bypass-auth"))]
- {
- let token = req
- .query_value("session")
- .map(|e| e.unwrap())
- .or_else(|| req.query_value("api_key").map(|e| e.unwrap()))
- .or_else(|| req.headers().get_one("X-MediaBrowser-Token"))
- .or_else(|| {
- req.headers()
- .get_one("Authorization")
- .and_then(parse_jellyfin_auth)
- }) // for jellyfin compat
- .or(req.cookies().get("session").map(|cookie| cookie.value()))
- .ok_or(anyhow!("not logged in"))?;
-
- // jellyfin urlescapes the token for *some* requests
- let token = token.replace("%3D", "=");
- username = super::token::validate(&token)?;
- };
-
- #[cfg(feature = "bypass-auth")]
- {
- parse_jellyfin_auth("a"); // unused warning is annoying
- username = "admin".to_string();
- }
-
- let db = req.guard::<&State<Database>>().await.unwrap();
-
- let user = db.get_user(&username)?.ok_or(anyhow!("user not found"))?;
-
- Ok(Session { user })
- }
-}
-
-fn parse_jellyfin_auth(h: &str) -> Option<&str> {
- for tok in h.split(" ") {
- if let Some(tok) = tok.strip_prefix("Token=\"") {
- if let Some(tok) = tok.strip_suffix("\"") {
- return Some(tok);
- }
- }
- }
- None
-}
-
-#[async_trait]
-impl<'r> FromRequest<'r> for Session {
- type Error = MyError;
- async fn from_request<'life0>(
- request: &'r Request<'life0>,
- ) -> request::Outcome<Self, Self::Error> {
- match Session::from_request_ut(request).await {
- Ok(x) => Outcome::Success(x),
- Err(e) => {
- warn!("authentificated route rejected: {e:?}");
- Outcome::Forward(Status::Unauthorized)
- }
- }
- }
-}
-
-#[async_trait]
-impl<'r> FromRequest<'r> for AdminSession {
- type Error = MyError;
- async fn from_request<'life0>(
- request: &'r Request<'life0>,
- ) -> request::Outcome<Self, Self::Error> {
- match Session::from_request_ut(request).await {
- Ok(x) => {
- if x.user.admin {
- Outcome::Success(AdminSession(x))
- } else {
- Outcome::Error((
- Status::Unauthorized,
- MyError(anyhow!("you are not an admin")),
- ))
- }
- }
- Err(e) => {
- warn!("authentificated route rejected: {e:?}");
- Outcome::Forward(Status::Unauthorized)
- }
- }
- }
-}
diff --git a/server/src/routes/ui/account/session/mod.rs b/server/src/routes/ui/account/session/mod.rs
deleted file mode 100644
index cb06255..0000000
--- a/server/src/routes/ui/account/session/mod.rs
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use chrono::{DateTime, Utc};
-use jellycommon::user::{PermissionSet, User};
-use serde::{Deserialize, Serialize};
-
-pub mod guard;
-pub mod token;
-
-pub struct Session {
- pub user: User,
-}
-
-pub struct AdminSession(pub Session);
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct SessionData {
- username: String,
- expire: DateTime<Utc>,
- permissions: PermissionSet,
-}
diff --git a/server/src/routes/ui/account/session/token.rs b/server/src/routes/ui/account/session/token.rs
deleted file mode 100644
index 3ada0ec..0000000
--- a/server/src/routes/ui/account/session/token.rs
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::SessionData;
-use aes_gcm_siv::{
- aead::{generic_array::GenericArray, Aead},
- KeyInit,
-};
-use anyhow::anyhow;
-use base64::Engine;
-use chrono::{Duration, Utc};
-use jellybase::SECRETS;
-use jellycommon::user::PermissionSet;
-use log::warn;
-use std::sync::LazyLock;
-
-static SESSION_KEY: LazyLock<[u8; 32]> = LazyLock::new(|| {
- if let Some(sk) = &SECRETS.session_key {
- let r = base64::engine::general_purpose::STANDARD
- .decode(sk)
- .expect("key invalid; should be valid base64");
- r.try_into()
- .expect("key has the wrong length; should be 32 bytes")
- } else {
- warn!("session_key not configured; generating a random one.");
- [(); 32].map(|_| rand::random())
- }
-});
-
-pub fn create(username: String, permissions: PermissionSet, expire: Duration) -> String {
- let session_data = SessionData {
- expire: Utc::now() + expire,
- username: username.to_owned(),
- permissions,
- };
- let mut plaintext =
- bincode::serde::encode_to_vec(&session_data, bincode::config::standard()).unwrap();
-
- while plaintext.len() % 16 == 0 {
- plaintext.push(0);
- }
-
- let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap();
- let nonce = [(); 12].map(|_| rand::random());
- let mut ciphertext = cipher
- .encrypt(&GenericArray::from(nonce), plaintext.as_slice())
- .unwrap();
- ciphertext.extend(nonce);
-
- base64::engine::general_purpose::URL_SAFE.encode(&ciphertext)
-}
-
-pub fn validate(token: &str) -> anyhow::Result<String> {
- let ciphertext = base64::engine::general_purpose::URL_SAFE.decode(token)?;
- let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap();
- let (ciphertext, nonce) = ciphertext.split_at(ciphertext.len() - 12);
- let plaintext = cipher
- .decrypt(nonce.into(), ciphertext)
- .map_err(|e| anyhow!("decryption failed: {e:?}"))?;
-
- let (session_data, _): (SessionData, _) =
- bincode::serde::decode_from_slice(&plaintext, bincode::config::standard())?;
-
- if session_data.expire < Utc::now() {
- Err(anyhow!("session expired"))?
- }
-
- Ok(session_data.username)
-}
-
-#[test]
-fn test() {
- jellybase::use_test_config();
- let tok = create(
- "blub".to_string(),
- jellycommon::user::PermissionSet::default(),
- Duration::days(1),
- );
- validate(&tok).unwrap();
-}
-
-#[test]
-fn test_crypto() {
- jellybase::use_test_config();
- let nonce = [(); 12].map(|_| rand::random());
- let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap();
- let plaintext = b"testing stuff---";
- let ciphertext = cipher
- .encrypt(&GenericArray::from(nonce), plaintext.as_slice())
- .unwrap();
- let plaintext2 = cipher
- .decrypt((&nonce).into(), ciphertext.as_slice())
- .unwrap();
- assert_eq!(plaintext, plaintext2.as_slice());
-}
diff --git a/server/src/routes/ui/account/settings.rs b/server/src/routes/ui/account/settings.rs
deleted file mode 100644
index 2e170b0..0000000
--- a/server/src/routes/ui/account/settings.rs
+++ /dev/null
@@ -1,187 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{format_form_error, hash_password};
-use crate::{
- database::Database,
- routes::{
- locale::AcceptLanguage,
- ui::{
- account::{rocket_uri_macro_r_account_login, session::Session},
- error::MyResult,
- layout::{trs, DynLayoutPage, LayoutPage},
- },
- },
- uri,
-};
-use jellybase::{
- locale::{tr, Language},
- permission::PermissionSetExt,
-};
-use jellycommon::user::{PlayerKind, Theme, UserPermission};
-use markup::{Render, RenderAttributeValue};
-use rocket::{
- form::{self, validate::len, Contextual, Form},
- get,
- http::uri::fmt::{Query, UriDisplay},
- post, FromForm, State,
-};
-use std::ops::Range;
-
-#[derive(FromForm)]
-pub struct SettingsForm {
- #[field(validate = option_len(4..64))]
- password: Option<String>,
- #[field(validate = option_len(4..32))]
- display_name: Option<String>,
- theme: Option<Theme>,
- player_preference: Option<PlayerKind>,
- native_secret: Option<String>,
-}
-
-fn option_len<'v>(value: &Option<String>, range: Range<usize>) -> form::Result<'v, ()> {
- value.as_ref().map(|v| len(v, range)).unwrap_or(Ok(()))
-}
-
-fn settings_page(
- session: Session,
- flash: Option<MyResult<String>>,
- lang: Language,
-) -> DynLayoutPage<'static> {
- LayoutPage {
- title: "Settings".to_string(),
- class: Some("settings"),
- content: markup::new! {
- h1 { "Settings" }
- @if let Some(flash) = &flash {
- @match flash {
- Ok(mesg) => { section.message { p.success { @mesg } } }
- Err(err) => { section.message { p.error { @format!("{err}") } } }
- }
- }
- h2 { @trs(&lang, "account") }
- a.switch_account[href=uri!(r_account_login())] { "Switch Account" }
- form[method="POST", action=uri!(r_account_settings_post())] {
- label[for="username"] { @trs(&lang, "account.username") }
- input[type="text", id="username", disabled, value=&session.user.name];
- input[type="submit", disabled, value=&*tr(lang, "settings.immutable")];
- }
- form[method="POST", action=uri!(r_account_settings_post())] {
- label[for="display_name"] { @trs(&lang, "account.display_name") }
- input[type="text", id="display_name", name="display_name", value=&session.user.display_name];
- input[type="submit", value=&*tr(lang, "settings.update")];
- }
- form[method="POST", action=uri!(r_account_settings_post())] {
- label[for="password"] { @trs(&lang, "account.password") }
- input[type="password", id="password", name="password"];
- input[type="submit", value=&*tr(lang, "settings.update")];
- }
- h2 { @trs(&lang, "settings.appearance") }
- form[method="POST", action=uri!(r_account_settings_post())] {
- fieldset {
- legend { @trs(&lang, "settings.appearance.theme") }
- @for (t, tlabel) in Theme::LIST {
- label { input[type="radio", name="theme", value=A(*t), checked=session.user.theme==*t]; @tlabel } br;
- }
- }
- input[type="submit", value=&*tr(lang, "settings.apply")];
- }
- form[method="POST", action=uri!(r_account_settings_post())] {
- fieldset {
- legend { @trs(&lang, "settings.player_preference") }
- @for (t, tlabel) in PlayerKind::LIST {
- label { input[type="radio", name="player_preference", value=A(*t), checked=session.user.player_preference==*t]; @tlabel } br;
- }
- }
- input[type="submit", value=&*tr(lang, "settings.apply")];
- }
- form[method="POST", action=uri!(r_account_settings_post())] {
- label[for="native_secret"] { "Native Secret" }
- input[type="password", id="native_secret", name="native_secret"];
- input[type="submit", value=&*tr(lang, "settings.update")];
- p { "The secret can be found in " code{"$XDG_CONFIG_HOME/jellynative_secret"} " or by clicking " a.button[href="jellynative://show-secret-v1"] { "Show Secret" } "." }
- }
- },
- }
-}
-
-struct A<T>(pub T);
-impl<T: UriDisplay<Query>> RenderAttributeValue for A<T> {}
-impl<T: UriDisplay<Query>> Render for A<T> {
- fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
- writer.write_fmt(format_args!("{}", &self.0 as &dyn UriDisplay<Query>))
- }
-}
-
-#[get("/account/settings")]
-pub fn r_account_settings(session: Session, lang: AcceptLanguage) -> DynLayoutPage<'static> {
- let AcceptLanguage(lang) = lang;
- settings_page(session, None, lang)
-}
-
-#[post("/account/settings", data = "<form>")]
-pub fn r_account_settings_post(
- session: Session,
- database: &State<Database>,
- form: Form<Contextual<SettingsForm>>,
- lang: AcceptLanguage,
-) -> MyResult<DynLayoutPage<'static>> {
- let AcceptLanguage(lang) = lang;
- session
- .user
- .permissions
- .assert(&UserPermission::ManageSelf)?;
-
- let form = match &form.value {
- Some(v) => v,
- None => {
- return Ok(settings_page(
- session,
- Some(Err(format_form_error(form))),
- lang,
- ))
- }
- };
-
- let mut out = String::new();
-
- database.update_user(&session.user.name, |user| {
- if let Some(password) = &form.password {
- user.password = hash_password(&session.user.name, password);
- out += &*tr(lang, "settings.account.password.changed");
- out += "\n";
- }
- if let Some(display_name) = &form.display_name {
- user.display_name = display_name.clone();
- out += &*tr(lang, "settings.account.display_name.changed");
- out += "\n";
- }
- if let Some(theme) = form.theme {
- user.theme = theme;
- out += &*tr(lang, "settings.account.theme.changed");
- out += "\n";
- }
- if let Some(player_preference) = form.player_preference {
- user.player_preference = player_preference;
- out += &*tr(lang, "settings.player_preference.changed");
- out += "\n";
- }
- if let Some(native_secret) = &form.native_secret {
- user.native_secret = native_secret.to_owned();
- out += "Native secret updated.\n";
- }
- Ok(())
- })?;
-
- Ok(settings_page(
- session, // using the old session here, results in outdated theme being displayed
- Some(Ok(if out.is_empty() {
- tr(lang, "settings.no_change").to_string()
- } else {
- out
- })),
- lang,
- ))
-}
diff --git a/server/src/routes/ui/admin/log.rs b/server/src/routes/ui/admin/log.rs
deleted file mode 100644
index fc85b37..0000000
--- a/server/src/routes/ui/admin/log.rs
+++ /dev/null
@@ -1,258 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use crate::{
- routes::ui::{
- account::session::AdminSession,
- error::MyResult,
- layout::{DynLayoutPage, LayoutPage},
- },
- uri,
-};
-use chrono::{DateTime, Utc};
-use log::Level;
-use markup::Render;
-use rocket::get;
-use rocket_ws::{Message, Stream, WebSocket};
-use serde_json::json;
-use std::{
- collections::VecDeque,
- fmt::Write,
- sync::{Arc, LazyLock, RwLock},
-};
-use tokio::sync::broadcast;
-
-const MAX_LOG_LEN: usize = 4096;
-
-static LOGGER: LazyLock<Log> = LazyLock::new(Log::default);
-
-pub fn enable_logging() {
- log::set_logger(&*LOGGER).unwrap();
- log::set_max_level(log::LevelFilter::Debug);
-}
-
-type LogBuffer = VecDeque<Arc<LogLine>>;
-
-pub struct Log {
- inner: env_logger::Logger,
- stream: (
- broadcast::Sender<Arc<LogLine>>,
- broadcast::Sender<Arc<LogLine>>,
- ),
- log: RwLock<(LogBuffer, LogBuffer)>,
-}
-
-pub struct LogLine {
- time: DateTime<Utc>,
- module: Option<&'static str>,
- level: Level,
- message: String,
-}
-
-#[get("/admin/log?<warnonly>", rank = 2)]
-pub fn r_admin_log<'a>(_session: AdminSession, warnonly: bool) -> MyResult<DynLayoutPage<'a>> {
- Ok(LayoutPage {
- title: "Log".into(),
- class: Some("admin_log"),
- content: markup::new! {
- h1 { "Server Log" }
- a[href=uri!(r_admin_log(!warnonly))] { @if warnonly { "Show everything" } else { "Show only warnings" }}
- code.log[id="log"] {
- @let g = LOGGER.log.read().unwrap();
- table { @for e in if warnonly { g.1.iter() } else { g.0.iter() } {
- tr[class=format!("level-{:?}", e.level).to_ascii_lowercase()] {
- td.time { @e.time.to_rfc3339() }
- td.loglevel { @format_level(e.level) }
- td.module { @e.module }
- td { @markup::raw(vt100_to_html(&e.message)) }
- }
- }}
- }
- },
- })
-}
-
-#[get("/admin/log?stream&<warnonly>", rank = 1)]
-pub fn r_admin_log_stream(
- _session: AdminSession,
- ws: WebSocket,
- warnonly: bool,
-) -> Stream!['static] {
- let mut stream = if warnonly {
- LOGGER.stream.1.subscribe()
- } else {
- LOGGER.stream.0.subscribe()
- };
- Stream! { ws =>
- let _ = ws;
- while let Ok(line) = stream.recv().await {
- yield Message::Text(json!({
- "time": line.time,
- "level_class": format!("level-{:?}", line.level).to_ascii_lowercase(),
- "level_html": format_level_string(line.level),
- "module": line.module,
- "message": vt100_to_html(&line.message),
- }).to_string());
- }
- }
-}
-
-impl Default for Log {
- fn default() -> Self {
- Self {
- inner: env_logger::builder()
- .filter_level(log::LevelFilter::Warn)
- .parse_env("LOG")
- .build(),
- stream: (
- tokio::sync::broadcast::channel(1024).0,
- tokio::sync::broadcast::channel(1024).0,
- ),
- log: Default::default(),
- }
- }
-}
-impl Log {
- fn should_log(&self, metadata: &log::Metadata) -> bool {
- let level = metadata.level();
- level
- <= match metadata.target() {
- x if x.starts_with("jelly") => Level::Debug,
- x if x.starts_with("rocket::") => Level::Info,
- _ => Level::Warn,
- }
- }
- fn do_log(&self, record: &log::Record) {
- let time = Utc::now();
- let line = Arc::new(LogLine {
- time,
- module: record.module_path_static(),
- level: record.level(),
- message: record.args().to_string(),
- });
- let mut w = self.log.write().unwrap();
- w.0.push_back(line.clone());
- let _ = self.stream.0.send(line.clone());
- while w.0.len() > MAX_LOG_LEN {
- w.0.pop_front();
- }
- if record.level() <= Level::Warn {
- let _ = self.stream.1.send(line.clone());
- w.1.push_back(line);
- while w.1.len() > MAX_LOG_LEN {
- w.1.pop_front();
- }
- }
- }
-}
-
-impl log::Log for Log {
- fn enabled(&self, metadata: &log::Metadata) -> bool {
- self.inner.enabled(metadata) || self.should_log(metadata)
- }
- fn log(&self, record: &log::Record) {
- match (record.module_path_static(), record.line()) {
- // TODO is there a better way to ignore those?
- (Some("rocket::rocket"), Some(670)) => return,
- (Some("rocket::server"), Some(401)) => return,
- _ => {}
- }
- if self.inner.enabled(record.metadata()) {
- self.inner.log(record);
- }
- if self.should_log(record.metadata()) {
- self.do_log(record)
- }
- }
- fn flush(&self) {
- self.inner.flush();
- }
-}
-
-fn vt100_to_html(s: &str) -> String {
- let mut out = HtmlOut::default();
- let mut st = vte::Parser::new();
- st.advance(&mut out, s.as_bytes());
- out.s
-}
-
-fn format_level(level: Level) -> impl markup::Render {
- let (s, c) = match level {
- Level::Debug => ("DEBUG", "blue"),
- Level::Error => ("ERROR", "red"),
- Level::Warn => ("WARN", "yellow"),
- Level::Info => ("INFO", "green"),
- Level::Trace => ("TRACE", "lightblue"),
- };
- markup::new! { span[style=format!("color:{c}")] {@s} }
-}
-fn format_level_string(level: Level) -> String {
- let mut w = String::new();
- format_level(level).render(&mut w).unwrap();
- w
-}
-
-#[derive(Default)]
-pub struct HtmlOut {
- s: String,
- color: bool,
-}
-impl HtmlOut {
- pub fn set_color(&mut self, [r, g, b]: [u8; 3]) {
- self.reset_color();
- self.color = true;
- write!(self.s, "<span style=color:#{:02x}{:02x}{:02x}>", r, g, b).unwrap()
- }
- pub fn reset_color(&mut self) {
- if self.color {
- write!(self.s, "</span>").unwrap();
- self.color = false;
- }
- }
-}
-impl vte::Perform for HtmlOut {
- fn print(&mut self, c: char) {
- match c {
- 'a'..='z' | 'A'..='Z' | '0'..='9' | ' ' => self.s.push(c),
- x => write!(self.s, "&#{};", x as u32).unwrap(),
- }
- }
- fn execute(&mut self, _byte: u8) {}
- fn hook(&mut self, _params: &vte::Params, _i: &[u8], _ignore: bool, _a: char) {}
- fn put(&mut self, _byte: u8) {}
- fn unhook(&mut self) {}
- fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {}
- fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {}
- fn csi_dispatch(
- &mut self,
- params: &vte::Params,
- _intermediates: &[u8],
- _ignore: bool,
- action: char,
- ) {
- let mut k = params.iter();
- #[allow(clippy::single_match)]
- match action {
- 'm' => match k.next().unwrap_or(&[0]).first().unwrap_or(&0) {
- c @ (30..=37 | 40..=47) => {
- let c = if *c >= 40 { *c - 10 } else { *c };
- self.set_color(match c {
- 30 => [0, 0, 0],
- 31 => [255, 0, 0],
- 32 => [0, 255, 0],
- 33 => [255, 255, 0],
- 34 => [0, 0, 255],
- 35 => [255, 0, 255],
- 36 => [0, 255, 255],
- 37 => [255, 255, 255],
- _ => unreachable!(),
- });
- }
- _ => (),
- },
- _ => (),
- }
- }
-}
diff --git a/server/src/routes/ui/admin/mod.rs b/server/src/routes/ui/admin/mod.rs
deleted file mode 100644
index f44b36c..0000000
--- a/server/src/routes/ui/admin/mod.rs
+++ /dev/null
@@ -1,290 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-pub mod log;
-pub mod user;
-
-use super::{
- account::session::AdminSession,
- assets::{resolve_asset, AVIF_QUALITY, AVIF_SPEED},
-};
-use crate::{
- database::Database,
- routes::ui::{
- admin::log::rocket_uri_macro_r_admin_log,
- error::MyResult,
- layout::{DynLayoutPage, FlashDisplay, LayoutPage},
- },
- uri,
-};
-use anyhow::{anyhow, Context};
-use jellybase::{assetfed::AssetInner, federation::Federation, CONF};
-use jellyimport::{import_wrap, is_importing, IMPORT_ERRORS};
-use markup::DynRender;
-use rand::Rng;
-use rocket::{form::Form, get, post, FromForm, State};
-use std::time::Instant;
-use tokio::{sync::Semaphore, task::spawn_blocking};
-use user::rocket_uri_macro_r_admin_users;
-
-#[get("/admin/dashboard")]
-pub async fn r_admin_dashboard(
- _session: AdminSession,
- database: &State<Database>,
-) -> MyResult<DynLayoutPage<'static>> {
- admin_dashboard(database, None).await
-}
-
-pub async fn admin_dashboard<'a>(
- database: &Database,
- flash: Option<MyResult<String>>,
-) -> MyResult<DynLayoutPage<'a>> {
- let invites = database.list_invites()?;
- let flash = flash.map(|f| f.map_err(|e| format!("{e:?}")));
-
- let last_import_err = IMPORT_ERRORS.read().await.to_owned();
-
- let database = database.to_owned();
- Ok(LayoutPage {
- title: "Admin Dashboard".to_string(),
- content: markup::new! {
- h1 { "Admin Panel" }
- @FlashDisplay { flash: flash.clone() }
- @if !last_import_err.is_empty() {
- section.message.error {
- details {
- summary { p.error { @format!("The last import resulted in {} errors:", last_import_err.len()) } }
- ol { @for e in &last_import_err {
- li.error { pre.error { @e } }
- }}
- }
- }
- }
- ul {
- li{a[href=uri!(r_admin_log(true))] { "Server Log (Warnings only)" }}
- li{a[href=uri!(r_admin_log(false))] { "Server Log (Full) " }}
- }
- h2 { "Library" }
- @if is_importing() {
- section.message { p.warn { "An import is currently running." } }
- }
- @if is_transcoding() {
- section.message { p.warn { "Currently transcoding posters." } }
- }
- form[method="POST", action=uri!(r_admin_import(true))] {
- input[type="submit", disabled=is_importing(), value="Start incremental import"];
- }
- form[method="POST", action=uri!(r_admin_import(false))] {
- input[type="submit", disabled=is_importing(), value="Start full import"];
- }
- form[method="POST", action=uri!(r_admin_transcode_posters())] {
- input[type="submit", disabled=is_transcoding(), value="Transcode all posters with low resolution"];
- }
- form[method="POST", action=uri!(r_admin_update_search())] {
- input[type="submit", value="Regenerate full-text search index"];
- }
- form[method="POST", action=uri!(r_admin_delete_cache())] {
- input.danger[type="submit", value="Delete Cache"];
- }
- h2 { "Users" }
- p { a[href=uri!(r_admin_users())] "Manage Users" }
- h2 { "Invitations" }
- form[method="POST", action=uri!(r_admin_invite())] {
- input[type="submit", value="Generate new invite code"];
- }
- ul { @for t in &invites {
- li {
- form[method="POST", action=uri!(r_admin_remove_invite())] {
- span { @t }
- input[type="text", name="invite", value=&t, hidden];
- input[type="submit", value="Invalidate"];
- }
- }
- }}
-
- h2 { "Database" }
- @match db_stats(&database) {
- Ok(s) => { @s }
- Err(e) => { pre.error { @format!("{e:?}") } }
- }
- },
- ..Default::default()
- })
-}
-
-#[post("/admin/generate_invite")]
-pub async fn r_admin_invite(
- _session: AdminSession,
- database: &State<Database>,
-) -> MyResult<DynLayoutPage<'static>> {
- let i = format!("{}", rand::rng().random::<u128>());
- database.create_invite(&i)?;
- admin_dashboard(database, Some(Ok(format!("Invite: {}", i)))).await
-}
-
-#[derive(FromForm)]
-pub struct DeleteInvite {
- invite: String,
-}
-
-#[post("/admin/remove_invite", data = "<form>")]
-pub async fn r_admin_remove_invite(
- session: AdminSession,
- database: &State<Database>,
- form: Form<DeleteInvite>,
-) -> MyResult<DynLayoutPage<'static>> {
- drop(session);
- if !database.delete_invite(&form.invite)? {
- Err(anyhow!("invite does not exist"))?;
- };
- admin_dashboard(database, Some(Ok("Invite invalidated".into()))).await
-}
-
-#[post("/admin/import?<incremental>")]
-pub async fn r_admin_import(
- session: AdminSession,
- database: &State<Database>,
- _federation: &State<Federation>,
- incremental: bool,
-) -> MyResult<DynLayoutPage<'static>> {
- drop(session);
- let t = Instant::now();
- if !incremental {
- database.clear_nodes()?;
- }
- let r = import_wrap((*database).clone(), incremental).await;
- let flash = r
- .map_err(|e| e.into())
- .map(|_| format!("Import successful; took {:?}", t.elapsed()));
- admin_dashboard(database, Some(flash)).await
-}
-
-#[post("/admin/update_search")]
-pub async fn r_admin_update_search(
- _session: AdminSession,
- database: &State<Database>,
-) -> MyResult<DynLayoutPage<'static>> {
- let db2 = (*database).clone();
- let r = spawn_blocking(move || db2.search_create_index())
- .await
- .unwrap();
- admin_dashboard(
- database,
- Some(
- r.map_err(|e| e.into())
- .map(|_| "Search index updated".to_string()),
- ),
- )
- .await
-}
-
-#[post("/admin/delete_cache")]
-pub async fn r_admin_delete_cache(
- session: AdminSession,
- database: &State<Database>,
-) -> MyResult<DynLayoutPage<'static>> {
- drop(session);
- let t = Instant::now();
- let r = tokio::fs::remove_dir_all(&CONF.cache_path).await;
- tokio::fs::create_dir(&CONF.cache_path).await?;
- admin_dashboard(
- database,
- Some(
- r.map_err(|e| e.into())
- .map(|_| format!("Cache deleted; took {:?}", t.elapsed())),
- ),
- )
- .await
-}
-
-static SEM_TRANSCODING: Semaphore = Semaphore::const_new(1);
-fn is_transcoding() -> bool {
- SEM_TRANSCODING.available_permits() == 0
-}
-
-#[post("/admin/transcode_posters")]
-pub async fn r_admin_transcode_posters(
- session: AdminSession,
- database: &State<Database>,
-) -> MyResult<DynLayoutPage<'static>> {
- drop(session);
- let _permit = SEM_TRANSCODING
- .try_acquire()
- .context("transcoding in progress")?;
-
- let t = Instant::now();
-
- {
- let nodes = database.list_nodes_with_udata("")?;
- for (node, _) in nodes {
- if let Some(poster) = &node.poster {
- let asset = AssetInner::deser(&poster.0)?;
- if asset.is_federated() {
- continue;
- }
- let source = resolve_asset(asset).await.context("resolving asset")?;
- jellytranscoder::image::transcode(&source, AVIF_QUALITY, AVIF_SPEED, 1024)
- .await
- .context("transcoding asset")?;
- }
- }
- }
- drop(_permit);
-
- admin_dashboard(
- database,
- Some(Ok(format!(
- "All posters pre-transcoded; took {:?}",
- t.elapsed()
- ))),
- )
- .await
-}
-
-fn db_stats(_db: &Database) -> anyhow::Result<DynRender> {
- // TODO
- // let txn = db.inner.begin_read()?;
- // let stats = [
- // ("node", txn.open_table(T_NODE)?.stats()?),
- // ("user", txn.open_table(T_USER_NODE)?.stats()?),
- // ("user-node", txn.open_table(T_USER_NODE)?.stats()?),
- // ("invite", txn.open_table(T_INVITE)?.stats()?),
- // ];
-
- // let cache_stats = db.node_index.reader.searcher().doc_store_cache_stats();
- // let ft_total_docs = db.node_index.reader.searcher().total_num_docs()?;
-
- Ok(markup::new! {
- // h3 { "Key-Value-Store Statistics" }
- // table.border {
- // tbody {
- // tr {
- // th { "table name" }
- // th { "tree height" }
- // th { "stored bytes" }
- // th { "metadata bytes" }
- // th { "fragmented bytes" }
- // th { "branch pages" }
- // th { "leaf pages" }
- // }
- // @for (name, stats) in &stats { tr {
- // td { @name }
- // td { @stats.tree_height() }
- // td { @format_size(stats.stored_bytes(), DECIMAL) }
- // td { @format_size(stats.metadata_bytes(), DECIMAL) }
- // td { @format_size(stats.fragmented_bytes(), DECIMAL) }
- // td { @stats.branch_pages() }
- // td { @stats.leaf_pages() }
- // }}
- // }
- // }
- // h3 { "Search Engine Statistics" }
- // ul {
- // li { "Total documents: " @ft_total_docs }
- // li { "Cache misses: " @cache_stats.cache_misses }
- // li { "Cache hits: " @cache_stats.cache_hits }
- // }
- })
-}
diff --git a/server/src/routes/ui/admin/user.rs b/server/src/routes/ui/admin/user.rs
deleted file mode 100644
index 7ba6d4e..0000000
--- a/server/src/routes/ui/admin/user.rs
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use crate::{
- database::Database,
- routes::ui::{
- account::session::AdminSession,
- error::MyResult,
- layout::{DynLayoutPage, FlashDisplay, LayoutPage},
- },
- uri,
-};
-use anyhow::{anyhow, Context};
-use jellycommon::user::{PermissionSet, UserPermission};
-use rocket::{form::Form, get, post, FromForm, FromFormField, State};
-
-#[get("/admin/users")]
-pub fn r_admin_users(
- _session: AdminSession,
- database: &State<Database>,
-) -> MyResult<DynLayoutPage<'static>> {
- user_management(database, None)
-}
-
-fn user_management<'a>(
- database: &Database,
- flash: Option<MyResult<String>>,
-) -> MyResult<DynLayoutPage<'a>> {
- // TODO this doesnt scale, pagination!
- let users = database.list_users()?;
- let flash = flash.map(|f| f.map_err(|e| format!("{e:?}")));
-
- Ok(LayoutPage {
- title: "User management".to_string(),
- content: markup::new! {
- h1 { "User Management" }
- @FlashDisplay { flash: flash.clone() }
- h2 { "All Users" }
- ul { @for u in &users {
- li {
- a[href=uri!(r_admin_user(&u.name))] { @format!("{:?}", u.display_name) " (" @u.name ")" }
- }
- }}
- },
- ..Default::default()
- })
-}
-
-#[get("/admin/user/<name>")]
-pub fn r_admin_user<'a>(
- _session: AdminSession,
- database: &State<Database>,
- name: &'a str,
-) -> MyResult<DynLayoutPage<'a>> {
- manage_single_user(database, None, name.to_string())
-}
-
-fn manage_single_user<'a>(
- database: &Database,
- flash: Option<MyResult<String>>,
- name: String,
-) -> MyResult<DynLayoutPage<'a>> {
- let user = database
- .get_user(&name)?
- .ok_or(anyhow!("user does not exist"))?;
- let flash = flash.map(|f| f.map_err(|e| format!("{e:?}")));
-
- Ok(LayoutPage {
- title: "User management".to_string(),
- content: markup::new! {
- h1 { @format!("{:?}", user.display_name) " (" @user.name ")" }
- a[href=uri!(r_admin_users())] "Back to the User List"
- @FlashDisplay { flash: flash.clone() }
- form[method="POST", action=uri!(r_admin_remove_user())] {
- input[type="text", name="name", value=&user.name, hidden];
- input.danger[type="submit", value="Remove user(!)"];
- }
-
- h2 { "Permissions" }
- @PermissionDisplay { perms: &user.permissions }
-
- form[method="POST", action=uri!(r_admin_user_permission())] {
- input[type="text", name="name", value=&user.name, hidden];
- fieldset.perms {
- legend { "Permission" }
- @for p in UserPermission::ALL_ENUMERABLE {
- label {
- input[type="radio", name="permission", value=serde_json::to_string(p).unwrap()];
- @format!("{p}")
- } br;
- }
- }
- fieldset.perms {
- legend { "Permission" }
- label { input[type="radio", name="action", value="unset"]; "Unset" } br;
- label { input[type="radio", name="action", value="grant"]; "Grant" } br;
- label { input[type="radio", name="action", value="revoke"]; "Revoke" } br;
- }
- input[type="submit", value="Update"];
- }
-
- },
- ..Default::default()
- })
-}
-
-markup::define! {
- PermissionDisplay<'a>(perms: &'a PermissionSet) {
- ul { @for (perm,grant) in &perms.0 {
- @if *grant {
- li[class="perm-grant"] { @format!("Allow {}", perm) }
- } else {
- li[class="perm-revoke"] { @format!("Deny {}", perm) }
- }
- }}
- }
-}
-
-#[derive(FromForm)]
-pub struct DeleteUser {
- name: String,
-}
-#[derive(FromForm)]
-pub struct UserPermissionForm {
- name: String,
- permission: String,
- action: GrantState,
-}
-
-#[derive(FromFormField)]
-pub enum GrantState {
- Grant,
- Revoke,
- Unset,
-}
-
-#[post("/admin/update_user_permission", data = "<form>")]
-pub fn r_admin_user_permission(
- session: AdminSession,
- database: &State<Database>,
- form: Form<UserPermissionForm>,
-) -> MyResult<DynLayoutPage<'static>> {
- drop(session);
- let perm = serde_json::from_str::<UserPermission>(&form.permission)
- .context("parsing provided permission")?;
-
- database.update_user(&form.name, |user| {
- match form.action {
- GrantState::Grant => drop(user.permissions.0.insert(perm.clone(), true)),
- GrantState::Revoke => drop(user.permissions.0.insert(perm.clone(), false)),
- GrantState::Unset => drop(user.permissions.0.remove(&perm)),
- }
- Ok(())
- })?;
-
- manage_single_user(
- database,
- Some(Ok("Permissions update".into())),
- form.name.clone(),
- )
-}
-
-#[post("/admin/remove_user", data = "<form>")]
-pub fn r_admin_remove_user(
- session: AdminSession,
- database: &State<Database>,
- form: Form<DeleteUser>,
-) -> MyResult<DynLayoutPage<'static>> {
- drop(session);
- if !database.delete_user(&form.name)? {
- Err(anyhow!("user did not exist"))?;
- }
- user_management(database, Some(Ok("User removed".into())))
-}
diff --git a/server/src/routes/ui/assets.rs b/server/src/routes/ui/assets.rs
deleted file mode 100644
index c661771..0000000
--- a/server/src/routes/ui/assets.rs
+++ /dev/null
@@ -1,200 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use crate::routes::ui::{account::session::Session, error::MyResult, CacheControlFile};
-use anyhow::{anyhow, bail, Context};
-use base64::Engine;
-use jellybase::{
- assetfed::AssetInner, cache::async_cache_file, database::Database, federation::Federation, CONF,
-};
-use jellycommon::{LocalTrack, NodeID, PeopleGroup, SourceTrackKind, TrackSource};
-use log::info;
-use rocket::{get, http::ContentType, response::Redirect, State};
-use std::{path::PathBuf, str::FromStr};
-
-pub const AVIF_QUALITY: f32 = 50.;
-pub const AVIF_SPEED: u8 = 5;
-
-#[get("/asset/<token>?<width>")]
-pub async fn r_asset(
- _session: Session,
- fed: &State<Federation>,
- token: &str,
- width: Option<usize>,
-) -> MyResult<(ContentType, CacheControlFile)> {
- let width = width.unwrap_or(2048);
- let asset = AssetInner::deser(token)?;
-
- let path = if let AssetInner::Federated { host, asset } = asset {
- let session = fed.get_session(&host).await?;
-
- let asset = base64::engine::general_purpose::URL_SAFE.encode(asset);
- async_cache_file("fed-asset", &asset, |out| async {
- session.asset(out, &asset, width).await
- })
- .await?
- } else {
- let source = resolve_asset(asset).await.context("resolving asset")?;
-
- // fit the resolution into a finite set so the maximum cache is finite too.
- let width = 2usize.pow(width.clamp(128, 2048).ilog2());
- jellytranscoder::image::transcode(&source, AVIF_QUALITY, AVIF_SPEED, width)
- .await
- .context("transcoding asset")?
- };
- info!("loading asset from {path:?}");
- Ok((
- ContentType::AVIF,
- CacheControlFile::new_cachekey(&path.abs()).await?,
- ))
-}
-
-pub async fn resolve_asset(asset: AssetInner) -> anyhow::Result<PathBuf> {
- match asset {
- AssetInner::Cache(c) => Ok(c.abs()),
- AssetInner::Assets(c) => Ok(CONF.asset_path.join(c)),
- AssetInner::Media(c) => Ok(CONF.media_path.join(c)),
- _ => bail!("wrong asset type"),
- }
-}
-
-#[get("/n/<id>/poster?<width>")]
-pub async fn r_item_poster(
- _session: Session,
- db: &State<Database>,
- id: NodeID,
- width: Option<usize>,
-) -> MyResult<Redirect> {
- // TODO perm
- let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?;
-
- let mut asset = node.poster.clone();
- if asset.is_none() {
- if let Some(parent) = node.parents.last().copied() {
- let parent = db.get_node(parent)?.ok_or(anyhow!("node does not exist"))?;
- asset = parent.poster.clone();
- }
- };
- let asset = asset.unwrap_or_else(|| {
- AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser()
- });
- Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width))))
-}
-
-#[get("/n/<id>/backdrop?<width>")]
-pub async fn r_item_backdrop(
- _session: Session,
- db: &State<Database>,
- id: NodeID,
- width: Option<usize>,
-) -> MyResult<Redirect> {
- // TODO perm
- let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?;
-
- let mut asset = node.backdrop.clone();
- if asset.is_none() {
- if let Some(parent) = node.parents.last().copied() {
- let parent = db.get_node(parent)?.ok_or(anyhow!("node does not exist"))?;
- asset = parent.backdrop.clone();
- }
- };
- let asset = asset.unwrap_or_else(|| {
- AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()).ser()
- });
- Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width))))
-}
-
-#[get("/n/<id>/person/<index>/asset?<group>&<width>")]
-pub async fn r_person_asset(
- _session: Session,
- db: &State<Database>,
- id: NodeID,
- index: usize,
- group: String,
- width: Option<usize>,
-) -> MyResult<Redirect> {
- // TODO perm
-
- let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?;
- let app = node
- .people
- .get(&PeopleGroup::from_str(&group).map_err(|()| anyhow!("unknown people group"))?)
- .ok_or(anyhow!("group has no members"))?
- .get(index)
- .ok_or(anyhow!("person does not exist"))?;
-
- let asset = app
- .person
- .headshot
- .to_owned()
- .unwrap_or(AssetInner::Assets("fallback-Person.avif".into()).ser());
- Ok(Redirect::permanent(rocket::uri!(r_asset(asset.0, width))))
-}
-
-// TODO this can create "federation recursion" because track selection cannot be relied on.
-//? TODO is this still relevant?
-
-#[get("/n/<id>/thumbnail?<t>&<width>")]
-pub async fn r_node_thumbnail(
- _session: Session,
- db: &State<Database>,
- fed: &State<Federation>,
- id: NodeID,
- t: f64,
- width: Option<usize>,
-) -> MyResult<Redirect> {
- let node = db.get_node(id)?.ok_or(anyhow!("node does not exist"))?;
-
- let media = node.media.as_ref().ok_or(anyhow!("no media"))?;
- let (thumb_track_index, thumb_track) = media
- .tracks
- .iter()
- .enumerate()
- .find(|(_i, t)| matches!(t.kind, SourceTrackKind::Video { .. }))
- .ok_or(anyhow!("no video track to create a thumbnail of"))?;
- let source = media
- .tracks
- .get(thumb_track_index)
- .ok_or(anyhow!("no source"))?;
- let thumb_track_source = source.source.clone();
-
- if t < 0. || t > media.duration {
- Err(anyhow!("thumbnail instant not within media duration"))?
- }
-
- let step = 8.;
- let t = (t / step).floor() * step;
-
- let asset = match thumb_track_source {
- TrackSource::Local(a) => {
- let AssetInner::LocalTrack(LocalTrack { path, .. }) = AssetInner::deser(&a.0)? else {
- return Err(anyhow!("track set to wrong asset type").into());
- };
- // the track selected might be different from thumb_track
- jellytranscoder::thumbnail::create_thumbnail(&CONF.media_path.join(path), t).await?
- }
- TrackSource::Remote(_) => {
- // TODO in the new system this is preferrably a property of node ext for regular fed
- let session = fed
- .get_session(
- thumb_track
- .federated
- .last()
- .ok_or(anyhow!("federation broken"))?,
- )
- .await?;
-
- async_cache_file("fed-thumb", (id, t as i64), |out| {
- session.node_thumbnail(out, id.into(), 2048, t)
- })
- .await?
- }
- };
-
- Ok(Redirect::temporary(rocket::uri!(r_asset(
- AssetInner::Cache(asset).ser().0,
- width
- ))))
-}
diff --git a/server/src/routes/ui/browser.rs b/server/src/routes/ui/browser.rs
deleted file mode 100644
index 96c005d..0000000
--- a/server/src/routes/ui/browser.rs
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- account::session::Session,
- error::MyError,
- layout::{trs, DynLayoutPage, LayoutPage},
- node::NodeCard,
- sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty},
-};
-use crate::{
- database::Database,
- routes::{api::AcceptJson, locale::AcceptLanguage},
- uri,
-};
-use jellybase::locale::tr;
-use jellycommon::{api::ApiItemsResponse, Visibility};
-use rocket::{get, serde::json::Json, Either, State};
-
-/// This function is a stub and only useful for use in the uri! macro.
-#[get("/items")]
-pub fn r_all_items() {}
-
-#[get("/items?<page>&<filter..>")]
-pub fn r_all_items_filter(
- sess: Session,
- db: &State<Database>,
- aj: AcceptJson,
- page: Option<usize>,
- filter: NodeFilterSort,
- lang: AcceptLanguage,
-) -> Result<Either<DynLayoutPage<'_>, Json<ApiItemsResponse>>, MyError> {
- let AcceptLanguage(lang) = lang;
- let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?;
-
- items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible));
-
- filter_and_sort_nodes(
- &filter,
- (SortProperty::Title, SortOrder::Ascending),
- &mut items,
- );
-
- let page_size = 100;
- let page = page.unwrap_or(0);
- let offset = page * page_size;
- let from = offset.min(items.len());
- let to = (offset + page_size).min(items.len());
- let max_page = items.len().div_ceil(page_size);
-
- Ok(if *aj {
- Either::Right(Json(ApiItemsResponse {
- count: items.len(),
- pages: max_page,
- items: items[from..to].to_vec(),
- }))
- } else {
- Either::Left(LayoutPage {
- title: "All Items".to_owned(),
- content: markup::new! {
- .page.dir {
- h1 { "All Items" }
- @NodeFilterSortForm { f: &filter, lang: &lang }
- ul.children { @for (node, udata) in &items[from..to] {
- li {@NodeCard { node, udata, lang: &lang }}
- }}
- p.pagecontrols {
- span.current { @tr(lang, "page.curr").replace("{cur}", &(page + 1).to_string()).replace("{max}", &max_page.to_string()) " " }
- @if page > 0 {
- a.prev[href=uri!(r_all_items_filter(Some(page - 1), filter.clone()))] { @trs(&lang, "page.prev") } " "
- }
- @if page + 1 < max_page {
- a.next[href=uri!(r_all_items_filter(Some(page + 1), filter.clone()))] { @trs(&lang, "page.next") }
- }
- }
- }
- },
- ..Default::default()
- })
- })
-}
diff --git a/server/src/routes/ui/error.rs b/server/src/routes/ui/error.rs
deleted file mode 100644
index ee593a2..0000000
--- a/server/src/routes/ui/error.rs
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::layout::{DynLayoutPage, LayoutPage};
-use crate::{routes::ui::account::rocket_uri_macro_r_account_login, uri};
-use jellybase::CONF;
-use log::info;
-use rocket::{
- catch,
- http::{ContentType, Status},
- response::{self, Responder},
- Request,
-};
-use serde_json::{json, Value};
-use std::{fmt::Display, fs::File, io::Read, sync::LazyLock};
-
-static ERROR_IMAGE: LazyLock<Vec<u8>> = LazyLock::new(|| {
- info!("loading error image");
- let mut f = File::open(CONF.asset_path.join("error.avif"))
- .expect("please create error.avif in the asset dir");
- let mut o = Vec::new();
- f.read_to_end(&mut o).unwrap();
- o
-});
-
-#[catch(default)]
-pub fn r_catch<'a>(status: Status, _request: &Request) -> DynLayoutPage<'a> {
- LayoutPage {
- title: "Not found".to_string(),
- content: markup::new! {
- h2 { "Error" }
- p { @format!("{status}") }
- @if status == Status::NotFound {
- p { "You might need to " a[href=uri!(r_account_login())] { "log in" } ", to see this page" }
- }
- },
- ..Default::default()
- }
-}
-
-#[catch(default)]
-pub fn r_api_catch(status: Status, _request: &Request) -> Value {
- json!({ "error": format!("{status}") })
-}
-
-pub type MyResult<T> = Result<T, MyError>;
-
-// TODO an actual error enum would be useful for status codes
-
-pub struct MyError(pub anyhow::Error);
-
-impl<'r> Responder<'r, 'static> for MyError {
- fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> {
- match req.accept().map(|a| a.preferred()) {
- Some(x) if x.is_json() => json!({ "error": format!("{}", self.0) }).respond_to(req),
- Some(x) if x.is_avif() || x.is_png() || x.is_jpeg() => {
- (ContentType::AVIF, ERROR_IMAGE.as_slice()).respond_to(req)
- }
- _ => LayoutPage {
- title: "Error".to_string(),
- content: markup::new! {
- h2 { "An error occured. Nobody is sorry"}
- pre.error { @format!("{:?}", self.0) }
- },
- ..Default::default()
- }
- .respond_to(req),
- }
- }
-}
-
-impl std::fmt::Debug for MyError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.write_fmt(format_args!("{:?}", self.0))
- }
-}
-
-impl Display for MyError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- self.0.fmt(f)
- }
-}
-impl From<anyhow::Error> for MyError {
- fn from(err: anyhow::Error) -> MyError {
- MyError(err)
- }
-}
-impl From<std::fmt::Error> for MyError {
- fn from(err: std::fmt::Error) -> MyError {
- MyError(anyhow::anyhow!("{err}"))
- }
-}
-impl From<std::io::Error> for MyError {
- fn from(err: std::io::Error) -> Self {
- MyError(anyhow::anyhow!("{err}"))
- }
-}
-impl From<serde_json::Error> for MyError {
- fn from(err: serde_json::Error) -> Self {
- MyError(anyhow::anyhow!("{err}"))
- }
-}
diff --git a/server/src/routes/ui/home.rs b/server/src/routes/ui/home.rs
deleted file mode 100644
index 8f8a876..0000000
--- a/server/src/routes/ui/home.rs
+++ /dev/null
@@ -1,180 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- account::session::Session,
- layout::{trs, LayoutPage},
- node::{DatabaseNodeUserDataExt, NodeCard},
-};
-use crate::{
- database::Database,
- routes::{
- api::AcceptJson,
- locale::AcceptLanguage,
- ui::{error::MyResult, layout::DynLayoutPage},
- },
-};
-use anyhow::Context;
-use chrono::{Datelike, Utc};
-use jellybase::{locale::tr, CONF};
-use jellycommon::{api::ApiHomeResponse, user::WatchedState, NodeID, NodeKind, Rating, Visibility};
-use rocket::{get, serde::json::Json, Either, State};
-
-#[get("/home")]
-pub fn r_home(
- sess: Session,
- db: &State<Database>,
- aj: AcceptJson,
- lang: AcceptLanguage,
-) -> MyResult<Either<DynLayoutPage, Json<ApiHomeResponse>>> {
- let AcceptLanguage(lang) = lang;
- let mut items = db.list_nodes_with_udata(&sess.user.name)?;
-
- let mut toplevel = db
- .get_node_children(NodeID::from_slug("library"))
- .context("root node missing")?
- .into_iter()
- .map(|n| db.get_node_with_userdata(n, &sess))
- .collect::<anyhow::Result<Vec<_>>>()?;
- toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX));
-
- let mut categories = Vec::<(String, Vec<_>)>::new();
-
- categories.push((
- "home.bin.continue_watching".to_string(),
- items
- .iter()
- .filter(|(_, u)| matches!(u.watched, WatchedState::Progress(_)))
- .cloned()
- .collect(),
- ));
- categories.push((
- "home.bin.watchlist".to_string(),
- items
- .iter()
- .filter(|(_, u)| matches!(u.watched, WatchedState::Pending))
- .cloned()
- .collect(),
- ));
-
- items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible));
-
- items.sort_by_key(|(n, _)| n.release_date.map(|d| -d).unwrap_or(i64::MAX));
-
- categories.push((
- "home.bin.latest_video".to_string(),
- items
- .iter()
- .filter(|(n, _)| matches!(n.kind, NodeKind::Video))
- .take(16)
- .cloned()
- .collect(),
- ));
- categories.push((
- "home.bin.latest_music".to_string(),
- items
- .iter()
- .filter(|(n, _)| matches!(n.kind, NodeKind::Music))
- .take(16)
- .cloned()
- .collect(),
- ));
- categories.push((
- "home.bin.latest_short_form".to_string(),
- items
- .iter()
- .filter(|(n, _)| matches!(n.kind, NodeKind::ShortFormVideo))
- .take(16)
- .cloned()
- .collect(),
- ));
-
- items.sort_by_key(|(n, _)| {
- n.ratings
- .get(&Rating::Tmdb)
- .map(|x| (*x * -1000.) as i32)
- .unwrap_or(0)
- });
-
- categories.push((
- "home.bin.max_rating".to_string(),
- items
- .iter()
- .take(16)
- .filter(|(n, _)| n.ratings.contains_key(&Rating::Tmdb))
- .cloned()
- .collect(),
- ));
-
- items.retain(|(n, _)| {
- matches!(
- n.kind,
- NodeKind::Video | NodeKind::Movie | NodeKind::Episode | NodeKind::Music
- )
- });
-
- categories.push((
- "home.bin.daily_random".to_string(),
- (0..16)
- .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone()))
- .collect(),
- ));
-
- {
- let mut items = items.clone();
- items.retain(|(_, u)| matches!(u.watched, WatchedState::Watched));
- categories.push((
- "home.bin.watch_again".to_string(),
- (0..16)
- .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone()))
- .collect(),
- ));
- }
-
- items.retain(|(n, _)| matches!(n.kind, NodeKind::Music));
- categories.push((
- "home.bin.daily_random_music".to_string(),
- (0..16)
- .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone()))
- .collect(),
- ));
-
- Ok(if *aj {
- Either::Right(Json(ApiHomeResponse {
- toplevel,
- categories,
- }))
- } else {
- Either::Left(LayoutPage {
- title: tr(lang, "home").to_string(),
- content: markup::new! {
- h2 { @tr(lang, "home.bin.root").replace("{title}", &CONF.brand) }
- ul.children.hlist {@for (node, udata) in &toplevel {
- li { @NodeCard { node, udata, lang: &lang } }
- }}
- @for (name, nodes) in &categories {
- @if !nodes.is_empty() {
- h2 { @trs(&lang, &name) }
- ul.children.hlist {@for (node, udata) in nodes {
- li { @NodeCard { node, udata, lang: &lang } }
- }}
- }
- }
- },
- ..Default::default()
- })
- })
-}
-
-fn cheap_daily_random(i: usize) -> usize {
- xorshift(xorshift(Utc::now().num_days_from_ce() as u64) + i as u64) as usize
-}
-
-fn xorshift(mut x: u64) -> u64 {
- x ^= x << 13;
- x ^= x >> 7;
- x ^= x << 17;
- x
-}
diff --git a/server/src/routes/ui/layout.rs b/server/src/routes/ui/layout.rs
deleted file mode 100644
index 0a0d036..0000000
--- a/server/src/routes/ui/layout.rs
+++ /dev/null
@@ -1,184 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use crate::{
- routes::{
- locale::lang_from_request,
- ui::{
- account::{
- rocket_uri_macro_r_account_login, rocket_uri_macro_r_account_logout,
- rocket_uri_macro_r_account_register, session::Session,
- settings::rocket_uri_macro_r_account_settings,
- },
- admin::rocket_uri_macro_r_admin_dashboard,
- browser::rocket_uri_macro_r_all_items,
- node::rocket_uri_macro_r_library_node,
- search::rocket_uri_macro_r_search,
- stats::rocket_uri_macro_r_stats,
- },
- },
- uri,
-};
-use futures::executor::block_on;
-use jellybase::{
- locale::{tr, Language},
- CONF,
-};
-use jellycommon::user::Theme;
-use jellycommon::NodeID;
-use jellyimport::is_importing;
-use markup::{raw, DynRender, Render, RenderAttributeValue};
-use rocket::{
- http::ContentType,
- response::{self, Responder},
- Request, Response,
-};
-use std::{borrow::Cow, io::Cursor, sync::LazyLock};
-
-static LOGO_ENABLED: LazyLock<bool> = LazyLock::new(|| CONF.asset_path.join("logo.svg").exists());
-
-pub struct TrString<'a>(Cow<'a, str>);
-impl Render for TrString<'_> {
- fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
- self.0.as_str().render(writer)
- }
-}
-impl RenderAttributeValue for TrString<'_> {
- fn is_none(&self) -> bool {
- false
- }
- fn is_true(&self) -> bool {
- false
- }
- fn is_false(&self) -> bool {
- false
- }
-}
-
-pub fn escape(str: &str) -> String {
- let mut o = String::with_capacity(str.len());
- let mut last = 0;
- for (index, byte) in str.bytes().enumerate() {
- if let Some(esc) = match byte {
- b'<' => Some("&lt;"),
- b'>' => Some("&gt;"),
- b'&' => Some("&amp;"),
- b'"' => Some("&quot;"),
- _ => None,
- } {
- o += &str[last..index];
- o += esc;
- last = index + 1;
- }
- }
- o += &str[last..];
- o
-}
-
-pub fn trs<'a>(lang: &Language, key: &str) -> TrString<'a> {
- TrString(tr(*lang, key))
-}
-
-markup::define! {
- Layout<'a, Main: Render>(title: String, main: Main, class: &'a str, session: Option<Session>, lang: Language) {
- @markup::doctype()
- html {
- head {
- title { @title " - " @CONF.brand }
- meta[name="viewport", content="width=device-width, initial-scale=1.0"];
- link[rel="stylesheet", href="/assets/style.css"];
- script[src="/assets/bundle.js"] {}
- }
- body[class=class] {
- nav {
- h1 { a[href=if session.is_some() {"/home"} else {"/"}] { @if *LOGO_ENABLED { img.logo[src="/assets/logo.svg"]; } else { @CONF.brand } } } " "
- @if let Some(_) = session {
- a.library[href=uri!(r_library_node("library"))] { @trs(lang, "nav.root") } " "
- a.library[href=uri!(r_all_items())] { @trs(lang, "nav.all") } " "
- a.library[href=uri!(r_search(None::<&'static str>, None::<usize>))] { @trs(lang, "nav.search") } " "
- a.library[href=uri!(r_stats())] { @trs(lang, "nav.stats") } " "
- }
- @if is_importing() { span.warn { "Library database is updating..." } }
- div.account {
- @if let Some(session) = session {
- span { @raw(tr(*lang, "nav.username").replace("{name}", &format!("<b class=\"username\">{}</b>", escape(&session.user.display_name)))) } " "
- @if session.user.admin {
- a.admin.hybrid_button[href=uri!(r_admin_dashboard())] { p {@trs(lang, "nav.admin")} } " "
- }
- a.settings.hybrid_button[href=uri!(r_account_settings())] { p {@trs(lang, "nav.settings")} } " "
- a.logout.hybrid_button[href=uri!(r_account_logout())] { p {@trs(lang, "nav.logout")} }
- } else {
- a.register.hybrid_button[href=uri!(r_account_register())] { p {@trs(lang, "nav.register")} } " "
- a.login.hybrid_button[href=uri!(r_account_login())] { p {@trs(lang, "nav.login")} }
- }
- }
- }
- #main { @main }
- footer {
- p { @CONF.brand " - " @CONF.slogan " | powered by " a[href="https://codeberg.org/metamuffin/jellything"]{"Jellything"} }
- }
- }
- }
- }
-
- FlashDisplay(flash: Option<Result<String, String>>) {
- @if let Some(flash) = &flash {
- @match flash {
- Ok(mesg) => { section.message { p.success { @mesg } } }
- Err(err) => { section.message { p.error { @err } } }
- }
- }
- }
-}
-
-pub type DynLayoutPage<'a> = LayoutPage<markup::DynRender<'a>>;
-
-pub struct LayoutPage<T> {
- pub title: String,
- pub class: Option<&'static str>,
- pub content: T,
-}
-
-impl Default for LayoutPage<DynRender<'_>> {
- fn default() -> Self {
- Self {
- class: None,
- content: markup::new!(),
- title: String::new(),
- }
- }
-}
-
-impl<'r, Main: Render> Responder<'r, 'static> for LayoutPage<Main> {
- fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> {
- // TODO blocking the event loop here. it seems like there is no other way to
- // TODO offload this, since the guard references `req` which has a lifetime.
- // TODO therefore we just block. that is fine since the database is somewhat fast.
- let lang = lang_from_request(&req);
- let session = block_on(req.guard::<Option<Session>>()).unwrap();
- let mut out = String::new();
- Layout {
- main: self.content,
- title: self.title,
- class: &format!(
- "{} theme-{:?}",
- self.class.unwrap_or(""),
- session
- .as_ref()
- .map(|s| s.user.theme)
- .unwrap_or(Theme::Dark)
- ),
- session,
- lang,
- }
- .render(&mut out)
- .unwrap();
-
- Response::build()
- .header(ContentType::HTML)
- .streamed_body(Cursor::new(out))
- .ok()
- }
-}
diff --git a/server/src/routes/ui/mod.rs b/server/src/routes/ui/mod.rs
deleted file mode 100644
index d61ef9e..0000000
--- a/server/src/routes/ui/mod.rs
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use account::session::Session;
-use error::MyResult;
-use home::rocket_uri_macro_r_home;
-use jellybase::CONF;
-use layout::{DynLayoutPage, LayoutPage};
-use log::debug;
-use markup::Render;
-use rocket::{
- futures::FutureExt,
- get,
- http::{ContentType, Header, Status},
- response::{self, Redirect, Responder},
- Either, Request, Response,
-};
-use std::{
- collections::hash_map::DefaultHasher,
- future::Future,
- hash::{Hash, Hasher},
- io::Cursor,
- os::unix::prelude::MetadataExt,
- path::Path,
- pin::Pin,
-};
-use tokio::{
- fs::{read_to_string, File},
- io::AsyncRead,
-};
-
-pub mod account;
-pub mod admin;
-pub mod assets;
-pub mod browser;
-pub mod error;
-pub mod home;
-pub mod layout;
-pub mod node;
-pub mod player;
-pub mod search;
-pub mod sort;
-pub mod stats;
-pub mod style;
-
-#[get("/")]
-pub async fn r_index(sess: Option<Session>) -> MyResult<Either<Redirect, DynLayoutPage<'static>>> {
- if sess.is_some() {
- Ok(Either::Left(Redirect::temporary(rocket::uri!(r_home()))))
- } else {
- let front = read_to_string(CONF.asset_path.join("front.htm")).await?;
- Ok(Either::Right(LayoutPage {
- title: "Home".to_string(),
- content: markup::new! {
- @markup::raw(&front)
- },
- ..Default::default()
- }))
- }
-}
-
-pub struct HtmlTemplate<'a>(pub markup::DynRender<'a>);
-
-impl<'r> Responder<'r, 'static> for HtmlTemplate<'_> {
- fn respond_to(self, _req: &'r Request<'_>) -> response::Result<'static> {
- let mut out = String::new();
- self.0.render(&mut out).unwrap();
- Response::build()
- .header(ContentType::HTML)
- .sized_body(out.len(), Cursor::new(out))
- .ok()
- }
-}
-
-pub struct Defer(Pin<Box<dyn Future<Output = String> + Send>>);
-
-impl AsyncRead for Defer {
- fn poll_read(
- mut self: std::pin::Pin<&mut Self>,
- cx: &mut std::task::Context<'_>,
- buf: &mut tokio::io::ReadBuf<'_>,
- ) -> std::task::Poll<std::io::Result<()>> {
- match self.0.poll_unpin(cx) {
- std::task::Poll::Ready(r) => {
- buf.put_slice(r.as_bytes());
- std::task::Poll::Ready(Ok(()))
- }
- std::task::Poll::Pending => std::task::Poll::Pending,
- }
- }
-}
-
-pub struct CacheControlFile(File, String);
-impl CacheControlFile {
- pub async fn new_cachekey(p: &Path) -> anyhow::Result<Self> {
- let tag = p.file_name().unwrap().to_str().unwrap().to_owned();
- let f = File::open(p).await?;
- Ok(Self(f, tag))
- }
- pub async fn new_mtime(f: File) -> Self {
- let meta = f.metadata().await.unwrap();
- let modified = meta.mtime();
- let mut h = DefaultHasher::new();
- modified.hash(&mut h);
- let tag = format!("{:0>16x}", h.finish());
- Self(f, tag)
- }
-}
-impl<'r> Responder<'r, 'static> for CacheControlFile {
- fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> {
- let Self(file, tag) = self;
- if req.headers().get_one("if-none-match") == Some(&tag) {
- debug!("file cache: not modified");
- Response::build()
- .status(Status::NotModified)
- .header(Header::new("cache-control", "private"))
- .header(Header::new("etag", tag))
- .ok()
- } else {
- debug!("file cache: transfer");
- Response::build()
- .status(Status::Ok)
- .header(Header::new("cache-control", "private"))
- .header(Header::new("etag", tag))
- .streamed_body(file)
- .ok()
- }
- }
-}
diff --git a/server/src/routes/ui/node.rs b/server/src/routes/ui/node.rs
deleted file mode 100644
index d968f0a..0000000
--- a/server/src/routes/ui/node.rs
+++ /dev/null
@@ -1,558 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- assets::{
- rocket_uri_macro_r_item_backdrop, rocket_uri_macro_r_item_poster,
- rocket_uri_macro_r_node_thumbnail,
- },
- error::MyResult,
- layout::{trs, TrString},
- sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty},
-};
-use crate::{
- database::Database,
- routes::{
- api::AcceptJson,
- locale::AcceptLanguage,
- ui::{
- account::session::Session,
- assets::rocket_uri_macro_r_person_asset,
- layout::{DynLayoutPage, LayoutPage},
- player::{rocket_uri_macro_r_player, PlayerConfig},
- },
- userdata::{
- rocket_uri_macro_r_node_userdata_rating, rocket_uri_macro_r_node_userdata_watched,
- UrlWatchedState,
- },
- },
- uri,
-};
-use anyhow::{anyhow, Result};
-use chrono::DateTime;
-use jellybase::locale::{tr, Language};
-use jellycommon::{
- api::ApiNodeResponse,
- user::{NodeUserData, WatchedState},
- Chapter, MediaInfo, Node, NodeID, NodeKind, PeopleGroup, Rating, SourceTrackKind, Visibility,
-};
-use rocket::{get, serde::json::Json, Either, State};
-use std::{cmp::Reverse, collections::BTreeMap, fmt::Write, sync::Arc};
-
-/// This function is a stub and only useful for use in the uri! macro.
-#[get("/n/<id>")]
-pub fn r_library_node(id: NodeID) {
- let _ = id;
-}
-
-#[get("/n/<id>?<parents>&<children>&<filter..>")]
-pub async fn r_library_node_filter<'a>(
- session: Session,
- id: NodeID,
- db: &'a State<Database>,
- aj: AcceptJson,
- filter: NodeFilterSort,
- lang: AcceptLanguage,
- parents: bool,
- children: bool,
-) -> MyResult<Either<DynLayoutPage<'a>, Json<ApiNodeResponse>>> {
- let AcceptLanguage(lang) = lang;
- let (node, udata) = db.get_node_with_userdata(id, &session)?;
-
- let mut children = if !*aj || children {
- db.get_node_children(id)?
- .into_iter()
- .map(|c| db.get_node_with_userdata(c, &session))
- .collect::<anyhow::Result<Vec<_>>>()?
- } else {
- Vec::new()
- };
-
- let mut parents = if !*aj || parents {
- node.parents
- .iter()
- .map(|pid| db.get_node_with_userdata(*pid, &session))
- .collect::<anyhow::Result<Vec<_>>>()?
- } else {
- Vec::new()
- };
-
- let mut similar = get_similar_media(&node, db, &session)?;
-
- similar.retain(|(n, _)| n.visibility >= Visibility::Reduced);
- children.retain(|(n, _)| n.visibility >= Visibility::Reduced);
- parents.retain(|(n, _)| n.visibility >= Visibility::Reduced);
-
- filter_and_sort_nodes(
- &filter,
- match node.kind {
- NodeKind::Channel => (SortProperty::ReleaseDate, SortOrder::Descending),
- NodeKind::Season | NodeKind::Show => (SortProperty::Index, SortOrder::Ascending),
- _ => (SortProperty::Title, SortOrder::Ascending),
- },
- &mut children,
- );
-
- Ok(if *aj {
- Either::Right(Json(ApiNodeResponse {
- children,
- parents,
- node,
- userdata: udata,
- }))
- } else {
- Either::Left(LayoutPage {
- title: node.title.clone().unwrap_or_default(),
- content: markup::new!(@NodePage {
- node: &node,
- udata: &udata,
- children: &children,
- parents: &parents,
- filter: &filter,
- player: false,
- similar: &similar,
- lang: &lang,
- }),
- ..Default::default()
- })
- })
-}
-
-pub fn get_similar_media(
- node: &Node,
- db: &Database,
- session: &Session,
-) -> Result<Vec<(Arc<Node>, NodeUserData)>> {
- let this_id = NodeID::from_slug(&node.slug);
- let mut ranking = BTreeMap::<NodeID, usize>::new();
- for tag in &node.tags {
- let nodes = db.get_tag_nodes(tag)?;
- let weight = 1_000_000 / nodes.len();
- for n in nodes {
- if n != this_id {
- *ranking.entry(n).or_default() += weight;
- }
- }
- }
- let mut ranking = ranking.into_iter().collect::<Vec<_>>();
- ranking.sort_by_key(|(_, k)| Reverse(*k));
- ranking
- .into_iter()
- .take(32)
- .map(|(pid, _)| db.get_node_with_userdata(pid, session))
- .collect::<anyhow::Result<Vec<_>>>()
-}
-
-markup::define! {
- NodeCard<'a>(node: &'a Node, udata: &'a NodeUserData, lang: &'a Language) {
- @let cls = format!("node card poster {}", aspect_class(node.kind));
- div[class=cls] {
- .poster {
- a[href=uri!(r_library_node(&node.slug))] {
- img[src=uri!(r_item_poster(&node.slug, Some(1024))), loading="lazy"];
- }
- .cardhover.item {
- @if node.media.is_some() {
- a.play.icon[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { "play_arrow" }
- }
- @Props { node, udata, full: false, lang }
- }
- }
- div.title {
- a[href=uri!(r_library_node(&node.slug))] {
- @node.title
- }
- }
- div.subtitle {
- span {
- @node.subtitle
- }
- }
- }
- }
- NodeCardWide<'a>(node: &'a Node, udata: &'a NodeUserData, lang: &'a Language) {
- div[class="node card widecard poster"] {
- div[class=&format!("poster {}", aspect_class(node.kind))] {
- a[href=uri!(r_library_node(&node.slug))] {
- img[src=uri!(r_item_poster(&node.slug, Some(1024))), loading="lazy"];
- }
- .cardhover.item {
- @if node.media.is_some() {
- a.play.icon[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { "play_arrow" }
- }
- }
- }
- div.details {
- a.title[href=uri!(r_library_node(&node.slug))] { @node.title }
- @Props { node, udata, full: false, lang }
- span.overview { @node.description }
- }
- }
- }
- NodePage<'a>(
- node: &'a Node,
- udata: &'a NodeUserData,
- children: &'a [(Arc<Node>, NodeUserData)],
- parents: &'a [(Arc<Node>, NodeUserData)],
- similar: &'a [(Arc<Node>, NodeUserData)],
- filter: &'a NodeFilterSort,
- lang: &'a Language,
- player: bool,
- ) {
- @if !matches!(node.kind, NodeKind::Collection) && !player {
- img.backdrop[src=uri!(r_item_backdrop(&node.slug, Some(2048))), loading="lazy"];
- }
- .page.node {
- @if !matches!(node.kind, NodeKind::Collection) && !player {
- @let cls = format!("bigposter {}", aspect_class(node.kind));
- div[class=cls] { img[src=uri!(r_item_poster(&node.slug, Some(2048))), loading="lazy"]; }
- }
- .title {
- h1 { @node.title }
- ul.parents { @for (node, _) in *parents { li {
- a.component[href=uri!(r_library_node(&node.slug))] { @node.title }
- }}}
- @if node.media.is_some() {
- a.play[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { @trs(lang, "node.player_link") }
- }
- @if !matches!(node.kind, NodeKind::Collection | NodeKind::Channel) {
- @if matches!(udata.watched, WatchedState::None | WatchedState::Pending | WatchedState::Progress(_)) {
- form.mark_watched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Watched))] {
- input[type="submit", value=trs(lang, "node.watched.set")];
- }
- }
- @if matches!(udata.watched, WatchedState::Watched) {
- form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] {
- input[type="submit", value=trs(lang, "node.watched.unset")];
- }
- }
- @if matches!(udata.watched, WatchedState::None) {
- form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::Pending))] {
- input[type="submit", value=trs(lang, "node.watchlist.set")];
- }
- }
- @if matches!(udata.watched, WatchedState::Pending) {
- form.mark_unwatched[method="POST", action=uri!(r_node_userdata_watched(&node.slug, UrlWatchedState::None))] {
- input[type="submit", value=trs(lang, "node.watchlist.unset")];
- }
- }
- form.rating[method="POST", action=uri!(r_node_userdata_rating(&node.slug))] {
- input[type="range", name="rating", min=-10, max=10, step=1, value=udata.rating];
- input[type="submit", value=trs(lang, "node.update_rating")];
- }
- }
- }
- .details {
- @Props { node, udata, full: true, lang }
- h3 { @node.tagline }
- @if let Some(description) = &node.description {
- p { @for line in description.lines() { @line br; } }
- }
- @if let Some(media) = &node.media {
- @if !media.chapters.is_empty() {
- h2 { @trs(lang, "node.chapters") }
- ul.children.hlist { @for chap in &media.chapters {
- @let (inl, sub) = format_chapter(chap);
- li { .card."aspect-thumb" {
- .poster {
- a[href=&uri!(r_player(&node.slug, PlayerConfig::seek(chap.time_start.unwrap_or(0.))))] {
- img[src=&uri!(r_node_thumbnail(&node.slug, chapter_key_time(chap, media.duration), Some(1024))), loading="lazy"];
- }
- .cardhover { .props { p { @inl } } }
- }
- .title { span { @sub } }
- }}
- }}
- }
- @if !node.people.is_empty() {
- h2 { @trs(lang, "node.people") }
- @for (group, people) in &node.people {
- details[open=group==&PeopleGroup::Cast] {
- summary { h3 { @format!("{}", group) } }
- ul.children.hlist { @for (i, pe) in people.iter().enumerate() {
- li { .card."aspect-port" {
- .poster {
- a[href="#"] {
- img[src=&uri!(r_person_asset(&node.slug, i, group.to_string(), Some(1024))), loading="lazy"];
- }
- }
- .title {
- span { @pe.person.name } br;
- @if let Some(c) = pe.characters.first() {
- span.subtitle { @c }
- }
- @if let Some(c) = pe.jobs.first() {
- span.subtitle { @c }
- }
- }
- }}
- }}
- }
- }
- }
- details {
- summary { @trs(lang, "media.tracks") }
- ol { @for track in &media.tracks {
- li { @format!("{track}") }
- }}
- }
- }
- @if !node.external_ids.is_empty() {
- details {
- summary { @trs(lang, "node.external_ids") }
- table {
- @for (key, value) in &node.external_ids { tr {
- tr {
- td { @trs(lang, &format!("eid.{}", key)) }
- @if let Some(url) = external_id_url(key, value) {
- td { a[href=url] { pre { @value } } }
- } else {
- td { pre { @value } }
- }
- }
- }}
- }
- }
- }
- @if !node.tags.is_empty() {
- details {
- summary { @trs(lang, "node.tags") }
- ol { @for tag in &node.tags {
- li { @tag }
- }}
- }
- }
- }
- @if matches!(node.kind, NodeKind::Collection | NodeKind::Channel) {
- @NodeFilterSortForm { f: filter, lang }
- }
- @if !similar.is_empty() {
- h2 { @trs(lang, "node.similar") }
- ul.children.hlist {@for (node, udata) in similar.iter() {
- li { @NodeCard { node, udata, lang } }
- }}
- }
- @match node.kind {
- NodeKind::Show | NodeKind::Series | NodeKind::Season => {
- ol { @for (node, udata) in children.iter() {
- li { @NodeCardWide { node, udata, lang } }
- }}
- }
- NodeKind::Collection | NodeKind::Channel | _ => {
- ul.children {@for (node, udata) in children.iter() {
- li { @NodeCard { node, udata, lang } }
- }}
- }
- }
- }
- }
-
- Props<'a>(node: &'a Node, udata: &'a NodeUserData, full: bool, lang: &'a Language) {
- .props {
- @if let Some(m) = &node.media {
- p { @format_duration(m.duration) }
- p { @m.resolution_name() }
- }
- @if let Some(d) = &node.release_date {
- p { @if *full {
- @DateTime::from_timestamp_millis(*d).unwrap().naive_utc().to_string()
- } else {
- @DateTime::from_timestamp_millis(*d).unwrap().date_naive().to_string()
- }}
- }
- @match node.visibility {
- Visibility::Visible => {}
- Visibility::Reduced => {p.visibility{@trs(lang, "prop.vis.reduced")}}
- Visibility::Hidden => {p.visibility{@trs(lang, "prop.vis.hidden")}}
- }
- // TODO
- // @if !node.children.is_empty() {
- // p { @format!("{} items", node.children.len()) }
- // }
- @for (kind, value) in &node.ratings {
- @match kind {
- Rating::YoutubeLikes => {p.likes{ @format_count(*value as usize) " Likes" }}
- Rating::YoutubeViews => {p{ @format_count(*value as usize) " Views" }}
- Rating::YoutubeFollowers => {p{ @format_count(*value as usize) " Subscribers" }}
- Rating::RottenTomatoes => {p.rating{ @value " Tomatoes" }}
- Rating::Metacritic if *full => {p{ "Metacritic Score: " @value }}
- Rating::Imdb => {p.rating{ "IMDb " @value }}
- Rating::Tmdb => {p.rating{ "TMDB " @value }}
- Rating::Trakt if *full => {p.rating{ "Trakt " @value }}
- _ => {}
- }
- }
- @if let Some(f) = &node.federated {
- p.federation { @f }
- }
- @match udata.watched {
- WatchedState::None => {}
- WatchedState::Pending => { p.pending { @trs(lang, "prop.watched.pending") } }
- WatchedState::Progress(x) => { p.progress { @tr(**lang, "prop.watched.progress").replace("{time}", &format_duration(x)) } }
- WatchedState::Watched => { p.watched { @trs(lang, "prop.watched.watched") } }
- }
- }
- }
-}
-
-pub fn aspect_class(kind: NodeKind) -> &'static str {
- use NodeKind::*;
- match kind {
- Video | Episode => "aspect-thumb",
- Collection => "aspect-land",
- Season | Show | Series | Movie | ShortFormVideo => "aspect-port",
- Channel | Music | Unknown => "aspect-square",
- }
-}
-
-pub fn format_duration(d: f64) -> String {
- format_duration_mode(d, false, Language::English)
-}
-pub fn format_duration_long(d: f64, lang: Language) -> String {
- format_duration_mode(d, true, lang)
-}
-fn format_duration_mode(mut d: f64, long_units: bool, lang: Language) -> String {
- let mut s = String::new();
- let sign = if d > 0. { "" } else { "-" };
- d = d.abs();
- for (short, long, long_pl, k) in [
- ("d", "time.day", "time.days", 60. * 60. * 24.),
- ("h", "time.hour", "time.hours", 60. * 60.),
- ("m", "time.minute", "time.minutes", 60.),
- ("s", "time.second", "time.seconds", 1.),
- ] {
- let h = (d / k).floor();
- d -= h * k;
- if h > 0. {
- if long_units {
- let long = tr(lang, if h != 1. { long_pl } else { long });
- let and = format!(" {} ", tr(lang, "time.and_join"));
- // TODO breaks if seconds is zero
- write!(
- s,
- "{}{h} {long}{}",
- if k != 1. { "" } else { &and },
- if k > 60. { ", " } else { "" },
- )
- .unwrap();
- } else {
- write!(s, "{h}{short} ").unwrap();
- }
- }
- }
- format!("{sign}{}", s.trim())
-}
-pub fn format_size(size: u64) -> String {
- humansize::format_size(size, humansize::DECIMAL)
-}
-pub fn format_kind(k: NodeKind, lang: Language) -> TrString<'static> {
- trs(
- &lang,
- match k {
- NodeKind::Unknown => "kind.unknown",
- NodeKind::Movie => "kind.movie",
- NodeKind::Video => "kind.video",
- NodeKind::Music => "kind.music",
- NodeKind::ShortFormVideo => "kind.short_form_video",
- NodeKind::Collection => "kind.collection",
- NodeKind::Channel => "kind.channel",
- NodeKind::Show => "kind.show",
- NodeKind::Series => "kind.series",
- NodeKind::Season => "kind.season",
- NodeKind::Episode => "kind.episode",
- },
- )
-}
-
-pub trait DatabaseNodeUserDataExt {
- fn get_node_with_userdata(
- &self,
- id: NodeID,
- session: &Session,
- ) -> Result<(Arc<Node>, NodeUserData)>;
-}
-impl DatabaseNodeUserDataExt for Database {
- fn get_node_with_userdata(
- &self,
- id: NodeID,
- session: &Session,
- ) -> Result<(Arc<Node>, NodeUserData)> {
- Ok((
- self.get_node(id)?.ok_or(anyhow!("node does not exist"))?,
- self.get_node_udata(id, &session.user.name)?
- .unwrap_or_default(),
- ))
- }
-}
-
-trait MediaInfoExt {
- fn resolution_name(&self) -> &'static str;
-}
-impl MediaInfoExt for MediaInfo {
- fn resolution_name(&self) -> &'static str {
- let mut maxdim = 0;
- for t in &self.tracks {
- if let SourceTrackKind::Video { width, height, .. } = &t.kind {
- maxdim = maxdim.max(*width.max(height))
- }
- }
-
- match maxdim {
- 30720.. => "32K",
- 15360.. => "16K",
- 7680.. => "8K UHD",
- 5120.. => "5K UHD",
- 3840.. => "4K UHD",
- 2560.. => "QHD 1440p",
- 1920.. => "FHD 1080p",
- 1280.. => "HD 720p",
- 854.. => "SD 480p",
- _ => "Unkown",
- }
- }
-}
-
-fn format_count(n: impl Into<usize>) -> String {
- let n: usize = n.into();
-
- if n >= 1_000_000 {
- format!("{:.1}M", n as f32 / 1_000_000.)
- } else if n >= 1_000 {
- format!("{:.1}k", n as f32 / 1_000.)
- } else {
- format!("{n}")
- }
-}
-
-fn format_chapter(c: &Chapter) -> (String, String) {
- (
- format!(
- "{}-{}",
- c.time_start.map(format_duration).unwrap_or_default(),
- c.time_end.map(format_duration).unwrap_or_default(),
- ),
- c.labels.first().map(|l| l.1.clone()).unwrap_or_default(),
- )
-}
-
-fn chapter_key_time(c: &Chapter, dur: f64) -> f64 {
- let start = c.time_start.unwrap_or(0.);
- let end = c.time_end.unwrap_or(dur);
- start * 0.8 + end * 0.2
-}
-
-fn external_id_url(key: &str, value: &str) -> Option<String> {
- Some(match key {
- "youtube.video" => format!("https://youtube.com/watch?v={value}"),
- "youtube.channel" => format!("https://youtube.com/channel/{value}"),
- "youtube.channelname" => format!("https://youtube.com/channel/@{value}"),
- "musicbrainz.release" => format!("https://musicbrainz.org/release/{value}"),
- "musicbrainz.albumartist" => format!("https://musicbrainz.org/artist/{value}"),
- "musicbrainz.artist" => format!("https://musicbrainz.org/artist/{value}"),
- "musicbrainz.releasegroup" => format!("https://musicbrainz.org/release-group/{value}"),
- "musicbrainz.recording" => format!("https://musicbrainz.org/recording/{value}"),
- _ => return None,
- })
-}
diff --git a/server/src/routes/ui/player.rs b/server/src/routes/ui/player.rs
deleted file mode 100644
index 2bb439b..0000000
--- a/server/src/routes/ui/player.rs
+++ /dev/null
@@ -1,200 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- account::session::{token, Session},
- layout::LayoutPage,
- node::{get_similar_media, DatabaseNodeUserDataExt, NodePage},
- sort::NodeFilterSort,
-};
-use crate::{
- database::Database,
- routes::{
- locale::AcceptLanguage,
- ui::{error::MyResult, layout::DynLayoutPage},
- },
-};
-use anyhow::anyhow;
-use jellybase::CONF;
-use jellycommon::{
- stream::{StreamContainer, StreamSpec},
- user::{PermissionSet, PlayerKind},
- Node, NodeID, SourceTrackKind, TrackID, Visibility,
-};
-use markup::DynRender;
-use rocket::{get, response::Redirect, Either, FromForm, State, UriDisplayQuery};
-use std::sync::Arc;
-
-#[derive(FromForm, Default, Clone, Debug, UriDisplayQuery)]
-pub struct PlayerConfig {
- pub a: Option<TrackID>,
- pub v: Option<TrackID>,
- pub s: Option<TrackID>,
- pub t: Option<f64>,
- pub kind: Option<PlayerKind>,
-}
-
-impl PlayerConfig {
- pub fn seek(t: f64) -> Self {
- Self {
- t: Some(t),
- ..Default::default()
- }
- }
-}
-
-fn jellynative_url(action: &str, seek: f64, secret: &str, node: &str, session: &str) -> String {
- let protocol = if CONF.tls { "https" } else { "http" };
- let host = &CONF.hostname;
- let stream_url = format!(
- "/n/{node}/stream{}",
- StreamSpec::HlsMultiVariant {
- segment: 0,
- container: StreamContainer::Matroska
- }
- .to_query()
- );
- format!("jellynative://{action}/{secret}/{session}/{seek}/{protocol}://{host}{stream_url}",)
-}
-
-#[get("/n/<id>/player?<conf..>", rank = 4)]
-pub fn r_player(
- session: Session,
- lang: AcceptLanguage,
- db: &State<Database>,
- id: NodeID,
- conf: PlayerConfig,
-) -> MyResult<Either<DynLayoutPage<'_>, Redirect>> {
- let AcceptLanguage(lang) = lang;
- let (node, udata) = db.get_node_with_userdata(id, &session)?;
-
- let mut parents = node
- .parents
- .iter()
- .map(|pid| db.get_node_with_userdata(*pid, &session))
- .collect::<anyhow::Result<Vec<_>>>()?;
-
- let mut similar = get_similar_media(&node, db, &session)?;
-
- similar.retain(|(n, _)| n.visibility >= Visibility::Reduced);
- parents.retain(|(n, _)| n.visibility >= Visibility::Reduced);
-
- let native_session = |action: &str| {
- Ok(Either::Right(Redirect::temporary(jellynative_url(
- action,
- conf.t.unwrap_or(0.),
- &session.user.native_secret,
- &id.to_string(),
- &token::create(
- session.user.name,
- PermissionSet::default(), // TODO
- chrono::Duration::hours(24),
- ),
- ))))
- };
-
- match conf.kind.unwrap_or(session.user.player_preference) {
- PlayerKind::Browser => (),
- PlayerKind::Native => {
- return native_session("player-v2");
- }
- PlayerKind::NativeFullscreen => {
- return native_session("player-fullscreen-v2");
- }
- }
-
- // TODO
- // let spec = StreamSpec {
- // track: None
- // .into_iter()
- // .chain(conf.v)
- // .chain(conf.a)
- // .chain(conf.s)
- // .collect::<Vec<_>>(),
- // format: StreamFormat::Matroska,
- // webm: Some(true),
- // ..Default::default()
- // };
- // let playing = false; // !spec.track.is_empty();
- // let conf = player_conf(node.clone(), playing)?;
-
- Ok(Either::Left(LayoutPage {
- title: node.title.to_owned().unwrap_or_default(),
- class: Some("player"),
- content: markup::new! {
- // @if playing {
- // // video[src=uri!(r_stream(&node.slug, &spec)), controls, preload="auto"]{}
- // }
- // @conf
- @NodePage {
- children: &[],
- parents: &parents,
- filter: &NodeFilterSort::default(),
- node: &node,
- udata: &udata,
- player: true,
- similar: &similar,
- lang: &lang
- }
- },
- }))
-}
-
-pub fn player_conf<'a>(item: Arc<Node>, playing: bool) -> anyhow::Result<DynRender<'a>> {
- let mut audio_tracks = vec![];
- let mut video_tracks = vec![];
- let mut sub_tracks = vec![];
- let tracks = item
- .media
- .clone()
- .ok_or(anyhow!("node does not have media"))?
- .tracks
- .clone();
- for (tid, track) in tracks.into_iter().enumerate() {
- match &track.kind {
- SourceTrackKind::Audio { .. } => audio_tracks.push((tid, track)),
- SourceTrackKind::Video { .. } => video_tracks.push((tid, track)),
- SourceTrackKind::Subtitles => sub_tracks.push((tid, track)),
- }
- }
-
- Ok(markup::new! {
- form.playerconf[method = "GET", action = ""] {
- h2 { "Select tracks for " @item.title }
-
- fieldset.video {
- legend { "Video" }
- @for (i, (tid, track)) in video_tracks.iter().enumerate() {
- input[type="radio", id=tid, name="v", value=tid, checked=i==0];
- label[for=tid] { @format!("{track}") } br;
- }
- input[type="radio", id="v-none", name="v", value=""];
- label[for="v-none"] { "No video" }
- }
-
- fieldset.audio {
- legend { "Audio" }
- @for (i, (tid, track)) in audio_tracks.iter().enumerate() {
- input[type="radio", id=tid, name="a", value=tid, checked=i==0];
- label[for=tid] { @format!("{track}") } br;
- }
- input[type="radio", id="a-none", name="a", value=""];
- label[for="a-none"] { "No audio" }
- }
-
- fieldset.subtitles {
- legend { "Subtitles" }
- @for (_i, (tid, track)) in sub_tracks.iter().enumerate() {
- input[type="radio", id=tid, name="s", value=tid];
- label[for=tid] { @format!("{track}") } br;
- }
- input[type="radio", id="s-none", name="s", value="", checked=true];
- label[for="s-none"] { "No subtitles" }
- }
-
- input[type="submit", value=if playing { "Change tracks" } else { "Start playback" }];
- }
- })
-}
diff --git a/server/src/routes/ui/search.rs b/server/src/routes/ui/search.rs
deleted file mode 100644
index bc84e57..0000000
--- a/server/src/routes/ui/search.rs
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- account::session::Session,
- error::MyResult,
- layout::{trs, DynLayoutPage, LayoutPage},
- node::{DatabaseNodeUserDataExt, NodeCard},
-};
-use crate::routes::{api::AcceptJson, locale::AcceptLanguage};
-use anyhow::anyhow;
-use jellybase::{database::Database, locale::tr};
-use jellycommon::{api::ApiSearchResponse, Visibility};
-use rocket::{get, serde::json::Json, Either, State};
-use std::time::Instant;
-
-#[get("/search?<query>&<page>")]
-pub async fn r_search<'a>(
- session: Session,
- db: &State<Database>,
- aj: AcceptJson,
- query: Option<&str>,
- page: Option<usize>,
- lang: AcceptLanguage,
-) -> MyResult<Either<DynLayoutPage<'a>, Json<ApiSearchResponse>>> {
- let AcceptLanguage(lang) = lang;
- let results = if let Some(query) = query {
- let timing = Instant::now();
- let (count, ids) = db.search(query, 32, page.unwrap_or_default() * 32)?;
- let mut nodes = ids
- .into_iter()
- .map(|id| db.get_node_with_userdata(id, &session))
- .collect::<Result<Vec<_>, anyhow::Error>>()?;
- nodes.retain(|(n, _)| n.visibility >= Visibility::Reduced);
- let search_dur = timing.elapsed();
- Some((count, nodes, search_dur))
- } else {
- None
- };
- let query = query.unwrap_or_default().to_string();
-
- Ok(if *aj {
- let Some((count, results, _)) = results else {
- Err(anyhow!("no query"))?
- };
- Either::Right(Json(ApiSearchResponse { count, results }))
- } else {
- Either::Left(LayoutPage {
- title: tr(lang, "search.title").to_string(),
- class: Some("search"),
- content: markup::new! {
- h1 { @trs(&lang, "search.title") }
- form[action="", method="GET"] {
- input[type="text", name="query", placeholder=&*tr(lang, "search.placeholder"), value=&query];
- input[type="submit", value="Search"];
- }
- @if let Some((count, results, search_dur)) = &results {
- h2 { @trs(&lang, "search.results.title") }
- p.stats { @tr(lang, "search.results.stats").replace("{count}", &count.to_string()).replace("{dur}", &format!("{search_dur:?}")) }
- ul.children {@for (node, udata) in results.iter() {
- li { @NodeCard { node, udata, lang: &lang } }
- }}
- // TODO pagination
- }
- },
- })
- })
-}
diff --git a/server/src/routes/ui/sort.rs b/server/src/routes/ui/sort.rs
deleted file mode 100644
index 6d38e11..0000000
--- a/server/src/routes/ui/sort.rs
+++ /dev/null
@@ -1,290 +0,0 @@
-use jellybase::locale::Language;
-use jellycommon::{helpers::SortAnyway, user::NodeUserData, Node, NodeKind, Rating};
-use markup::RenderAttributeValue;
-use rocket::{
- http::uri::fmt::{Query, UriDisplay},
- FromForm, FromFormField, UriDisplayQuery,
-};
-use std::sync::Arc;
-
-use crate::routes::ui::layout::trs;
-
-#[derive(FromForm, UriDisplayQuery, Default, Clone)]
-pub struct NodeFilterSort {
- pub sort_by: Option<SortProperty>,
- pub filter_kind: Option<Vec<FilterProperty>>,
- pub sort_order: Option<SortOrder>,
-}
-
-macro_rules! form_enum {
- (enum $i:ident { $($vi:ident = $vk:literal),*, }) => {
- #[derive(Debug, FromFormField, UriDisplayQuery, Clone, Copy, PartialEq, Eq)]
- pub enum $i { $(#[field(value = $vk)] $vi),* }
- impl $i { #[allow(unused)] const ALL: &'static [$i] = &[$($i::$vi),*]; }
- };
-}
-
-form_enum!(
- enum FilterProperty {
- FederationLocal = "fed_local",
- FederationRemote = "fed_remote",
- Watched = "watched",
- Unwatched = "unwatched",
- WatchProgress = "watch_progress",
- KindMovie = "kind_movie",
- KindVideo = "kind_video",
- KindShortFormVideo = "kind_short_form_video",
- KindMusic = "kind_music",
- KindCollection = "kind_collection",
- KindChannel = "kind_channel",
- KindShow = "kind_show",
- KindSeries = "kind_series",
- KindSeason = "kind_season",
- KindEpisode = "kind_episode",
- }
-);
-
-form_enum!(
- enum SortProperty {
- ReleaseDate = "release_date",
- Title = "title",
- Index = "index",
- Duration = "duration",
- RatingRottenTomatoes = "rating_rt",
- RatingMetacritic = "rating_mc",
- RatingImdb = "rating_imdb",
- RatingTmdb = "rating_tmdb",
- RatingYoutubeViews = "rating_yt_views",
- RatingYoutubeLikes = "rating_yt_likes",
- RatingYoutubeFollowers = "rating_yt_followers",
- RatingUser = "rating_user",
- RatingLikesDivViews = "rating_loved",
- }
-);
-
-impl SortProperty {
- const CATS: &'static [(&'static str, &'static [(SortProperty, &'static str)])] = {
- use SortProperty::*;
- &[
- (
- "filter_sort.sort.general",
- &[(Title, "node.title"), (ReleaseDate, "node.release_date")],
- ),
- ("filter_sort.sort.media", &[(Duration, "media.runtime")]),
- (
- "filter_sort.sort.rating",
- &[
- (RatingImdb, "rating.imdb"),
- (RatingTmdb, "rating.tmdb"),
- (RatingMetacritic, "rating.metacritic"),
- (RatingRottenTomatoes, "rating.rotten_tomatoes"),
- (RatingYoutubeFollowers, "rating.youtube_followers"),
- (RatingYoutubeLikes, "rating.youtube_likes"),
- (RatingYoutubeViews, "rating.youtube_views"),
- (RatingUser, "filter_sort.sort.rating.user"),
- (
- RatingLikesDivViews,
- "filter_sort.sort.rating.likes_div_views",
- ),
- ],
- ),
- ]
- };
-}
-impl FilterProperty {
- const CATS: &'static [(&'static str, &'static [(FilterProperty, &'static str)])] = {
- use FilterProperty::*;
- &[
- (
- "filter_sort.filter.kind",
- &[
- (KindMovie, "kind.movie"),
- (KindVideo, "kind.video"),
- (KindShortFormVideo, "kind.short_form_video"),
- (KindMusic, "kind.music"),
- (KindCollection, "kind.collection"),
- (KindChannel, "kind.channel"),
- (KindShow, "kind.show"),
- (KindSeries, "kind.series"),
- (KindSeason, "kind.season"),
- (KindEpisode, "kind.episode"),
- ],
- ),
- (
- "filter_sort.filter.federation",
- &[(FederationLocal, "federation.local"), (FederationRemote, "federation.remote")],
- ),
- (
- "filter_sort.filter.watched",
- &[
- (Watched, "watched.watched"),
- (Unwatched, "watched.none"),
- (WatchProgress, "watched.progress"),
- ],
- ),
- ]
- };
-}
-
-impl NodeFilterSort {
- pub fn is_open(&self) -> bool {
- self.filter_kind.is_some() || self.sort_by.is_some()
- }
-}
-
-#[rustfmt::skip]
-#[derive(FromFormField, UriDisplayQuery, Clone, Copy, PartialEq, Eq)]
-pub enum SortOrder {
- #[field(value = "ascending")] Ascending,
- #[field(value = "descending")] Descending,
-}
-
-pub fn filter_and_sort_nodes(
- f: &NodeFilterSort,
- default_sort: (SortProperty, SortOrder),
- nodes: &mut Vec<(Arc<Node>, NodeUserData)>,
-) {
- let sort_prop = f.sort_by.unwrap_or(default_sort.0);
- nodes.retain(|(node, _udata)| {
- let mut o = true;
- if let Some(prop) = &f.filter_kind {
- o = false;
- for p in prop {
- o |= match p {
- // FilterProperty::FederationLocal => node.federated.is_none(),
- // FilterProperty::FederationRemote => node.federated.is_some(),
- FilterProperty::KindMovie => node.kind == NodeKind::Movie,
- FilterProperty::KindVideo => node.kind == NodeKind::Video,
- FilterProperty::KindShortFormVideo => node.kind == NodeKind::ShortFormVideo,
- FilterProperty::KindMusic => node.kind == NodeKind::Music,
- FilterProperty::KindCollection => node.kind == NodeKind::Collection,
- FilterProperty::KindChannel => node.kind == NodeKind::Channel,
- FilterProperty::KindShow => node.kind == NodeKind::Show,
- FilterProperty::KindSeries => node.kind == NodeKind::Series,
- FilterProperty::KindSeason => node.kind == NodeKind::Season,
- FilterProperty::KindEpisode => node.kind == NodeKind::Episode,
- // FilterProperty::Watched => udata.watched == WatchedState::Watched,
- // FilterProperty::Unwatched => udata.watched == WatchedState::None,
- // FilterProperty::WatchProgress => {
- // matches!(udata.watched, WatchedState::Progress(_))
- // }
- _ => false, // TODO
- }
- }
- }
- match sort_prop {
- SortProperty::ReleaseDate => o &= node.release_date.is_some(),
- SortProperty::Duration => o &= node.media.is_some(),
- _ => (),
- }
- o
- });
- match sort_prop {
- SortProperty::Duration => {
- nodes.sort_by_key(|(n, _)| (n.media.as_ref().unwrap().duration * 1000.) as i64)
- }
- SortProperty::ReleaseDate => {
- nodes.sort_by_key(|(n, _)| n.release_date.expect("asserted above"))
- }
- SortProperty::Title => nodes.sort_by(|(a, _), (b, _)| a.title.cmp(&b.title)),
- SortProperty::Index => nodes.sort_by(|(a, _), (b, _)| {
- a.index
- .unwrap_or(usize::MAX)
- .cmp(&b.index.unwrap_or(usize::MAX))
- }),
- SortProperty::RatingRottenTomatoes => nodes.sort_by_cached_key(|(n, _)| {
- SortAnyway(*n.ratings.get(&Rating::RottenTomatoes).unwrap_or(&0.))
- }),
- SortProperty::RatingMetacritic => nodes.sort_by_cached_key(|(n, _)| {
- SortAnyway(*n.ratings.get(&Rating::Metacritic).unwrap_or(&0.))
- }),
- SortProperty::RatingImdb => nodes
- .sort_by_cached_key(|(n, _)| SortAnyway(*n.ratings.get(&Rating::Imdb).unwrap_or(&0.))),
- SortProperty::RatingTmdb => nodes
- .sort_by_cached_key(|(n, _)| SortAnyway(*n.ratings.get(&Rating::Tmdb).unwrap_or(&0.))),
- SortProperty::RatingYoutubeViews => nodes.sort_by_cached_key(|(n, _)| {
- SortAnyway(*n.ratings.get(&Rating::YoutubeViews).unwrap_or(&0.))
- }),
- SortProperty::RatingYoutubeLikes => nodes.sort_by_cached_key(|(n, _)| {
- SortAnyway(*n.ratings.get(&Rating::YoutubeLikes).unwrap_or(&0.))
- }),
- SortProperty::RatingYoutubeFollowers => nodes.sort_by_cached_key(|(n, _)| {
- SortAnyway(*n.ratings.get(&Rating::YoutubeFollowers).unwrap_or(&0.))
- }),
- SortProperty::RatingLikesDivViews => nodes.sort_by_cached_key(|(n, _)| {
- SortAnyway(
- *n.ratings.get(&Rating::YoutubeLikes).unwrap_or(&0.)
- / (1. + *n.ratings.get(&Rating::YoutubeViews).unwrap_or(&0.)),
- )
- }),
- SortProperty::RatingUser => nodes.sort_by_cached_key(|(_, u)| u.rating),
- }
-
- match f.sort_order.unwrap_or(default_sort.1) {
- SortOrder::Ascending => (),
- SortOrder::Descending => nodes.reverse(),
- }
-}
-
-markup::define! {
- NodeFilterSortForm<'a>(f: &'a NodeFilterSort, lang: &'a Language) {
- details.filtersort[open=f.is_open()] {
- summary { "Filter and Sort" }
- form[method="GET", action=""] {
- fieldset.filter {
- legend { "Filter" }
- .categories {
- @for (cname, cat) in FilterProperty::CATS {
- .category {
- h3 { @trs(lang, cname) }
- @for (value, label) in *cat {
- label { input[type="checkbox", name="filter_kind", value=value, checked=f.filter_kind.as_ref().map(|k|k.contains(value)).unwrap_or(true)]; @trs(lang, label) } br;
- }
- }
- }
- }
- }
- fieldset.sortby {
- legend { "Sort" }
- .categories {
- @for (cname, cat) in SortProperty::CATS {
- .category {
- h3 { @trs(lang, cname) }
- @for (value, label) in *cat {
- label { input[type="radio", name="sort_by", value=value, checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br;
- }
- }
- }
- }
- }
- fieldset.sortorder {
- legend { "Sort Order" }
- @use SortOrder::*;
- @for (value, label) in [(Ascending, "filter_sort.order.asc"), (Descending, "filter_sort.order.desc")] {
- label { input[type="radio", name="sort_order", value=value, checked=Some(value)==f.sort_order]; @trs(lang, label) } br;
- }
- }
- input[type="submit", value="Apply"]; a[href="?"] { "Clear" }
- }
- }
- }
-}
-
-impl markup::Render for SortProperty {
- fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
- writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>))
- }
-}
-impl markup::Render for SortOrder {
- fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
- writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>))
- }
-}
-impl markup::Render for FilterProperty {
- fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
- writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>))
- }
-}
-impl RenderAttributeValue for SortOrder {}
-impl RenderAttributeValue for FilterProperty {}
-impl RenderAttributeValue for SortProperty {}
diff --git a/server/src/routes/ui/stats.rs b/server/src/routes/ui/stats.rs
deleted file mode 100644
index 4e4eef1..0000000
--- a/server/src/routes/ui/stats.rs
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
-*/
-use super::{
- account::session::Session,
- error::MyError,
- layout::{DynLayoutPage, LayoutPage},
-};
-use crate::{
- database::Database,
- routes::{
- api::AcceptJson,
- locale::AcceptLanguage,
- ui::{
- layout::trs,
- node::{
- format_duration, format_duration_long, format_kind, format_size,
- rocket_uri_macro_r_library_node,
- },
- },
- },
- uri,
-};
-use jellybase::locale::tr;
-use jellycommon::{Node, NodeID, NodeKind, Visibility};
-use markup::raw;
-use rocket::{get, serde::json::Json, Either, State};
-use serde::Serialize;
-use serde_json::{json, Value};
-use std::collections::BTreeMap;
-
-#[get("/stats")]
-pub fn r_stats(
- sess: Session,
- db: &State<Database>,
- aj: AcceptJson,
- lang: AcceptLanguage,
-) -> Result<Either<DynLayoutPage<'_>, Json<Value>>, MyError> {
- let AcceptLanguage(lang) = lang;
- let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?;
- items.retain(|(n, _)| n.visibility >= Visibility::Reduced);
-
- #[derive(Default, Serialize)]
- struct Bin {
- runtime: f64,
- size: u64,
- count: usize,
- max_runtime: (f64, String),
- max_size: (u64, String),
- }
- impl Bin {
- fn update(&mut self, node: &Node) {
- self.count += 1;
- self.size += node.storage_size;
- if node.storage_size > self.max_size.0 {
- self.max_size = (node.storage_size, node.slug.clone())
- }
- if let Some(m) = &node.media {
- self.runtime += m.duration;
- if m.duration > self.max_runtime.0 {
- self.max_runtime = (m.duration, node.slug.clone())
- }
- }
- }
- fn average_runtime(&self) -> f64 {
- self.runtime / self.count as f64
- }
- fn average_size(&self) -> f64 {
- self.size as f64 / self.count as f64
- }
- }
-
- let mut all = Bin::default();
- let mut kinds = BTreeMap::<NodeKind, Bin>::new();
- for (i, _) in items {
- all.update(&i);
- kinds.entry(i.kind).or_default().update(&i);
- }
-
- Ok(if *aj {
- Either::Right(Json(json!({
- "all": all,
- "kinds": kinds,
- })))
- } else {
- Either::Left(LayoutPage {
- title: tr(lang, "stats.title").to_string(),
- content: markup::new! {
- .page.stats {
- h1 { @trs(&lang, "stats.title") }
- p { @raw(tr(lang, "stats.count")
- .replace("{count}", &format!("<b>{}</b>", all.count))
- )}
- p { @raw(tr(lang, "stats.runtime")
- .replace("{dur}", &format!("<b>{}</b>", format_duration_long(all.runtime, lang)))
- .replace("{size}", &format!("<b>{}</b>", format_size(all.size)))
- )}
- p { @raw(tr(lang, "stats.average")
- .replace("{dur}", &format!("<b>{}</b>", format_duration(all.average_runtime())))
- .replace("{size}", &format!("<b>{}</b>", format_size(all.average_size() as u64)))
- )}
-
- h2 { @trs(&lang, "stats.by_kind.title") }
- table.striped {
- tr {
- th { @trs(&lang, "stats.by_kind.kind") }
- th { @trs(&lang, "stats.by_kind.count") }
- th { @trs(&lang, "stats.by_kind.total_size") }
- th { @trs(&lang, "stats.by_kind.total_runtime") }
- th { @trs(&lang, "stats.by_kind.average_size") }
- th { @trs(&lang, "stats.by_kind.average_runtime") }
- th { @trs(&lang, "stats.by_kind.max_size") }
- th { @trs(&lang, "stats.by_kind.max_runtime") }
- }
- @for (k,b) in &kinds { tr {
- td { @format_kind(*k, lang) }
- td { @b.count }
- td { @format_size(b.size) }
- td { @format_duration(b.runtime) }
- td { @format_size(b.average_size() as u64) }
- td { @format_duration(b.average_runtime()) }
- td { @if b.max_size.0 > 0 { a[href=uri!(r_library_node(&b.max_size.1))]{ @format_size(b.max_size.0) }}}
- td { @if b.max_runtime.0 > 0. { a[href=uri!(r_library_node(&b.max_runtime.1))]{ @format_duration(b.max_runtime.0) }}}
- }}
- }
- }
- },
- ..Default::default()
- })
- })
-}
diff --git a/server/src/routes/ui/style.rs b/server/src/routes/ui/style.rs
deleted file mode 100644
index c935c8a..0000000
--- a/server/src/routes/ui/style.rs
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2025 metamuffin <metamuffin.org>
- Copyright (C) 2023 tpart
-*/
-use rocket::{
- get,
- http::{ContentType, Header},
- response::Responder,
-};
-
-macro_rules! concat_files {
- ([$base: expr], $($files:literal),*) => {{
- #[cfg(any(debug_assertions, feature = "hot-css"))]
- {
- use std::{fs::read_to_string, path::PathBuf, str::FromStr};
- [ $($files),* ]
- .into_iter()
- .map(|n| {
- read_to_string({
- let p = PathBuf::from_str(file!()).unwrap().parent().unwrap().join($base).join(n);
- log::info!("load {p:?}");
- p
- })
- .unwrap()
- })
- .collect::<Vec<_>>()
- .join("\n")
- }
- #[cfg(not(any(debug_assertions, feature = "hot-css")))]
- concat!($(include_str!(concat!($base, "/", $files))),*).to_string()
- }};
-}
-
-fn css_bundle() -> String {
- concat_files!(
- ["../../../../web/style"],
- "layout.css",
- "player.css",
- "nodepage.css",
- "nodecard.css",
- "js-player.css",
- "js-transition.css",
- "forms.css",
- "props.css",
- "themes.css",
- "navbar.css"
- )
-}
-
-pub struct CachedAsset<T>(pub T);
-impl<'r, 'o: 'r, T: Responder<'r, 'o>> Responder<'r, 'o> for CachedAsset<T> {
- fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'o> {
- let mut res = self.0.respond_to(request)?;
- if cfg!(not(debug_assertions)) {
- res.set_header(Header::new("cache-control", "max-age=86400"));
- }
- Ok(res)
- }
-}
-
-fn js_bundle() -> String {
- concat_files!([env!("OUT_DIR")], "bundle.js")
-}
-fn js_bundle_map() -> String {
- concat_files!([env!("OUT_DIR")], "bundle.js.map")
-}
-
-#[get("/assets/style.css")]
-pub fn r_assets_style() -> CachedAsset<(ContentType, String)> {
- CachedAsset((ContentType::CSS, css_bundle()))
-}
-
-#[get("/assets/cantarell.woff2")]
-pub fn r_assets_font() -> CachedAsset<(ContentType, &'static [u8])> {
- CachedAsset((
- ContentType::WOFF2,
- include_bytes!("../../../../web/cantarell.woff2"),
- ))
-}
-
-#[get("/assets/bundle.js")]
-pub fn r_assets_js() -> CachedAsset<(ContentType, String)> {
- CachedAsset((ContentType::JavaScript, js_bundle()))
-}
-#[get("/assets/bundle.js.map")]
-pub fn r_assets_js_map() -> CachedAsset<(ContentType, String)> {
- CachedAsset((ContentType::JSON, js_bundle_map()))
-}
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 <metamuffin.org>
-*/
-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/<id>/userdata")]
-pub fn r_node_userdata(
- session: Session,
- db: &State<Database>,
- id: NodeID,
-) -> MyResult<Json<NodeUserData>> {
- let u = db
- .get_node_udata(id, &session.user.name)?
- .unwrap_or_default();
- Ok(Json(u))
-}
-
-#[post("/n/<id>/watched?<state>")]
-pub async fn r_node_userdata_watched(
- session: Session,
- db: &State<Database>,
- id: NodeID,
- state: UrlWatchedState,
-) -> MyResult<Redirect> {
- // 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/<id>/update_rating", data = "<form>")]
-pub async fn r_node_userdata_rating(
- session: Session,
- db: &State<Database>,
- id: NodeID,
- form: Form<UpdateRating>,
-) -> MyResult<Redirect> {
- // 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/<id>/progress?<t>")]
-pub async fn r_node_userdata_progress(
- session: Session,
- db: &State<Database>,
- 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(())
-}