Initial commit (history removed)

This commit is contained in:
Lukas Kreussel
2025-08-18 17:43:32 +02:00
commit 5d20c95392
78 changed files with 22286 additions and 0 deletions

4
.cargo/config.toml Normal file
View File

@@ -0,0 +1,4 @@
[profile.release]
strip = true
opt-level = "z"
lto = true

20
.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
# Rust build artifacts
/target
# Node modules (reinstalled inside image)
/ui/node_modules
# Git
.git
.gitignore
# Local data & logs
/logs
/data
*.log
*.db
# Editor/OS noise
.DS_Store
.vscode
.idea

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
data/
.last_build_commit
# Generated by Cargo
# will have compiled files and executables
debug
target
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Generated by cargo mutants
# Contains mutation testing data
**/mutants.out*/
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Added by cargo
/target
/logs

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "ui"]
path = ui
url = https://github.com/jellyfin/jellyfin-web.git

62
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,62 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Start Jellyfin Web UI",
"type": "shell",
"command": "npm",
"args": [
"run",
"serve"
],
"group": "build",
"isBackground": true,
"problemMatcher": [],
"options": {
"cwd": "${workspaceFolder}/ui"
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new",
"showReuseMessage": true,
"clear": false
}
},
{
"label": "Install UI Dependencies",
"type": "shell",
"command": "npm",
"args": ["install"],
"options": {
"cwd": "${workspaceFolder}/ui"
},
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "Build UI Production",
"type": "shell",
"command": "npm",
"args": ["run", "build:production"],
"options": {
"cwd": "${workspaceFolder}/ui"
},
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "shared"
},
"problemMatcher": []
}
]
}

4048
Cargo.lock generated Normal file
View File

File diff suppressed because it is too large Load Diff

50
Cargo.toml Normal file
View File

@@ -0,0 +1,50 @@
[workspace]
members = [
"crates/jellyswarrm-proxy",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/yourusername/jellyswarrm"
[workspace.dependencies]
# HTTP and Web Framework
axum = { version = "0.8", features = ["macros"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
hyper = { version = "1.0", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "gzip", "brotli", "deflate", "rustls-tls"] }
# Database
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono"] }
# Async Runtime
tokio = { version = "1.0", features = ["full"] }
# Logging and Tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
tracing-appender = "0.2"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Utilities
url = {version = "2.4", features = ["serde"]}
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1.0"
sha2 = "0.10"
hex = "0.4"
csv = "1.3"
rand = "0.8"
# Development dependencies
tokio-test = "0.4"

66
Dockerfile Normal file
View File

@@ -0,0 +1,66 @@
#################################
# Stage 1: Build Rust Application (Alpine)
#################################
FROM rust:1-alpine AS rust-build
WORKDIR /app
# Install build dependencies (sqlite dev headers, build base, nodejs + npm)
# nodejs & npm from Alpine repo (may lag slightly vs upstream 20.x). If strict Node 20 needed, replace with manual install.
RUN apk add --no-cache \
build-base \
curl \
ca-certificates \
git \
pkgconf \
sqlite-dev \
openssl-dev \
nodejs \
npm
# 2. Cache UI dependencies (copy only package manifests)
COPY ui/package.json ui/package-lock.json* ui/
RUN npm ci --prefix ui || npm install --prefix ui --no-audit --no-fund
# 3. Copy ui sources
COPY ui/ ui/
# 4. Build UI explicitly (not via build.rs) and place into static for rust-embed
RUN npm run build:production --prefix ui \
&& rm -rf crates/jellyswarrm-proxy/static \
&& mkdir -p crates/jellyswarrm-proxy/static \
&& cp -R ui/dist/* crates/jellyswarrm-proxy/static/
# 5. Copy Rust sources and Cargo manifests
COPY Cargo.toml Cargo.lock .cargo ./
COPY crates/jellyswarrm-proxy/Cargo.toml crates/jellyswarrm-proxy/Cargo.toml
COPY crates/jellyswarrm-proxy/askama.toml crates/jellyswarrm-proxy/askama.toml
COPY crates/jellyswarrm-proxy/src crates/jellyswarrm-proxy/src
# Set env var so build.rs skips internal UI build (we already did it)
ENV JELLYSWARRM_SKIP_UI=1
# 5. Build optimized release binary with embedded static assets
RUN cargo build --release --bin jellyswarrm-proxy
#################################
# Stage 2: Runtime Image (Alpine)
#################################
FROM alpine:3.20 AS runtime
WORKDIR /app
ENV HOME=/app
ENV PATH="/app:${PATH}"
ENV RUST_LOG=info
# Install runtime certs + OpenSSL runtime libs + sqlite (for dynamic linking if needed)
RUN apk add --no-cache ca-certificates openssl sqlite-libs \
&& update-ca-certificates
COPY --from=rust-build /app/target/release/jellyswarrm-proxy /app/jellyswarrm-proxy
EXPOSE 3000
ENTRYPOINT ["/app/jellyswarrm-proxy"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Lukas Kreussel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

72
README.md Normal file
View File

@@ -0,0 +1,72 @@
<h1 align="center">Jellyswarrm</h1>
<h3 align="center">Many servers. Single experience.</h3>
<p align="center">
<img alt="Logo Banner" src="./media/banner.svg"/>
<br/>
<br/>
<a href="https://github.com/LLukas22/Jellyswarrm">
<img alt="MIT License" src="https://img.shields.io/github/license/LLukas22/Jellyswarrm.svg"/>
</a>
<a href="https://github.com/LLukas22/Jellyswarrm/releases">
<img alt="Current Release" src="https://img.shields.io/github/release/LLukas22/Jellyswarrm/.svg"/>
</a>
</p>
Jellyswarrm is a powerful proxy service that seamlessly aggregates multiple Jellyfin media servers into a unified interface. Whether you're managing distributed libraries across different locations or simply want to consolidate your media experience, Jellyswarrm makes it effortless to access all your content from a single point.
---
## Local Development
### Getting Started
To get started with development, you'll need to clone the repository along with its submodules. This ensures you have all the necessary components for a complete build:
```bash
git clone --recurse-submodules https://github.com/LLukas22/Jellyswarrm.git
```
If you've already cloned the repository, you can initialize the submodules separately:
```bash
git submodule init
git submodule update
```
<details open>
<summary><strong>Docker</strong></summary>
The quickest way to get Jellyswarrm up and running is with Docker. Simply use the provided [docker-compose](./docker-compose.yml) configuration:
```bash
docker compose up -d
```
This will build and start the application with all necessary dependencies, perfect for both development and production deployments.
</details>
<details>
<summary><strong>Native Build</strong></summary>
For a native development setup, ensure you have both Rust and Node.js installed on your system.
First, install the UI dependencies. You can use the convenient VS Code task `Install UI Dependencies` from the tasks.json file, or run it manually:
```bash
cd ui
npm install
cd ..
```
Once the dependencies are installed, build the entire project with:
```bash
cargo build --release
```
The build process is streamlined thanks to the included [`build.rs`](./crates/jellyswarrm-proxy/build.rs) script, which automatically compiles the web UI and embeds it into the final binary for a truly self-contained application.
</details>

1
crates/jellyswarrm-proxy/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
static/

View File

@@ -0,0 +1,63 @@
[package]
name = "jellyswarrm-proxy"
version.workspace = true
edition.workspace = true
authors.workspace = true
description = "A high-performance reverse proxy for combining multiple Jellyfin instances"
[[bin]]
name = "jellyswarrm-proxy"
path = "src/main.rs"
[dependencies]
axum.workspace = true
tokio.workspace = true
tower.workspace = true
tower-http.workspace = true
hyper.workspace = true
hyper-util.workspace = true
http-body-util.workspace = true
tracing.workspace = true
askama = { version = "0.12", features = ["with-axum"] }
askama_axum = "0.4"
tracing-subscriber.workspace = true
tracing-appender.workspace = true
url.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
sqlx.workspace = true
sha2.workspace = true
hex.workspace = true
chrono.workspace = true
csv.workspace = true
rand.workspace = true
dashmap = "6.1.0"
uuid = { version = "1.0", features = ["v4"] }
anyhow = "1.0.98"
serde_urlencoded = "0.7.1"
indexmap = "2.10.0"
regex = "1.11.1"
rust-embed = "8"
mime_guess = "2"
percent-encoding = "2.3.1"
config = "0.14"
toml = "0.8"
once_cell = "1.19"
serde_default = "0.2.0"
axum-login = "0.18.0"
password-auth = "1.0.0"
thiserror.workspace = true
axum-messages = "0.8.0"
tower-sessions = { version = "0.14.0", features = ["signed"] }
tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
time = "0.3.41"
base64 = "0.22.1"
[dev-dependencies]
tokio-test = "0.4.4"
tempfile = "3.14.0"
mockall = "0.13.1"
[build-dependencies]
fs_extra = "1.2"

View File

@@ -0,0 +1,2 @@
[general]
dirs = ["src/ui/templates"]

View File

@@ -0,0 +1,67 @@
use std::{env, fs, io::Write, path::PathBuf, process::Command};
fn main() {
if std::env::var("JELLYSWARRM_SKIP_UI").ok().as_deref() == Some("1") {
println!("cargo:warning=Skipping internal UI build (JELLYSWARRM_SKIP_UI=1)");
return;
}
// Get the path to the crate's directory
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
// Assume workspace root is two levels up from this crate (adjust if needed)
let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
let ui_dir = workspace_root.join("ui");
let dist_dir = manifest_dir.join("static"); // static/ in the crate
// Get the latest commit hash for the ui directory
let output = Command::new("git")
.args([
"log",
"-n",
"1",
"--pretty=format:%H",
"--",
ui_dir.to_str().unwrap(),
])
.current_dir(workspace_root)
.output()
.expect("Failed to get git commit hash for ui directory");
let current_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Read the last built commit hash
let hash_file = workspace_root.join(".last_build_commit");
let last_hash = fs::read_to_string(&hash_file).unwrap_or_default();
if last_hash != current_hash {
println!("Building UI: new commit detected.");
// Build the UI
let status = Command::new("npm")
.args(["run", "build:production"])
.current_dir(&ui_dir)
.status()
.expect("Failed to run npm build");
assert!(status.success(), "UI build failed");
// Copy dist/* to static/
let src = ui_dir.join("dist");
let dst = &dist_dir;
if dst.exists() {
fs::remove_dir_all(dst).expect("Failed to remove old static dir");
}
fs_extra::dir::copy(
&src,
dst,
&fs_extra::dir::CopyOptions::new().content_only(true),
)
.expect("Failed to copy dist to static");
// Save the new commit hash
let mut file = fs::File::create(&hash_file).expect("Failed to write hash file");
file.write_all(current_hash.as_bytes())
.expect("Failed to write hash");
} else {
println!("cargo:warning=UI unchanged, skipping build");
}
}

View File

@@ -0,0 +1,151 @@
use serde::{Deserialize, Serialize};
use serde_default::DefaultFromSerde;
use std::fs;
use std::path::PathBuf;
use tower_sessions::cookie::Key;
use uuid::Uuid;
use once_cell::sync::Lazy;
use base64::prelude::*;
// Lazily-resolved data directory shared across the application.
// Priority: env var JELLYSWARRM_DATA_DIR, else "./data" relative to current working dir.
// The directory is created on first access.
pub static DATA_DIR: Lazy<PathBuf> = Lazy::new(|| {
let base = std::env::var("JELLYSWARRM_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::current_dir().unwrap().join("data"));
if let Err(e) = std::fs::create_dir_all(&base) {
eprintln!("Failed to create data directory {base:?}: {e}");
}
base
});
fn default_server_id() -> String {
let raw = Uuid::new_v4().simple().to_string(); // 32 hex chars
let trimmed = &raw[..20]; // first 20 to allow for "jellyswarrm" in front
format!("jellyswarrm{trimmed}")
}
fn default_public_address() -> String {
"localhost:3000".to_string()
}
fn default_server_name() -> String {
"Jellyswarrm Proxy".to_string()
}
fn default_host() -> String {
"0.0.0.0".to_string()
}
fn default_port() -> u16 {
3000
}
fn default_include_server_name_in_media() -> bool {
true
}
fn default_username() -> String {
"admin".to_string()
}
fn default_password() -> String {
"jellyswarrm".to_string()
}
fn default_session_key() -> Vec<u8> {
Key::generate().master().to_vec()
}
fn default_timeout() -> u64 {
20
}
mod base64_serde {
use super::*;
use serde::de::Error as DeError;
use serde::{Deserializer, Serializer};
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let s = BASE64_STANDARD.encode(bytes);
serializer.serialize_str(&s)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
BASE64_STANDARD.decode(&s).map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, Deserialize, Serialize, DefaultFromSerde)]
pub struct AppConfig {
#[serde(default = "default_server_id")]
pub server_id: String,
#[serde(default = "default_public_address")]
pub public_address: String,
#[serde(default = "default_server_name")]
pub server_name: String,
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_include_server_name_in_media")]
pub include_server_name_in_media: bool,
#[serde(default = "default_username")]
pub username: String,
#[serde(default = "default_password")]
pub password: String,
#[serde(default = "default_session_key", with = "base64_serde")]
pub session_key: Vec<u8>,
#[serde(default = "default_timeout")]
pub timeout: u64, // in seconds
}
pub const DEFAULT_CONFIG_FILENAME: &str = "jellyswarrm.toml";
fn config_path() -> PathBuf {
DATA_DIR.join(DEFAULT_CONFIG_FILENAME)
}
/// Load configuration from known files and environment. Falls back to defaults.
pub fn load_config() -> AppConfig {
let path = config_path();
let builder = config::Config::builder()
.add_source(config::File::with_name(path.to_string_lossy().as_ref()).required(false))
.add_source(config::Environment::with_prefix("JELLYSWARRM").separator("_"));
let config = match builder.build() {
Ok(c) => c.try_deserialize().unwrap_or_default(),
Err(e) => {
let config = AppConfig::default();
eprintln!("Failed to load config using defaults: {e}");
config
}
};
if !path.exists() {
if let Err(e) = save_config(&config) {
eprintln!("Failed to save default config to {path:?}: {e}");
}
}
config
}
/// Persist configuration to the first existing file or the primary default file.
pub fn save_config(cfg: &AppConfig) -> std::io::Result<()> {
let toml_str = toml::to_string_pretty(cfg).expect("serialize config");
fs::write(config_path(), toml_str)
}

View File

@@ -0,0 +1,37 @@
use axum::{extract::State, Json};
use hyper::StatusCode;
use crate::{models::BrandingConfig, AppState};
pub async fn handle_branding(
State(state): State<AppState>,
) -> Result<Json<BrandingConfig>, StatusCode> {
let servers = state
.server_storage
.list_servers()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut message = "Jellyswarrm proxying to the following servers: ".to_string();
if !servers.is_empty() {
let server_links: Vec<String> = servers
.iter()
.map(|s| {
format!(
"<a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\">{}</a>",
s.url, s.name
)
})
.collect();
message.push_str(&server_links.join(", "));
} else {
message.push_str("No servers configured.");
}
let config = BrandingConfig {
login_disclaimer: message,
custom_css: String::new(),
splashscreen_enabled: false,
};
Ok(Json(config))
}

View File

@@ -0,0 +1,269 @@
use std::collections::HashMap;
use hyper::StatusCode;
use regex::Regex;
use tracing::{error, info};
use crate::{
media_storage_service::MediaStorageService,
models::{MediaItem, MediaSource},
server_storage::Server,
session_storage::PlaybackSession,
AppState,
};
pub fn payload_from_request<T>(request: &reqwest::Request) -> Result<T, StatusCode>
where
T: serde::de::DeserializeOwned,
{
let bytes = request
.body()
.ok_or(StatusCode::BAD_REQUEST)?
.as_bytes()
.ok_or(StatusCode::BAD_REQUEST)?;
match serde_json::from_slice::<T>(bytes) {
Ok(val) => Ok(val),
Err(e) => {
if let Ok(body_str) = std::str::from_utf8(bytes) {
eprintln!("Failed to parse JSON body: {e}\nBody: {body_str}");
} else {
eprintln!("Failed to parse JSON body: {e}\nBody (non-UTF8)");
}
Err(StatusCode::BAD_REQUEST)
}
}
}
/// Execute a reqwest request and parse the JSON response with comprehensive error handling
pub async fn execute_json_request<T>(
client: &reqwest::Client,
request: reqwest::Request,
) -> Result<T, StatusCode>
where
T: serde::de::DeserializeOwned,
{
let response = client
.execute(request)
.await
.map_err(|e| {
error!("Failed to execute request: {}", e);
StatusCode::BAD_GATEWAY
})?
.error_for_status()
.map_err(|e| {
error!("Request failed with status: {}", e);
StatusCode::UNAUTHORIZED
})?;
let response_text = response.text().await.map_err(|e| {
error!("Failed to get response text: {}", e);
StatusCode::BAD_GATEWAY
})?;
serde_json::from_str(&response_text).map_err(|e| {
error!(
"Failed to parse response JSON: {}. Response body: {}",
e, response_text
);
StatusCode::BAD_GATEWAY
})
}
pub async fn get_virtual_id(
id: &str,
media_storage: &MediaStorageService,
server: &Server,
) -> Result<String, StatusCode> {
let mapping = media_storage
.get_or_create_media_mapping(id, server.url.as_str())
.await
.map_err(|e| {
error!(
"Failed to get virtual id for: `{}` on server: {}!/n Error: {}",
id, server.name, e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(mapping.virtual_media_id.clone())
}
/// Processes a media item.
/// Replaces the original ids with vitual ids that map back to the original media item and server.
pub async fn process_media_item(
item: MediaItem,
media_storage: &MediaStorageService,
server: &Server,
change_name: bool,
server_id: &str,
) -> Result<MediaItem, StatusCode> {
let mut item = item;
if change_name {
item.name = format!("{} [{}]", item.name, server.name);
if let Some(series_name) = &item.series_name {
item.series_name = Some(format!("{} [{}]", series_name, server.name));
}
}
item.id = get_virtual_id(&item.id, media_storage, server).await?;
if let Some(parent_id) = &item.parent_id {
item.parent_id = Some(get_virtual_id(parent_id, media_storage, server).await?);
}
if let Some(etag) = &item.etag {
item.etag = Some(get_virtual_id(etag, media_storage, server).await?);
}
if let Some(series_id) = &item.series_id {
item.series_id = Some(get_virtual_id(series_id, media_storage, server).await?);
}
if let Some(season_id) = &item.season_id {
item.season_id = Some(get_virtual_id(season_id, media_storage, server).await?);
}
if let Some(preferences_id) = &item.display_preferences_id {
item.display_preferences_id =
Some(get_virtual_id(preferences_id, media_storage, server).await?);
}
if item.can_delete.is_some() {
item.can_delete = Some(false);
}
if item.can_download.is_some() {
item.can_download = Some(false);
}
if let Some(media_sources) = &mut item.media_sources {
for source in media_sources.iter_mut() {
*source = process_media_source(source.clone(), media_storage, server).await?;
}
}
if let Some(parent_logo_item_id) = &item.parent_logo_item_id {
item.parent_logo_item_id =
Some(get_virtual_id(parent_logo_item_id, media_storage, server).await?);
}
if let Some(parent_backdrop_item_id) = &item.parent_backdrop_item_id {
item.parent_backdrop_item_id =
Some(get_virtual_id(parent_backdrop_item_id, media_storage, server).await?);
}
if let Some(parent_logo_image_tag) = &item.parent_logo_image_tag {
item.parent_logo_image_tag =
Some(get_virtual_id(parent_logo_image_tag, media_storage, server).await?);
}
if let Some(parent_thumb_item_id) = &item.parent_thumb_item_id {
item.parent_thumb_item_id =
Some(get_virtual_id(parent_thumb_item_id, media_storage, server).await?);
}
if let Some(parent_thumb_image_tag) = &item.parent_thumb_image_tag {
item.parent_thumb_image_tag =
Some(get_virtual_id(parent_thumb_image_tag, media_storage, server).await?);
}
if let Some(series_primary_image_tag) = &item.series_primary_image_tag {
item.series_primary_image_tag =
Some(get_virtual_id(series_primary_image_tag, media_storage, server).await?);
}
if let Some(image_tags) = &mut item.image_tags {
let mut updated_tags = HashMap::new();
for (tag_type, tag_id) in image_tags.iter() {
let virtual_id = get_virtual_id(tag_id, media_storage, server).await?;
updated_tags.insert(tag_type.clone(), virtual_id);
}
*image_tags = updated_tags;
}
if let Some(image_blur_hashes) = &mut item.image_blur_hashes {
let mut updated_blur_hashes = HashMap::new();
for (image_type, hash_map) in image_blur_hashes.iter() {
let mut updated_hash_map = HashMap::new();
for (hash_id, hash_value) in hash_map.iter() {
let virtual_id = get_virtual_id(hash_id, media_storage, server).await?;
updated_hash_map.insert(virtual_id, hash_value.clone());
}
updated_blur_hashes.insert(image_type.clone(), updated_hash_map);
}
*image_blur_hashes = updated_blur_hashes;
}
if let Some(backdrop_image_tags) = &mut item.backdrop_image_tags {
let mut new_backdrop_tags = Vec::new();
for tag in backdrop_image_tags.iter() {
let virtual_id = get_virtual_id(tag, media_storage, server).await?;
new_backdrop_tags.push(virtual_id);
}
item.backdrop_image_tags = Some(new_backdrop_tags);
}
if let Some(parent_backdrop_image_tags) = &mut item.parent_backdrop_image_tags {
let mut new_parent_backdrop_image_tags = Vec::new();
for tag in parent_backdrop_image_tags.iter() {
let virtual_id = get_virtual_id(tag, media_storage, server).await?;
new_parent_backdrop_image_tags.push(virtual_id);
}
item.parent_backdrop_image_tags = Some(new_parent_backdrop_image_tags);
}
if let Some(chapters) = &mut item.chapters {
for chapter in chapters.iter_mut() {
if let Some(image_tag) = &chapter.image_tag {
chapter.image_tag = Some(get_virtual_id(image_tag, media_storage, server).await?);
}
}
}
item.server_id = server_id.to_string();
Ok(item)
}
pub async fn process_media_source(
item: MediaSource,
media_storage: &MediaStorageService,
server: &Server,
) -> Result<MediaSource, StatusCode> {
let mut item = item;
item.id = get_virtual_id(&item.id, media_storage, server).await?;
// TODO check media streams
Ok(item)
}
pub async fn track_play_session(
item: &MediaSource,
session_id: &str,
server: &Server,
state: &AppState,
) -> Result<(), StatusCode> {
if let Some(transcoding_url) = &item.transcoding_url {
let re = Regex::new(r"/videos/([^/]+)/").unwrap();
let id = re
.captures(transcoding_url)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str())
.unwrap_or_default();
info!(
"Tracking play session for item: {}, server: {}",
id, server.name
);
state
.play_sessions
.add_session(PlaybackSession {
item_id: id.to_string(),
session_id: session_id.to_string(),
server: server.clone(),
})
.await;
}
Ok(())
}

View File

@@ -0,0 +1,177 @@
use axum::{
extract::{Request, State},
Json,
};
use hyper::StatusCode;
use tokio::task::JoinSet;
use tracing::{debug, error};
use crate::{
handlers::{
common::{execute_json_request, process_media_item},
items::get_items,
},
request_preprocessing::{apply_to_request, extract_request_infos, JellyfinAuthorization},
AppState,
};
pub async fn get_items_from_all_servers_if_not_restricted(
State(state): State<AppState>,
req: Request,
) -> Result<Json<crate::models::ItemsResponse>, StatusCode> {
// Extract request information and sessions
if let Some(query) = req.uri().query() {
// Check if the request is for a specific series
if query.contains("SeriesId") {
return get_items(State(state), req).await;
}
}
get_items_from_all_servers(State(state), req).await
}
pub async fn get_items_from_all_servers(
State(state): State<AppState>,
req: Request,
) -> Result<Json<crate::models::ItemsResponse>, StatusCode> {
let (original_request, _, _, sessions) =
extract_request_infos(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
let sessions = sessions.ok_or(StatusCode::UNAUTHORIZED)?;
if sessions.is_empty() {
return Err(StatusCode::UNAUTHORIZED);
}
// Create JoinSet for parallel execution
let mut join_set = JoinSet::new();
for (index, (session, server)) in sessions.into_iter().enumerate() {
let request = match original_request.try_clone() {
Some(req) => req,
None => {
error!("Failed to clone request for server: {}", server.name);
continue;
}
};
let auth = JellyfinAuthorization::Authorization(session.to_authorization());
let mut request = request;
let state_clone = state.clone();
let server_clone = server.clone();
let session_clone = session.clone();
// Spawn task in JoinSet with server index
join_set.spawn(async move {
apply_to_request(
&mut request,
&server_clone,
&Some(session_clone),
&Some(auth),
&state_clone,
)
.await;
let result = match execute_json_request::<crate::models::ItemsResponse>(
&state_clone.reqwest_client,
request,
)
.await
{
Ok(items_response) => {
let mut processed_items = Vec::new();
let server_id = { state_clone.config.read().await.server_id.clone() };
for item in items_response.items {
match process_media_item(
item,
&state_clone.media_storage,
&server_clone,
true, // Change name to include server name
&server_id,
)
.await
{
Ok(processed_item) => processed_items.push(processed_item),
Err(e) => {
error!(
"Failed to process media item from server '{}': {:?}",
server_clone.name, e
);
return (index, None);
}
}
}
let item_count = processed_items.len();
debug!(
"Successfully retrieved {} items from server: {}",
item_count, server_clone.name
);
Some(processed_items)
}
Err(e) => {
error!(
"Failed to get items from server '{}': {:?}",
server_clone.name, e
);
None
}
};
(index, result)
});
}
// Wait for all tasks to complete and collect results with their original indices
let mut indexed_results: Vec<(usize, Option<Vec<crate::models::MediaItem>>)> = Vec::new();
while let Some(result) = join_set.join_next().await {
match result {
Ok((index, items)) => indexed_results.push((index, items)),
Err(e) => error!("Task failed: {:?}", e),
}
}
// Sort results by original server order
indexed_results.sort_by_key(|(index, _)| *index);
// Extract items in original server order
let mut server_items: Vec<Vec<crate::models::MediaItem>> = Vec::new();
for (_, items) in indexed_results {
if let Some(items) = items {
server_items.push(items);
}
}
// Interleave items from all servers
let mut interleaved_items = Vec::new();
let max_items = server_items
.iter()
.map(|items| items.len())
.max()
.unwrap_or(0);
for i in 0..max_items {
for server_item_list in &server_items {
if let Some(item) = server_item_list.get(i) {
interleaved_items.push(item.clone());
}
}
}
let count = interleaved_items.len();
debug!(
"Returning {} interleaved items from {} servers",
count,
server_items.len()
);
Ok(Json(crate::models::ItemsResponse {
items: interleaved_items,
total_record_count: count as i32,
start_index: 0,
}))
}

View File

@@ -0,0 +1,174 @@
use axum::{
extract::{Request, State},
Json,
};
use hyper::StatusCode;
use reqwest::Body;
use tracing::{debug, error};
use crate::{
handlers::common::{
execute_json_request, payload_from_request, process_media_item, process_media_source,
track_play_session,
},
models::{ItemsResponse, MediaItem, PlaybackRequest, PlaybackResponse},
request_preprocessing::preprocess_request,
AppState,
};
//http://localhost:3000/Users/7bc57a386ab84999ad7262210a9cd253/Items/5f7e146c44d84b479cafecd3280be4ea
//http://localhost:3000/Items/430c368c5eb34534bf98363d5adbb92f?userId=520ea298ed8044338a28d912523d715f
pub async fn get_item(
State(state): State<AppState>,
req: Request,
) -> Result<Json<MediaItem>, StatusCode> {
let preprocessed = preprocess_request(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
let server = preprocessed.server;
match execute_json_request::<MediaItem>(&state.reqwest_client, preprocessed.request).await {
Ok(media_item) => {
let server_id = { state.config.read().await.server_id.clone() };
Ok(Json(
process_media_item(media_item, &state.media_storage, &server, false, &server_id)
.await?,
))
}
Err(e) => {
error!("Failed to get MediaItem: {:?}", e);
Err(e)
}
}
}
//http://localhost:3000/Users/7bc57a386ab84999ad7262210a9cd253/Items?SortBy=SortName%2CProductionYear&SortOrder=Ascending&IncludeItemTypes=Movie&Recursive=true&Fields=PrimaryImageAspectRatio%2CMediaSourceCount&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CBanner%2CThumb&StartIndex=0&ParentId=5f7e146c44d84b479cafecd3280be4ea&Limit=100
//http://localhost:3000/Items/430c368c5eb34534bf98363d5adbb92f/Similar?userId=520ea298ed8044338a28d912523d715f&limit=12&fields=PrimaryImageAspectRatio%2CCanDelete
pub async fn get_items(
State(state): State<AppState>,
req: Request,
) -> Result<Json<ItemsResponse>, StatusCode> {
let preprocessed = preprocess_request(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
let server = preprocessed.server;
match execute_json_request::<ItemsResponse>(&state.reqwest_client, preprocessed.request).await {
Ok(mut response) => {
let server_id = { state.config.read().await.server_id.clone() };
for item in &mut response.items {
*item = process_media_item(
item.clone(),
&state.media_storage,
&server,
false,
&server_id,
)
.await?;
}
Ok(Json(response))
}
Err(e) => {
error!("Failed to get ItemsResponse: {:?}", e);
Err(e)
}
}
}
// can be used for special features etc.
pub async fn get_items_list(
State(state): State<AppState>,
req: Request,
) -> Result<Json<Vec<MediaItem>>, StatusCode> {
let preprocessed = preprocess_request(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
let server = preprocessed.server;
match execute_json_request::<Vec<MediaItem>>(&state.reqwest_client, preprocessed.request).await
{
Ok(mut response) => {
let server_id = { state.config.read().await.server_id.clone() };
for item in &mut response {
*item = process_media_item(
item.clone(),
&state.media_storage,
&server,
false,
&server_id,
)
.await?;
}
Ok(Json(response))
}
Err(e) => {
error!("Failed to get Vec<MediaItem>: {:?}", e);
Err(e)
}
}
}
//http://192.168.188.142:30013/Items/165a66aa5bd2e62c0df0f8da332ae47d/PlaybackInfo
#[axum::debug_handler]
pub async fn post_playback_info(
State(state): State<AppState>,
req: Request,
) -> Result<Json<PlaybackResponse>, StatusCode> {
let preprocessed = preprocess_request(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
let original_request = preprocessed
.original_request
.ok_or(StatusCode::BAD_REQUEST)?;
let payload: PlaybackRequest = payload_from_request(&original_request)?;
let server = preprocessed.server;
let session = preprocessed.session.ok_or(StatusCode::UNAUTHORIZED)?;
let mut payload = payload;
payload.user_id = session.original_user_id.clone();
if let Some(media_source_id) = &payload.media_source_id {
if let Some(media_mapping) = state
.media_storage
.get_media_mapping_by_virtual(media_source_id)
.await
.unwrap_or_default()
{
payload.media_source_id = Some(media_mapping.original_media_id);
}
}
let json = serde_json::to_vec(&payload).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut request = preprocessed.request;
*request.body_mut() = Some(Body::from(json));
match execute_json_request::<PlaybackResponse>(&state.reqwest_client, request).await {
Ok(mut response) => {
for item in &mut response.media_sources {
*item = process_media_source(item.clone(), &state.media_storage, &server).await?;
track_play_session(item, &response.play_session_id, &server, &state).await?;
}
debug!("Requested Playback: {:?}", response);
Ok(Json(response))
}
Err(e) => {
error!("Failed to get playback info: {:?}", e);
Err(e)
}
}
}

View File

@@ -0,0 +1,9 @@
pub(crate) mod branding;
pub(crate) mod common;
pub(crate) mod federated;
pub(crate) mod items;
pub(crate) mod quick_connect;
pub(crate) mod sessions;
pub(crate) mod system;
pub(crate) mod users;
pub(crate) mod videos;

View File

@@ -0,0 +1,6 @@
use axum::Json;
use hyper::StatusCode;
pub async fn handle_quick_connect() -> Result<Json<bool>, StatusCode> {
Ok(Json(false))
}

View File

@@ -0,0 +1,87 @@
use axum::extract::{Request, State};
use hyper::StatusCode;
use reqwest::Body;
use tracing::{debug, error};
use crate::{
handlers::common::payload_from_request,
models::ProgressRequest,
request_preprocessing::{apply_to_request, extract_request_infos, remap_authorization},
AppState,
};
// http://localhost:3000/Sessions/Playing
// http://localhost:3000/Sessions/Playing/Progress
// http://localhost:3000/Sessions/Playing/Stopped
pub async fn post_playing(
State(state): State<AppState>,
req: Request,
) -> Result<StatusCode, StatusCode> {
let (request, auth, _, sessions) = extract_request_infos(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
let sessions = sessions.ok_or(StatusCode::UNAUTHORIZED)?;
let mut payload: ProgressRequest = payload_from_request(&request)?;
let session_server = if let Some((media_mapping, server)) = state
.media_storage
.get_media_mapping_with_server(&payload.media_source_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
payload.media_source_id = media_mapping.original_media_id.clone();
payload.item_id = media_mapping.original_media_id.clone();
if let Some(now_playing_queue) = payload.now_playing_queue.as_mut() {
now_playing_queue.iter_mut().for_each(|item| {
item.id = media_mapping.original_media_id.clone();
});
}
server
} else {
error!(
"No server found for media source: {}",
payload.media_source_id
);
return Err(StatusCode::NOT_FOUND);
};
let session = if let Some((session, server)) = sessions.iter().find(|(_, server)| {
let request_url = session_server.url.as_str().trim_end_matches('/');
let server_url = server.url.as_str().trim_end_matches('/');
request_url == server_url
}) {
debug!("Found server in request: {}", server.url);
Some(session.clone())
} else {
error!("No user session found for server: {}", session_server.url);
return Err(StatusCode::UNAUTHORIZED);
};
let new_auth = remap_authorization(&auth, &session).await.map_err(|e| {
error!("Failed to process auth: {}", e);
StatusCode::UNAUTHORIZED
})?;
let mut request = request;
apply_to_request(&mut request, &session_server, &session, &new_auth, &state).await;
let json = serde_json::to_vec(&payload).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
*request.body_mut() = Some(Body::from(json));
let response = state.reqwest_client.execute(request).await.map_err(|e| {
error!("Failed to execute request: {}", e);
StatusCode::BAD_GATEWAY
})?;
let status = response.status();
if !status.is_success() {
error!("Request failed with status: {}", status);
}
Ok(status)
}

View File

@@ -0,0 +1,77 @@
use axum::{
extract::{Request, State},
Json,
};
use hyper::StatusCode;
use tracing::error;
use crate::{
handlers::common::execute_json_request, request_preprocessing::preprocess_request, AppState,
};
pub async fn info_public(
State(state): State<AppState>,
req: Request,
) -> Result<Json<crate::models::PublicServerInfo>, StatusCode> {
let preprocessed = preprocess_request(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
match execute_json_request::<crate::models::PublicServerInfo>(
&state.reqwest_client,
preprocessed.request,
)
.await
{
Ok(mut server_info) => {
let cfg = state.config.read().await;
server_info.id = cfg.server_id.clone();
server_info.server_name = cfg.server_name.clone();
server_info.local_address = cfg.public_address.clone();
Ok(Json(server_info))
}
Err(e) => {
error!("Failed to get server info: {:?}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn info(
State(state): State<AppState>,
req: Request,
) -> Result<Json<crate::models::ServerInfo>, StatusCode> {
let preprocessed = preprocess_request(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
preprocessed.user.ok_or_else(|| {
error!("User not found in request preprocessing");
StatusCode::UNAUTHORIZED
})?;
// return Err(StatusCode::UNAUTHORIZED);
match execute_json_request::<crate::models::ServerInfo>(
&state.reqwest_client,
preprocessed.request,
)
.await
{
Ok(mut server_info) => {
let cfg = state.config.read().await;
server_info.id = cfg.server_id.clone();
server_info.server_name = "Jellyswarrm Proxy".to_string();
server_info.local_address = cfg.public_address.clone();
Ok(Json(server_info))
}
Err(e) => {
error!("Failed to get server info: {:?}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}

View File

@@ -0,0 +1,357 @@
use axum::{
extract::{Path, Request, State},
Json,
};
use hyper::{HeaderMap, StatusCode};
use tracing::{error, info};
use crate::{
handlers::common::execute_json_request,
models::{AuthenticateRequest, AuthenticateResponse, Authorization},
request_preprocessing::preprocess_request,
AppState,
};
pub async fn handle_get_user_by_id(
State(state): State<AppState>,
Path(_user_id): Path<String>,
req: Request,
) -> Result<Json<crate::models::User>, StatusCode> {
// Preprocess request and extract required data
let preprocessed = preprocess_request(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
let session = preprocessed.session.ok_or(StatusCode::UNAUTHORIZED)?;
let user = preprocessed.user.ok_or(StatusCode::UNAUTHORIZED)?;
// Build request URL
let server_url = preprocessed.server.url.as_str().trim_end_matches('/');
let user_url = format!("{}/Users/{}", server_url, session.original_user_id);
let mut request = preprocessed.request;
*request.url_mut() = reqwest::Url::parse(&user_url).map_err(|_| StatusCode::BAD_REQUEST)?;
// Execute request and parse JSON response
let mut server_user: crate::models::User =
execute_json_request(&state.reqwest_client, request).await?;
// Override response fields with proxy values
server_user.id = user.id;
server_user.name = user.original_username.clone();
server_user.policy.is_administrator = false;
server_user.server_id = state.config.read().await.server_id.clone();
Ok(Json(server_user))
}
// Authenticates a user by trying all configured servers in parallel
pub async fn handle_authenticate_by_name(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<AuthenticateRequest>,
) -> Result<Json<AuthenticateResponse>, StatusCode> {
let mut servers = state
.server_storage
.list_servers()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if servers.is_empty() {
tracing::warn!("No servers configured for authentication");
return Err(StatusCode::NOT_FOUND);
}
info!(
"Attempting authentication for user '{}' across {} servers",
payload.username,
servers.len()
);
let mut auth_tasks = Vec::with_capacity(servers.len());
if let Some(user) = state
.user_authorization
.get_user_by_credentials(&payload.username, &payload.password)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
let server_mappings = state
.user_authorization
.list_server_mappings(&user.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !server_mappings.is_empty() {
for server_mapping in server_mappings {
if let Some(pos) = servers.iter().position(|s| {
s.url.as_str().trim_end_matches('/')
== server_mapping.server_url.trim_end_matches('/')
}) {
let server = servers.remove(pos);
info!(
"Using server mapping for user '{}' on server '{}'",
&payload.username, server.name
);
{
let state = state.clone();
let headers = headers.clone();
let payload = payload.clone();
auth_tasks.push(tokio::spawn(async move {
authenticate_on_server(
state.clone(),
headers.clone(),
payload.clone(),
server,
Some(server_mapping),
)
.await
}));
}
}
}
}
}
// also try to authenticate on leftover servers without a mapping
let mut leftover_tasks: Vec<_> = servers
.into_iter()
.map(|server| {
let state = state.clone();
let headers = headers.clone();
let payload = payload.clone();
info!(
"No server mapping found for user '{}' on server '{}'",
payload.username, server.name
);
tokio::spawn(async move {
authenticate_on_server(state, headers, payload, server, None).await
})
})
.collect();
auth_tasks.append(&mut leftover_tasks);
// Wait for all authentication attempts to complete
let mut successful_auths = Vec::new();
let total_servers = auth_tasks.len();
for task in auth_tasks {
match task.await {
Ok(Ok(auth_response)) => {
info!("Successfully authenticated user: {}", payload.username);
successful_auths.push(auth_response);
}
Ok(Err(e)) => {
tracing::debug!("Authentication attempt failed: {:?}", e);
}
Err(join_err) => {
tracing::error!("Authentication task failed: {}", join_err);
}
}
}
if successful_auths.is_empty() {
tracing::warn!(
"All authentication attempts failed for user: {}",
payload.username
);
Err(StatusCode::UNAUTHORIZED)
} else {
info!(
"User '{}' successfully authenticated on {} out of {} servers and stored in authorization storage",
payload.username,
successful_auths.len(),
total_servers
);
// Return the first successful authentication (you could also implement priority logic here)
Ok(Json(successful_auths[0].clone()))
}
}
/// Authenticates a user on a specific server
async fn authenticate_on_server(
state: AppState,
headers: HeaderMap,
payload: AuthenticateRequest,
server: crate::server_storage::Server,
server_mapping: Option<crate::user_authorization_service::ServerMapping>,
) -> Result<AuthenticateResponse, AuthError> {
let server_url = server.url.as_str().trim_end_matches('/');
let auth_url = format!("{server_url}/Users/authenticatebyname");
info!(
"Authenticating user '{}' at server '{}' ({})",
payload.username, server.name, auth_url
);
// Get user mapping for this server
let (final_username, final_password) = if let Some(mapping) = &server_mapping {
(
mapping.mapped_username.clone(),
mapping.mapped_password.clone(),
)
} else {
(payload.username.clone(), payload.password.clone())
};
// Create authentication payload
let auth_payload = AuthenticateRequest {
username: final_username.clone(),
password: final_password.clone(),
};
// Extract authorization header or use default
let auth_header = extract_auth_header(&headers);
let authorization = Authorization::parse(&auth_header).unwrap_or_else(|_| Authorization {
client: "Jellyfin Web".to_string(),
device: "JellyswarmProxy".to_string(),
device_id: "jellyswarrm-proxy-001".to_string(),
version: "10.10.7".to_string(),
token: None,
});
// Make authentication request
let response = state
.reqwest_client
.post(&auth_url)
.header("Authorization", &auth_header)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.json(&auth_payload)
.send()
.await
.map_err(|e| {
tracing::error!(
"Failed to send authentication request to {}: {}",
server.name,
e
);
AuthError::NetworkError(e.to_string())
})?;
// Check response status
if !response.status().is_success() {
tracing::warn!(
"Authentication failed for server '{}' with status: {}",
server.name,
response.status()
);
return Err(AuthError::InvalidCredentials);
}
// Parse response
let mut auth_response = response.json::<AuthenticateResponse>().await.map_err(|e| {
tracing::error!(
"Failed to parse authentication response from {}: {}",
server.name,
e
);
AuthError::ParseError(e.to_string())
})?;
// We authenticated sucessfully, now we need to get the user or create it
let user = state
.user_authorization
.get_or_create_user(&payload.username, &payload.password)
.await
.map_err(|e| {
tracing::error!("Error getting user: {}", e);
AuthError::InternalError
})?;
// if we dont have a server mapping, we need to create one
if server_mapping.is_none() {
info!(
"Creating server mapping for user '{}' on server '{}'",
payload.username, server.name
);
state
.user_authorization
.add_server_mapping(
&user.id,
server.url.as_str(),
&payload.username,
&payload.password,
)
.await
.map_err(|e| {
tracing::error!("Error creating server mapping: {}", e);
AuthError::InternalError
})?;
}
let auth_token = auth_response.access_token.clone();
let original_user_id = auth_response.user.id.clone();
let server_id = state.config.read().await.server_id.clone();
auth_response.server_id = server_id.clone();
auth_response.user.server_id = server_id.clone();
auth_response.session_info.server_id = server_id.clone();
auth_response.session_info.user_id = user.id.clone();
// Restore original username in response
auth_response.user.name = payload.username.clone();
auth_response.session_info.user_name = payload.username.clone();
// Modify admin status (security measure)
auth_response.user.policy.is_administrator = false;
// Generate a unique access token for this authentication
auth_response.access_token = user.virtual_key.clone();
// Use our user id as the user ID in the response
auth_response.user.id = user.id.clone();
// Store authorization data with the new access token
let mut auth_to_store = authorization.clone();
auth_to_store.token = Some(auth_token.clone());
// Store authorization session
state
.user_authorization
.store_authorization_session(
&user.id,
server.url.as_str(),
&auth_to_store,
auth_token.clone(),
original_user_id, // Store the original Jellyfin user ID
None, // No expiration for now
)
.await
.map_err(|e| {
tracing::error!("Error storing authorization session: {}", e);
AuthError::InternalError
})?;
info!(
"Successfully authenticated user '{}' on server '{}' and stored authorization data with token: {}",
payload.username, server.name, auth_token
);
Ok(auth_response)
}
/// Extracts authorization header or provides default
fn extract_auth_header(headers: &HeaderMap) -> String {
headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.and_then(|auth_str| Authorization::parse(auth_str).ok())
.map(|auth| auth.to_header_value())
.unwrap_or(r#"MediaBrowser Client="Jellyfin Web", Device="JellyswarmProxy", DeviceId="jellyswarrm-proxy-001", Version="10.10.7""#.to_string())
}
/// Custom error type for authentication operations
#[derive(Debug)]
#[allow(dead_code)]
enum AuthError {
NetworkError(String),
InvalidCredentials,
ParseError(String),
InternalError,
}

View File

@@ -0,0 +1,119 @@
use axum::extract::{Request, State};
use hyper::StatusCode;
use regex::Regex;
use tracing::{error, info};
use crate::{request_preprocessing::preprocess_request, AppState};
//http://localhost:3000/videos/71bda5a4-267a-1a6c-49ce-8536d36628d8/master.m3u8?DeviceId=TW96aWxsYS81LjAgKFgxMTsgTGludXggeDg2XzY0OyBydjoxNDEuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC8xNDEuMHwxNzUzNTM1MDA0NDk4&MediaSourceId=4984199da7b84d1d8ca640cafe041e20&VideoCodec=av1%2Ch264%2Cvp9&AudioCodec=aac%2Copus%2Cflac&AudioStreamIndex=1&VideoBitrate=2147099647&AudioBitrate=384000&MaxFramerate=24&PlaySessionId=f6f93680f3f345e1a90c8d73d8c56698&api_key=2fac9237707a4bfb8a6a601ba0c6b4a0&SubtitleMethod=Encode&TranscodingMaxAudioChannels=2&RequireAvc=false&EnableAudioVbrEncoding=true&Tag=dcfdf6b92443006121a95aaa46804a0a&SegmentContainer=mp4&MinSegments=1&BreakOnNonKeyFrames=True&h264-level=40&h264-videobitdepth=8&h264-profile=high&av1-profile=main&av1-rangetype=SDR&av1-level=19&vp9-rangetype=SDR&h264-rangetype=SDR&h264-deinterlace=true&TranscodeReasons=ContainerNotSupported%2C+AudioCodecNotSupported
pub async fn get_stream_part(
State(state): State<AppState>,
req: Request,
) -> Result<axum::response::Redirect, StatusCode> {
let preprocessed = preprocess_request(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
let original_request = preprocessed
.original_request
.ok_or(StatusCode::BAD_REQUEST)?;
let re = Regex::new(r"/videos/([^/]+)/").unwrap();
let id = re
.captures(original_request.url().path())
.and_then(|cap| cap.get(1))
.map(|m| m.as_str())
.unwrap_or_default()
.to_string();
let server = if let Some(session) = state.play_sessions.get_session_by_item_id(&id).await {
info!(
"Found play session for item: {}, server: {}",
id, session.server.name
);
session.server
} else {
error!("No play session found for item: {}", id);
return Err(StatusCode::NOT_FOUND);
};
let mut new_url = server.url.clone();
// Get the original path and query
let orig_url = original_request.url().clone();
new_url.set_path(orig_url.path());
new_url.set_query(orig_url.query());
info!("Redirecting to: {}", new_url);
// Redirect to the actual jellyfin server
Ok(axum::response::Redirect::temporary(new_url.as_ref()))
}
//http://localhost:3000/Videos/82fe5aab-29ff-9630-05c2-da1a5a640428/82fe5aab29ff963005c2da1a5a640428/Attachments/5
//http://localhost:3000/Videos/71bda5a4-267a-1a6c-49ce-8536d36628d8/71bda5a4267a1a6c49ce8536d36628d8/Subtitles/3/0/Stream.js?api_key=4543ddacf7544d258444677c680d81a5
pub async fn get_video_resource(
State(state): State<AppState>,
req: Request,
) -> Result<axum::response::Redirect, StatusCode> {
let preprocessed = preprocess_request(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
let original_request = preprocessed
.original_request
.ok_or(StatusCode::BAD_REQUEST)?;
let re = Regex::new(r"/Videos/([^/]+)/").unwrap();
let captures = re
.captures(original_request.url().path())
.ok_or(StatusCode::NOT_FOUND)?;
let id = captures.get(1).map_or("", |m| m.as_str());
let server = if let Some(session) = state.play_sessions.get_session_by_item_id(id).await {
info!(
"Found play session for resource: {}, server: {}",
id, session.server.name
);
session.server
} else {
error!("No play session found for resource: {}", id);
return Err(StatusCode::NOT_FOUND);
};
let mut new_url = server.url.clone();
// Get the original path and query
let orig_url = original_request.url().clone();
new_url.set_path(orig_url.path());
new_url.set_query(orig_url.query());
info!("Redirecting HLS stream to: {}", new_url);
Ok(axum::response::Redirect::temporary(new_url.as_ref()))
}
pub async fn get_mkv(
State(state): State<AppState>,
req: Request,
) -> Result<axum::response::Redirect, StatusCode> {
let preprocessed = preprocess_request(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
let url = preprocessed.request.url().clone();
info!("Redirecting MKV stream to: {}", url);
Ok(axum::response::Redirect::temporary(url.as_ref()))
}

View File

@@ -0,0 +1,461 @@
use axum::{
body::Body,
extract::{Request, State},
http::{HeaderName, StatusCode},
response::Response,
routing::{any, get, post},
Router,
};
use axum_messages::MessagesManagerLayer;
use percent_encoding::percent_decode_str;
use rust_embed::RustEmbed;
use sqlx::{sqlite::SqliteConnectOptions, SqlitePool};
use std::{net::SocketAddr, str::FromStr};
use std::{sync::Arc, time::Duration};
use tokio::task::AbortHandle;
use tower::ServiceBuilder;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tower_sessions::cookie::Key;
use tower_sessions_sqlx_store::SqliteStore;
use tracing::{error, info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use axum_login::{
tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer},
AuthManagerLayerBuilder,
};
mod config;
mod handlers;
mod media_storage_service;
mod models;
mod request_preprocessing;
mod server_storage;
mod session_storage;
mod ui;
mod url_helper;
mod user_authorization_service;
use media_storage_service::MediaStorageService;
use server_storage::ServerStorageService;
use user_authorization_service::UserAuthorizationService;
use crate::{
config::AppConfig,
ui::{resource_handler, Backend},
};
use crate::{
config::DATA_DIR, request_preprocessing::preprocess_request, session_storage::SessionStorage,
ui::ui_routes,
};
#[derive(Clone)]
pub struct AppState {
pub reqwest_client: reqwest::Client,
pub user_authorization: Arc<UserAuthorizationService>,
pub server_storage: Arc<ServerStorageService>,
pub media_storage: Arc<MediaStorageService>,
pub play_sessions: Arc<SessionStorage>,
pub config: Arc<tokio::sync::RwLock<AppConfig>>,
}
#[derive(RustEmbed)]
#[folder = "static/"]
struct Asset;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize file logging
let file_appender = tracing_appender::rolling::daily(DATA_DIR.join("logs"), "jellyswarm.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
// Create an environment filter to only show logs from our application
let env_filter = EnvFilter::new("jellyswarrm_proxy=info");
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false),
)
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stdout))
.init();
let loaded_config = crate::config::load_config();
info!("Loaded configuration: {:?}", loaded_config);
// Resolve database path inside DATA_DIR
let db_path = DATA_DIR.join("jellyswarrm.db");
let db_url = format!("sqlite://{}", db_path.to_string_lossy());
let options = SqliteConnectOptions::from_str(&db_url)?.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await?;
// Create reqwest client
let reqwest_client = reqwest::Client::builder()
.timeout(Duration::from_secs(loaded_config.timeout))
.build()
.unwrap_or_else(|e| {
error!("Failed to create reqwest client: {}", e);
std::process::exit(1);
});
// Initialize user authorization service
let user_authorization = UserAuthorizationService::new(pool.clone())
.await
.unwrap_or_else(|e| {
error!("Failed to initialize user authorization service: {}", e);
std::process::exit(1);
});
// Initialize server storage service
let server_storage = ServerStorageService::new(pool.clone())
.await
.unwrap_or_else(|e| {
error!("Failed to initialize server storage database: {}", e);
std::process::exit(1);
});
// Initialize media storage service
let media_storage = MediaStorageService::new(pool.clone())
.await
.unwrap_or_else(|e| {
error!("Failed to initialize media storage service: {}", e);
std::process::exit(1);
});
match server_storage.list_servers().await {
Ok(servers) => {
if servers.is_empty() {
warn!("No servers found, configur them via the UI.");
} else {
info!("Found {} configured servers", servers.len());
for server in &servers {
info!(
" {} ({}): priority {}",
server.name, server.url, server.priority,
);
}
}
}
Err(e) => {
error!("Failed to check existing servers: {}", e);
}
}
let app_state = AppState {
reqwest_client,
user_authorization: Arc::new(user_authorization),
server_storage: Arc::new(server_storage),
media_storage: Arc::new(media_storage),
play_sessions: Arc::new(SessionStorage::new()),
config: Arc::new(tokio::sync::RwLock::new(loaded_config.clone())),
};
let session_store = SqliteStore::new(pool);
session_store.migrate().await?;
let deletion_task = tokio::task::spawn(
session_store
.clone()
.continuously_delete_expired(tokio::time::Duration::from_secs(60)),
);
let key = Key::from(loaded_config.session_key.as_slice());
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false)
.with_expiry(Expiry::OnInactivity(time::Duration::days(1))) // 24 hour
.with_signed(key);
let backend = Backend::new(app_state.config.clone());
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
let ui_route = "/ui";
let app = Router::new()
// UI Management routes
.nest(ui_route, ui_routes())
.route("/", get(index_handler))
.route("/resources/{*path}", get(resource_handler))
.route(
"/QuickConnect/Enabled",
get(handlers::quick_connect::handle_quick_connect),
)
.route(
"/Branding/Configuration",
get(handlers::branding::handle_branding),
)
// User authentication and profile routes
.nest(
"/Users",
Router::new()
.route(
"/authenticatebyname",
post(handlers::users::handle_authenticate_by_name),
)
.route("/{user_id}", get(handlers::users::handle_get_user_by_id))
.route("/{user_id}/Items", get(handlers::items::get_items))
.route(
"/{user_id}/Items/Resume",
get(handlers::federated::get_items_from_all_servers),
)
.route(
"/{user_id}/Items/Latest",
get(handlers::items::get_items_list),
)
.route("/{user_id}/Items/{item_id}", get(handlers::items::get_item))
.route(
"/{user_id}/Items/{item_id}/SpecialFeatures",
get(handlers::items::get_items_list),
),
)
.route(
"/UserViews",
get(handlers::federated::get_items_from_all_servers),
)
// System info routes
.nest(
"/System",
Router::new()
.route("/Info", get(handlers::system::info))
.route("/Info/Public", get(handlers::system::info_public)),
)
.route("/system/info/public", get(handlers::system::info_public))
// Item routes (non-user specific)
.nest(
"/Items",
Router::new()
.route("/", get(handlers::federated::get_items_from_all_servers))
.route("/{item_id}", get(handlers::items::get_item))
.route("/{item_id}/Similar", get(handlers::items::get_items))
.route(
"/{item_id}/PlaybackInfo",
post(handlers::items::post_playback_info),
),
)
// Show-specific routes
.nest(
"/Shows",
Router::new()
.route("/{item_id}/Seasons", get(handlers::items::get_items))
.route("/{item_id}/Episodes", get(handlers::items::get_items))
.route(
"/NextUp",
get(handlers::federated::get_items_from_all_servers_if_not_restricted),
),
)
.route("/LiveTv/Programs", get(handlers::items::get_items))
// Video streaming routes
.nest(
"/Videos",
Router::new()
.route("/{item_id}/stream.mkv", get(handlers::videos::get_mkv))
.route("/{item_id}/stream.mp4", get(handlers::videos::get_mkv))
.route(
"/{stream_id}/{item_id}/{*path}",
get(handlers::videos::get_video_resource),
),
)
.route(
"/videos/{stream_id}/{*path}",
get(handlers::videos::get_stream_part),
)
// Session management routes
.nest(
"/Sessions/Playing",
Router::new()
.route("/", post(handlers::sessions::post_playing))
.route("/Progress", post(handlers::sessions::post_playing))
.route("/Stopped", post(handlers::sessions::post_playing)),
)
// Persons
.nest(
"/Persons",
Router::new().route("/", get(handlers::federated::get_items_from_all_servers)),
)
// Artists
.nest(
"/Artists",
Router::new().route("/", get(handlers::federated::get_items_from_all_servers)),
)
.route("/{*path}", any(proxy_handler))
.fallback(proxy_handler)
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive()),
)
.layer(MessagesManagerLayer)
.layer(auth_layer)
.with_state(app_state);
// Create socket address
let addr = match format!("{}:{}", loaded_config.host, loaded_config.port).parse::<SocketAddr>()
{
Ok(addr) => addr,
Err(e) => {
error!(
"Invalid address {}:{}: {}",
loaded_config.host, loaded_config.port, e
);
std::process::exit(1);
}
};
info!("Starting reverse proxy on http://{}", addr);
info!(
"UI Management routes available at: http://{}/{}",
addr,
ui_route.trim_start_matches('/')
);
// Start the server
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => listener,
Err(e) => {
error!("Failed to bind to {}: {}", addr, e);
std::process::exit(1);
}
};
axum::serve(listener, app.into_make_service())
.with_graceful_shutdown(shutdown_signal(deletion_task.abort_handle()))
.await?;
deletion_task.await??;
Ok(())
}
async fn index_handler(
State(state): State<AppState>,
_req: Request,
) -> Result<Response<Body>, StatusCode> {
let servers = state.server_storage.list_servers().await.map_err(|e| {
error!("Failed to list servers: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
if servers.is_empty() {
// No servers configured, redirect to UI management
Ok(Response::builder()
.status(StatusCode::TEMPORARY_REDIRECT)
.header("Location", "/ui")
.body(Body::empty())
.unwrap())
} else {
// Servers exist, return the index.html page
if let Some(content) = Asset::get("index.html") {
Ok(Response::builder()
.header("Content-Type", "text/html")
.body(Body::from(content.data.into_owned()))
.unwrap())
} else {
// Fallback if index.html is not found in assets
error!("index.html not found in static assets");
Err(StatusCode::NOT_FOUND)
}
}
}
async fn proxy_handler(
State(state): State<AppState>,
req: Request,
) -> Result<Response<Body>, StatusCode> {
// check if a resource was requested
let path = req.uri().path();
let path = if path.starts_with('/') {
&path[1..] // remove leading slash
} else {
path
};
let path = if path.is_empty() { "index.html" } else { path };
let decoded_path = percent_decode_str(path).decode_utf8_lossy().to_string();
if let Some(content) = Asset::get(&decoded_path) {
let mime = mime_guess::from_path(decoded_path).first_or_octet_stream();
return Ok(Response::builder()
.header("Content-Type", mime.as_ref())
.body(Body::from(content.data.into_owned()))
.unwrap());
}
let preprocessed = preprocess_request(req, &state).await.map_err(|e| {
error!("Failed to preprocess request: {}", e);
StatusCode::BAD_REQUEST
})?;
//info!("Proxying request: {:?}", preprocessed);
let response = state
.reqwest_client
.execute(preprocessed.request)
.await
.map_err(|e| {
error!("Failed to execute proxy request: {}", e);
StatusCode::BAD_GATEWAY
})?;
let status = response.status();
let headers = response.headers().clone();
let body_bytes = response.bytes().await.map_err(|e| {
error!("Failed to read response body: {}", e);
StatusCode::BAD_GATEWAY
})?;
let mut response_builder = Response::builder().status(status);
// Copy headers, filtering out hop-by-hop headers
for (name, value) in headers.iter() {
if !is_hop_by_hop_header(name) {
response_builder = response_builder.header(name, value);
}
}
let response = response_builder.body(Body::from(body_bytes)).map_err(|e| {
error!("Failed to build response: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(response)
}
fn is_hop_by_hop_header(name: &HeaderName) -> bool {
// RFC 7230 Section 6.1: Hop-by-hop headers
matches!(
name.as_str().to_lowercase().as_str(),
"connection"
| "keep-alive"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailers"
| "transfer-encoding"
| "upgrade"
)
}
async fn shutdown_signal(deletion_task_abort_handle: AbortHandle) {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => { deletion_task_abort_handle.abort() },
_ = terminate => { deletion_task_abort_handle.abort() },
}
}

View File

@@ -0,0 +1,374 @@
use sqlx::{FromRow, Row, SqlitePool};
use tracing::{debug, info};
use crate::models::generate_token;
use crate::server_storage::Server;
#[derive(Debug, Clone, FromRow)]
pub struct MediaMapping {
pub id: i64,
pub virtual_media_id: String,
pub original_media_id: String,
pub server_url: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub struct MediaStorageService {
pool: SqlitePool,
}
impl MediaStorageService {
pub async fn new(pool: SqlitePool) -> Result<Self, sqlx::Error> {
// Create media_mappings table
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS media_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
virtual_media_id TEXT NOT NULL UNIQUE,
original_media_id TEXT NOT NULL,
server_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(original_media_id, server_url)
)
"#,
)
.execute(&pool)
.await?;
// Create indexes for better performance
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_media_mappings_virtual_id
ON media_mappings(virtual_media_id)
"#,
)
.execute(&pool)
.await?;
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_media_mappings_original_server
ON media_mappings(original_media_id, server_url)
"#,
)
.execute(&pool)
.await?;
info!("Media storage service database initialized");
Ok(Self { pool })
}
/// Create or get a media mapping
pub async fn get_or_create_media_mapping(
&self,
original_media_id: &str,
server_url: &str,
) -> Result<MediaMapping, sqlx::Error> {
// Try to find existing mapping
if let Some(mapping) = self
.get_media_mapping_by_original(original_media_id, server_url)
.await?
{
return Ok(mapping);
}
// Create new mapping
let virtual_media_id = generate_token();
let now = chrono::Utc::now();
let inserted = sqlx::query_as::<_, MediaMapping>(
r#"
INSERT INTO media_mappings (virtual_media_id, original_media_id, server_url, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(original_media_id, server_url) DO NOTHING
RETURNING id, virtual_media_id, original_media_id, server_url, created_at
"#,
)
.bind(&virtual_media_id)
.bind(original_media_id)
.bind(server_url)
.bind(now)
.fetch_optional(&self.pool)
.await?;
if let Some(row) = inserted {
debug!(
"Created new media mapping: {} -> {} ({})",
original_media_id, row.virtual_media_id, server_url
);
return Ok(row);
}
// Conflict path: fetch existing row. Happens if another process created it concurrently
if let Some(existing) = self
.get_media_mapping_by_original(original_media_id, server_url)
.await?
{
return Ok(existing);
}
// If we reach here, something went very wrong
Err(sqlx::Error::RowNotFound)
}
/// Get media mapping by virtual media ID
pub async fn get_media_mapping_by_virtual(
&self,
virtual_media_id: &str,
) -> Result<Option<MediaMapping>, sqlx::Error> {
let mapping = sqlx::query_as::<_, MediaMapping>(
r#"
SELECT id, virtual_media_id, original_media_id, server_url, created_at
FROM media_mappings
WHERE virtual_media_id = ?
"#,
)
.bind(virtual_media_id)
.fetch_optional(&self.pool)
.await?;
Ok(mapping)
}
/// Get media mapping by original media ID and server
pub async fn get_media_mapping_by_original(
&self,
original_media_id: &str,
server_url: &str,
) -> Result<Option<MediaMapping>, sqlx::Error> {
let mapping = sqlx::query_as::<_, MediaMapping>(
r#"
SELECT id, virtual_media_id, original_media_id, server_url, created_at
FROM media_mappings
WHERE original_media_id = ? AND server_url = ?
"#,
)
.bind(original_media_id)
.bind(server_url)
.fetch_optional(&self.pool)
.await?;
Ok(mapping)
}
/// Get media mapping with server information by virtual media ID
pub async fn get_media_mapping_with_server(
&self,
virtual_media_id: &str,
) -> Result<Option<(MediaMapping, Server)>, sqlx::Error> {
let row = sqlx::query(
r#"
SELECT
m.id as media_id,
m.virtual_media_id,
m.original_media_id,
m.server_url as media_server_url,
m.created_at as media_created_at,
s.id as server_id,
s.name as server_name,
s.url as server_url_full,
s.priority,
s.created_at as server_created_at,
s.updated_at as server_updated_at
FROM media_mappings m
JOIN servers s ON RTRIM(m.server_url, '/') = RTRIM(s.url, '/')
WHERE m.virtual_media_id = ?
"#,
)
.bind(virtual_media_id)
.fetch_optional(&self.pool)
.await?;
if let Some(row) = row {
let mapping = MediaMapping {
id: row.get("media_id"),
virtual_media_id: row.get("virtual_media_id"),
original_media_id: row.get("original_media_id"),
server_url: row.get("media_server_url"),
created_at: row.get("media_created_at"),
};
let server = Server {
id: row.get("server_id"),
name: row.get("server_name"),
url: url::Url::parse(row.get::<String, _>("server_url_full").as_str()).unwrap(),
priority: row.get("priority"),
created_at: row.get("server_created_at"),
updated_at: row.get("server_updated_at"),
};
Ok(Some((mapping, server)))
} else {
Ok(None)
}
}
/// Delete a media mapping
pub async fn delete_media_mapping(&self, virtual_media_id: &str) -> Result<bool, sqlx::Error> {
let result = sqlx::query(
r#"
DELETE FROM media_mappings WHERE virtual_media_id = ?
"#,
)
.bind(virtual_media_id)
.execute(&self.pool)
.await?;
if result.rows_affected() > 0 {
info!("Deleted media mapping: {}", virtual_media_id);
Ok(true)
} else {
Ok(false)
}
}
/// Delete all media mappings for a specific server
pub async fn delete_media_mappings_by_server(
&self,
server_url: &str,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
r#"
DELETE FROM media_mappings WHERE server_url = ?
"#,
)
.bind(server_url)
.execute(&self.pool)
.await?;
let deleted_count = result.rows_affected();
if deleted_count > 0 {
info!(
"Deleted {} media mappings for server: {}",
deleted_count, server_url
);
}
Ok(deleted_count)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_media_storage_service() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
let service = MediaStorageService::new(pool.clone()).await.unwrap();
// Create media mapping
let mapping = service
.get_or_create_media_mapping("original-movie-123", "http://localhost:8096")
.await
.unwrap();
assert_eq!(mapping.original_media_id, "original-movie-123");
assert_eq!(mapping.server_url, "http://localhost:8096");
// Get mapping by virtual ID
let retrieved_mapping = service
.get_media_mapping_by_virtual(&mapping.virtual_media_id)
.await
.unwrap()
.unwrap();
assert_eq!(retrieved_mapping.virtual_media_id, mapping.virtual_media_id);
assert_eq!(retrieved_mapping.original_media_id, "original-movie-123");
}
#[tokio::test]
async fn test_get_media_mapping_with_server() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
let service = MediaStorageService::new(pool.clone()).await.unwrap();
// Create the servers table (normally done by ServerStorageService)
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 100,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"#,
)
.execute(&pool)
.await
.unwrap();
// Create a server in the servers table
sqlx::query(
r#"
INSERT INTO servers (name, url, priority, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
"#,
)
.bind("Test Server")
.bind("http://localhost:8096")
.bind(100)
.bind(chrono::Utc::now())
.bind(chrono::Utc::now())
.execute(&pool)
.await
.unwrap();
// Create media mapping
let mapping = service
.get_or_create_media_mapping("original-movie-123", "http://localhost:8096")
.await
.unwrap();
// Get mapping with server info
let (retrieved_mapping, server) = service
.get_media_mapping_with_server(&mapping.virtual_media_id)
.await
.unwrap()
.unwrap();
assert_eq!(retrieved_mapping.virtual_media_id, mapping.virtual_media_id);
assert_eq!(retrieved_mapping.original_media_id, "original-movie-123");
assert_eq!(server.name, "Test Server");
assert_eq!(
server.url.as_str().trim_end_matches('/'),
"http://localhost:8096"
);
}
#[tokio::test]
async fn test_delete_operations() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
let service = MediaStorageService::new(pool.clone()).await.unwrap();
// Create media mapping
let mapping = service
.get_or_create_media_mapping("movie-123", "http://localhost:8096")
.await
.unwrap();
// Verify mapping exists
assert!(service
.get_media_mapping_by_virtual(&mapping.virtual_media_id)
.await
.unwrap()
.is_some());
// Delete mapping
let deleted = service
.delete_media_mapping(&mapping.virtual_media_id)
.await
.unwrap();
assert!(deleted);
// Verify mapping is gone
assert!(service
.get_media_mapping_by_virtual(&mapping.virtual_media_id)
.await
.unwrap()
.is_none());
}
}

View File

@@ -0,0 +1,194 @@
use percent_encoding::percent_decode_str;
pub fn generate_token() -> String {
use uuid::Uuid;
Uuid::new_v4().simple().to_string()
}
#[derive(Debug, Clone)]
pub struct Authorization {
pub client: String,
pub device: String,
pub device_id: String,
pub version: String,
pub token: Option<String>,
}
impl Authorization {
pub fn parse(header_value: &str) -> Result<Self, String> {
if !header_value.starts_with("MediaBrowser ") {
return Err("Invalid authorization header format".to_string());
}
let content = &header_value[12..]; // Skip "MediaBrowser "
let mut client = String::new();
let mut device = String::new();
let mut device_id = String::new();
let mut version = String::new();
let mut token = None;
let parts = parse_quoted_params(content)?;
for (key, value) in parts {
match key.as_str() {
"Client" => client = percent_decode_str(&value).decode_utf8_lossy().to_string(),
"Device" => device = percent_decode_str(&value).decode_utf8_lossy().to_string(),
"DeviceId" => device_id = value,
"Version" => version = value,
"Token" => token = Some(value),
_ => {} // Ignore unknown parameters
}
}
if client.is_empty() || device.is_empty() || device_id.is_empty() || version.is_empty() {
return Err("Missing required authorization parameters".to_string());
}
Ok(Authorization {
client,
device,
device_id,
version,
token,
})
}
/// Convert to MediaBrowser authorization header string
pub fn to_header_value(&self) -> String {
let mut result = format!(
"MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"",
self.client, self.device, self.device_id, self.version
);
if let Some(token) = &self.token {
result.push_str(&format!(", Token=\"{token}\""));
}
result
}
/// Convert to header value without "MediaBrowser " prefix
pub fn to_params_string(&self) -> String {
let mut result = format!(
"Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"",
self.client, self.device, self.device_id, self.version
);
if let Some(token) = &self.token {
result.push_str(&format!(", Token=\"{token}\""));
}
result
}
/// Get a short string representation for logging
pub fn to_short_string(&self) -> String {
format!(
"{} on {} ({})",
self.client,
self.device,
self.token.as_deref().unwrap_or("no token")
)
}
}
fn parse_quoted_params(content: &str) -> Result<Vec<(String, String)>, String> {
let mut params = Vec::new();
let mut chars = content.chars().peekable();
while chars.peek().is_some() {
// Skip whitespace and commas
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() || ch == ',' {
chars.next();
} else {
break;
}
}
if chars.peek().is_none() {
break;
}
// Parse key
let mut key = String::new();
while let Some(&ch) = chars.peek() {
if ch == '=' {
chars.next(); // consume '='
break;
} else if ch.is_alphanumeric() || ch == '_' {
key.push(chars.next().unwrap());
} else {
return Err(format!("Invalid character in parameter key: {ch}"));
}
}
if key.is_empty() {
return Err("Empty parameter key".to_string());
}
// Parse value (quoted)
if chars.peek() != Some(&'"') {
return Err("Expected quoted value".to_string());
}
chars.next(); // consume opening quote
let mut value = String::new();
let mut escaped = false;
for ch in chars.by_ref() {
if escaped {
value.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
break; // end of quoted value
} else {
value.push(ch);
}
}
params.push((key, value));
}
Ok(params)
}
impl std::fmt::Display for Authorization {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"",
self.client, self.device, self.device_id, self.version
)?;
if let Some(token) = &self.token {
write!(f, ", Token=\"{token}\"")?;
}
Ok(())
}
}
// Usage example:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_authorization() {
let header = r#"MediaBrowser Client="Jellyfin Web", Device="Firefox", DeviceId="TW96aWxsYS81LjAgKFgxMTsgTGludXggeDg2XzY0OyBydjoxNDAuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC8xNDAuMHwxNzUyMDcwMzk0MDky", Version="10.10.7", Token="6fbe3193155f45b3bc3f229469db1568""#;
let auth = Authorization::parse(header).unwrap();
assert_eq!(auth.client, "Jellyfin Web");
assert_eq!(auth.device, "Firefox");
assert_eq!(auth.device_id, "TW96aWxsYS81LjAgKFgxMTsgTGludXggeDg2XzY0OyBydjoxNDAuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC8xNDAuMHwxNzUyMDcwMzk0MDky");
assert_eq!(auth.version, "10.10.7");
assert_eq!(
auth.token,
Some("6fbe3193155f45b3bc3f229469db1568".to_string())
);
}
}

View File

@@ -0,0 +1,984 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum StreamIndex {
Int(i32),
Str(String),
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlaybackRequest {
#[serde(rename = "AlwaysBurnInSubtitleWhenTranscoding")]
pub always_burn_in_subtitle_when_transcoding: bool,
#[serde(rename = "AudioStreamIndex", skip_serializing_if = "Option::is_none")]
pub audio_stream_index: Option<StreamIndex>,
#[serde(rename = "AutoOpenLiveStream")]
pub auto_open_live_stream: bool,
#[serde(rename = "DeviceProfile")]
pub device_profile: serde_json::Value,
#[serde(rename = "IsPlayback")]
pub is_playback: bool,
#[serde(rename = "MaxStreamingBitrate")]
pub max_streaming_bitrate: i64,
#[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")]
pub media_source_id: Option<String>,
#[serde(rename = "StartTimeTicks")]
pub start_time_ticks: i64,
#[serde(
rename = "SubtitleStreamIndex",
skip_serializing_if = "Option::is_none"
)]
pub subtitle_stream_index: Option<StreamIndex>,
#[serde(rename = "UserId")]
pub user_id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlaybackResponse {
#[serde(rename = "MediaSources")]
pub media_sources: Vec<MediaSource>,
#[serde(rename = "PlaySessionId")]
pub play_session_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProgressRequest {
#[serde(rename = "AudioStreamIndex", skip_serializing_if = "Option::is_none")]
pub audio_stream_index: Option<StreamIndex>,
#[serde(rename = "BufferedRanges")]
pub buffered_ranges: serde_json::Value,
#[serde(rename = "CanSeek")]
pub can_seek: bool,
#[serde(rename = "EventName", skip_serializing_if = "Option::is_none")]
pub event_name: Option<String>,
#[serde(rename = "IsMuted")]
pub is_muted: bool,
#[serde(rename = "IsPaused")]
pub is_paused: bool,
#[serde(rename = "ItemId")]
pub item_id: String,
#[serde(rename = "MaxStreamingBitrate")]
pub max_streaming_bitrate: i64,
#[serde(rename = "MediaSourceId")]
pub media_source_id: String,
#[serde(rename = "NowPlayingQueue", skip_serializing_if = "Option::is_none")]
pub now_playing_queue: Option<Vec<NowPlayingQueueItem>>,
#[serde(rename = "PlaybackRate")]
pub playback_rate: i32,
#[serde(rename = "PlaybackStartTimeTicks")]
pub playback_start_time_ticks: i64,
#[serde(rename = "PlaylistItemId")]
pub playlist_item_id: String,
#[serde(rename = "PlayMethod")]
pub play_method: String,
#[serde(rename = "PlaySessionId")]
pub play_session_id: String,
#[serde(rename = "PositionTicks")]
pub position_ticks: i64,
#[serde(rename = "RepeatMode")]
pub repeat_mode: String,
#[serde(
rename = "SecondarySubtitleStreamIndex",
skip_serializing_if = "Option::is_none"
)]
pub secondary_subtitle_stream_index: Option<StreamIndex>,
#[serde(rename = "ShuffleMode")]
pub shuffle_mode: String,
#[serde(
rename = "SubtitleStreamIndex",
skip_serializing_if = "Option::is_none"
)]
pub subtitle_stream_index: Option<StreamIndex>,
#[serde(rename = "VolumeLevel")]
pub volume_level: i32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BufferedRange {
pub start: i64,
pub end: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NowPlayingQueueItem {
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "PlaylistItemId")]
pub playlist_item_id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthenticateRequest {
#[serde(rename = "Username")]
pub username: String,
#[serde(rename = "Pw")]
pub password: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthenticateResponse {
#[serde(rename = "User")]
pub user: User,
#[serde(rename = "SessionInfo")]
pub session_info: SessionInfo,
#[serde(rename = "AccessToken")]
pub access_token: String,
#[serde(rename = "ServerId")]
pub server_id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PublicServerInfo {
#[serde(rename = "LocalAddress")]
pub local_address: String,
#[serde(rename = "ServerName")]
pub server_name: String,
#[serde(rename = "Version")]
pub version: String,
#[serde(rename = "ProductName")]
pub product_name: String,
#[serde(rename = "OperatingSystem")]
pub operating_system: String,
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "StartupWizardCompleted")]
pub startup_wizard_completed: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CastReceiverApplication {
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "Name")]
pub name: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ServerInfo {
#[serde(
rename = "OperatingSystemDisplayName",
skip_serializing_if = "Option::is_none"
)]
pub operating_system_display_name: Option<String>,
#[serde(rename = "HasPendingRestart", skip_serializing_if = "Option::is_none")]
pub has_pending_restart: Option<bool>,
#[serde(rename = "IsShuttingDown", skip_serializing_if = "Option::is_none")]
pub is_shutting_down: Option<bool>,
#[serde(
rename = "SupportsLibraryMonitor",
skip_serializing_if = "Option::is_none"
)]
pub supports_library_monitor: Option<bool>,
#[serde(
rename = "WebSocketPortNumber",
skip_serializing_if = "Option::is_none"
)]
pub web_socket_port_number: Option<i32>,
#[serde(
rename = "CompletedInstallations",
skip_serializing_if = "Option::is_none"
)]
pub completed_installations: Option<serde_json::Value>,
#[serde(rename = "CanSelfRestart", skip_serializing_if = "Option::is_none")]
pub can_self_restart: Option<bool>,
#[serde(
rename = "CanLaunchWebBrowser",
skip_serializing_if = "Option::is_none"
)]
pub can_launch_web_browser: Option<bool>,
#[serde(rename = "ProgramDataPath", skip_serializing_if = "Option::is_none")]
pub program_data_path: Option<String>,
#[serde(rename = "WebPath", skip_serializing_if = "Option::is_none")]
pub web_path: Option<String>,
#[serde(rename = "ItemsByNamePath", skip_serializing_if = "Option::is_none")]
pub items_by_name_path: Option<String>,
#[serde(rename = "CachePath", skip_serializing_if = "Option::is_none")]
pub cache_path: Option<String>,
#[serde(rename = "LogPath", skip_serializing_if = "Option::is_none")]
pub log_path: Option<String>,
#[serde(
rename = "InternalMetadataPath",
skip_serializing_if = "Option::is_none"
)]
pub internal_metadata_path: Option<String>,
#[serde(
rename = "TranscodingTempPath",
skip_serializing_if = "Option::is_none"
)]
pub transcoding_temp_path: Option<String>,
#[serde(
rename = "CastReceiverApplications",
skip_serializing_if = "Option::is_none"
)]
pub cast_receiver_applications: Option<Vec<CastReceiverApplication>>,
#[serde(rename = "HasUpdateAvailable", skip_serializing_if = "Option::is_none")]
pub has_update_available: Option<bool>,
#[serde(rename = "EncoderLocation", skip_serializing_if = "Option::is_none")]
pub encoder_location: Option<String>,
#[serde(rename = "SystemArchitecture", skip_serializing_if = "Option::is_none")]
pub system_architecture: Option<String>,
#[serde(rename = "LocalAddress")]
pub local_address: String,
#[serde(rename = "ServerName")]
pub server_name: String,
#[serde(rename = "Version", skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(rename = "OperatingSystem", skip_serializing_if = "Option::is_none")]
pub operating_system: Option<String>,
#[serde(rename = "Id")]
pub id: String,
#[serde(
rename = "StartupWizardCompleted",
skip_serializing_if = "Option::is_none"
)]
pub startup_wizard_completed: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct User {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "ServerId")]
pub server_id: String,
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "HasPassword")]
pub has_password: bool,
#[serde(rename = "HasConfiguredPassword")]
pub has_configured_password: bool,
#[serde(rename = "HasConfiguredEasyPassword")]
pub has_configured_easy_password: bool,
#[serde(rename = "EnableAutoLogin")]
pub enable_auto_login: bool,
#[serde(rename = "LastLoginDate")]
pub last_login_date: String,
#[serde(rename = "LastActivityDate")]
pub last_activity_date: String,
#[serde(rename = "Configuration")]
pub configuration: UserConfiguration,
#[serde(rename = "Policy")]
pub policy: UserPolicy,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserConfiguration {
#[serde(rename = "PlayDefaultAudioTrack")]
pub play_default_audio_track: bool,
#[serde(rename = "SubtitleLanguagePreference")]
pub subtitle_language_preference: String,
#[serde(rename = "DisplayMissingEpisodes")]
pub display_missing_episodes: bool,
#[serde(rename = "GroupedFolders")]
pub grouped_folders: Vec<String>,
#[serde(rename = "SubtitleMode")]
pub subtitle_mode: String,
#[serde(rename = "DisplayCollectionsView")]
pub display_collections_view: bool,
#[serde(rename = "EnableLocalPassword")]
pub enable_local_password: bool,
#[serde(rename = "OrderedViews")]
pub ordered_views: Vec<String>,
#[serde(rename = "LatestItemsExcludes")]
pub latest_items_excludes: Vec<String>,
#[serde(rename = "MyMediaExcludes")]
pub my_media_excludes: Vec<String>,
#[serde(rename = "HidePlayedInLatest")]
pub hide_played_in_latest: bool,
#[serde(rename = "RememberAudioSelections")]
pub remember_audio_selections: bool,
#[serde(rename = "RememberSubtitleSelections")]
pub remember_subtitle_selections: bool,
#[serde(rename = "EnableNextEpisodeAutoPlay")]
pub enable_next_episode_auto_play: bool,
#[serde(rename = "CastReceiverId")]
pub cast_receiver_id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserPolicy {
#[serde(rename = "IsAdministrator")]
pub is_administrator: bool,
#[serde(rename = "IsHidden")]
pub is_hidden: bool,
#[serde(rename = "EnableCollectionManagement")]
pub enable_collection_management: bool,
#[serde(rename = "EnableSubtitleManagement")]
pub enable_subtitle_management: bool,
#[serde(rename = "EnableLyricManagement")]
pub enable_lyric_management: bool,
#[serde(rename = "IsDisabled")]
pub is_disabled: bool,
#[serde(rename = "BlockedTags")]
pub blocked_tags: Vec<String>,
#[serde(rename = "AllowedTags")]
pub allowed_tags: Vec<String>,
#[serde(rename = "EnableUserPreferenceAccess")]
pub enable_user_preference_access: bool,
#[serde(rename = "AccessSchedules")]
pub access_schedules: Vec<String>,
#[serde(rename = "BlockUnratedItems")]
pub block_unrated_items: Vec<String>,
#[serde(rename = "EnableRemoteControlOfOtherUsers")]
pub enable_remote_control_of_other_users: bool,
#[serde(rename = "EnableSharedDeviceControl")]
pub enable_shared_device_control: bool,
#[serde(rename = "EnableRemoteAccess")]
pub enable_remote_access: bool,
#[serde(rename = "EnableLiveTvManagement")]
pub enable_live_tv_management: bool,
#[serde(rename = "EnableLiveTvAccess")]
pub enable_live_tv_access: bool,
#[serde(rename = "EnableMediaPlayback")]
pub enable_media_playback: bool,
#[serde(rename = "EnableAudioPlaybackTranscoding")]
pub enable_audio_playback_transcoding: bool,
#[serde(rename = "EnableVideoPlaybackTranscoding")]
pub enable_video_playback_transcoding: bool,
#[serde(rename = "EnablePlaybackRemuxing")]
pub enable_playback_remuxing: bool,
#[serde(rename = "ForceRemoteSourceTranscoding")]
pub force_remote_source_transcoding: bool,
#[serde(rename = "EnableContentDeletion")]
pub enable_content_deletion: bool,
#[serde(rename = "EnableContentDeletionFromFolders")]
pub enable_content_deletion_from_folders: Vec<String>,
#[serde(rename = "EnableContentDownloading")]
pub enable_content_downloading: bool,
#[serde(rename = "EnableSyncTranscoding")]
pub enable_sync_transcoding: bool,
#[serde(rename = "EnableMediaConversion")]
pub enable_media_conversion: bool,
#[serde(rename = "EnabledDevices")]
pub enabled_devices: Vec<String>,
#[serde(rename = "EnableAllDevices")]
pub enable_all_devices: bool,
#[serde(rename = "EnabledChannels")]
pub enabled_channels: Vec<String>,
#[serde(rename = "EnableAllChannels")]
pub enable_all_channels: bool,
#[serde(rename = "EnabledFolders")]
pub enabled_folders: Vec<String>,
#[serde(rename = "EnableAllFolders")]
pub enable_all_folders: bool,
#[serde(rename = "InvalidLoginAttemptCount")]
pub invalid_login_attempt_count: i32,
#[serde(rename = "LoginAttemptsBeforeLockout")]
pub login_attempts_before_lockout: i32,
#[serde(rename = "MaxActiveSessions")]
pub max_active_sessions: i32,
#[serde(rename = "EnablePublicSharing")]
pub enable_public_sharing: bool,
#[serde(rename = "BlockedMediaFolders")]
pub blocked_media_folders: Vec<String>,
#[serde(rename = "BlockedChannels")]
pub blocked_channels: Vec<String>,
#[serde(rename = "RemoteClientBitrateLimit")]
pub remote_client_bitrate_limit: i32,
#[serde(rename = "AuthenticationProviderId")]
pub authentication_provider_id: String,
#[serde(rename = "PasswordResetProviderId")]
pub password_reset_provider_id: String,
#[serde(rename = "SyncPlayAccess")]
pub sync_play_access: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SessionInfo {
#[serde(rename = "PlayState")]
pub play_state: PlayState,
#[serde(rename = "AdditionalUsers")]
pub additional_users: Vec<String>,
#[serde(rename = "Capabilities")]
pub capabilities: Capabilities,
#[serde(rename = "RemoteEndPoint")]
pub remote_end_point: String,
#[serde(rename = "PlayableMediaTypes")]
pub playable_media_types: Vec<String>,
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "UserId")]
pub user_id: String,
#[serde(rename = "UserName")]
pub user_name: String,
#[serde(rename = "Client")]
pub client: String,
#[serde(rename = "LastActivityDate")]
pub last_activity_date: String,
#[serde(rename = "LastPlaybackCheckIn")]
pub last_playback_check_in: String,
#[serde(rename = "DeviceName")]
pub device_name: String,
#[serde(rename = "DeviceId")]
pub device_id: String,
#[serde(rename = "ApplicationVersion")]
pub application_version: String,
#[serde(rename = "IsActive")]
pub is_active: bool,
#[serde(rename = "SupportsMediaControl")]
pub supports_media_control: bool,
#[serde(rename = "SupportsRemoteControl")]
pub supports_remote_control: bool,
#[serde(rename = "NowPlayingQueue")]
pub now_playing_queue: Vec<String>,
#[serde(rename = "NowPlayingQueueFullItems")]
pub now_playing_queue_full_items: Vec<String>,
#[serde(rename = "HasCustomDeviceName")]
pub has_custom_device_name: bool,
#[serde(rename = "ServerId")]
pub server_id: String,
#[serde(rename = "SupportedCommands")]
pub supported_commands: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlayState {
#[serde(rename = "CanSeek")]
pub can_seek: bool,
#[serde(rename = "IsPaused")]
pub is_paused: bool,
#[serde(rename = "IsMuted")]
pub is_muted: bool,
#[serde(rename = "RepeatMode")]
pub repeat_mode: String,
#[serde(rename = "PlaybackOrder")]
pub playback_order: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Capabilities {
#[serde(rename = "PlayableMediaTypes")]
pub playable_media_types: Vec<String>,
#[serde(rename = "SupportedCommands")]
pub supported_commands: Vec<String>,
#[serde(rename = "SupportsMediaControl")]
pub supports_media_control: bool,
#[serde(rename = "SupportsPersistentIdentifier")]
pub supports_persistent_identifier: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BrandingConfig {
#[serde(rename = "LoginDisclaimer")]
pub login_disclaimer: String,
#[serde(rename = "CustomCss")]
pub custom_css: String,
#[serde(rename = "SplashscreenEnabled")]
pub splashscreen_enabled: bool,
}
impl Default for BrandingConfig {
fn default() -> Self {
Self {
login_disclaimer:
"You are using Jellyswarrm Proxy, a <b>reverse</b> proxy for Jellyfin.".to_string(),
custom_css: String::new(),
splashscreen_enabled: false,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ItemsResponse {
#[serde(rename = "Items")]
pub items: Vec<MediaItem>,
#[serde(rename = "TotalRecordCount")]
pub total_record_count: i32,
#[serde(rename = "StartIndex")]
pub start_index: i32,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MediaItem {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "ServerId")]
pub server_id: String,
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "SeriesId", skip_serializing_if = "Option::is_none")]
pub series_id: Option<String>,
#[serde(rename = "SeriesName", skip_serializing_if = "Option::is_none")]
pub series_name: Option<String>,
#[serde(rename = "SeasonId", skip_serializing_if = "Option::is_none")]
pub season_id: Option<String>,
#[serde(rename = "Etag", skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
#[serde(rename = "DateCreated", skip_serializing_if = "Option::is_none")]
pub date_created: Option<String>,
#[serde(rename = "CanDelete", skip_serializing_if = "Option::is_none")]
pub can_delete: Option<bool>,
#[serde(rename = "CanDownload", skip_serializing_if = "Option::is_none")]
pub can_download: Option<bool>,
#[serde(rename = "SortName", skip_serializing_if = "Option::is_none")]
pub sort_name: Option<String>,
#[serde(rename = "ExternalUrls", skip_serializing_if = "Option::is_none")]
pub external_urls: Option<Vec<ExternalUrl>>,
#[serde(rename = "Path", skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(
rename = "EnableMediaSourceDisplay",
skip_serializing_if = "Option::is_none"
)]
pub enable_media_source_display: Option<bool>,
#[serde(rename = "ChannelId")]
pub channel_id: Option<String>,
#[serde(rename = "Taglines", skip_serializing_if = "Option::is_none")]
pub taglines: Option<Vec<String>>,
#[serde(rename = "Genres", skip_serializing_if = "Option::is_none")]
pub genres: Option<Vec<String>>,
#[serde(rename = "PlayAccess", skip_serializing_if = "Option::is_none")]
pub play_access: Option<String>,
#[serde(rename = "RemoteTrailers", skip_serializing_if = "Option::is_none")]
pub remote_trailers: Option<Vec<RemoteTrailer>>,
#[serde(rename = "ProviderIds", skip_serializing_if = "Option::is_none")]
pub provider_ids: Option<serde_json::Value>,
#[serde(rename = "IsFolder", skip_serializing_if = "Option::is_none")]
pub is_folder: Option<bool>,
#[serde(rename = "ParentId", skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(rename = "ParentLogoItemId", skip_serializing_if = "Option::is_none")]
pub parent_logo_item_id: Option<String>,
#[serde(
rename = "ParentBackdropItemId",
skip_serializing_if = "Option::is_none"
)]
pub parent_backdrop_item_id: Option<String>,
#[serde(
rename = "ParentBackdropImageTags",
skip_serializing_if = "Option::is_none"
)]
pub parent_backdrop_image_tags: Option<Vec<String>>,
#[serde(rename = "ParentLogoImageTag", skip_serializing_if = "Option::is_none")]
pub parent_logo_image_tag: Option<String>,
#[serde(rename = "ParentThumbItemId", skip_serializing_if = "Option::is_none")]
pub parent_thumb_item_id: Option<String>,
#[serde(
rename = "ParentThumbImageTag",
skip_serializing_if = "Option::is_none"
)]
pub parent_thumb_image_tag: Option<String>,
#[serde(rename = "Type")]
pub item_type: String,
#[serde(rename = "People", skip_serializing_if = "Option::is_none")]
pub people: Option<Vec<Person>>,
#[serde(rename = "Studios", skip_serializing_if = "Option::is_none")]
pub studios: Option<Vec<Studio>>,
#[serde(rename = "GenreItems", skip_serializing_if = "Option::is_none")]
pub genre_items: Option<Vec<GenreItem>>,
#[serde(rename = "LocalTrailerCount", skip_serializing_if = "Option::is_none")]
pub local_trailer_count: Option<i32>,
#[serde(rename = "UserData")]
pub user_data: UserData,
#[serde(rename = "ChildCount", skip_serializing_if = "Option::is_none")]
pub child_count: Option<i32>,
#[serde(
rename = "SpecialFeatureCount",
skip_serializing_if = "Option::is_none"
)]
pub special_feature_count: Option<i32>,
#[serde(
rename = "DisplayPreferencesId",
skip_serializing_if = "Option::is_none"
)]
pub display_preferences_id: Option<String>,
#[serde(rename = "Tags", skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(
rename = "PrimaryImageAspectRatio",
skip_serializing_if = "Option::is_none"
)]
pub primary_image_aspect_ratio: Option<f64>,
#[serde(
rename = "SeriesPrimaryImageTag",
skip_serializing_if = "Option::is_none"
)]
pub series_primary_image_tag: Option<String>,
#[serde(rename = "CollectionType", skip_serializing_if = "Option::is_none")]
pub collection_type: Option<String>,
#[serde(rename = "ImageTags", skip_serializing_if = "Option::is_none")]
pub image_tags: Option<ImageTags>,
#[serde(rename = "BackdropImageTags", skip_serializing_if = "Option::is_none")]
pub backdrop_image_tags: Option<Vec<String>>,
#[serde(rename = "ImageBlurHashes", skip_serializing_if = "Option::is_none")]
pub image_blur_hashes: Option<ImageBlurHashes>,
#[serde(rename = "LocationType", skip_serializing_if = "Option::is_none")]
pub location_type: Option<String>,
#[serde(rename = "MediaType", skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
#[serde(rename = "LockedFields", skip_serializing_if = "Option::is_none")]
pub locked_fields: Option<Vec<String>>,
#[serde(rename = "LockData", skip_serializing_if = "Option::is_none")]
pub lock_data: Option<bool>,
// New fields from the provided response
#[serde(rename = "Container", skip_serializing_if = "Option::is_none")]
pub container: Option<String>,
#[serde(rename = "PremiereDate", skip_serializing_if = "Option::is_none")]
pub premiere_date: Option<String>,
#[serde(rename = "CriticRating", skip_serializing_if = "Option::is_none")]
pub critic_rating: Option<i32>,
#[serde(rename = "OfficialRating", skip_serializing_if = "Option::is_none")]
pub official_rating: Option<String>,
#[serde(rename = "CommunityRating", skip_serializing_if = "Option::is_none")]
pub community_rating: Option<f64>,
#[serde(rename = "RunTimeTicks", skip_serializing_if = "Option::is_none")]
pub run_time_ticks: Option<i64>,
#[serde(rename = "ProductionYear", skip_serializing_if = "Option::is_none")]
pub production_year: Option<i32>,
#[serde(rename = "VideoType", skip_serializing_if = "Option::is_none")]
pub video_type: Option<String>,
#[serde(rename = "HasSubtitles", skip_serializing_if = "Option::is_none")]
pub has_subtitles: Option<bool>,
#[serde(rename = "OriginalTitle", skip_serializing_if = "Option::is_none")]
pub original_title: Option<String>,
#[serde(rename = "Overview", skip_serializing_if = "Option::is_none")]
pub overview: Option<String>,
#[serde(
rename = "ProductionLocations",
skip_serializing_if = "Option::is_none"
)]
pub production_locations: Option<Vec<String>>,
#[serde(rename = "IsHD", skip_serializing_if = "Option::is_none")]
pub is_hd: Option<bool>,
#[serde(rename = "Width", skip_serializing_if = "Option::is_none")]
pub width: Option<i32>,
#[serde(rename = "Height", skip_serializing_if = "Option::is_none")]
pub height: Option<i32>,
#[serde(rename = "MediaSources", skip_serializing_if = "Option::is_none")]
pub media_sources: Option<Vec<MediaSource>>,
#[serde(rename = "MediaStreams", skip_serializing_if = "Option::is_none")]
pub media_streams: Option<Vec<MediaStream>>,
#[serde(rename = "Chapters", skip_serializing_if = "Option::is_none")]
pub chapters: Option<Vec<Chapter>>,
#[serde(rename = "Trickplay", skip_serializing_if = "Option::is_none")]
pub trickplay: Option<serde_json::Value>,
#[serde(flatten)]
pub extra: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MediaSource {
#[serde(rename = "Protocol", skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "Path", skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(rename = "Type", skip_serializing_if = "Option::is_none")]
pub source_type: Option<String>,
#[serde(rename = "Container", skip_serializing_if = "Option::is_none")]
pub container: Option<String>,
#[serde(rename = "Size", skip_serializing_if = "Option::is_none")]
pub size: Option<i64>,
#[serde(rename = "Name", skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "IsRemote", skip_serializing_if = "Option::is_none")]
pub is_remote: Option<bool>,
#[serde(rename = "ETag", skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
#[serde(rename = "RunTimeTicks", skip_serializing_if = "Option::is_none")]
pub run_time_ticks: Option<i64>,
#[serde(
rename = "ReadAtNativeFramerate",
skip_serializing_if = "Option::is_none"
)]
pub read_at_native_framerate: Option<bool>,
#[serde(rename = "IgnoreDts", skip_serializing_if = "Option::is_none")]
pub ignore_dts: Option<bool>,
#[serde(rename = "IgnoreIndex", skip_serializing_if = "Option::is_none")]
pub ignore_index: Option<bool>,
#[serde(rename = "GenPtsInput", skip_serializing_if = "Option::is_none")]
pub gen_pts_input: Option<bool>,
#[serde(
rename = "SupportsTranscoding",
skip_serializing_if = "Option::is_none"
)]
pub supports_transcoding: Option<bool>,
#[serde(
rename = "SupportsDirectStream",
skip_serializing_if = "Option::is_none"
)]
pub supports_direct_stream: Option<bool>,
#[serde(rename = "SupportsDirectPlay", skip_serializing_if = "Option::is_none")]
pub supports_direct_play: Option<bool>,
#[serde(rename = "IsInfiniteStream", skip_serializing_if = "Option::is_none")]
pub is_infinite_stream: Option<bool>,
#[serde(
rename = "UseMostCompatibleTranscodingProfile",
skip_serializing_if = "Option::is_none"
)]
pub use_most_compatible_transcoding_profile: Option<bool>,
#[serde(rename = "RequiresOpening", skip_serializing_if = "Option::is_none")]
pub requires_opening: Option<bool>,
#[serde(rename = "RequiresClosing", skip_serializing_if = "Option::is_none")]
pub requires_closing: Option<bool>,
#[serde(rename = "RequiresLooping", skip_serializing_if = "Option::is_none")]
pub requires_looping: Option<bool>,
#[serde(rename = "SupportsProbing", skip_serializing_if = "Option::is_none")]
pub supports_probing: Option<bool>,
#[serde(rename = "VideoType", skip_serializing_if = "Option::is_none")]
pub video_type: Option<String>,
#[serde(rename = "MediaStreams", skip_serializing_if = "Option::is_none")]
pub media_streams: Option<Vec<MediaStream>>,
#[serde(rename = "MediaAttachments", skip_serializing_if = "Option::is_none")]
pub media_attachments: Option<Vec<serde_json::Value>>,
#[serde(rename = "Formats", skip_serializing_if = "Option::is_none")]
pub formats: Option<Vec<String>>,
#[serde(rename = "Bitrate", skip_serializing_if = "Option::is_none")]
pub bitrate: Option<i64>,
#[serde(
rename = "RequiredHttpHeaders",
skip_serializing_if = "Option::is_none"
)]
pub required_http_headers: Option<serde_json::Value>,
#[serde(
rename = "TranscodingSubProtocol",
skip_serializing_if = "Option::is_none"
)]
pub transcoding_sub_protocol: Option<String>,
#[serde(rename = "TranscodingUrl", skip_serializing_if = "Option::is_none")]
pub transcoding_url: Option<String>,
#[serde(
rename = "TranscodingContainer",
skip_serializing_if = "Option::is_none"
)]
pub transcoding_container: Option<String>,
#[serde(
rename = "DefaultAudioStreamIndex",
skip_serializing_if = "Option::is_none"
)]
pub default_audio_stream_index: Option<i32>,
#[serde(
rename = "DefaultSubtitleStreamIndex",
skip_serializing_if = "Option::is_none"
)]
pub default_subtitle_stream_index: Option<i32>,
#[serde(rename = "HasSegments", skip_serializing_if = "Option::is_none")]
pub has_segments: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MediaStream {
#[serde(rename = "Codec", skip_serializing_if = "Option::is_none")]
pub codec: Option<String>,
#[serde(rename = "ColorSpace", skip_serializing_if = "Option::is_none")]
pub color_space: Option<String>,
#[serde(rename = "ColorTransfer", skip_serializing_if = "Option::is_none")]
pub color_transfer: Option<String>,
#[serde(rename = "ColorPrimaries", skip_serializing_if = "Option::is_none")]
pub color_primaries: Option<String>,
#[serde(rename = "DvVersionMajor", skip_serializing_if = "Option::is_none")]
pub dv_version_major: Option<i32>,
#[serde(rename = "DvVersionMinor", skip_serializing_if = "Option::is_none")]
pub dv_version_minor: Option<i32>,
#[serde(rename = "DvProfile", skip_serializing_if = "Option::is_none")]
pub dv_profile: Option<i32>,
#[serde(rename = "DvLevel", skip_serializing_if = "Option::is_none")]
pub dv_level: Option<i32>,
#[serde(rename = "RpuPresentFlag", skip_serializing_if = "Option::is_none")]
pub rpu_present_flag: Option<i32>,
#[serde(rename = "ElPresentFlag", skip_serializing_if = "Option::is_none")]
pub el_present_flag: Option<i32>,
#[serde(rename = "BlPresentFlag", skip_serializing_if = "Option::is_none")]
pub bl_present_flag: Option<i32>,
#[serde(
rename = "DvBlSignalCompatibilityId",
skip_serializing_if = "Option::is_none"
)]
pub dv_bl_signal_compatibility_id: Option<i32>,
#[serde(rename = "TimeBase", skip_serializing_if = "Option::is_none")]
pub time_base: Option<String>,
#[serde(rename = "VideoRange", skip_serializing_if = "Option::is_none")]
pub video_range: Option<String>,
#[serde(rename = "VideoRangeType", skip_serializing_if = "Option::is_none")]
pub video_range_type: Option<String>,
#[serde(rename = "VideoDoViTitle", skip_serializing_if = "Option::is_none")]
pub video_dovi_title: Option<String>,
#[serde(rename = "AudioSpatialFormat", skip_serializing_if = "Option::is_none")]
pub audio_spatial_format: Option<String>,
#[serde(rename = "DisplayTitle", skip_serializing_if = "Option::is_none")]
pub display_title: Option<String>,
#[serde(rename = "IsInterlaced", skip_serializing_if = "Option::is_none")]
pub is_interlaced: Option<bool>,
#[serde(rename = "IsAVC", skip_serializing_if = "Option::is_none")]
pub is_avc: Option<bool>,
#[serde(rename = "BitRate", skip_serializing_if = "Option::is_none")]
pub bit_rate: Option<i64>,
#[serde(rename = "BitDepth", skip_serializing_if = "Option::is_none")]
pub bit_depth: Option<i32>,
#[serde(rename = "RefFrames", skip_serializing_if = "Option::is_none")]
pub ref_frames: Option<i32>,
#[serde(rename = "IsDefault", skip_serializing_if = "Option::is_none")]
pub is_default: Option<bool>,
#[serde(rename = "IsForced", skip_serializing_if = "Option::is_none")]
pub is_forced: Option<bool>,
#[serde(rename = "IsHearingImpaired", skip_serializing_if = "Option::is_none")]
pub is_hearing_impaired: Option<bool>,
#[serde(rename = "Height", skip_serializing_if = "Option::is_none")]
pub height: Option<i32>,
#[serde(rename = "Width", skip_serializing_if = "Option::is_none")]
pub width: Option<i32>,
#[serde(rename = "AverageFrameRate", skip_serializing_if = "Option::is_none")]
pub average_frame_rate: Option<f64>,
#[serde(rename = "RealFrameRate", skip_serializing_if = "Option::is_none")]
pub real_frame_rate: Option<f64>,
#[serde(rename = "ReferenceFrameRate", skip_serializing_if = "Option::is_none")]
pub reference_frame_rate: Option<f64>,
#[serde(rename = "Profile", skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(rename = "Type", skip_serializing_if = "Option::is_none")]
pub stream_type: Option<String>,
#[serde(rename = "AspectRatio", skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
#[serde(rename = "Index")]
pub index: i32,
#[serde(rename = "IsExternal", skip_serializing_if = "Option::is_none")]
pub is_external: Option<bool>,
#[serde(
rename = "IsTextSubtitleStream",
skip_serializing_if = "Option::is_none"
)]
pub is_text_subtitle_stream: Option<bool>,
#[serde(
rename = "SupportsExternalStream",
skip_serializing_if = "Option::is_none"
)]
pub supports_external_stream: Option<bool>,
#[serde(rename = "PixelFormat", skip_serializing_if = "Option::is_none")]
pub pixel_format: Option<String>,
#[serde(rename = "Level")]
pub level: i32,
#[serde(rename = "IsAnamorphic", skip_serializing_if = "Option::is_none")]
pub is_anamorphic: Option<bool>,
#[serde(rename = "Language", skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(rename = "Title", skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(rename = "LocalizedDefault", skip_serializing_if = "Option::is_none")]
pub localized_default: Option<String>,
#[serde(rename = "LocalizedExternal", skip_serializing_if = "Option::is_none")]
pub localized_external: Option<String>,
#[serde(rename = "ChannelLayout", skip_serializing_if = "Option::is_none")]
pub channel_layout: Option<String>,
#[serde(rename = "Channels", skip_serializing_if = "Option::is_none")]
pub channels: Option<i32>,
#[serde(rename = "SampleRate", skip_serializing_if = "Option::is_none")]
pub sample_rate: Option<i32>,
#[serde(rename = "DeliveryUrl", skip_serializing_if = "Option::is_none")]
pub delivery_url: Option<String>,
#[serde(rename = "DeliveryMethod", skip_serializing_if = "Option::is_none")]
pub delivery_method: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Chapter {
#[serde(rename = "StartPositionTicks", skip_serializing_if = "Option::is_none")]
pub start_position_ticks: Option<i64>,
#[serde(rename = "Name", skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "ImagePath", skip_serializing_if = "Option::is_none")]
pub image_path: Option<String>,
#[serde(rename = "ImageDateModified", skip_serializing_if = "Option::is_none")]
pub image_date_modified: Option<String>,
#[serde(rename = "ImageTag", skip_serializing_if = "Option::is_none")]
pub image_tag: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RemoteTrailer {
#[serde(rename = "Url")]
pub url: String,
#[serde(rename = "Name", skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Person {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "Role", skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(rename = "Type")]
pub person_type: String,
#[serde(rename = "PrimaryImageTag", skip_serializing_if = "Option::is_none")]
pub primary_image_tag: Option<String>,
#[serde(rename = "ImageBlurHashes", skip_serializing_if = "Option::is_none")]
pub image_blur_hashes: Option<ImageBlurHashes>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Studio {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Id")]
pub id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GenreItem {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Id")]
pub id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ExternalUrl {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Url")]
pub url: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserData {
#[serde(rename = "PlaybackPositionTicks")]
pub playback_position_ticks: i64,
#[serde(rename = "PlayCount")]
pub play_count: i32,
#[serde(rename = "IsFavorite")]
pub is_favorite: bool,
#[serde(rename = "Played")]
pub played: bool,
#[serde(rename = "Key")]
pub key: String,
#[serde(rename = "ItemId")]
pub item_id: String,
#[serde(rename = "PlayedPercentage", skip_serializing_if = "Option::is_none")]
pub played_percentage: Option<f64>,
#[serde(rename = "LastPlayedDate", skip_serializing_if = "Option::is_none")]
pub last_played_date: Option<String>,
#[serde(rename = "UnplayedItemCount", skip_serializing_if = "Option::is_none")]
pub unplayed_item_count: Option<i32>,
}
pub type ImageTags = std::collections::HashMap<String, String>;
pub type ImageBlurHashes =
std::collections::HashMap<String, std::collections::HashMap<String, String>>;

View File

@@ -0,0 +1,8 @@
mod authorization;
mod jellyfin;
#[cfg(test)]
mod tests;
pub use authorization::{generate_token, Authorization};
pub use jellyfin::*;

View File

@@ -0,0 +1,764 @@
{
"Items": [
{
"Name": "DOG & CHAINSAW",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "4d03fa91f6365b7d6f21da42070b506c",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-10-12T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "Denji is a teenager who lives his life as a Devil Hunter.\n\nAiming to pay off the debt he inherited from his father, he starts hunting devils for the yakuza with his buddy Pochita, a \"Chainsaw Devil\", while living a tragically poor life.\n\nJust when things seem like they couldn't get any worse, Denji gets betrayed and killed by the yakuza. As Denji starts passing out, he hears someone calling from inside his head...",
"CommunityRating": 7.25,
"RunTimeTicks": 15270250496,
"ProductionYear": 2022,
"IndexNumber": 1,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlayedPercentage": 23.749760424362325,
"PlaybackPositionTicks": 3626647909,
"PlayCount": 2,
"IsFavorite": false,
"LastPlayedDate": "2024-10-15T14:29:10.287164Z",
"Played": false,
"Key": "397934001001",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "b756a1325f06d0631203f79c9dfdb269"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"b756a1325f06d0631203f79c9dfdb269": "W~K^+@xvM{bFX9og~ptRayoIj]of%Mozs:e-oLofxus.j[bIofay",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "ARRIVAL IN TOKYO",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "72ea8d462e2c1671fe4a90e6fd32f018",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-10-19T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "After being taken into custody by Makima, one of the Public Safety Devil Hunters, Denji finds himself head over heels in love with her thanks to her suggestive insinuations.\n\nWishing to team up with Makima, Denji arrives at the Devil Hunters Headquarters in Tokyo, and gets introduced to Aki Hayakawa, a senior hunter that he was supposed to partner with. But then Hayakawa beats Denji up, and demands that he “quit this job”...",
"CommunityRating": 7.938,
"RunTimeTicks": 14370599936,
"ProductionYear": 2022,
"IndexNumber": 2,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934001002",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "073fe46cd68c3cb8ee0d144edb2f6ccd"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"073fe46cd68c3cb8ee0d144edb2f6ccd": "WIB3.[WC01oftRRj~qof9FayofM{%Nj[IUs:M{M{?boMM{fjM{Rj",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "MEOWY'S WHEREABOUTS",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "eb8f0e3d6c6a038ff21416166759193c",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-10-26T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "Denji decides that his new dream is \"to fondle some boobs.\"\n\nHis partner, Power the Blood Fiend, offers to let him feel her boobs only if he rescues her old pet cat Meowy from a devil.\n\nWith his dream in sight, Denji is all fired up and ready to go, until...",
"CommunityRating": 7.938,
"RunTimeTicks": 14369920000,
"ProductionYear": 2022,
"IndexNumber": 3,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934001003",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "e53fe47bcefc686229d75e211d5bfc4f"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"e53fe47bcefc686229d75e211d5bfc4f": "WUGR|}~pMxM{%g?b-p%1bIS4M|M{IoWX%Mt7M|Rkxat7xuxtxuxu",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "RESCUE",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "fed8d8c1f66948f495a57a283ab72b7d",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-11-02T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "After a fierce battle, Denji defeats the “Bat Devil” and rescues Power even after she had trapped him.\n\nHe says it is all for the sake of “touching Powers boobs”.\n\nPower mocks his ridiculous motivation saying “thats a pretty dumb reason”, but still agrees to let him fondle her boobs.\n\nAs Denji gets thrilled with the idea, he faces a sudden attack from out of nowhere...",
"CommunityRating": 7.857,
"RunTimeTicks": 14370599936,
"ProductionYear": 2022,
"IndexNumber": 4,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934001004",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "fa4c444639f8dceaa5fabe0428a815c7"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"fa4c444639f8dceaa5fabe0428a815c7": "WEE33g%N~q%M%Mo#_3xuR*ayIoxv-;jZD%NG%2of-;xuWBM{s:j[",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "GUN DEVIL",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "3397df3329573781c04a6fe6ace76f76",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-11-09T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "Aki Hayakawa, Denji, and Power start living together.\n\nDenji finally fulfills his dream of touching Powers boobs, but is shocked when things do not turn out the way he expected. As Denji realizes that chasing his dream was more fun than achieving it, Makima approaches him and says, \"If you can kill the Gun Devil, Ill grant you one wish.”",
"CommunityRating": 7.538,
"RunTimeTicks": 14369920000,
"ProductionYear": 2022,
"IndexNumber": 5,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934001005",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "d08a3e8c62f1d8a909bea2a4c9393a28"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"d08a3e8c62f1d8a909bea2a4c9393a28": "WSIzFyt3I:WBNGxF}?NaNGoKoLn%M|oyofR+afs:$%s:ofWVWCj[",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "KILL DENJI",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "7c619fe95e7dad05193f31ac6dd8e6e5",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-11-16T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "Denji and Tokyo Special Division 4 members enter a hotel to collect a piece of the Gun Devil. However, they end up being trapped on the 8th floor due to the devils extraordinary power.\n\nWith the members being cornered on the endless 8th floor with seemingly no way to escape, the devil makes them an offer: “If you let me eat Denji, I will let all of the other devil hunters leave in one piece.”",
"CommunityRating": 7.615,
"RunTimeTicks": 14369599488,
"ProductionYear": 2022,
"IndexNumber": 6,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934001006",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "513aa49e983817ea9b3d392842a692d6"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"513aa49e983817ea9b3d392842a692d6": "WZG*4oof0fs.$$R%ayR*oKt6xaa#9aayxtoeoKR*WCj[xZoJI;WV",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "THE TASTE OF A KISS",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "33f299181cbd3a2128ddd4b20ddb424b",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-11-23T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "Aki gets stabbed by Kobeni while protecting Denji. Seeing this, Denji jumps inside the “Eternity Devil” and starts attacking it. He fails to kill the “Eternity Devil” and his chainsaw power ends up fading after losing too much blood. But then, he manages to restore his power after eating the devils flesh and drinking its blood, and begins to fight back.\n\nAs Himeno watches Denji, she remembers the saying, “The Devil Hunters that devils fear are the ones with a few screws loose...”",
"CommunityRating": 7.385,
"RunTimeTicks": 14370599936,
"ProductionYear": 2022,
"IndexNumber": 7,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934001007",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "73ef014d62eae3e064ed1c33e76fae19"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"73ef014d62eae3e064ed1c33e76fae19": "WTLO1l?GGb.TVr-:.8kDSP%MVsVsM{oexZV@bct7~qWVt7W=Ioxa",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "GUNFIRE",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "f1f7b15334cfca68e7169c98ff4fab24",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-11-30T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "Denji experiences his first, yet devastating, kiss.\n\nAn intoxicated Denji is taken to Himeno's house, where she starts seducing him. Feeling torn between Makima, whom he admires, and Himeno, who is standing in front of him, he decides to fall into temptation. However, Denji then finds something in his pocket: The Chupa Chups lollipop that Makima gave him.",
"CommunityRating": 7.4,
"RunTimeTicks": 14370239488,
"ProductionYear": 2022,
"IndexNumber": 8,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934001008",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "c2130c4f687a9c7763405530750b317c"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"c2130c4f687a9c7763405530750b317c": "WHD9YTtlI[tSNHt7~pxut8xuW=W;ozxu%M%Lt7oe-;%Mt8t7ofof",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "FROM KYOTO",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "b2cc878d5527f239f3f7a6a0ea3812d9",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-12-07T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "A fierce battle unfolds between Denji and the menacing Samurai Sword. But even after Denji takes one of Samurai Sword's companions hostage, Samurai Sword slashes through them both until Denji is captured. Meanwhile, after seemingly being killed, Makima turns out to be alive. She sets out to rescue Denji with the help of Public Safety Devil Hunters from Kyoto, Kurose and Tendo.",
"CommunityRating": 7.714,
"RunTimeTicks": 14371200000,
"ProductionYear": 2022,
"IndexNumber": 9,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934001009",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "ac9a6c1f4e808eac403c10c24f0e8f00"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"ac9a6c1f4e808eac403c10c24f0e8f00": "WfGu,p-;-=?bt7WB_4xaxux]RiWBxvM{ogoLIUWB%Moft7M{j[kC",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "BRUISED & BATTERED",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "489519627711cbe20bfe1b78f90c3968",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-12-14T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "Samurai Sword's vicious attack resulted in many lost personnel for Public Safety Devil Extermination Division 4.\n\nAki Hayakawa wakes up in a hospital bed, unable to accept the reality of losing Himeno. Kurose and Tendo then appear in front of Hayakawa, letting him know that they are now in charge of coaching him.\n\nMeanwhile, Makima introduces Denji and Power to a member of Public Safety who will act as their mentor in order to strengthen Division 4.",
"CommunityRating": 7.083,
"RunTimeTicks": 15270400000,
"ProductionYear": 2022,
"IndexNumber": 10,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934001010",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "867d0ee8c338df470876df6a719530b7"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"867d0ee8c338df470876df6a719530b7": "WbDS:xWC-;t7o}W=_4xat7ofkXR*?cxuWBt7R-WW%hogWBkCR+WV",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "MISSION START",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "8755c9cad18eac40b86160efbb434b07",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-12-21T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "Denji and Power complete their training with Kishibe, while Aki Hayakawa makes a new contract with the \"Future Devil\" through Kurose and Tendo.\n\nMeanwhile, Makima threatens the yakuza boss into revealing the names of all those who have made contracts with the \"Gun Devil,\" along with the location of Samurai Sword's hideout.\n\nThe time has finally come for Public Safety to begin their counterattack against Samurai Sword and his companions.",
"CommunityRating": 6.846,
"RunTimeTicks": 14370879488,
"ProductionYear": 2022,
"IndexNumber": 11,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934001011",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "baaaf66c20c4caa669cda394d0c78651"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"baaaf66c20c4caa669cda394d0c78651": "W44epDSi01%L?aE2-oR.EN%L-oIVVsbIo~oeniWrXTozn+RjWVtR",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "KATANA VS. CHAINSAW",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "f1f43e97cf6a9a2a5cd5809aa7eaef0d",
"CanDelete": true,
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-12-28T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"Overview": "Sawatari sends the \"Ghost Devil\" after Hayakawa, and he's nearly killed before the \"Ghost Devil\" stops and hands him a cigarette with a message from Himeno. The words inside give Hayakawa the strength to kill the “Ghost Devil” and capture Sawatari.\n\nMeanwhile, Denji heads into his rematch with Samurai Sword. With a pull of the starter on his chest, he transforms into Chainsaw Man.\n\nAnd now, the last battle between Chainsaw Man and Samurai Sword has finally begun...",
"CommunityRating": 7.643,
"RunTimeTicks": 14370239488,
"ProductionYear": 2022,
"IndexNumber": 12,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934001012",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "78cd6a026a5f42626707061a781d2b47"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"78cd6a026a5f42626707061a781d2b47": "WRM%GvxB0fD*o}%LD*D*SeInx]Rl4ooeofNGkDM{_2sli{e.tRog",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
}
],
"TotalRecordCount": 12,
"StartIndex": 0
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,293 @@
{
"Items": [
{
"Name": "Andor",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "27839354c2bcbec5c0b34ea921fc00ec",
"PremiereDate": "2022-09-21T00:00:00.0000000Z",
"OfficialRating": "TV-14",
"ChannelId": null,
"CommunityRating": 8.258,
"RunTimeTicks": 0,
"ProductionYear": 2022,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 24,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "393189",
"ItemId": "00000000000000000000000000000000"
},
"Status": "Ended",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Primary": "29ef63192293b8b7248d88165691a290",
"Thumb": "b89cda1c2521a1e426bb217e8e0ccd9e"
},
"BackdropImageTags": [
"e71e70b8a902614acee4213f3f8640b6"
],
"ImageBlurHashes": {
"Backdrop": {
"e71e70b8a902614acee4213f3f8640b6": "WJBy$}V?E1xuM{t60gWXkAWVt7WBDhxuogM{xuf6~pWUR*ofM|of"
},
"Primary": {
"29ef63192293b8b7248d88165691a290": "dMDc2Nn~t6s.0fR,R*j]Q,xujtxt_Moet6a}9aRjWBM{"
},
"Thumb": {
"b89cda1c2521a1e426bb217e8e0ccd9e": "WVD*Ik=v-T-oou-U}s$%xYxZoexZMya$afM|W=R+M|WERkM|R-Rk"
}
},
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2025-05-13T00:00:00.0000000Z"
},
{
"Name": "Arcane",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "c841b6657e2ea916338f1b962ced667b",
"PremiereDate": "2021-11-06T00:00:00.0000000Z",
"OfficialRating": "TV-14",
"ChannelId": null,
"CommunityRating": 8.75,
"RunTimeTicks": 0,
"ProductionYear": 2021,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 16,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "371028",
"ItemId": "00000000000000000000000000000000"
},
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Primary": "65ec880bff491dd4c2ec162373c57c78",
"Thumb": "c798766f3e8baa3464d97576d7e49a5b"
},
"BackdropImageTags": [
"3bb169ad4dd9d04affec448033723f22"
],
"ImageBlurHashes": {
"Backdrop": {
"3bb169ad4dd9d04affec448033723f22": "WuKdoL-V~qtSNd%g?aIU%MbcR+x[t7ozofaeRjWBxtoLogofofkB"
},
"Primary": {
"65ec880bff491dd4c2ec162373c57c78": "d*KB2Z~q^+x]VsRPofozi^ofRkRioLs:kCofjbj?ofof"
},
"Thumb": {
"c798766f3e8baa3464d97576d7e49a5b": "WB8Dz@$+0KEL.6x@^*of9ZRj-:%2t2SdM|nS-WxbR*jZxZbaR-WB"
}
},
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2021-11-20T00:00:00.0000000Z"
},
{
"Name": "Call of the Night",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "62482a7cfdc7aa0af64e459b0d335a68",
"PremiereDate": "2022-07-08T00:00:00.0000000Z",
"OfficialRating": "TV-14",
"ChannelId": null,
"CommunityRating": 7.6,
"RunTimeTicks": 13800000000,
"ProductionYear": 2022,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 3,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "412374",
"ItemId": "00000000000000000000000000000000"
},
"Status": "Continuing",
"AirTime": "23:30",
"AirDays": [
"Friday"
],
"PrimaryImageAspectRatio": 0.68,
"ImageTags": {
"Primary": "dc705370d676bdd1ae52c811b2d0fe2a",
"Banner": "a242fe99cc434c450667354f0042a9d9",
"Thumb": "a30b79e1f6711fa688fb7aedb18120c6"
},
"BackdropImageTags": [
"6b4639e932885e2aeaf4ffff193fb53c"
],
"ImageBlurHashes": {
"Backdrop": {
"6b4639e932885e2aeaf4ffff193fb53c": "WM9%^%jZ00kC-;RjxubHM|oJt7R*M{WBxut7RjRjs:WBRjt7xuR*"
},
"Primary": {
"dc705370d676bdd1ae52c811b2d0fe2a": "daFqky$+M_-X5MRpogkTRrNYW,WBxpxuaeRjRin,s;bZ"
},
"Banner": {
"a242fe99cc434c450667354f0042a9d9": "HOGkqxx#0LD,fdIWRPxtW-S5xaxsRoInWVWSNKt5"
},
"Thumb": {
"a30b79e1f6711fa688fb7aedb18120c6": "NKF5RU^nRi?IWH?I.8SvRk-pR+butQR}EKt8Roog"
}
},
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2022-09-30T00:00:00.0000000Z"
},
{
"Name": "Chainsaw Man",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "d530a8428e87018e832e3d65b04775c5",
"PremiereDate": "2022-10-12T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"CommunityRating": 8.6,
"RunTimeTicks": 14400000000,
"ProductionYear": 2022,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 12,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "397934",
"ItemId": "00000000000000000000000000000000"
},
"Status": "Ended",
"AirDays": [],
"PrimaryImageAspectRatio": 0.68,
"ImageTags": {
"Primary": "a88eeb71365d9ef18a68e1eeaa2d845e",
"Banner": "a3b7ddf86cce5eaa73cfe327f3a07396",
"Thumb": "a2b37e353fdddbb88d64308edbec3f4e"
},
"BackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"ImageBlurHashes": {
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
},
"Primary": {
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Banner": {
"a3b7ddf86cce5eaa73cfe327f3a07396": "HTF}K8*_XTK7Vr$%s:NHkC}@,nR*bvNGxZWCX8R*"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
}
},
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2022-12-28T00:00:00.0000000Z"
},
{
"Name": "Chernobyl",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "6c1a5d2cc4796a60ede9a973d4f20f68",
"PremiereDate": "2019-05-06T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"CommunityRating": 8.688,
"RunTimeTicks": 0,
"ProductionYear": 2019,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 5,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "360893",
"ItemId": "00000000000000000000000000000000"
},
"Status": "Ended",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Primary": "12dd999cebff9dc3024c69c1fb2564a6",
"Thumb": "e0f7854945b9241498198c6cae099ba0"
},
"BackdropImageTags": [
"f547cd50f1fec9200600ccd27938719a"
],
"ImageBlurHashes": {
"Backdrop": {
"f547cd50f1fec9200600ccd27938719a": "WSGT7,-;x[%fIUS2~qxuWBkCWpWpxuoLWBWBt6of.7t7bHayaeof"
},
"Primary": {
"12dd999cebff9dc3024c69c1fb2564a6": "dYJIRr_3oz%f~q%MM{WBM{R%ayWBbaWBoftQRjj[afoL"
},
"Thumb": {
"e0f7854945b9241498198c6cae099ba0": "WUG]y|.RNZM|-;t7~qj[RjWCs:xuR*M{kCt7afs:x[W.WVt7j[j["
}
},
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2019-06-03T00:00:00.0000000Z"
},
{
"Name": "Dan Da Dan",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "df5efad5776d4668d76462f1bb978895",
"PremiereDate": "2024-10-04T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"CommunityRating": 8.8,
"RunTimeTicks": 14400000000,
"ProductionYear": 2024,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 15,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "432832",
"ItemId": "00000000000000000000000000000000"
},
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Primary": "987fc0444005cbc8116b34ea8d4962e5",
"Thumb": "9647eeb194c44297beb8715db4944972"
},
"BackdropImageTags": [
"ffb9811514a46ab535c36ffb1a3ad059"
],
"ImageBlurHashes": {
"Backdrop": {
"ffb9811514a46ab535c36ffb1a3ad059": "WDD[;I~WJ:6*4oq^}V-pyDyEENNf+GM_x@NGo~%M?H$%r=InD%kB"
},
"Primary": {
"987fc0444005cbc8116b34ea8d4962e5": "dJA]y}s:0%V~YQkWRpVyIht5o|I]aBf8s;tREmNL#s%1"
},
"Thumb": {
"9647eeb194c44297beb8715db4944972": "WC9ih^~B-p=eI:Io}@={t7WCIpELTE-Ss+ACM|RjOlxVw]EgI;r?"
}
},
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2024-12-20T00:00:00.0000000Z"
}
],
"TotalRecordCount": 30,
"StartIndex": 0
}

View File

@@ -0,0 +1,82 @@
{
"Name": "Jared Harris",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "4489f6a0e2abc608b915830a0ce2e0ac",
"Etag": "1b9a1765f1e6c5b894eef0803d88f32d",
"DateCreated": "2025-06-05T15:19:33.0995621Z",
"CanDelete": false,
"CanDownload": false,
"SortName": "Jared Harris",
"PremiereDate": "1961-08-24T00:00:00.0000000Z",
"ExternalUrls": [
{
"Name": "IMDb",
"Url": "https://www.imdb.com/name/nm0364813"
},
{
"Name": "TheMovieDb",
"Url": "https://www.themoviedb.org/person/15440"
},
{
"Name": "TheTVDB",
"Url": "https://www.thetvdb.com/people/360332"
}
],
"ProductionLocations": [
"London, England, UK"
],
"Path": "/config/metadata/People/J/Jared Harris",
"EnableMediaSourceDisplay": true,
"ChannelId": null,
"Overview": "Jared Francis Harris (born August 24, 1961) is a British actor who has appeared in film, television, and theater. He is the son of the late Irish actor Richard Harris and the Welsh actress Elizabeth Rees-Williams.\n\nHarris was born in Hammersmith, London, in 1961. He studied drama and literature at Duke University in North Carolina, and then went on to train at the Royal Central School of Speech and Drama in London.\n\nHarris made his film debut in 1989 with a small role in the film The Rachel Papers. He went on to appear in a number of films, including The Last of the Mohicans (1992), Natural Born Killers (1994), Smoke (1995), Happiness (1998), and How to Kill Your Neighbor\u0027s Dog (2000).\n\nIn 2007, Harris began a recurring role as Lane Pryce in the AMC television series Mad Men. He was nominated for a Primetime Emmy Award for Outstanding Supporting Actor in a Drama Series for his performance.\n\nHarris has also had notable roles in television series such as Fringe, The Crown, and The Expanse. In 2019, he won the British Academy Television Award for Best Actor for his performance as Valery Legasov in the HBO miniseries Chernobyl.\n\nOn stage, Harris has appeared in productions of The Crucible, The Cherry Orchard, and The Homecoming. He has also directed several stage productions, including The Glass Menagerie and The Birthday Party.",
"Taglines": [],
"Genres": [],
"PlayAccess": "Full",
"RemoteTrailers": [],
"ProviderIds": {
"Tmdb": "15440",
"Tvdb": "360332",
"Imdb": "nm0364813"
},
"ParentId": null,
"Type": "Person",
"People": [],
"Studios": [],
"GenreItems": [],
"LocalTrailerCount": 0,
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "Person-Jared Harris",
"ItemId": "00000000000000000000000000000000"
},
"ChildCount": 7,
"SpecialFeatureCount": 0,
"DisplayPreferencesId": "b607178b0ac2f604a458d3d2363fdc83",
"Tags": [],
"PrimaryImageAspectRatio": 0.666,
"ImageTags": {
"Primary": "2d12c0bcb33d62f4247037e5de68160f"
},
"BackdropImageTags": [],
"ImageBlurHashes": {
"Primary": {
"2d12c0bcb33d62f4247037e5de68160f": "dxJ7]?og9^xtHqRjIoRjTKt7xZbHIpaytRjZIUaenhWV"
}
},
"LocationType": "FileSystem",
"MediaType": "Unknown",
"LockedFields": [],
"TrailerCount": 0,
"MovieCount": 0,
"SeriesCount": 1,
"ProgramCount": 0,
"EpisodeCount": 5,
"SongCount": 0,
"AlbumCount": 0,
"ArtistCount": 0,
"MusicVideoCount": 0,
"LockData": false
}

View File

@@ -0,0 +1,68 @@
{
"Items": [
{
"Name": "DOG & CHAINSAW",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "4d03fa91f6365b7d6f21da42070b506c",
"HasSubtitles": true,
"Container": "mkv,webm",
"PremiereDate": "2022-10-12T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"CommunityRating": 7.25,
"RunTimeTicks": 15270250496,
"ProductionYear": 2022,
"IndexNumber": 1,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentBackdropImageTags": [
"450e93012eb5f84dd187e51e9ccc00b0"
],
"UserData": {
"PlayedPercentage": 23.749760424362325,
"PlaybackPositionTicks": 3626647909,
"PlayCount": 2,
"IsFavorite": false,
"LastPlayedDate": "2024-10-15T14:29:10.287164Z",
"Played": false,
"Key": "397934001001",
"ItemId": "00000000000000000000000000000000"
},
"SeriesName": "Chainsaw Man",
"SeriesId": "d530a8428e87018e832e3d65b04775c5",
"SeasonId": "b7f6b36a38d8864f7b0e704c1108823a",
"SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "b756a1325f06d0631203f79c9dfdb269"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d",
"ImageBlurHashes": {
"Primary": {
"b756a1325f06d0631203f79c9dfdb269": "W~K^+@xvM{bFX9og~ptRayoIj]of%Mozs:e-oLofxus.j[bIofay",
"a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2"
},
"Logo": {
"8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7"
},
"Thumb": {
"a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF"
},
"Backdrop": {
"450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu"
}
},
"ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5",
"ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e",
"LocationType": "FileSystem",
"MediaType": "Video"
}
],
"TotalRecordCount": 1,
"StartIndex": 0
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
{
"Items": [
{
"Name": "Filme",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "7a2175bccb1f1a94152cbd2b2bae8f6d",
"Etag": "a2ceddc6670c8cf6a77c215d1e20feff",
"DateCreated": "2024-03-07T15:54:15.1376334Z",
"CanDelete": false,
"CanDownload": false,
"SortName": "filme",
"ExternalUrls": [],
"Path": "/config/root/default/Filme",
"EnableMediaSourceDisplay": true,
"ChannelId": null,
"Taglines": [],
"Genres": [],
"PlayAccess": "Full",
"RemoteTrailers": [],
"ProviderIds": {},
"IsFolder": true,
"ParentId": "e9d5075a555c1cbc394eec4cef295274",
"Type": "CollectionFolder",
"People": [],
"Studios": [],
"GenreItems": [],
"LocalTrailerCount": 0,
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "7a2175bc-cb1f-1a94-152c-bd2b2bae8f6d",
"ItemId": "00000000000000000000000000000000"
},
"ChildCount": 6,
"SpecialFeatureCount": 0,
"DisplayPreferencesId": "7a2175bccb1f1a94152cbd2b2bae8f6d",
"Tags": [],
"PrimaryImageAspectRatio": 1.7777777777777777,
"CollectionType": "movies",
"ImageTags": {
"Primary": "cf88773a4957287197ed1460c299248f"
},
"BackdropImageTags": [],
"ImageBlurHashes": {
"Primary": {
"cf88773a4957287197ed1460c299248f": "WC6uO.kCDiaexvo#aee.WCa}V@ae4Tj[-;kCM{axbcWBoLoLkDWq"
}
},
"LocationType": "FileSystem",
"MediaType": "Unknown",
"LockedFields": [],
"LockData": false
},
{
"Name": "Serien",
"ServerId": "0555e8a91bfc4189a2585ede39a52dc8",
"Id": "43cfe12fe7d9d8d21251e0964e0232e2",
"Etag": "d83015cb967c50942003e5472e934788",
"DateCreated": "2024-03-07T16:01:14.3788766Z",
"CanDelete": false,
"CanDownload": false,
"SortName": "serien",
"ExternalUrls": [],
"Path": "/config/root/default/Serien",
"EnableMediaSourceDisplay": true,
"ChannelId": null,
"Taglines": [],
"Genres": [],
"PlayAccess": "Full",
"RemoteTrailers": [],
"ProviderIds": {},
"IsFolder": true,
"ParentId": "e9d5075a555c1cbc394eec4cef295274",
"Type": "CollectionFolder",
"People": [],
"Studios": [],
"GenreItems": [],
"LocalTrailerCount": 0,
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "43cfe12f-e7d9-d8d2-1251-e0964e0232e2",
"ItemId": "00000000000000000000000000000000"
},
"ChildCount": 4,
"SpecialFeatureCount": 0,
"DisplayPreferencesId": "43cfe12fe7d9d8d21251e0964e0232e2",
"Tags": [],
"PrimaryImageAspectRatio": 1.7777777777777777,
"CollectionType": "tvshows",
"ImageTags": {
"Primary": "98562456587cfd6d6eed5bf72068c414"
},
"BackdropImageTags": [],
"ImageBlurHashes": {
"Primary": {
"98562456587cfd6d6eed5bf72068c414": "W87K*jV?01axozW;x]V@bbkBV[WB00WB_3jbRPofW:WBoLogaejs"
}
},
"LocationType": "FileSystem",
"MediaType": "Unknown",
"LockedFields": [],
"LockData": false
}
],
"TotalRecordCount": 2,
"StartIndex": 0
}

View File

@@ -0,0 +1 @@
mod test_jellyfin_models;

View File

@@ -0,0 +1,226 @@
use crate::models::MediaItem;
use std::fs;
#[cfg(test)]
mod tests {
use crate::models::ItemsResponse;
use super::*;
#[test]
fn test_deserialize_item_from_json() {
// Read the JSON file from the workspace root
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let file_path = format!("{manifest_dir}/src/models/tests/files/item.json");
let json_content = fs::read_to_string(file_path).expect("Failed to read item.json file");
// Deserialize the JSON into a MediaItem
let media_item: MediaItem =
serde_json::from_str(&json_content).expect("Failed to deserialize JSON into MediaItem");
// Verify basic fields are correct
assert_eq!(media_item.name, "The Batman");
assert_eq!(media_item.server_id, "0555e8a91bfc4189a2585ede39a52dc8");
assert_eq!(media_item.id, "165a66aa5bd2e62c0df0f8da332ae47d");
// Test optional fields
assert!(media_item.etag.is_some());
assert_eq!(media_item.etag.unwrap(), "11a345e866240c2637db0df717aed59b");
assert!(media_item.can_delete.is_some());
assert!(media_item.can_delete.unwrap());
assert!(media_item.can_download.is_some());
assert!(media_item.can_download.unwrap());
assert!(media_item.has_subtitles.is_some());
assert!(media_item.has_subtitles.unwrap());
assert!(media_item.container.is_some());
assert_eq!(media_item.container.unwrap(), "mkv");
assert!(media_item.sort_name.is_some());
assert_eq!(media_item.sort_name.unwrap(), "batman");
// Test external URLs
assert!(media_item.external_urls.is_some());
let external_urls = media_item.external_urls.unwrap();
assert_eq!(external_urls.len(), 3);
assert_eq!(external_urls[0].name, "IMDb");
assert_eq!(external_urls[0].url, "https://www.imdb.com/title/tt1877830");
// Test media sources
assert!(media_item.media_sources.is_some());
let media_sources = media_item.media_sources.unwrap();
assert!(!media_sources.is_empty());
let first_source = &media_sources[0];
assert_eq!(first_source.id, "165a66aa5bd2e62c0df0f8da332ae47d");
assert_eq!(first_source.container.as_ref().unwrap(), "mkv");
assert_eq!(first_source.size.unwrap(), 94045682646);
println!("✅ Successfully deserialized MediaItem from JSON!");
println!("Media Item: {} ({})", media_item.name, media_item.item_type);
}
#[test]
fn test_deserialize_items_from_json() {
// Read the JSON file from the workspace root
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let file_path = format!("{manifest_dir}/src/models/tests/files/items.json");
let json_content = fs::read_to_string(file_path).expect("Failed to read items.json file");
// Deserialize the JSON into a MediaItem
let _: ItemsResponse = serde_json::from_str(&json_content)
.expect("Failed to deserialize JSON into ItemsResponse");
}
#[test]
fn test_deserialize_userviews_from_json() {
// Read the JSON file from the workspace root
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let file_path = format!("{manifest_dir}/src/models/tests/files/userviews.json");
let json_content =
fs::read_to_string(file_path).expect("Failed to read userviews.json file");
// Deserialize the JSON into a MediaItem
let _: ItemsResponse = serde_json::from_str(&json_content)
.expect("Failed to deserialize JSON into ItemsResponse");
}
#[test]
fn test_serialize_media_item_to_json() {
// First deserialize from file
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let file_path = format!("{manifest_dir}/src/models/tests/files/item.json");
let json_content = fs::read_to_string(file_path).expect("Failed to read res.json file");
let media_item: MediaItem =
serde_json::from_str(&json_content).expect("Failed to deserialize JSON into MediaItem");
// Now serialize back to JSON
let serialized_json = serde_json::to_string_pretty(&media_item)
.expect("Failed to serialize MediaItem to JSON");
// Verify we can deserialize it again
let deserialized_again: MediaItem =
serde_json::from_str(&serialized_json).expect("Failed to deserialize serialized JSON");
// Verify key fields match
assert_eq!(media_item.name, deserialized_again.name);
assert_eq!(media_item.id, deserialized_again.id);
assert_eq!(media_item.server_id, deserialized_again.server_id);
println!("✅ Successfully round-trip serialized/deserialized MediaItem!");
}
#[test]
fn test_media_item_partial_deserialization() {
// Test with minimal JSON data
let minimal_json = r#"{
"Name": "Test Movie",
"ServerId": "test-server-id",
"Id": "test-id",
"IsFolder": false,
"Type": "Movie",
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "test-key",
"ItemId": "test-id"
}
}"#;
let media_item: MediaItem = serde_json::from_str(minimal_json)
.expect("Failed to deserialize minimal MediaItem JSON");
assert_eq!(media_item.name, "Test Movie");
assert_eq!(media_item.server_id, "test-server-id");
assert_eq!(media_item.id, "test-id");
assert_eq!(media_item.is_folder, Some(false));
assert_eq!(media_item.item_type, "Movie");
// Verify optional fields are None when not provided
assert!(media_item.etag.is_none());
assert!(media_item.can_delete.is_none());
assert!(media_item.external_urls.is_none());
println!("✅ Successfully deserialized minimal MediaItem!");
}
#[test]
fn test_media_items() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let file_path = format!(
"{manifest_dir}/src/models/tests/files/special_features.json"
);
let json_content =
fs::read_to_string(file_path).expect("Failed to read special_features.json file");
let media_items: Vec<MediaItem> = serde_json::from_str(&json_content)
.expect("Failed to deserialize JSON into Vec<MediaItem>");
assert!(!media_items.is_empty(), "Media items should not be empty");
}
#[test]
fn test_media_nextup() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let file_path = format!("{manifest_dir}/src/models/tests/files/series_nextup.json");
let json_content =
fs::read_to_string(file_path).expect("Failed to read series_nextup.json file");
let media_items: ItemsResponse = serde_json::from_str(&json_content)
.expect("Failed to deserialize JSON into ItemsResponse");
assert!(
!media_items.items.is_empty(),
"Media items should not be empty"
);
}
#[test]
fn test_media_episodes() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let file_path = format!("{manifest_dir}/src/models/tests/files/episodes.json");
let json_content =
fs::read_to_string(file_path).expect("Failed to read episodes.json file");
let media_items: ItemsResponse = serde_json::from_str(&json_content)
.expect("Failed to deserialize JSON into ItemsResponse");
assert!(
!media_items.items.is_empty(),
"Media items should not be empty"
);
}
#[test]
fn test_person() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let file_path = format!("{manifest_dir}/src/models/tests/files/person.json");
let json_content = fs::read_to_string(file_path).expect("Failed to read person.json file");
let _person: MediaItem = serde_json::from_str(&json_content)
.map_err(|e| {
eprintln!("Deserialization error: {e}");
eprintln!("JSON content: {json_content}");
e
})
.expect("Failed to deserialize JSON into ItemsResponse");
}
}

View File

@@ -0,0 +1,466 @@
use axum::extract::{OriginalUri, Request};
use anyhow::Result;
use axum::http;
use http_body_util::BodyExt;
use tracing::debug;
use crate::models::Authorization;
use crate::server_storage::Server;
use crate::url_helper::{contains_id, replace_id};
use crate::user_authorization_service::{AuthorizationSession, Device, User};
use crate::AppState;
// Static configuration for server resolution
static MEDIA_ID_PATH_TAGS: &[&str] = &["Items", "Shows", "Videos", "PlayedItems", "FavoriteItems"];
static MEDIA_ID_QUERY_TAGS: &[&str] = &["ParentId", "SeriesId", "MediaSourceId", "Tag", "SeasonId"];
static USER_ID_PATH_TAGS: &[&str] = &["Users"];
static USER_ID_QUERY_TAGS: &[&str] = &["UserId"];
static API_KEY_QUERY_TAGS: &[&str] = &["api_key"];
#[derive(Debug, Clone)]
pub enum JellyfinAuthorization {
Authorization(Authorization),
XMediaBrowser(String),
ApiKey(String),
}
impl JellyfinAuthorization {
pub fn token(&self) -> Option<String> {
match self {
JellyfinAuthorization::Authorization(auth) => auth.token.clone(),
JellyfinAuthorization::XMediaBrowser(token) => Some(token.clone()),
JellyfinAuthorization::ApiKey(token) => Some(token.clone()),
}
}
pub fn get_device(&self) -> Option<Device> {
match self {
JellyfinAuthorization::Authorization(auth) => Some(Device {
client: auth.client.clone(),
device: auth.device.clone(),
device_id: auth.device_id.clone(),
version: auth.version.clone(),
}),
JellyfinAuthorization::XMediaBrowser(_) => None,
JellyfinAuthorization::ApiKey(_) => None,
}
}
pub fn from_request(req: &reqwest::Request) -> Option<Self> {
let headers = req.headers();
if let Some(auth_header) = headers.get("authorization") {
if let Ok(auth_str) = auth_header.to_str() {
if let Ok(auth) = Authorization::parse(auth_str) {
return Some(JellyfinAuthorization::Authorization(auth));
}
}
}
if let Some(token_header) = headers.get("X-MediaBrowser-Token") {
if let Ok(token_str) = token_header.to_str() {
return Some(JellyfinAuthorization::XMediaBrowser(token_str.to_string()));
}
}
if let Some(auth) = req.url().query_pairs().find_map(|(k, v)| {
if k == "api_key" {
Some(JellyfinAuthorization::ApiKey(v.to_string()))
} else {
None
}
}) {
return Some(auth);
}
None
}
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct PreprocessedRequest {
pub request: reqwest::Request,
pub original_request: Option<reqwest::Request>,
pub user: Option<User>,
pub sessions: Option<Vec<(AuthorizationSession, Server)>>,
pub server: Server,
pub auth: Option<JellyfinAuthorization>,
pub session: Option<AuthorizationSession>,
pub new_auth: Option<JellyfinAuthorization>,
}
pub async fn extract_request_infos(
req: Request,
state: &AppState,
) -> Result<(
reqwest::Request,
Option<JellyfinAuthorization>,
Option<User>,
Option<Vec<(AuthorizationSession, Server)>>,
)> {
let request = axum_to_reqwest(req).await?;
let auth = JellyfinAuthorization::from_request(&request);
let device = if let Some(auth) = &auth {
auth.get_device()
} else {
None
};
let user = get_user_from_request(&request, &auth, state).await?;
let sessions = if let Some(user) = &user {
let sessions = state
.user_authorization
.get_user_sessions(&user.id, device)
.await?;
if !sessions.is_empty() {
Some(sessions)
} else {
None
}
} else {
None
};
Ok((request, auth, user, sessions))
}
pub async fn preprocess_request(req: Request, state: &AppState) -> Result<PreprocessedRequest> {
debug!("Preprocessing request: {:?}", req.uri());
let (mut request, auth, user, sessions) = extract_request_infos(req, state).await?;
let original_request = request.try_clone();
let (server, session) = resolve_server(&sessions, state, &request).await?;
let new_auth = remap_authorization(&auth, &session).await?;
apply_to_request(&mut request, &server, &session, &new_auth, state).await;
Ok(PreprocessedRequest {
request,
original_request,
user,
sessions,
server,
auth,
session,
new_auth,
})
}
pub async fn apply_to_request(
request: &mut reqwest::Request,
server: &Server,
session: &Option<AuthorizationSession>,
auth: &Option<JellyfinAuthorization>,
state: &AppState,
) {
remove_hop_by_hop_headers(request.headers_mut());
apply_host_header(request, server);
apply_authorization_header(request, auth);
apply_new_target_uri(request, server, session, state).await;
}
pub async fn apply_new_target_uri(
request: &mut reqwest::Request,
server: &Server,
session: &Option<AuthorizationSession>,
state: &AppState,
) {
let mut new_url = server.url.clone();
// Get the original path and query
let mut orig_url = request.url().clone();
// Handle user ID replacement in the path if session is available
if let Some(session) = session {
for &path_segment in USER_ID_PATH_TAGS {
if let Some(user_id) = contains_id(&orig_url, path_segment) {
orig_url = replace_id(orig_url, &user_id, &session.original_user_id);
}
}
}
// Process media IDs in the path
for &path_segment in MEDIA_ID_PATH_TAGS {
if let Some(media_id) = contains_id(&orig_url, path_segment) {
if let Some(media_mapping) = state
.media_storage
.get_media_mapping_by_virtual(&media_id)
.await
.unwrap_or_default()
{
orig_url = replace_id(
orig_url,
&media_mapping.virtual_media_id,
&media_mapping.original_media_id,
);
}
}
}
// Parse and modify query pairs
let mut pairs: Vec<(String, String)> = orig_url
.query_pairs()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
// If session is available, update the user ID and api key in the query
if let Some(session) = session {
for &param_name in USER_ID_QUERY_TAGS {
if let Some(idx) = pairs
.iter()
.position(|(k, _)| k.eq_ignore_ascii_case(param_name))
{
pairs[idx].1 = session.original_user_id.clone();
}
}
for &param_name in API_KEY_QUERY_TAGS {
if let Some(idx) = pairs
.iter()
.position(|(k, _)| k.eq_ignore_ascii_case(param_name))
{
pairs[idx].1 = session.jellyfin_token.clone();
}
}
}
// Process media IDs in the query
for &param_name in MEDIA_ID_QUERY_TAGS {
if let Some(idx) = pairs
.iter()
.position(|(k, _)| k.eq_ignore_ascii_case(param_name))
{
let param_value = &pairs[idx].1.clone();
if let Some(media_mapping) = state
.media_storage
.get_media_mapping_by_virtual(param_value)
.await
.unwrap_or_default()
{
pairs[idx].1 = media_mapping.original_media_id;
}
}
}
// Clear and set new query
new_url.query_pairs_mut().clear().extend_pairs(pairs);
new_url.set_path(orig_url.path());
// Set the new URL on the request
*request.url_mut() = new_url;
}
pub fn apply_authorization_header(
request: &mut reqwest::Request,
auth: &Option<JellyfinAuthorization>,
) {
if let Some(auth) = auth {
match auth {
JellyfinAuthorization::Authorization(auth) => {
request.headers_mut().insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&auth.to_header_value()).unwrap(),
);
}
JellyfinAuthorization::XMediaBrowser(token) => {
request.headers_mut().insert(
"X-MediaBrowser-Token",
reqwest::header::HeaderValue::from_str(token).unwrap(),
);
}
JellyfinAuthorization::ApiKey(_) => {}
}
}
}
pub fn apply_host_header(request: &mut reqwest::Request, server: &Server) {
if let Some(host) = server.url.host_str() {
request.headers_mut().insert(
reqwest::header::HOST,
reqwest::header::HeaderValue::from_str(host).unwrap(),
);
}
}
pub async fn remap_authorization(
auth: &Option<JellyfinAuthorization>,
session: &Option<AuthorizationSession>,
) -> Result<Option<JellyfinAuthorization>> {
let Some(auth) = auth else {
return Ok(None);
};
if let Some(session) = session {
match auth {
JellyfinAuthorization::Authorization(_) => {
return Ok(Some(JellyfinAuthorization::Authorization(
session.to_authorization(),
)));
}
JellyfinAuthorization::XMediaBrowser(_) => {
let token = session.jellyfin_token.clone();
return Ok(Some(JellyfinAuthorization::XMediaBrowser(token)));
}
JellyfinAuthorization::ApiKey(_) => {
let token = session.jellyfin_token.clone();
return Ok(Some(JellyfinAuthorization::ApiKey(token)));
}
}
}
Ok(None)
}
pub async fn resolve_server(
sessions: &Option<Vec<(AuthorizationSession, Server)>>,
state: &AppState,
request: &reqwest::Request,
) -> Result<(Server, Option<AuthorizationSession>)> {
let mut request_server = None;
// Check URL paths for media IDs using the static configuration
for &path_segment in MEDIA_ID_PATH_TAGS {
if let Some(media_id) = contains_id(request.url(), path_segment) {
debug!("Found {} ID in request: {}", path_segment, media_id);
if let Some((_mapping, server)) = state
.media_storage
.get_media_mapping_with_server(&media_id)
.await?
{
debug!(
"Found server for {} ID {}: {} ({})",
path_segment, media_id, server.name, server.url
);
request_server = Some(server);
break; // Stop at first match
} else {
debug!("No server found for {} ID: {}", path_segment, media_id);
}
}
}
// Check query parameters using the static configuration
if request_server.is_none() {
for &param_name in MEDIA_ID_QUERY_TAGS {
if let Some(param_value) = request
.url()
.query_pairs()
.find(|(k, _)| k == param_name)
.map(|(_, v)| v.to_string())
{
debug!("Found {} in query: {}", param_name, param_value);
if let Some((_mapping, server)) = state
.media_storage
.get_media_mapping_with_server(&param_value)
.await?
{
debug!(
"Found server for {} {}: {} ({})",
param_name, param_value, server.name, server.url
);
request_server = Some(server);
break; // Stop at first match
} else {
debug!("No server found for {} : {}", param_name, param_value);
}
}
}
}
if let Some(sessions) = sessions {
if let Some(request_server) = request_server {
if let Some((session, server)) = sessions.iter().find(|(_, server)| {
let request_url = request_server.url.as_str().trim_end_matches('/');
let server_url = server.url.as_str().trim_end_matches('/');
request_url == server_url
}) {
debug!("Found server in request: {}", server.url);
return Ok((server.clone(), Some(session.clone())));
}
}
let (session, server) = sessions.first().unwrap();
return Ok((server.clone(), Some(session.clone())));
}
if let Some(request_server) = request_server {
debug!("Using request server: {}", request_server.url);
return Ok((request_server, None));
}
let server = state.server_storage.get_best_server().await?;
let server = server.ok_or_else(|| anyhow::anyhow!("No server available"))?;
Ok((server, None))
}
pub async fn get_user_from_request(
_req: &reqwest::Request,
auth: &Option<JellyfinAuthorization>,
state: &AppState,
) -> Result<Option<User>> {
let Some(auth) = auth else {
// todo: handle user ids in request params
return Ok(None);
};
let Some(token) = auth.token() else {
// No token, return None
return Ok(None);
};
let user = state
.user_authorization
.get_user_by_virtual_key(&token)
.await?;
Ok(user)
}
pub async fn axum_to_reqwest(req: Request) -> Result<reqwest::Request> {
let original_uri = req.extensions().get::<OriginalUri>().unwrap();
let uri_with_host = http::uri::Builder::new()
.scheme("http")
.authority("localhost")
.path_and_query(original_uri.path_and_query().unwrap().to_string())
.build()
.unwrap();
// First extract parts and body separately
let (parts, body) = req.into_parts();
let body_bytes = body.collect().await?.to_bytes();
let mut http_req = http::Request::from_parts(parts, reqwest::Body::from(body_bytes));
*http_req.uri_mut() = uri_with_host;
let rewquest_req =
reqwest::Request::try_from(http_req).expect("http::Uri to url::Url conversion failed");
Ok(rewquest_req)
}
fn remove_hop_by_hop_headers(headers: &mut reqwest::header::HeaderMap) {
let hop_by_hop_headers = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
];
for h in hop_by_hop_headers.iter() {
headers.remove(*h);
}
}

View File

@@ -0,0 +1,243 @@
use serde::{Deserialize, Serialize};
use sqlx::{Row, SqlitePool};
use tracing::info;
use url::Url;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Server {
pub id: i64,
pub name: String,
pub url: Url,
pub priority: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub struct ServerStorageService {
pool: SqlitePool,
}
impl ServerStorageService {
pub async fn new(pool: SqlitePool) -> Result<Self, sqlx::Error> {
// Create the servers table if it doesn't exist
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 100,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"#,
)
.execute(&pool)
.await?;
info!("Server storage database initialized");
Ok(Self { pool })
}
pub async fn add_server(
&self,
name: &str,
url: &str,
priority: i32,
) -> Result<i64, sqlx::Error> {
// Validate URL
if Url::parse(url).is_err() {
return Err(sqlx::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Invalid URL format",
)));
}
let now = chrono::Utc::now();
let result = sqlx::query(
r#"
INSERT INTO servers (name, url, priority, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
"#,
)
.bind(name)
.bind(url)
.bind(priority)
.bind(now)
.bind(now)
.execute(&self.pool)
.await?;
let server_id = result.last_insert_rowid();
info!(
"Added server: {} ({}) with priority {}",
name, url, priority
);
Ok(server_id)
}
pub async fn get_server_by_name(&self, name: &str) -> Result<Option<Server>, sqlx::Error> {
let row = sqlx::query(
r#"
SELECT id, name, url, priority, created_at, updated_at
FROM servers
WHERE name = ?
"#,
)
.bind(name)
.fetch_optional(&self.pool)
.await?;
if let Some(row) = row {
Ok(Some(self.row_to_server(row)))
} else {
Ok(None)
}
}
pub async fn get_server_by_id(&self, id: i64) -> Result<Option<Server>, sqlx::Error> {
let row = sqlx::query(
r#"
SELECT id, name, url, priority, created_at, updated_at
FROM servers
WHERE id = ?
"#,
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
if let Some(row) = row {
Ok(Some(self.row_to_server(row)))
} else {
Ok(None)
}
}
pub async fn list_servers(&self) -> Result<Vec<Server>, sqlx::Error> {
let rows = sqlx::query(
r#"
SELECT id, name, url, priority, created_at, updated_at
FROM servers
ORDER BY priority DESC, name ASC
"#,
)
.fetch_all(&self.pool)
.await?;
let servers = rows
.into_iter()
.map(|row| self.row_to_server(row))
.collect();
Ok(servers)
}
pub async fn update_server_priority(
&self,
server_id: i64,
new_priority: i32,
) -> Result<bool, sqlx::Error> {
let now = chrono::Utc::now();
let result = sqlx::query(
r#"
UPDATE servers
SET priority = ?, updated_at = ?
WHERE id = ?
"#,
)
.bind(new_priority)
.bind(now)
.bind(server_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn delete_server(&self, server_id: i64) -> Result<bool, sqlx::Error> {
let result = sqlx::query(
r#"
DELETE FROM servers
WHERE id = ?
"#,
)
.bind(server_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn delete_server_by_name(&self, name: &str) -> Result<bool, sqlx::Error> {
let result = sqlx::query(
r#"
DELETE FROM servers
WHERE name = ?
"#,
)
.bind(name)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Get the best available server (highest priority, healthy, active)
pub async fn get_best_server(&self) -> Result<Option<Server>, sqlx::Error> {
let servers = self.list_servers().await?;
Ok(servers.into_iter().next())
}
/// Internal method to convert database row to Server struct
fn row_to_server(&self, row: sqlx::sqlite::SqliteRow) -> Server {
Server {
id: row.get("id"),
name: row.get("name"),
url: Url::parse(row.get("url")).unwrap(),
priority: row.get("priority"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_server_storage_service() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
let service = ServerStorageService::new(pool).await.unwrap();
// Test adding a server
let server_id = service
.add_server("test-server", "http://localhost:8096", 100)
.await
.unwrap();
// Test getting the server
let server = service.get_server_by_id(server_id).await.unwrap();
assert!(server.is_some());
let server = server.unwrap();
assert_eq!(server.name, "test-server");
assert_eq!(server.url, Url::parse("http://localhost:8096").unwrap());
assert_eq!(server.priority, 100);
// Test listing servers
let servers = service.list_servers().await.unwrap();
assert_eq!(servers.len(), 1);
// Test updating priority
let updated = service
.update_server_priority(server_id, 200)
.await
.unwrap();
assert!(updated);
}
}

View File

@@ -0,0 +1,51 @@
use tokio::sync::RwLock;
use crate::server_storage::Server;
#[derive(Clone)]
pub struct PlaybackSession {
pub session_id: String, // Unique identifier for the session
pub item_id: String, // ID of the media item being played
pub server: Server,
}
pub struct SessionStorage {
pub sessions: RwLock<Vec<PlaybackSession>>,
}
impl Default for SessionStorage {
fn default() -> Self {
Self::new()
}
}
impl SessionStorage {
pub fn new() -> Self {
SessionStorage {
sessions: RwLock::new(Vec::new()),
}
}
pub async fn add_session(&self, session: PlaybackSession) {
let mut sessions = self.sessions.write().await;
sessions.push(session);
}
pub async fn get_session(&self, session_id: &str) -> Option<PlaybackSession> {
let sessions = self.sessions.read().await;
sessions
.iter()
.find(|s| s.session_id == session_id)
.cloned()
}
pub async fn get_session_by_item_id(&self, item_id: &str) -> Option<PlaybackSession> {
let sessions = self.sessions.read().await;
sessions.iter().find(|s| s.item_id == item_id).cloned()
}
pub async fn remove_session(&self, session_id: &str) {
let mut sessions = self.sessions.write().await;
sessions.retain(|s| s.session_id != session_id);
}
}

View File

@@ -0,0 +1,117 @@
use std::sync::Arc;
use axum_login::{AuthUser, AuthnBackend, UserId};
use serde::{Deserialize, Serialize};
use tokio::{sync::RwLock, task};
use crate::config::AppConfig;
mod routes;
pub use routes::router;
#[derive(Clone, Serialize, Deserialize)]
pub struct User {
id: i64,
pub username: String,
password: String,
}
// Here we've implemented `Debug` manually to avoid accidentally logging the
// password hash.
impl std::fmt::Debug for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("User")
.field("id", &self.id)
.field("username", &self.username)
.field("password", &"[redacted]")
.finish()
}
}
impl AuthUser for User {
type Id = i64;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
self.password.as_bytes() // We use the password hash as the auth
// hash--what this means
// is when the user changes their password the
// auth session becomes invalid.
}
}
// This allows us to extract the authentication fields from forms. We use this
// to authenticate requests with the backend.
#[derive(Debug, Clone, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
pub next: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Backend {
config: Arc<RwLock<AppConfig>>,
}
impl Backend {
pub fn new(config: Arc<RwLock<AppConfig>>) -> Self {
Self { config }
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
TaskJoin(#[from] task::JoinError),
}
impl AuthnBackend for Backend {
type User = User;
type Credentials = Credentials;
type Error = Error;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let config = self.config.read().await;
if creds.username != config.username {
return Ok(None);
}
if creds.password == config.password {
// If the password is correct, we return the default user.
let user = User {
id: 1,
username: creds.username,
password: config.password.clone(),
};
Ok(Some(user))
} else {
Ok(None)
}
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let config = self.config.read().await;
if *user_id != 1 {
return Ok(None); // Only one user in this example.
}
Ok(Some(User {
id: 1,
username: config.username.clone(),
password: config.password.clone(),
}))
}
}
// We use a type alias for convenience.
//
// Note that we've supplied our concrete backend here.
pub type AuthSession = axum_login::AuthSession<Backend>;

View File

@@ -0,0 +1,102 @@
use askama::Template;
use axum::{
extract::Query,
http::StatusCode,
response::{Html, IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use axum_messages::{Message, Messages};
use serde::Deserialize;
use crate::{
ui::auth::{AuthSession, Credentials},
AppState,
};
#[derive(Template)]
#[template(path = "login.html")]
pub struct LoginTemplate {
messages: Vec<Message>,
next: Option<String>,
}
// This allows us to extract the "next" field from the query string. We use this
// to redirect after log in.
#[derive(Debug, Deserialize)]
pub struct NextUrl {
next: Option<String>,
}
pub fn router() -> axum::Router<AppState> {
Router::new()
.route("/login", post(self::post::login))
.route("/login", get(self::get::login))
.route("/logout", get(self::get::logout))
}
mod post {
use super::*;
pub async fn login(
mut auth_session: AuthSession,
messages: Messages,
Form(creds): Form<Credentials>,
) -> impl IntoResponse {
let user = match auth_session.authenticate(creds.clone()).await {
Ok(Some(user)) => user,
Ok(None) => {
messages.error("Invalid credentials");
let mut login_url = "/ui/login".to_string();
if let Some(next) = creds.next {
login_url = format!("{login_url}?next={next}");
} else {
login_url = format!("{login_url}?next=/ui");
}
return Redirect::to(&login_url).into_response();
}
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
if auth_session.login(&user).await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
messages.success(format!("Successfully logged in as {}", user.username));
if let Some(ref next) = creds.next {
Redirect::to(next)
} else {
Redirect::to("/ui")
}
.into_response()
}
}
mod get {
use super::*;
pub async fn login(
messages: Messages,
Query(NextUrl { next }): Query<NextUrl>,
) -> Html<String> {
Html(
LoginTemplate {
messages: messages.into_iter().collect(),
next,
}
.render()
.unwrap(),
)
}
pub async fn logout(mut auth_session: AuthSession) -> impl IntoResponse {
match auth_session.logout().await {
Ok(_) => Redirect::to("/ui/login").into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
}

View File

@@ -0,0 +1,73 @@
use axum::{
body::Body,
extract::Path,
response::{IntoResponse, Response},
routing::{get, post},
Router,
};
use axum_login::login_required;
use hyper::StatusCode;
use rust_embed::RustEmbed;
use crate::AppState;
mod auth;
pub mod root;
pub mod servers;
pub mod settings;
pub mod users;
pub use auth::Backend;
#[derive(RustEmbed)]
#[folder = "src/ui/resources/"]
struct Resources;
pub async fn resource_handler(Path(path): Path<String>) -> impl IntoResponse {
if let Some(file) = Resources::get(&path) {
let mime = mime_guess::from_path(path).first_or_octet_stream();
Ok(Response::builder()
.header("Content-Type", mime.as_ref())
.body(Body::from(file.data.into_owned()))
.unwrap())
} else {
Err(StatusCode::NOT_FOUND)
}
}
pub fn ui_routes() -> axum::Router<AppState> {
Router::new()
.route("/", get(root::index))
// Users
.route("/users", get(users::users_page))
.route("/users", post(users::add_user))
.route("/users/list", get(users::get_user_list))
.route("/users/{id}", axum::routing::delete(users::delete_user))
.route("/users/mappings", post(users::add_mapping))
.route(
"/users/mappings/{id}",
axum::routing::delete(users::delete_mapping),
)
.route(
"/users/sessions/{id}",
axum::routing::delete(users::delete_sessions),
)
.route("/servers", get(servers::servers_page))
.route("/servers", post(servers::add_server))
.route("/servers/list", get(servers::get_server_list))
.route(
"/servers/{id}",
axum::routing::delete(servers::delete_server),
)
.route(
"/servers/{id}/priority",
axum::routing::patch(servers::update_server_priority),
)
.route("/servers/{id}/status", get(servers::check_server_status))
// Settings
.route("/settings", get(settings::settings_page))
.route("/settings/form", get(settings::settings_form))
.route("/settings/save", post(settings::save_settings))
.route("/settings/reload", post(settings::reload_config))
.route_layer(login_required!(Backend, login_url = "/ui/login"))
.merge(auth::router())
}

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">JellySwarrm Logo</title>
<desc id="desc">A hexagonal swarm of gradient circles with the official Jellyfin logo and larger JellySwarrm text below, fully visible in the viewport.</desc>
<defs>
<!-- Official Jellyfin gradient -->
<linearGradient id="jfGradient" gradientUnits="userSpaceOnUse" x1="126.15" y1="219.32" x2="457.68" y2="410.73">
<stop offset="0%" stop-color="#aa5cc3"/>
<stop offset="100%" stop-color="#00a4dc"/>
</linearGradient>
</defs>
<!-- Enlarged swarm hexagon with circles -->
<g stroke="url(#jfGradient)" stroke-width="8" fill="none">
<polygon points="
256,72
400,156
400,356
256,440
112,356
112,156
" />
<circle cx="256" cy="72" r="20" fill="url(#jfGradient)"/>
<circle cx="400" cy="156" r="20" fill="url(#jfGradient)"/>
<circle cx="400" cy="356" r="20" fill="url(#jfGradient)"/>
<circle cx="256" cy="440" r="20" fill="url(#jfGradient)"/>
<circle cx="112" cy="356" r="20" fill="url(#jfGradient)"/>
<circle cx="112" cy="156" r="20" fill="url(#jfGradient)"/>
</g>
<!-- Official Jellyfin logo scaled and moved up -->
<g transform="translate(256,240) scale(0.55) translate(-256,-256)">
<!-- Outer shape -->
<path d="M58.75 417.03c25.97 52.15 368.86 51.55 394.55 0S308.93 56.08 256.03 56.08c-52.92 0-223.25 308.8-197.28 360.95zm68.04-45.25c-17.02-34.17 94.6-236.5 129.26-236.5 34.67 0 146.1 202.7 129.26 236.5-16.83 33.8-241.5 34.17-258.52 0z"
fill="url(#jfGradient)"/>
<!-- Inner shape -->
<path d="M190.56 329.07c8.63 17.3 122.4 17.12 130.93 0 8.52-17.1-47.9-119.78-65.46-119.8-17.57 0-74.1 102.5-65.47 119.8z"
fill="url(#jfGradient)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse},
};
use tracing::error;
#[derive(Template)]
#[template(path = "index.html")]
pub struct IndexTemplate {
pub version: Option<String>,
}
/// Root/home page
pub async fn index() -> impl IntoResponse {
let template = IndexTemplate {
version: Some(env!("CARGO_PKG_VERSION").to_string()),
};
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render index template: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}

View File

@@ -0,0 +1,289 @@
use askama::Template;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{Html, IntoResponse, Response},
Form,
};
use serde::Deserialize;
use tracing::{error, info};
use crate::{server_storage::Server, AppState};
#[derive(Template)]
#[template(path = "servers.html")]
pub struct ServersPageTemplate {}
#[derive(Template)]
#[template(path = "server_list.html")]
pub struct ServerListTemplate {
pub servers: Vec<Server>,
}
#[derive(Template)]
#[template(path = "server_status.html")]
pub struct ServerStatusTemplate {
pub error_message: Option<String>,
}
#[derive(Deserialize)]
pub struct AddServerForm {
pub name: String,
pub url: String,
pub priority: i32,
}
#[derive(Deserialize)]
pub struct UpdatePriorityForm {
pub priority: i32,
}
/// Main servers management page
pub async fn servers_page() -> impl IntoResponse {
let template = ServersPageTemplate {};
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render servers template: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}
/// Get server list partial (for HTMX)
pub async fn get_server_list(State(state): State<AppState>) -> impl IntoResponse {
match state.server_storage.list_servers().await {
Ok(servers) => {
let template = ServerListTemplate { servers };
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render server list template: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}
Err(e) => {
error!("Failed to fetch servers: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
}
}
}
/// Add a new server
pub async fn add_server(
State(state): State<AppState>,
Form(form): Form<AddServerForm>,
) -> Response {
// Validate the form data
if form.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Html("<div class=\"alert alert-error\">Server name cannot be empty</div>"),
)
.into_response();
}
if form.url.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Html("<div class=\"alert alert-error\">Server URL cannot be empty</div>"),
)
.into_response();
}
if form.priority < 1 || form.priority > 999 {
return (
StatusCode::BAD_REQUEST,
Html("<div class=\"alert alert-error\">Priority must be between 1 and 999</div>"),
)
.into_response();
}
// Try to add the server
match state
.server_storage
.add_server(form.name.trim(), form.url.trim(), form.priority)
.await
{
Ok(server_id) => {
info!(
"Added new server: {} ({}) with ID: {}",
form.name, form.url, server_id
);
// Return updated server list
get_server_list(State(state)).await.into_response()
}
Err(e) => {
error!("Failed to add server: {}", e);
let error_message = if e.to_string().contains("UNIQUE constraint failed") {
"A server with that name already exists"
} else if e.to_string().contains("Invalid URL") {
"Invalid URL format"
} else {
"Failed to add server"
};
(
StatusCode::BAD_REQUEST,
Html(format!(
"<div class=\"alert alert-error\">{error_message}</div>"
)),
)
.into_response()
}
}
}
/// Delete a server
pub async fn delete_server(State(state): State<AppState>, Path(server_id): Path<i64>) -> Response {
match state.server_storage.delete_server(server_id).await {
Ok(true) => {
info!("Deleted server with ID: {}", server_id);
// Return updated server list
get_server_list(State(state)).await.into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Html("<div class=\"alert alert-error\">Server not found</div>"),
)
.into_response(),
Err(e) => {
error!("Failed to delete server: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Html("<div class=\"alert alert-error\">Failed to delete server</div>"),
)
.into_response()
}
}
}
/// Update server priority
pub async fn update_server_priority(
State(state): State<AppState>,
Path(server_id): Path<i64>,
Form(form): Form<UpdatePriorityForm>,
) -> Response {
if form.priority < 1 || form.priority > 999 {
return (
StatusCode::BAD_REQUEST,
Html("<div class=\"alert alert-error\">Priority must be between 1 and 999</div>"),
)
.into_response();
}
match state
.server_storage
.update_server_priority(server_id, form.priority)
.await
{
Ok(true) => {
info!("Updated server {} priority to {}", server_id, form.priority);
// Return updated server list
get_server_list(State(state)).await.into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Html("<div class=\"alert alert-error\">Server not found</div>"),
)
.into_response(),
Err(e) => {
error!("Failed to update server priority: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Html("<div class=\"alert alert-error\">Failed to update priority</div>"),
)
.into_response()
}
}
}
/// Check server status
pub async fn check_server_status(
State(state): State<AppState>,
Path(server_id): Path<i64>,
) -> impl IntoResponse {
// Get the server details first
match state.server_storage.get_server_by_id(server_id).await {
Ok(Some(server)) => {
// Test connection to the server
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let status_url = format!(
"{}/system/info/public",
server.url.to_string().trim_end_matches('/')
);
match client.get(&status_url).send().await {
Ok(response) if response.status().is_success() => {
let template = ServerStatusTemplate {
error_message: None,
};
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render status template: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}
Ok(response) => {
let template = ServerStatusTemplate {
error_message: Some(format!("HTTP {}", response.status().as_u16())),
};
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render status template: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}
Err(e) => {
let error_msg = if e.is_timeout() {
"Connection timeout".to_string()
} else if e.is_connect() {
"Connection refused".to_string()
} else {
format!("Network error: {e}")
};
let template = ServerStatusTemplate {
error_message: Some(error_msg),
};
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render status template: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}
}
}
Ok(None) => (
StatusCode::NOT_FOUND,
Html("<span style=\"color: #dc3545;\">Server not found</span>"),
)
.into_response(),
Err(e) => {
error!("Failed to get server: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Html("<span style=\"color: #dc3545;\">Database error</span>"),
)
.into_response()
}
}
}

View File

@@ -0,0 +1,93 @@
use askama::Template;
use axum::{
extract::State,
http::StatusCode,
response::{Html, IntoResponse},
Form,
};
use serde::Deserialize;
use tracing::error;
use crate::{config::save_config, AppState};
#[derive(Template)]
#[template(path = "settings.html")]
pub struct SettingsPageTemplate {}
#[derive(Template)]
#[template(path = "settings_form.html")]
pub struct SettingsFormTemplate {
pub server_id: String,
pub public_address: String,
pub server_name: String,
pub include_server_name_in_media: bool,
}
pub async fn settings_page() -> impl IntoResponse {
let template = SettingsPageTemplate {};
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render settings page: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}
pub async fn settings_form(State(state): State<AppState>) -> impl IntoResponse {
let cfg = state.config.read().await.clone();
let form = SettingsFormTemplate {
server_id: cfg.server_id,
public_address: cfg.public_address,
server_name: cfg.server_name,
include_server_name_in_media: cfg.include_server_name_in_media,
};
match form.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render settings form: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}
#[derive(Deserialize)]
pub struct SaveForm {
pub public_address: String,
pub server_name: String,
// When the checkbox is unchecked the field is absent; default to false.
#[serde(default)]
pub include_server_name_in_media: bool,
}
pub async fn save_settings(
State(state): State<AppState>,
Form(form): Form<SaveForm>,
) -> impl IntoResponse {
if form.public_address.trim().is_empty() || form.server_name.trim().is_empty() {
return Html(
"<div id=\"settings-messages\" class=\"alert alert-error\">All fields required</div>",
)
.into_response();
}
{
let mut cfg = state.config.write().await;
cfg.public_address = form.public_address.trim().to_string();
cfg.server_name = form.server_name.trim().to_string();
cfg.include_server_name_in_media = form.include_server_name_in_media;
if let Err(e) = save_config(&cfg) {
error!("Save failed: {}", e);
}
}
// Return fresh form (like server list pattern)
settings_form(State(state)).await.into_response()
}
pub async fn reload_config(State(state): State<AppState>) -> impl IntoResponse {
let new_cfg = crate::config::load_config();
{
let mut cfg = state.config.write().await;
*cfg = new_cfg;
}
Html("<div class=\"alert\">Configuration reloaded</div>")
}

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Jellyswarrm{% endblock %}</title>
<!-- Pico CSS -->
<link rel="stylesheet" href="/resources/pico.min.css">
<!-- Font Awesome -->
<link rel="stylesheet" href="/resources/fontawesome/css/all.min.css">
<!-- HTMX -->
<script src="/resources/htmx.min.js"></script>
{% block head %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
<!-- HTMX Configuration -->
<script>
// Theme switcher
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
// Load saved theme
document.addEventListener('DOMContentLoaded', function() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
}
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Dashboard - Jellyswarrm{% endblock %}
{% block body %}
<!-- Navigation -->
<header class="container">
<nav>
<ul>
<li>
<a href="/ui" style="display: flex; align-items: center; text-decoration: none;">
<img src="/resources/icon.svg" alt="Jellyswarrm Logo" style="width: 120px; height: 120px; margin-right: 0.25rem;">
<strong>Jellyswarrm</strong>
</a>
</li>
</ul>
<ul>
<li>
<a href="#" hx-get="/ui/servers" hx-target="#main-content" class="nav-link">
<i class="fas fa-server" style="margin-right: 0.25rem;"></i>
Servers
</a>
</li>
<li>
<a href="#" hx-get="/ui/users" hx-target="#main-content" class="nav-link">
<i class="fas fa-users" style="margin-right: 0.25rem;"></i>
Users
</a>
</li>
<li>
<a href="#" hx-get="/ui/settings" hx-target="#main-content" class="nav-link">
<i class="fas fa-cog" style="margin-right: 0.25rem;"></i>
Settings
</a>
</li>
<li>
<a href="/" target="_blank">
<i class="fas fa-film" style="margin-right: 0.25rem;"></i>
Web UI
</a>
</li>
<li>
<button onclick="location.href='/ui/logout';" class="outline" style="background-color: var(--pico-del-color); color: white; border: 1px solid var(--pico-del-color); outline-color: var(--pico-del-color); outline-style: solid; outline-width: 2px; padding: 0.25rem;">
<i class="fas fa-sign-out-alt"></i>
</button>
</li>
<li>
<button onclick="toggleTheme()" aria-label="Toggle theme" class="secondary" style="padding: 0.25rem;">
<i class="fas fa-adjust"></i>
</button>
</li>
</ul>
</nav>
</header>
<!-- Main content -->
<main class="container">
<!-- Flash messages -->
<div id="messages" style="margin-bottom: 1rem;">
<!-- Messages will be dynamically loaded here via HTMX -->
</div>
<!-- Main content area -->
<section>
<div id="main-content" hx-get="/ui/servers" hx-trigger="load">
<div style="text-align: center; padding: 2rem;">
<i class="fas fa-spinner fa-spin" style="font-size: 1.5rem; color: var(--pico-muted-color);"></i>
<p style="margin-top: 1rem; color: var(--pico-muted-color);">Loading dashboard...</p>
</div>
</div>
</section>
</main>
<!-- Footer -->
<footer class="container" style="margin-top: 3rem;">
<hr>
<div style="text-align: center; padding: 1rem 0;">
<p style="margin: 0; color: var(--pico-muted-color); font-size: 0.85rem;">
<i class="fas fa-server" style="margin-right: 0.5rem;"></i>
<a href="https://github.com/LLukas22/Jellyswarrm">Jellyswarrm Proxy Server
{% match version %}{% when Some with (v) %} v{{ v }}{% when None %}{% endmatch %}
</a>
</p>
<p style="margin: 0.5rem 0 0 0; color: var(--pico-muted-color); font-size: 0.75rem;">
<i class="fas fa-heart" style="margin-right: 0.25rem; color: var(--pico-del-color);"></i>
Built with Rust & HTMX
</p>
</div>
</footer>
{% endblock %}

View File

@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}Login - Jellyswarrm{% endblock %}
{% block body %}
<main class="container">
<!-- Header with logo and branding -->
<header style="text-align: center; padding: 2rem 0;">
<img src="/resources/icon.svg" alt="Jellyswarrm Logo" style="width: 200px; height: 200px; margin-bottom: 1rem;">
<h1 style="margin-top: 0;">Jellyswarrm</h1>
<h1 style="margin-top: 0; color: var(--pico-muted-color);">Admin Interface</h1>
</header>
<!-- Login card -->
<div style="max-width: 400px; margin: 0 auto;">
<!-- Flash messages -->
{% if !messages.is_empty() %}
<div style="margin-bottom: 1rem;">
{% for message in messages %}
<article style="background-color: var(--pico-del-background-color); border-left: 4px solid var(--pico-del-color); margin-bottom: 0.5rem;">
<p style="margin: 0; padding: 0.75rem 1rem;">
<i class="fas fa-exclamation-triangle" style="margin-right: 0.5rem;"></i>
{{ message }}
</p>
</article>
{% endfor %}
</div>
{% endif %}
<!-- Login form -->
<article>
<header>
<h3 style="margin-bottom: 0.5rem;">
<i class="fas fa-sign-in-alt" style="margin-right: 0.5rem;"></i>
Sign In
</h3>
<p style="margin: 0; color: var(--pico-muted-color); font-size: 0.9rem;">
Please enter your credentials to access the admin interface.
</p>
</header>
<form method="post">
<div style="margin-bottom: 1rem;">
<label for="username">
<i class="fas fa-user" style="margin-right: 0.5rem;"></i>
Username
</label>
<input
name="username"
id="username"
type="text"
placeholder="Enter your username"
autocomplete="username"
required
/>
</div>
<div style="margin-bottom: 1.5rem;">
<label for="password">
<i class="fas fa-lock" style="margin-right: 0.5rem;"></i>
Password
</label>
<div style="position: relative;">
<input
name="password"
id="password"
type="password"
placeholder="Enter your password"
autocomplete="current-password"
required
style="padding-right: 3rem;"
/>
<button
type="button"
onclick="togglePassword()"
style="position: absolute; right: 0.5rem; top: 0; bottom: 0; background: none; border: none; cursor: pointer; color: var(--pico-muted-color); padding: 0; width: 1.5rem; display: flex; align-items: center; justify-content: center;"
aria-label="Toggle password visibility"
>
<i id="password-toggle-icon" class="fas fa-eye"></i>
</button>
</div>
</div>
<button type="submit" style="width: 100%;">
<i class="fas fa-sign-in-alt" style="margin-right: 0.5rem;"></i>
Sign In
</button>
{% if let Some(next) = next %}
<input type="hidden" name="next" value="{{next}}" />
{% endif %}
</form>
</article>
</div>
<!-- Theme toggle -->
<div style="text-align: center; margin-top: 2rem;">
<button onclick="toggleTheme()" aria-label="Toggle theme" class="secondary outline">
<i class="fas fa-adjust" style="margin-right: 0.5rem;"></i>
Toggle Theme
</button>
</div>
</main>
<!-- Footer -->
<footer class="container" style="margin-top: 3rem;">
<hr>
<p style="text-align: center; color: var(--pico-muted-color); font-size: 0.85rem;">
<i class="fas fa-server" style="margin-right: 0.5rem;"></i>
Jellyswarrm Proxy Server
</p>
</footer>
{% endblock %}
{% block scripts %}
<script>
function togglePassword() {
const passwordInput = document.getElementById('password');
const toggleIcon = document.getElementById('password-toggle-icon');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleIcon.className = 'fas fa-eye-slash';
} else {
passwordInput.type = 'password';
toggleIcon.className = 'fas fa-eye';
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,61 @@
<style>
/* Lightweight shared styles (can be moved to global if reused) */
.icon-btn {--_size:.7rem; font-size:var(--_size); line-height:1; padding:.3rem .45rem; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--muted-border,#555); background:transparent; cursor:pointer;}
.icon-btn:hover {background:rgba(255,255,255,.05);}
.icon-btn.danger {color:var(--del,#e55353); border-color:var(--del,#e55353);}
.status-chip {font-size:.65rem; padding:.25rem .55rem; border-radius:1rem; background:var(--pill-bg,rgba(127,127,127,.15)); display:inline-block; min-width:70px; text-align:center;}
</style>
{% if servers.is_empty() %}
<article>
<header><h4>No servers configured</h4></header>
<p>Add your first Jellyfin server to get started.</p>
</article>
{% else %}
<table aria-label="Registered servers">
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th>Priority</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for server in servers %}
<tr>
<td><strong>{{ server.name }}</strong></td>
<td><a href="{{ server.url }}" target="_blank" rel="noopener noreferrer">{{ server.url }}</a></td>
<td>
<input type="number" value="{{ server.priority }}" min="1" max="999"
hx-patch="/ui/servers/{{ server.id }}/priority"
hx-trigger="change delay:200ms"
hx-include="closest tr"
name="priority"
hx-target="#server-list" hx-swap="innerHTML"
style="width: 130px; min-width: 130px;">
</td>
<td>
<span hx-get="/ui/servers/{{ server.id }}/status"
hx-trigger="load, every 5s"
hx-swap="outerHTML"
style="min-width: 80px; display: inline-block;">
<span style="color: #666;">Checking...</span>
</span>
</td>
<td style="text-align: center;">
<button type="button" class="icon-btn danger"
hx-delete="/ui/servers/{{ server.id }}"
hx-confirm="Delete server '{{ server.name }}'?"
hx-target="#server-list" hx-swap="innerHTML"
title="Delete server">
<i class="fas fa-trash" aria-hidden="true"></i><span class="sr-only" style="position:absolute;left:-9999px;">Delete</span>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

View File

@@ -0,0 +1,5 @@
{% if let Some(error) = error_message%}
<span style="color: #dc3545; font-weight: bold;" title="Error: {{ error }}">✗ Offline</span>
{% else %}
<span style="color: #28a745; font-weight: bold;" title="Server is responding">✓ Online</span>
{% endif %}

View File

@@ -0,0 +1,33 @@
<div id="messages"></div>
<header>
<h1>Server Management</h1>
<h3>Add, prioritize and monitor Jellyfin servers.</h3>
</header>
<section id="add-server" aria-labelledby="add-server-heading">
<h2 id="add-server-heading" style="margin-bottom:.5rem;">Add Server</h2>
<form hx-post="/ui/servers" hx-target="#server-list" hx-swap="innerHTML" hx-disabled-elt="this" hx-on::after-request="if(event.detail.successful) this.reset()">
<div class="grid" style="--pico-grid-gap:1rem;">
<label> Name
<input type="text" name="name" placeholder="e.g. Media Vault" autocomplete="off" required>
</label>
<label> URL
<input type="url" name="url" placeholder="http://192.168.0.10:8096" inputmode="url" required>
</label>
<label> Priority
<input type="number" name="priority" value="100" min="1" max="999" required>
</label>
<div style="display:flex; align-items:center; padding-top:1.5rem;">
<button type="submit">Add</button>
</div>
</div>
</form>
<p style="font-size:.8rem; opacity:.7; margin-top:.5rem;">Higher numbers imply higher importance in routing logic for e.g. styling.</p>
</section>
<hr />
<section id="server-list" hx-get="/ui/servers/list" hx-trigger="load">
<p>Loading servers...</p>
</section>

View File

@@ -0,0 +1,8 @@
<header>
<h1>Settings</h1>
<h3>Modify runtime configuration values.</h3>
</header>
<section id="settings-form" hx-get="/ui/settings/form" hx-trigger="load">
<p>Loading settings...</p>
</section>

View File

@@ -0,0 +1,23 @@
<div id="settings-messages"></div>
<form hx-post="/ui/settings/save" hx-target="#settings-form" hx-swap="outerHTML" hx-disabled-elt="this">
<fieldset>
<label>Server ID
<input type="text" name="server_id" value="{{ server_id }}" disabled>
</label>
<label>Public Address
<input type="text" name="public_address" value="{{ public_address }}" required>
</label>
<label>Server Name
<input type="text" name="server_name" value="{{ server_name }}" required>
</label>
<label>Add Server Names to Media
<input type="checkbox" role="switch" name="include_server_name_in_media" value="true" {% if include_server_name_in_media %}checked{% endif %}>
</label>
</fieldset>
<div style="display:flex; gap:.5rem;">
<button type="submit">Save</button>
<button type="button" hx-get="/ui/settings/form" hx-target="#settings-form" class="secondary">Reset</button>
<button type="button" hx-post="/ui/settings/reload" hx-target="#settings-messages" class="secondary">Reload From Disk</button>
</div>
<p style="font-size:.75rem; opacity:.7; margin-top:.5rem;">Edits persist to TOML.</p>
</form>

View File

@@ -0,0 +1,130 @@
<style>
/* Minimal additions on top of Pico defaults */
.filter-bar {
margin-bottom: 1rem;
}
.badge {
display: inline-block;
padding: .25em .55em;
font-size: .65em;
background: var(--pico-primary, #0b6efd);
color: #fff;
border-radius: 1rem;
line-height: 1;
}
.user-key {
font-size: .6rem;
opacity: .6;
word-break: break-all;
padding-top: .25em;
}
.pill {
display: inline-block;
padding: .25em .7em;
background: var(--pico-muted-border-color, rgba(127, 127, 127, .15));
border-radius: 1rem;
font-size: .7em;
}
.danger {
color: var(--pico-del-color, #d9534f);
border-color: var(--pico-del-color, #d9534f);
cursor: pointer;
}
.empty-state {
padding: 1rem;
border: 2px dashed var(--pico-border-color, #ccc);
border-radius: .6rem;
text-align: center;
}
</style>
<div class="filter-bar">
<input id="user-filter" type="search" placeholder="Filter users" oninput="filterUsers(this.value)">
</div>
{% if users.is_empty() %}
<div class="empty-state" role="note">
<p>No users yet. Add one above to begin.</p>
</div>
{% else %}
<section class="user-list" aria-label="Users">
{% for uwm in users %}
<article data-username="{{ uwm.user.original_username }}">
<header>
<div style="display:flex; align-items:center; gap:.5rem;">
<h3 style="margin:0;">{{ uwm.user.original_username }} <span class="badge" title="Total sessions">{{
uwm.total_sessions }}</span></h3>
<i class="fas fa-trash danger" style="margin-left:auto;" aria-label="Delete user"
hx-delete="/ui/users/{{ uwm.user.id }}" hx-target="#user-list" hx-swap="innerHTML"
hx-confirm="Delete user '{{ uwm.user.original_username }}' and all mappings?"></i>
</div>
<p class="user-key" title="Virtual key">{{ uwm.user.virtual_key }}</p>
</header>
<section>
{% if !uwm.mappings.is_empty() %}
<details>
<summary role="button" class="outline secondary">Server mappings ({{ uwm.mappings.len() }})</summary>
{% for (mapping, server, scount) in uwm.mappings %}
<div style="display:flex; align-items:center; gap:.5rem;">
<strong style="margin:0;" title="{{ server.url }}">{{ server.name }} <span class="badge" title="Sessions">{{ scount }}</span></strong>
<i class="fas fa-trash danger" style="margin-left:auto;" aria-label="Delete mapping" hx-delete="/ui/users/mappings/{{ mapping.id }}"
hx-target="#user-list" hx-swap="innerHTML" hx-confirm="Delete mapping '{{ server.name }}' for user '{{ uwm.user.original_username }}'?"></i>
</div>
<span>Mapped User: <strong>{{ mapping.mapped_username }}</strong></span>
<hr />
{% endfor %}
</details>
{% endif %}
</section>
{% if !uwm.available_servers.is_empty() %}
<hr />
<h4>Add server mapping:</h4>
<form hx-post="/ui/users/mappings" hx-target="#user-list" hx-swap="innerHTML" hx-disabled-elt="this">
<input type="hidden" name="user_id" value="{{ uwm.user.id }}">
<label class="visually-hidden" for="server-{{ uwm.user.id }}">Server</label>
<select id="server-{{ uwm.user.id }}" name="server_url" required>
<option value="" disabled selected>Select server</option>
{% for server in uwm.available_servers %}
<option value="{{ server.url }}">{{ server.name }}</option>
{% endfor %}
</select>
<label class="visually-hidden" for="mu-{{ uwm.user.id }}">Mapped username</label>
<input id="mu-{{ uwm.user.id }}" type="text" name="mapped_username" placeholder="Username" required>
<label class="visually-hidden" for="mp-{{ uwm.user.id }}">Mapped password</label>
<input id="mp-{{ uwm.user.id }}" type="password" name="mapped_password" placeholder="Password" required
autocomplete="new-password">
<button type="submit">Add</button>
</form>
{% endif %}
<footer>
<div aria-label="Reset sessions" class="danger"
hx-delete="/ui/users/sessions/{{ uwm.user.id }}" hx-target="#user-list" hx-swap="innerHTML"
hx-confirm="Reset all sessions for user '{{ uwm.user.original_username }}'?"><i class="fas fa-recycle" style="margin-right: 0.25rem;"></i>Reset Sessions</div>
</footer>
</article>
{% endfor %}
</section>
{% endif %}
<script>
function filterUsers(q) {
q = q.toLowerCase().trim();
for (const art of document.querySelectorAll('section.user-list > article')) {
const name = art.dataset.username.toLowerCase();
art.hidden = q && !name.includes(q);
}
}
</script>

View File

@@ -0,0 +1,31 @@
<header>
<h1>User Management</h1>
<h3>Create users and map credentials to each server.</h3>
<h6>Warning: If a mapping is added to a user all existing sessions will be invalidated.</h6>
</header>
<hr />
<section id="add-user" aria-labelledby="add-user-heading">
<h2 id="add-user-heading" style="margin-bottom:.5rem;">Add User</h2>
<form hx-post="/ui/users" hx-target="#user-list" hx-swap="innerHTML" hx-disabled-elt="this", hx-on::after-request="if(event.detail.successful) this.reset()">
<div class="grid" style="--pico-grid-gap:1rem;">
<label>Username
<input type="text" name="username" placeholder="e.g. alice" autocomplete="off" required>
</label>
<label>Password
<input type="password" name="password" placeholder="••••••" required>
</label>
<div style="display:flex; align-items:center; padding-top:1.5rem;">
<button type="submit">Add</button>
</div>
</div>
</form>
</section>
<hr />
<h2 id="users-heading" style="margin-bottom:.5rem;">Users</h2>
<section id="user-list" hx-get="/ui/users/list" hx-trigger="load">
<p>Loading users...</p>
</section>

View File

@@ -0,0 +1,289 @@
use askama::Template;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{Html, IntoResponse, Response},
Form,
};
use serde::Deserialize;
use std::collections::HashMap;
use tracing::{error, info};
use crate::{
server_storage::Server,
user_authorization_service::{ServerMapping, User},
AppState,
};
#[derive(Template)]
#[template(path = "users.html")]
pub struct UsersPageTemplate {}
pub struct UserWithMappings {
pub user: User,
pub mappings: Vec<(ServerMapping, Server, i64)>, // per mapping session count
pub available_servers: Vec<Server>, // servers not yet mapped
pub total_sessions: i64,
}
#[derive(Template)]
#[template(path = "user_list.html")]
pub struct UserListTemplate {
pub users: Vec<UserWithMappings>,
}
#[derive(Deserialize)]
pub struct AddUserForm {
pub username: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct AddMappingForm {
pub user_id: String,
pub server_url: String,
pub mapped_username: String,
pub mapped_password: String,
}
/// Main users page
pub async fn users_page() -> impl IntoResponse {
let template = UsersPageTemplate {};
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render users template: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}
/// List users with mappings
pub async fn get_user_list(State(state): State<AppState>) -> impl IntoResponse {
// Fetch servers once for mapping lookup
let servers = match state.server_storage.list_servers().await {
Ok(s) => s,
Err(e) => {
error!("Failed to list servers: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
};
match state.user_authorization.list_users().await {
Ok(users) => {
let mut result = Vec::new();
for user in users {
// session counts per server_url (normalized)
let mut session_counts: HashMap<String, i64> = HashMap::new();
if let Ok(rows) = state
.user_authorization
.session_counts_by_server(&user.id)
.await
{
for (url, cnt) in rows {
session_counts.insert(url, cnt);
}
}
let mappings_fetch = state
.user_authorization
.list_server_mappings(&user.id)
.await;
let mut mappings_vec: Vec<(ServerMapping, Server, i64)> = Vec::new();
let mut mapped_urls: Vec<String> = Vec::new();
match mappings_fetch {
Ok(mappings) => {
for mapping in mappings {
if let Some(server) = servers.iter().find(|srv| {
srv.url.as_str().trim_end_matches('/')
== mapping.server_url.trim_end_matches('/')
}) {
let count = session_counts
.get(mapping.server_url.trim_end_matches('/'))
.cloned()
.unwrap_or(0);
mappings_vec.push((mapping, server.clone(), count));
mapped_urls
.push(server.url.as_str().trim_end_matches('/').to_string());
}
}
}
Err(e) => {
error!("Failed to list mappings: {}", e);
}
}
let available_servers: Vec<Server> = servers
.iter()
.filter(|srv| {
!mapped_urls
.iter()
.any(|u| u == srv.url.as_str().trim_end_matches('/'))
})
.cloned()
.collect();
let user_total_sessions: i64 = mappings_vec.iter().map(|(_, _, c)| *c).sum();
result.push(UserWithMappings {
user,
mappings: mappings_vec,
available_servers,
total_sessions: user_total_sessions,
});
}
let template = UserListTemplate { users: result };
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Render error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}
Err(e) => {
error!("Failed to list users: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
}
}
}
/// Add user
pub async fn add_user(State(state): State<AppState>, Form(form): Form<AddUserForm>) -> Response {
if form.username.trim().is_empty() || form.password.is_empty() {
return (
StatusCode::BAD_REQUEST,
Html("<div class=\"alert alert-error\">Username and password required</div>"),
)
.into_response();
}
match state
.user_authorization
.get_or_create_user(&form.username, &form.password)
.await
{
Ok(_user) => {
info!("Created user {}", form.username);
get_user_list(State(state)).await.into_response()
}
Err(e) => {
error!("Failed to create user: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Html("<div class=\"alert alert-error\">Failed to create user</div>"),
)
.into_response()
}
}
}
/// Delete user
pub async fn delete_user(State(state): State<AppState>, Path(user_id): Path<String>) -> Response {
match state.user_authorization.delete_user(&user_id).await {
Ok(true) => get_user_list(State(state)).await.into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Html("<div class=\"alert alert-error\">User not found</div>"),
)
.into_response(),
Err(e) => {
error!("Delete user error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Html("<div class=\"alert alert-error\">Failed to delete user</div>"),
)
.into_response()
}
}
}
/// Add mapping
pub async fn add_mapping(
State(state): State<AppState>,
Form(form): Form<AddMappingForm>,
) -> Response {
if form.mapped_username.trim().is_empty() || form.mapped_password.is_empty() {
return (
StatusCode::BAD_REQUEST,
Html("<div class=\"alert alert-error\">Mapping credentials required</div>"),
)
.into_response();
}
match state
.user_authorization
.add_server_mapping(
&form.user_id,
&form.server_url,
&form.mapped_username,
&form.mapped_password,
)
.await
{
Ok(_id) => {
match state
.user_authorization
.delete_all_sessions_for_user(&form.user_id)
.await
{
Ok(_) => info!("Deleted all sessions for user {}", form.user_id),
Err(e) => error!("Failed to delete sessions for user {}: {}", form.user_id, e),
}
get_user_list(State(state)).await.into_response()
}
Err(e) => {
error!("Add mapping error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Html("<div class=\"alert alert-error\">Failed to add mapping</div>"),
)
.into_response()
}
}
}
/// Delete mapping
pub async fn delete_mapping(
State(state): State<AppState>,
Path(mapping_id): Path<i64>,
) -> Response {
match state
.user_authorization
.delete_server_mapping(mapping_id)
.await
{
Ok(true) => get_user_list(State(state)).await.into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Html("<div class=\"alert alert-error\">Mapping not found</div>"),
)
.into_response(),
Err(e) => {
error!("Delete mapping error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Html("<div class=\"alert alert-error\">Failed to delete mapping</div>"),
)
.into_response()
}
}
}
/// Delete sessions
pub async fn delete_sessions(
State(state): State<AppState>,
Path(user_id): Path<String>,
) -> Response {
match state
.user_authorization
.delete_all_sessions_for_user(&user_id)
.await
{
Ok(_) => get_user_list(State(state)).await.into_response(),
Err(e) => {
error!("Delete user error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Html("<div class=\"alert alert-error\">Failed to delete user</div>"),
)
.into_response()
}
}
}

View File

@@ -0,0 +1,74 @@
use url::Url;
pub fn is_id_like(segment: &str) -> bool {
segment.len() == 32 && segment.chars().all(|c| c.is_ascii_hexdigit())
}
pub fn contains_id(url: &Url, name: &str) -> Option<String> {
let segments: Vec<&str> = match url.path_segments() {
Some(segments) => segments.collect(),
None => Vec::new(),
};
let mut i = 0;
while i < segments.len() {
if i + 1 < segments.len() {
let current = segments[i];
let next = segments[i + 1];
if current.eq_ignore_ascii_case(name) && is_id_like(next) {
return Some(next.to_string());
}
}
i += 1;
}
None
}
pub fn replace_id(url: Url, original: &str, replacement: &str) -> Url {
let mut url = url.clone();
let path = url.path();
url.set_path(&path.replace(original, replacement));
url
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_id_like() {
assert!(is_id_like("0123456789abcdef0123456789abcdef"));
assert!(!is_id_like("0123456789abcdef0123456789abcde")); // 31 chars
assert!(!is_id_like("g123456789abcdef0123456789abcdef")); // non-hex
}
#[test]
fn test_contains_id_found() {
let url =
Url::parse("https://example.com/foo/0123456789abcdef0123456789abcdef/bar").unwrap();
assert_eq!(
contains_id(&url, "foo"),
Some("0123456789abcdef0123456789abcdef".to_string())
);
}
#[test]
fn test_contains_id_not_found() {
let url = Url::parse("https://example.com/foo/bar").unwrap();
assert_eq!(contains_id(&url, "foo"), None);
}
#[test]
fn test_replace_id() {
let url =
Url::parse("https://example.com/foo/0123456789abcdef0123456789abcdef/bar").unwrap();
let replaced = replace_id(
url,
"0123456789abcdef0123456789abcdef",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
assert_eq!(replaced.path(), "/foo/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bar");
}
}

View File

File diff suppressed because it is too large Load Diff

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
jellyswarrm-proxy:
build:
context: .
dockerfile: Dockerfile
image: jellyswarrm-proxy:latest
container_name: jellyswarrm-proxy
ports:
- "3000:3000"
volumes:
- ./data:/app/data
environment:
- JELLYSWARRM_USERNAME=admin
- JELLYSWARRM_PASSWORD=jellyswarrm

42
media/banner.svg Normal file
View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg id="banner-logo-jellyswarrm" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2560 512" version="1.1">
<defs>
<!-- Official Jellyfin gradient -->
<linearGradient id="jfGradient" x1="126.15" y1="219.32" x2="457.68" y2="410.73" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#aa5cc3"/>
<stop offset="100%" stop-color="#00a4dc"/>
</linearGradient>
</defs>
<!-- Solid dark background -->
<rect width="2560" height="512" fill="#000b25" />
<!-- Centered JellySwarrm icon -->
<g transform="translate(1280,256) scale(0.85) translate(-256,-256)">
<!-- Swarm hexagon -->
<g stroke="url(#jfGradient)" stroke-width="8" fill="none">
<polygon points="
256,72
400,156
400,356
256,440
112,356
112,156
" />
<circle cx="256" cy="72" r="20" fill="url(#jfGradient)"/>
<circle cx="400" cy="156" r="20" fill="url(#jfGradient)"/>
<circle cx="400" cy="356" r="20" fill="url(#jfGradient)"/>
<circle cx="256" cy="440" r="20" fill="url(#jfGradient)"/>
<circle cx="112" cy="356" r="20" fill="url(#jfGradient)"/>
<circle cx="112" cy="156" r="20" fill="url(#jfGradient)"/>
</g>
<!-- Jellyfin logo inside swarm -->
<g transform="translate(256,240) scale(0.55) translate(-256,-256)">
<path d="M58.75 417.03c25.97 52.15 368.86 51.55 394.55 0S308.93 56.08 256.03 56.08c-52.92 0-223.25 308.8-197.28 360.95zm68.04-45.25c-17.02-34.17 94.6-236.5 129.26-236.5 34.67 0 146.1 202.7 129.26 236.5-16.83 33.8-241.5 34.17-258.52 0z"
fill="url(#jfGradient)"/>
<path d="M190.56 329.07c8.63 17.3 122.4 17.12 130.93 0 8.52-17.1-47.9-119.78-65.46-119.8-17.57 0-74.1 102.5-65.47 119.8z"
fill="url(#jfGradient)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

48
media/icon.svg Normal file
View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="560" viewBox="0 0 512 560"
xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">JellySwarrm Logo</title>
<desc id="desc">A hexagonal swarm of gradient circles with the official Jellyfin logo and larger JellySwarrm text below, fully visible in the viewport.</desc>
<defs>
<!-- Official Jellyfin gradient -->
<linearGradient id="jfGradient" gradientUnits="userSpaceOnUse" x1="126.15" y1="219.32" x2="457.68" y2="410.73">
<stop offset="0%" stop-color="#aa5cc3"/>
<stop offset="100%" stop-color="#00a4dc"/>
</linearGradient>
</defs>
<!-- Enlarged swarm hexagon with circles -->
<g stroke="url(#jfGradient)" stroke-width="8" fill="none">
<polygon points="
256,72
400,156
400,356
256,440
112,356
112,156
" />
<circle cx="256" cy="72" r="20" fill="url(#jfGradient)"/>
<circle cx="400" cy="156" r="20" fill="url(#jfGradient)"/>
<circle cx="400" cy="356" r="20" fill="url(#jfGradient)"/>
<circle cx="256" cy="440" r="20" fill="url(#jfGradient)"/>
<circle cx="112" cy="356" r="20" fill="url(#jfGradient)"/>
<circle cx="112" cy="156" r="20" fill="url(#jfGradient)"/>
</g>
<!-- Official Jellyfin logo scaled and moved up -->
<g transform="translate(256,240) scale(0.55) translate(-256,-256)">
<!-- Outer shape -->
<path d="M58.75 417.03c25.97 52.15 368.86 51.55 394.55 0S308.93 56.08 256.03 56.08c-52.92 0-223.25 308.8-197.28 360.95zm68.04-45.25c-17.02-34.17 94.6-236.5 129.26-236.5 34.67 0 146.1 202.7 129.26 236.5-16.83 33.8-241.5 34.17-258.52 0z"
fill="url(#jfGradient)"/>
<!-- Inner shape -->
<path d="M190.56 329.07c8.63 17.3 122.4 17.12 130.93 0 8.52-17.1-47.9-119.78-65.46-119.8-17.57 0-74.1 102.5-65.47 119.8z"
fill="url(#jfGradient)"/>
</g>
<!-- JellySwarrm text (larger and with room in viewport) -->
<text x="256" y="540" font-family="Segoe UI, Arial, sans-serif" font-size="56" font-weight="bold"
text-anchor="middle" fill="url(#jfGradient)">
JellySwarrm
</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
repo-backup.zip Normal file
View File

Binary file not shown.

1
ui Submodule

Submodule ui added at f4b8aa0ed4