aboutsummaryrefslogtreecommitdiff
path: root/server/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/routes')
-rw-r--r--server/src/routes/account/mod.rs139
-rw-r--r--server/src/routes/account/settings.rs127
-rw-r--r--server/src/routes/admin/import.rs78
-rw-r--r--server/src/routes/admin/log.rs55
-rw-r--r--server/src/routes/admin/mod.rs29
-rw-r--r--server/src/routes/admin/users.rs119
-rw-r--r--server/src/routes/api.rs38
-rw-r--r--server/src/routes/assets.rs53
-rw-r--r--server/src/routes/compat/jellyfin/mod.rs884
-rw-r--r--server/src/routes/compat/jellyfin/models.rs206
-rw-r--r--server/src/routes/compat/mod.rs7
-rw-r--r--server/src/routes/compat/youtube.rs67
-rw-r--r--server/src/routes/error.rs75
-rw-r--r--server/src/routes/home.rs96
-rw-r--r--server/src/routes/index.rs42
-rw-r--r--server/src/routes/items.rs69
-rw-r--r--server/src/routes/mod.rs163
-rw-r--r--server/src/routes/node.rs187
-rw-r--r--server/src/routes/player.rs58
-rw-r--r--server/src/routes/playersync.rs109
-rw-r--r--server/src/routes/search.rs37
-rw-r--r--server/src/routes/stats.rs12
-rw-r--r--server/src/routes/stream.rs216
-rw-r--r--server/src/routes/style.rs46
-rw-r--r--server/src/routes/userdata.rs59
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(())
+// }