mirror of
https://github.com/LLukas22/Jellyswarrm.git
synced 2025-12-23 22:47:47 -05:00
Initial commit (history removed)
This commit is contained in:
4
.cargo/config.toml
Normal file
4
.cargo/config.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
20
.dockerignore
Normal file
20
.dockerignore
Normal 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
32
.gitignore
vendored
Normal 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
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "ui"]
|
||||
path = ui
|
||||
url = https://github.com/jellyfin/jellyfin-web.git
|
||||
62
.vscode/tasks.json
vendored
Normal file
62
.vscode/tasks.json
vendored
Normal 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
4048
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
Cargo.toml
Normal file
50
Cargo.toml
Normal 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
66
Dockerfile
Normal 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
21
LICENSE
Normal 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
72
README.md
Normal 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
1
crates/jellyswarrm-proxy/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
static/
|
||||
63
crates/jellyswarrm-proxy/Cargo.toml
Normal file
63
crates/jellyswarrm-proxy/Cargo.toml
Normal 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"
|
||||
2
crates/jellyswarrm-proxy/askama.toml
Normal file
2
crates/jellyswarrm-proxy/askama.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[general]
|
||||
dirs = ["src/ui/templates"]
|
||||
67
crates/jellyswarrm-proxy/build.rs
Normal file
67
crates/jellyswarrm-proxy/build.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
151
crates/jellyswarrm-proxy/src/config.rs
Normal file
151
crates/jellyswarrm-proxy/src/config.rs
Normal 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)
|
||||
}
|
||||
37
crates/jellyswarrm-proxy/src/handlers/branding.rs
Normal file
37
crates/jellyswarrm-proxy/src/handlers/branding.rs
Normal 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))
|
||||
}
|
||||
269
crates/jellyswarrm-proxy/src/handlers/common.rs
Normal file
269
crates/jellyswarrm-proxy/src/handlers/common.rs
Normal 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(())
|
||||
}
|
||||
177
crates/jellyswarrm-proxy/src/handlers/federated.rs
Normal file
177
crates/jellyswarrm-proxy/src/handlers/federated.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
174
crates/jellyswarrm-proxy/src/handlers/items.rs
Normal file
174
crates/jellyswarrm-proxy/src/handlers/items.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/jellyswarrm-proxy/src/handlers/mod.rs
Normal file
9
crates/jellyswarrm-proxy/src/handlers/mod.rs
Normal 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;
|
||||
6
crates/jellyswarrm-proxy/src/handlers/quick_connect.rs
Normal file
6
crates/jellyswarrm-proxy/src/handlers/quick_connect.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use axum::Json;
|
||||
use hyper::StatusCode;
|
||||
|
||||
pub async fn handle_quick_connect() -> Result<Json<bool>, StatusCode> {
|
||||
Ok(Json(false))
|
||||
}
|
||||
87
crates/jellyswarrm-proxy/src/handlers/sessions.rs
Normal file
87
crates/jellyswarrm-proxy/src/handlers/sessions.rs
Normal 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)
|
||||
}
|
||||
77
crates/jellyswarrm-proxy/src/handlers/system.rs
Normal file
77
crates/jellyswarrm-proxy/src/handlers/system.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
357
crates/jellyswarrm-proxy/src/handlers/users.rs
Normal file
357
crates/jellyswarrm-proxy/src/handlers/users.rs
Normal 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,
|
||||
}
|
||||
119
crates/jellyswarrm-proxy/src/handlers/videos.rs
Normal file
119
crates/jellyswarrm-proxy/src/handlers/videos.rs
Normal 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()))
|
||||
}
|
||||
461
crates/jellyswarrm-proxy/src/main.rs
Normal file
461
crates/jellyswarrm-proxy/src/main.rs
Normal 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() },
|
||||
}
|
||||
}
|
||||
374
crates/jellyswarrm-proxy/src/media_storage_service.rs
Normal file
374
crates/jellyswarrm-proxy/src/media_storage_service.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
194
crates/jellyswarrm-proxy/src/models/authorization.rs
Normal file
194
crates/jellyswarrm-proxy/src/models/authorization.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
984
crates/jellyswarrm-proxy/src/models/jellyfin.rs
Normal file
984
crates/jellyswarrm-proxy/src/models/jellyfin.rs
Normal 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>>;
|
||||
8
crates/jellyswarrm-proxy/src/models/mod.rs
Normal file
8
crates/jellyswarrm-proxy/src/models/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
mod authorization;
|
||||
mod jellyfin;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use authorization::{generate_token, Authorization};
|
||||
pub use jellyfin::*;
|
||||
764
crates/jellyswarrm-proxy/src/models/tests/files/episodes.json
Normal file
764
crates/jellyswarrm-proxy/src/models/tests/files/episodes.json
Normal 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 Power’s boobs”.\n\nPower mocks his ridiculous motivation saying “that’s 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 Power’s 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, I’ll 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 devil’s 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 devil’s 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
|
||||
}
|
||||
3989
crates/jellyswarrm-proxy/src/models/tests/files/item.json
Normal file
3989
crates/jellyswarrm-proxy/src/models/tests/files/item.json
Normal file
File diff suppressed because it is too large
Load Diff
293
crates/jellyswarrm-proxy/src/models/tests/files/items.json
Normal file
293
crates/jellyswarrm-proxy/src/models/tests/files/items.json
Normal 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
|
||||
}
|
||||
82
crates/jellyswarrm-proxy/src/models/tests/files/person.json
Normal file
82
crates/jellyswarrm-proxy/src/models/tests/files/person.json
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
112
crates/jellyswarrm-proxy/src/models/tests/files/userviews.json
Normal file
112
crates/jellyswarrm-proxy/src/models/tests/files/userviews.json
Normal 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
|
||||
}
|
||||
1
crates/jellyswarrm-proxy/src/models/tests/mod.rs
Normal file
1
crates/jellyswarrm-proxy/src/models/tests/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
mod test_jellyfin_models;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
466
crates/jellyswarrm-proxy/src/request_preprocessing.rs
Normal file
466
crates/jellyswarrm-proxy/src/request_preprocessing.rs
Normal 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 ¶m_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 ¶m_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 ¶m_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 ¶m_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(¶m_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);
|
||||
}
|
||||
}
|
||||
243
crates/jellyswarrm-proxy/src/server_storage.rs
Normal file
243
crates/jellyswarrm-proxy/src/server_storage.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
51
crates/jellyswarrm-proxy/src/session_storage.rs
Normal file
51
crates/jellyswarrm-proxy/src/session_storage.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
117
crates/jellyswarrm-proxy/src/ui/auth/mod.rs
Normal file
117
crates/jellyswarrm-proxy/src/ui/auth/mod.rs
Normal 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>;
|
||||
102
crates/jellyswarrm-proxy/src/ui/auth/routes.rs
Normal file
102
crates/jellyswarrm-proxy/src/ui/auth/routes.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
73
crates/jellyswarrm-proxy/src/ui/mod.rs
Normal file
73
crates/jellyswarrm-proxy/src/ui/mod.rs
Normal 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())
|
||||
}
|
||||
9
crates/jellyswarrm-proxy/src/ui/resources/fontawesome/css/all.min.css
vendored
Normal file
9
crates/jellyswarrm-proxy/src/ui/resources/fontawesome/css/all.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
crates/jellyswarrm-proxy/src/ui/resources/htmx.min.js
vendored
Normal file
1
crates/jellyswarrm-proxy/src/ui/resources/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
42
crates/jellyswarrm-proxy/src/ui/resources/icon.svg
Normal file
42
crates/jellyswarrm-proxy/src/ui/resources/icon.svg
Normal 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 |
4
crates/jellyswarrm-proxy/src/ui/resources/pico.min.css
vendored
Normal file
4
crates/jellyswarrm-proxy/src/ui/resources/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
27
crates/jellyswarrm-proxy/src/ui/root.rs
Normal file
27
crates/jellyswarrm-proxy/src/ui/root.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
289
crates/jellyswarrm-proxy/src/ui/servers.rs
Normal file
289
crates/jellyswarrm-proxy/src/ui/servers.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
93
crates/jellyswarrm-proxy/src/ui/settings.rs
Normal file
93
crates/jellyswarrm-proxy/src/ui/settings.rs
Normal 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>")
|
||||
}
|
||||
44
crates/jellyswarrm-proxy/src/ui/templates/base.html
Normal file
44
crates/jellyswarrm-proxy/src/ui/templates/base.html
Normal 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>
|
||||
90
crates/jellyswarrm-proxy/src/ui/templates/index.html
Normal file
90
crates/jellyswarrm-proxy/src/ui/templates/index.html
Normal 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 %}
|
||||
130
crates/jellyswarrm-proxy/src/ui/templates/login.html
Normal file
130
crates/jellyswarrm-proxy/src/ui/templates/login.html
Normal 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 %}
|
||||
61
crates/jellyswarrm-proxy/src/ui/templates/server_list.html
Normal file
61
crates/jellyswarrm-proxy/src/ui/templates/server_list.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
33
crates/jellyswarrm-proxy/src/ui/templates/servers.html
Normal file
33
crates/jellyswarrm-proxy/src/ui/templates/servers.html
Normal 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>
|
||||
8
crates/jellyswarrm-proxy/src/ui/templates/settings.html
Normal file
8
crates/jellyswarrm-proxy/src/ui/templates/settings.html
Normal 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>
|
||||
23
crates/jellyswarrm-proxy/src/ui/templates/settings_form.html
Normal file
23
crates/jellyswarrm-proxy/src/ui/templates/settings_form.html
Normal 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>
|
||||
130
crates/jellyswarrm-proxy/src/ui/templates/user_list.html
Normal file
130
crates/jellyswarrm-proxy/src/ui/templates/user_list.html
Normal 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>
|
||||
31
crates/jellyswarrm-proxy/src/ui/templates/users.html
Normal file
31
crates/jellyswarrm-proxy/src/ui/templates/users.html
Normal 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>
|
||||
289
crates/jellyswarrm-proxy/src/ui/users.rs
Normal file
289
crates/jellyswarrm-proxy/src/ui/users.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
74
crates/jellyswarrm-proxy/src/url_helper.rs
Normal file
74
crates/jellyswarrm-proxy/src/url_helper.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
1164
crates/jellyswarrm-proxy/src/user_authorization_service.rs
Normal file
1164
crates/jellyswarrm-proxy/src/user_authorization_service.rs
Normal file
File diff suppressed because it is too large
Load Diff
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal 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
42
media/banner.svg
Normal 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
48
media/icon.svg
Normal 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
BIN
repo-backup.zip
Normal file
Binary file not shown.
1
ui
Submodule
1
ui
Submodule
Submodule ui added at f4b8aa0ed4
Reference in New Issue
Block a user