diff --git a/CHANGELOG.md b/CHANGELOG.md index e2bb1ba0fb..ea4142ca72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2026.06.29] + +### Added +- `jellyfin`: Jellyfin-compatible HTTP API server on its own port — new `crates/jellyfin/src/server/` module gated behind the `server` cargo feature, spawned alongside Navidrome from `crates/server/src/lib.rs`; opt-in by setting `jellyfin_port` in `settings.toml` (conventionally `8096`) — omitting the key disables the server entirely; spoofs `ProductName: "Jellyfin Server"` and `Version: 10.11.11` so the SDK-generated clients (Finamp, Findroid, Streamyfin, Amcfy Music, Symfonium) treat it as a real Jellyfin server; reuses `subsonic_username` / `subsonic_password` from `settings.toml` as the Jellyfin credentials (additionally gated on the password being non-empty); tokens are persisted in a new `jellyfin_tokens` table (migration applied at startup) and accepted via `X-Emby-Token`, `Authorization: MediaBrowser Token=…`, or `?api_key=` query param on streaming URLs; item IDs are deterministic dashed-UUIDs derived from the native `Artist/Album/Track` ids and round-tripped via a new `jf_guids` lookup table; full route surface covers `/System/Info{,/Public}`, `/Users/AuthenticateByName` (PascalCase + lowercase variants for Amcfy), `/Users/Public`, `/Users/{id}/Views`, `/UserViews`, `/Library/MediaFolders`, `/Items` (with `parentId`, `includeItemTypes`, `searchTerm`, `albumArtistIds`/`artistIds`, `ids`, pagination — accepts both camelCase and PascalCase plus repeated keys), `/Items/{id}` + `/Items/{id}/{Images/Primary,File,PlaybackInfo}`, `/Audio/{id}/{stream,stream.{ext},universal}` with HTTP Range support, `/Search/Hints` backed by the same `repo::{artist,album,track}::filter` LIKE queries, `/Sessions{,/Capabilities/Full,/Playing,/Playing/Progress,/Playing/Stopped}` acks, `/ScheduledTasks/Running/{id}` + `/Library/Refresh` (return 204 — actual scans are owned by `audio_scan::start_watcher`), `/Shows/*` + `/UserItems/*` + `/Items/{Suggestions,Resume,Latest}` stubs for client home rails, and `/Items/{id}/Images/{kind}` (both PascalCase and lowercase `/items/...` paths for Findroid); discovery runs in two tokio tasks — `_jellyfin._tcp.local.` mDNS broadcast plus a UDP listener on port `7359` that answers the literal `"Who is JellyfinServer?"` probe with `{"Address":"http://:","Id":…,"Name":…}` +- `settings`: new optional `jellyfin_port: Option` field on `NewGlobalSettings` (defaults to `8096`); also added to the `Into` impl in `crates/rpc/src/lib.rs` so gRPC settings round-trips don't drop the field + +### Fixed +- `jellyfin`: album art now resolves correctly — `rockbox-library` stores `album_art` as one of three forms (bare filename living under `~/.config/rockbox.org/covers/`, absolute filesystem path, or `http(s)://` URL for Rocksky-sourced artist images), but the initial Jellyfin image handler joined bare filenames against `music_dir` and got `NotFound`; replaced with a `serve_art_value` that mirrors Navidrome's resolver (proxy `http(s)://` via `reqwest`, read absolute paths as-is, prefix bare filenames with `~/.config/rockbox.org/covers/`), and added the "if the album row has no art, search its tracks" fallback Navidrome already uses + ## [2026.06.26] ### Added diff --git a/Cargo.lock b/Cargo.lock index bbea18d45a..2346cc1ee4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6253,6 +6253,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "if_chain" version = "1.0.2" @@ -7197,6 +7207,20 @@ dependencies = [ "socket2 0.4.10", ] +[[package]] +name = "mdns-sd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "400c6168c6a7d2cd936365dc51e0ee4159830876aac224b5e3e042605dcb73f3" +dependencies = [ + "fastrand", + "flume 0.11.0", + "if-addrs 0.13.4", + "log", + "mio 1.0.2", + "socket2 0.5.7", +] + [[package]] name = "memchr" version = "2.7.4" @@ -9584,7 +9608,7 @@ dependencies = [ "futures-util", "libmdns", "mdns", - "mdns-sd", + "mdns-sd 0.5.10", "rand 0.8.5", "tokio", "tracing", @@ -9747,12 +9771,28 @@ dependencies = [ name = "rockbox-jellyfin" version = "0.1.0" dependencies = [ + "actix-cors", + "actix-rt", + "actix-web", "anyhow", + "chrono", + "futures", + "hex", + "if-addrs 0.13.4", + "md-5", + "mdns-sd 0.13.3", + "mime_guess", "reqwest", + "rockbox-library", + "rockbox-settings", "serde", "serde_json", + "sha2", + "sqlx", "tokio", "tracing", + "urlencoding", + "uuid", ] [[package]] @@ -9760,7 +9800,7 @@ name = "rockbox-kodi" version = "0.1.0" dependencies = [ "anyhow", - "mdns-sd", + "mdns-sd 0.5.10", "reqwest", "serde", "serde_json", @@ -9883,7 +9923,7 @@ name = "rockbox-plex" version = "0.1.0" dependencies = [ "anyhow", - "mdns-sd", + "mdns-sd 0.5.10", "reqwest", "tokio", "tracing", @@ -9998,6 +10038,7 @@ dependencies = [ "rockbox-discovery", "rockbox-fts5", "rockbox-graphql", + "rockbox-jellyfin", "rockbox-library", "rockbox-mpd", "rockbox-mpris", @@ -10074,7 +10115,7 @@ name = "rockbox-types" version = "0.1.0" dependencies = [ "local-ip-addr", - "mdns-sd", + "mdns-sd 0.5.10", "rockbox-traits", "rockbox-typesense", "serde", diff --git a/README.md b/README.md index d1cacfd69c..70c6f7dba2 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ and Squeezelite. - [x] [MPD](https://mpd.readthedocs.io/en/stable/protocol.html) server — compatible with all MPD clients - [x] [MPRIS](https://specifications.freedesktop.org/mpris-spec/) — desktop media key and taskbar integration - [x] Subsonic/Navidrome API — compatible with Cassette, Symfonium, DSub, Ultrasonic and more +- [x] Jellyfin-compatible API — works with native Jellyfin clients (Finamp, Findroid, Streamyfin, Amcfy Music, Symfonium); reuses the Subsonic credentials, opt-in by setting `jellyfin_port` (conventionally `8096`) in `settings.toml`, with mDNS + UDP 7359 discovery - [x] S3-compatible API — upload / delete audio files from `awscli`, `mc`, `rclone`; the library DB stays in sync automatically - [x] Fast search powered by [Typesense](https://typesense.org) - [x] Navigate by folders or tag database diff --git a/crates/jellyfin/Cargo.toml b/crates/jellyfin/Cargo.toml index a238bea4c4..733b22e9f5 100644 --- a/crates/jellyfin/Cargo.toml +++ b/crates/jellyfin/Cargo.toml @@ -9,6 +9,29 @@ repository.workspace = true [lib] crate-type = ["rlib"] +[features] +default = [] +# Optional Jellyfin-compatible HTTP API server. Off by default — clients of +# this crate only get the discovery/auth client unless they opt in. +server = [ + "actix-web", + "actix-rt", + "actix-cors", + "sqlx", + "rockbox-library", + "rockbox-settings", + "uuid", + "chrono", + "futures", + "hex", + "mime_guess", + "sha2", + "urlencoding", + "md-5", + "if-addrs", + "mdns-sd", +] + [dependencies] anyhow = { workspace = true } tokio = { workspace = true } @@ -16,3 +39,19 @@ tracing = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +actix-web = { version = "4", optional = true } +actix-rt = { version = "2", optional = true } +actix-cors = { version = "0.7", optional = true } +sqlx = { version = "0.8.2", optional = true, features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "derive", "macros"] } +rockbox-library = { path = "../library", optional = true } +rockbox-settings = { path = "../settings", optional = true } +uuid = { version = "1.3.0", optional = true, features = ["v4"] } +chrono = { version = "0.4", optional = true, default-features = false, features = ["clock", "serde"] } +futures = { version = "0.3", optional = true } +hex = { version = "0.4", optional = true } +mime_guess = { version = "2", optional = true } +sha2 = { version = "0.10", optional = true } +urlencoding = { version = "2", optional = true } +md-5 = { version = "0.10", optional = true } +if-addrs = { version = "0.13", optional = true } +mdns-sd = { version = "0.13", optional = true } diff --git a/crates/jellyfin/migrations/20260629000000_jellyfin_tables.sql b/crates/jellyfin/migrations/20260629000000_jellyfin_tables.sql new file mode 100644 index 0000000000..dfd2a02414 --- /dev/null +++ b/crates/jellyfin/migrations/20260629000000_jellyfin_tables.sql @@ -0,0 +1,24 @@ +-- Jellyfin sidecar tables. Run on every startup; harmless on re-run. + +CREATE TABLE IF NOT EXISTS jellyfin_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS jellyfin_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + device_id TEXT, + device_name TEXT, + client TEXT, + created_at TEXT NOT NULL +); + +-- Reverse lookup from a dashed-UUID GUID we emit to Jellyfin clients back +-- to the native rockbox-library id (artist/album/track UUID strings). +CREATE TABLE IF NOT EXISTS jf_guids ( + guid TEXT PRIMARY KEY, + kind TEXT NOT NULL, + native_id TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_jf_guids_native ON jf_guids(kind, native_id); diff --git a/crates/jellyfin/src/lib.rs b/crates/jellyfin/src/lib.rs index f9d072a907..76faf8a54f 100644 --- a/crates/jellyfin/src/lib.rs +++ b/crates/jellyfin/src/lib.rs @@ -3,6 +3,10 @@ use std::time::Duration; use serde::Deserialize; +/// Jellyfin-compatible HTTP API server. Opt in via the `server` feature. +#[cfg(feature = "server")] +pub mod server; + #[derive(Debug, Clone)] pub struct JellyfinServer { pub name: String, diff --git a/crates/jellyfin/src/server/auth.rs b/crates/jellyfin/src/server/auth.rs new file mode 100644 index 0000000000..3e63649ee7 --- /dev/null +++ b/crates/jellyfin/src/server/auth.rs @@ -0,0 +1,232 @@ +//! Jellyfin auth: X-Emby-Authorization header parsing, opaque-token store +//! backed by sqlite, FromRequest extractor for protected endpoints. + +use actix_web::{dev::Payload, web, FromRequest, HttpRequest}; +use anyhow::Result; +use futures::future::LocalBoxFuture; +use sha2::{Digest, Sha256}; +use sqlx::{Pool, Sqlite}; +use std::collections::HashMap; + +use super::JellyfinState; + +/// Parsed `X-Emby-Authorization` / `Authorization: MediaBrowser …` header. +/// Jellyfin clients send a comma-separated list of `Key="Value"` pairs. +#[derive(Debug, Default, Clone)] +pub struct EmbyAuth { + pub client: Option, + pub device: Option, + pub device_id: Option, + pub version: Option, + pub token: Option, +} + +pub fn parse_emby_auth_header(value: &str) -> EmbyAuth { + let body = value + .strip_prefix("MediaBrowser ") + .or_else(|| value.strip_prefix("Emby ")) + .unwrap_or(value); + + let mut out = EmbyAuth::default(); + let mut buf = String::new(); + let mut in_quotes = false; + let chars = body.chars(); + let mut parts: Vec = Vec::new(); + for c in chars { + match c { + '"' => in_quotes = !in_quotes, + ',' if !in_quotes => { + parts.push(std::mem::take(&mut buf)); + } + _ => buf.push(c), + } + } + if !buf.is_empty() { + parts.push(buf); + } + + let pairs: HashMap = parts + .into_iter() + .filter_map(|p| { + let trimmed = p.trim(); + let eq = trimmed.find('=')?; + let key = trimmed[..eq].trim().to_string(); + let val = trimmed[eq + 1..].trim().trim_matches('"').to_string(); + if key.is_empty() { + None + } else { + Some((key, val)) + } + }) + .collect(); + + out.client = pairs.get("Client").cloned(); + out.device = pairs.get("Device").cloned(); + out.device_id = pairs.get("DeviceId").cloned(); + out.version = pairs.get("Version").cloned(); + out.token = pairs.get("Token").cloned(); + out +} + +/// Pull the access token from any header/query position Jellyfin clients use. +pub fn extract_token(req: &HttpRequest) -> Option { + let headers = req.headers(); + for name in ["x-emby-token", "x-mediabrowser-token"] { + if let Some(v) = headers.get(name) { + if let Ok(s) = v.to_str() { + let t = s.trim(); + if !t.is_empty() { + return Some(t.to_string()); + } + } + } + } + for name in ["x-emby-authorization", "authorization"] { + if let Some(v) = headers.get(name) { + if let Ok(s) = v.to_str() { + if let Some(t) = parse_emby_auth_header(s).token { + if !t.is_empty() { + return Some(t); + } + } + } + } + } + // Some clients pass it as a query param on streaming URLs. + for pair in req.query_string().split('&') { + let mut it = pair.splitn(2, '='); + let k = it.next().unwrap_or(""); + let v = it.next().unwrap_or(""); + if k.eq_ignore_ascii_case("api_key") || k.eq_ignore_ascii_case("apikey") { + if !v.is_empty() { + return Some( + urlencoding::decode(v) + .map(|s| s.into_owned()) + .unwrap_or_else(|_| v.to_string()), + ); + } + } + } + None +} + +pub async fn token_valid(pool: &Pool, token: &str) -> bool { + let row: Option<(String,)> = + sqlx::query_as("SELECT user_id FROM jellyfin_tokens WHERE token = ?1") + .bind(token) + .fetch_optional(pool) + .await + .unwrap_or(None); + row.is_some() +} + +pub async fn store_token( + pool: &Pool, + token: &str, + user_id: &str, + auth: &EmbyAuth, + now: &str, +) -> Result<()> { + sqlx::query( + "INSERT INTO jellyfin_tokens (token, user_id, device_id, device_name, client, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(token) DO NOTHING", + ) + .bind(token) + .bind(user_id) + .bind(auth.device_id.as_deref()) + .bind(auth.device.as_deref()) + .bind(auth.client.as_deref()) + .bind(now) + .execute(pool) + .await?; + Ok(()) +} + +pub fn random_hex(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + let ok = std::fs::File::open("/dev/urandom") + .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut buf)) + .is_ok(); + if !ok { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut h = Sha256::new(); + h.update(nanos.to_le_bytes()); + h.update(n.to_le_bytes()); + let digest = h.finalize(); + let take = bytes.min(digest.len()); + buf[..take].copy_from_slice(&digest[..take]); + } + hex::encode(buf) +} + +/// Look up or generate the server's stable Jellyfin id (dashed UUID). +/// Persisted in `jellyfin_meta`; old un-dashed values are upgraded in place. +pub async fn ensure_server_id(pool: &Pool) -> Result { + let existing: Option<(String,)> = + sqlx::query_as("SELECT value FROM jellyfin_meta WHERE key = 'server_id'") + .fetch_optional(pool) + .await?; + if let Some((v,)) = existing { + if v.len() == 32 && v.chars().all(|c| c.is_ascii_hexdigit()) { + let upgraded = super::mapping::guid_dashed(&v); + sqlx::query("UPDATE jellyfin_meta SET value = ?1 WHERE key = 'server_id'") + .bind(&upgraded) + .execute(pool) + .await?; + return Ok(upgraded); + } + return Ok(v); + } + let id = super::mapping::guid_dashed(&random_hex(16)); + sqlx::query( + "INSERT INTO jellyfin_meta (key, value) VALUES ('server_id', ?1) + ON CONFLICT(key) DO NOTHING", + ) + .bind(&id) + .execute(pool) + .await?; + let (val,): (String,) = + sqlx::query_as("SELECT value FROM jellyfin_meta WHERE key = 'server_id'") + .fetch_one(pool) + .await?; + Ok(val) +} + +// ── FromRequest extractor for protected endpoints ───────────────────────────── + +pub struct AuthedUser { + #[allow(dead_code)] + pub user_id: String, +} + +impl FromRequest for AuthedUser { + type Error = actix_web::Error; + type Future = LocalBoxFuture<'static, Result>; + + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + let token = extract_token(req); + let state = req.app_data::>().cloned(); + Box::pin(async move { + let Some(token) = token else { + return Err(actix_web::error::ErrorUnauthorized("missing access token")); + }; + let Some(state) = state else { + return Err(actix_web::error::ErrorInternalServerError("missing state")); + }; + if token_valid(&state.pool, &token).await { + Ok(AuthedUser { + user_id: state.user_id.as_str().to_string(), + }) + } else { + Err(actix_web::error::ErrorUnauthorized("invalid token")) + } + }) + } +} diff --git a/crates/jellyfin/src/server/discovery.rs b/crates/jellyfin/src/server/discovery.rs new file mode 100644 index 0000000000..2b151882d8 --- /dev/null +++ b/crates/jellyfin/src/server/discovery.rs @@ -0,0 +1,130 @@ +//! Jellyfin "client discovery" UDP protocol on port 7359. Clients broadcast +//! the literal string `"Who is JellyfinServer?"` and the server replies with +//! a JSON identifier. +//! +//! See: https://jellyfin.org/docs/general/networking/index.html#auto-discovery + +use anyhow::{Context, Result}; +use serde_json::json; +use tokio::net::UdpSocket; + +pub const DISCOVERY_PORT: u16 = 7359; +const PROBE: &str = "Who is JellyfinServer?"; + +pub async fn run(server_name: String, server_id: String, http_port: u16) -> Result<()> { + let socket = UdpSocket::bind(("0.0.0.0", DISCOVERY_PORT)) + .await + .with_context(|| format!("binding UDP {DISCOVERY_PORT} for jellyfin discovery"))?; + socket + .set_broadcast(true) + .context("enabling SO_BROADCAST on jellyfin discovery socket")?; + + tracing::info!( + "jellyfin: discovery listener on udp/0.0.0.0:{DISCOVERY_PORT} as {server_name} ({server_id})" + ); + + let mut buf = vec![0u8; 1024]; + loop { + let (n, peer) = match socket.recv_from(&mut buf).await { + Ok(v) => v, + Err(e) => { + tracing::warn!("jellyfin discovery recv: {e}"); + continue; + } + }; + let msg = String::from_utf8_lossy(&buf[..n]); + if msg.trim() != PROBE { + continue; + } + let local_ip = local_ip_for_peer(peer.ip()).unwrap_or_else(|| peer.ip().to_string()); + let address = format!("http://{}:{}", local_ip, http_port); + let reply = json!({ + "Address": address, + "Id": server_id, + "Name": server_name, + "EndpointAddress": null, + }); + if let Err(e) = socket.send_to(reply.to_string().as_bytes(), peer).await { + tracing::warn!("jellyfin discovery reply to {peer}: {e}"); + } + } +} + +fn local_ip_for_peer(peer: std::net::IpAddr) -> Option { + let ifaces = if_addrs::get_if_addrs().ok()?; + let peer_v4 = match peer { + std::net::IpAddr::V4(v) => Some(v), + std::net::IpAddr::V6(_) => None, + }; + + let mut best: Option = None; + for iface in ifaces { + if iface.is_loopback() { + continue; + } + let std::net::IpAddr::V4(v4) = iface.ip() else { + continue; + }; + if !v4.is_private() { + continue; + } + if let Some(peer_v4) = peer_v4 { + let a = peer_v4.octets(); + let b = v4.octets(); + if a[0] == b[0] && a[1] == b[1] && a[2] == b[2] { + return Some(v4.to_string()); + } + } + best.get_or_insert(v4); + } + best.map(|v| v.to_string()) +} + +/// Register `_jellyfin._tcp.local.` over mDNS. Best-effort — if the daemon +/// can't be created we just log and skip. +pub fn register_mdns(instance_name: &str, http_port: u16, server_id: &str) { + let Ok(daemon) = mdns_sd::ServiceDaemon::new() else { + tracing::warn!("jellyfin: mDNS responder unavailable"); + return; + }; + let hostname = format!("{instance_name}.local."); + let ips: Vec = if_addrs::get_if_addrs() + .unwrap_or_default() + .into_iter() + .filter_map(|i| match i.ip() { + std::net::IpAddr::V4(v) if v.is_private() && !v.is_loopback() => { + Some(std::net::IpAddr::V4(v)) + } + _ => None, + }) + .collect(); + if ips.is_empty() { + tracing::debug!("jellyfin: no LAN IPs to advertise"); + return; + } + let mut txt = std::collections::HashMap::new(); + txt.insert("ID".to_string(), server_id.to_string()); + let info = match mdns_sd::ServiceInfo::new( + "_jellyfin._tcp.local.", + instance_name, + &hostname, + ips.as_slice(), + http_port, + Some(txt), + ) { + Ok(i) => i, + Err(e) => { + tracing::warn!("jellyfin: mDNS ServiceInfo build failed: {e}"); + return; + } + }; + if let Err(e) = daemon.register(info) { + tracing::warn!("jellyfin: mDNS register failed: {e}"); + } else { + tracing::info!( + "jellyfin: advertising _jellyfin._tcp.local. on {ips:?}:{http_port} (ID={server_id})" + ); + // Daemon lives for the program lifetime — leak the handle so it doesn't drop. + std::mem::forget(daemon); + } +} diff --git a/crates/jellyfin/src/server/dto.rs b/crates/jellyfin/src/server/dto.rs new file mode 100644 index 0000000000..b56565c8c3 --- /dev/null +++ b/crates/jellyfin/src/server/dto.rs @@ -0,0 +1,443 @@ +//! Jellyfin response DTOs aligned to the official OpenAPI spec. +//! +//! Conventions: +//! - **Required non-nullable** fields are plain types — MUST be emitted. +//! Missing them causes SDK-generated clients (jellyfin-sdk-kotlin, +//! kotlinx.serialization) to silently drop the whole object. +//! - **Nullable** fields are `Option` and serialize as `null` when None. +//! - **Optional non-nullable** uses `#[serde(skip_serializing_if = +//! "Option::is_none")]`. + +use serde::Serialize; +use serde_json::Value; + +/// Version string we report to Jellyfin clients. The official Android app +/// and several SDK-generated clients refuse anything older than recent +/// Jellyfin releases — we pose as a modern server. +pub const JELLYFIN_API_VERSION: &str = "10.11.11"; + +// ── System info ───────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct PublicSystemInfo { + pub local_address: Option, + pub server_name: Option, + pub version: Option, + pub product_name: Option, + pub operating_system: Option, + pub id: Option, + pub startup_wizard_completed: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct SystemInfo { + pub local_address: Option, + pub server_name: Option, + pub version: Option, + pub product_name: Option, + pub operating_system: Option, + pub id: Option, + pub startup_wizard_completed: Option, + pub operating_system_display_name: Option, + pub package_name: Option, + pub has_pending_restart: bool, + pub is_shutting_down: bool, + pub supports_library_monitor: bool, + pub web_socket_port_number: i32, + pub completed_installations: Option>, + pub can_self_restart: bool, + pub can_launch_web_browser: bool, + pub program_data_path: Option, + pub web_path: Option, + pub items_by_name_path: Option, + pub cache_path: Option, + pub log_path: Option, + pub internal_metadata_path: Option, + pub transcoding_temp_path: Option, + pub cast_receiver_applications: Option>, + pub has_update_available: bool, + pub encoder_location: Option, + pub system_architecture: Option, +} + +// ── User ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct UserConfiguration { + pub audio_language_preference: Option, + pub play_default_audio_track: bool, + pub subtitle_language_preference: Option, + pub display_missing_episodes: bool, + pub grouped_folders: Vec, + pub subtitle_mode: &'static str, + pub display_collections_view: bool, + pub enable_local_password: bool, + pub ordered_views: Vec, + pub latest_items_excludes: Vec, + pub my_media_excludes: Vec, + pub hide_played_in_latest: bool, + pub remember_audio_selections: bool, + pub remember_subtitle_selections: bool, + pub enable_next_episode_auto_play: bool, + pub cast_receiver_id: Option, +} + +impl Default for UserConfiguration { + fn default() -> Self { + Self { + audio_language_preference: None, + play_default_audio_track: true, + subtitle_language_preference: None, + display_missing_episodes: false, + grouped_folders: vec![], + subtitle_mode: "Default", + display_collections_view: false, + enable_local_password: false, + ordered_views: vec![], + latest_items_excludes: vec![], + my_media_excludes: vec![], + hide_played_in_latest: true, + remember_audio_selections: true, + remember_subtitle_selections: true, + enable_next_episode_auto_play: true, + cast_receiver_id: None, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct UserPolicy { + pub is_administrator: bool, + pub is_hidden: bool, + pub enable_collection_management: bool, + pub enable_subtitle_management: bool, + pub enable_lyric_management: bool, + pub is_disabled: bool, + pub max_parental_rating: Option, + pub max_parental_sub_rating: Option, + pub blocked_tags: Option>, + pub allowed_tags: Option>, + pub enable_user_preference_access: bool, + pub access_schedules: Option>, + pub block_unrated_items: Option>, + pub enable_remote_control_of_other_users: bool, + pub enable_shared_device_control: bool, + pub enable_remote_access: bool, + pub enable_live_tv_management: bool, + pub enable_live_tv_access: bool, + pub enable_media_playback: bool, + pub enable_audio_playback_transcoding: bool, + pub enable_video_playback_transcoding: bool, + pub enable_playback_remuxing: bool, + pub force_remote_source_transcoding: bool, + pub enable_content_deletion: bool, + pub enable_content_deletion_from_folders: Option>, + pub enable_content_downloading: bool, + pub enable_sync_transcoding: bool, + pub enable_media_conversion: bool, + pub enabled_devices: Option>, + pub enable_all_devices: bool, + pub enabled_channels: Option>, + pub enable_all_channels: bool, + pub enabled_folders: Option>, + pub enable_all_folders: bool, + pub invalid_login_attempt_count: i32, + pub login_attempts_before_lockout: i32, + pub max_active_sessions: i32, + pub enable_public_sharing: bool, + pub blocked_media_folders: Option>, + pub blocked_channels: Option>, + pub remote_client_bitrate_limit: i32, + pub authentication_provider_id: String, + pub password_reset_provider_id: String, + pub sync_play_access: &'static str, +} + +impl UserPolicy { + pub fn admin() -> Self { + Self { + is_administrator: true, + is_hidden: false, + enable_collection_management: true, + enable_subtitle_management: true, + enable_lyric_management: true, + is_disabled: false, + max_parental_rating: None, + max_parental_sub_rating: None, + blocked_tags: Some(vec![]), + allowed_tags: Some(vec![]), + enable_user_preference_access: true, + access_schedules: Some(vec![]), + block_unrated_items: Some(vec![]), + enable_remote_control_of_other_users: false, + enable_shared_device_control: false, + enable_remote_access: true, + enable_live_tv_management: false, + enable_live_tv_access: false, + enable_media_playback: true, + enable_audio_playback_transcoding: false, + enable_video_playback_transcoding: false, + enable_playback_remuxing: false, + force_remote_source_transcoding: false, + enable_content_deletion: false, + enable_content_deletion_from_folders: Some(vec![]), + enable_content_downloading: true, + enable_sync_transcoding: false, + enable_media_conversion: false, + enabled_devices: Some(vec![]), + enable_all_devices: true, + enabled_channels: Some(vec![]), + enable_all_channels: true, + enabled_folders: Some(vec![]), + enable_all_folders: true, + invalid_login_attempt_count: 0, + login_attempts_before_lockout: -1, + max_active_sessions: 0, + enable_public_sharing: true, + blocked_media_folders: Some(vec![]), + blocked_channels: Some(vec![]), + remote_client_bitrate_limit: 0, + authentication_provider_id: + "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider".to_string(), + password_reset_provider_id: + "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider".to_string(), + sync_play_access: "CreateAndJoinGroups", + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct UserDto { + pub name: Option, + pub server_id: Option, + pub server_name: Option, + pub id: String, + pub primary_image_tag: Option, + pub has_password: Option, + pub has_configured_password: Option, + pub has_configured_easy_password: Option, + pub enable_auto_login: Option, + pub last_login_date: Option, + pub last_activity_date: Option, + pub configuration: Option, + pub policy: Option, + pub primary_image_aspect_ratio: Option, +} + +// ── Authentication / session ──────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct AuthenticationResult { + pub user: Option, + pub session_info: Option, + pub access_token: Option, + pub server_id: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct SessionInfoDto { + pub play_state: Option, + pub additional_users: Option>, + pub capabilities: Option, + pub remote_end_point: Option, + pub playable_media_types: Vec, + pub id: Option, + pub user_id: String, + pub user_name: Option, + pub client: Option, + pub last_activity_date: String, + pub last_playback_check_in: String, + pub last_paused_date: Option, + pub device_name: Option, + pub device_type: Option, + pub now_playing_item: Option, + pub now_viewing_item: Option, + pub device_id: Option, + pub application_version: Option, + pub transcoding_info: Option, + pub is_active: bool, + pub supports_media_control: bool, + pub supports_remote_control: bool, + pub now_playing_queue: Option>, + pub has_custom_device_name: bool, + pub playlist_item_id: Option, + pub server_id: Option, + pub user_primary_image_tag: Option, + pub supported_commands: Vec, +} + +// ── BaseItem + helpers ────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct UserItemDataDto { + pub rating: Option, + pub played_percentage: Option, + pub unplayed_item_count: Option, + pub playback_position_ticks: i64, + pub play_count: i32, + pub is_favorite: bool, + pub likes: Option, + pub last_played_date: Option, + pub played: bool, + pub key: String, + pub item_id: String, +} + +#[derive(Debug, Serialize, Default, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct ImageBlurHashes {} + +#[derive(Debug, Serialize, Default, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct ImageTags { + #[serde(skip_serializing_if = "Option::is_none")] + pub primary: Option, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct NameGuidPair { + pub name: Option, + pub id: String, +} + +#[derive(Debug, Serialize, Default, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct MediaStream { + pub codec: Option, + pub language: Option, + pub title: Option, + pub video_range: &'static str, + pub video_range_type: &'static str, + pub audio_spatial_format: &'static str, + pub display_title: Option, + pub is_interlaced: bool, + pub channel_layout: Option, + pub bit_rate: Option, + pub channels: Option, + pub sample_rate: Option, + pub is_default: bool, + pub is_forced: bool, + pub is_hearing_impaired: bool, + pub is_original: bool, + pub height: Option, + pub width: Option, + pub profile: Option, + #[serde(rename = "Type")] + pub stream_type: &'static str, + pub index: i32, + pub is_external: bool, + pub is_text_subtitle_stream: bool, + pub supports_external_stream: bool, +} + +#[derive(Debug, Serialize, Default, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct MediaSource { + pub protocol: &'static str, + pub id: Option, + pub path: Option, + #[serde(rename = "Type")] + pub source_type: &'static str, + pub container: Option, + pub size: Option, + pub name: Option, + pub is_remote: bool, + pub run_time_ticks: Option, + 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 media_streams: Option>, + pub formats: Option>, + pub bitrate: Option, + pub transcoding_sub_protocol: &'static str, + pub default_audio_stream_index: Option, + pub default_subtitle_stream_index: Option, + pub has_segments: bool, +} + +#[derive(Debug, Serialize, Default)] +#[serde(rename_all = "PascalCase")] +pub struct BaseItemDto { + pub name: Option, + pub server_id: Option, + pub id: String, + pub etag: Option, + pub date_created: Option, + pub container: Option, + pub sort_name: Option, + pub premiere_date: Option, + pub media_sources: Option>, + pub path: Option, + pub overview: Option, + pub genres: Option>, + pub run_time_ticks: Option, + pub production_year: Option, + pub index_number: Option, + pub parent_index_number: Option, + pub is_folder: Option, + pub parent_id: Option, + #[serde(rename = "Type")] + pub item_type: &'static str, + pub artist_items: Option>, + pub user_data: Option, + pub child_count: Option, + pub artists: Option>, + pub album: Option, + pub collection_type: Option<&'static str>, + pub album_id: Option, + pub album_primary_image_tag: Option, + pub album_artist: Option, + pub album_artists: Option>, + pub media_streams: Option>, + pub media_source_count: Option, + pub image_tags: Option, + pub backdrop_image_tags: Option>, + pub image_blur_hashes: Option, + pub location_type: Option<&'static str>, + pub media_type: &'static str, + pub song_count: Option, + pub album_count: Option, + pub artist_count: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ItemsResult { + pub items: Vec, + pub total_record_count: i32, + pub start_index: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ViewsResult { + pub items: Vec, + pub total_record_count: i32, + pub start_index: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct PlaybackInfoResponse { + pub media_sources: Vec, + pub play_session_id: Option, +} diff --git a/crates/jellyfin/src/server/handlers.rs b/crates/jellyfin/src/server/handlers.rs new file mode 100644 index 0000000000..9f2288e3f3 --- /dev/null +++ b/crates/jellyfin/src/server/handlers.rs @@ -0,0 +1,1456 @@ +//! Jellyfin endpoint handlers. Audio-only — this server is built on top of +//! rockbox-library's track/album/artist tables. + +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::Utc; +use rockbox_library::entity::{album::Album, artist::Artist, track::Track}; +use rockbox_library::repo; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::PathBuf; + +use super::auth::{self, AuthedUser, EmbyAuth}; +use super::dto::{ + AuthenticationResult, BaseItemDto, ImageBlurHashes, ImageTags, ItemsResult, MediaSource, + MediaStream, NameGuidPair, PlaybackInfoResponse, PublicSystemInfo, SessionInfoDto, SystemInfo, + UserConfiguration, UserDto, UserItemDataDto, UserPolicy, ViewsResult, JELLYFIN_API_VERSION, +}; +use super::mapping; +use super::JellyfinState; + +const TICKS_PER_MS: i64 = 10_000; +const TICKS_PER_SEC: i64 = TICKS_PER_MS * 1000; + +fn now_iso() -> String { + let now = Utc::now(); + let ticks = now.timestamp_subsec_nanos() / 100; + format!("{}.{ticks:07}", now.format("%Y-%m-%dT%H:%M:%S")) +} + +fn parse_auth(req: &HttpRequest) -> EmbyAuth { + for name in ["x-emby-authorization", "authorization"] { + if let Some(v) = req.headers().get(name) { + if let Ok(s) = v.to_str() { + return auth::parse_emby_auth_header(s); + } + } + } + EmbyAuth::default() +} + +fn server_base(state: &JellyfinState, req: &HttpRequest) -> String { + if let Some(h) = req.headers().get("host").and_then(|v| v.to_str().ok()) { + let scheme = req.connection_info().scheme().to_string(); + return format!("{scheme}://{h}"); + } + format!("http://0.0.0.0:{}", state.port) +} + +// ── Root index ──────────────────────────────────────────────────────────────── + +pub async fn index(state: web::Data) -> HttpResponse { + HttpResponse::Ok() + .content_type("text/plain; charset=utf-8") + .body(format!( + "rockbox-jellyfin — Jellyfin-compatible API\nserver: {} ({})\n", + state.server_name, state.server_id + )) +} + +// ── System ─────────────────────────────────────────────────────────────────── + +fn jellyfin_os_name() -> &'static str { + match std::env::consts::OS { + "macos" => "Darwin", + "linux" => "Linux", + "windows" => "Windows", + "freebsd" => "FreeBSD", + "openbsd" => "OpenBSD", + "netbsd" => "NetBSD", + "android" => "Android", + "ios" => "iOS", + other => other, + } +} + +fn public_info(state: &JellyfinState, req: &HttpRequest) -> PublicSystemInfo { + PublicSystemInfo { + local_address: Some(server_base(state, req)), + server_name: Some(state.server_name.clone()), + version: Some(JELLYFIN_API_VERSION.to_string()), + product_name: Some("Jellyfin Server".to_string()), + operating_system: Some(jellyfin_os_name().to_string()), + id: Some(state.server_id.clone()), + startup_wizard_completed: Some(true), + } +} + +pub async fn system_info_public(state: web::Data, req: HttpRequest) -> HttpResponse { + HttpResponse::Ok().json(public_info(&state, &req)) +} + +pub async fn system_info( + _user: AuthedUser, + state: web::Data, + req: HttpRequest, +) -> HttpResponse { + let pub_info = public_info(&state, &req); + HttpResponse::Ok().json(SystemInfo { + local_address: pub_info.local_address, + server_name: pub_info.server_name, + version: pub_info.version, + product_name: pub_info.product_name, + operating_system: pub_info.operating_system, + id: pub_info.id, + startup_wizard_completed: pub_info.startup_wizard_completed, + operating_system_display_name: Some(jellyfin_os_name().to_string()), + package_name: Some("rockbox".to_string()), + has_pending_restart: false, + is_shutting_down: false, + supports_library_monitor: true, + web_socket_port_number: state.port as i32, + completed_installations: Some(vec![]), + can_self_restart: false, + can_launch_web_browser: false, + program_data_path: Some(String::new()), + web_path: Some(String::new()), + items_by_name_path: Some(String::new()), + cache_path: Some(String::new()), + log_path: Some(String::new()), + internal_metadata_path: Some(String::new()), + transcoding_temp_path: Some(String::new()), + cast_receiver_applications: Some(vec![]), + has_update_available: false, + encoder_location: Some("Default".to_string()), + system_architecture: Some(std::env::consts::ARCH.to_string()), + }) +} + +pub async fn system_endpoint(_user: AuthedUser, req: HttpRequest) -> HttpResponse { + let info = req.connection_info(); + let addr = info.realip_remote_addr().unwrap_or(""); + HttpResponse::Ok().json(json!({ + "IsLocal": true, + "IsInNetwork": true, + "RemoteAddress": addr, + })) +} + +// ── Users / Auth ───────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct AuthenticateBody { + pub username: Option, + pub pw: Option, + pub password: Option, +} + +fn build_user(state: &JellyfinState) -> UserDto { + UserDto { + name: Some(state.username.as_str().to_string()), + server_id: Some(state.server_id.clone()), + server_name: Some(state.server_name.clone()), + id: state.user_id.as_str().to_string(), + primary_image_tag: None, + primary_image_aspect_ratio: None, + has_password: Some(true), + has_configured_password: Some(true), + has_configured_easy_password: Some(false), + enable_auto_login: Some(false), + last_login_date: Some(now_iso()), + last_activity_date: Some(now_iso()), + configuration: Some(UserConfiguration::default()), + policy: Some(UserPolicy::admin()), + } +} + +pub async fn authenticate_by_name( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> HttpResponse { + let body = body.into_inner(); + let username = body.username.unwrap_or_default(); + let password = body.pw.or(body.password).unwrap_or_default(); + + if username != *state.username || password != *state.password { + return HttpResponse::Unauthorized() + .json(json!({"Message": "Invalid username or password"})); + } + + let parsed = parse_auth(&req); + let token = auth::random_hex(16); + let now = now_iso(); + if let Err(e) = + auth::store_token(&state.pool, &token, state.user_id.as_str(), &parsed, &now).await + { + tracing::error!("jellyfin: store_token: {e}"); + return HttpResponse::InternalServerError().finish(); + } + + let user = build_user(&state); + let session = SessionInfoDto { + play_state: None, + additional_users: Some(vec![]), + capabilities: None, + remote_end_point: Some( + req.connection_info() + .realip_remote_addr() + .unwrap_or("") + .to_string(), + ), + playable_media_types: vec!["Audio".into()], + id: Some(auth::random_hex(16)), + user_id: user.id.clone(), + user_name: user.name.clone(), + client: parsed.client.clone(), + last_activity_date: now.clone(), + last_playback_check_in: now.clone(), + last_paused_date: None, + device_name: parsed.device.clone(), + device_type: None, + now_playing_item: None, + now_viewing_item: None, + device_id: parsed.device_id.clone(), + application_version: parsed.version.clone(), + transcoding_info: None, + is_active: true, + supports_media_control: false, + supports_remote_control: false, + now_playing_queue: Some(vec![]), + has_custom_device_name: false, + playlist_item_id: None, + server_id: Some(state.server_id.clone()), + user_primary_image_tag: None, + supported_commands: vec![], + }; + + HttpResponse::Ok().json(AuthenticationResult { + user: Some(user), + session_info: Some(session), + access_token: Some(token), + server_id: Some(state.server_id.clone()), + }) +} + +pub async fn users_public(state: web::Data) -> HttpResponse { + HttpResponse::Ok().json(vec![build_user(&state)]) +} + +pub async fn users_list(_user: AuthedUser, state: web::Data) -> HttpResponse { + HttpResponse::Ok().json(vec![build_user(&state)]) +} + +pub async fn users_me(_user: AuthedUser, state: web::Data) -> HttpResponse { + HttpResponse::Ok().json(build_user(&state)) +} + +pub async fn user_by_id( + _user: AuthedUser, + state: web::Data, + _path: web::Path, +) -> HttpResponse { + HttpResponse::Ok().json(build_user(&state)) +} + +// ── Views (top-level library list) ─────────────────────────────────────────── + +fn music_library_view(state: &JellyfinState) -> BaseItemDto { + BaseItemDto { + id: mapping::library_guid(), + server_id: Some(state.server_id.clone()), + name: Some("Music".to_string()), + item_type: "CollectionFolder", + media_type: "Unknown", + is_folder: Some(true), + collection_type: Some("music"), + location_type: Some("FileSystem"), + ..Default::default() + } +} + +fn all_library_views(state: &JellyfinState) -> Vec { + vec![music_library_view(state)] +} + +pub async fn user_views( + _user: AuthedUser, + state: web::Data, + _path: web::Path, +) -> HttpResponse { + let views = all_library_views(&state); + let total = views.len() as i32; + HttpResponse::Ok().json(ViewsResult { + items: views, + total_record_count: total, + start_index: 0, + }) +} + +pub async fn user_views_query(_user: AuthedUser, state: web::Data) -> HttpResponse { + let views = all_library_views(&state); + let total = views.len() as i32; + HttpResponse::Ok().json(ViewsResult { + items: views, + total_record_count: total, + start_index: 0, + }) +} + +pub async fn media_folders(_user: AuthedUser, state: web::Data) -> HttpResponse { + let views = all_library_views(&state); + let total = views.len() as i32; + HttpResponse::Ok().json(ViewsResult { + items: views, + total_record_count: total, + start_index: 0, + }) +} + +pub async fn library_virtual_folders( + _user: AuthedUser, + state: web::Data, +) -> HttpResponse { + HttpResponse::Ok().json(vec![json!({ + "Name": "Music", + "Locations": [state.music_dir.to_string_lossy()], + "CollectionType": "music", + "ItemId": mapping::library_guid(), + })]) +} + +// ── Items query (custom parser — handles repeated keys + Pascal/camelCase) ── + +#[derive(Debug, Default)] +#[allow(dead_code)] +pub struct ItemsQuery { + pub parent_id: Option, + pub include_item_types: Option, + pub recursive: Option, + pub search_term: Option, + pub ids: Option, + pub album_artist_ids: Option, + pub artist_ids: Option, + pub album_ids: Option, + pub start_index: Option, + pub limit: Option, + pub sort_by: Option, + pub sort_order: Option, + pub user_id: Option, + pub enable_user_data: Option, + pub enable_total_record_count: Option, + pub enable_images: Option, + pub fields: Option, +} + +fn collect_query(req: &HttpRequest) -> std::collections::HashMap> { + let mut out: std::collections::HashMap> = std::collections::HashMap::new(); + for pair in req.query_string().split('&').filter(|s| !s.is_empty()) { + let mut it = pair.splitn(2, '='); + let key = it.next().unwrap_or(""); + let val = it.next().unwrap_or(""); + let decoded = urlencoding::decode(val) + .map(|s| s.into_owned()) + .unwrap_or_else(|_| val.to_string()); + out.entry(key.to_string()).or_default().push(decoded); + } + out +} + +fn parse_items_query(req: &HttpRequest) -> ItemsQuery { + let q = collect_query(req); + let one = |k: &str| q.get(k).and_then(|v| v.first()).cloned(); + let csv = |k: &str| q.get(k).map(|v| v.join(",")); + let parse_bool = |k: &str| one(k).and_then(|s| s.parse::().ok()); + let parse_i64 = |k: &str| one(k).and_then(|s| s.parse::().ok()); + ItemsQuery { + parent_id: one("parentId").or_else(|| one("ParentId")), + include_item_types: csv("includeItemTypes").or_else(|| csv("IncludeItemTypes")), + recursive: parse_bool("recursive"), + search_term: one("searchTerm").or_else(|| one("SearchTerm")), + ids: csv("ids").or_else(|| csv("Ids")), + album_artist_ids: csv("albumArtistIds").or_else(|| csv("AlbumArtistIds")), + artist_ids: csv("artistIds").or_else(|| csv("ArtistIds")), + album_ids: csv("albumIds").or_else(|| csv("AlbumIds")), + start_index: parse_i64("startIndex").or_else(|| parse_i64("StartIndex")), + limit: parse_i64("limit").or_else(|| parse_i64("Limit")), + sort_by: one("sortBy").or_else(|| one("SortBy")), + sort_order: one("sortOrder").or_else(|| one("SortOrder")), + user_id: one("userId").or_else(|| one("UserId")), + enable_user_data: parse_bool("enableUserData"), + enable_total_record_count: parse_bool("enableTotalRecordCount"), + enable_images: parse_bool("enableImages"), + fields: csv("fields").or_else(|| csv("Fields")), + } +} + +// ── Track/album/artist → BaseItemDto ──────────────────────────────────────── + +async fn artist_to_dto(state: &JellyfinState, a: &Artist) -> BaseItemDto { + let id = mapping::remember_artist(&state.pool, a) + .await + .unwrap_or_else(|_| mapping::guid(mapping::KIND_ARTIST, &a.id)); + BaseItemDto { + id, + server_id: Some(state.server_id.clone()), + name: Some(a.name.clone()), + item_type: "MusicArtist", + media_type: "Unknown", + is_folder: Some(true), + sort_name: Some(a.name.clone()), + location_type: Some("FileSystem"), + image_tags: Some(ImageTags { + primary: a.image.clone().map(|_| a.id.clone()), + }), + ..Default::default() + } +} + +async fn album_to_dto(state: &JellyfinState, al: &Album) -> BaseItemDto { + let id = mapping::remember_album(&state.pool, al) + .await + .unwrap_or_else(|_| mapping::guid(mapping::KIND_ALBUM, &al.id)); + let artist_guid = mapping::guid(mapping::KIND_ARTIST, &al.artist_id); + let tracks = repo::album_tracks::find_by_album(state.pool.clone(), &al.id) + .await + .unwrap_or_default(); + let song_count = tracks.len() as i32; + let duration_ms: i64 = tracks.iter().map(|t| t.length as i64).sum(); + BaseItemDto { + id: id.clone(), + server_id: Some(state.server_id.clone()), + name: Some(al.title.clone()), + item_type: "MusicAlbum", + media_type: "Unknown", + is_folder: Some(true), + production_year: if al.year > 0 { + Some(al.year as i32) + } else { + None + }, + premiere_date: if al.year > 0 { + Some(format!("{:04}-01-01T00:00:00.0000000", al.year)) + } else { + None + }, + album: Some(al.title.clone()), + album_id: Some(id), + album_artist: Some(al.artist.clone()), + album_artists: Some(vec![NameGuidPair { + name: Some(al.artist.clone()), + id: artist_guid.clone(), + }]), + artist_items: Some(vec![NameGuidPair { + name: Some(al.artist.clone()), + id: artist_guid.clone(), + }]), + artists: Some(vec![al.artist.clone()]), + parent_id: Some(artist_guid), + sort_name: Some(al.title.clone()), + run_time_ticks: Some(duration_ms * TICKS_PER_MS), + song_count: Some(song_count), + child_count: Some(song_count), + location_type: Some("FileSystem"), + image_tags: Some(ImageTags { + primary: al.album_art.clone().map(|_| al.id.clone()), + }), + ..Default::default() + } +} + +async fn track_to_dto(state: &JellyfinState, t: &Track) -> BaseItemDto { + let id = mapping::remember_track(&state.pool, t) + .await + .unwrap_or_else(|_| mapping::guid(mapping::KIND_TRACK, &t.id)); + let album_guid = mapping::guid(mapping::KIND_ALBUM, &t.album_id); + let artist_guid = mapping::guid(mapping::KIND_ARTIST, &t.artist_id); + // rockbox-library stores length in milliseconds. + let run_time_ticks = (t.length as i64) * TICKS_PER_MS; + let container = std::path::Path::new(&t.path) + .extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_ascii_lowercase()) + .unwrap_or_else(|| "mp3".to_string()); + + let audio_stream = MediaStream { + codec: Some(container.clone()), + stream_type: "Audio", + index: 0, + is_default: true, + channels: Some(2), + sample_rate: Some(t.frequency as i32), + bit_rate: Some((t.bitrate as i32) * 1000), + video_range: "Unknown", + video_range_type: "Unknown", + audio_spatial_format: "None", + is_interlaced: false, + is_forced: false, + is_hearing_impaired: false, + is_original: false, + is_external: false, + is_text_subtitle_stream: false, + supports_external_stream: false, + ..Default::default() + }; + + let media_source = MediaSource { + protocol: "File", + id: Some(id.clone()), + path: Some(t.path.clone()), + source_type: "Default", + container: Some(container.clone()), + size: Some(t.filesize as i64), + name: Some(t.title.clone()), + is_remote: false, + run_time_ticks: Some(run_time_ticks), + read_at_native_framerate: false, + ignore_dts: false, + ignore_index: false, + gen_pts_input: false, + supports_transcoding: false, + 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: false, + media_streams: Some(vec![audio_stream.clone()]), + formats: None, + bitrate: Some((t.bitrate as i32) * 1000), + transcoding_sub_protocol: "http", + default_audio_stream_index: Some(0), + default_subtitle_stream_index: None, + has_segments: false, + }; + + BaseItemDto { + id: id.clone(), + server_id: Some(state.server_id.clone()), + name: Some(t.title.clone()), + item_type: "Audio", + media_type: "Audio", + is_folder: Some(false), + production_year: t.year.map(|y| y as i32), + index_number: t.track_number.map(|n| n as i32), + parent_index_number: Some(t.disc_number as i32), + run_time_ticks: Some(run_time_ticks), + container: Some(container), + path: Some(t.path.clone()), + album: Some(t.album.clone()), + album_id: Some(album_guid.clone()), + album_artist: Some(t.album_artist.clone()), + album_artists: Some(vec![NameGuidPair { + name: Some(t.album_artist.clone()), + id: artist_guid.clone(), + }]), + artist_items: Some(vec![NameGuidPair { + name: Some(t.artist.clone()), + id: artist_guid.clone(), + }]), + artists: Some(vec![t.artist.clone()]), + parent_id: Some(album_guid), + genres: t.genre.as_ref().map(|g| vec![g.clone()]), + location_type: Some("FileSystem"), + media_sources: Some(vec![media_source.clone()]), + media_source_count: Some(1), + media_streams: Some(vec![audio_stream]), + image_tags: Some(ImageTags { + primary: t.album_art.clone().map(|_| t.album_id.clone()), + }), + image_blur_hashes: Some(ImageBlurHashes::default()), + user_data: Some(UserItemDataDto { + rating: None, + played_percentage: None, + unplayed_item_count: None, + playback_position_ticks: 0, + play_count: 0, + is_favorite: false, + likes: None, + last_played_date: None, + played: false, + key: id.clone(), + item_id: id, + }), + ..Default::default() + } +} + +fn includes(types_csv: &Option, want: &str) -> bool { + match types_csv { + None => false, + Some(s) => s.split(',').any(|p| p.trim().eq_ignore_ascii_case(want)), + } +} + +async fn resolve_native(state: &JellyfinState, guid: &str) -> Option<(String, String)> { + mapping::lookup(&state.pool, guid).await.ok().flatten() +} + +// ── Items endpoints ───────────────────────────────────────────────────────── + +pub async fn items( + _user: AuthedUser, + state: web::Data, + req: HttpRequest, +) -> HttpResponse { + items_impl(state, parse_items_query(&req)).await +} + +pub async fn user_items( + _user: AuthedUser, + state: web::Data, + _path: web::Path, + req: HttpRequest, +) -> HttpResponse { + items_impl(state, parse_items_query(&req)).await +} + +async fn items_impl(state: web::Data, q: ItemsQuery) -> HttpResponse { + let library_id = mapping::library_guid(); + + // `/Items?userId=X` with no filters = list libraries. + if q.parent_id.is_none() + && q.ids.is_none() + && q.search_term.is_none() + && q.include_item_types.is_none() + && q.album_artist_ids.is_none() + && q.artist_ids.is_none() + && q.album_ids.is_none() + { + let views = all_library_views(&state); + let total = views.len() as i32; + return HttpResponse::Ok().json(ItemsResult { + items: views, + total_record_count: total, + start_index: 0, + }); + } + + // Free-text search. + if let Some(term) = q.search_term.as_deref().filter(|s| !s.is_empty()) { + let limit = q.limit.unwrap_or(50).max(1); + let no_filter = q.include_item_types.is_none(); + let pattern = like_pattern(term); + let mut dtos: Vec = Vec::new(); + if no_filter || includes(&q.include_item_types, "MusicArtist") { + let where_clause = ("name LIKE ?1".to_string(), vec![pattern.clone()]); + if let Ok(rows) = repo::artist::filter(state.pool.clone(), where_clause).await { + for a in rows.into_iter().take(limit as usize) { + dtos.push(artist_to_dto(&state, &a).await); + } + } + } + if no_filter || includes(&q.include_item_types, "MusicAlbum") { + let where_clause = ("title LIKE ?1".to_string(), vec![pattern.clone()]); + if let Ok(rows) = repo::album::filter(state.pool.clone(), where_clause).await { + for a in rows.into_iter().take(limit as usize) { + dtos.push(album_to_dto(&state, &a).await); + } + } + } + if no_filter || includes(&q.include_item_types, "Audio") { + let where_clause = ("title LIKE ?1".to_string(), vec![pattern]); + if let Ok(rows) = repo::track::filter(state.pool.clone(), where_clause).await { + for t in rows.into_iter().take(limit as usize) { + dtos.push(track_to_dto(&state, &t).await); + } + } + } + let total = dtos.len() as i32; + return HttpResponse::Ok().json(ItemsResult { + items: dtos, + total_record_count: total, + start_index: 0, + }); + } + + // Explicit Ids= lookup. + if let Some(ids) = q.ids.as_ref() { + let mut out: Vec = Vec::new(); + for raw in ids.split(',') { + let g = mapping::normalize_guid(raw.trim()); + if let Some((kind, native)) = resolve_native(&state, &g).await { + match kind.as_str() { + "track" => { + if let Ok(Some(t)) = repo::track::find(state.pool.clone(), &native).await { + out.push(track_to_dto(&state, &t).await); + } + } + "album" => { + if let Ok(Some(a)) = repo::album::find(state.pool.clone(), &native).await { + out.push(album_to_dto(&state, &a).await); + } + } + "artist" => { + if let Ok(Some(a)) = repo::artist::find(state.pool.clone(), &native).await { + out.push(artist_to_dto(&state, &a).await); + } + } + _ => {} + } + } + } + let total = out.len() as i32; + return HttpResponse::Ok().json(ItemsResult { + items: out, + total_record_count: total, + start_index: 0, + }); + } + + // Filter by parent — artist parent → albums; album parent → tracks. + if let Some(parent) = q.parent_id.as_ref() { + let g = mapping::normalize_guid(parent); + if g == library_id { + return list_artists_or_albums_or_tracks(&state, &q).await; + } + if let Some((kind, native)) = resolve_native(&state, &g).await { + match kind.as_str() { + "artist" => { + let albums = repo::album::find_by_artist(state.pool.clone(), &native) + .await + .unwrap_or_default(); + let mut dtos = Vec::with_capacity(albums.len()); + for a in &albums { + dtos.push(album_to_dto(&state, a).await); + } + let total = dtos.len() as i32; + return HttpResponse::Ok().json(ItemsResult { + items: dtos, + total_record_count: total, + start_index: 0, + }); + } + "album" => { + let tracks = repo::album_tracks::find_by_album(state.pool.clone(), &native) + .await + .unwrap_or_default(); + let mut dtos = Vec::with_capacity(tracks.len()); + for t in &tracks { + dtos.push(track_to_dto(&state, t).await); + } + let total = dtos.len() as i32; + return HttpResponse::Ok().json(ItemsResult { + items: dtos, + total_record_count: total, + start_index: 0, + }); + } + _ => {} + } + } + return HttpResponse::Ok().json(ItemsResult { + items: vec![], + total_record_count: 0, + start_index: 0, + }); + } + + // No parent: filter by artistIds / albumArtistIds. + let artist_filter = q.album_artist_ids.clone().or_else(|| q.artist_ids.clone()); + + if includes(&q.include_item_types, "Audio") { + if let Some(filter) = artist_filter.clone() { + if let Some(first) = filter.split(',').next() { + let g = mapping::normalize_guid(first); + if let Some((kind, native)) = resolve_native(&state, &g).await { + if kind == "artist" { + let tracks = + repo::artist_tracks::find_by_artist(state.pool.clone(), &native) + .await + .unwrap_or_default(); + let mut dtos = Vec::with_capacity(tracks.len()); + for t in &tracks { + dtos.push(track_to_dto(&state, t).await); + } + let total = dtos.len() as i32; + return HttpResponse::Ok().json(ItemsResult { + items: dtos, + total_record_count: total, + start_index: 0, + }); + } + } + } + } + // Plain all-songs. + return list_artists_or_albums_or_tracks(&state, &q).await; + } + + if includes(&q.include_item_types, "MusicAlbum") { + if let Some(filter) = artist_filter { + if let Some(first) = filter.split(',').next() { + let g = mapping::normalize_guid(first); + if let Some((kind, native)) = resolve_native(&state, &g).await { + if kind == "artist" { + let albums = repo::album::find_by_artist(state.pool.clone(), &native) + .await + .unwrap_or_default(); + let mut dtos = Vec::with_capacity(albums.len()); + for a in &albums { + dtos.push(album_to_dto(&state, a).await); + } + let total = dtos.len() as i32; + return HttpResponse::Ok().json(ItemsResult { + items: dtos, + total_record_count: total, + start_index: 0, + }); + } + } + } + } + return list_artists_or_albums_or_tracks(&state, &q).await; + } + + list_artists_or_albums_or_tracks(&state, &q).await +} + +async fn list_artists_or_albums_or_tracks(state: &JellyfinState, q: &ItemsQuery) -> HttpResponse { + let limit = q.limit.unwrap_or(100).max(1) as usize; + let offset = q.start_index.unwrap_or(0).max(0) as usize; + + if includes(&q.include_item_types, "Audio") { + let mut tracks = repo::track::all(state.pool.clone()) + .await + .unwrap_or_default(); + let total = tracks.len() as i32; + if offset < tracks.len() { + tracks = tracks.split_off(offset); + } else { + tracks.clear(); + } + tracks.truncate(limit); + let mut dtos = Vec::with_capacity(tracks.len()); + for t in &tracks { + dtos.push(track_to_dto(state, t).await); + } + return HttpResponse::Ok().json(ItemsResult { + items: dtos, + total_record_count: total, + start_index: offset as i32, + }); + } + if includes(&q.include_item_types, "MusicAlbum") { + let mut albums = repo::album::all(state.pool.clone()) + .await + .unwrap_or_default(); + let total = albums.len() as i32; + if offset < albums.len() { + albums = albums.split_off(offset); + } else { + albums.clear(); + } + albums.truncate(limit); + let mut dtos = Vec::with_capacity(albums.len()); + for a in &albums { + dtos.push(album_to_dto(state, a).await); + } + return HttpResponse::Ok().json(ItemsResult { + items: dtos, + total_record_count: total, + start_index: offset as i32, + }); + } + let artists = repo::artist::all(state.pool.clone()) + .await + .unwrap_or_default(); + let mut dtos = Vec::with_capacity(artists.len()); + for a in &artists { + dtos.push(artist_to_dto(state, a).await); + } + let total = dtos.len() as i32; + HttpResponse::Ok().json(ItemsResult { + items: dtos, + total_record_count: total, + start_index: 0, + }) +} + +fn like_pattern(term: &str) -> String { + let escaped = term.replace('%', "\\%").replace('_', "\\_"); + format!("%{}%", escaped) +} + +pub async fn item_by_id( + _user: AuthedUser, + state: web::Data, + path: web::Path, +) -> HttpResponse { + let g = mapping::normalize_guid(&path.into_inner()); + let Some((kind, native)) = resolve_native(&state, &g).await else { + return HttpResponse::NotFound().finish(); + }; + match kind.as_str() { + "track" => match repo::track::find(state.pool.clone(), &native).await { + Ok(Some(t)) => HttpResponse::Ok().json(track_to_dto(&state, &t).await), + _ => HttpResponse::NotFound().finish(), + }, + "album" => match repo::album::find(state.pool.clone(), &native).await { + Ok(Some(a)) => HttpResponse::Ok().json(album_to_dto(&state, &a).await), + _ => HttpResponse::NotFound().finish(), + }, + "artist" => match repo::artist::find(state.pool.clone(), &native).await { + Ok(Some(a)) => HttpResponse::Ok().json(artist_to_dto(&state, &a).await), + _ => HttpResponse::NotFound().finish(), + }, + _ => HttpResponse::NotFound().finish(), + } +} + +pub async fn user_item_by_id( + _user: AuthedUser, + state: web::Data, + path: web::Path<(String, String)>, +) -> HttpResponse { + let (_user_id, item_id) = path.into_inner(); + let g = mapping::normalize_guid(&item_id); + let Some((kind, native)) = resolve_native(&state, &g).await else { + return HttpResponse::NotFound().finish(); + }; + match kind.as_str() { + "track" => match repo::track::find(state.pool.clone(), &native).await { + Ok(Some(t)) => HttpResponse::Ok().json(track_to_dto(&state, &t).await), + _ => HttpResponse::NotFound().finish(), + }, + "album" => match repo::album::find(state.pool.clone(), &native).await { + Ok(Some(a)) => HttpResponse::Ok().json(album_to_dto(&state, &a).await), + _ => HttpResponse::NotFound().finish(), + }, + "artist" => match repo::artist::find(state.pool.clone(), &native).await { + Ok(Some(a)) => HttpResponse::Ok().json(artist_to_dto(&state, &a).await), + _ => HttpResponse::NotFound().finish(), + }, + _ => HttpResponse::NotFound().finish(), + } +} + +// ── Artists endpoints ─────────────────────────────────────────────────────── + +pub async fn artists( + _user: AuthedUser, + state: web::Data, + _req: HttpRequest, +) -> HttpResponse { + let artists = repo::artist::all(state.pool.clone()) + .await + .unwrap_or_default(); + let mut dtos = Vec::with_capacity(artists.len()); + for a in &artists { + dtos.push(artist_to_dto(&state, a).await); + } + let total = dtos.len() as i32; + HttpResponse::Ok().json(ItemsResult { + items: dtos, + total_record_count: total, + start_index: 0, + }) +} + +pub async fn artist_by_name( + _user: AuthedUser, + state: web::Data, + path: web::Path, +) -> HttpResponse { + let name = path.into_inner(); + if let Ok(Some(a)) = repo::artist::find_by_name(state.pool.clone(), &name).await { + HttpResponse::Ok().json(artist_to_dto(&state, &a).await) + } else { + HttpResponse::NotFound().finish() + } +} + +// ── Images ─────────────────────────────────────────────────────────────────── + +/// Resolve a Jellyfin item GUID to a stored `album_art` / `image` value. +/// The returned string is one of: +/// - a bare filename like `"abc123.jpg"` → lives under `~/.config/rockbox.org/covers/` +/// - an absolute filesystem path starting with `/` +/// - an `http(s)://` URL (artist images sourced from Rocksky) +async fn resolve_image_value(state: &JellyfinState, guid: &str) -> Option { + let g = mapping::normalize_guid(guid); + let (kind, native) = mapping::lookup(&state.pool, &g).await.ok().flatten()?; + match kind.as_str() { + "album" => album_art_value(state, &native).await, + "track" => { + let t = repo::track::find(state.pool.clone(), &native) + .await + .ok() + .flatten()?; + if let Some(art) = t.album_art.clone() { + return Some(art); + } + if !t.album_id.is_empty() { + if let Some(a) = album_art_value(state, &t.album_id).await { + return Some(a); + } + } + None + } + "artist" => repo::artist::find(state.pool.clone(), &native) + .await + .ok() + .flatten() + .and_then(|a| a.image), + _ => None, + } +} + +/// Find album art either on the album row directly, or on any of its tracks. +async fn album_art_value(state: &JellyfinState, album_id: &str) -> Option { + if let Ok(Some(album)) = repo::album::find(state.pool.clone(), album_id).await { + if let Some(art) = album.album_art { + return Some(art); + } + } + repo::album_tracks::find_by_album(state.pool.clone(), album_id) + .await + .ok()? + .into_iter() + .find_map(|t| t.album_art) +} + +pub async fn item_image( + state: web::Data, + path: web::Path<(String, String)>, +) -> HttpResponse { + let (item_guid, _kind) = path.into_inner(); + serve_image(&state, &item_guid).await +} + +pub async fn item_image_by_index( + state: web::Data, + path: web::Path<(String, String, i32)>, +) -> HttpResponse { + let (item_guid, _kind, _idx) = path.into_inner(); + serve_image(&state, &item_guid).await +} + +async fn serve_image(state: &JellyfinState, item_guid: &str) -> HttpResponse { + let Some(art) = resolve_image_value(state, item_guid).await else { + return HttpResponse::NotFound().finish(); + }; + serve_art_value(&art).await +} + +async fn serve_art_value(art: &str) -> HttpResponse { + // Remote artist images (Rocksky) — proxy via reqwest. + if art.starts_with("http://") || art.starts_with("https://") { + match reqwest::get(art).await { + Ok(resp) => { + let mime = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("image/jpeg") + .to_string(); + match resp.bytes().await { + Ok(data) => HttpResponse::Ok().content_type(mime).body(data.to_vec()), + Err(_) => HttpResponse::NotFound().finish(), + } + } + Err(e) => { + tracing::warn!("jellyfin image proxy {art}: {e}"); + HttpResponse::NotFound().finish() + } + } + } else { + // Bare filename → resolve under ~/.config/rockbox.org/covers/. + // Absolute path → use as-is. + let full = if art.starts_with('/') { + art.to_string() + } else { + let home = std::env::var("HOME").unwrap_or_default(); + format!("{}/.config/rockbox.org/covers/{}", home, art) + }; + match std::fs::read(&full) { + Ok(data) => { + let mime = mime_guess::from_path(&full) + .first_or_octet_stream() + .to_string(); + HttpResponse::Ok().content_type(mime).body(data) + } + Err(e) => { + tracing::warn!("jellyfin image read {full}: {e}"); + HttpResponse::NotFound().finish() + } + } + } +} + +// ── PlaybackInfo + stream ─────────────────────────────────────────────────── + +pub async fn playback_info( + _user: AuthedUser, + state: web::Data, + path: web::Path, +) -> HttpResponse { + let g = mapping::normalize_guid(&path.into_inner()); + let Some((kind, native)) = resolve_native(&state, &g).await else { + return HttpResponse::NotFound().finish(); + }; + if kind != "track" { + return HttpResponse::BadRequest().finish(); + } + let Ok(Some(t)) = repo::track::find(state.pool.clone(), &native).await else { + return HttpResponse::NotFound().finish(); + }; + let dto = track_to_dto(&state, &t).await; + HttpResponse::Ok().json(PlaybackInfoResponse { + media_sources: dto.media_sources.unwrap_or_default(), + play_session_id: Some(auth::random_hex(8)), + }) +} + +fn content_type_for(container: &str) -> &'static str { + match container { + "mp3" => "audio/mpeg", + "flac" => "audio/flac", + "ogg" | "oga" => "audio/ogg", + "opus" => "audio/opus", + "m4a" | "aac" | "mp4" => "audio/mp4", + "wav" => "audio/wav", + _ => "application/octet-stream", + } +} + +async fn audio_by_guid(state: &JellyfinState, guid: &str, req: &HttpRequest) -> HttpResponse { + let token = auth::extract_token(req); + let authorized = match token { + Some(t) => auth::token_valid(&state.pool, &t).await, + None => false, + }; + if !authorized { + return HttpResponse::Unauthorized().finish(); + } + let g = mapping::normalize_guid(guid); + let Some((kind, native)) = resolve_native(state, &g).await else { + return HttpResponse::NotFound().finish(); + }; + if kind != "track" { + return HttpResponse::BadRequest().finish(); + } + let Ok(Some(t)) = repo::track::find(state.pool.clone(), &native).await else { + return HttpResponse::NotFound().finish(); + }; + let container = std::path::Path::new(&t.path) + .extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_ascii_lowercase()) + .unwrap_or_else(|| "mp3".to_string()); + serve_file(&t.path, content_type_for(&container), req) +} + +pub async fn audio_stream( + state: web::Data, + path: web::Path, + req: HttpRequest, +) -> HttpResponse { + audio_by_guid(&state, &path.into_inner(), &req).await +} + +pub async fn audio_stream_ext( + state: web::Data, + path: web::Path<(String, String)>, + req: HttpRequest, +) -> HttpResponse { + let (id, _ext) = path.into_inner(); + audio_by_guid(&state, &id, &req).await +} + +pub async fn audio_universal( + state: web::Data, + path: web::Path, + req: HttpRequest, +) -> HttpResponse { + audio_by_guid(&state, &path.into_inner(), &req).await +} + +pub async fn item_file_stream( + state: web::Data, + path: web::Path, + req: HttpRequest, +) -> HttpResponse { + audio_by_guid(&state, &path.into_inner(), &req).await +} + +fn serve_file(path_str: &str, content_type: &str, req: &HttpRequest) -> HttpResponse { + let path = PathBuf::from(path_str); + let file_size = match std::fs::metadata(&path) { + Ok(m) => m.len(), + Err(e) => { + tracing::error!("jellyfin stream stat {path_str}: {e}"); + return HttpResponse::InternalServerError().finish(); + } + }; + + if let Some(range_hdr) = req.headers().get(actix_web::http::header::RANGE) { + if let Ok(range_str) = range_hdr.to_str() { + if let Some(range) = range_str.strip_prefix("bytes=") { + let mut parts = range.splitn(2, '-'); + let start: u64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + let end: u64 = parts + .next() + .filter(|s| !s.is_empty()) + .and_then(|s| s.parse().ok()) + .unwrap_or(file_size.saturating_sub(1)) + .min(file_size.saturating_sub(1)); + if start <= end && file_size > 0 { + use std::io::{Read, Seek, SeekFrom}; + return match std::fs::File::open(&path) { + Ok(mut file) => { + let _ = file.seek(SeekFrom::Start(start)); + let length = (end - start + 1) as usize; + let mut buf = vec![0u8; length]; + let n = file.read(&mut buf).unwrap_or(0); + buf.truncate(n); + let actual_end = start + n as u64 - 1; + HttpResponse::PartialContent() + .content_type(content_type.to_string()) + .insert_header(("Accept-Ranges", "bytes")) + .insert_header(("Content-Length", n.to_string())) + .insert_header(( + "Content-Range", + format!("bytes {start}-{actual_end}/{file_size}"), + )) + .body(buf) + } + Err(e) => { + tracing::error!("jellyfin stream open {path_str}: {e}"); + HttpResponse::InternalServerError().finish() + } + }; + } + } + } + } + + match std::fs::read(&path) { + Ok(data) => HttpResponse::Ok() + .content_type(content_type.to_string()) + .insert_header(("Accept-Ranges", "bytes")) + .insert_header(("Content-Length", file_size.to_string())) + .body(data), + Err(e) => { + tracing::error!("jellyfin stream read {path_str}: {e}"); + HttpResponse::InternalServerError().finish() + } + } +} + +// ── Sessions / scrobble ────────────────────────────────────────────────────── + +pub async fn sessions_capabilities(_user: AuthedUser) -> HttpResponse { + HttpResponse::NoContent().finish() +} + +pub async fn sessions_list(_user: AuthedUser) -> HttpResponse { + HttpResponse::Ok().json(Vec::::new()) +} + +pub async fn sessions_playing(_user: AuthedUser, _body: web::Json) -> HttpResponse { + HttpResponse::NoContent().finish() +} + +pub async fn sessions_playing_progress(_user: AuthedUser, _body: web::Json) -> HttpResponse { + HttpResponse::NoContent().finish() +} + +pub async fn sessions_playing_stopped(_user: AuthedUser, _body: web::Json) -> HttpResponse { + HttpResponse::NoContent().finish() +} + +pub async fn user_played_item( + _user: AuthedUser, + _state: web::Data, + _path: web::Path<(String, String)>, +) -> HttpResponse { + HttpResponse::NoContent().finish() +} + +// ── Misc stubs ────────────────────────────────────────────────────────────── + +pub async fn empty_array() -> HttpResponse { + HttpResponse::Ok().json(Vec::::new()) +} + +pub async fn empty_items() -> HttpResponse { + HttpResponse::Ok().json(ItemsResult { + items: vec![], + total_record_count: 0, + start_index: 0, + }) +} + +pub async fn no_content() -> HttpResponse { + HttpResponse::NoContent().finish() +} + +pub async fn branding_config() -> HttpResponse { + HttpResponse::Ok().json(json!({ + "LoginDisclaimer": "", + "CustomCss": "", + "SplashscreenEnabled": false, + })) +} + +pub async fn displaypreferences(_user: AuthedUser) -> HttpResponse { + HttpResponse::Ok().json(json!({ + "Id": "", + "ViewType": "", + "SortBy": "SortName", + "SortOrder": "Ascending", + "RememberIndexing": false, + "PrimaryImageHeight": 250, + "PrimaryImageWidth": 250, + "CustomPrefs": {}, + "ScrollDirection": "Vertical", + "ShowBackdrop": true, + "RememberSorting": false, + "IndexBy": "", + "ShowSidebar": false, + "Client": "" + })) +} + +// ── /Items/Latest ──────────────────────────────────────────────────────────── + +pub async fn items_latest( + _user: AuthedUser, + state: web::Data, + req: HttpRequest, +) -> HttpResponse { + let q = parse_items_query(&req); + let limit = q.limit.unwrap_or(16).max(1) as usize; + let mut albums = repo::album::all(state.pool.clone()) + .await + .unwrap_or_default(); + albums.truncate(limit); + let mut dtos = Vec::with_capacity(albums.len()); + for a in &albums { + dtos.push(album_to_dto(&state, a).await); + } + HttpResponse::Ok().json(dtos) +} + +// ── /Search/Hints ──────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct SearchHintsQuery { + pub search_term: Option, + pub limit: Option, + pub start_index: Option, + pub include_item_types: Option, + pub user_id: Option, +} + +pub async fn search_hints( + _user: AuthedUser, + state: web::Data, + query: web::Query, +) -> HttpResponse { + let q = query.into_inner(); + let Some(term) = q.search_term.as_deref().filter(|s| !s.is_empty()) else { + return HttpResponse::Ok().json(json!({ + "SearchHints": [], + "TotalRecordCount": 0, + })); + }; + let limit = q.limit.unwrap_or(20).max(1); + let pattern = like_pattern(term); + let mut hints: Vec = Vec::new(); + let want_artist = q + .include_item_types + .as_deref() + .map(|s| { + s.split(',') + .any(|t| t.trim().eq_ignore_ascii_case("MusicArtist")) + }) + .unwrap_or(true); + let want_album = q + .include_item_types + .as_deref() + .map(|s| { + s.split(',') + .any(|t| t.trim().eq_ignore_ascii_case("MusicAlbum")) + }) + .unwrap_or(true); + let want_audio = q + .include_item_types + .as_deref() + .map(|s| s.split(',').any(|t| t.trim().eq_ignore_ascii_case("Audio"))) + .unwrap_or(true); + + if want_artist { + let w = ("name LIKE ?1".to_string(), vec![pattern.clone()]); + if let Ok(rows) = repo::artist::filter(state.pool.clone(), w).await { + for a in rows.into_iter().take(limit as usize) { + let id = mapping::remember_artist(&state.pool, &a) + .await + .unwrap_or_else(|_| mapping::guid(mapping::KIND_ARTIST, &a.id)); + hints.push(json!({ + "ItemId": id.clone(), + "Id": id, + "Name": a.name, + "Type": "MusicArtist", + "MediaType": "Unknown", + "IsFolder": true, + })); + } + } + } + if want_album { + let w = ("title LIKE ?1".to_string(), vec![pattern.clone()]); + if let Ok(rows) = repo::album::filter(state.pool.clone(), w).await { + for al in rows.into_iter().take(limit as usize) { + let id = mapping::remember_album(&state.pool, &al) + .await + .unwrap_or_else(|_| mapping::guid(mapping::KIND_ALBUM, &al.id)); + hints.push(json!({ + "ItemId": id.clone(), + "Id": id, + "Name": al.title, + "Album": al.title, + "AlbumArtist": al.artist, + "Type": "MusicAlbum", + "MediaType": "Unknown", + "IsFolder": true, + })); + } + } + } + if want_audio { + let w = ("title LIKE ?1".to_string(), vec![pattern]); + if let Ok(rows) = repo::track::filter(state.pool.clone(), w).await { + for t in rows.into_iter().take(limit as usize) { + let id = mapping::remember_track(&state.pool, &t) + .await + .unwrap_or_else(|_| mapping::guid(mapping::KIND_TRACK, &t.id)); + hints.push(json!({ + "ItemId": id.clone(), + "Id": id, + "Name": t.title, + "Album": t.album, + "AlbumArtist": t.album_artist, + "Type": "Audio", + "MediaType": "Audio", + "IsFolder": false, + "RunTimeTicks": (t.length as i64) * TICKS_PER_MS, + })); + } + } + } + let total = hints.len() as i32; + HttpResponse::Ok().json(json!({ + "SearchHints": hints, + "TotalRecordCount": total, + })) +} + +// keep the warning quiet — TICKS_PER_SEC is reserved for future use. +#[allow(dead_code)] +const _RESERVED: i64 = TICKS_PER_SEC; diff --git a/crates/jellyfin/src/server/mapping.rs b/crates/jellyfin/src/server/mapping.rs new file mode 100644 index 0000000000..d3b180cc5a --- /dev/null +++ b/crates/jellyfin/src/server/mapping.rs @@ -0,0 +1,101 @@ +//! GUID translation between rockbox-library's native string ids and the +//! dashed-UUID format Jellyfin clients require. + +use rockbox_library::entity::{album::Album, artist::Artist, track::Track}; +use sha2::{Digest, Sha256}; +use sqlx::{Pool, Sqlite}; + +pub const KIND_ARTIST: &str = "artist"; +pub const KIND_ALBUM: &str = "album"; +pub const KIND_TRACK: &str = "track"; +pub const KIND_LIBRARY: &str = "library"; +pub const KIND_USER: &str = "user"; + +/// Stable per-(kind, native_id) GUID formatted as a dashed UUID. Jellyfin +/// clients built on the official Kotlin/Java SDKs parse this via +/// `UUID.fromString()`, which rejects un-dashed 32-char hex. +pub fn guid(kind: &str, native_id: &str) -> String { + let mut h = Sha256::new(); + h.update(kind.as_bytes()); + h.update(b":"); + h.update(native_id.as_bytes()); + let digest = h.finalize(); + format_as_uuid(&hex::encode(&digest[..16])) +} + +pub fn guid_dashed(hex32: &str) -> String { + format_as_uuid(hex32) +} + +fn format_as_uuid(hex32: &str) -> String { + if hex32.len() != 32 { + return hex32.to_string(); + } + format!( + "{}-{}-{}-{}-{}", + &hex32[0..8], + &hex32[8..12], + &hex32[12..16], + &hex32[16..20], + &hex32[20..32], + ) +} + +/// Accept either dashed or un-dashed input from clients and return the +/// canonical dashed form we store in `jf_guids`. +pub fn normalize_guid(input: &str) -> String { + let stripped: String = input + .chars() + .filter(|c| *c != '-') + .flat_map(|c| c.to_lowercase()) + .collect(); + if stripped.len() == 32 && stripped.chars().all(|c| c.is_ascii_hexdigit()) { + format_as_uuid(&stripped) + } else { + input.to_ascii_lowercase() + } +} + +pub async fn remember(pool: &Pool, kind: &str, native_id: &str) -> anyhow::Result { + let g = guid(kind, native_id); + sqlx::query( + "INSERT INTO jf_guids (guid, kind, native_id) VALUES (?1, ?2, ?3) + ON CONFLICT(guid) DO NOTHING", + ) + .bind(&g) + .bind(kind) + .bind(native_id) + .execute(pool) + .await?; + Ok(g) +} + +pub async fn lookup(pool: &Pool, guid: &str) -> anyhow::Result> { + let g = normalize_guid(guid); + let row: Option<(String, String)> = + sqlx::query_as("SELECT kind, native_id FROM jf_guids WHERE guid = ?1") + .bind(&g) + .fetch_optional(pool) + .await?; + Ok(row) +} + +pub async fn remember_artist(pool: &Pool, a: &Artist) -> anyhow::Result { + remember(pool, KIND_ARTIST, &a.id).await +} + +pub async fn remember_album(pool: &Pool, a: &Album) -> anyhow::Result { + remember(pool, KIND_ALBUM, &a.id).await +} + +pub async fn remember_track(pool: &Pool, t: &Track) -> anyhow::Result { + remember(pool, KIND_TRACK, &t.id).await +} + +pub fn library_guid() -> String { + guid(KIND_LIBRARY, "music") +} + +pub fn user_guid(username: &str) -> String { + guid(KIND_USER, username) +} diff --git a/crates/jellyfin/src/server/mod.rs b/crates/jellyfin/src/server/mod.rs new file mode 100644 index 0000000000..52d62a012b --- /dev/null +++ b/crates/jellyfin/src/server/mod.rs @@ -0,0 +1,297 @@ +//! Jellyfin-compatible HTTP server. Mirrors the routes Finamp / Findroid / +//! Streamyfin / Amcfy / Symfonium hit, audio-only. + +pub mod auth; +pub mod discovery; +pub mod dto; +pub mod handlers; +pub mod mapping; + +use actix_cors::Cors; +use actix_web::{web, App, HttpServer}; +use rockbox_library::create_connection_pool; +use rockbox_settings::read_settings; +use sqlx::{Executor, Pool, Sqlite}; +use std::path::PathBuf; +use std::sync::Arc; + +pub struct JellyfinState { + pub pool: Pool, + pub username: Arc, + pub password: Arc, + pub music_dir: PathBuf, + pub server_id: String, + pub server_name: String, + pub user_id: Arc, + pub port: u16, +} + +pub async fn start() -> anyhow::Result<()> { + let settings = read_settings().unwrap_or_default(); + // Activation is gated on `jellyfin_port` being present in settings.toml. + // Omit the key entirely to disable the server (the Subsonic gate is + // separately controlled by `subsonic_password`). + let Some(port) = settings.jellyfin_port else { + tracing::info!("Jellyfin server: jellyfin_port not set in settings.toml, server disabled"); + return Ok(()); + }; + let username = settings + .subsonic_username + .clone() + .unwrap_or_else(|| "admin".to_string()); + let password = settings.subsonic_password.clone().unwrap_or_default(); + let server_name = "rockbox".to_string(); + + if password.is_empty() { + tracing::info!( + "Jellyfin server: subsonic_password not set in settings.toml, server disabled" + ); + return Ok(()); + } + + let music_dir = std::env::var("ROCKBOX_LIBRARY") + .map(PathBuf::from) + .unwrap_or_else(|_| { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(format!("{home}/Music")) + }); + + let pool = create_connection_pool().await?; + + // Apply our migrations on top of the library schema. + pool.execute(include_str!( + "../../migrations/20260629000000_jellyfin_tables.sql" + )) + .await?; + + let server_id = auth::ensure_server_id(&pool).await?; + let user_id = mapping::user_guid(&username); + let addr = format!("0.0.0.0:{port}"); + + let state = web::Data::new(JellyfinState { + pool, + username: Arc::new(username), + password: Arc::new(password), + music_dir, + server_id: server_id.clone(), + server_name: server_name.clone(), + user_id: Arc::new(user_id), + port, + }); + + tracing::info!("Jellyfin API server listening on {addr} (id={server_id})"); + + // Background tasks: mDNS announcement + UDP discovery listener. + discovery::register_mdns("rockbox", port, &server_id); + let dname = server_name.clone(); + let did = server_id.clone(); + tokio::spawn(async move { + if let Err(e) = discovery::run(dname, did, port).await { + tracing::error!("jellyfin discovery stopped: {e}"); + } + }); + + HttpServer::new(move || { + App::new() + .app_data(state.clone()) + .wrap(Cors::permissive()) + .configure(configure_routes) + }) + .bind(&addr)? + .run() + .await?; + + Ok(()) +} + +pub fn configure_routes(cfg: &mut web::ServiceConfig) { + cfg.route("/", web::get().to(handlers::index)) + .route("/", web::head().to(handlers::index)) + // System + .route( + "/System/Info/Public", + web::get().to(handlers::system_info_public), + ) + .route("/System/Info", web::get().to(handlers::system_info)) + .route("/System/Endpoint", web::get().to(handlers::system_endpoint)) + .route( + "/System/Configuration/branding", + web::get().to(handlers::branding_config), + ) + .route( + "/Branding/Configuration", + web::get().to(handlers::branding_config), + ) + .route( + "/Branding/Css", + web::get().to(|| async { + actix_web::HttpResponse::Ok() + .content_type("text/css") + .body("") + }), + ) + // Users / auth — register PascalCase + common lowercase variants. + .route( + "/Users/AuthenticateByName", + web::post().to(handlers::authenticate_by_name), + ) + .route( + "/Users/authenticatebyname", + web::post().to(handlers::authenticate_by_name), + ) + .route( + "/Users/authenticateByName", + web::post().to(handlers::authenticate_by_name), + ) + .route("/Users/Public", web::get().to(handlers::users_public)) + .route("/Users", web::get().to(handlers::users_list)) + .route("/Users/Me", web::get().to(handlers::users_me)) + .route("/Users/{id}", web::get().to(handlers::user_by_id)) + // Views / libraries + .route("/Users/{id}/Views", web::get().to(handlers::user_views)) + .route("/UserViews", web::get().to(handlers::user_views_query)) + .route( + "/Library/MediaFolders", + web::get().to(handlers::media_folders), + ) + .route( + "/Library/VirtualFolders", + web::get().to(handlers::library_virtual_folders), + ) + .route("/Library/Refresh", web::post().to(handlers::no_content)) + // Items — specific paths before /Items/{id}. + .route("/Items", web::get().to(handlers::items)) + .route("/Items/Suggestions", web::get().to(handlers::empty_items)) + .route("/Items/Resume", web::get().to(handlers::empty_items)) + .route("/Items/Latest", web::get().to(handlers::items_latest)) + .route( + "/Items/{id}/File", + web::get().to(handlers::item_file_stream), + ) + .route( + "/Items/{id}/File", + web::head().to(handlers::item_file_stream), + ) + .route( + "/Items/{id}/Download", + web::get().to(handlers::item_file_stream), + ) + .route("/Items/{id}", web::get().to(handlers::item_by_id)) + .route("/Users/{id}/Items", web::get().to(handlers::user_items)) + .route( + "/Users/{id}/Items/Resume", + web::get().to(handlers::empty_items), + ) + .route( + "/Users/{id}/Items/Latest", + web::get().to(handlers::items_latest), + ) + .route( + "/Users/{uid}/Items/{id}", + web::get().to(handlers::user_item_by_id), + ) + // Legacy /UserItems/* aliases. + .route("/UserItems/Resume", web::get().to(handlers::empty_items)) + .route("/UserItems/Latest", web::get().to(handlers::empty_array)) + // Search + .route("/Search/Hints", web::get().to(handlers::search_hints)) + // /Shows/* — no series concept here. + .route("/Shows/NextUp", web::get().to(handlers::empty_items)) + .route("/Shows/Upcoming", web::get().to(handlers::empty_items)) + // Artists + .route("/Artists", web::get().to(handlers::artists)) + .route("/Artists/AlbumArtists", web::get().to(handlers::artists)) + .route("/Artists/{name}", web::get().to(handlers::artist_by_name)) + // Images — register both Items and items (lowercase used by Findroid). + .route( + "/Items/{id}/Images/{kind}", + web::get().to(handlers::item_image), + ) + .route( + "/Items/{id}/Images/{kind}/{idx}", + web::get().to(handlers::item_image_by_index), + ) + .route( + "/items/{id}/Images/{kind}", + web::get().to(handlers::item_image), + ) + .route( + "/items/{id}/Images/{kind}/{idx}", + web::get().to(handlers::item_image_by_index), + ) + .route( + "/Items/{id}/Images/{kind}", + web::head().to(handlers::item_image), + ) + // Playback + .route( + "/Items/{id}/PlaybackInfo", + web::get().to(handlers::playback_info), + ) + .route( + "/Items/{id}/PlaybackInfo", + web::post().to(handlers::playback_info), + ) + .route("/Audio/{id}/stream", web::get().to(handlers::audio_stream)) + .route("/Audio/{id}/stream", web::head().to(handlers::audio_stream)) + .route( + "/Audio/{id}/stream.{ext}", + web::get().to(handlers::audio_stream_ext), + ) + .route( + "/Audio/{id}/stream.{ext}", + web::head().to(handlers::audio_stream_ext), + ) + .route( + "/Audio/{id}/universal", + web::get().to(handlers::audio_universal), + ) + .route( + "/Audio/{id}/universal", + web::head().to(handlers::audio_universal), + ) + // Sessions / scrobble + .route("/Sessions", web::get().to(handlers::sessions_list)) + .route( + "/Sessions/Capabilities/Full", + web::post().to(handlers::sessions_capabilities), + ) + .route( + "/Sessions/Playing", + web::post().to(handlers::sessions_playing), + ) + .route( + "/Sessions/Playing/Progress", + web::post().to(handlers::sessions_playing_progress), + ) + .route( + "/Sessions/Playing/Stopped", + web::post().to(handlers::sessions_playing_stopped), + ) + .route( + "/Users/{uid}/PlayedItems/{id}", + web::post().to(handlers::user_played_item), + ) + .route( + "/Users/{uid}/PlayedItems/{id}", + web::delete().to(handlers::user_played_item), + ) + // ScheduledTasks + .route("/ScheduledTasks", web::get().to(handlers::empty_array)) + .route( + "/ScheduledTasks/Running/{id}", + web::post().to(handlers::no_content), + ) + .route( + "/ScheduledTasks/Running/{id}", + web::delete().to(handlers::no_content), + ) + // Common probes + .route( + "/DisplayPreferences/{id}", + web::get().to(handlers::displaypreferences), + ) + .route("/Playlists", web::get().to(handlers::empty_items)) + .route("/Genres", web::get().to(handlers::empty_items)) + .route("/MusicGenres", web::get().to(handlers::empty_items)); +} diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 4a57986bc1..82b7e8e5a3 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -983,6 +983,7 @@ pub mod api { subsonic_username: None, subsonic_password: None, subsonic_port: None, + jellyfin_port: None, cmaf_http_port: None, cmaf_bitrate: None, cmaf_segment_dir: None, diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 79ea8f6289..9e929aefe7 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -30,6 +30,7 @@ rockbox-graphql = {path = "../graphql"} rockbox-library = {path = "../library"} rockbox-playlists = {path = "../playlists"} rockbox-navidrome = {path = "../navidrome", features = ["server"]} +rockbox-jellyfin = {path = "../jellyfin", features = ["server"]} rockbox-rocksky = {path = "../rocksky"} rockbox-mpd = {path = "../mpd"} rockbox-mpris = {path = "../mpris"} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 00dd0ca036..3196f14500 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -601,6 +601,19 @@ pub extern "C" fn start_servers() { } }); + thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + match runtime.block_on(rockbox_jellyfin::server::start()) { + Ok(_) => {} + Err(e) => { + error!("Error starting Jellyfin API server: {}", e); + } + } + }); + thread::spawn(move || { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/crates/sys/src/types/user_settings.rs b/crates/sys/src/types/user_settings.rs index d19a62980f..af127cd548 100644 --- a/crates/sys/src/types/user_settings.rs +++ b/crates/sys/src/types/user_settings.rs @@ -743,6 +743,10 @@ pub struct NewGlobalSettings { pub subsonic_password: Option, /// Port for the Subsonic-compatible API server (default: 4533). pub subsonic_port: Option, + /// Port for the Jellyfin-compatible API server (default: 8096). The + /// server reuses `subsonic_username` / `subsonic_password` as the + /// Jellyfin credentials, so it activates whenever those are set. + pub jellyfin_port: Option, /// HTTP port for the CMAF sink (HLS + DASH manifests + fMP4 segments). Default: 7882. pub cmaf_http_port: Option, /// AAC-LC bitrate for the CMAF sink, in bits/sec. Default: 128000. @@ -830,6 +834,7 @@ impl From for NewGlobalSettings { subsonic_username: None, subsonic_password: None, subsonic_port: None, + jellyfin_port: None, cmaf_http_port: None, cmaf_bitrate: None, cmaf_segment_dir: None, diff --git a/dist/debian/amd64/DEBIAN/control b/dist/debian/amd64/DEBIAN/control index 15fa25ce82..9c06a2d099 100644 --- a/dist/debian/amd64/DEBIAN/control +++ b/dist/debian/amd64/DEBIAN/control @@ -1,5 +1,5 @@ Package: rockbox -Version: 2026.06.26 +Version: 2026.06.29 Section: user/multimedia Priority: optional Architecture: amd64 diff --git a/dist/debian/arm64/DEBIAN/control b/dist/debian/arm64/DEBIAN/control index 2caba7b9f6..8130237348 100644 --- a/dist/debian/arm64/DEBIAN/control +++ b/dist/debian/arm64/DEBIAN/control @@ -1,5 +1,5 @@ Package: rockbox -Version: 2026.06.26 +Version: 2026.06.29 Section: user/multimedia Priority: optional Architecture: arm64 diff --git a/dist/debian/armhf/DEBIAN/control b/dist/debian/armhf/DEBIAN/control index 7b5bfad7c6..9ca7791afb 100644 --- a/dist/debian/armhf/DEBIAN/control +++ b/dist/debian/armhf/DEBIAN/control @@ -1,5 +1,5 @@ Package: rockbox -Version: 2026.06.26 +Version: 2026.06.29 Section: user/multimedia Priority: optional Architecture: armhf diff --git a/dist/rpm/amd64/rockbox.spec b/dist/rpm/amd64/rockbox.spec index 13a953facf..c9a576c815 100644 --- a/dist/rpm/amd64/rockbox.spec +++ b/dist/rpm/amd64/rockbox.spec @@ -1,5 +1,5 @@ Name: rockbox -Version: 2026.06.26 +Version: 2026.06.29 Release: 1%{?dist} Summary: High quality audio player diff --git a/dist/rpm/arm64/rockbox.spec b/dist/rpm/arm64/rockbox.spec index 362a53b197..d198cbadb4 100644 --- a/dist/rpm/arm64/rockbox.spec +++ b/dist/rpm/arm64/rockbox.spec @@ -1,5 +1,5 @@ Name: rockbox -Version: 2026.06.26 +Version: 2026.06.29 Release: 1%{?dist} Summary: High quality audio player diff --git a/mintlify/api-reference/introduction.mdx b/mintlify/api-reference/introduction.mdx index fe6323d934..564b63b4ef 100644 --- a/mintlify/api-reference/introduction.mdx +++ b/mintlify/api-reference/introduction.mdx @@ -16,6 +16,9 @@ HTTP and MPD. | **GraphQL** | 6062 | Best fit for UIs. Subscriptions for real-time events.| | **gRPC** | 6061 | Strongly-typed, multi-language. gRPC-Web supported. | | **MPD** | 6600 | Existing MPD clients (`mpc`, `ncmpcpp`, MALP, …). | +| **Subsonic** | 4533 | Cassette, Symfonium, DSub, Ultrasonic, play:sub, … | +| **Jellyfin** | 8096 (opt-in)| Finamp, Findroid, Streamyfin, Amcfy Music, Symfonium.| +| **S3** | 9000 (opt-in)| Upload / delete tracks with `awscli`, `mc`, `rclone`.| @@ -30,6 +33,12 @@ HTTP and MPD. Anything that speaks MPD on `localhost:6600`. + + Native Jellyfin clients on `localhost:8096`. + + + Upload tracks via the S3 protocol on `localhost:9000`. + ## Auto-generated REST pages diff --git a/mintlify/api-reference/jellyfin/overview.mdx b/mintlify/api-reference/jellyfin/overview.mdx new file mode 100644 index 0000000000..18040769c7 --- /dev/null +++ b/mintlify/api-reference/jellyfin/overview.mdx @@ -0,0 +1,111 @@ +--- +title: "Jellyfin-compatible API" +description: "Optional sidecar HTTP server that speaks the Jellyfin protocol so native Jellyfin clients can browse and stream your library." +icon: 'film' +--- + +Rockbox can act as a Jellyfin server for native Jellyfin clients on your +LAN. Internally it's a thin actix-web shim over the same `rockbox-library` +SQLite database the Subsonic API reads from — there's no separate scan, +no separate user store, and no extra daemon to manage. + +## Enabling + +Set `jellyfin_port` in `~/.config/rockbox.org/settings.toml`: + +```toml +subsonic_username = "admin" +subsonic_password = "changeme" +jellyfin_port = 8096 # conventional Jellyfin port; pick any free port +``` + +Then `rockbox restart`. The server is **disabled** when `jellyfin_port` +is missing or when `subsonic_password` is empty — credentials are shared +with the Subsonic side. + +## Discovery + +When enabled, two discovery mechanisms run alongside the HTTP server: + +- **mDNS**: `_jellyfin._tcp.local.` is advertised on the configured port, + with a `ID=…` TXT record matching the server's stable Jellyfin id. +- **UDP 7359**: the server binds the standard Jellyfin client-discovery + port and answers the literal probe `"Who is JellyfinServer?"` with a + JSON response containing the server's LAN URL. + +Both run automatically; nothing to configure. + +## Tested clients + +| Client | Platform | Notes | +|--------------|-------------|----------------------------------------------------------------| +| Finamp | Android/iOS | Best-tested music client. Full browse + stream + scrobble. | +| Symfonium | Android | Paid. Works against the Jellyfin API. | +| Amcfy Music | Android | Triggers library refresh on `ScheduledTasks/Running`. | +| Findroid | Android | Video-focused; will show empty libraries because Rockbox is audio-only. | +| Streamyfin | Android | Polls `/Sessions`; works. | +| Official app | Android | Not supported — the official app is a WebView around the Jellyfin web UI, which Rockbox doesn't ship. | + +For music, use **Finamp** (or Amcfy / Symfonium). + +## Endpoint surface + +The server implements enough of the [Jellyfin OpenAPI](https://api.jellyfin.org/) +to satisfy the native music clients above. All authenticated routes accept +the token via `X-Emby-Token` header, `Authorization: MediaBrowser Token="…"`, +or `?api_key=…` on streaming URLs. Query parameters work in both +camelCase (`?parentId=…`) and PascalCase (`?ParentId=…`); repeated keys +(`?includeItemTypes=Audio&includeItemTypes=MusicAlbum`) are concatenated. + +| Group | Endpoints | +| ----------- | ------------------------------------------------------------------------- | +| System | `GET /System/Info`, `GET /System/Info/Public`, `GET /System/Endpoint` | +| Auth | `POST /Users/AuthenticateByName` (+ `authenticatebyname` lowercase alias) | +| Users | `GET /Users`, `GET /Users/Public`, `GET /Users/Me`, `GET /Users/{id}` | +| Views | `GET /Users/{id}/Views`, `GET /UserViews`, `GET /Library/MediaFolders` | +| Items | `GET /Items`, `GET /Users/{id}/Items`, `GET /Items/{id}`, `GET /Items/Latest`, `GET /Items/Suggestions`, `GET /Items/{id}/File`, `GET /Items/{id}/Download`, `GET /Items/{id}/Images/{kind}` | +| Audio | `GET /Audio/{id}/stream`, `/stream.{ext}`, `/universal` (Range-aware) | +| Artists | `GET /Artists`, `GET /Artists/AlbumArtists`, `GET /Artists/{name}` | +| Search | `GET /Search/Hints`, `GET /Items?searchTerm=…` | +| Playback | `GET POST /Items/{id}/PlaybackInfo` | +| Sessions | `GET /Sessions`, `POST /Sessions/Capabilities/Full`, `POST /Sessions/Playing{,/Progress,/Stopped}` | +| Tasks | `POST /ScheduledTasks/Running/{id}`, `POST /Library/Refresh` | +| Discovery | UDP `7359` probe responder, mDNS `_jellyfin._tcp.local.` advertisement | + +Item IDs are deterministic dashed UUIDs derived from the native +`Artist/Album/Track` ids, and round-tripped via a `jf_guids` lookup +table so subsequent requests resolve back to the right row. + +## Quick test + +```sh +# Discover the server (or just `curl http://:8096/System/Info/Public`) +echo -n 'Who is JellyfinServer?' | nc -u -w1 -b 255.255.255.255 7359 + +# Authenticate +TOKEN=$(curl -s -X POST http://localhost:8096/Users/AuthenticateByName \ + -H 'Content-Type: application/json' \ + -H 'Authorization: MediaBrowser Client="curl", Device="d", DeviceId="i", Version="0.1"' \ + -d '{"Username":"admin","Pw":"changeme"}' \ + | jq -r .AccessToken) + +# List libraries (returns the synthetic "Music" CollectionFolder) +curl -s -H "X-Emby-Token: $TOKEN" "http://localhost:8096/Users/me/Views" | jq + +# List all artists +curl -s -H "X-Emby-Token: $TOKEN" \ + "http://localhost:8096/Items?includeItemTypes=MusicArtist" | jq '.Items[].Name' +``` + +## What's not supported + +- **Video** — Rockbox is an audio-only player. Video libraries and the + `/Videos/{id}/stream` endpoint family are not implemented. +- **Transcoding** — only direct play. `MediaSource.SupportsTranscoding` + is `false`; clients must support the container natively. +- **WebSocket notifications** (`/socket`) — clients fall back to polling + `/Sessions`, which is supported. +- **Multi-user** — there is a single synthetic user matching + `subsonic_username`. Token storage is real (persisted in + `jellyfin_tokens`), but every token belongs to the same user. +- **Playlists, lyrics, parental ratings, sync, live TV** — out of scope. diff --git a/mintlify/docs.json b/mintlify/docs.json index 9d2875f683..0fe4bfedae 100644 --- a/mintlify/docs.json +++ b/mintlify/docs.json @@ -99,6 +99,7 @@ "api-reference/graphql/overview", "api-reference/grpc/overview", "api-reference/mpd/overview", + "api-reference/jellyfin/overview", "api-reference/s3/overview" ] }, diff --git a/mintlify/index.mdx b/mintlify/index.mdx index 7f6f97e1f0..31d9918eb5 100644 --- a/mintlify/index.mdx +++ b/mintlify/index.mdx @@ -49,8 +49,8 @@ Everything ships as a single binary called `rockboxd` (all codecs statically lin gRPC on `:6061`, GraphQL on `:6062`, REST on `:6063`, MPD on `:6600`, - Subsonic on `:4533`, S3-compatible upload/delete on `:9000`, - plus MPRIS and UPnP/DLNA. + Subsonic on `:4533`, Jellyfin on `:8096` (opt-in), + S3-compatible upload/delete on `:9000`, plus MPRIS and UPnP/DLNA. Native macOS (GPUI), GTK4 desktop app, React web UI, terminal TUI and diff --git a/mintlify/reference/ports.mdx b/mintlify/reference/ports.mdx index c7eb9a33d5..9fb8060af1 100644 --- a/mintlify/reference/ports.mdx +++ b/mintlify/reference/ports.mdx @@ -12,6 +12,8 @@ icon: 'plug' | MPD server | 6600 | MPD protocol | `ROCKBOX_MPD_PORT` | | Subsonic / Navidrome API | 4533 | HTTP | `subsonic_port` | | S3-compatible API | 9000 | HTTP | `s3_port` | +| Jellyfin-compatible API (opt-in) | 8096 | HTTP | `jellyfin_port` — omit to disable | +| Jellyfin client discovery | 7359 | UDP | (fixed — bound when Jellyfin is enabled) | | CMAF (HLS + DASH) | 7882 | HTTP | `cmaf_http_port` | | Slim Protocol (squeezelite) | 3483 | TCP | `squeezelite_port` | | HTTP PCM stream (squeezelite) | 9999 | HTTP | `squeezelite_http_port` | @@ -28,6 +30,7 @@ Rockbox both **advertises** and **scans for** the following on the LAN: | Service | Service type | Direction | |-------------------------------|---------------------------------|--------------------| | Rockbox itself | `_rockbox._tcp.local.` | advertise | +| Jellyfin server (this one) | `_jellyfin._tcp.local.` | advertise | | Chromecast | `_googlecast._tcp.local.` | scan | | AirPlay (RAOP) | `_raop._tcp.local.` | scan | | Squeezelite players | `_slim._tcp.local.` | scan | @@ -44,6 +47,7 @@ inbound on these is needed: - `6063/tcp` — for the REST API - `6600/tcp` — for MPD clients - `4533/tcp` — for Subsonic/Navidrome clients (Cassette, Symfonium, DSub, …) +- `8096/tcp` — for Jellyfin clients (Finamp, Findroid, Streamyfin, Amcfy, Symfonium); also `7359/udp` if you want client auto-discovery - `9000/tcp` — for the S3-compatible API (awscli, mc, rclone) when `s3_enabled = true` - `7882/tcp` — for HLS / DASH in-browser playback (when `audio_output = "cmaf"`) - `5353/udp` — mDNS (multicast) diff --git a/mintlify/reference/settings-toml.mdx b/mintlify/reference/settings-toml.mdx index 6b58a8dd22..c9ec012f5b 100644 --- a/mintlify/reference/settings-toml.mdx +++ b/mintlify/reference/settings-toml.mdx @@ -23,6 +23,20 @@ Keys not listed here are ignored. | `subsonic_password` | string | — | Password; server is **disabled** when this is empty | | `subsonic_port` | int | `4533` | TCP port the Subsonic API listens on | +## Jellyfin-compatible API server + +| Key | Type | Default | Description | +| --------------- | ---- | ------- | ---------------------------------------------------------------------------- | +| `jellyfin_port` | int | — | TCP port the Jellyfin-compatible API listens on. **Omit to disable the server.** Conventional value: `8096`. | + +The Jellyfin server activates only when `jellyfin_port` is set. It +reuses `subsonic_username` / `subsonic_password` as its credentials and +is additionally **disabled** when the password is empty. On startup it +also binds `udp/7359` for client auto-discovery (`"Who is JellyfinServer?"`) +and advertises `_jellyfin._tcp.local.` over mDNS. See +[API reference › Jellyfin](/api-reference/jellyfin/overview) for the +endpoint surface and the list of tested native clients. + ## S3-compatible API server | Key | Type | Default | Description |