├── dev ├── .gitignore ├── .python-version ├── pyproject.toml ├── README.md ├── docker-compose.yml └── scripts │ ├── init-jellyfin.py │ └── download-content.py ├── crates ├── jellyswarrm-proxy │ ├── .gitignore │ ├── src │ │ ├── models │ │ │ ├── tests │ │ │ │ ├── mod.rs │ │ │ │ └── files │ │ │ │ │ ├── series_nextup.json │ │ │ │ │ ├── userviews.json │ │ │ │ │ ├── person.json │ │ │ │ │ └── livetv_playback_response.json │ │ │ └── mod.rs │ │ ├── ui │ │ │ ├── admin │ │ │ │ ├── mod.rs │ │ │ │ └── settings.rs │ │ │ ├── user │ │ │ │ ├── mod.rs │ │ │ │ ├── common.rs │ │ │ │ └── profile.rs │ │ │ ├── resources │ │ │ │ ├── fontawesome │ │ │ │ │ └── webfonts │ │ │ │ │ │ ├── fa-brands-400.ttf │ │ │ │ │ │ ├── fa-regular-400.ttf │ │ │ │ │ │ ├── fa-solid-900.ttf │ │ │ │ │ │ ├── fa-solid-900.woff2 │ │ │ │ │ │ ├── fa-brands-400.woff2 │ │ │ │ │ │ ├── fa-regular-400.woff2 │ │ │ │ │ │ ├── fa-v4compatibility.ttf │ │ │ │ │ │ └── fa-v4compatibility.woff2 │ │ │ │ ├── icon.svg │ │ │ │ └── custom.css │ │ │ ├── templates │ │ │ │ ├── admin │ │ │ │ │ ├── settings.html │ │ │ │ │ ├── server_status.html │ │ │ │ │ ├── index.html │ │ │ │ │ ├── settings_form.html │ │ │ │ │ ├── servers.html │ │ │ │ │ ├── users.html │ │ │ │ │ ├── user_list.html │ │ │ │ │ ├── user_item.html │ │ │ │ │ └── server_list.html │ │ │ │ ├── user │ │ │ │ │ ├── server_libraries.html │ │ │ │ │ ├── user_server_status.html │ │ │ │ │ ├── library_items.html │ │ │ │ │ ├── index.html │ │ │ │ │ ├── user_profile.html │ │ │ │ │ ├── user_media.html │ │ │ │ │ └── user_server_list.html │ │ │ │ ├── base.html │ │ │ │ ├── index.html │ │ │ │ └── login.html │ │ │ ├── root.rs │ │ │ ├── server_status.rs │ │ │ ├── auth │ │ │ │ ├── routes.rs │ │ │ │ └── mod.rs │ │ │ └── mod.rs │ │ ├── handlers │ │ │ ├── quick_connect.rs │ │ │ ├── mod.rs │ │ │ ├── branding.rs │ │ │ ├── system.rs │ │ │ ├── livestreams.rs │ │ │ └── videos.rs │ │ ├── processors │ │ │ ├── mod.rs │ │ │ ├── field_matcher.rs │ │ │ ├── response_processor.rs │ │ │ ├── request_processor.rs │ │ │ └── request_analyzer.rs │ │ ├── session_storage.rs │ │ └── url_helper.rs │ ├── askama.toml │ ├── migrations │ │ ├── 20251122120000_add_server_admins.down.sql │ │ ├── 20251122120000_add_server_admins.up.sql │ │ ├── 20251005093214_init.down.sql │ │ └── 20251005093214_init.up.sql │ ├── LICENSE-MIT │ ├── Cargo.toml │ └── build.rs ├── jellyfin-api │ ├── src │ │ ├── lib.rs │ │ ├── error.rs │ │ ├── models.rs │ │ └── storage.rs │ └── Cargo.toml └── jellyswarrm-macros │ ├── Cargo.toml │ └── examples │ └── usage.rs ├── media ├── users.png ├── library.png ├── servers.png ├── user_page.png ├── banner.svg └── icon.svg ├── .cargo └── config.toml ├── .gitmodules ├── docs ├── images │ ├── servers.png │ ├── add_user.png │ ├── federated.png │ ├── settings.png │ └── add_mapping.png ├── config.md └── ui.md ├── rust-toolchain.toml ├── docker-compose.yml ├── .dockerignore ├── .gitignore ├── .vscode └── tasks.json ├── Cargo.toml ├── .github └── workflows │ ├── update-ui.yml │ ├── ci.yml │ └── docker.yml ├── flake.lock ├── Dockerfile └── README.md /dev/.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ -------------------------------------------------------------------------------- /dev/.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/.gitignore: -------------------------------------------------------------------------------- 1 | static/ -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/models/tests/mod.rs: -------------------------------------------------------------------------------- 1 | mod test_jellyfin_models; 2 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/askama.toml: -------------------------------------------------------------------------------- 1 | [general] 2 | dirs = ["src/ui/templates"] 3 | -------------------------------------------------------------------------------- /media/users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/media/users.png -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [profile.release] 2 | strip = true 3 | opt-level = "z" 4 | lto = true 5 | -------------------------------------------------------------------------------- /media/library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/media/library.png -------------------------------------------------------------------------------- /media/servers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/media/servers.png -------------------------------------------------------------------------------- /media/user_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/media/user_page.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "ui"] 2 | path = ui 3 | url = https://github.com/jellyfin/jellyfin-web.git 4 | -------------------------------------------------------------------------------- /docs/images/servers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/docs/images/servers.png -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/admin/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod servers; 2 | pub mod settings; 3 | pub mod users; 4 | -------------------------------------------------------------------------------- /docs/images/add_user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/docs/images/add_user.png -------------------------------------------------------------------------------- /docs/images/federated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/docs/images/federated.png -------------------------------------------------------------------------------- /docs/images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/docs/images/settings.png -------------------------------------------------------------------------------- /docs/images/add_mapping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/docs/images/add_mapping.png -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/migrations/20251122120000_add_server_admins.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS server_admins; 2 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | components = ["rustfmt", "clippy"] 4 | profile = "minimal" -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/user/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod common; 2 | pub mod media; 3 | pub mod profile; 4 | pub mod servers; 5 | -------------------------------------------------------------------------------- /crates/jellyfin-api/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod client; 2 | pub mod error; 3 | pub mod models; 4 | pub mod storage; 5 | 6 | pub use client::{ClientInfo, JellyfinClient}; 7 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/handlers/quick_connect.rs: -------------------------------------------------------------------------------- 1 | use axum::Json; 2 | use hyper::StatusCode; 3 | 4 | pub async fn handle_quick_connect() -> Result, StatusCode> { 5 | Ok(Json(false)) 6 | } 7 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | mod authorization; 2 | mod jellyfin; 3 | 4 | #[cfg(test)] 5 | mod tests; 6 | 7 | pub use authorization::{generate_token, Authorization}; 8 | pub use jellyfin::*; 9 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-v4compatibility.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-v4compatibility.ttf -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-v4compatibility.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LLukas22/Jellyswarrm/HEAD/crates/jellyswarrm-proxy/src/ui/resources/fontawesome/webfonts/fa-v4compatibility.woff2 -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/processors/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod field_matcher; 2 | mod json_processor; 3 | pub mod request_analyzer; 4 | pub mod request_processor; 5 | pub mod response_processor; 6 | 7 | pub use json_processor::{analyze_json, process_json}; 8 | -------------------------------------------------------------------------------- /dev/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "dev" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "httpx>=0.28.1", 9 | "jellyfin-apiclient-python>=1.11.0", 10 | ] 11 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod branding; 2 | pub(crate) mod common; 3 | pub(crate) mod federated; 4 | pub(crate) mod items; 5 | pub(crate) mod livestreams; 6 | pub(crate) mod quick_connect; 7 | pub(crate) mod system; 8 | pub(crate) mod users; 9 | pub(crate) mod videos; 10 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/admin/settings.html: -------------------------------------------------------------------------------- 1 |
2 |

Settings

3 |

Modify runtime configuration values.

4 |
5 | 6 |
7 |

Loading settings...

8 |
9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | jellyswarrm-proxy: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | image: jellyswarrm-proxy:latest 7 | container_name: jellyswarrm-proxy 8 | ports: 9 | - "3000:3000" 10 | volumes: 11 | - ./data:/app/data 12 | environment: 13 | - JELLYSWARRM_USERNAME=admin 14 | - JELLYSWARRM_PASSWORD=jellyswarrm # Change this in production! 15 | - RUST_LOG=jellyswarrm_proxy=debug -------------------------------------------------------------------------------- /crates/jellyswarrm-macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jellyswarrm-macros" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | license = "MIT OR Apache-2.0" 7 | repository.workspace = true 8 | 9 | [lib] 10 | proc-macro = true 11 | 12 | [dependencies] 13 | proc-macro2 = "1.0" 14 | quote = "1.0" 15 | syn = { version = "2.0", features = ["full", "extra-traits"] } 16 | 17 | [dev-dependencies] 18 | serde = { version = "1.0", features = ["derive"] } 19 | serde_json = "1.0" 20 | serde_with = "3.14.0" 21 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Rust build artifacts 2 | /target 3 | 4 | # Node modules (reinstalled inside image) 5 | /ui/node_modules 6 | /ui/dist 7 | 8 | # Git 9 | .gitignore 10 | .gitattributes 11 | 12 | # Local data & logs 13 | /logs 14 | /data 15 | *.log 16 | *.db 17 | 18 | # Editor/OS noise 19 | .DS_Store 20 | .vscode 21 | .idea 22 | 23 | # Documentation 24 | *.md 25 | !README.md 26 | 27 | # Docker files 28 | docker-compose.yml 29 | Dockerfile.alpine 30 | 31 | # Static assets (will be rebuilt) 32 | /crates/jellyswarrm-proxy/static 33 | 34 | # Development files 35 | /scripts 36 | -------------------------------------------------------------------------------- /crates/jellyfin-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jellyfin-api" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | repository.workspace = true 7 | 8 | [dependencies] 9 | tokio = { workspace = true } 10 | reqwest = { workspace = true } 11 | serde = { workspace = true } 12 | serde_json = { workspace = true } 13 | url = { workspace = true } 14 | thiserror = { workspace = true } 15 | tracing = { workspace = true } 16 | moka = { workspace = true } 17 | 18 | [dev-dependencies] 19 | tokio = { workspace = true } 20 | wiremock = "0.6" 21 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/migrations/20251122120000_add_server_admins.up.sql: -------------------------------------------------------------------------------- 1 | -- Create server_admins table 2 | CREATE TABLE IF NOT EXISTS server_admins ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | server_id INTEGER NOT NULL, 5 | username TEXT NOT NULL, 6 | password TEXT NOT NULL, 7 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 8 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 9 | FOREIGN KEY (server_id) REFERENCES servers (id) ON DELETE CASCADE, 10 | UNIQUE(server_id) 11 | ); 12 | 13 | CREATE INDEX IF NOT EXISTS idx_server_admins_server_id ON server_admins(server_id); 14 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/user/server_libraries.html: -------------------------------------------------------------------------------- 1 |
2 | {% for library in libraries %} 3 |
4 | {{ library.name }} ({{ library.count }}) 5 |
10 |
Loading items...
11 |
12 |
13 | {% endfor %} 14 |
-------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/migrations/20251005093214_init.down.sql: -------------------------------------------------------------------------------- 1 | -- Add down migration script here 2 | -- Not much to do here since we are just dropping the table 3 | DROP TABLE IF EXISTS media_mappings; 4 | 5 | DROP INDEX IF EXISTS idx_media_mappings_virtual_id; 6 | DROP INDEX IF EXISTS idx_media_mappings_original_server; 7 | 8 | DROP TABLE IF EXISTS servers; 9 | DROP TABLE IF EXISTS users; 10 | DROP TABLE IF EXISTS server_mappings; 11 | DROP TABLE IF EXISTS authorization_sessions; 12 | 13 | DROP INDEX IF EXISTS idx_authorization_sessions_mapping; 14 | DROP INDEX IF EXISTS idx_users_virtual_key; 15 | DROP INDEX IF EXISTS idx_server_mappings_user_server; 16 | DROP INDEX IF EXISTS idx_authorization_sessions_user_server; 17 | 18 | -------------------------------------------------------------------------------- /crates/jellyfin-api/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum Error { 5 | #[error("Network error: {0}")] 6 | Network(#[from] reqwest::Error), 7 | #[error("Serialization error: {0}")] 8 | Serialization(#[from] serde_json::Error), 9 | #[error("URL parse error: {0}")] 10 | UrlParse(#[from] url::ParseError), 11 | #[error("Authentication failed: {0}")] 12 | AuthenticationFailed(String), 13 | #[error("Unauthorized")] 14 | Unauthorized, 15 | #[error("Forbidden")] 16 | Forbidden, 17 | #[error("Not found")] 18 | NotFound, 19 | #[error("Server error: {0}")] 20 | ServerError(String), 21 | #[error("Invalid response: {0}")] 22 | InvalidResponse(String), 23 | } 24 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/user/user_server_status.html: -------------------------------------------------------------------------------- 1 | {% if let Some(username) = username %} 2 | 3 | 4 | {{ username }} - ({{ server_version }}) 5 | 6 | {% else %} 7 | {% if let Some(error) = error_message %} 8 | 9 | 10 | {{ error }} 11 | 12 | {% else %} 13 | 14 | 15 | 16 | {% endif %} 17 | {% endif %} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | .last_build_commit 3 | 4 | 5 | # Generated by Cargo 6 | # will have compiled files and executables 7 | debug 8 | target 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # Generated by cargo mutants 17 | # Contains mutation testing data 18 | **/mutants.out*/ 19 | 20 | # RustRover 21 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 22 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 23 | # and can be added to the global gitignore or merged into this file. For a more nuclear 24 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 25 | #.idea/ 26 | 27 | 28 | # Added by cargo 29 | 30 | /target 31 | 32 | /logs 33 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/admin/server_status.html: -------------------------------------------------------------------------------- 1 | {% if let Some(error) = error_message%} 2 | 3 | Offline 4 | 5 | {% else %} 6 | 7 | Online 8 | {% if let Some(ver) = server_version %} 9 | ({{ ver }}) 10 | {% endif %} 11 | 12 | {% endif %} 13 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/user/library_items.html: -------------------------------------------------------------------------------- 1 | {% for item in items %} 2 |
3 | {% if let Some(tags) = item.image_tags %} 4 | {% if tags.contains_key("Primary") %} 5 | {{ item.name }} 6 | {% else %} 7 |
8 | {% endif %} 9 | {% else %} 10 |
11 | {% endif %} 12 |
13 | {{ item.name }} 14 |
15 |
16 | {% endfor %} 17 | 18 | {% if let Some(page) = next_page %} 19 |
23 |
Loading...
24 |
25 | {% endif %} -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Lukas Kreussel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/handlers/branding.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::State, Json}; 2 | use hyper::StatusCode; 3 | 4 | use crate::{models::BrandingConfig, AppState}; 5 | 6 | pub async fn handle_branding( 7 | State(state): State, 8 | ) -> Result, StatusCode> { 9 | let servers = state 10 | .server_storage 11 | .list_servers() 12 | .await 13 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 14 | 15 | let mut message = "Jellyswarrm proxying to the following servers: ".to_string(); 16 | if !servers.is_empty() { 17 | let server_links: Vec = servers 18 | .iter() 19 | .map(|s| { 20 | format!( 21 | "{}", 22 | s.url, s.name 23 | ) 24 | }) 25 | .collect(); 26 | message.push_str(&server_links.join(", ")); 27 | } else { 28 | message.push_str("No servers configured."); 29 | } 30 | 31 | let config = BrandingConfig { 32 | login_disclaimer: message, 33 | custom_css: String::new(), 34 | splashscreen_enabled: false, 35 | }; 36 | Ok(Json(config)) 37 | } 38 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/admin/index.html: -------------------------------------------------------------------------------- 1 | {% extends "../index.html" %} 2 | 3 | 4 | {% block tabs %} 5 | 6 |
  • 7 | 8 | 9 | Servers 10 | 11 |
  • 12 |
  • 13 | 14 | 15 | Users 16 | 17 |
  • 18 |
  • 19 | 20 | 21 | Settings 22 | 23 |
  • 24 | 25 | {% endblock %} 26 | 27 | 28 | {% block main_content %} 29 |
    30 |
    31 |
    32 | 33 |

    Loading dashboard...

    34 |
    35 |
    36 |
    37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/user/index.html: -------------------------------------------------------------------------------- 1 | {% extends "../index.html" %} 2 | 3 | {% block tabs %} 4 |
  • 5 | 6 | 7 | Servers 8 | 9 |
  • 10 |
  • 11 | 12 | 13 | Media 14 | 15 |
  • 16 |
  • 17 | 18 | 19 | Profile 20 | 21 |
  • 22 | {% endblock %} 23 | 24 | {% block main_content %} 25 |
    26 |
    27 |
    28 | 29 |

    Loading dashboard...

    30 |
    31 |
    32 |
    33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/admin/settings_form.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 | 7 | 10 | 13 | 16 |
    17 |
    18 | 19 | 20 | 21 |
    22 |

    Edits persist to TOML.

    23 |
    24 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/processors/field_matcher.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, sync::LazyLock}; 2 | 3 | /// A struct for case-insensitive field name matching 4 | pub struct FieldMatcher { 5 | fields: HashSet, 6 | } 7 | 8 | impl FieldMatcher { 9 | /// Create a new FieldMatcher with the given field names 10 | pub fn new(fields: &[&str]) -> Self { 11 | Self { 12 | fields: fields.iter().map(|s| s.to_string()).collect(), 13 | } 14 | } 15 | 16 | /// Check if a field name matches any of the stored fields (case-insensitive) 17 | pub fn contains(&self, field_name: &str) -> bool { 18 | self.fields 19 | .iter() 20 | .any(|field| field.eq_ignore_ascii_case(field_name)) 21 | } 22 | } 23 | 24 | // Static field matchers for different field types 25 | pub static ID_FIELDS: LazyLock = LazyLock::new(|| { 26 | FieldMatcher::new(&[ 27 | "Id", 28 | "ItemId", 29 | "ParentId", 30 | "SeriesId", 31 | "SeasonId", 32 | "MediaSourceId", 33 | "PlaylistItemId", 34 | ]) 35 | }); 36 | 37 | pub static SESSION_FIELDS: LazyLock = 38 | LazyLock::new(|| FieldMatcher::new(&["SessionId", "PlaySessionId"])); 39 | 40 | pub static USER_FIELDS: LazyLock = LazyLock::new(|| FieldMatcher::new(&["UserId"])); 41 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Start Jellyfin Web UI", 6 | "type": "shell", 7 | "command": "npm", 8 | "args": [ 9 | "run", 10 | "serve" 11 | ], 12 | "group": "build", 13 | "isBackground": true, 14 | "problemMatcher": [], 15 | "options": { 16 | "cwd": "${workspaceFolder}/ui" 17 | }, 18 | "presentation": { 19 | "echo": true, 20 | "reveal": "always", 21 | "focus": false, 22 | "panel": "new", 23 | "showReuseMessage": true, 24 | "clear": false 25 | } 26 | }, 27 | { 28 | "label": "Install UI Dependencies", 29 | "type": "shell", 30 | "command": "npm", 31 | "args": ["install"], 32 | "options": { 33 | "cwd": "${workspaceFolder}/ui" 34 | }, 35 | "group": "build", 36 | "presentation": { 37 | "echo": true, 38 | "reveal": "always", 39 | "focus": true, 40 | "panel": "shared" 41 | }, 42 | "problemMatcher": [] 43 | }, 44 | { 45 | "label": "Build UI Production", 46 | "type": "shell", 47 | "command": "npm", 48 | "args": ["run", "build:production"], 49 | "options": { 50 | "cwd": "${workspaceFolder}/ui" 51 | }, 52 | "group": "build", 53 | "presentation": { 54 | "echo": true, 55 | "reveal": "always", 56 | "focus": true, 57 | "panel": "shared" 58 | }, 59 | "problemMatcher": [] 60 | } 61 | ] 62 | } -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/admin/servers.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 |

    Server Management

    5 |

    Add, prioritize and monitor Jellyfin servers.

    6 |
    7 | 8 |
    9 |

    Add Server

    10 |
    11 |
    12 | 15 | 18 | 21 |
    22 | 23 |
    24 |
    25 |
    26 |

    Higher numbers imply higher importance in routing logic for e.g. styling.

    27 |
    28 | 29 |
    30 | 31 |
    32 |

    Loading servers...

    33 |
    34 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/session_storage.rs: -------------------------------------------------------------------------------- 1 | use tokio::sync::RwLock; 2 | 3 | use crate::server_storage::Server; 4 | 5 | #[derive(Clone)] 6 | pub struct PlaybackSession { 7 | pub session_id: String, // Unique identifier for the session 8 | pub item_id: String, // ID of the media item being played 9 | pub server: Server, 10 | } 11 | 12 | pub struct SessionStorage { 13 | pub sessions: RwLock>, 14 | } 15 | 16 | impl Default for SessionStorage { 17 | fn default() -> Self { 18 | Self::new() 19 | } 20 | } 21 | 22 | impl SessionStorage { 23 | pub fn new() -> Self { 24 | SessionStorage { 25 | sessions: RwLock::new(Vec::new()), 26 | } 27 | } 28 | 29 | pub async fn add_session(&self, session: PlaybackSession) { 30 | let mut sessions = self.sessions.write().await; 31 | sessions.push(session); 32 | } 33 | 34 | pub async fn get_session(&self, session_id: &str) -> Option { 35 | let sessions = self.sessions.read().await; 36 | sessions 37 | .iter() 38 | .find(|s| s.session_id == session_id) 39 | .cloned() 40 | } 41 | 42 | pub async fn get_session_by_item_id(&self, item_id: &str) -> Option { 43 | let sessions = self.sessions.read().await; 44 | sessions.iter().find(|s| s.item_id == item_id).cloned() 45 | } 46 | 47 | pub async fn remove_session(&self, session_id: &str) { 48 | let mut sessions = self.sessions.write().await; 49 | sessions.retain(|s| s.session_id != session_id); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "crates/jellyswarrm-proxy", 4 | "crates/jellyswarrm-macros", 5 | "crates/jellyfin-api", 6 | ] 7 | resolver = "2" 8 | 9 | [workspace.package] 10 | version = "0.2.0" 11 | edition = "2021" 12 | authors = ["lukaskreussel@gmail.com"] 13 | repository = "https://github.com/LLukas22/Jellyswarrm" 14 | 15 | [workspace.dependencies] 16 | jellyswarrm-macros = { path = "crates/jellyswarrm-macros" } 17 | # HTTP and Web Framework 18 | axum = { version = "0.8", features = ["macros"] } 19 | tower = "0.4" 20 | tower-http = { version = "0.5", features = ["cors", "trace"] } 21 | hyper = { version = "1.0", features = ["full"] } 22 | hyper-util = { version = "0.1", features = ["full"] } 23 | http-body-util = "0.1" 24 | reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "gzip", "brotli", "deflate", "rustls-tls"] } 25 | moka = { version = "0.12.11", features = ["future"] } 26 | 27 | # Database 28 | sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono"] } 29 | 30 | # Async Runtime 31 | tokio = { version = "1.0", features = ["full"] } 32 | 33 | # Logging and Tracing 34 | tracing = "0.1" 35 | tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 36 | tracing-appender = "0.2" 37 | 38 | # Serialization 39 | serde = { version = "1.0", features = ["derive"] } 40 | serde_json = "1.0" 41 | 42 | # Utilities 43 | url = {version = "2.4", features = ["serde"]} 44 | uuid = { version = "1.0", features = ["v4", "serde"] } 45 | chrono = { version = "0.4", features = ["serde"] } 46 | thiserror = "1.0" 47 | sha2 = "0.10" 48 | hex = "0.4" 49 | rand = "0.8" 50 | 51 | # Development dependencies 52 | tokio-test = "0.4" 53 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/admin/users.html: -------------------------------------------------------------------------------- 1 |
    2 |

    User Management

    3 |

    Create users and map credentials to each server.

    4 |
    Warning: If a mapping is added to a user all existing sessions will be invalidated.
    5 |
    6 | 7 |
    8 | 9 |
    10 |

    Add User

    11 |
    12 |
    13 | 16 | 19 |
    20 | 21 |
    22 |
    23 | 27 |
    28 |
    29 | 30 |
    31 | 32 |

    Users

    33 |
    34 |

    Loading users...

    35 |
    36 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/user/user_profile.html: -------------------------------------------------------------------------------- 1 |
    2 |

    User Profile

    3 |

    Manage your account settings

    4 |
    5 | 6 |
    7 |
    8 | Profile Information 9 |
    10 |
    11 |
    12 | 16 |
    17 |
    18 |
    19 | 20 |
    21 |
    22 | Change Password 23 |
    24 | 25 |
    30 | 31 |
    32 | 33 | 37 | 41 | 45 | 46 | 52 |
    53 |
    54 | -------------------------------------------------------------------------------- /.github/workflows/update-ui.yml: -------------------------------------------------------------------------------- 1 | name: Update UI Submodule 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: 'The tag to update the UI submodule to (e.g., v10.9.0)' 8 | required: true 9 | type: string 10 | 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | 15 | jobs: 16 | update-ui: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | with: 22 | submodules: true 23 | fetch-depth: 0 24 | token: ${{ secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }} 25 | 26 | - name: Configure Git 27 | run: | 28 | git config --global user.name "github-actions[bot]" 29 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 30 | 31 | - name: Update UI Submodule 32 | run: | 33 | cd ui 34 | git fetch --tags origin 35 | git checkout ${{ inputs.tag }} 36 | cd .. 37 | 38 | - name: Create Pull Request 39 | env: 40 | GH_TOKEN: ${{ secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }} 41 | TAG: ${{ inputs.tag }} 42 | run: | 43 | BRANCH_NAME="update-ui-${TAG}" 44 | git checkout -b "$BRANCH_NAME" 45 | git add ui 46 | 47 | if git diff --staged --quiet; then 48 | echo "No changes detected in submodule." 49 | exit 0 50 | fi 51 | 52 | git commit -m "Update UI to ${TAG}" 53 | git push origin "$BRANCH_NAME" --force 54 | 55 | gh pr create --title "Update UI to ${TAG}" --body "Updates the UI submodule to tag ${TAG}." --base main --head "$BRANCH_NAME" || echo "PR might already exist" 56 | -------------------------------------------------------------------------------- /crates/jellyswarrm-macros/examples/usage.rs: -------------------------------------------------------------------------------- 1 | use jellyswarrm_macros::multi_case_struct; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_with::skip_serializing_none; 4 | 5 | #[skip_serializing_none] 6 | #[multi_case_struct(pascal, camel)] 7 | #[derive(Debug, Serialize, Deserialize, Clone)] 8 | pub struct PlaybackRequest { 9 | pub always_burn_in_subtitle_when_transcoding: Option, 10 | pub audio_stream_index: Option, 11 | pub auto_open_live_stream: Option, 12 | pub is_playback: Option, 13 | pub max_streaming_bitrate: Option, 14 | pub media_source_id: Option, 15 | pub start_time_ticks: Option, 16 | pub subtitle_stream_index: Option, 17 | pub user_id: String, 18 | 19 | #[serde(flatten)] 20 | pub extra: std::collections::HashMap, 21 | } 22 | 23 | fn main() { 24 | let json_pascal = r#"{ 25 | "AlwaysBurnInSubtitleWhenTranscoding": true, 26 | "UserId": "123", 27 | "MaxStreamingBitrate": 1000000 28 | }"#; 29 | 30 | let json_camel = r#"{ 31 | "alwaysBurnInSubtitleWhenTranscoding": true, 32 | "userId": "123", 33 | "maxStreamingBitrate": 1000000 34 | }"#; 35 | 36 | // Both formats should deserialize successfully 37 | let request1: PlaybackRequest = serde_json::from_str(json_pascal).unwrap(); 38 | let request2: PlaybackRequest = serde_json::from_str(json_camel).unwrap(); 39 | 40 | println!("Pascal case JSON parsed: {request1:?}"); 41 | println!("Camel case JSON parsed: {request2:?}"); 42 | 43 | // Serialization will use the primary format (first case - pascal in this example) 44 | let serialized = serde_json::to_string_pretty(&request1).unwrap(); 45 | println!("Serialized (PascalCase):\n{serialized}"); 46 | } 47 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/processors/response_processor.rs: -------------------------------------------------------------------------------- 1 | // use async_trait::async_trait; 2 | // use serde_json::Value; 3 | // use std::collections::HashSet; 4 | // use std::sync::LazyLock; 5 | // use tracing::info; 6 | 7 | // use crate::processors::json_processor::{ 8 | // JsonProcessingContext, JsonProcessingResult, JsonProcessor, 9 | // }; 10 | // use crate::request_preprocessing::{JellyfinAuthorization, PreprocessedRequest}; 11 | // use crate::server_storage::Server; 12 | // use crate::user_authorization_service::{AuthorizationSession, User}; 13 | // use crate::AppState; 14 | 15 | // pub struct ResponseProcessor {} 16 | 17 | // #[async_trait] 18 | // impl JsonProcessor for ResponseProcessor { 19 | // async fn process( 20 | // &self, 21 | // json_context: &JsonProcessingContext, 22 | // value: &mut Value, 23 | // context: &String, 24 | // ) -> JsonProcessingResult { 25 | // let mut result = JsonProcessingResult::new(); 26 | 27 | // match json_context.key.as_str() { 28 | // // Handle ID fields that need virtual ID transformation 29 | // "id" 30 | // | "parent_id" 31 | // | "item_id" 32 | // | "etag" 33 | // | "series_id" 34 | // | "season_id" 35 | // | "display_preferences_id" 36 | // | "parent_logo_item_id" 37 | // | "parent_backdrop_item_id" 38 | // | "parent_logo_image_tag" 39 | // | "parent_thumb_item_id" 40 | // | "parent_thumb_image_tag" 41 | // | "series_primary_image_tag" => if let Value::String(ref id_str) = value {}, 42 | 43 | // _ => { 44 | // // Handle other fields as needed 45 | // } 46 | // } 47 | // result 48 | // } 49 | // } 50 | -------------------------------------------------------------------------------- /media/banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 43 | -------------------------------------------------------------------------------- /dev/README.md: -------------------------------------------------------------------------------- 1 | # Jellyfin Development Environment 2 | 3 | A complete Docker Compose setup for testing Jellyswarrm with three preconfigured Jellyfin servers (Movies, TV Shows, Music) and legally downloadable content. 4 | 5 | ## 🚀 Quick Start 6 | 7 | ```bash 8 | cd dev 9 | docker-compose up -d 10 | ``` 11 | 12 | What happens: 13 | - Downloads legal sample content automatically 14 | - Starts three Jellyfin servers (movies, tv, music) 15 | - Initializes each server (skips wizard, creates library, ready to browse) 16 | 17 | Then access: 18 | - Movies: http://localhost:8096 19 | - TV Shows: http://localhost:8097 20 | - Music: http://localhost:8098 21 | 22 | ## 👥 Users and libraries 23 | 24 | - Each server creates an admin user automatically: 25 | - Admin: `admin` / `password` 26 | - User: `user` / `[shows|movies|music]` (depending on server) 27 | - Libraries are created via API and point to: 28 | - Movies → `/media/movies` 29 | - TV Shows → `/media/tv-shows` 30 | - Music → `/media/music` 31 | 32 | 33 | 34 | ## 📁 Downloaded content 35 | 36 | All content is legally downloadable. Current script includes: 37 | 38 | - Movies 39 | - Night of the Living Dead (1968) — Internet Archive (Public Domain) 40 | - Plan 9 from Outer Space (1959) — Internet Archive (Public Domain) 41 | - Big Buck Bunny (2008) — Blender Foundation (CC) 42 | 43 | - TV Shows 44 | - The Cisco Kid (1950) — S01E01, S01E03 — Internet Archive (Public Domain) 45 | 46 | - Music 47 | - Kimiko Ishizaka — The Open Goldberg Variations (2012) — OGG — Internet Archive (CC0/PD) 48 | 49 | Content is placed under `./data/media/` on the host: 50 | 51 | ``` 52 | data/media/ 53 | ├── movies/ 54 | ├── tv-shows/ 55 | └── music/ 56 | ``` 57 | 58 | ## 📜 Licenses and attribution 59 | 60 | - Public domain items can be used freely. 61 | - CC-BY items (e.g., Kevin MacLeod) require attribution if used or redistributed publicly. Keep attribution in your app/docs if you publish content beyond local testing. 62 | 63 | Sources: 64 | - Internet Archive — https://archive.org/ 65 | - Blender Foundation — https://www.blender.org/about/projects/ -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/resources/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | JellySwarrm Logo 5 | A hexagonal swarm of gradient circles with the official Jellyfin logo and larger JellySwarrm text below, fully visible in the viewport. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 38 | 39 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jellyswarrm-proxy" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | repository.workspace = true 7 | description = "A high-performance reverse proxy for combining multiple Jellyfin instances" 8 | license = "MIT OR Apache-2.0" 9 | 10 | [[bin]] 11 | name = "jellyswarrm-proxy" 12 | path = "src/main.rs" 13 | 14 | [dependencies] 15 | jellyswarrm-macros.workspace = true 16 | axum.workspace = true 17 | tokio.workspace = true 18 | tower.workspace = true 19 | tower-http.workspace = true 20 | hyper.workspace = true 21 | hyper-util.workspace = true 22 | http-body-util.workspace = true 23 | tracing.workspace = true 24 | askama = { version = "0.12", features = ["with-axum"] } 25 | askama_axum = "0.4" 26 | tracing-subscriber.workspace = true 27 | tracing-appender.workspace = true 28 | url.workspace = true 29 | serde.workspace = true 30 | serde_json.workspace = true 31 | reqwest.workspace = true 32 | sqlx.workspace = true 33 | sha2.workspace = true 34 | hex.workspace = true 35 | chrono.workspace = true 36 | rand.workspace = true 37 | moka.workspace = true 38 | dashmap = "6.1.0" 39 | uuid = { version = "1.0", features = ["v4"] } 40 | anyhow = "1.0.98" 41 | serde_urlencoded = "0.7.1" 42 | indexmap = "2.11.4" 43 | regex = "1.11.1" 44 | rust-embed = "8" 45 | mime_guess = "2" 46 | percent-encoding = "2.3.1" 47 | config = "0.15.19" 48 | toml = "0.9.8" 49 | once_cell = "1.19" 50 | serde_default = "0.2.0" 51 | axum-login = "0.18.0" 52 | password-auth = "1.0.0" 53 | thiserror.workspace = true 54 | axum-messages = "0.8.0" 55 | tower-sessions = { version = "0.14.0", features = ["signed"] } 56 | tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] } 57 | time = "0.3.41" 58 | base64 = "0.22.1" 59 | serde-aux = "4.7.0" 60 | async-trait = "0.1.89" 61 | async-recursion = "1.1.1" 62 | 63 | aes-gcm = "0.10.3" 64 | serde_with = "3.16.1" 65 | jellyfin-api = { path = "../jellyfin-api" } 66 | 67 | [dev-dependencies] 68 | tokio-test = "0.4.4" 69 | tempfile = "3.14.0" 70 | mockall = "0.13.1" 71 | 72 | [build-dependencies] 73 | fs_extra = "1.2" 74 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/admin/user_list.html: -------------------------------------------------------------------------------- 1 | {% if let Some(report) = sync_report %} 2 |
    3 | Federated Sync Results: 4 |
      5 | {% for result in report %} 6 |
    • 7 | {{ result.server_name }}: 8 | {% match result.status %} 9 | {% when crate::federated_users::SyncStatus::Created %} 10 | Created 11 | {% when crate::federated_users::SyncStatus::AlreadyExists %} 12 | Exists 13 | {% when crate::federated_users::SyncStatus::ExistsWithDifferentPassword %} 14 | Exists (Password Mismatch) 15 | {% when crate::federated_users::SyncStatus::Failed %} 16 | Failed 17 | {% when crate::federated_users::SyncStatus::Skipped %} 18 | Skipped 19 | {% when crate::federated_users::SyncStatus::Deleted %} 20 | Deleted 21 | {% when crate::federated_users::SyncStatus::NotFound %} 22 | Not Found 23 | {% endmatch %} 24 | {% if let Some(msg) = result.message %} 25 | ({{ msg }}) 26 | {% endif %} 27 |
    • 28 | {% endfor %} 29 |
    30 |
    31 | {% endif %} 32 | 33 |
    34 | 35 |
    36 | 37 | {% if users.is_empty() %} 38 |
    39 |

    No users yet. Add one above to begin.

    40 |
    41 | {% else %} 42 |
    43 | {% for uwm in users %} 44 | {% include "user_item.html" %} 45 | {% endfor %} 46 |
    47 | {% endif %} 48 | 49 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Jellyswarrm Configuration Documentation 2 | 3 | Jellyswarrm stores its configuration in a **TOML** file located at: 4 | `./data/jellyswarrm.toml` (inside the container). 5 | 6 | The SQLite database is stored at: 7 | `./data/jellyswarrm.db`. 8 | 9 | To persist your configuration and database across container restarts, mount a volume to the `./data` directory. 10 | 11 | You can override the default configuration in two ways: 12 | 1. Provide your own `jellyswarrm.toml` file and mount it into the container. 13 | 2. Use environment variables to override individual settings. 14 | 15 | --- 16 | 17 | ## Configuration Options 18 | 19 | The table below lists all available configuration options: 20 | 21 | | Variable | Default Value | Environment Key | Description | 22 | |----------|---------------|-----------------|-------------| 23 | | `server_id` | `jellyswarrm{20-char-uuid}` | `JELLYSWARRM_SERVER_ID` | Unique identifier for the proxy server instance. | 24 | | `public_address` | `localhost:3000` | `JELLYSWARRM_PUBLIC_ADDRESS` | Public address where the proxy is accessible. | 25 | | `server_name` | `Jellyswarrm Proxy` | `JELLYSWARRM_SERVER_NAME` | Display name for the proxy server. | 26 | | `host` | `0.0.0.0` | `JELLYSWARRM_HOST` | Host address the server binds to. | 27 | | `port` | `3000` | `JELLYSWARRM_PORT` | Port number for the proxy server. | 28 | | `include_server_name_in_media` | `true` | `JELLYSWARRM_INCLUDE_SERVER_NAME_IN_MEDIA` | Append the server name to media titles in responses. | 29 | | `username` | `admin` | `JELLYSWARRM_USERNAME` | Default admin username. | 30 | | `password` | `jellyswarrm` | `JELLYSWARRM_PASSWORD` | Default admin password (⚠️ change this in production). | 31 | | `session_key` | *Generated 64-byte key* | `JELLYSWARRM_SESSION_KEY` | Base64-encoded session encryption key. | 32 | | `timeout` | `20` | `JELLYSWARRM_TIMEOUT` | Request timeout in seconds. | 33 | | `ui_route` | `ui` | `JELLYSWARRM_UI_ROUTE` | URL path segment for accessing the web UI (e.g., `/ui`). | 34 | | `url_prefix` | *(none)* | `JELLYSWARRM_URL_PREFIX` | Optional URL prefix for all routes (useful for reverse proxy setups). | 35 | 36 | --- 37 | 38 | ### Notes 39 | - The `session_key` is generated as a secure 64-byte key if not specified, and is stored in the config file for reuse. 40 | -------------------------------------------------------------------------------- /media/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | JellySwarrm Logo 5 | A hexagonal swarm of gradient circles with the official Jellyfin logo and larger JellySwarrm text below, fully visible in the viewport. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 38 | 39 | 41 | 42 | 43 | 44 | 46 | JellySwarrm 47 | 48 | 49 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1765472234, 24 | "narHash": "sha256-9VvC20PJPsleGMewwcWYKGzDIyjckEz8uWmT0vCDYK0=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "2fbfb1d73d239d2402a8fe03963e37aab15abe8b", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs", 41 | "rust-overlay": "rust-overlay" 42 | } 43 | }, 44 | "rust-overlay": { 45 | "inputs": { 46 | "nixpkgs": [ 47 | "nixpkgs" 48 | ] 49 | }, 50 | "locked": { 51 | "lastModified": 1765680428, 52 | "narHash": "sha256-fyPmRof9SZeI14ChPk5rVPOm7ISiiGkwGCunkhM+eUg=", 53 | "owner": "oxalica", 54 | "repo": "rust-overlay", 55 | "rev": "eb3898d8ef143d4bf0f7f2229105fc51c7731b2f", 56 | "type": "github" 57 | }, 58 | "original": { 59 | "owner": "oxalica", 60 | "repo": "rust-overlay", 61 | "type": "github" 62 | } 63 | }, 64 | "systems": { 65 | "locked": { 66 | "lastModified": 1681028828, 67 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 68 | "owner": "nix-systems", 69 | "repo": "default", 70 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 71 | "type": "github" 72 | }, 73 | "original": { 74 | "owner": "nix-systems", 75 | "repo": "default", 76 | "type": "github" 77 | } 78 | } 79 | }, 80 | "root": "root", 81 | "version": 7 82 | } 83 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/models/tests/files/series_nextup.json: -------------------------------------------------------------------------------- 1 | { 2 | "Items": [ 3 | { 4 | "Name": "DOG & CHAINSAW", 5 | "ServerId": "0555e8a91bfc4189a2585ede39a52dc8", 6 | "Id": "4d03fa91f6365b7d6f21da42070b506c", 7 | "HasSubtitles": true, 8 | "Container": "mkv,webm", 9 | "PremiereDate": "2022-10-12T00:00:00.0000000Z", 10 | "OfficialRating": "TV-MA", 11 | "ChannelId": null, 12 | "CommunityRating": 7.25, 13 | "RunTimeTicks": 15270250496, 14 | "ProductionYear": 2022, 15 | "IndexNumber": 1, 16 | "ParentIndexNumber": 1, 17 | "IsFolder": false, 18 | "Type": "Episode", 19 | "ParentLogoItemId": "d530a8428e87018e832e3d65b04775c5", 20 | "ParentBackdropItemId": "d530a8428e87018e832e3d65b04775c5", 21 | "ParentBackdropImageTags": [ 22 | "450e93012eb5f84dd187e51e9ccc00b0" 23 | ], 24 | "UserData": { 25 | "PlayedPercentage": 23.749760424362325, 26 | "PlaybackPositionTicks": 3626647909, 27 | "PlayCount": 2, 28 | "IsFavorite": false, 29 | "LastPlayedDate": "2024-10-15T14:29:10.287164Z", 30 | "Played": false, 31 | "Key": "397934001001", 32 | "ItemId": "00000000000000000000000000000000" 33 | }, 34 | "SeriesName": "Chainsaw Man", 35 | "SeriesId": "d530a8428e87018e832e3d65b04775c5", 36 | "SeasonId": "b7f6b36a38d8864f7b0e704c1108823a", 37 | "SeriesPrimaryImageTag": "a88eeb71365d9ef18a68e1eeaa2d845e", 38 | "SeasonName": "Season 1", 39 | "VideoType": "VideoFile", 40 | "ImageTags": { 41 | "Primary": "b756a1325f06d0631203f79c9dfdb269" 42 | }, 43 | "BackdropImageTags": [], 44 | "ParentLogoImageTag": "8ca9220e416270ca60ef644f1ea1094d", 45 | "ImageBlurHashes": { 46 | "Primary": { 47 | "b756a1325f06d0631203f79c9dfdb269": "W~K^+@xvM{bFX9og~ptRayoIj]of%Mozs:e-oLofxus.j[bIofay", 48 | "a88eeb71365d9ef18a68e1eeaa2d845e": "dvIEU3nm-7xs}ni}wKn%-Ps,WCk8-js.bHsCxUX5jIo2" 49 | }, 50 | "Logo": { 51 | "8ca9220e416270ca60ef644f1ea1094d": "HJO:@Sxut74nofWB4n-;%M~qWBxuM{WBt7Rjt7t7" 52 | }, 53 | "Thumb": { 54 | "a2b37e353fdddbb88d64308edbec3f4e": "NPFr-X9ZMwjZbIV@~q9Fxts.jts:bbRPW;jZV@bF" 55 | }, 56 | "Backdrop": { 57 | "450e93012eb5f84dd187e51e9ccc00b0": "WLFr^t4nwHoz%N%2_401aKRiRjj@tRoLofxtWAtQ-=My9Fofx]xu" 58 | } 59 | }, 60 | "ParentThumbItemId": "d530a8428e87018e832e3d65b04775c5", 61 | "ParentThumbImageTag": "a2b37e353fdddbb88d64308edbec3f4e", 62 | "LocationType": "FileSystem", 63 | "MediaType": "Video" 64 | } 65 | ], 66 | "TotalRecordCount": 1, 67 | "StartIndex": 0 68 | } -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/root.rs: -------------------------------------------------------------------------------- 1 | use askama::Template; 2 | use axum::{ 3 | extract::State, 4 | http::StatusCode, 5 | response::{Html, IntoResponse}, 6 | }; 7 | use tracing::{error, info}; 8 | 9 | use crate::{ 10 | ui::{ 11 | auth::{AuthenticatedUser, UserRole}, 12 | JellyfinUiVersion, JELLYFIN_UI_VERSION, 13 | }, 14 | AppState, 15 | }; 16 | 17 | #[derive(Template)] 18 | #[template(path = "user/index.html")] 19 | pub struct UserIndexTemplate { 20 | pub version: Option, 21 | pub ui_route: String, 22 | pub root: Option, 23 | pub jellyfin_ui_version: Option, 24 | } 25 | 26 | #[derive(Template)] 27 | #[template(path = "admin/index.html")] 28 | pub struct AdminIndexTemplate { 29 | pub version: Option, 30 | pub ui_route: String, 31 | pub root: Option, 32 | pub jellyfin_ui_version: Option, 33 | } 34 | 35 | /// Root/home page 36 | pub async fn index( 37 | State(state): State, 38 | AuthenticatedUser(user): AuthenticatedUser, 39 | ) -> impl IntoResponse { 40 | let response = if user.role == UserRole::User { 41 | let template = UserIndexTemplate { 42 | version: Some(env!("CARGO_PKG_VERSION").to_string()), 43 | ui_route: state.get_ui_route().await, 44 | root: state.get_url_prefix().await, 45 | jellyfin_ui_version: JELLYFIN_UI_VERSION.clone(), 46 | }; 47 | 48 | match template.render() { 49 | Ok(html) => Html(html).into_response(), 50 | Err(e) => { 51 | error!("Failed to render index template: {}", e); 52 | (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response() 53 | } 54 | } 55 | } else { 56 | info!("Rendering admin dashboard for {}", user.username); 57 | let template = AdminIndexTemplate { 58 | version: Some(env!("CARGO_PKG_VERSION").to_string()), 59 | ui_route: state.get_ui_route().await, 60 | root: state.get_url_prefix().await, 61 | jellyfin_ui_version: JELLYFIN_UI_VERSION.clone(), 62 | }; 63 | 64 | match template.render() { 65 | Ok(html) => Html(html).into_response(), 66 | Err(e) => { 67 | error!("Failed to render index template: {}", e); 68 | (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response() 69 | } 70 | } 71 | }; 72 | response 73 | } 74 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/handlers/system.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Request, State}, 3 | Json, 4 | }; 5 | use hyper::StatusCode; 6 | use tracing::error; 7 | 8 | use crate::{ 9 | handlers::common::execute_json_request, request_preprocessing::preprocess_request, AppState, 10 | }; 11 | 12 | pub async fn info_public( 13 | State(state): State, 14 | req: Request, 15 | ) -> Result, StatusCode> { 16 | let preprocessed = preprocess_request(req, &state).await.map_err(|e| { 17 | error!("Failed to preprocess request: {}", e); 18 | StatusCode::BAD_REQUEST 19 | })?; 20 | 21 | match execute_json_request::( 22 | &state.reqwest_client, 23 | preprocessed.request, 24 | ) 25 | .await 26 | { 27 | Ok(mut server_info) => { 28 | let cfg = state.config.read().await; 29 | server_info.id = cfg.server_id.clone(); 30 | server_info.server_name = cfg.server_name.clone(); 31 | server_info.local_address = cfg.public_address.clone(); 32 | 33 | Ok(Json(server_info)) 34 | } 35 | Err(e) => { 36 | error!("Failed to get server info: {:?}", e); 37 | Err(StatusCode::INTERNAL_SERVER_ERROR) 38 | } 39 | } 40 | } 41 | 42 | pub async fn info( 43 | State(state): State, 44 | req: Request, 45 | ) -> Result, StatusCode> { 46 | let preprocessed = preprocess_request(req, &state).await.map_err(|e| { 47 | error!("Failed to preprocess request: {}", e); 48 | StatusCode::BAD_REQUEST 49 | })?; 50 | 51 | preprocessed.user.ok_or_else(|| { 52 | error!("User not found in request preprocessing"); 53 | StatusCode::UNAUTHORIZED 54 | })?; 55 | 56 | // return Err(StatusCode::UNAUTHORIZED); 57 | 58 | match execute_json_request::( 59 | &state.reqwest_client, 60 | preprocessed.request, 61 | ) 62 | .await 63 | { 64 | Ok(mut server_info) => { 65 | let cfg = state.config.read().await; 66 | server_info.id = cfg.server_id.clone(); 67 | server_info.server_name = "Jellyswarrm Proxy".to_string(); 68 | server_info.local_address = cfg.public_address.clone(); 69 | 70 | Ok(Json(server_info)) 71 | } 72 | Err(e) => { 73 | error!("Failed to get server info: {:?}", e); 74 | Err(StatusCode::INTERNAL_SERVER_ERROR) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/handlers/livestreams.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Request, State}, 3 | Json, 4 | }; 5 | use hyper::StatusCode; 6 | use reqwest::Body; 7 | use tracing::{debug, error}; 8 | 9 | use crate::{ 10 | handlers::common::{ 11 | execute_json_request, payload_from_request, process_media_source, track_play_session, 12 | }, 13 | models::{PlaybackRequest, PlaybackResponse}, 14 | request_preprocessing::preprocess_request, 15 | AppState, 16 | }; 17 | 18 | //http://localhost:3000/LiveStreams/Open?UserId=b88ec8ff27774f26a992ce60e3190b46&StartTimeTicks=0&ItemId=31204dde7d38420f8b166d02b26f8c75&PlaySessionId=b33ff036839b4e0992fb374ddcd24e7d&MaxStreamingBitrate=2147483647 19 | #[axum::debug_handler] 20 | #[allow(dead_code)] 21 | pub async fn post_livestream_open( 22 | State(state): State, 23 | req: Request, 24 | ) -> Result, StatusCode> { 25 | let preprocessed = preprocess_request(req, &state).await.map_err(|e| { 26 | error!("Failed to preprocess request: {}", e); 27 | StatusCode::BAD_REQUEST 28 | })?; 29 | 30 | let original_request = preprocessed 31 | .original_request 32 | .ok_or(StatusCode::BAD_REQUEST)?; 33 | let payload: PlaybackRequest = payload_from_request(&original_request)?; 34 | 35 | let server = preprocessed.server; 36 | 37 | let session = preprocessed.session.ok_or(StatusCode::UNAUTHORIZED)?; 38 | 39 | let mut payload = payload; 40 | if payload.user_id.is_some() { 41 | payload.user_id = Some(session.original_user_id.clone()); 42 | } 43 | 44 | if let Some(media_source_id) = &payload.media_source_id { 45 | if let Some(media_mapping) = state 46 | .media_storage 47 | .get_media_mapping_by_virtual(media_source_id) 48 | .await 49 | .unwrap_or_default() 50 | { 51 | payload.media_source_id = Some(media_mapping.original_media_id); 52 | } 53 | } 54 | 55 | let json = serde_json::to_vec(&payload).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 56 | 57 | let mut request = preprocessed.request; 58 | *request.body_mut() = Some(Body::from(json)); 59 | 60 | match execute_json_request::(&state.reqwest_client, request).await { 61 | Ok(mut response) => { 62 | for item in &mut response.media_sources { 63 | *item = process_media_source(item.clone(), &state.media_storage, &server).await?; 64 | track_play_session(item, &response.play_session_id, &server, &state).await?; 65 | } 66 | 67 | debug!("Requested Playback: {:?}", response); 68 | 69 | Ok(Json(response)) 70 | } 71 | Err(e) => { 72 | error!("Failed to get playback info: {:?}", e); 73 | Err(e) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /crates/jellyfin-api/src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Serialize, Deserialize)] 4 | pub struct UserPolicy { 5 | #[serde(rename = "IsAdministrator")] 6 | pub is_administrator: bool, 7 | } 8 | 9 | #[derive(Debug, Clone, Serialize, Deserialize)] 10 | pub struct User { 11 | #[serde(rename = "Id")] 12 | pub id: String, 13 | #[serde(rename = "Name")] 14 | pub name: String, 15 | #[serde(rename = "ServerId")] 16 | pub server_id: Option, 17 | #[serde(rename = "Policy")] 18 | pub policy: Option, 19 | } 20 | 21 | #[derive(Debug, Clone, Serialize, Deserialize)] 22 | pub struct AuthResponse { 23 | #[serde(rename = "AccessToken")] 24 | pub access_token: String, 25 | #[serde(rename = "User")] 26 | pub user: User, 27 | } 28 | 29 | #[derive(Debug, Clone, Serialize, Deserialize)] 30 | pub struct MediaFolder { 31 | #[serde(rename = "Name")] 32 | pub name: String, 33 | #[serde(rename = "CollectionType")] 34 | pub collection_type: Option, 35 | #[serde(rename = "Id")] 36 | pub id: String, 37 | } 38 | 39 | #[derive(Debug, Clone, Serialize, Deserialize)] 40 | pub struct MediaFoldersResponse { 41 | #[serde(rename = "Items")] 42 | pub items: Vec, 43 | } 44 | 45 | #[derive(Debug, Clone, Serialize, Deserialize)] 46 | pub struct NewUserRequest { 47 | #[serde(rename = "Name")] 48 | pub name: String, 49 | #[serde(rename = "Password")] 50 | pub password: Option, 51 | } 52 | 53 | #[derive(Debug, Clone, Serialize, Deserialize)] 54 | pub struct PublicSystemInfo { 55 | #[serde(rename = "LocalAddress")] 56 | pub local_address: Option, 57 | #[serde(rename = "ServerName")] 58 | pub server_name: Option, 59 | #[serde(rename = "Version")] 60 | pub version: Option, 61 | #[serde(rename = "ProductName")] 62 | pub product_name: Option, 63 | #[serde(rename = "Id")] 64 | pub id: Option, 65 | #[serde(rename = "StartupWizardCompleted")] 66 | pub startup_wizard_completed: Option, 67 | } 68 | 69 | #[derive(Debug, Clone, Serialize, Deserialize)] 70 | pub struct BaseItem { 71 | #[serde(rename = "Name")] 72 | pub name: String, 73 | #[serde(rename = "Id")] 74 | pub id: String, 75 | #[serde(rename = "Type")] 76 | pub type_: String, 77 | #[serde(rename = "ImageTags")] 78 | pub image_tags: Option>, 79 | #[serde(rename = "ProductionYear")] 80 | pub production_year: Option, 81 | #[serde(rename = "RunTimeTicks")] 82 | pub run_time_ticks: Option, 83 | #[serde(rename = "CommunityRating")] 84 | pub community_rating: Option, 85 | } 86 | 87 | #[derive(Debug, Clone, Serialize, Deserialize)] 88 | pub struct ItemsResponse { 89 | #[serde(rename = "Items")] 90 | pub items: Vec, 91 | #[serde(rename = "TotalRecordCount")] 92 | pub total_record_count: i32, 93 | } 94 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}Jellyswarrm{% endblock %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% block head %}{% endblock %} 21 | 22 | 23 | {% block body %}{% endblock %} 24 | 25 | 26 | 67 | 68 | {% block scripts %}{% endblock %} 69 | 70 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/user/common.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use jellyfin_api::JellyfinClient; 4 | use tracing::info; 5 | 6 | use crate::{config::CLIENT_STORAGE, server_storage::Server, AppState}; 7 | 8 | pub async fn authenticate_user_on_server( 9 | state: &AppState, 10 | user: &crate::ui::auth::User, 11 | server: &Server, 12 | ) -> Result< 13 | ( 14 | Arc, 15 | jellyfin_api::models::User, 16 | jellyfin_api::models::PublicSystemInfo, 17 | ), 18 | String, 19 | > { 20 | let client_info = crate::config::CLIENT_INFO.clone(); 21 | let server_url = server.url.clone(); 22 | 23 | // Check cache first 24 | let client = CLIENT_STORAGE 25 | .get( 26 | server_url.as_ref(), 27 | client_info, 28 | Some(user.username.as_str()), 29 | ) 30 | .await 31 | .map_err(|e| format!("Failed to get client from storage: {}", e))?; 32 | 33 | // Always check public system info first to get version and name 34 | let public_info = match client.get_public_system_info().await { 35 | Ok(info) => info, 36 | Err(_) => return Err("Server offline or unreachable".to_string()), 37 | }; 38 | 39 | // Check for mapping and try to authenticate 40 | let mapping = match state 41 | .user_authorization 42 | .get_server_mapping(&user.id, server.url.as_str()) 43 | .await 44 | { 45 | Ok(Some(m)) => m, 46 | Ok(None) => return Err("No mapping found for user on this server".to_string()), 47 | Err(e) => return Err(format!("Database error: {}", e)), 48 | }; 49 | 50 | let admin_password = state.get_admin_password().await; 51 | 52 | info!( 53 | "Authenticating user '{}' on server '{}'.", 54 | user.username, server.id 55 | ); 56 | 57 | let password = state.user_authorization.decrypt_server_mapping_password( 58 | &mapping, 59 | &user.password_hash, 60 | &admin_password.into(), 61 | ); 62 | 63 | if client.get_token().is_some() { 64 | // Try to validate existing session 65 | match client.get_me().await { 66 | Ok(jellyfin_user) => { 67 | return Ok((client, jellyfin_user, public_info)); 68 | } 69 | Err(e) => { 70 | tracing::warn!("Existing session invalid for server {}: {}", server.id, e); 71 | // Fall through to re-authenticate 72 | } 73 | } 74 | } 75 | 76 | match client 77 | .authenticate_by_name(&mapping.mapped_username, password.as_str()) 78 | .await 79 | { 80 | Ok(jellyfin_user) => Ok((client, jellyfin_user, public_info)), 81 | Err(e) => { 82 | // Auth failed, log it but continue to check existing session 83 | tracing::warn!( 84 | "Failed to authenticate with mapped credentials for server {}: {}", 85 | server.id, 86 | e 87 | ); 88 | Err("Failed to log in with provided credentials".to_string()) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/user/user_media.html: -------------------------------------------------------------------------------- 1 |
    2 |

    My Media

    3 | 4 |
    5 | {% for server in servers %} 6 |
    7 |
    8 |

    {{ server.name }}

    9 |
    10 | 11 |
    15 |
    Loading libraries...
    16 |
    17 |
    18 | {% endfor %} 19 |
    20 |
    21 | 22 | 105 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/ci.yml 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout (with submodules) 17 | uses: actions/checkout@v4 18 | with: 19 | submodules: recursive 20 | fetch-depth: 0 21 | 22 | - name: Install Rust 23 | uses: dtolnay/rust-toolchain@stable 24 | with: 25 | components: rustfmt, clippy 26 | 27 | - name: Cache Rust toolchain 28 | uses: actions/cache@v4 29 | with: 30 | path: | 31 | ~/.rustup/toolchains 32 | ~/.rustup/update-hashes 33 | ~/.rustup/settings.toml 34 | key: ${{ runner.os }}-rustup-${{ hashFiles('rust-toolchain.toml') }} 35 | restore-keys: | 36 | ${{ runner.os }}-rustup- 37 | 38 | - name: Cache cargo registry and index 39 | uses: actions/cache@v4 40 | with: 41 | path: | 42 | ~/.cargo/registry/index 43 | ~/.cargo/registry/cache 44 | ~/.cargo/git/db 45 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 46 | restore-keys: | 47 | ${{ runner.os }}-cargo-registry- 48 | 49 | - name: Cache target directory 50 | uses: actions/cache@v4 51 | with: 52 | path: target 53 | key: ${{ runner.os }}-target-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('**/*.rs') }} 54 | restore-keys: | 55 | ${{ runner.os }}-target-${{ hashFiles('**/Cargo.lock') }}- 56 | ${{ runner.os }}-target- 57 | 58 | - name: Setup Node.js (LTS) 59 | uses: actions/setup-node@v4 60 | with: 61 | node-version: 20 62 | cache: npm 63 | cache-dependency-path: ui/package-lock.json 64 | 65 | - name: Cache Node.js modules 66 | uses: actions/cache@v4 67 | with: 68 | path: | 69 | ui/node_modules 70 | ~/.npm 71 | key: ${{ runner.os }}-node-${{ hashFiles('ui/package-lock.json') }} 72 | restore-keys: | 73 | ${{ runner.os }}-node- 74 | 75 | - name: Cache Rust build artifacts 76 | uses: actions/cache@v4 77 | with: 78 | path: | 79 | target/debug/deps 80 | target/debug/build 81 | target/debug/.fingerprint 82 | key: ${{ runner.os }}-rust-build-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('**/*.rs') }} 83 | restore-keys: | 84 | ${{ runner.os }}-rust-build-${{ hashFiles('**/Cargo.lock') }}- 85 | ${{ runner.os }}-rust-build- 86 | 87 | - name: Install UI deps 88 | working-directory: ui 89 | run: | 90 | if [ -f package-lock.json ]; then 91 | npm ci 92 | else 93 | npm install 94 | fi 95 | 96 | - name: Build 97 | run: cargo build --verbose 98 | 99 | - name: Run tests 100 | run: cargo test --verbose 101 | 102 | - name: Lint (clippy) 103 | run: cargo clippy -- -D warnings 104 | 105 | - name: Format check 106 | run: cargo fmt -- --check 107 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/admin/settings.rs: -------------------------------------------------------------------------------- 1 | use askama::Template; 2 | use axum::{ 3 | extract::State, 4 | http::StatusCode, 5 | response::{Html, IntoResponse}, 6 | Form, 7 | }; 8 | use serde::Deserialize; 9 | use tracing::error; 10 | 11 | use crate::{config::save_config, AppState}; 12 | 13 | #[derive(Template)] 14 | #[template(path = "admin/settings.html")] 15 | pub struct SettingsPageTemplate { 16 | pub ui_route: String, 17 | } 18 | 19 | #[derive(Template)] 20 | #[template(path = "admin/settings_form.html")] 21 | pub struct SettingsFormTemplate { 22 | pub server_id: String, 23 | pub public_address: String, 24 | pub server_name: String, 25 | pub include_server_name_in_media: bool, 26 | pub ui_route: String, 27 | } 28 | 29 | pub async fn settings_page(State(state): State) -> impl IntoResponse { 30 | let template = SettingsPageTemplate { 31 | ui_route: state.get_ui_route().await, 32 | }; 33 | match template.render() { 34 | Ok(html) => Html(html).into_response(), 35 | Err(e) => { 36 | error!("Failed to render settings page: {}", e); 37 | (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response() 38 | } 39 | } 40 | } 41 | 42 | pub async fn settings_form(State(state): State) -> impl IntoResponse { 43 | let cfg = state.config.read().await.clone(); 44 | let form = SettingsFormTemplate { 45 | server_id: cfg.server_id, 46 | public_address: cfg.public_address, 47 | server_name: cfg.server_name, 48 | include_server_name_in_media: cfg.include_server_name_in_media, 49 | ui_route: state.get_ui_route().await, 50 | }; 51 | match form.render() { 52 | Ok(html) => Html(html).into_response(), 53 | Err(e) => { 54 | error!("Failed to render settings form: {}", e); 55 | (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response() 56 | } 57 | } 58 | } 59 | 60 | #[derive(Deserialize)] 61 | pub struct SaveForm { 62 | pub public_address: String, 63 | pub server_name: String, 64 | // When the checkbox is unchecked the field is absent; default to false. 65 | #[serde(default)] 66 | pub include_server_name_in_media: bool, 67 | } 68 | 69 | pub async fn save_settings( 70 | State(state): State, 71 | Form(form): Form, 72 | ) -> impl IntoResponse { 73 | if form.public_address.trim().is_empty() || form.server_name.trim().is_empty() { 74 | return Html( 75 | "
    All fields required
    ", 76 | ) 77 | .into_response(); 78 | } 79 | { 80 | let mut cfg = state.config.write().await; 81 | cfg.public_address = form.public_address.trim().to_string(); 82 | cfg.server_name = form.server_name.trim().to_string(); 83 | cfg.include_server_name_in_media = form.include_server_name_in_media; 84 | if let Err(e) = save_config(&cfg) { 85 | error!("Save failed: {}", e); 86 | } 87 | } 88 | // Return fresh form (like server list pattern) 89 | settings_form(State(state)).await.into_response() 90 | } 91 | 92 | pub async fn reload_config(State(state): State) -> impl IntoResponse { 93 | let new_cfg = crate::config::load_config(); 94 | { 95 | let mut cfg = state.config.write().await; 96 | *cfg = new_cfg; 97 | } 98 | Html("
    Configuration reloaded
    ") 99 | } 100 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/server_status.rs: -------------------------------------------------------------------------------- 1 | use askama::Template; 2 | use axum::{ 3 | extract::{Path, State}, 4 | http::StatusCode, 5 | response::{Html, IntoResponse}, 6 | }; 7 | use tracing::error; 8 | 9 | use crate::AppState; 10 | 11 | #[derive(Template)] 12 | #[template(path = "admin/server_status.html")] 13 | pub struct ServerStatusTemplate { 14 | pub error_message: Option, 15 | pub server_version: Option, 16 | } 17 | 18 | /// Check server status 19 | pub async fn check_server_status( 20 | State(state): State, 21 | Path(server_id): Path, 22 | ) -> impl IntoResponse { 23 | // Get the server details first 24 | match state.server_storage.get_server_by_id(server_id).await { 25 | Ok(Some(server)) => { 26 | let client_info = crate::config::CLIENT_INFO.clone(); 27 | 28 | let client = match jellyfin_api::JellyfinClient::new(server.url.as_str(), client_info) { 29 | Ok(c) => c, 30 | Err(e) => { 31 | let template = ServerStatusTemplate { 32 | error_message: Some(format!("Client error: {}", e)), 33 | server_version: None, 34 | }; 35 | 36 | return match template.render() { 37 | Ok(html) => Html(html).into_response(), 38 | Err(e) => { 39 | error!("Failed to render status template: {}", e); 40 | (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response() 41 | } 42 | }; 43 | } 44 | }; 45 | 46 | match client.get_public_system_info().await { 47 | Ok(info) => { 48 | let template = ServerStatusTemplate { 49 | error_message: None, 50 | server_version: info.version, 51 | }; 52 | 53 | match template.render() { 54 | Ok(html) => Html(html).into_response(), 55 | Err(e) => { 56 | error!("Failed to render status template: {}", e); 57 | (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response() 58 | } 59 | } 60 | } 61 | Err(e) => { 62 | let template = ServerStatusTemplate { 63 | error_message: Some(format!("Error: {}", e)), 64 | server_version: None, 65 | }; 66 | 67 | match template.render() { 68 | Ok(html) => Html(html).into_response(), 69 | Err(e) => { 70 | error!("Failed to render status template: {}", e); 71 | (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response() 72 | } 73 | } 74 | } 75 | } 76 | } 77 | Ok(None) => ( 78 | StatusCode::NOT_FOUND, 79 | Html("Server not found"), 80 | ) 81 | .into_response(), 82 | Err(e) => { 83 | error!("Failed to get server: {}", e); 84 | ( 85 | StatusCode::INTERNAL_SERVER_ERROR, 86 | Html("Database error"), 87 | ) 88 | .into_response() 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/resources/custom.css: -------------------------------------------------------------------------------- 1 | /* Global Styles for Jellyswarrm */ 2 | 3 | /* --- Components --- */ 4 | 5 | /* Icon Buttons */ 6 | .icon-btn { 7 | --_size: 1.1rem; 8 | font-size: var(--_size); 9 | line-height: 1; 10 | padding: .5rem; 11 | display: inline-flex; 12 | align-items: center; 13 | justify-content: center; 14 | border: 1px solid var(--pico-muted-border-color, #555); 15 | background: transparent; 16 | cursor: pointer; 17 | border-radius: 0.25rem; 18 | transition: all 0.2s; 19 | color: var(--pico-color); 20 | } 21 | 22 | .icon-btn:hover { 23 | background: var(--pico-card-background-color); 24 | transform: translateY(-1px); 25 | } 26 | 27 | .icon-btn.danger { 28 | color: var(--pico-del-color, #e55353); 29 | border-color: var(--pico-del-color, #e55353); 30 | } 31 | 32 | .icon-btn.danger:hover { 33 | background: rgba(229, 83, 83, 0.1); 34 | } 35 | 36 | /* Badges & Chips */ 37 | .badge { 38 | display: inline-flex; 39 | align-items: center; 40 | justify-content: center; 41 | padding: .25em .55em; 42 | font-size: .75rem; 43 | font-weight: 500; 44 | line-height: 1; 45 | border-radius: 1rem; 46 | color: #fff; 47 | background: var(--pico-primary-background); 48 | } 49 | 50 | .badge.muted, .status-chip { 51 | background: var(--pico-muted-border-color, rgba(127, 127, 127, .15)); 52 | color: var(--pico-color); 53 | } 54 | 55 | .badge.success { background-color: #2e7d32; color: white; } 56 | .badge.warning { background-color: #ffc107; color: black; } 57 | .badge.danger { background-color: #dc3545; color: white; } 58 | .badge.secondary { background-color: #6c757d; color: white; } 59 | 60 | /* Status Container */ 61 | .status-container { 62 | display: flex; 63 | align-items: center; 64 | gap: 0.75rem; 65 | } 66 | 67 | /* Filter Bar */ 68 | .filter-bar { 69 | margin-bottom: 1rem; 70 | } 71 | 72 | /* User Key */ 73 | .user-key { 74 | font-size: .6rem; 75 | opacity: .6; 76 | word-break: break-all; 77 | padding-top: .25em; 78 | } 79 | 80 | /* Danger Text/Icon */ 81 | .text-danger, .danger { 82 | color: var(--pico-del-color, #d9534f); 83 | } 84 | i.danger { 85 | cursor: pointer; 86 | } 87 | 88 | /* Empty State */ 89 | .empty-state { 90 | padding: 1rem; 91 | border: 2px dashed var(--pico-border-color, #ccc); 92 | border-radius: .6rem; 93 | text-align: center; 94 | } 95 | 96 | /* Sync Report */ 97 | .sync-report { 98 | margin-bottom: 1rem; 99 | padding: 1rem; 100 | background: var(--pico-card-background-color); 101 | border: 1px solid var(--pico-card-border-color); 102 | border-radius: var(--pico-border-radius); 103 | } 104 | .sync-report ul { 105 | margin-bottom: 0; 106 | padding-left: 1rem; 107 | } 108 | 109 | /* --- Animations --- */ 110 | .htmx-added { 111 | animation: fadeInSoft 160ms ease-out, highlightSoft 900ms ease-out; 112 | } 113 | .htmx-swapping { 114 | animation: fadeOutSoft 140ms ease-in forwards; 115 | pointer-events: none; 116 | } 117 | 118 | @keyframes fadeInSoft { 119 | from { opacity: 0; transform: translateY(2px); } 120 | to { opacity: 1; transform: translateY(0); } 121 | } 122 | 123 | @keyframes fadeOutSoft { 124 | from { opacity: 1; transform: translateY(0); } 125 | to { opacity: 0; transform: translateY(-2px); } 126 | } 127 | 128 | @keyframes highlightSoft { 129 | 0% { background: var(--pico-mark-background-color, #fff8d9); } 130 | 100% { background: transparent; } 131 | } 132 | 133 | @media (prefers-reduced-motion: reduce) { 134 | .htmx-added, .htmx-swapping { animation: none; } 135 | } 136 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Dashboard - Jellyswarrm{% endblock %} 4 | 5 | {% block body %} 6 | 7 |
    8 | 38 |
    39 | 40 | 41 |
    42 | 43 |
    44 | 45 |
    46 | 47 | 48 | {% block main_content %} {% endblock %} 49 | 50 |
    51 | 52 | 53 | 72 | {% endblock %} 73 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/models/tests/files/userviews.json: -------------------------------------------------------------------------------- 1 | { 2 | "Items": [ 3 | { 4 | "Name": "Filme", 5 | "ServerId": "0555e8a91bfc4189a2585ede39a52dc8", 6 | "Id": "7a2175bccb1f1a94152cbd2b2bae8f6d", 7 | "Etag": "a2ceddc6670c8cf6a77c215d1e20feff", 8 | "DateCreated": "2024-03-07T15:54:15.1376334Z", 9 | "CanDelete": false, 10 | "CanDownload": false, 11 | "SortName": "filme", 12 | "ExternalUrls": [], 13 | "Path": "/config/root/default/Filme", 14 | "EnableMediaSourceDisplay": true, 15 | "ChannelId": null, 16 | "Taglines": [], 17 | "Genres": [], 18 | "PlayAccess": "Full", 19 | "RemoteTrailers": [], 20 | "ProviderIds": {}, 21 | "IsFolder": true, 22 | "ParentId": "e9d5075a555c1cbc394eec4cef295274", 23 | "Type": "CollectionFolder", 24 | "People": [], 25 | "Studios": [], 26 | "GenreItems": [], 27 | "LocalTrailerCount": 0, 28 | "UserData": { 29 | "PlaybackPositionTicks": 0, 30 | "PlayCount": 0, 31 | "IsFavorite": false, 32 | "Played": false, 33 | "Key": "7a2175bc-cb1f-1a94-152c-bd2b2bae8f6d", 34 | "ItemId": "00000000000000000000000000000000" 35 | }, 36 | "ChildCount": 6, 37 | "SpecialFeatureCount": 0, 38 | "DisplayPreferencesId": "7a2175bccb1f1a94152cbd2b2bae8f6d", 39 | "Tags": [], 40 | "PrimaryImageAspectRatio": 1.7777777777777777, 41 | "CollectionType": "movies", 42 | "ImageTags": { 43 | "Primary": "cf88773a4957287197ed1460c299248f" 44 | }, 45 | "BackdropImageTags": [], 46 | "ImageBlurHashes": { 47 | "Primary": { 48 | "cf88773a4957287197ed1460c299248f": "WC6uO.kCDiaexvo#aee.WCa}V@ae4Tj[-;kCM{axbcWBoLoLkDWq" 49 | } 50 | }, 51 | "LocationType": "FileSystem", 52 | "MediaType": "Unknown", 53 | "LockedFields": [], 54 | "LockData": false 55 | }, 56 | { 57 | "Name": "Serien", 58 | "ServerId": "0555e8a91bfc4189a2585ede39a52dc8", 59 | "Id": "43cfe12fe7d9d8d21251e0964e0232e2", 60 | "Etag": "d83015cb967c50942003e5472e934788", 61 | "DateCreated": "2024-03-07T16:01:14.3788766Z", 62 | "CanDelete": false, 63 | "CanDownload": false, 64 | "SortName": "serien", 65 | "ExternalUrls": [], 66 | "Path": "/config/root/default/Serien", 67 | "EnableMediaSourceDisplay": true, 68 | "ChannelId": null, 69 | "Taglines": [], 70 | "Genres": [], 71 | "PlayAccess": "Full", 72 | "RemoteTrailers": [], 73 | "ProviderIds": {}, 74 | "IsFolder": true, 75 | "ParentId": "e9d5075a555c1cbc394eec4cef295274", 76 | "Type": "CollectionFolder", 77 | "People": [], 78 | "Studios": [], 79 | "GenreItems": [], 80 | "LocalTrailerCount": 0, 81 | "UserData": { 82 | "PlaybackPositionTicks": 0, 83 | "PlayCount": 0, 84 | "IsFavorite": false, 85 | "Played": false, 86 | "Key": "43cfe12f-e7d9-d8d2-1251-e0964e0232e2", 87 | "ItemId": "00000000000000000000000000000000" 88 | }, 89 | "ChildCount": 4, 90 | "SpecialFeatureCount": 0, 91 | "DisplayPreferencesId": "43cfe12fe7d9d8d21251e0964e0232e2", 92 | "Tags": [], 93 | "PrimaryImageAspectRatio": 1.7777777777777777, 94 | "CollectionType": "tvshows", 95 | "ImageTags": { 96 | "Primary": "98562456587cfd6d6eed5bf72068c414" 97 | }, 98 | "BackdropImageTags": [], 99 | "ImageBlurHashes": { 100 | "Primary": { 101 | "98562456587cfd6d6eed5bf72068c414": "W87K*jV?01axozW;x]V@bbkBV[WB00WB_3jbRPofW:WBoLogaejs" 102 | } 103 | }, 104 | "LocationType": "FileSystem", 105 | "MediaType": "Unknown", 106 | "LockedFields": [], 107 | "LockData": false 108 | } 109 | ], 110 | "TotalRecordCount": 2, 111 | "StartIndex": 0 112 | } -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/models/tests/files/person.json: -------------------------------------------------------------------------------- 1 | { 2 | "Name": "Jared Harris", 3 | "ServerId": "0555e8a91bfc4189a2585ede39a52dc8", 4 | "Id": "4489f6a0e2abc608b915830a0ce2e0ac", 5 | "Etag": "1b9a1765f1e6c5b894eef0803d88f32d", 6 | "DateCreated": "2025-06-05T15:19:33.0995621Z", 7 | "CanDelete": false, 8 | "CanDownload": false, 9 | "SortName": "Jared Harris", 10 | "PremiereDate": "1961-08-24T00:00:00.0000000Z", 11 | "ExternalUrls": [ 12 | { 13 | "Name": "IMDb", 14 | "Url": "https://www.imdb.com/name/nm0364813" 15 | }, 16 | { 17 | "Name": "TheMovieDb", 18 | "Url": "https://www.themoviedb.org/person/15440" 19 | }, 20 | { 21 | "Name": "TheTVDB", 22 | "Url": "https://www.thetvdb.com/people/360332" 23 | } 24 | ], 25 | "ProductionLocations": [ 26 | "London, England, UK" 27 | ], 28 | "Path": "/config/metadata/People/J/Jared Harris", 29 | "EnableMediaSourceDisplay": true, 30 | "ChannelId": null, 31 | "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.", 32 | "Taglines": [], 33 | "Genres": [], 34 | "PlayAccess": "Full", 35 | "RemoteTrailers": [], 36 | "ProviderIds": { 37 | "Tmdb": "15440", 38 | "Tvdb": "360332", 39 | "Imdb": "nm0364813" 40 | }, 41 | "ParentId": null, 42 | "Type": "Person", 43 | "People": [], 44 | "Studios": [], 45 | "GenreItems": [], 46 | "LocalTrailerCount": 0, 47 | "UserData": { 48 | "PlaybackPositionTicks": 0, 49 | "PlayCount": 0, 50 | "IsFavorite": false, 51 | "Played": false, 52 | "Key": "Person-Jared Harris", 53 | "ItemId": "00000000000000000000000000000000" 54 | }, 55 | "ChildCount": 7, 56 | "SpecialFeatureCount": 0, 57 | "DisplayPreferencesId": "b607178b0ac2f604a458d3d2363fdc83", 58 | "Tags": [], 59 | "PrimaryImageAspectRatio": 0.666, 60 | "ImageTags": { 61 | "Primary": "2d12c0bcb33d62f4247037e5de68160f" 62 | }, 63 | "BackdropImageTags": [], 64 | "ImageBlurHashes": { 65 | "Primary": { 66 | "2d12c0bcb33d62f4247037e5de68160f": "dxJ7]?og9^xtHqRjIoRjTKt7xZbHIpaytRjZIUaenhWV" 67 | } 68 | }, 69 | "LocationType": "FileSystem", 70 | "MediaType": "Unknown", 71 | "LockedFields": [], 72 | "TrailerCount": 0, 73 | "MovieCount": 0, 74 | "SeriesCount": 1, 75 | "ProgramCount": 0, 76 | "EpisodeCount": 5, 77 | "SongCount": 0, 78 | "AlbumCount": 0, 79 | "ArtistCount": 0, 80 | "MusicVideoCount": 0, 81 | "LockData": false 82 | } -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/auth/routes.rs: -------------------------------------------------------------------------------- 1 | use askama::Template; 2 | use axum::{ 3 | extract::Query, 4 | http::StatusCode, 5 | response::{Html, IntoResponse, Redirect}, 6 | routing::{get, post}, 7 | Form, Router, 8 | }; 9 | use axum_messages::{Message, Messages}; 10 | use serde::Deserialize; 11 | 12 | use crate::{ 13 | ui::auth::{AuthSession, Credentials}, 14 | AppState, 15 | }; 16 | 17 | #[derive(Template)] 18 | #[template(path = "login.html")] 19 | pub struct LoginTemplate { 20 | messages: Vec, 21 | next: Option, 22 | ui_route: String, 23 | } 24 | 25 | // This allows us to extract the "next" field from the query string. We use this 26 | // to redirect after log in. 27 | #[derive(Debug, Deserialize)] 28 | pub struct NextUrl { 29 | next: Option, 30 | } 31 | 32 | pub fn router() -> axum::Router { 33 | Router::new() 34 | .route("/login", post(self::post::login)) 35 | .route("/login", get(self::get::login)) 36 | .route("/logout", get(self::get::logout)) 37 | } 38 | 39 | mod post { 40 | 41 | use axum::extract::State; 42 | 43 | use super::*; 44 | 45 | pub async fn login( 46 | State(state): axum::extract::State, 47 | mut auth_session: AuthSession, 48 | messages: Messages, 49 | Form(creds): Form, 50 | ) -> impl IntoResponse { 51 | let user = match auth_session.authenticate(creds.clone()).await { 52 | Ok(Some(user)) => user, 53 | Ok(None) => { 54 | messages.error("Invalid credentials"); 55 | 56 | let mut login_url = format!("/{}/login", state.get_ui_route().await); 57 | if let Some(next) = creds.next { 58 | login_url = format!("{login_url}?next={next}"); 59 | } else { 60 | login_url = format!("{login_url}?next=/{}", state.get_ui_route().await); 61 | } 62 | 63 | return Redirect::to(&login_url).into_response(); 64 | } 65 | Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), 66 | }; 67 | 68 | if auth_session.login(&user).await.is_err() { 69 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); 70 | } 71 | 72 | messages.success(format!("Successfully logged in as {}", user.username)); 73 | 74 | if let Some(ref next) = creds.next { 75 | Redirect::to(next) 76 | } else { 77 | Redirect::to(&format!("/{}", state.get_ui_route().await)) 78 | } 79 | .into_response() 80 | } 81 | } 82 | 83 | mod get { 84 | use axum::extract::State; 85 | use tracing::info; 86 | 87 | use super::*; 88 | 89 | pub async fn login( 90 | State(state): axum::extract::State, 91 | messages: Messages, 92 | Query(NextUrl { next }): Query, 93 | ) -> Html { 94 | info!( 95 | "Rendering login page, base={:?}", 96 | state.get_ui_route().await 97 | ); 98 | Html( 99 | LoginTemplate { 100 | messages: messages.into_iter().collect(), 101 | next, 102 | ui_route: state.get_ui_route().await, 103 | } 104 | .render() 105 | .unwrap(), 106 | ) 107 | } 108 | 109 | pub async fn logout( 110 | State(state): axum::extract::State, 111 | mut auth_session: AuthSession, 112 | ) -> impl IntoResponse { 113 | match auth_session.logout().await { 114 | Ok(_) => { 115 | Redirect::to(&format!("/{}/login", state.get_ui_route().await)).into_response() 116 | } 117 | Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/migrations/20251005093214_init.up.sql: -------------------------------------------------------------------------------- 1 | -- Add up migration script here 2 | -- Create a new SQLite database schema for Jellyswarrm Proxy 3 | -- We check if tables already exist since sqlx migrations were added later and we want to support existing databases 4 | 5 | -- Create the media_mappings table 6 | CREATE TABLE IF NOT EXISTS media_mappings ( 7 | id INTEGER PRIMARY KEY AUTOINCREMENT, 8 | virtual_media_id TEXT NOT NULL UNIQUE, 9 | original_media_id TEXT NOT NULL, 10 | server_url TEXT NOT NULL, 11 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 12 | UNIQUE(original_media_id, server_url) 13 | ); 14 | 15 | CREATE INDEX IF NOT EXISTS idx_media_mappings_virtual_id ON media_mappings(virtual_media_id); 16 | 17 | CREATE INDEX IF NOT EXISTS idx_media_mappings_original_server ON media_mappings(original_media_id, server_url); 18 | 19 | 20 | -- Create the servers table 21 | CREATE TABLE IF NOT EXISTS servers ( 22 | id INTEGER PRIMARY KEY AUTOINCREMENT, 23 | name TEXT NOT NULL UNIQUE, 24 | url TEXT NOT NULL, 25 | priority INTEGER NOT NULL DEFAULT 100, 26 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 27 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 28 | ); 29 | 30 | 31 | -- Create users table 32 | CREATE TABLE IF NOT EXISTS users ( 33 | id TEXT PRIMARY KEY, 34 | virtual_key TEXT NOT NULL UNIQUE, 35 | original_username TEXT NOT NULL, 36 | original_password_hash TEXT NOT NULL, 37 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 38 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 39 | UNIQUE(original_username, original_password_hash) 40 | ); 41 | 42 | -- Server mappings table 43 | CREATE TABLE IF NOT EXISTS server_mappings ( 44 | id INTEGER PRIMARY KEY AUTOINCREMENT, 45 | user_id TEXT NOT NULL, 46 | server_url TEXT NOT NULL, 47 | mapped_username TEXT NOT NULL, 48 | mapped_password TEXT NOT NULL, 49 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 50 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 51 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, 52 | UNIQUE(user_id, server_url) 53 | ); 54 | 55 | 56 | -- Authorization sessions table 57 | CREATE TABLE IF NOT EXISTS authorization_sessions ( 58 | id INTEGER PRIMARY KEY AUTOINCREMENT, 59 | user_id TEXT NOT NULL, 60 | mapping_id INTEGER NOT NULL, 61 | server_url TEXT NOT NULL, 62 | client TEXT NOT NULL, 63 | device TEXT NOT NULL, 64 | device_id TEXT NOT NULL, 65 | version TEXT NOT NULL, 66 | jellyfin_token TEXT, 67 | original_user_id TEXT NOT NULL, 68 | expires_at TIMESTAMP, 69 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 70 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 71 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, 72 | FOREIGN KEY (mapping_id) REFERENCES server_mappings (id) ON DELETE CASCADE, 73 | UNIQUE(user_id, mapping_id, device_id) 74 | ); 75 | 76 | 77 | CREATE INDEX IF NOT EXISTS idx_authorization_sessions_mapping 78 | ON authorization_sessions(mapping_id); 79 | 80 | CREATE INDEX IF NOT EXISTS idx_users_virtual_key 81 | ON users(virtual_key); 82 | 83 | CREATE INDEX IF NOT EXISTS idx_server_mappings_user_server 84 | ON server_mappings(user_id, server_url); 85 | 86 | CREATE INDEX IF NOT EXISTS idx_authorization_sessions_user_server 87 | ON authorization_sessions(user_id, server_url); -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/processors/request_processor.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use serde_json::Value; 3 | use tracing::{debug, info}; 4 | 5 | use crate::processors::field_matcher::{ID_FIELDS, SESSION_FIELDS, USER_FIELDS}; 6 | use crate::processors::json_processor::{ 7 | JsonProcessingContext, JsonProcessingResult, JsonProcessor, 8 | }; 9 | use crate::request_preprocessing::{JellyfinAuthorization, PreprocessedRequest}; 10 | use crate::server_storage::Server; 11 | use crate::user_authorization_service::{AuthorizationSession, User}; 12 | use crate::DataContext; 13 | 14 | pub struct RequestProcessor { 15 | pub data_context: DataContext, 16 | } 17 | 18 | impl RequestProcessor { 19 | pub fn new(data_context: DataContext) -> Self { 20 | Self { data_context } 21 | } 22 | } 23 | 24 | #[allow(dead_code)] 25 | pub struct RequestProcessingContext { 26 | pub user: Option, 27 | pub server: Server, 28 | pub sessions: Option>, 29 | pub auth: Option, 30 | pub session: Option, 31 | pub new_auth: Option, 32 | } 33 | 34 | impl RequestProcessingContext { 35 | pub fn new(preprocessed_request: &PreprocessedRequest) -> Self { 36 | Self { 37 | user: preprocessed_request.user.clone(), 38 | server: preprocessed_request.server.clone(), 39 | sessions: preprocessed_request.sessions.clone(), 40 | auth: preprocessed_request.auth.clone(), 41 | session: preprocessed_request.session.clone(), 42 | new_auth: preprocessed_request.new_auth.clone(), 43 | } 44 | } 45 | } 46 | 47 | #[async_trait] 48 | impl JsonProcessor for RequestProcessor { 49 | async fn process( 50 | &self, 51 | json_context: &JsonProcessingContext, 52 | value: &mut Value, 53 | context: &RequestProcessingContext, 54 | ) -> JsonProcessingResult { 55 | let mut result = JsonProcessingResult::new(); 56 | // Check if this is an ID field (case-insensitive) 57 | if ID_FIELDS.contains(&json_context.key) { 58 | if let Value::String(ref virtual_id) = value { 59 | if let Some(media_mapping) = self 60 | .data_context 61 | .media_storage 62 | .get_media_mapping_by_virtual(virtual_id) 63 | .await 64 | .unwrap_or_default() 65 | { 66 | debug!( 67 | "Replacing virtual id {} -> {} for field: {} in payload", 68 | virtual_id, &media_mapping.original_media_id, &json_context.key 69 | ); 70 | *value = Value::String(media_mapping.original_media_id); 71 | result = result.mark_modified(); 72 | } 73 | // For r equests, we need to convert virtual IDs back to real IDs 74 | } 75 | } 76 | // Handle session IDs that might need transformation 77 | else if SESSION_FIELDS.contains(&json_context.key) { 78 | // For requests, session IDs typically stay as-is 79 | } 80 | // Handle user IDs 81 | else if USER_FIELDS.contains(&json_context.key) { 82 | if let Value::String(ref virtual_id) = value { 83 | // For requests, we need to convert virtual IDs back to real IDs 84 | if let Some(session) = &context.session { 85 | info!( 86 | "Replacing User ID {} -> {} for field: {} in payload", 87 | virtual_id, &session.original_user_id, &json_context.key 88 | ); 89 | *value = Value::String(session.original_user_id.clone()); 90 | } 91 | } 92 | } 93 | // Handle any other request-specific transformations 94 | else { 95 | // Handle any other request-specific transformations 96 | } 97 | 98 | result 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/models/tests/files/livetv_playback_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaSources": [ 3 | { 4 | "Protocol": "Http", 5 | "Id": "f821dce0fed67c9f4f898c8c786a364d", 6 | "Path": "https://58de7a369a9c4.streamlock.net/abgtv/abgtv_1080p/playlist.m3u8", 7 | "Type": "Default", 8 | "Container": "hls", 9 | "Size": 140, 10 | "IsRemote": true, 11 | "ReadAtNativeFramerate": false, 12 | "IgnoreDts": false, 13 | "IgnoreIndex": false, 14 | "GenPtsInput": false, 15 | "SupportsTranscoding": true, 16 | "SupportsDirectStream": false, 17 | "SupportsDirectPlay": false, 18 | "IsInfiniteStream": true, 19 | "UseMostCompatibleTranscodingProfile": false, 20 | "RequiresOpening": true, 21 | "RequiresClosing": true, 22 | "LiveStreamId": "e2329f4997b378e64ccf8fa396deb76e_af999c25a00715699361240d4c6c7a53_f821dce0fed67c9f4f898c8c786a364d", 23 | "RequiresLooping": false, 24 | "SupportsProbing": true, 25 | "MediaStreams": [ 26 | { 27 | "Codec": "h264", 28 | "TimeBase": "1/90000", 29 | "VideoRange": "SDR", 30 | "VideoRangeType": "SDR", 31 | "AudioSpatialFormat": "None", 32 | "DisplayTitle": "1080p H264 SDR", 33 | "NalLengthSize": "0", 34 | "IsInterlaced": false, 35 | "BitRate": 20000000, 36 | "BitDepth": 8, 37 | "RefFrames": 1, 38 | "IsDefault": false, 39 | "IsForced": false, 40 | "IsHearingImpaired": false, 41 | "Height": 1080, 42 | "Width": 1920, 43 | "AverageFrameRate": 25, 44 | "RealFrameRate": 25, 45 | "ReferenceFrameRate": 25, 46 | "Profile": "High", 47 | "Type": "Video", 48 | "AspectRatio": "16:9", 49 | "Index": -1, 50 | "IsExternal": false, 51 | "IsTextSubtitleStream": false, 52 | "SupportsExternalStream": false, 53 | "PixelFormat": "yuv420p", 54 | "Level": 41, 55 | "IsAnamorphic": false 56 | }, 57 | { 58 | "Codec": "aac", 59 | "TimeBase": "1/90000", 60 | "VideoRange": "Unknown", 61 | "VideoRangeType": "Unknown", 62 | "AudioSpatialFormat": "None", 63 | "LocalizedDefault": "Default", 64 | "LocalizedExternal": "External", 65 | "DisplayTitle": "AAC - Stereo", 66 | "IsInterlaced": false, 67 | "IsAVC": false, 68 | "ChannelLayout": "stereo", 69 | "BitRate": 192000, 70 | "Channels": 2, 71 | "SampleRate": 48000, 72 | "IsDefault": false, 73 | "IsForced": false, 74 | "IsHearingImpaired": false, 75 | "Profile": "LC", 76 | "Type": "Audio", 77 | "Index": -1, 78 | "IsExternal": false, 79 | "IsTextSubtitleStream": false, 80 | "SupportsExternalStream": false, 81 | "Level": 0 82 | } 83 | ], 84 | "MediaAttachments": [], 85 | "Formats": [], 86 | "Bitrate": 20192000, 87 | "FallbackMaxStreamingBitrate": 30000000, 88 | "RequiredHttpHeaders": { 89 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" 90 | }, 91 | "TranscodingUrl": "/videos/c6da25b8-cf44-e572-7518-edf61b4a7de9/master.m3u8?DeviceId=TW96aWxsYS81LjAgKFgxMTsgTGludXggeDg2XzY0OyBydjoxMzkuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC8xMzkuMHwxNzQ5NDg2MTQ3NDk3&MediaSourceId=f821dce0fed67c9f4f898c8c786a364d&VideoCodec=av1,h264,vp9&AudioCodec=aac&AudioStreamIndex=-1&VideoBitrate=652255289&AudioBitrate=192000&AudioSampleRate=48000&MaxFramerate=25&PlaySessionId=680d095d98b64cbe879924c4419eb014&api_key=a00437f35385447192301439720207ba&LiveStreamId=e2329f4997b378e64ccf8fa396deb76e_af999c25a00715699361240d4c6c7a53_f821dce0fed67c9f4f898c8c786a364d&TranscodingMaxAudioChannels=2&RequireAvc=false&EnableAudioVbrEncoding=true&SegmentContainer=mp4&MinSegments=1&BreakOnNonKeyFrames=True&h264-level=41&h264-videobitdepth=8&h264-profile=high&h264-audiochannels=2&aac-profile=lc&av1-profile=main&av1-rangetype=SDR&av1-level=19&vp9-rangetype=SDR&h264-rangetype=SDR&h264-deinterlace=true&TranscodeReasons=DirectPlayError", 92 | "TranscodingSubProtocol": "hls", 93 | "TranscodingContainer": "mp4", 94 | "AnalyzeDurationMs": 3000, 95 | "DefaultAudioStreamIndex": -1, 96 | "HasSegments": false 97 | } 98 | ], 99 | "PlaySessionId": "680d095d98b64cbe879924c4419eb014" 100 | } -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/url_helper.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | use uuid::Uuid; 3 | 4 | pub fn is_id_like(segment: &str) -> bool { 5 | Uuid::parse_str(segment).is_ok() 6 | } 7 | 8 | /// Joins a server URL with a request path, preserving any subdirectories in the server URL 9 | /// 10 | /// # Examples 11 | /// 12 | /// ``` 13 | /// use url::Url; 14 | /// let server_url = Url::parse("http://server.com/jellyfin").unwrap(); 15 | /// let request_path = "/Users/123"; 16 | /// let result = join_server_url(&server_url, request_path); 17 | /// assert_eq!(result.as_str(), "http://server.com/jellyfin/Users/123"); 18 | /// ``` 19 | pub fn join_server_url(server_url: &Url, request_path: &str) -> Url { 20 | let mut new_url = server_url.clone(); 21 | let server_path = new_url.path().trim_end_matches('/'); 22 | let combined_path = if server_path.is_empty() { 23 | request_path.to_string() 24 | } else { 25 | format!("{}{}", server_path, request_path) 26 | }; 27 | new_url.set_path(&combined_path); 28 | new_url 29 | } 30 | 31 | pub fn contains_id(url: &Url, name: &str) -> Option { 32 | let segments: Vec<&str> = match url.path_segments() { 33 | Some(segments) => segments.collect(), 34 | None => Vec::new(), 35 | }; 36 | 37 | let mut i = 0; 38 | 39 | while i < segments.len() { 40 | if i + 1 < segments.len() { 41 | let current = segments[i]; 42 | let next = segments[i + 1]; 43 | 44 | if current.eq_ignore_ascii_case(name) && is_id_like(next) { 45 | return Some(next.to_string()); 46 | } 47 | } 48 | i += 1; 49 | } 50 | None 51 | } 52 | 53 | pub fn replace_id(url: Url, original: &str, replacement: &str) -> Url { 54 | let mut url = url.clone(); 55 | let path = url.path(); 56 | url.set_path(&path.replace(original, replacement)); 57 | url 58 | } 59 | 60 | #[cfg(test)] 61 | mod tests { 62 | use super::*; 63 | 64 | #[test] 65 | fn test_join_server_url() { 66 | // Test with server having subdirectory 67 | let server_url = Url::parse("http://server.com/jellyfin").unwrap(); 68 | let result = join_server_url(&server_url, "/Users/123"); 69 | assert_eq!(result.as_str(), "http://server.com/jellyfin/Users/123"); 70 | 71 | // Test with server at root 72 | let server_url = Url::parse("http://server.com").unwrap(); 73 | let result = join_server_url(&server_url, "/Users/123"); 74 | assert_eq!(result.as_str(), "http://server.com/Users/123"); 75 | 76 | // Test with server having trailing slash 77 | let server_url = Url::parse("http://server.com/jellyfin/").unwrap(); 78 | let result = join_server_url(&server_url, "/Users/123"); 79 | assert_eq!(result.as_str(), "http://server.com/jellyfin/Users/123"); 80 | } 81 | 82 | #[test] 83 | fn test_is_id_like() { 84 | assert!(is_id_like("0123456789abcdef0123456789abcdef")); 85 | assert!(is_id_like("c3256b7a-96f3-4772-b7d5-cacb090bbb02")); // with dashes 86 | assert!(!is_id_like("0123456789abcdef0123456789abcde")); // 31 chars 87 | assert!(!is_id_like("g123456789abcdef0123456789abcdef")); // non-hex 88 | } 89 | 90 | #[test] 91 | fn test_contains_id_found() { 92 | let url = 93 | Url::parse("https://example.com/foo/0123456789abcdef0123456789abcdef/bar").unwrap(); 94 | assert_eq!( 95 | contains_id(&url, "foo"), 96 | Some("0123456789abcdef0123456789abcdef".to_string()) 97 | ); 98 | } 99 | 100 | #[test] 101 | fn test_contains_id_not_found() { 102 | let url = Url::parse("https://example.com/foo/bar").unwrap(); 103 | assert_eq!(contains_id(&url, "foo"), None); 104 | } 105 | 106 | #[test] 107 | fn test_replace_id() { 108 | let url = 109 | Url::parse("https://example.com/foo/0123456789abcdef0123456789abcdef/bar").unwrap(); 110 | let replaced = replace_id( 111 | url, 112 | "0123456789abcdef0123456789abcdef", 113 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 114 | ); 115 | assert_eq!(replaced.path(), "/foo/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/bar"); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | content-downloader: 3 | image: ghcr.io/astral-sh/uv:python3.11-alpine 4 | volumes: 5 | - ./data/media:/downloads 6 | - ./scripts/download-content.py:/scripts/download-content.py:ro 7 | command: ["uv", "run", "/scripts/download-content.py"] 8 | tty: true 9 | 10 | jellyfin-movies: 11 | image: jellyfin/jellyfin:latest 12 | container_name: jellyfin-movies 13 | environment: 14 | - PUID=1000 15 | - PGID=1000 16 | - TZ=UTC 17 | - JELLYFIN_PublishedServerUrl=http://localhost:8096 18 | volumes: 19 | - ./data/jellyfin-movies/config:/config 20 | - ./data/jellyfin-movies/cache:/cache 21 | - ./data/media:/media:ro 22 | ports: 23 | - "8096:8096" 24 | restart: unless-stopped 25 | networks: 26 | - jellyfin-dev-net 27 | depends_on: 28 | content-downloader: 29 | condition: service_completed_successfully 30 | 31 | jellyfin-movies-init: 32 | image: ghcr.io/astral-sh/uv:python3.11-alpine 33 | container_name: jellyfin-movies-init 34 | volumes: 35 | - ./scripts/init-jellyfin.py:/scripts/init-jellyfin.py:ro 36 | working_dir: /scripts 37 | command: ["uv", "run", "/scripts/init-jellyfin.py"] 38 | environment: 39 | - URL=http://jellyfin-movies:8096 40 | - USERNAME=user 41 | - PASSWORD=movies 42 | - COLLECTION_NAME=Movies 43 | - COLLECTION_PATH=/media/movies 44 | - COLLECTION_TYPE=movies 45 | 46 | networks: 47 | - jellyfin-dev-net 48 | depends_on: 49 | jellyfin-movies: 50 | condition: service_started 51 | 52 | jellyfin-tvshows: 53 | image: jellyfin/jellyfin:latest 54 | container_name: jellyfin-tvshows 55 | environment: 56 | - PUID=1000 57 | - PGID=1000 58 | - TZ=UTC 59 | - JELLYFIN_PublishedServerUrl=http://localhost:8097 60 | volumes: 61 | - ./data/jellyfin-tvshows/config:/config 62 | - ./data/jellyfin-tvshows/cache:/cache 63 | - ./data/media:/media:ro 64 | ports: 65 | - "8097:8096" 66 | restart: unless-stopped 67 | networks: 68 | - jellyfin-dev-net 69 | depends_on: 70 | content-downloader: 71 | condition: service_completed_successfully 72 | 73 | jellyfin-tvshows-init: 74 | image: ghcr.io/astral-sh/uv:python3.11-alpine 75 | container_name: jellyfin-tvshows-init 76 | volumes: 77 | - ./scripts/init-jellyfin.py:/scripts/init-jellyfin.py:ro 78 | working_dir: /scripts 79 | command: ["uv", "run", "/scripts/init-jellyfin.py"] 80 | environment: 81 | - URL=http://jellyfin-tvshows:8096 82 | - USERNAME=user 83 | - PASSWORD=shows 84 | - COLLECTION_NAME=Shows 85 | - COLLECTION_PATH=/media/tv-shows 86 | - COLLECTION_TYPE=tvshows 87 | networks: 88 | - jellyfin-dev-net 89 | depends_on: 90 | jellyfin-tvshows: 91 | condition: service_started 92 | 93 | jellyfin-music: 94 | image: jellyfin/jellyfin:latest 95 | container_name: jellyfin-music 96 | environment: 97 | - PUID=1000 98 | - PGID=1000 99 | - TZ=UTC 100 | - JELLYFIN_PublishedServerUrl=http://localhost:8098 101 | volumes: 102 | - ./data/jellyfin-music/config:/config 103 | - ./data/jellyfin-music/cache:/cache 104 | - ./data/media:/media:ro 105 | ports: 106 | - "8098:8096" 107 | restart: unless-stopped 108 | networks: 109 | - jellyfin-dev-net 110 | depends_on: 111 | content-downloader: 112 | condition: service_completed_successfully 113 | 114 | jellyfin-music-init: 115 | image: ghcr.io/astral-sh/uv:python3.11-alpine 116 | container_name: jellyfin-music-init 117 | volumes: 118 | - ./scripts/init-jellyfin.py:/scripts/init-jellyfin.py:ro 119 | working_dir: /scripts 120 | command: ["uv", "run", "/scripts/init-jellyfin.py"] 121 | environment: 122 | - URL=http://jellyfin-music:8096 123 | - USERNAME=user 124 | - PASSWORD=music 125 | - COLLECTION_NAME=Music 126 | - COLLECTION_PATH=/media/music 127 | - COLLECTION_TYPE=music 128 | networks: 129 | - jellyfin-dev-net 130 | depends_on: 131 | jellyfin-music: 132 | condition: service_started 133 | 134 | networks: 135 | jellyfin-dev-net: 136 | driver: bridge -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/build.rs: -------------------------------------------------------------------------------- 1 | use std::{env, fs, io::Write, path::PathBuf, process::Command}; 2 | 3 | fn main() { 4 | println!("cargo:rerun-if-changed=migrations"); 5 | 6 | if std::env::var("JELLYSWARRM_SKIP_UI").ok().as_deref() == Some("1") { 7 | println!("cargo:warning=Skipping internal UI build (JELLYSWARRM_SKIP_UI=1)"); 8 | return; 9 | } 10 | // Get the path to the crate's directory 11 | let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); 12 | 13 | // Assume workspace root is two levels up from this crate (adjust if needed) 14 | let workspace_root = manifest_dir.parent().unwrap().parent().unwrap(); 15 | 16 | let ui_dir = workspace_root.join("ui"); 17 | let dist_dir = manifest_dir.join("static"); // static/ in the crate 18 | 19 | // Get the latest commit hash for the ui submodule 20 | let output = Command::new("git") 21 | .args(["rev-parse", "HEAD"]) 22 | .current_dir(&ui_dir) 23 | .output() 24 | .expect("Failed to get git commit hash for ui submodule"); 25 | let current_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); 26 | 27 | // Read the last built commit hash 28 | let hash_file = workspace_root.join(".last_build_commit"); 29 | let last_hash = fs::read_to_string(&hash_file).unwrap_or_default(); 30 | 31 | if last_hash != current_hash { 32 | println!("Building UI: new commit detected."); 33 | 34 | // Install/update npm dependencies 35 | println!("Installing npm dependencies..."); 36 | let install_status = Command::new("npm") 37 | .args(["install", "--engine-strict=false"]) 38 | .current_dir(&ui_dir) 39 | .status() 40 | .expect("Failed to run npm install"); 41 | assert!(install_status.success(), "npm install failed"); 42 | 43 | // Build the UI 44 | let status = Command::new("npm") 45 | .args(["run", "build:production"]) 46 | .current_dir(&ui_dir) 47 | .status() 48 | .expect("Failed to run npm build"); 49 | assert!(status.success(), "UI build failed"); 50 | 51 | // Copy dist/* to static/ 52 | let src = ui_dir.join("dist"); 53 | let dst = &dist_dir; 54 | 55 | if dst.exists() { 56 | fs::remove_dir_all(dst).expect("Failed to remove old static dir"); 57 | } 58 | fs_extra::dir::copy( 59 | &src, 60 | dst, 61 | &fs_extra::dir::CopyOptions::new().content_only(true), 62 | ) 63 | .expect("Failed to copy dist to static"); 64 | 65 | // Save the new commit hash 66 | let mut file = fs::File::create(&hash_file).expect("Failed to write hash file"); 67 | file.write_all(current_hash.as_bytes()) 68 | .expect("Failed to write hash"); 69 | 70 | // Generate UI version file for runtime access 71 | generate_ui_version_file(workspace_root); 72 | } else { 73 | println!("cargo:warning=UI unchanged, skipping build"); 74 | } 75 | } 76 | 77 | fn generate_ui_version_file(workspace_root: &std::path::Path) { 78 | let ui_dir = workspace_root.join("ui"); 79 | 80 | // Get UI version 81 | let version_output = Command::new("git") 82 | .args(["-C", ui_dir.to_str().unwrap(), "describe", "--tags"]) 83 | .output() 84 | .expect("Failed to get UI version"); 85 | let ui_version = String::from_utf8_lossy(&version_output.stdout) 86 | .trim() 87 | .to_string(); 88 | 89 | // Get UI commit hash 90 | let commit_output = Command::new("git") 91 | .args(["-C", ui_dir.to_str().unwrap(), "rev-parse", "HEAD"]) 92 | .output() 93 | .expect("Failed to get UI commit hash"); 94 | let ui_commit = String::from_utf8_lossy(&commit_output.stdout) 95 | .trim() 96 | .to_string(); 97 | 98 | // Write version file 99 | let version_content = format!("UI_VERSION={}\nUI_COMMIT={}\n", ui_version, ui_commit); 100 | 101 | let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); 102 | let dist_dir = manifest_dir.join("static"); 103 | let version_file_in_dist = dist_dir.join("ui-version.env"); 104 | fs::write(&version_file_in_dist, version_content) 105 | .expect("Failed to write ui-version.env in static/"); 106 | 107 | println!( 108 | "Generated ui-version.env with UI_VERSION={} UI_COMMIT={}", 109 | ui_version, ui_commit 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/processors/request_analyzer.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use async_trait::async_trait; 3 | use serde_json::Value; 4 | 5 | use crate::{ 6 | processors::{ 7 | field_matcher::{ID_FIELDS, SESSION_FIELDS, USER_FIELDS}, 8 | json_processor::{JsonAnalyzer, JsonProcessingContext}, 9 | }, 10 | server_storage::Server, 11 | user_authorization_service::User, 12 | DataContext, 13 | }; 14 | 15 | pub struct RequestAnalyzer { 16 | pub data_context: DataContext, 17 | } 18 | 19 | impl RequestAnalyzer { 20 | pub fn new(data_context: DataContext) -> Self { 21 | Self { data_context } 22 | } 23 | } 24 | 25 | #[derive(Debug, Default)] 26 | pub struct RequestBodyAnalysisResult { 27 | pub found_ids: Vec, 28 | pub found_session_ids: Vec, 29 | pub found_user_ids: Vec, 30 | pub servers: Vec, 31 | pub users: Vec, 32 | } 33 | 34 | impl RequestBodyAnalysisResult { 35 | /// Returns the server with the highest occurance in the servers vector, or None if the vector is empty. 36 | pub fn get_server(&self) -> Option { 37 | if self.servers.is_empty() { 38 | return None; 39 | } 40 | let mut server_count = std::collections::HashMap::new(); 41 | for server in &self.servers { 42 | *server_count.entry(server).or_insert(0) += 1; 43 | } 44 | let (most_common_server, _) = server_count 45 | .into_iter() 46 | .max_by_key(|&(_, count)| count) 47 | .unwrap(); 48 | Some(most_common_server.clone()) 49 | } 50 | 51 | /// Returns the user with the highest occurance in the users vector, or None if the vector is empty. 52 | pub fn get_user(&self) -> Option { 53 | if self.users.is_empty() { 54 | return None; 55 | } 56 | let mut user_count = std::collections::HashMap::new(); 57 | for user in &self.users { 58 | *user_count.entry(user).or_insert(0) += 1; 59 | } 60 | let (most_common_user, _) = user_count 61 | .into_iter() 62 | .max_by_key(|&(_, count)| count) 63 | .unwrap(); 64 | Some(most_common_user.clone()) 65 | } 66 | } 67 | 68 | pub struct RequestAnalysisContext; 69 | 70 | #[async_trait] 71 | impl JsonAnalyzer for RequestAnalyzer { 72 | async fn analyze( 73 | &self, 74 | json_context: &JsonProcessingContext, 75 | value: &Value, 76 | _context: &RequestAnalysisContext, 77 | accumulator: &mut RequestBodyAnalysisResult, 78 | ) -> Result>> { 79 | // Check if this is an ID field (case-insensitive) 80 | if ID_FIELDS.contains(&json_context.key) { 81 | if let serde_json::Value::String(ref virtual_id) = value { 82 | if let Some((_, server)) = self 83 | .data_context 84 | .media_storage 85 | .get_media_mapping_with_server(virtual_id) 86 | .await? 87 | { 88 | accumulator.servers.push(server); 89 | } 90 | accumulator.found_ids.push(virtual_id.clone()); 91 | } 92 | } 93 | 94 | // Check if this is a SessionId field (case-insensitive) 95 | if SESSION_FIELDS.contains(&json_context.key) { 96 | if let serde_json::Value::String(ref session_id) = value { 97 | if let Some(play_session) = self 98 | .data_context 99 | .play_sessions 100 | .get_session(session_id) 101 | .await 102 | { 103 | accumulator.servers.push(play_session.server); 104 | } 105 | accumulator.found_session_ids.push(session_id.clone()); 106 | } 107 | } 108 | 109 | // Check if this is a UserId field (case-insensitive) 110 | if USER_FIELDS.contains(&json_context.key) { 111 | if let serde_json::Value::String(ref user_id) = value { 112 | if let Some(user) = self 113 | .data_context 114 | .user_authorization 115 | .get_user_by_id(user_id) 116 | .await? 117 | { 118 | accumulator.users.push(user); 119 | } 120 | accumulator.found_user_ids.push(user_id.clone()); 121 | } 122 | } 123 | Ok(None) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /dev/scripts/init-jellyfin.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.12" 3 | # dependencies = [ 4 | # "httpx", 5 | # "jellyfin-apiclient-python", 6 | # ] 7 | # /// 8 | 9 | import httpx 10 | import os 11 | from jellyfin_apiclient_python import JellyfinClient 12 | import time 13 | 14 | AUTHORIZATION_HEADER = 'MediaBrowser Client="Jellyfin Web", Device="Firefox", DeviceId="TW96aWxsYS81LjAgKFgxMTsgTGludXggeDg2XzY0OyBydjoxNDIuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC8xNDIuMHwxNzU4NDQ4NDAzOTk5", Version="10.10.7"' 15 | AUTHORIZATION = {"Authorization": AUTHORIZATION_HEADER} 16 | 17 | SERVER_URL = os.environ.get("URL", "http://localhost:8096") 18 | ADMIN_PASSWORD = "password" 19 | ADMIN_USER = "admin" 20 | 21 | USERNAME = os.environ.get("USERNAME","user") 22 | PASSWORD = os.environ.get("PASSWORD","password") 23 | 24 | COLLECTION_NAME = os.environ.get("COLLECTION_NAME", "Movies") 25 | COLLECTION_PATH = os.environ.get("COLLECTION_PATH","/media/movies") 26 | COLLECTION_TYPE = os.environ.get("COLLECTION_TYPE", "movies") 27 | 28 | def initialize_server(): 29 | 30 | with httpx.Client(headers=AUTHORIZATION, base_url=SERVER_URL) as client: 31 | 32 | # Retry logic for getting system info until we get a response 33 | max_retries = 10 34 | retry_delay = 5 # seconds 35 | for attempt in range(max_retries): 36 | try: 37 | info = client.get("/System/Info/Public").json() 38 | break # Success, exit loop 39 | except Exception as e: 40 | print(f"Attempt {attempt + 1} failed: {e}") 41 | if attempt < max_retries - 1: 42 | time.sleep(retry_delay) 43 | else: 44 | raise # Re-raise after max retries 45 | 46 | if info and info.get("Version"): 47 | print(f"ℹ️ Jellyfin version: {info['Version']}") 48 | if info.get("StartupWizardCompleted"): 49 | print("ℹ️ Setup wizard already completed, skipping initialization") 50 | return 51 | 52 | default_user = client.get("/Startup/User") 53 | default_user.raise_for_status() 54 | print("✅ Retrieved default user: ", default_user.json()) 55 | 56 | client.post("/Startup/User", json={"Name": ADMIN_USER,"Password": ADMIN_PASSWORD}).raise_for_status() 57 | print(f"✅ Created user '{ADMIN_USER}' with password '{ADMIN_PASSWORD}'") 58 | client.post("/Startup/Configuration", json={"UICulture": "en-US","MetadataCountryCode": "US","PreferredMetadataLanguage": "en"}).raise_for_status() 59 | print("✅ Configured server settings") 60 | client.post("/Startup/RemoteAccess", json={"EnableRemoteAccess": True,"EnableAutomaticPortMapping": True}).raise_for_status() 61 | print("✅ Enabled remote access and automatic port mapping") 62 | client.post("/Startup/Complete").raise_for_status() 63 | print("✅ Completed setup wizard") 64 | 65 | 66 | def create_users(client: JellyfinClient): 67 | try: 68 | users = client.jellyfin.get_users() 69 | for user in users: 70 | if user['Name'] == USERNAME: 71 | print(f"User '{USERNAME}' already exists, skipping creation") 72 | return 73 | client.jellyfin.new_user(name=USERNAME, pw=PASSWORD) 74 | print(f"✅ Created user '{USERNAME}' with password '{PASSWORD}'") 75 | except Exception as e: 76 | print(f"Failed to create user '{USERNAME}'. It might already exist. Error: {e}") 77 | 78 | def create_library(client: JellyfinClient): 79 | try: 80 | folders = client.jellyfin.get_media_folders() 81 | for folder in folders['Items']: 82 | if folder['Name'] == COLLECTION_NAME: 83 | print(f"Library '{COLLECTION_NAME}' already exists, skipping creation") 84 | return 85 | 86 | client.jellyfin.add_media_library( 87 | name=COLLECTION_NAME, 88 | collectionType=COLLECTION_TYPE, 89 | paths=[COLLECTION_PATH], 90 | ) 91 | print(f"✅ Created library '{COLLECTION_NAME}'") 92 | except Exception as e: 93 | print(f"❌ Failed to create library: {e}") 94 | 95 | client.jellyfin.refresh_library() 96 | 97 | 98 | if __name__ == "__main__": 99 | initialize_server() 100 | client = JellyfinClient() 101 | client.config.app('auto-init', '0.0.1', 'foo', 'bar') 102 | client.config.data["auth.ssl"] = False 103 | client.auth.connect_to_address(SERVER_URL) 104 | user = client.auth.login(SERVER_URL, username=ADMIN_USER, password=ADMIN_PASSWORD) 105 | print(f"✅ Authenticated as '{user['User']['Name']}'") 106 | create_users(client) 107 | create_library(client) 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ################################# 2 | # Stage 1: Build UI (Node.js optimized) 3 | ################################# 4 | FROM node:20-alpine AS ui-build 5 | 6 | # Install git for version detection 7 | RUN apk add --no-cache git 8 | 9 | WORKDIR /app/ui 10 | 11 | # Copy package files for dependency caching 12 | COPY ui/package.json ui/package-lock.json* ./ 13 | 14 | # Install all dependencies (including dev deps needed for build) 15 | RUN --mount=type=cache,target=/root/.npm \ 16 | npm install --engine-strict=false --ignore-scripts 17 | 18 | # Copy UI source code and git metadata 19 | COPY ui/ ./ 20 | COPY .git/modules/ui/ /app/.git/modules/ui/ 21 | 22 | # Get and print UI version info 23 | RUN UI_VERSION=$(git describe --tags) && \ 24 | UI_COMMIT=$(git rev-parse HEAD) && \ 25 | echo "UI_VERSION=$UI_VERSION" && \ 26 | echo "UI_COMMIT=$UI_COMMIT" 27 | 28 | # Build production UI bundle 29 | RUN npm run build:production 30 | 31 | # Write ui-version.env file 32 | RUN UI_VERSION=$(git describe --tags) && \ 33 | UI_COMMIT=$(git rev-parse HEAD) && \ 34 | printf "UI_VERSION=%s\nUI_COMMIT=%s\n" "$UI_VERSION" "$UI_COMMIT" > dist/ui-version.env && \ 35 | echo "Generated dist/ui-version.env" 36 | 37 | 38 | 39 | ################################# 40 | # Stage 2: Rust Dependencies Cache 41 | ################################# 42 | FROM rust:1-alpine AS rust-deps 43 | 44 | WORKDIR /app 45 | 46 | # Install build dependencies 47 | RUN apk add --no-cache \ 48 | build-base \ 49 | pkgconf \ 50 | sqlite-dev \ 51 | openssl-dev 52 | 53 | # Copy Cargo manifests for dependency caching 54 | COPY .cargo .cargo 55 | COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ 56 | COPY crates/jellyswarrm-proxy/Cargo.toml crates/jellyswarrm-proxy/Cargo.toml 57 | COPY crates/jellyswarrm-macros/Cargo.toml crates/jellyswarrm-macros/Cargo.toml 58 | COPY crates/jellyfin-api/Cargo.toml crates/jellyfin-api/Cargo.toml 59 | 60 | # Create dummy source files to build dependencies 61 | RUN mkdir -p crates/jellyswarrm-proxy/src crates/jellyswarrm-macros/src crates/jellyfin-api/src \ 62 | && echo "fn main() {}" > crates/jellyswarrm-proxy/src/main.rs \ 63 | && echo "" > crates/jellyswarrm-proxy/src/lib.rs \ 64 | && echo "use proc_macro::TokenStream; #[proc_macro_attribute] pub fn multi_case_struct(_args: TokenStream, input: TokenStream) -> TokenStream { input }" > crates/jellyswarrm-macros/src/lib.rs \ 65 | && echo "" > crates/jellyfin-api/src/lib.rs 66 | 67 | # Build dependencies only (will be cached) 68 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 69 | --mount=type=cache,target=/usr/local/cargo/git \ 70 | --mount=type=cache,target=/tmp/target,sharing=locked \ 71 | CARGO_TARGET_DIR=/tmp/target cargo build --release --bin jellyswarrm-proxy \ 72 | && cp /tmp/target/release/jellyswarrm-proxy /app/jellyswarrm-proxy-deps \ 73 | && rm -rf crates/jellyswarrm-proxy/src crates/jellyswarrm-macros/src crates/jellyfin-api/src 74 | 75 | ################################# 76 | # Stage 3: Build Rust Application 77 | ################################# 78 | FROM rust-deps AS rust-build 79 | 80 | # Set env var so build.rs skips internal UI build (we already did it) 81 | ENV JELLYSWARRM_SKIP_UI=1 82 | 83 | # Copy UI build artifacts from stage 1 84 | COPY --from=ui-build /app/ui/dist crates/jellyswarrm-proxy/static/ 85 | 86 | # Copy Rust source code and configuration 87 | COPY crates/jellyswarrm-proxy/askama.toml crates/jellyswarrm-proxy/askama.toml 88 | COPY crates/jellyswarrm-proxy/src crates/jellyswarrm-proxy/src 89 | COPY crates/jellyswarrm-proxy/migrations crates/jellyswarrm-proxy/migrations 90 | COPY crates/jellyswarrm-macros/src crates/jellyswarrm-macros/src 91 | COPY crates/jellyfin-api/src crates/jellyfin-api/src 92 | 93 | # Build only the application code (dependencies already cached) 94 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 95 | --mount=type=cache,target=/usr/local/cargo/git \ 96 | --mount=type=cache,target=/tmp/target,sharing=locked \ 97 | rm -rf /tmp/target/release/deps/libjellyswarrm_macros* /tmp/target/release/deps/jellyswarrm_macros* \ 98 | && rm -rf /tmp/target/release/deps/libjellyfin_api* \ 99 | && touch crates/jellyswarrm-macros/src/lib.rs \ 100 | && touch crates/jellyfin-api/src/lib.rs \ 101 | && CARGO_TARGET_DIR=/tmp/target cargo build --release --bin jellyswarrm-proxy \ 102 | && cp /tmp/target/release/jellyswarrm-proxy /app/jellyswarrm-proxy 103 | 104 | ################################# 105 | # Stage 4: Runtime Image (Alpine) 106 | ################################# 107 | FROM alpine:3.20 AS runtime 108 | 109 | WORKDIR /app 110 | 111 | ENV RUST_LOG=info 112 | 113 | # Install minimal runtime dependencies 114 | RUN apk add --no-cache \ 115 | ca-certificates \ 116 | sqlite-libs \ 117 | && update-ca-certificates 118 | 119 | # Copy the compiled binary 120 | COPY --from=rust-build /app/jellyswarrm-proxy /app/jellyswarrm-proxy 121 | 122 | EXPOSE 3000 123 | 124 | ENTRYPOINT ["/app/jellyswarrm-proxy"] 125 | 126 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/handlers/videos.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::{Request, State}; 2 | use hyper::StatusCode; 3 | use regex::Regex; 4 | use tracing::{error, info}; 5 | 6 | use crate::{request_preprocessing::preprocess_request, url_helper::join_server_url, AppState}; 7 | 8 | //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 9 | pub async fn get_stream_part( 10 | State(state): State, 11 | req: Request, 12 | ) -> Result { 13 | let preprocessed = preprocess_request(req, &state).await.map_err(|e| { 14 | error!("Failed to preprocess request: {}", e); 15 | StatusCode::BAD_REQUEST 16 | })?; 17 | 18 | let original_request = preprocessed 19 | .original_request 20 | .ok_or(StatusCode::BAD_REQUEST)?; 21 | 22 | let re = Regex::new(r"/videos/([^/]+)/").unwrap(); 23 | 24 | let id: String = re 25 | .captures(original_request.url().path()) 26 | .and_then(|cap| cap.get(1)) 27 | .map(|m| m.as_str()) 28 | .unwrap_or_default() 29 | .to_string(); 30 | 31 | let server = if let Some(session) = state.play_sessions.get_session_by_item_id(&id).await { 32 | info!( 33 | "Found play session for item: {}, server: {}", 34 | id, session.server.name 35 | ); 36 | session.server 37 | } else { 38 | error!("No play session found for item: {}", id); 39 | return Err(StatusCode::NOT_FOUND); 40 | }; 41 | 42 | // Get the original path and query 43 | let orig_url = original_request.url().clone(); 44 | 45 | let path = state.remove_prefix_from_path(orig_url.path()).await; 46 | 47 | let mut new_url = join_server_url(&server.url, path); 48 | new_url.set_query(orig_url.query()); 49 | 50 | info!("Redirecting to: {}", new_url); 51 | 52 | // Redirect to the actual jellyfin server 53 | Ok(axum::response::Redirect::temporary(new_url.as_ref())) 54 | } 55 | 56 | //http://localhost:3000/Videos/82fe5aab-29ff-9630-05c2-da1a5a640428/82fe5aab29ff963005c2da1a5a640428/Attachments/5 57 | //http://localhost:3000/Videos/71bda5a4-267a-1a6c-49ce-8536d36628d8/71bda5a4267a1a6c49ce8536d36628d8/Subtitles/3/0/Stream.js?api_key=4543ddacf7544d258444677c680d81a5 58 | pub async fn get_video_resource( 59 | State(state): State, 60 | req: Request, 61 | ) -> Result { 62 | let preprocessed = preprocess_request(req, &state).await.map_err(|e| { 63 | error!("Failed to preprocess request: {}", e); 64 | StatusCode::BAD_REQUEST 65 | })?; 66 | 67 | let original_request = preprocessed 68 | .original_request 69 | .ok_or(StatusCode::BAD_REQUEST)?; 70 | 71 | let re = Regex::new(r"/Videos/([^/]+)/").unwrap(); 72 | 73 | let captures = re 74 | .captures(original_request.url().path()) 75 | .ok_or(StatusCode::NOT_FOUND)?; 76 | 77 | let id = captures.get(1).map_or("", |m| m.as_str()); 78 | 79 | let server = if let Some(session) = state.play_sessions.get_session_by_item_id(id).await { 80 | info!( 81 | "Found play session for resource: {}, server: {}", 82 | id, session.server.name 83 | ); 84 | session.server 85 | } else { 86 | error!("No play session found for resource: {}", id); 87 | return Err(StatusCode::NOT_FOUND); 88 | }; 89 | 90 | // Get the original path and query 91 | let orig_url = original_request.url().clone(); 92 | let path = state.remove_prefix_from_path(orig_url.path()).await; 93 | let mut new_url = join_server_url(&server.url, path); 94 | new_url.set_query(orig_url.query()); 95 | 96 | info!("Redirecting HLS stream to: {}", new_url); 97 | 98 | Ok(axum::response::Redirect::temporary(new_url.as_ref())) 99 | } 100 | 101 | pub async fn get_stream( 102 | State(state): State, 103 | req: Request, 104 | ) -> Result { 105 | let preprocessed = preprocess_request(req, &state).await.map_err(|e| { 106 | error!("Failed to preprocess request: {}", e); 107 | StatusCode::BAD_REQUEST 108 | })?; 109 | 110 | let url = preprocessed.request.url().clone(); 111 | 112 | info!("Redirecting MKV stream to: {}", url); 113 | 114 | Ok(axum::response::Redirect::temporary(url.as_ref())) 115 | } 116 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/admin/user_item.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    {{ uwm.user.original_username }} {{ 5 | uwm.total_sessions }}

    6 | 7 | 9 | 10 | 11 |
    12 |
    13 | 14 |

    Delete User

    15 |
    16 |

    Are you sure you want to delete {{ uwm.user.original_username }}?

    17 |
    18 | 22 |
    23 |
    24 | 25 | 26 |
    27 |
    28 |
    29 |
    30 |

    {{ uwm.user.virtual_key }}

    31 |
    32 | 33 |
    34 | {% if !uwm.mappings.is_empty() %} 35 |
    36 | Server mappings ({{ uwm.mappings.len() }}) 37 | 38 | {% for (mapping, server, scount) in uwm.mappings %} 39 |
    40 | {{ server.name }} {{ scount }} 42 | 43 | 46 |
    47 | Mapped User: {{ mapping.mapped_username }} 48 |
    49 | {% endfor %} 50 |
    51 | {% endif %} 52 |
    53 | 54 | 55 | {% if !uwm.available_servers.is_empty() %} 56 |
    57 |

    Add server mapping:

    58 |
    59 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 |
    74 | {% endif %} 75 | 76 |
    77 |
    Reset Sessions
    81 |
    82 | 83 |
    -------------------------------------------------------------------------------- /crates/jellyfin-api/src/storage.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::{error::Error, ClientInfo, JellyfinClient}; 4 | use moka::future::Cache; 5 | use url::Url; 6 | 7 | #[derive(Clone)] 8 | pub struct JellyfinClientStorage { 9 | cache: Cache<(String, ClientInfo, String), Arc>, 10 | } 11 | 12 | impl JellyfinClientStorage { 13 | pub fn new(capacity: u64, ttl: std::time::Duration) -> Self { 14 | let cache = Cache::builder() 15 | .max_capacity(capacity) 16 | .time_to_idle(ttl) 17 | .eviction_listener(|_key, value: Arc, _cause| { 18 | if value.get_token().is_some() { 19 | tokio::spawn(async move { 20 | if let Err(e) = value.logout().await { 21 | tracing::error!("Failed to logout evicted client: {:?}", e); 22 | } 23 | }); 24 | } 25 | }) 26 | .build(); 27 | 28 | Self { cache } 29 | } 30 | 31 | pub async fn get( 32 | &self, 33 | base_url: &str, 34 | client_info: ClientInfo, 35 | id: Option<&str>, 36 | ) -> Result, Error> { 37 | let mut url = Url::parse(base_url)?; 38 | if url.path().ends_with('/') { 39 | url.path_segments_mut() 40 | .map_err(|_| Error::UrlParse(url::ParseError::EmptyHost))? 41 | .pop_if_empty(); 42 | } 43 | let normalized_url = url.to_string(); 44 | let id = id.unwrap_or_default().to_string(); 45 | let key = (normalized_url.clone(), client_info.clone(), id); 46 | 47 | if let Some(client) = self.cache.get(&key).await { 48 | return Ok(client); 49 | } 50 | 51 | let client = Arc::new(JellyfinClient::new(&normalized_url, client_info)?); 52 | self.cache.insert(key, client.clone()).await; 53 | 54 | Ok(client) 55 | } 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use super::*; 61 | use std::time::Duration; 62 | use wiremock::matchers::{method, path}; 63 | use wiremock::{Mock, MockServer, ResponseTemplate}; 64 | 65 | #[tokio::test] 66 | async fn test_client_eviction_logout() { 67 | let mock_server = MockServer::start().await; 68 | 69 | // Mock the logout endpoint 70 | Mock::given(method("POST")) 71 | .and(path("/Sessions/Logout")) 72 | .respond_with(ResponseTemplate::new(204)) 73 | .expect(1) // Expect exactly one call 74 | .mount(&mock_server) 75 | .await; 76 | 77 | // Create storage with capacity 1 78 | let storage = JellyfinClientStorage::new(1, Duration::from_secs(60)); 79 | let client_info = ClientInfo::default(); 80 | 81 | // 1. Get first client 82 | let client1 = storage 83 | .get(&mock_server.uri(), client_info.clone(), None) 84 | .await 85 | .unwrap(); 86 | 87 | // Simulate authentication (this updates the shared Arc) 88 | let _ = client1.with_token("test_token".to_string()); 89 | 90 | // 2. Manually invalidate the client to force eviction 91 | // We need to reconstruct the key used in storage 92 | let mut url = Url::parse(&mock_server.uri()).unwrap(); 93 | if url.path().ends_with('/') { 94 | url.path_segments_mut().unwrap().pop_if_empty(); 95 | } 96 | let normalized_url = url.to_string(); 97 | let key = (normalized_url, client_info, "".to_string()); 98 | 99 | storage.cache.invalidate(&key).await; 100 | 101 | // Force maintenance to ensure eviction happens (invalidate might be lazy or listener might be async) 102 | storage.cache.run_pending_tasks().await; 103 | 104 | // We need to wait for the background task to complete. 105 | tokio::time::sleep(Duration::from_millis(500)).await; 106 | 107 | // The expectation on the Mock will verify that the request was received. 108 | } 109 | 110 | #[tokio::test] 111 | async fn test_client_ttl_eviction() { 112 | let mock_server = MockServer::start().await; 113 | 114 | Mock::given(method("POST")) 115 | .and(path("/Sessions/Logout")) 116 | .respond_with(ResponseTemplate::new(204)) 117 | .expect(1) 118 | .mount(&mock_server) 119 | .await; 120 | 121 | // Create storage with short TTL 122 | let storage = JellyfinClientStorage::new(10, Duration::from_millis(100)); 123 | let client_info = ClientInfo::default(); 124 | 125 | let client = storage 126 | .get(&mock_server.uri(), client_info, None) 127 | .await 128 | .unwrap(); 129 | 130 | let _ = client.with_token("test_token".to_string()); 131 | 132 | // Wait for TTL to expire + some buffer 133 | tokio::time::sleep(Duration::from_millis(200)).await; 134 | 135 | // Trigger maintenance/eviction check 136 | storage.cache.run_pending_tasks().await; 137 | 138 | // Wait for the eviction listener to run 139 | tokio::time::sleep(Duration::from_millis(500)).await; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /docs/ui.md: -------------------------------------------------------------------------------- 1 | # Jellyswarrm UI Documentation 2 | 3 | ## Admin Interface Overview 4 | 5 | The Admin Interface is displayed when you log in with an administrator account. It provides tools to manage connected Jellyfin servers, user accounts, and global settings for your Jellyswarrm instance. 6 | 7 | ### Adding Servers 8 | 9 |

    10 | Servers 11 |

    12 | 13 | To connect your Jellyfin servers to Jellyswarrm, follow these steps: 14 | 15 | 1. Open the Jellyswarrm Web UI in your browser: 16 | `http://[JELLYSWARRM_HOST]:[JELLYSWARRM_PORT]/ui` 17 | 18 | 2. Log in with the username and password you set in the environment variables during deployment. 19 | 20 | 3. Go to the **Servers** section. 21 | 22 | 4. Fill in the required details: 23 | - **Server Name** – A friendly display name for the server. 24 | - **Jellyfin URL** – The full URL to your Jellyfin server (e.g. `http://jellyfin.example.com`). 25 | - **Priority** – Determines which server takes precedence for tasks not handled by the proxy (such as theming/styling). Higher numbers = higher priority. 26 | 27 | 5. Click **Add** to save the server. 28 | 29 | To remove a server, simply click the **Delete** button next to the one you want to remove. 30 | 31 | #### Federarated Servers 32 | 33 |

    34 | Add User with Federation 35 |

    36 | 37 | 38 | After you add a server, you can optionally provide admin credentials. This allows Jellyswarrm to create and manage user accounts on that server automatically when using the federation features. Simply press the admin icon next to the server entry and enter the admin username and password for that Jellyfin instance. 39 | 40 | 41 | 42 | ### User Management & Federation 43 | 44 | Jellyswarrm allows you to link users across multiple Jellyfin servers into a single unified account. This way, users can log in once and access media from all their connected servers seamlessly. 45 | 46 | --- 47 | 48 | ### Adding Users 49 | 50 | #### Federation (Recommended) 51 | When creating a new user in Jellyswarrm via the Admin UI, you can check the **Enable Federation** option. 52 | 53 |

    54 | Add User with Federation 55 |

    56 | 57 | This will automatically: 58 | 1. Create the user on all connected Jellyfin servers (requires Admin credentials to be configured for those servers). 59 | 2. Set the same password for all of them. 60 | 3. Automatically create the server mappings in Jellyswarrm. 61 | 62 | This ensures that the user exists everywhere and is ready to use immediately without manual configuration. 63 | 64 | #### Automatic Mapping (Login) 65 | If a user already exists on one or more connected servers, they can log in directly with their existing Jellyfin credentials. Jellyswarrm will automatically create a local user and set up the necessary server mappings. 66 | 67 | If the same username and password exist on multiple servers, Jellyswarrm will link those accounts together automatically. This provides a smooth experience, giving the user unified access to all linked servers. 68 | 69 | #### Manual Creation 70 | To manually create a user in Jellyswarrm without federation: 71 | 72 | 1. Open the Jellyswarrm Web UI in your browser: 73 | `http://[JELLYSWARRM_HOST]:[JELLYSWARRM_PORT]/ui` 74 | 2. Log in with the admin credentials you set during deployment. 75 | 3. Navigate to the **Users** section. 76 | 4. Define a **username** and **password** for the new user. 77 | 5. Uncheck **Enable Federation** if you only want to create the user locally in Jellyswarrm. 78 | 6. Click **Add** to create the user. 79 | 80 | --- 81 | 82 | ### Adding Server Mappings to a User 83 | 84 |

    85 | Add Mapping 86 |

    87 | 88 | To manually link a user to additional server accounts (e.g. if they have different passwords or usernames on different servers): 89 | 90 | 1. In the **Users** section, find the user you want to extend with server mappings. 91 | 2. If the user does not yet have a mapping for a server, a dropdown menu will appear under their entry. 92 | 3. Select the target server from the dropdown. 93 | 4. Enter the **username** and **password** for the Jellyfin account on that server. 94 | 5. Click **Add** to save the mapping. 95 | 96 | --- 97 | 98 | ### Removing Users or Mappings 99 | 100 | To remove a user or unlink a specific server mapping, simply press the **Delete** button next to the entry you want to remove. 101 | 102 | When deleting a user, you can optionally choose to **Delete from all servers**, which will attempt to remove the user account from all connected Jellyfin instances where the admin has access. 103 | 104 | 105 | ## Global Settings 106 | 107 |

    108 | Settings 109 |

    110 | 111 | The **Settings** section lets you configure global options that affect how Jellyswarrm appears to your users and clients: 112 | 113 | - **Public Address** – The external address reported to Jellyfin clients. 114 | - **Server Name** – The display name shown to users when they log in. 115 | - **Add Server Name to Title** – When enabled, Jellyswarrm will append the server name to media titles in the format: 116 | `Title [Server Name]`. 117 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: [ 'v*' ] 7 | pull_request: 8 | branches: [ main ] 9 | workflow_dispatch: 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | 14 | jobs: 15 | build-and-push: 16 | strategy: 17 | matrix: 18 | include: 19 | - platform: linux/amd64 20 | runner: ubuntu-latest 21 | - platform: linux/arm64 22 | runner: ubuntu-24.04-arm 23 | runs-on: ${{ matrix.runner }} 24 | permissions: 25 | contents: read 26 | packages: write 27 | 28 | steps: 29 | - name: Checkout (with submodules) 30 | uses: actions/checkout@v4 31 | with: 32 | submodules: recursive 33 | fetch-depth: 0 34 | 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v3 37 | 38 | - name: Log in to Container Registry 39 | if: github.event_name != 'pull_request' 40 | uses: docker/login-action@v3 41 | with: 42 | registry: ${{ env.REGISTRY }} 43 | username: ${{ github.actor }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Convert repository name to lowercase 47 | id: repo 48 | run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT 49 | 50 | - name: Create platform suffix 51 | id: platform 52 | run: echo "suffix=$(echo '${{ matrix.platform }}' | tr '/' '-')" >> $GITHUB_OUTPUT 53 | 54 | - name: Extract metadata 55 | id: meta 56 | uses: docker/metadata-action@v5 57 | with: 58 | images: ${{ env.REGISTRY }}/${{ steps.repo.outputs.name }} 59 | flavor: | 60 | suffix=-${{ steps.platform.outputs.suffix }} 61 | tags: | 62 | type=semver,pattern={{version}} 63 | type=semver,pattern={{major}}.{{minor}} 64 | type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} 65 | type=raw,value=main,enable={{is_default_branch}} 66 | type=sha,enable=${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }} 67 | 68 | - name: Build and push Docker image 69 | if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) 70 | uses: docker/build-push-action@v5 71 | with: 72 | context: . 73 | platforms: ${{ matrix.platform }} 74 | outputs: type=registry 75 | tags: ${{ steps.meta.outputs.tags }} 76 | labels: ${{ steps.meta.outputs.labels }} 77 | provenance: false 78 | sbom: false 79 | cache-from: | 80 | type=gha,scope=${{ steps.platform.outputs.suffix }} 81 | type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}:cache-${{ steps.platform.outputs.suffix }} 82 | cache-to: | 83 | type=gha,scope=${{ steps.platform.outputs.suffix }},mode=max 84 | build-args: | 85 | BUILDKIT_INLINE_CACHE=1 86 | 87 | - name: Build Docker image (PR) 88 | if: github.event_name == 'pull_request' 89 | uses: docker/build-push-action@v5 90 | with: 91 | context: . 92 | platforms: ${{ matrix.platform }} 93 | outputs: type=docker 94 | tags: ${{ steps.meta.outputs.tags }} 95 | labels: ${{ steps.meta.outputs.labels }} 96 | provenance: false 97 | sbom: false 98 | cache-from: | 99 | type=gha,scope=${{ steps.platform.outputs.suffix }} 100 | type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}:cache-${{ steps.platform.outputs.suffix }} 101 | build-args: | 102 | BUILDKIT_INLINE_CACHE=1 103 | 104 | create-manifest: 105 | if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) 106 | needs: build-and-push 107 | runs-on: ubuntu-latest 108 | permissions: 109 | contents: read 110 | packages: write 111 | 112 | steps: 113 | - name: Log in to Container Registry 114 | uses: docker/login-action@v3 115 | with: 116 | registry: ${{ env.REGISTRY }} 117 | username: ${{ github.actor }} 118 | password: ${{ secrets.GITHUB_TOKEN }} 119 | 120 | - name: Convert repository name to lowercase 121 | id: repo 122 | run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT 123 | 124 | - name: Extract metadata 125 | id: meta 126 | uses: docker/metadata-action@v5 127 | with: 128 | images: ${{ env.REGISTRY }}/${{ steps.repo.outputs.name }} 129 | tags: | 130 | type=semver,pattern={{version}} 131 | type=semver,pattern={{major}}.{{minor}} 132 | type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} 133 | type=raw,value=main,enable={{is_default_branch}} 134 | type=sha,enable=${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }} 135 | 136 | - name: Create and push multi-platform manifest 137 | run: | 138 | TAGS="${{ steps.meta.outputs.tags }}" 139 | for TAG in $TAGS; do 140 | docker manifest create $TAG \ 141 | --amend ${TAG}-linux-amd64 \ 142 | --amend ${TAG}-linux-arm64 143 | docker manifest push $TAG 144 | done -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/user/profile.rs: -------------------------------------------------------------------------------- 1 | use askama::Template; 2 | use axum::{ 3 | extract::State, 4 | http::StatusCode, 5 | response::{Html, IntoResponse}, 6 | Form, 7 | }; 8 | use serde::Deserialize; 9 | use tracing::error; 10 | 11 | use crate::{encryption::Password, ui::auth::AuthenticatedUser, AppState}; 12 | 13 | #[derive(Template)] 14 | #[template(path = "user/user_profile.html")] 15 | pub struct UserProfileTemplate { 16 | pub username: String, 17 | pub ui_route: String, 18 | } 19 | 20 | #[derive(Deserialize)] 21 | pub struct ChangePasswordForm { 22 | pub current_password: Password, 23 | pub new_password: Password, 24 | pub confirm_password: Password, 25 | } 26 | 27 | pub async fn get_user_profile( 28 | State(state): State, 29 | AuthenticatedUser(user): AuthenticatedUser, 30 | ) -> impl IntoResponse { 31 | let template = UserProfileTemplate { 32 | username: user.username, 33 | ui_route: state.get_ui_route().await, 34 | }; 35 | 36 | match template.render() { 37 | Ok(html) => Html(html).into_response(), 38 | Err(e) => { 39 | error!("Failed to render user profile template: {}", e); 40 | (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response() 41 | } 42 | } 43 | } 44 | 45 | pub async fn post_user_password( 46 | State(state): State, 47 | AuthenticatedUser(user): AuthenticatedUser, 48 | Form(form): Form, 49 | ) -> impl IntoResponse { 50 | if form.new_password != form.confirm_password { 51 | return ( 52 | StatusCode::OK, 53 | Html(r#" 54 |
    55 | New passwords do not match 56 |
    57 | "#), 58 | ) 59 | .into_response(); 60 | } 61 | 62 | match state 63 | .user_authorization 64 | .verify_user_password(&user.id, &form.current_password) 65 | .await 66 | { 67 | Ok(true) => { 68 | let admin_password = { 69 | let config = state.config.read().await; 70 | config.password.clone() 71 | }; 72 | 73 | match state 74 | .user_authorization 75 | .update_user_password( 76 | &user.id, 77 | &form.current_password, 78 | &form.new_password, 79 | &admin_password, 80 | ) 81 | .await 82 | { 83 | Ok(_) => { 84 | let logout_url = format!("/{}/logout", state.get_ui_route().await); 85 | ( 86 | StatusCode::OK, 87 | Html(format!(r#" 88 |
    89 | Password updated successfully 90 |
    91 | 98 | "#, logout_url)), 99 | ) 100 | .into_response() 101 | }, 102 | Err(e) => { 103 | error!("Failed to update password: {}", e); 104 | ( 105 | StatusCode::OK, 106 | Html(r#" 107 |
    108 | Database error 109 |
    110 | "#.to_string()), 111 | ) 112 | .into_response() 113 | } 114 | } 115 | } 116 | Ok(false) => ( 117 | StatusCode::OK, 118 | Html(r#" 119 |
    120 | Incorrect current password 121 |
    122 | "#), 123 | ) 124 | .into_response(), 125 | Err(e) => { 126 | error!("Failed to verify password: {}", e); 127 | ( 128 | StatusCode::OK, 129 | Html(r#" 130 |
    131 | Database error 132 |
    133 | "#), 134 | ) 135 | .into_response() 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /dev/scripts/download-content.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib.request 3 | import urllib.error 4 | from pathlib import Path 5 | 6 | def download_with_progress(url, filepath): 7 | """Download a file with progress indication""" 8 | try: 9 | print(f" 📥 Downloading to {filepath}...") 10 | urllib.request.urlretrieve(url, filepath) 11 | return True 12 | except urllib.error.URLError as e: 13 | print(f" ⚠️ Failed to download: {e}") 14 | return False 15 | 16 | def ensure_directory(path): 17 | """Create directory if it doesn't exist""" 18 | Path(path).mkdir(parents=True, exist_ok=True) 19 | 20 | def main(): 21 | print("🎬 Starting content download for Jellyfin development servers...") 22 | 23 | # Create directory structure 24 | print("📁 Creating directory structure...") 25 | downloads_base = Path("/downloads") 26 | movies_dir = downloads_base / "movies" 27 | tv_shows_dir = downloads_base / "tv-shows" 28 | music_dir = downloads_base / "music" 29 | 30 | ensure_directory(movies_dir) 31 | ensure_directory(tv_shows_dir) 32 | ensure_directory(music_dir) 33 | 34 | print("🎭 Downloading public domain movies from Internet Archive...") 35 | 36 | # Night of the Living Dead (1968) - Public Domain 37 | print(" 📥 Night of the Living Dead (1968)...") 38 | night_dir = movies_dir / "Night of the Living Dead (1968)" 39 | night_file = night_dir / "Night of the Living Dead (1968).mp4" 40 | ensure_directory(night_dir) 41 | 42 | if not night_file.exists(): 43 | download_with_progress( 44 | "https://archive.org/download/night_of_the_living_dead_dvd/Night.mp4", 45 | night_file 46 | ) 47 | else: 48 | print(" ✅ Night of the Living Dead already exists, skipping download") 49 | 50 | # Plan 9 from Outer Space (1959) - Public Domain 51 | print(" 📥 Plan 9 from Outer Space (1959)...") 52 | plan9_dir = movies_dir / "Plan 9 from Outer Space (1959)" 53 | plan9_file = plan9_dir / "Plan 9 from Outer Space (1959).mp4" 54 | ensure_directory(plan9_dir) 55 | 56 | if not plan9_file.exists(): 57 | download_with_progress( 58 | "https://archive.org/download/plan-9-from-outer-space-1959_ed-wood/PLAN%209%20FROM%20OUTER%20SPACE%201959.ia.mp4", 59 | plan9_file 60 | ) 61 | else: 62 | print(" ✅ Plan 9 from Outer Space already exists, skipping download") 63 | 64 | print("🎨 Downloading Creative Commons content...") 65 | 66 | # Big Buck Bunny 67 | print(" 📥 Big Buck Bunny...") 68 | bunny_dir = movies_dir / "Big Buck Bunny (2008)" 69 | bunny_file = bunny_dir / "Big Buck Bunny (2008).mp4" 70 | ensure_directory(bunny_dir) 71 | 72 | if not bunny_file.exists(): 73 | download_with_progress( 74 | "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", 75 | bunny_file 76 | ) 77 | else: 78 | print(" ✅ Big Buck Bunny already exists, skipping download") 79 | 80 | print("📺 Downloading public domain TV series...") 81 | 82 | # The Cisco Kid - Public Domain Western Series 83 | print(" 📥 The Cisco Kid (1950-1956)...") 84 | cisco_dir = tv_shows_dir / "The Cisco Kid (1950)" / "Season 01" 85 | ensure_directory(cisco_dir) 86 | 87 | # Episode 1 88 | ep1_file = cisco_dir / "The Cisco Kid - S01E01 - The Gay Caballero.mp4" 89 | if not ep1_file.exists(): 90 | download_with_progress( 91 | "https://archive.org/download/TheCiscoKidpublicdomain/The_Cisco_Kid_s01e01.mp4", 92 | ep1_file 93 | ) 94 | else: 95 | print(" ✅ Cisco Kid S01E01 already exists, skipping download") 96 | 97 | # Episode 2 98 | ep2_file = cisco_dir / "The Cisco Kid - S01E03 - Rustling.mp4" 99 | if not ep2_file.exists(): 100 | download_with_progress( 101 | "https://ia801409.us.archive.org/19/items/TheCiscoKidpublicdomain/The_Cisco_Kid_s01e03.mp4", 102 | ep2_file 103 | ) 104 | else: 105 | print(" ✅ Cisco Kid S01E03 already exists, skipping download") 106 | 107 | print("🎵 Downloading royalty-free and freely-copiable music albums...") 108 | 109 | # Album 1: The Open Goldberg Variations (2012) — Kimiko Ishizaka (CC0/Public Domain) 110 | # Source: https://archive.org/details/The_Open_Goldberg_Variations-11823 111 | print(" 🎹 Downloading 'The Open Goldberg Variations' (Kimiko Ishizaka)...") 112 | ogv_dir = music_dir / "Kimiko Ishizaka" / "The Open Goldberg Variations (2012)" 113 | ensure_directory(ogv_dir) 114 | 115 | ogv_tracks = [ 116 | ("01 - Aria.ogg", "Kimiko_Ishizaka_-_01_-_Aria.ogg"), 117 | ("02 - Variatio 1 a 1 Clav.ogg", "Kimiko_Ishizaka_-_02_-_Variatio_1_a_1_Clav.ogg"), 118 | ("03 - Variatio 2 a 1 Clav.ogg", "Kimiko_Ishizaka_-_03_-_Variatio_2_a_1_Clav.ogg"), 119 | ("04 - Variatio 3 a 1 Clav. Canone all'Unisuono.ogg", "Kimiko_Ishizaka_-_04_-_Variatio_3_a_1_Clav_Canone_allUnisuono.ogg"), 120 | ] 121 | for display_name, src_name in ogv_tracks: 122 | target = ogv_dir / display_name 123 | if not target.exists(): 124 | download_with_progress( 125 | f"https://archive.org/download/The_Open_Goldberg_Variations-11823/{src_name}", 126 | target 127 | ) 128 | else: 129 | print(f" ✅ {display_name} already exists, skipping") 130 | 131 | if __name__ == "__main__": 132 | main() -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Login - Jellyswarrm{% endblock %} 4 | 5 | {% block body %} 6 |
    7 | 8 |
    9 | Jellyswarrm Logo 10 |

    Jellyswarrm

    11 |
    12 | 13 | 14 |
    15 | 16 | {% if !messages.is_empty() %} 17 |
    18 | {% for message in messages %} 19 |
    20 |

    21 | 22 | {{ message }} 23 |

    24 |
    25 | {% endfor %} 26 |
    27 | {% endif %} 28 | 29 | 30 |
    31 |
    32 |

    33 | 34 | Sign In 35 |

    36 |

    37 | Please enter your credentials to access the dashboard. 38 |

    39 |
    40 | 41 |
    42 |
    43 | 47 | 55 |
    56 | 57 |
    58 | 62 |
    63 | 72 | 80 |
    81 |
    82 | 83 | 87 | 88 | {% if let Some(next) = next %} 89 | 90 | {% endif %} 91 |
    92 |
    93 |
    94 | 95 | 96 |
    97 | 101 |
    102 |
    103 | 104 | 105 |
    106 |
    107 |

    108 | 109 | Jellyswarrm Proxy Server 110 |

    111 |
    112 | {% endblock %} 113 | 114 | {% block scripts %} 115 | 129 | {% endblock %} -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/auth/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::FromRequestParts, 5 | http::{request::Parts, StatusCode}, 6 | }; 7 | use axum_login::{AuthUser, AuthnBackend, UserId}; 8 | use serde::{Deserialize, Serialize}; 9 | use tokio::{sync::RwLock, task}; 10 | use tracing::{error, info}; 11 | 12 | use crate::{ 13 | config::AppConfig, encryption::HashedPassword, 14 | user_authorization_service::UserAuthorizationService, 15 | }; 16 | 17 | mod routes; 18 | 19 | pub use routes::router; 20 | 21 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 22 | pub enum UserRole { 23 | Admin, 24 | User, 25 | } 26 | 27 | #[derive(Clone, Serialize, Deserialize)] 28 | pub struct User { 29 | pub id: String, 30 | pub username: String, 31 | pub password_hash: HashedPassword, 32 | pub role: UserRole, 33 | } 34 | 35 | pub struct AuthenticatedUser(pub User); 36 | 37 | impl FromRequestParts for AuthenticatedUser 38 | where 39 | S: Send + Sync, 40 | { 41 | type Rejection = StatusCode; 42 | 43 | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { 44 | let auth_session = AuthSession::from_request_parts(parts, state) 45 | .await 46 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 47 | 48 | match auth_session.user { 49 | Some(user) => Ok(AuthenticatedUser(user)), 50 | None => Err(StatusCode::UNAUTHORIZED), 51 | } 52 | } 53 | } 54 | 55 | // Here we've implemented `Debug` manually to avoid accidentally logging the 56 | // password hash. 57 | impl std::fmt::Debug for User { 58 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 59 | f.debug_struct("User") 60 | .field("id", &self.id) 61 | .field("username", &self.username) 62 | .field("password", &"[redacted]") 63 | .field("role", &self.role) 64 | .finish() 65 | } 66 | } 67 | 68 | impl AuthUser for User { 69 | type Id = String; 70 | 71 | fn id(&self) -> Self::Id { 72 | self.id.clone() 73 | } 74 | 75 | fn session_auth_hash(&self) -> &[u8] { 76 | self.password_hash.as_str().as_bytes() // We use the password hash as the auth 77 | // hash--what this means 78 | // is when the user changes their password the 79 | // auth session becomes invalid. 80 | } 81 | } 82 | 83 | // This allows us to extract the authentication fields from forms. We use this 84 | // to authenticate requests with the backend. 85 | #[derive(Debug, Clone, Deserialize)] 86 | pub struct Credentials { 87 | pub username: String, 88 | pub password: String, 89 | pub next: Option, 90 | } 91 | 92 | #[derive(Debug, Clone)] 93 | pub struct Backend { 94 | config: Arc>, 95 | user_auth: Arc, 96 | } 97 | 98 | impl Backend { 99 | pub fn new(config: Arc>, user_auth: Arc) -> Self { 100 | Self { config, user_auth } 101 | } 102 | } 103 | 104 | #[derive(Debug, thiserror::Error)] 105 | pub enum Error { 106 | #[error(transparent)] 107 | TaskJoin(#[from] task::JoinError), 108 | #[error(transparent)] 109 | Sqlx(#[from] sqlx::Error), 110 | } 111 | 112 | impl AuthnBackend for Backend { 113 | type User = User; 114 | type Credentials = Credentials; 115 | type Error = Error; 116 | 117 | async fn authenticate( 118 | &self, 119 | creds: Self::Credentials, 120 | ) -> Result, Self::Error> { 121 | let password = creds.password.into(); 122 | let config = self.config.read().await; 123 | info!("Authenticating user: {}", creds.username); 124 | if creds.username == config.username && password == config.password { 125 | info!("Admin authentication successful"); 126 | // If the password is correct, we return the default user. 127 | let user = User { 128 | id: "admin".to_string(), 129 | username: creds.username, 130 | password_hash: config.password.clone().into(), 131 | role: UserRole::Admin, 132 | }; 133 | return Ok(Some(user)); 134 | } 135 | 136 | if let Some(user) = self 137 | .user_auth 138 | .get_user_by_credentials(&creds.username, &password) 139 | .await? 140 | { 141 | info!("User authentication successful: {}", user.original_username); 142 | let user = User { 143 | id: user.id, 144 | username: user.original_username, 145 | password_hash: user.original_password_hash, 146 | role: UserRole::User, 147 | }; 148 | return Ok(Some(user)); 149 | } 150 | 151 | info!("Authentication failed for user: {}", creds.username); 152 | Ok(None) 153 | } 154 | 155 | async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { 156 | if user_id == "admin" { 157 | let config = self.config.read().await; 158 | return Ok(Some(User { 159 | id: "admin".to_string(), 160 | username: config.username.clone(), 161 | password_hash: config.password.clone().into(), 162 | role: UserRole::Admin, 163 | })); 164 | } 165 | 166 | if let Some(user) = self.user_auth.get_user_by_id(user_id).await? { 167 | let user = User { 168 | id: user.id, 169 | username: user.original_username, 170 | password_hash: user.original_password_hash, 171 | role: UserRole::User, 172 | }; 173 | return Ok(Some(user)); 174 | } 175 | 176 | Ok(None) 177 | } 178 | } 179 | 180 | // We use a type alias for convenience. 181 | // 182 | // Note that we've supplied our concrete backend here. 183 | pub type AuthSession = axum_login::AuthSession; 184 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | body::Body, 3 | extract::Path, 4 | middleware, 5 | response::{IntoResponse, Response}, 6 | routing::{get, post}, 7 | Router, 8 | }; 9 | use axum_login::login_required; 10 | use hyper::StatusCode; 11 | use rust_embed::RustEmbed; 12 | use tracing::error; 13 | 14 | use crate::{ 15 | ui::auth::{AuthenticatedUser, UserRole}, 16 | AppState, Asset, 17 | }; 18 | 19 | pub mod admin; 20 | pub mod auth; 21 | pub mod root; 22 | pub mod server_status; 23 | pub mod user; 24 | pub use auth::Backend; 25 | 26 | #[derive(RustEmbed)] 27 | #[folder = "src/ui/resources/"] 28 | struct Resources; 29 | 30 | async fn require_admin( 31 | AuthenticatedUser(user): AuthenticatedUser, 32 | req: axum::extract::Request, 33 | next: axum::middleware::Next, 34 | ) -> impl IntoResponse { 35 | if user.role == UserRole::Admin { 36 | next.run(req).await 37 | } else { 38 | StatusCode::FORBIDDEN.into_response() 39 | } 40 | } 41 | 42 | async fn resource_handler(Path(path): Path) -> impl IntoResponse { 43 | if let Some(file) = Resources::get(&path) { 44 | let mime = mime_guess::from_path(path).first_or_octet_stream(); 45 | Ok(Response::builder() 46 | .header("Content-Type", mime.as_ref()) 47 | .body(Body::from(file.data.into_owned())) 48 | .unwrap()) 49 | } else { 50 | Err(StatusCode::NOT_FOUND) 51 | } 52 | } 53 | 54 | #[derive(Debug, Clone, serde::Serialize)] 55 | pub struct JellyfinUiVersion { 56 | pub version: String, 57 | pub commit: String, 58 | } 59 | 60 | fn get_jellyfin_ui_version() -> Option { 61 | if let Some(file) = Asset::get("ui-version.env") { 62 | let content = String::from_utf8_lossy(&file.data); 63 | let mut version = "unknown"; 64 | let mut commit = "unknown"; 65 | for line in content.lines() { 66 | if line.starts_with("UI_VERSION=") { 67 | version = line.trim_start_matches("UI_VERSION="); 68 | } else if line.starts_with("UI_COMMIT=") { 69 | commit = line.trim_start_matches("UI_COMMIT="); 70 | } 71 | } 72 | Some(JellyfinUiVersion { 73 | version: version.to_string(), 74 | commit: commit.to_string(), 75 | }) 76 | } else { 77 | error!("Failed to load Jellyfin UI version info from embedded resources"); 78 | None 79 | } 80 | } 81 | 82 | pub static JELLYFIN_UI_VERSION: once_cell::sync::Lazy> = 83 | once_cell::sync::Lazy::new(get_jellyfin_ui_version); 84 | 85 | pub fn ui_routes() -> axum::Router { 86 | let admin_routes = Router::new() 87 | // Users 88 | .route("/users", get(admin::users::users_page)) 89 | .route("/users", post(admin::users::add_user)) 90 | .route("/users/list", get(admin::users::get_user_list)) 91 | .route("/users/{id}/delete", post(admin::users::delete_user)) 92 | .route("/users/mappings", post(admin::users::add_mapping)) 93 | .route( 94 | "/users/{user_id}/mappings/{mapping_id}", 95 | axum::routing::delete(admin::users::delete_mapping), 96 | ) 97 | .route( 98 | "/users/{user_id}/sessions", 99 | axum::routing::delete(admin::users::delete_sessions), 100 | ) 101 | .route("/servers", get(admin::servers::servers_page)) 102 | .route("/servers", post(admin::servers::add_server)) 103 | .route("/servers/list", get(admin::servers::get_server_list)) 104 | .route( 105 | "/servers/{id}", 106 | axum::routing::delete(admin::servers::delete_server), 107 | ) 108 | .route( 109 | "/servers/{id}/priority", 110 | axum::routing::patch(admin::servers::update_server_priority), 111 | ) 112 | .route( 113 | "/servers/{id}/admin", 114 | post(admin::servers::add_server_admin), 115 | ) 116 | .route( 117 | "/servers/{id}/admin", 118 | axum::routing::delete(admin::servers::delete_server_admin), 119 | ) 120 | // Settings 121 | .route("/settings", get(admin::settings::settings_page)) 122 | .route("/settings/form", get(admin::settings::settings_form)) 123 | .route("/settings/save", post(admin::settings::save_settings)) 124 | .route("/settings/reload", post(admin::settings::reload_config)) 125 | .route_layer(middleware::from_fn(require_admin)); 126 | 127 | Router::new() 128 | // Root 129 | .route("/", get(root::index)) 130 | .route("/user/servers", get(user::servers::get_user_servers)) 131 | .route( 132 | "/user/servers/{id}", 133 | axum::routing::delete(user::servers::delete_server_mapping), 134 | ) 135 | .route( 136 | "/user/servers/{id}/connect", 137 | post(user::servers::connect_server), 138 | ) 139 | .route("/user/media", get(user::media::get_user_media)) 140 | .route( 141 | "/user/media/server/{server_id}/libraries", 142 | get(user::media::get_server_libraries), 143 | ) 144 | .route( 145 | "/user/media/server/{server_id}/library/{library_id}/items", 146 | get(user::media::get_library_items), 147 | ) 148 | .route( 149 | "/user/media/image/{server_id}/{item_id}", 150 | get(user::media::proxy_media_image), 151 | ) 152 | .route("/user/profile", get(user::profile::get_user_profile)) 153 | .route( 154 | "/user/profile/password", 155 | post(user::profile::post_user_password), 156 | ) 157 | .route( 158 | "/user/servers/{id}/status", 159 | get(user::servers::check_user_server_status), 160 | ) 161 | .route( 162 | "/servers/{id}/status", 163 | get(server_status::check_server_status), 164 | ) 165 | .merge(admin_routes) 166 | .route_layer(login_required!(Backend, login_url = "/ui/login")) 167 | .route("/resources/{*path}", get(resource_handler)) 168 | .merge(auth::router()) 169 | } 170 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/admin/server_list.html: -------------------------------------------------------------------------------- 1 | {% if servers.is_empty() %} 2 |
    3 |

    No servers configured

    4 |

    Add your first Jellyfin server to get started.

    5 |
    6 | {% else %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% for item in servers %} 19 | 20 | 21 | 22 | 31 | 46 | 109 | 110 | {% endfor %} 111 | 112 |
    NameURLPriorityStatusActions
    {{ item.server.name }}{{ item.server.url }} 23 | 30 | 32 |
    33 | 37 | Checking... 38 | 39 | {% if item.has_admin %} 40 | 41 | Federated 42 | 43 | {% endif %} 44 |
    45 |
    47 |
    48 | {% if !item.has_admin %} 49 | 54 | 55 | 56 |
    57 |
    58 | 59 |

    Add Admin for {{ item.server.name }}

    60 |
    61 | 62 |
    63 |
    64 | 65 |
    66 | Disclaimer 67 | Adding an admin account gives Jellyswarrm full administrative control over this Jellyfin server. This allows the proxy to manage users and settings automatically. 68 |
    69 |
    70 |
    71 | 72 |
    73 | 74 |
    75 | 79 | 83 |
    84 |
    85 | 86 | 87 |
    88 |
    89 |
    90 | {% else %} 91 | 98 | {% endif %} 99 | 100 | 107 |
    108 |
    113 | 114 | {% endif %} 115 | -------------------------------------------------------------------------------- /crates/jellyswarrm-proxy/src/ui/templates/user/user_server_list.html: -------------------------------------------------------------------------------- 1 |
    2 |

    Welcome, {{ username }}

    3 |

    Your Connected Servers

    4 |
    5 | 6 | {% if servers.is_empty() %} 7 |
    8 | 9 |

    No Servers Connected

    10 |

    You are not currently connected to any Jellyfin servers.

    11 |
    12 | {% else %} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {% for server in servers %} 25 | 26 | 30 | 36 | 37 | 44 | 53 | 54 | {% endfor %} 55 | 56 |
    NameURLPriorityStatusActions
    27 | 28 | {{ server.name }} 29 | 31 | 32 | {{ server.url }} 33 | 34 | 35 | {{ server.priority }} 38 | 41 | 42 | 43 | 45 | 52 |
    57 | {% endif %} 58 | 59 | {% if !unmapped_servers.is_empty() %} 60 |
    61 |

    Available Servers

    62 |

    Connect to these servers by providing your credentials.

    63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | {% for server in unmapped_servers %} 75 | 76 | 80 | 86 | 93 | 101 | 102 | {% endfor %} 103 | 104 |
    NameURLStatusActions
    77 | 78 | {{ server.name }} 79 | 81 | 82 | {{ server.url }} 83 | 84 | 85 | 87 | 90 | 91 | 92 | 94 | 100 |
    105 | 106 | 107 |
    108 |
    109 | 110 |

    Connect to

    111 |
    112 |
    113 |
    114 | 118 | 122 |
    123 |
    124 | 125 | 126 |
    127 |
    128 |
    129 |
    130 |
    131 | 132 | 154 | {% endif %} 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

    Jellyswarrm

    2 | 3 |

    Many servers. Single experience.

    4 | 5 |

    6 | Logo Banner 7 |
    8 |
    9 | 10 | MIT License 11 | 12 | 13 | Current Release 14 | 15 |

    16 | 17 | Jellyswarrm is a reverse proxy that lets you combine multiple Jellyfin servers into one place. If you’ve got libraries spread across different locations or just want everything together, Jellyswarrm makes it easy to access all your media from a single interface. 18 | 19 | --- 20 | 21 |

    22 | 23 | Library 24 |

    25 | 26 |

    27 | 28 | Server Selection 29 | User Mappings 30 | Settings 31 |

    32 | 33 | ## Features 34 | 35 | > [!WARNING] 36 | > Jellyswarrm is still in **early development**. It works, but some features are incomplete or missing. If you run into issues, please report them on the [GitHub Issues page](https://github.com/LLukas22/Jellyswarrm/issues). 37 | 38 | ### ✅ Working 39 | 40 | * **Unified Library Access** – Browse media from multiple Jellyfin servers in one place. 41 | * **Direct Playback** – Play content straight from the original server without extra overhead. 42 | * **User Mapping** – Link accounts across servers for a consistent user experience. 43 | * **API Compatibility** – Appears as a normal Jellyfin server, so existing apps and tools still work. 44 | * **Server Federation** – Automatically sync users across connected servers. 45 | * **User Page** – Personal dashboard for managing credentials and libraries. 46 | 47 | ### ⚠️ In Progress 48 | 49 | * **QuickConnect** – This feature isn’t available yet. Please log in using your **username & password** for now. 50 | * **Websocket Support** – Needed for real-time features like SyncPlay (not fully reliable yet). 51 | * **Audio Streaming** – May not function correctly (still untested in many cases). 52 | * **Automatic Bitrate Adjustment** – Stream quality based on network conditions isn’t supported yet. 53 | * **Media Management** – Features like adding or deleting media libraries through Jellyswarrm are not implemented yet. 54 | 55 | --- 56 | 57 | ## Deployment 58 | 59 | The easiest way to run Jellyswarrm is with the prebuilt [Docker images](https://github.com/LLukas22?tab=packages&repo_name=Jellyswarrm). 60 | Here’s a minimal `docker-compose.yml` example to get started: 61 | 62 | ```yaml 63 | services: 64 | jellyswarrm: 65 | image: ghcr.io/llukas22/jellyswarrm:latest 66 | container_name: jellyswarrm 67 | restart: unless-stopped 68 | ports: 69 | - 3000:3000 70 | volumes: 71 | - ./data:/app/data 72 | environment: 73 | - JELLYSWARRM_USERNAME=admin 74 | - JELLYSWARRM_PASSWORD=jellyswarrm # ⚠️ Change this in production! 75 | ``` 76 | 77 | Once the container is running, open: 78 | 79 | * **Web UI (setup & management):** `http://[JELLYSWARRM_HOST]:[JELLYSWARRM_PORT]/ui` 80 | – Log in with the username and password you set in the environment variables. 81 | – From here, you can add your Jellyfin servers and configure user mappings. 82 | 83 | * **Bundled Jellyfin Web Client:** `http://[JELLYSWARRM_HOST]:[JELLYSWARRM_PORT]` 84 | 85 | For advanced configuration options, check out the [ui](./docs/ui.md) and [configuration](./docs/config.md) documentation. 86 | 87 | --- 88 | 89 | 90 | ## Local Development 91 | ### Getting Started 92 | 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: 93 | 94 | ```bash 95 | git clone --recurse-submodules https://github.com/LLukas22/Jellyswarrm.git 96 | ``` 97 | 98 | If you've already cloned the repository, you can initialize the submodules separately: 99 | 100 | ```bash 101 | git submodule init 102 | git submodule update 103 | ``` 104 | 105 | 106 |
    107 | Docker 108 | 109 | The quickest way to get Jellyswarrm up and running is with Docker. Simply use the provided [docker-compose](./docker-compose.yml) configuration: 110 | 111 | ```bash 112 | docker compose up -d 113 | ``` 114 | 115 | This will build and start the application with all necessary dependencies, perfect for both development and production deployments. 116 |
    117 | 118 | 119 | 120 |
    121 | Native Build 122 | 123 | For a native development setup, ensure you have both Rust and Node.js installed on your system. 124 | 125 | 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: 126 | 127 | ```bash 128 | cd ui 129 | npm install 130 | cd .. 131 | ``` 132 | 133 | Once the dependencies are installed, build the entire project with: 134 | 135 | ```bash 136 | cargo build --release 137 | ``` 138 | 139 | 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. 140 |
    141 | 142 | ## FAQ 143 | 144 | 1. **Why not just add multiple servers directly in the Jellyfin app?** 145 | Some Jellyfin apps do support multiple servers, but switching between them can be inconvenient. Jellyswarrm brings everything together in one place and also merges features like *Next Up* and *Recently Added* across all servers. This way, you can easily see what’s new in your own libraries or what your friends have added. 146 | 147 | 2. **Will Jellyswarrm work with my existing Jellyfin apps?** 148 | Most likely! Jellyswarrm presents itself as a standard Jellyfin server, so most clients should work out of the box. That said, not every Jellyfin client has been tested, so a few may have issues. 149 | 150 | 3. **Why use Jellyswarrm instead of mounting a remote library via e.g. SMB?** 151 | Jellyswarrm is built to **connect your servers with your friends’ servers** across different networks. Setting up SMB in these cases can be complicated, and performance is often worse. With Jellyswarrm, content is streamed directly from the original server, so all the heavy lifting (like transcoding) happens where the media actually lives. 152 | --------------------------------------------------------------------------------