diff options
Diffstat (limited to 'server/src/routes')
25 files changed, 2971 insertions, 0 deletions
diff --git a/server/src/routes/account/mod.rs b/server/src/routes/account/mod.rs new file mode 100644 index 0000000..e15df9e --- /dev/null +++ b/server/src/routes/account/mod.rs @@ -0,0 +1,139 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +// pub mod settings; + +pub mod settings; + +use crate::{ + auth::{hash_password, login}, + request_info::RequestInfo, + routes::error::MyResult, +}; +use jellycommon::{ + jellyobject::Path, + routes::{u_account_login, u_home}, + *, +}; +use jellydb::{Filter, Query}; +use jellyui::components::login::{AccountLogin, AccountLogout, AccountSetPassword}; +use rocket::{ + Either, FromForm, + form::{Contextual, Form}, + get, + http::{Cookie, CookieJar}, + post, + response::{Flash, Redirect, content::RawHtml}, +}; +use serde::{Deserialize, Serialize}; + +#[get("/account/login")] +pub async fn r_account_login(ri: RequestInfo<'_>) -> RawHtml<String> { + ri.respond_ui(&AccountLogin { + ri: &ri.render_info(), + }) +} + +#[get("/account/logout")] +pub fn r_account_logout(ri: RequestInfo<'_>) -> RawHtml<String> { + ri.respond_ui(&AccountLogout { + ri: &ri.render_info(), + }) +} + +#[derive(FromForm, Serialize, Deserialize)] +pub struct LoginForm { + #[field(validate = len(..32))] + pub username: String, + #[field(validate = len(..64))] + pub password: String, + #[field(validate = len(..64))] + pub new_password: Option<String>, + #[field(validate = len(..64))] + pub display_name: Option<String>, + #[field(default = 604800)] // one week + pub expire: u64, +} + +#[post("/account/login", data = "<form>")] +pub fn r_account_login_post( + ri: RequestInfo<'_>, + jar: &CookieJar, + form: Form<Contextual<LoginForm>>, +) -> MyResult<Either<Redirect, Either<Flash<Redirect>, RawHtml<String>>>> { + let form = match &form.value { + Some(v) => v, + None => { + return Ok(Either::Right(Either::Left(Flash::error( + Redirect::to(u_account_login()), + format_form_error(form), + )))); + } + }; + let (session, need_pw_change) = match login(&ri.state, &form.username, &form.password, None) { + Ok(x) => x, + Err(e) => { + return Ok(Either::Right(Either::Left(Flash::error( + Redirect::to(u_account_login()), + format!("{e:#}"), + )))); + } + }; + if need_pw_change { + if let Some(new_password) = &form.new_password { + let password_hash = hash_password(&form.username, &new_password); + ri.state.database.transaction(&mut |txn| { + let user_row = txn.query_single(Query { + filter: Filter::Match(Path(vec![USER_LOGIN.0]), form.username.clone().into()), + ..Default::default() + })?; + if let Some(ur) = user_row { + let mut user = txn.get(ur)?.unwrap(); + user = user.remove(USER_PASSWORD_REQUIRE_CHANGE); + user = user.insert(USER_PASSWORD, &password_hash); + if let Some(name) = &form.display_name { + user = user.insert(USER_NAME, &name); + } + txn.update(ur, user)?; + } + Ok(()) + })?; + } else { + return Ok(Either::Right(Either::Right(ri.respond_ui( + &AccountSetPassword { + ri: &ri.render_info(), + password: &form.password, + username: &form.username, + }, + )))); + } + } + + jar.add(Cookie::build(("session", session)).permanent().build()); + Ok(Either::Left(Redirect::found(u_home()))) +} + +#[post("/account/logout")] +pub fn r_account_logout_post(jar: &CookieJar) -> MyResult<Flash<Redirect>> { + jar.remove(Cookie::build("session")); + Ok(Flash::success( + Redirect::found(u_account_login()), + "Logged out!", + )) +} + +pub fn format_form_error<T>(form: Form<Contextual<T>>) -> String { + 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()) + ) + } + k +} diff --git a/server/src/routes/account/settings.rs b/server/src/routes/account/settings.rs new file mode 100644 index 0000000..54ecf22 --- /dev/null +++ b/server/src/routes/account/settings.rs @@ -0,0 +1,127 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use super::format_form_error; +use crate::{auth::hash_password, request_info::RequestInfo, routes::error::MyResult}; +use anyhow::anyhow; +use jellycommon::{ + jellyobject::{Object, Path, Tag}, + routes::u_account_settings, + *, +}; +use jellydb::{Filter, Query}; +use jellyui::{components::user::UserSettings, tr}; +use rocket::{ + FromForm, + form::{self, Contextual, Form, validate::len}, + get, post, + response::{Flash, Redirect, content::RawHtml}, +}; +use std::ops::Range; + +#[derive(FromForm)] +pub struct SettingsForm { + #[field(validate = option_len(4..64))] + password: Option<String>, + #[field(validate = option_len(4..32))] + name: Option<String>, + theme_accent: Option<u32>, + theme_preset: 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(())) +} + +#[get("/account/settings")] +pub fn r_account_settings(ri: RequestInfo) -> MyResult<RawHtml<String>> { + let user = ri.require_user()?; + Ok(ri.respond_ui(&UserSettings { + ri: &ri.render_info(), + user, + })) +} + +#[post("/account/settings", data = "<form>")] +pub fn r_account_settings_post( + ri: RequestInfo, + form: Form<Contextual<SettingsForm>>, +) -> MyResult<Flash<Redirect>> { + let form = match &form.value { + Some(v) => v, + None => { + return Ok(Flash::error( + Redirect::to(u_account_settings()), + format_form_error(form), + )); + } + }; + + let mut out = String::new(); + + if let Some(password) = &form.password { + let login = ri + .require_user()? + .get(USER_LOGIN) + .ok_or(anyhow!("user has no login"))?; + let password = hash_password(login, password); + update_user(&ri, |user| user.insert(USER_PASSWORD, &password))?; + out += &*tr(ri.lang, "settings.account.password.changed"); + out += "\n"; + } + if let Some(name) = &form.name { + update_user(&ri, |user| user.insert(USER_NAME, name))?; + out += &*tr(ri.lang, "settings.account.display_name.changed"); + out += "\n"; + } + if let Some(preset) = &form.theme_preset { + let tag = Tag::new( + preset + .as_bytes() + .try_into() + .map_err(|_| anyhow!("invalid theme preset"))?, + ); + update_user(&ri, |user| user.insert(USER_THEME_PRESET, tag))?; + out += &*tr(ri.lang, "settings.appearance.theme.changed"); + out += "\n"; + } + if let Some(accent) = form.theme_accent { + update_user(&ri, |user| user.insert(USER_THEME_ACCENT, accent))?; + } + // if let Some(player_preference) = form.player_preference { + // update_user_player_preference(&ri.session, player_preference.0)?; + // out += &*tr(ri.lang, "settings.player_preference.changed"); + // out += "\n"; + // } + let out = if out.is_empty() { + tr(ri.lang, "settings.no_change").to_string() + } else { + out + }; + + Ok(Flash::success(Redirect::to(u_account_settings()), out)) +} + +fn update_user(ri: &RequestInfo, update: impl Fn(&Object) -> Box<Object>) -> MyResult<()> { + let login = ri + .require_user()? + .get(USER_LOGIN) + .ok_or(anyhow!("user has no login"))?; + ri.state.database.transaction(&mut |txn| { + let user_row = txn + .query_single(Query { + filter: Filter::Match(Path(vec![USER_LOGIN.0]), login.into()), + ..Default::default() + })? + .ok_or(anyhow!("user vanished"))?; + + let user = txn.get(user_row)?.unwrap(); + let new_user = update(&user); + txn.update(user_row, new_user)?; + + Ok(()) + })?; + Ok(()) +} diff --git a/server/src/routes/admin/import.rs b/server/src/routes/admin/import.rs new file mode 100644 index 0000000..31e7d70 --- /dev/null +++ b/server/src/routes/admin/import.rs @@ -0,0 +1,78 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ + +use crate::{request_info::RequestInfo, routes::error::MyResult}; +use jellycommon::routes::u_admin_import; +use jellyimport::{ + ImportConfig, import_wrap, is_importing, + reporting::{IMPORT_ERRORS, IMPORT_PROGRESS}, +}; +use jellyui::{components::admin::AdminImport, tr}; +use rocket::{ + get, post, + response::{Flash, Redirect, content::RawHtml}, +}; +use rocket_ws::{Message, Stream, WebSocket}; +use std::time::Duration; +use tokio::{spawn, time::sleep}; + +#[get("/admin/import", rank = 2)] +pub async fn r_admin_import(ri: RequestInfo<'_>) -> MyResult<RawHtml<String>> { + ri.require_admin()?; + + let last_import_err = IMPORT_ERRORS.read().await.clone(); + let last_import_err = last_import_err + .iter() + .map(|e| e.as_str()) + .collect::<Vec<_>>(); + + Ok(ri.respond_ui(&AdminImport { + busy: is_importing(), + errors: &last_import_err, + ri: &ri.render_info(), + })) +} + +#[post("/admin/import?<incremental>")] +pub async fn r_admin_import_post( + ri: RequestInfo<'_>, + incremental: bool, +) -> MyResult<Flash<Redirect>> { + ri.require_admin()?; + spawn(async move { + let _ = import_wrap( + ImportConfig { + config: ri.state.config.import.clone(), + cache: ri.state.cache.clone(), + db: ri.state.database.clone(), + }, + incremental, + ) + .await; + }); + Ok(Flash::success( + Redirect::to(u_admin_import()), + tr(ri.lang, "admin.import_success"), + )) +} + +#[get("/admin/import", rank = 1)] +pub fn r_admin_import_stream(ri: RequestInfo<'_>, ws: WebSocket) -> MyResult<Stream!['static]> { + ri.require_admin()?; + Ok({ + Stream! { ws => + loop { + let Some(p) = IMPORT_PROGRESS.read().await.clone() else { + break; + }; + yield Message::Text(serde_json::to_string(&p).unwrap()); + sleep(Duration::from_secs_f32(0.05)).await; + } + yield Message::Text("done".to_string()); + let _ = ws; + } + }) +} diff --git a/server/src/routes/admin/log.rs b/server/src/routes/admin/log.rs new file mode 100644 index 0000000..bf8126a --- /dev/null +++ b/server/src/routes/admin/log.rs @@ -0,0 +1,55 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use crate::{ + logger::{get_log_buffer, get_log_stream}, + request_info::RequestInfo, + routes::error::MyResult, +}; +use jellyui::components::admin_log::{ServerLogPage, render_log_line}; +use rocket::{get, response::content::RawHtml}; +use rocket_ws::{Message, Stream, WebSocket}; +use serde_json::json; + +#[get("/admin/log?<warnonly>", rank = 2)] +pub fn r_admin_log(ri: RequestInfo, warnonly: bool) -> MyResult<RawHtml<String>> { + ri.require_admin()?; + let messages = get_log_buffer(warnonly) + .into_iter() + .map(|l| render_log_line(&l)) + .collect::<Vec<_>>(); + + Ok(ri.respond_ui(&ServerLogPage { + ri: &ri.render_info(), + messages: &messages, + warnonly, + })) +} + +#[get("/admin/log?stream&<warnonly>&<html>", rank = 1)] +pub fn r_admin_log_stream( + ri: RequestInfo, + ws: WebSocket, + warnonly: bool, + html: bool, +) -> MyResult<Stream!['static]> { + ri.require_admin()?; + let mut stream = get_log_stream(warnonly); + Ok({ + Stream! { ws => + if html { + let _ = ws; + while let Ok(line) = stream.recv().await { + yield Message::Text(render_log_line(&line)); + } + } else { + let _ = ws; + while let Ok(line) = stream.recv().await { + yield Message::Text(json!(line).to_string()); + } + } + } + }) +} diff --git a/server/src/routes/admin/mod.rs b/server/src/routes/admin/mod.rs new file mode 100644 index 0000000..6119b74 --- /dev/null +++ b/server/src/routes/admin/mod.rs @@ -0,0 +1,29 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ + +pub mod import; +pub mod log; +pub mod users; + +use super::error::MyResult; +use crate::request_info::RequestInfo; +use jellyui::components::admin::AdminDashboard; +use rocket::{get, response::content::RawHtml}; + +#[get("/admin/dashboard")] +pub async fn r_admin_dashboard(ri: RequestInfo<'_>) -> MyResult<RawHtml<String>> { + ri.require_admin()?; + + // let mut db_debug = String::new(); + // ri.state.database.transaction(&mut |txn| { + // db_debug = txn.debug_info()?; + // Ok(()) + // })?; + + Ok(ri.respond_ui(&AdminDashboard { + ri: &ri.render_info(), + })) +} diff --git a/server/src/routes/admin/users.rs b/server/src/routes/admin/users.rs new file mode 100644 index 0000000..01a6403 --- /dev/null +++ b/server/src/routes/admin/users.rs @@ -0,0 +1,119 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ + +use std::str::FromStr; + +use crate::{auth::hash_password, request_info::RequestInfo, routes::error::MyResult}; +use anyhow::anyhow; +use base64::{Engine, prelude::BASE64_URL_SAFE}; +use jellycommon::{ + jellyobject::{ObjectBufferBuilder, Path}, + routes::u_admin_users, + *, +}; +use jellydb::{Filter, Query}; +use jellyui::{ + components::admin::{AdminUser, AdminUserList}, + tr, +}; +use rand::random; +use rocket::{ + FromForm, + form::Form, + get, post, + response::{Flash, Redirect, content::RawHtml}, +}; + +#[get("/admin/users")] +pub fn r_admin_users(ri: RequestInfo) -> MyResult<RawHtml<String>> { + ri.require_admin()?; + + let mut users = Vec::new(); + ri.state.database.transaction(&mut |txn| { + users.clear(); + let rows = txn + .query(Query::from_str("FILTER Ulgn")?)? + .collect::<Vec<_>>(); + for row in rows { + let (row, _) = row?; + users.push(txn.get(row)?.unwrap()); + } + Ok(()) + })?; + + Ok(ri.respond_ui(&AdminUserList { + ri: &ri.render_info(), + users: &users.iter().map(|u| &**u).collect::<Vec<_>>(), + })) +} + +#[derive(FromForm)] +pub struct NewUser { + login: String, +} + +#[post("/admin/new_user", data = "<form>")] +pub fn r_admin_new_user(ri: RequestInfo, form: Form<NewUser>) -> MyResult<Flash<Redirect>> { + ri.require_admin()?; + + let password = BASE64_URL_SAFE.encode([(); 12].map(|()| random())); + let password_hashed = hash_password(&form.login, &password); + + ri.state.database.transaction(&mut |txn| { + let mut user = ObjectBufferBuilder::default(); + user.push(USER_LOGIN, &form.login); + user.push(USER_PASSWORD, &password_hashed); + user.push(USER_PASSWORD_REQUIRE_CHANGE, ()); + txn.insert(user.finish())?; + Ok(()) + })?; + + Ok(Flash::success( + Redirect::to(u_admin_users()), + format!("User created; password: {password}"), + )) +} + +#[get("/admin/user/<name>")] +pub fn r_admin_user(ri: RequestInfo<'_>, name: &str) -> MyResult<RawHtml<String>> { + ri.require_admin()?; + let mut user = None; + ri.state.database.transaction(&mut |txn| { + if let Some(row) = txn.query_single(Query { + filter: Filter::Match(Path(vec![USER_LOGIN.0]), name.into()), + ..Default::default() + })? { + user = Some(txn.get(row)?.unwrap()); + } + Ok(()) + })?; + let Some(user) = user else { + Err(anyhow!("no such user"))? + }; + + Ok(ri.respond_ui(&AdminUser { + ri: &ri.render_info(), + user: &user, + })) +} + +#[post("/admin/user/<name>/remove")] +pub fn r_admin_user_remove(ri: RequestInfo<'_>, name: &str) -> MyResult<Flash<Redirect>> { + ri.require_admin()?; + ri.state.database.transaction(&mut |txn| { + if let Some(row) = txn.query_single(Query { + filter: Filter::Match(Path(vec![USER_LOGIN.0]), name.into()), + ..Default::default() + })? { + txn.remove(row)?; + } + Ok(()) + })?; + Ok(Flash::success( + Redirect::to(u_admin_users()), + tr(ri.lang, "admin.users.remove_success"), + )) +} diff --git a/server/src/routes/api.rs b/server/src/routes/api.rs new file mode 100644 index 0000000..d83d8e3 --- /dev/null +++ b/server/src/routes/api.rs @@ -0,0 +1,38 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ + +use rocket::{get, response::Redirect}; + +#[get("/api")] +pub fn r_api_root() -> Redirect { + Redirect::moved("https://jellything.metamuffin.org/book/api.html#jellything-http-api") +} + +#[get("/version")] +pub fn r_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +// #[get("/translations")] +// pub fn r_translations( +// lang: AcceptLanguage, +// aj: AcceptJson, +// ) -> Either<Json<&'static HashMap<&'static str, &'static str>>, String> { +// let AcceptLanguage(lang) = lang; +// let table = get_translation_table(&lang); +// if *aj { +// Either::Left(Json(table)) +// } else { +// let mut s = String::new(); +// for (k, v) in table { +// s += k; +// s += "="; +// s += v; +// s += "\n"; +// } +// Either::Right(s) +// } +// } diff --git a/server/src/routes/assets.rs b/server/src/routes/assets.rs new file mode 100644 index 0000000..089f293 --- /dev/null +++ b/server/src/routes/assets.rs @@ -0,0 +1,53 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use super::error::MyResult; +use crate::{request_info::RequestInfo, responders::cache::CacheControlImage}; +use anyhow::Context; +use jellycache::HashKey; +use jellycommon::routes::u_image; +use jellyimport::generate_person_fallback; +use rocket::{get, http::ContentType, response::Redirect}; +use std::path::PathBuf; +use tokio::task::spawn_blocking; + +pub const AVIF_QUALITY: u32 = 70; +pub const AVIF_SPEED: u8 = 5; + +#[get("/image/<path..>?<size>")] +pub async fn r_image( + ri: RequestInfo<'_>, + path: PathBuf, + size: Option<usize>, +) -> MyResult<(ContentType, CacheControlImage)> { + let size = size.unwrap_or(2048); + let path = path.to_string_lossy().to_string(); + + // fit the resolution into a finite set so the maximum cache is finite too. + let width = 2usize.pow(size.clamp(128, 2048).ilog2()); + let encoded = spawn_blocking(move || { + jellytranscoder::image::transcode(&ri.state.cache, &path, AVIF_QUALITY, AVIF_SPEED, width) + .context("transcoding asset") + }) + .await + .unwrap()?; + + Ok((ContentType::AVIF, CacheControlImage(encoded))) +} + +#[get("/image_fallback/person/<name>?<size>")] +pub async fn r_image_fallback_person( + ri: RequestInfo<'_>, + name: &str, + size: Option<usize>, +) -> MyResult<Redirect> { + let path = ri + .state + .cache + .store(format!("fallback/person/{}.image", HashKey(name)), || { + generate_person_fallback(name) + })?; + Ok(Redirect::found(u_image(&path, size.unwrap_or(2048)))) +} diff --git a/server/src/routes/compat/jellyfin/mod.rs b/server/src/routes/compat/jellyfin/mod.rs new file mode 100644 index 0000000..8fa44cb --- /dev/null +++ b/server/src/routes/compat/jellyfin/mod.rs @@ -0,0 +1,884 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +pub mod models; + +use crate::{request_helpers::A, ui::error::MyResult}; +use anyhow::anyhow; +use jellycommon::{ + api::{NodeFilterSort, SortOrder, SortProperty}, + routes::{u_asset, u_image}, + stream::{StreamContainer, StreamSpec}, + user::{NodeUserData, WatchedState}, + MediaInfo, Node, NodeID, NodeKind, PictureSlot, SourceTrack, SourceTrackKind, Visibility, +}; +use jellylogic::{ + login::login_logic, + node::{get_node, update_node_userdata_watched_progress}, + search::search, + session::Session, +}; +use jellyui::{get_brand, get_slogan, node_page::aspect_class}; +use log::warn; +use models::*; +use rocket::{ + get, + http::{Cookie, CookieJar}, + post, + response::Redirect, + serde::json::Json, + FromForm, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{collections::BTreeMap, net::IpAddr}; + +// these are both random values. idk what they are for +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"; // TODO + +#[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": get_brand(), + "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!("{} - {}", get_brand(), get_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: A<Session>) -> Json<Value> { + Json(json!({ + "IsLocal": false, + "IsInNetwork": false, + })) +} + +use rocket_ws::{Message, Stream, WebSocket}; +#[get("/socket")] +pub fn r_jellyfin_socket(_session: A<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: A<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": get_brand(), + "Version": VERSION, + "OperatingSystem": "", + "Id": SERVER_ID + })) +} + +#[get("/DisplayPreferences/usersettings")] +pub fn r_jellyfin_displaypreferences_usersettings(_session: A<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: A<Session>) {} + +#[get("/Users/<id>")] +pub fn r_jellyfin_users_id(session: A<Session>, id: &str) -> Json<Value> { + let _ = id; + Json(user_object(session.0.user.name)) +} + +#[get("/Items/<id>/Images/Primary?<fillWidth>&<tag>")] +#[allow(non_snake_case)] +pub fn r_jellyfin_items_image_primary( + _session: A<Session>, + id: &str, + fillWidth: Option<usize>, + tag: String, +) -> Redirect { + if tag == "poster" { + Redirect::permanent(u_image( + id, + PictureSlot::Cover, + fillWidth.unwrap_or(1024), + )) + } else { + Redirect::permanent(u_asset(&tag, fillWidth.unwrap_or(1024))) + } +} + +#[get("/Items/<id>/Images/Backdrop/0?<maxWidth>")] +#[allow(non_snake_case)] +pub fn r_jellyfin_items_images_backdrop( + _session: A<Session>, + id: &str, + maxWidth: Option<usize>, +) -> Redirect { + Redirect::permanent(u_image( + id, + PictureSlot::Backdrop, + maxWidth.unwrap_or(1024), + )) +} + +#[get("/Items/<id>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_items_item(session: A<Session>, id: &str) -> MyResult<Json<JellyfinItem>> { + let r = get_node( + &session.0, + NodeID::from_slug(id), + false, + false, + NodeFilterSort::default(), + )?; + Ok(Json(item_object(&r.node, &r.userdata))) +} + +#[get("/Users/<uid>/Items/<id>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_items_item( + session: A<Session>, + uid: &str, + id: &str, +) -> MyResult<Json<JellyfinItem>> { + let _ = uid; + r_jellyfin_items_item(session, id) +} + +#[derive(Debug, FromForm)] +#[allow(unused)] // TODO +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: A<Session>, + uid: &str, + query: JellyfinItemQuery, +) -> MyResult<Json<JellyfinItemsResponse>> { + let _ = uid; + r_jellyfin_items(session, query) +} + +#[get("/Artists?<query..>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_artists( + session: A<Session>, + mut query: JellyfinItemQuery, +) -> MyResult<Json<JellyfinItemsResponse>> { + query.internal_artists = true; + r_jellyfin_items(session, query)?; // TODO + Ok(Json(JellyfinItemsResponse::default())) +} + +#[get("/Persons?<query..>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_persons( + session: A<Session>, + mut query: JellyfinItemQuery, +) -> MyResult<Json<JellyfinItemsResponse>> { + query.internal_persons = true; + r_jellyfin_items(session, query)?; // TODO + Ok(Json(JellyfinItemsResponse::default())) +} + +#[get("/Items?<query..>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_items( + session: A<Session>, + query: JellyfinItemQuery, +) -> MyResult<Json<JellyfinItemsResponse>> { + // 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.0)) + // .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 nodes = if let Some(q) = query.search_term { + search(&session.0, &q, query.start_index.map(|x| x / 50))?.results // TODO + } else if let Some(parent) = query.parent_id { + get_node( + &session.0, + NodeID::from_slug(&parent), + true, + false, + NodeFilterSort::default(), + )? + .children + } else { + warn!("unknown items request"); + vec![] + }; + + // TODO reimplemnt filter behaviour + + let items = nodes + .into_iter() + .filter(|(n, _)| n.visibility >= Visibility::Reduced) + .map(|(n, ud)| item_object(&n, &ud)) + .collect::<Vec<_>>(); + + Ok(Json(JellyfinItemsResponse { + total_record_count: items.len(), + start_index: query.start_index.unwrap_or_default(), + items, + })) +} + +#[get("/UserViews?<userId>")] +#[allow(non_snake_case)] +pub fn r_jellyfin_users_views(session: A<Session>, userId: &str) -> MyResult<Json<Value>> { + let _ = userId; + + let items = get_node( + &session.0, + NodeID::from_slug("library"), + false, + true, + NodeFilterSort { + sort_by: Some(SortProperty::Index), + sort_order: Some(SortOrder::Ascending), + filter_kind: None, + }, + )? + .children + .into_iter() + .map(|(node, udata)| item_object(&node, &udata)) + .collect::<Vec<_>>(); + + Ok(Json(json!({ + "Items": items, + "TotalRecordCount": items.len(), + "StartIndex": 0 + }))) +} + +#[get("/Items/<id>/Similar")] +pub fn r_jellyfin_items_similar(_session: A<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: A<Session>) -> Json<Value> { + Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + })) +} + +#[get("/Users/<uid>/Items/<id>/Intros")] +pub fn r_jellyfin_items_intros(_session: A<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: A<Session>) -> Json<Value> { + Json(json!({ + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + })) +} + +#[post("/Items/<id>/PlaybackInfo")] +pub fn r_jellyfin_items_playbackinfo(session: A<Session>, id: &str) -> MyResult<Json<Value>> { + let node = get_node( + &session.0, + NodeID::from_slug(id), + false, + false, + NodeFilterSort::default(), + )? + .node; + 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: A<Session>, id: &str) -> MyResult<Redirect> { + let node = get_node( + &session.0, + NodeID::from_slug(id), + false, + false, + NodeFilterSort::default(), + )? + .node; + 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: A<Session>, + data: Json<JellyfinProgressData>, +) -> MyResult<()> { + let position = data.position_ticks / 10_000_000.; + update_node_userdata_watched_progress(&session.0, NodeID::from_slug(&data.item_id), position)?; + Ok(()) +} + +#[post("/Sessions/Playing")] +pub fn r_jellyfin_sessions_playing(_session: A<Session>) {} + +#[get("/Playback/BitrateTest?<Size>")] +#[allow(non_snake_case)] +pub fn r_jellyfin_playback_bitratetest(_session: A<Session>, Size: usize) -> Vec<u8> { + vec![0; Size.min(1_000_000)] +} + +#[post("/Sessions/Capabilities/Full")] +pub fn r_jellyfin_sessions_capabilities_full(_session: A<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, + data: Json<AuthData>, + jar: &CookieJar, +) -> MyResult<Json<Value>> { + r_jellyfin_users_authenticatebyname(client_addr, data, jar) +} + +#[post("/Users/authenticatebyname", data = "<data>")] +#[allow(private_interfaces)] +pub fn r_jellyfin_users_authenticatebyname( + client_addr: IpAddr, + data: Json<AuthData>, + jar: &CookieJar, +) -> MyResult<Json<Value>> { + let token = login_logic(&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::Subtitle => 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 + .credits + .iter() + .flat_map(|(_pg, ps)| { + ps.iter().map(|p| JellyfinPerson { + // TODO + id: String::new(), + name: String::new(), + primary_image_tag: String::new(), + // name: p..name.clone(), + // id: p..ids.tmdb.unwrap_or_default().to_string(), + // primary_image_tag: p..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 new file mode 100644 index 0000000..0a41461 --- /dev/null +++ b/server/src/routes/compat/jellyfin/models.rs @@ -0,0 +1,206 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Serialize, Default)] +#[serde(rename_all = "PascalCase")] +pub(super) struct JellyfinItemsResponse { + pub items: Vec<JellyfinItem>, + pub total_record_count: usize, + pub start_index: usize, +} + +#[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 new file mode 100644 index 0000000..859b60a --- /dev/null +++ b/server/src/routes/compat/mod.rs @@ -0,0 +1,7 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2026 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 new file mode 100644 index 0000000..9674635 --- /dev/null +++ b/server/src/routes/compat/youtube.rs @@ -0,0 +1,67 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use crate::request_info::RequestInfo; +use crate::routes::error::MyResult; +use anyhow::anyhow; +use jellycommon::{ + IDENT_YOUTUBE_VIDEO, NO_IDENTIFIERS, NO_SLUG, jellyobject::Path, routes::u_node_id, +}; +use jellydb::{Filter, Query}; +use rocket::{get, response::Redirect}; + +#[get("/watch?<v>")] +pub fn r_youtube_watch(ri: RequestInfo<'_>, v: &str) -> MyResult<Redirect> { + if v.len() != 11 { + Err(anyhow!("video id length incorrect"))? + } + let mut res = None; + ri.state.database.transaction(&mut |txn| { + if let Some(row) = txn.query_single(Query { + filter: Filter::Match( + Path(vec![NO_IDENTIFIERS.0, IDENT_YOUTUBE_VIDEO.0]), + v.into(), + ), + ..Default::default() + })? { + res = txn.get(row)?; + } + Ok(()) + })?; + let node = res.ok_or(anyhow!("video not found"))?; + let slug = node.get(NO_SLUG).ok_or(anyhow!("node has no slug"))?; + Ok(Redirect::found(u_node_id(slug))) +} + +#[get("/channel/<id>")] +pub fn r_youtube_channel(_ri: RequestInfo<'_>, id: &str) -> MyResult<Redirect> { + let _ = id; + // let Some(id) = (if id.starts_with("UC") { + // get_node_by_eid(&session.0, IdentifierType::YoutubeChannel, id)? + // } else if id.starts_with("@") { + // get_node_by_eid(&session.0, IdentifierType::YoutubeChannelHandle, id)? + // } else { + // Err(anyhow!("unknown channel id format"))? + // }) else { + // Err(anyhow!("channel not found"))? + // }; + // let slug = node_id_to_slug(&session.0, id)?; + // Ok(Redirect::to(u_node_slug(&slug))) + todo!() +} + +#[get("/embed/<v>")] +pub fn r_youtube_embed(_ri: RequestInfo<'_>, v: &str) -> MyResult<Redirect> { + let _ = v; + // if v.len() != 11 { + // Err(anyhow!("video id length incorrect"))? + // } + // let Some(id) = get_node_by_eid(&session.0, IdentifierType::YoutubeVideo, v)? else { + // Err(anyhow!("element not found"))? + // }; + // let slug = node_id_to_slug(&session.0, id)?; + // Ok(Redirect::to(u_node_slug_player(&slug))) + todo!() +} diff --git a/server/src/routes/error.rs b/server/src/routes/error.rs new file mode 100644 index 0000000..578d841 --- /dev/null +++ b/server/src/routes/error.rs @@ -0,0 +1,75 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use rocket::{ + Request, catch, + http::Status, + response::{self, Responder, content::RawHtml}, +}; +use serde_json::{Value, json}; +use std::fmt::Display; + +#[catch(default)] +pub fn r_catch(status: Status, _request: &Request) -> RawHtml<String> { + catch_with_message(format!("{status}")) +} +fn catch_with_message(message: String) -> RawHtml<String> { + RawHtml(message) // TODO +} + +#[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) + // } + _ => catch_with_message(format!("{:#}", self.0)).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/home.rs b/server/src/routes/home.rs new file mode 100644 index 0000000..17cac83 --- /dev/null +++ b/server/src/routes/home.rs @@ -0,0 +1,96 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2026 metamuffin <metamuffin.org> +*/ + +use super::error::MyResult; +use crate::request_info::RequestInfo; +use anyhow::{Context, Result}; +use jellycommon::{Nku, jellyobject::EMPTY}; +use jellydb::{Query, helper::DatabaseReturnExt}; +use jellyui::components::home::{Home, HomeRow}; +use rocket::{get, response::content::RawHtml}; +use std::{borrow::Cow, str::FromStr}; + +#[get("/home")] +pub fn r_home(ri: RequestInfo<'_>) -> MyResult<RawHtml<String>> { + ri.require_user()?; + + let mut rows = Vec::new(); + rows.push(home_row( + &ri, + "home.bin.latest_video", + "FILTER (visi = visi AND kind = vide) SORT DESCENDING BY FIRST rldt", + )?); + rows.push(home_row( + &ri, + "home.bin.latest_music", + "FILTER (visi = visi AND kind = musi) SORT DESCENDING BY FIRST rldt", + )?); + rows.extend(home_row_highlight( + &ri, + "home.bin.daily_random", + "FILTER (visi = visi AND kind = movi) SORT RANDOM", + )?); + rows.push(home_row( + &ri, + "home.bin.max_rating", + "SORT DESCENDING BY FIRST rtng.imdb", + )?); + rows.extend(home_row_highlight( + &ri, + "home.bin.daily_random", + "FILTER (visi = visi AND kind = show) SORT RANDOM", + )?); + + Ok(ri.respond_ui(&Home { + ri: &ri.render_info(), + rows: &rows, + })) +} + +fn home_row( + ri: &RequestInfo<'_>, + title: &'static str, + query: &str, +) -> Result<(&'static str, HomeRow<'static>)> { + let q = Query::from_str(query).context("parse query")?; + ri.state.database.transaction_ret(|txn| { + let rows = txn.query(q.clone())?.take(16).collect::<Result<Vec<_>>>()?; + + let mut nkus = Vec::new(); + for (row, _) in rows { + let node = txn.get(row)?.unwrap(); + nkus.push(Nku { + node: Cow::Owned(node), + role: None, + userdata: Cow::Borrowed(EMPTY), + }); + } + Ok((title, HomeRow::Inline(nkus))) + }) +} + +fn home_row_highlight( + ri: &RequestInfo<'_>, + title: &'static str, + query: &str, +) -> Result<Option<(&'static str, HomeRow<'static>)>> { + let q = Query::from_str(query).context("parse query")?; + ri.state.database.transaction_ret(|txn| { + let Some(row) = txn.query(q.clone())?.next() else { + return Ok(None); + }; + let row = row?.0; + let node = txn.get(row)?.unwrap(); + Ok(Some(( + title, + HomeRow::Highlight(Nku { + node: Cow::Owned(node), + role: None, + userdata: Cow::Borrowed(EMPTY), + }), + ))) + }) +} diff --git a/server/src/routes/index.rs b/server/src/routes/index.rs new file mode 100644 index 0000000..1f0e8c9 --- /dev/null +++ b/server/src/routes/index.rs @@ -0,0 +1,42 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2026 metamuffin <metamuffin.org> +*/ +use crate::{State, request_info::RequestInfo, routes::error::MyResult}; +use jellycommon::routes::{u_account_login, u_home}; +use rocket::{futures::FutureExt, get, response::Redirect}; +use std::{future::Future, pin::Pin, sync::Arc}; +use tokio::{fs::File, io::AsyncRead}; + +#[get("/")] +pub async fn r_index(ri: RequestInfo<'_>) -> MyResult<Redirect> { + if ri.user.is_some() { + Ok(Redirect::temporary(u_home())) + } else { + Ok(Redirect::temporary(u_account_login())) + } +} + +#[get("/favicon.ico")] +pub async fn r_favicon(s: &rocket::State<Arc<State>>) -> MyResult<File> { + Ok(File::open(s.config.asset_path.join("favicon.ico")).await?) +} + +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, + } + } +} diff --git a/server/src/routes/items.rs b/server/src/routes/items.rs new file mode 100644 index 0000000..0f7386c --- /dev/null +++ b/server/src/routes/items.rs @@ -0,0 +1,69 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2026 metamuffin <metamuffin.org> +*/ + +use crate::{request_info::RequestInfo, routes::error::MyResult}; +use anyhow::anyhow; +use base64::{Engine, prelude::BASE64_URL_SAFE}; +use jellycommon::{ + jellyobject::{EMPTY, Path}, + *, +}; +use jellydb::{Filter, MultiBehaviour, Query, Sort, SortOrder, ValueSort}; +use jellyui::components::items::Items; +use rocket::{get, response::content::RawHtml}; +use std::borrow::Cow; + +#[get("/items?<cont>")] +pub fn r_items(ri: RequestInfo, cont: Option<&str>) -> MyResult<RawHtml<String>> { + let cont_in = cont + .map(|s| BASE64_URL_SAFE.decode(s)) + .transpose() + .map_err(|_| anyhow!("invalid contination token"))?; + + let mut items = Vec::new(); + let mut cont_out = None; + ri.state.database.transaction(&mut |txn| { + let rows = txn + .query(Query { + filter: Filter::All(vec![ + Filter::Match(Path(vec![NO_KIND.0]), KIND_VIDEO.into()), + Filter::Match(Path(vec![NO_VISIBILITY.0]), VISI_VISIBLE.into()), + ]), + sort: Sort::Value(ValueSort { + path: Path(vec![NO_RELEASEDATE.0]), + multi: MultiBehaviour::First, + order: SortOrder::Descending, + offset: None, + }), + continuation: cont_in.clone(), + ..Default::default() + })? + .take(64) + .collect::<Result<Vec<_>, _>>()?; + + items.clear(); + cont_out = None; + for (r, is) in rows { + let node = txn.get(r)?.unwrap(); + items.push(node); + cont_out = Some(is) + } + Ok(()) + })?; + + Ok(ri.respond_ui(&Items { + ri: &ri.render_info(), + items: &items + .iter() + .map(|node| Nku { + node: Cow::Borrowed(&node), + userdata: Cow::Borrowed(EMPTY), + role: None, + }) + .collect::<Vec<_>>(), + cont: cont_out.map(|x| BASE64_URL_SAFE.encode(x)), + })) +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs new file mode 100644 index 0000000..959971a --- /dev/null +++ b/server/src/routes/mod.rs @@ -0,0 +1,163 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ + +pub mod account; +pub mod admin; +pub mod api; +pub mod assets; +pub mod compat; +pub mod error; +pub mod home; +pub mod index; +pub mod items; +pub mod node; +pub mod player; +pub mod playersync; +pub mod stream; +pub mod style; +pub mod userdata; + +use self::{ + account::{ + r_account_login, r_account_login_post, r_account_logout, r_account_logout_post, + settings::{r_account_settings, r_account_settings_post}, + }, + admin::{ + import::{r_admin_import, r_admin_import_post, r_admin_import_stream}, + log::{r_admin_log, r_admin_log_stream}, + r_admin_dashboard, + users::{r_admin_new_user, r_admin_user, r_admin_user_remove, r_admin_users}, + }, + api::{r_api_root, r_version}, + assets::{r_image, r_image_fallback_person}, + compat::youtube::{r_youtube_channel, r_youtube_embed, r_youtube_watch}, + error::{r_api_catch, r_catch}, + home::r_home, + index::{r_favicon, r_index}, + items::r_items, + node::r_node, + player::r_player, + playersync::{PlayersyncChannels, r_playersync}, + stream::r_stream, + style::{r_assets_css, r_assets_font, r_assets_js, r_assets_js_map}, +}; +use crate::State; +use rocket::{ + Build, Config, Rocket, catchers, fairing::AdHoc, fs::FileServer, http::Header, routes, + shield::Shield, +}; +use std::sync::Arc; + +#[macro_export] +macro_rules! uri { + ($kk:stmt) => { + &rocket::uri!($kk).to_string() + }; +} + +pub(super) fn build_rocket(state: Arc<State>) -> 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), + ip_header: Some("x-real-ip".into()), + ..Default::default() + }) + .manage(PlayersyncChannels::default()) + .manage(state.clone()) + .attach(AdHoc::on_response("set server header", |_req, res| { + res.set_header(Header::new("server", "jellything")); + Box::pin(async {}) + })) + .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(&state.config.asset_path)) + .mount( + "/", + routes![ + r_account_login_post, + r_account_login, + r_account_logout_post, + r_account_logout, + r_account_settings_post, + r_account_settings, + r_admin_dashboard, + r_admin_import_post, + r_admin_import_stream, + r_admin_import, + r_admin_log_stream, + r_admin_log, + r_admin_new_user, + r_admin_users, + r_admin_user, + r_admin_user_remove, + r_api_root, + r_assets_css, + r_assets_font, + r_assets_js_map, + r_assets_js, + r_favicon, + r_home, + r_image_fallback_person, + r_image, + r_index, + r_items, + r_node, + r_player, + r_playersync, + r_stream, + r_version, + // Compat + // r_jellyfin_artists, + // r_jellyfin_branding_configuration, + // r_jellyfin_branding_css, + // r_jellyfin_displaypreferences_usersettings_post, + // r_jellyfin_displaypreferences_usersettings, + // r_jellyfin_items_image_primary, + // r_jellyfin_items_images_backdrop, + // r_jellyfin_items_intros, + // r_jellyfin_items_item, + // r_jellyfin_items_playbackinfo, + // r_jellyfin_items_similar, + // r_jellyfin_items, + // r_jellyfin_livetv_programs_recommended, + // r_jellyfin_persons, + // r_jellyfin_playback_bitratetest, + // r_jellyfin_quickconnect_enabled, + // r_jellyfin_sessions_capabilities_full, + // r_jellyfin_sessions_playing_progress, + // r_jellyfin_sessions_playing, + // r_jellyfin_shows_nextup, + // r_jellyfin_socket, + // r_jellyfin_system_endpoint, + // r_jellyfin_system_info_public_case, + // r_jellyfin_system_info_public, + // r_jellyfin_system_info, + // r_jellyfin_users_authenticatebyname, + // r_jellyfin_users_authenticatebyname_case, + // r_jellyfin_users_id, + // r_jellyfin_users_items_item, + // r_jellyfin_users_items, + // r_jellyfin_users_public, + // r_jellyfin_users_views, + // r_jellyfin_video_stream, + r_youtube_channel, + r_youtube_embed, + r_youtube_watch, + ], + ) +} diff --git a/server/src/routes/node.rs b/server/src/routes/node.rs new file mode 100644 index 0000000..ca07bac --- /dev/null +++ b/server/src/routes/node.rs @@ -0,0 +1,187 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ + +use super::error::MyResult; +use crate::request_info::RequestInfo; +use anyhow::anyhow; +use jellycommon::{ + jellyobject::{EMPTY, Path}, + *, +}; +use jellydb::{Filter, Query}; +use jellyui::components::node_page::NodePage; +use rocket::{get, response::content::RawHtml}; +use std::borrow::Cow; + +#[get("/n/<slug>")] +pub fn r_node(ri: RequestInfo<'_>, slug: &str) -> MyResult<RawHtml<String>> { + ri.require_user()?; + + let mut nku = None; + ri.state.database.transaction(&mut |txn| { + if let Some(row) = txn.query_single(Query { + filter: Filter::Match(Path(vec![NO_SLUG.0]), slug.into()), + ..Default::default() + })? { + let n = txn.get(row)?.unwrap(); + nku = Some(Nku { + node: Cow::Owned(n), + userdata: Cow::Borrowed(EMPTY), + role: None, + }); + } + Ok(()) + })?; + let Some(nku) = nku else { + Err(anyhow!("no such node"))? + }; + + Ok(ri.respond_ui(&NodePage { + ri: &ri.render_info(), + nku, + })) +} + +// fn c_children( +// page: &mut ObjectBufferBuilder, +// txn: &mut dyn Transaction, +// row: u64, +// nku: &Object, +// ) -> Result<()> { +// let kind = nku +// .get(NKU_NODE) +// .unwrap_or_default() +// .get(NO_KIND) +// .unwrap_or(KIND_COLLECTION); + +// let (order, path) = match kind { +// KIND_CHANNEL => (SortOrder::Descending, Path(vec![NO_RELEASEDATE.0])), +// KIND_SEASON | KIND_SHOW => (SortOrder::Ascending, Path(vec![NO_INDEX.0])), +// _ => (SortOrder::Ascending, Path(vec![NO_TITLE.0])), +// }; + +// let children_rows = txn +// .query(Query { +// sort: Sort::Value(ValueSort { +// multi: MultiBehaviour::First, +// offset: None, +// order, +// path, +// }), +// filter: Filter::All(vec![ +// Filter::Match(Path(vec![NO_VISIBILITY.0]), VISI_VISIBLE.into()), +// Filter::Match(Path(vec![NO_PARENT.0]), row.into()), +// ]), +// ..Default::default() +// })? +// .collect::<Result<Vec<_>>>()?; + +// if children_rows.is_empty() { +// return Ok(()); +// } + +// let mut list = ObjectBufferBuilder::default(); + +// list.push( +// NODELIST_DISPLAYSTYLE, +// match kind { +// KIND_SEASON | KIND_SHOW => NLSTYLE_LIST, +// _ => NLSTYLE_GRID, +// }, +// ); + +// for (row, _) in children_rows { +// list.push( +// NODELIST_ITEM, +// Object::EMPTY +// .insert(NKU_NODE, txn.get(row)?.unwrap().as_object()) +// .as_object(), +// ); +// } + +// page.push(VIEW_NODE_LIST, list.finish().as_object()); +// Ok(()) +// } + +// fn c_credits( +// page: &mut ObjectBufferBuilder, +// txn: &mut dyn Transaction, +// nku: &Object, +// ) -> Result<()> { +// if !nku.get(NKU_NODE).unwrap_or_default().has(NO_CREDIT.0) { +// return Ok(()); +// } + +// let mut cats = BTreeMap::<_, Vec<_>>::new(); +// for cred in nku.get(NKU_NODE).unwrap_or_default().iter(NO_CREDIT) { +// let mut o = ObjectBuffer::empty(); +// if let Some(row) = cred.get(CR_NODE) { +// let node = txn.get(row)?.unwrap(); +// o = o.as_object().insert(NKU_NODE, node.as_object()); +// } +// if let Some(role) = cred.get(CR_ROLE) { +// o = o.as_object().insert(NKU_ROLE, role) +// } +// cats.entry(cred.get(CR_KIND).unwrap_or(CRCAT_CREW)) +// .or_default() +// .push(o); +// } +// let mut cats = cats.into_iter().collect::<Vec<_>>(); +// cats.sort_by_key(|(c, _)| match *c { +// CRCAT_CAST => 0, +// CRCAT_CREW => 1, +// _ => 100, +// }); +// for (cat, elems) in cats { +// let mut list = ObjectBufferBuilder::default(); +// list.push(NODELIST_DISPLAYSTYLE, NLSTYLE_INLINE); +// list.push(NODELIST_TITLE, &format!("tag.cred.kind.{cat}")); +// for item in elems { +// list.push(NODELIST_ITEM, item.as_object()); +// } +// page.push(VIEW_NODE_LIST, list.finish().as_object()); +// } + +// Ok(()) +// } + +// fn c_credited(page: &mut ObjectBufferBuilder, txn: &mut dyn Transaction, row: u64) -> Result<()> { +// let children_rows = txn +// .query(Query { +// sort: Sort::Value(ValueSort { +// multi: MultiBehaviour::First, +// offset: None, +// order: SortOrder::Ascending, +// path: Path(vec![NO_TITLE.0]), +// }), +// filter: Filter::All(vec![ +// Filter::Match(Path(vec![NO_VISIBILITY.0]), VISI_VISIBLE.into()), +// Filter::Match(Path(vec![NO_CREDIT.0, CR_NODE.0]), row.into()), +// ]), +// ..Default::default() +// })? +// .collect::<Result<Vec<_>>>()?; + +// if children_rows.is_empty() { +// return Ok(()); +// } + +// let mut list = ObjectBufferBuilder::default(); +// list.push(NODELIST_DISPLAYSTYLE, NLSTYLE_GRID); +// list.push(NODELIST_TITLE, "node.credited"); + +// for (row, _) in children_rows { +// list.push( +// NODELIST_ITEM, +// Object::EMPTY +// .insert(NKU_NODE, txn.get(row)?.unwrap().as_object()) +// .as_object(), +// ); +// } + +// page.push(VIEW_NODE_LIST, list.finish().as_object()); +// Ok(()) +// } diff --git a/server/src/routes/player.rs b/server/src/routes/player.rs new file mode 100644 index 0000000..c6c177e --- /dev/null +++ b/server/src/routes/player.rs @@ -0,0 +1,58 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use super::error::MyResult; +use crate::request_info::RequestInfo; +use anyhow::anyhow; +use jellycommon::{ + jellyobject::{EMPTY, Path}, + *, +}; +use jellydb::{Filter, Query}; +use jellyui::components::node_page::Player; +use rocket::{get, response::content::RawHtml}; +use std::borrow::Cow; + +// 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 { +// container: StreamContainer::Matroska +// } +// .to_query() +// ); +// format!("jellynative://{action}/{secret}/{session}/{seek}/{protocol}://{host}{stream_url}",) +// } + +#[get("/n/<slug>/player?<t>", rank = 4)] +pub fn r_player(ri: RequestInfo<'_>, t: Option<f64>, slug: &str) -> MyResult<RawHtml<String>> { + ri.require_user()?; + let _ = t; + + let mut node = None; + ri.state.database.transaction(&mut |txn| { + if let Some(row) = txn.query_single(Query { + filter: Filter::Match(Path(vec![NO_SLUG.0]), slug.into()), + ..Default::default() + })? { + node = Some(txn.get(row)?.unwrap()); + } + Ok(()) + })?; + let Some(node) = node else { + Err(anyhow!("no such node"))? + }; + + Ok(ri.respond_ui(&Player { + ri: &ri.render_info(), + nku: Nku { + node: Cow::Borrowed(&node), + userdata: Cow::Borrowed(EMPTY), + role: None, + }, + })) +} diff --git a/server/src/routes/playersync.rs b/server/src/routes/playersync.rs new file mode 100644 index 0000000..71e2809 --- /dev/null +++ b/server/src/routes/playersync.rs @@ -0,0 +1,109 @@ +use anyhow::bail; +use chashmap::CHashMap; +use futures::{SinkExt, StreamExt}; +use log::warn; +use rocket::{State, get}; +use rocket_ws::{Channel, Message, WebSocket, stream::DuplexStream}; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast::{self, Sender}; + +use crate::responders::cors::Cors; + +#[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/search.rs b/server/src/routes/search.rs new file mode 100644 index 0000000..8ec2697 --- /dev/null +++ b/server/src/routes/search.rs @@ -0,0 +1,37 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use super::error::MyResult; +use crate::request_info::RequestInfo; +use anyhow::anyhow; +use rocket::{Either, get, response::content::RawHtml, serde::json::Json}; + +#[get("/search?<query>&<page>")] +pub async fn r_search( + ri: RequestInfo<'_>, + query: Option<&str>, + page: Option<usize>, +) -> MyResult<RawHtml<String>> { + // let r = query + // .map(|query| search(&ri.session, query, page)) + // .transpose()?; + + // Ok(if ri.accept.is_json() { + // let Some(r) = r else { + // Err(anyhow!("no query"))? + // }; + // Either::Right(Json(r)) + // } else { + // Either::Left(RawHtml(render_page( + // &SearchPage { + // lang: &ri.lang, + // query: &query.map(|s| s.to_string()), + // r, + // }, + // ri.render_info(), + // ))) + // }) + todo!() +} diff --git a/server/src/routes/stats.rs b/server/src/routes/stats.rs new file mode 100644 index 0000000..387ca63 --- /dev/null +++ b/server/src/routes/stats.rs @@ -0,0 +1,12 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use crate::{request_info::RequestInfo, ui::error::MyResult}; +use rocket::{get, response::content::RawHtml}; + +#[get("/stats")] +pub fn r_stats(ri: RequestInfo) -> MyResult<RawHtml<String>> { + todo!() +} diff --git a/server/src/routes/stream.rs b/server/src/routes/stream.rs new file mode 100644 index 0000000..a72e0d9 --- /dev/null +++ b/server/src/routes/stream.rs @@ -0,0 +1,216 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use crate::{request_info::RequestInfo, routes::error::MyError}; +use anyhow::{Result, anyhow}; +use jellycommon::{ + NO_SLUG, NO_TITLE, NO_TRACK, TR_SOURCE, TRSOURCE_LOCAL_PATH, jellyobject::Path, + stream::StreamSpec, +}; +use jellydb::{Filter, Query}; +use jellystream::SMediaInfo; +use log::{info, warn}; +use rocket::{ + Either, Request, Response, get, head, + http::{Header, Status}, + request::{self, FromRequest}, + response::{self, Redirect, Responder}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + ops::Range, + sync::Arc, +}; +use tokio::{ + io::{DuplexStream, duplex}, + task::spawn_blocking, +}; +use tokio_util::io::SyncIoBridge; + +#[head("/n/<_id>/stream?<spec..>")] +pub async fn r_stream_head( + _sess: RequestInfo<'_>, + _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/<slug>/stream?<spec..>")] +pub async fn r_stream( + ri: RequestInfo<'_>, + slug: &str, + range: Option<RequestRange>, + spec: BTreeMap<String, String>, +) -> Result<StreamResponse, MyError> { + let spec = StreamSpec::from_query_kv(&spec).map_err(|x| anyhow!("spec invalid: {x}"))?; + + let mut node = None; + ri.state.database.transaction(&mut |txn| { + if let Some(row) = txn.query_single(Query { + filter: Filter::Match(Path(vec![NO_SLUG.0]), slug.into()), + ..Default::default() + })? { + node = txn.get(row)?; + } + Ok(()) + })?; + + let Some(node) = node else { + Err(anyhow!("node not found"))? + }; + + 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(u64::MAX) + } + None => 0..u64::MAX, + }; + + let head = jellystream::stream_head(&spec); + + let mut sources = BTreeSet::new(); + for track in node.iter(NO_TRACK) { + if let Some(s) = track.get(TR_SOURCE) { + if let Some(path) = s.get(TRSOURCE_LOCAL_PATH) { + sources.insert(path.into()); + } + } + } + let media = Arc::new(SMediaInfo { + files: sources, + title: node.get(NO_TITLE).map(String::from), + cache: ri.state.cache.clone(), + config: ri.state.config.stream.clone(), + }); + + // TODO too many threads + let mut reader = match spawn_blocking(move || jellystream::stream(media, spec, urange)) + .await + .unwrap() + { + Ok(o) => o, + Err(e) => { + warn!("stream error: {e:?}"); + Err(e)? + } + }; + let (stream_write, stream_read) = duplex(4096); + spawn_blocking(move || std::io::copy(&mut reader, &mut SyncIoBridge::new(stream_write))); + + Ok(StreamResponse { + stream: stream_read, + range, + advertise_range: head.range_supported, + content_type: head.content_type, + }) +} + +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<u64>>>); + +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/style.rs b/server/src/routes/style.rs new file mode 100644 index 0000000..b2a2189 --- /dev/null +++ b/server/src/routes/style.rs @@ -0,0 +1,46 @@ +/* + 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) 2026 metamuffin <metamuffin.org> + Copyright (C) 2023 tpart +*/ +use jellyui::{css_bundle, js_bundle, js_bundle_map}; +use rocket::{ + get, + http::{ContentType, Header}, + response::Responder, +}; +use std::borrow::Cow; + +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) + } +} + +#[get("/assets/bundle.css")] +pub fn r_assets_css() -> CachedAsset<(ContentType, Cow<'static, str>)> { + 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, Cow<'static, str>)> { + CachedAsset((ContentType::JavaScript, js_bundle())) +} +#[get("/assets/bundle.js.map")] +pub fn r_assets_js_map() -> CachedAsset<(ContentType, Cow<'static, str>)> { + CachedAsset((ContentType::JSON, js_bundle_map())) +} diff --git a/server/src/routes/userdata.rs b/server/src/routes/userdata.rs new file mode 100644 index 0000000..9fdc2bf --- /dev/null +++ b/server/src/routes/userdata.rs @@ -0,0 +1,59 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +use rocket::{FromFormField, UriDisplayQuery}; + +#[derive(Debug, FromFormField, UriDisplayQuery)] +pub enum UrlWatchedState { + None, + Watched, + Pending, +} + +// #[get("/n/<id>/userdata")] +// pub fn r_node_userdata(session: A<Session>, id: A<NodeID>) -> MyResult<Json<NodeUserData>> { +// let u = get_node(&session.0, id.0, false, false, NodeFilterSort::default())?.userdata; +// Ok(Json(u)) +// } + +// #[post("/n/<id>/watched?<state>")] +// pub async fn r_node_userdata_watched( +// session: A<Session>, +// id: A<NodeID>, +// state: UrlWatchedState, +// ) -> MyResult<Redirect> { +// update_node_userdata_watched( +// &session.0, +// id.0, +// match state { +// UrlWatchedState::None => WatchedState::None, +// UrlWatchedState::Watched => WatchedState::Watched, +// UrlWatchedState::Pending => WatchedState::Pending, +// }, +// )?; +// Ok(Redirect::found(u_node_id(id.0))) +// } + +// #[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: A<Session>, +// id: A<NodeID>, +// form: Form<UpdateRating>, +// ) -> MyResult<Redirect> { +// update_node_userdata_rating(&session.0, id.0, form.rating)?; +// Ok(Redirect::found(u_node_id(id.0))) +// } + +// #[post("/n/<id>/progress?<t>")] +// pub async fn r_node_userdata_progress(session: A<Session>, id: A<NodeID>, t: f64) -> MyResult<()> { +// update_node_userdata_watched_progress(&session.0, id.0, t)?; +// Ok(()) +// } |