Merge pull request #61 from LLukas22/user-media

Jellyfin API, Typesave Passwords and Improved UserMediaView
This commit is contained in:
Lukas Kreussel
2025-12-06 21:25:21 +01:00
committed by GitHub
28 changed files with 1299 additions and 541 deletions

7
Cargo.lock generated
View File

@@ -199,9 +199,9 @@ dependencies = [
[[package]]
name = "async-lock"
version = "3.4.0"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
dependencies = [
"event-listener",
"event-listener-strategy",
@@ -930,7 +930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -1590,6 +1590,7 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
name = "jellyfin-api"
version = "0.2.0"
dependencies = [
"moka",
"reqwest",
"serde",
"serde_json",

View File

@@ -22,6 +22,7 @@ hyper = { version = "1.0", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "gzip", "brotli", "deflate", "rustls-tls"] }
moka = { version = "0.12.11", features = ["future"] }
# Database
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono"] }

View File

@@ -6,12 +6,14 @@ authors.workspace = true
repository.workspace = true
[dependencies]
tokio = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
url = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
moka = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,396 @@
use crate::error::Error;
use crate::models::{AuthResponse, MediaFoldersResponse, User};
use reqwest::{header, Client, StatusCode};
use serde::de::DeserializeOwned;
use serde_json::json;
use std::sync::{Arc, RwLock};
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ClientInfo {
pub client: String,
pub device: String,
pub device_id: String,
pub version: String,
}
impl Default for ClientInfo {
fn default() -> Self {
Self {
client: "Jellyfin API Client".to_string(),
device: "Unknown".to_string(),
device_id: "unknown-device-id".to_string(),
version: "0.0.0".to_string(),
}
}
}
#[derive(Clone)]
pub struct JellyfinClient {
base_url: Url,
client_info: ClientInfo,
http_client: Client,
auth_token: Arc<RwLock<Option<String>>>,
}
impl PartialEq for JellyfinClient {
fn eq(&self, other: &Self) -> bool {
self.base_url == other.base_url && self.client_info == other.client_info
}
}
impl Eq for JellyfinClient {}
impl std::hash::Hash for JellyfinClient {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.base_url.hash(state);
self.client_info.hash(state);
}
}
impl JellyfinClient {
pub fn new(base_url: &str, client_info: ClientInfo) -> Result<Self, Error> {
let mut url = Url::parse(base_url)?;
// Ensure no trailing slash for consistent joining
if url.path().ends_with('/') {
url.path_segments_mut()
.map_err(|_| Error::UrlParse(url::ParseError::EmptyHost))?
.pop_if_empty();
}
let http_client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
Ok(Self {
base_url: url,
client_info,
http_client,
auth_token: Arc::new(RwLock::new(None)),
})
}
pub fn with_token(&self, token: String) -> &Self {
*self.auth_token.write().unwrap() = Some(token);
self
}
pub fn get_token(&self) -> Option<String> {
self.auth_token.read().unwrap().clone()
}
fn build_auth_header(&self) -> String {
let mut header = format!(
"MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"",
self.client_info.client,
self.client_info.device,
self.client_info.device_id,
self.client_info.version
);
if let Some(token) = self.auth_token.read().unwrap().as_ref() {
header.push_str(&format!(", Token=\"{}\"", token));
}
// println!("DEBUG HEADER: {}", header);
header
}
async fn request<T: DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<T, Error> {
let url = self.base_url.join(path)?;
let auth_header = self.build_auth_header();
let mut request = self
.http_client
.request(method, url)
.header(header::AUTHORIZATION, auth_header);
if let Some(b) = body {
request = request.json(b);
}
let response = request.send().await?;
let status = response.status();
if status.is_success() {
let data = response.json::<T>().await?;
Ok(data)
} else {
match status {
StatusCode::UNAUTHORIZED => Err(Error::Unauthorized),
StatusCode::FORBIDDEN => Err(Error::Forbidden),
StatusCode::NOT_FOUND => Err(Error::NotFound),
_ => {
let text = response.text().await.unwrap_or_default();
Err(Error::ServerError(format!("{} - {}", status, text)))
}
}
}
}
async fn request_no_content(
&self,
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<(), Error> {
let url = self.base_url.join(path)?;
let auth_header = self.build_auth_header();
let mut request = self
.http_client
.request(method, url)
.header(header::AUTHORIZATION, auth_header);
if let Some(b) = body {
request = request.json(b);
}
let response = request.send().await?;
let status = response.status();
if status.is_success() {
Ok(())
} else {
match status {
StatusCode::UNAUTHORIZED => Err(Error::Unauthorized),
StatusCode::FORBIDDEN => Err(Error::Forbidden),
StatusCode::NOT_FOUND => Err(Error::NotFound),
_ => {
let text = response.text().await.unwrap_or_default();
Err(Error::ServerError(format!("{} - {}", status, text)))
}
}
}
}
pub async fn authenticate_by_name(
&self,
username: &str,
password: &str,
) -> Result<User, Error> {
let body = json!({
"Username": username,
"Pw": password
});
let response: AuthResponse = self
.request(
reqwest::Method::POST,
"Users/AuthenticateByName",
Some(&body),
)
.await
.map_err(|e| match e {
Error::Unauthorized => {
Error::AuthenticationFailed("Invalid credentials".to_string())
}
_ => e,
})?;
*self.auth_token.write().unwrap() = Some(response.access_token);
Ok(response.user)
}
pub async fn logout(&self) -> Result<(), Error> {
self.request_no_content(reqwest::Method::POST, "Sessions/Logout", None)
.await?;
*self.auth_token.write().unwrap() = None;
Ok(())
}
pub async fn get_me(&self) -> Result<User, Error> {
self.request(reqwest::Method::GET, "Users/Me", None).await
}
pub async fn get_media_folders(
&self,
user_id: Option<&str>,
) -> Result<Vec<crate::models::MediaFolder>, Error> {
let path = if let Some(uid) = user_id {
format!("Users/{}/Views", uid)
} else {
"Library/MediaFolders".to_string()
};
let response: MediaFoldersResponse =
self.request(reqwest::Method::GET, &path, None).await?;
Ok(response.items)
}
pub async fn get_public_system_info(&self) -> Result<crate::models::PublicSystemInfo, Error> {
self.request(reqwest::Method::GET, "System/Info/Public", None)
.await
}
// Admin methods
pub async fn get_users(&self) -> Result<Vec<User>, Error> {
self.request(reqwest::Method::GET, "Users", None).await
}
pub async fn create_user(&self, username: &str, password: Option<&str>) -> Result<User, Error> {
let body = json!({
"Name": username,
"Password": password
});
let user: User = self
.request(reqwest::Method::POST, "Users/New", Some(&body))
.await?;
Ok(user)
}
pub async fn delete_user(&self, user_id: &str) -> Result<(), Error> {
let path = format!("Users/{}", user_id);
self.request_no_content(reqwest::Method::DELETE, &path, None)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn get_items(
&self,
user_id: &str,
parent_id: Option<&str>,
recursive: bool,
include_item_types: Option<Vec<String>>,
limit: Option<i32>,
start_index: Option<i32>,
sort_by: Option<String>,
sort_order: Option<String>,
) -> Result<crate::models::ItemsResponse, Error> {
let mut query = vec![
("Recursive", recursive.to_string()),
("Fields", "PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,ProductionYear,RunTimeTicks,CommunityRating".to_string()),
];
if let Some(pid) = parent_id {
query.push(("ParentId", pid.to_string()));
}
if let Some(types) = include_item_types {
query.push(("IncludeItemTypes", types.join(",")));
}
if let Some(l) = limit {
query.push(("Limit", l.to_string()));
}
if let Some(si) = start_index {
query.push(("StartIndex", si.to_string()));
}
if let Some(s) = sort_by {
query.push(("SortBy", s));
}
if let Some(o) = sort_order {
query.push(("SortOrder", o));
}
let path = format!("Users/{}/Items", user_id);
let url = self.base_url.join(&path)?;
let auth_header = self.build_auth_header();
let response = self
.http_client
.get(url)
.header(header::AUTHORIZATION, auth_header)
.query(&query)
.send()
.await?;
let status = response.status();
if status.is_success() {
let data = response.json::<crate::models::ItemsResponse>().await?;
Ok(data)
} else {
match status {
StatusCode::UNAUTHORIZED => Err(Error::Unauthorized),
StatusCode::FORBIDDEN => Err(Error::Forbidden),
StatusCode::NOT_FOUND => Err(Error::NotFound),
_ => {
let text = response.text().await.unwrap_or_default();
Err(Error::ServerError(format!("{} - {}", status, text)))
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_authenticate_success() {
let mock_server = MockServer::start().await;
let auth_response = json!({
"AccessToken": "test_token",
"User": {
"Id": "user_id",
"Name": "test_user",
"ServerId": "server_id"
}
});
Mock::given(method("POST"))
.and(path("/Users/AuthenticateByName"))
.respond_with(ResponseTemplate::new(200).set_body_json(auth_response))
.mount(&mock_server)
.await;
let client_info = ClientInfo::default();
let client = JellyfinClient::new(&mock_server.uri(), client_info).unwrap();
let user = client
.authenticate_by_name("test_user", "password")
.await
.unwrap();
assert_eq!(user.name, "test_user");
assert_eq!(client.get_token().as_deref(), Some("test_token"));
}
#[tokio::test]
async fn test_get_media_folders() {
let mock_server = MockServer::start().await;
let folders_response = json!({
"Items": [
{
"Name": "Movies",
"CollectionType": "movies",
"Id": "folder_1"
}
]
});
Mock::given(method("GET"))
.and(path("/Library/MediaFolders"))
//.and(header("Authorization", "MediaBrowser Client=\"Jellyfin API Client\", Device=\"Unknown\", DeviceId=\"unknown-device-id\", Version=\"0.0.0\", Token=\"test_token\""))
.respond_with(ResponseTemplate::new(200).set_body_json(folders_response))
.mount(&mock_server)
.await;
let client_info = ClientInfo::default();
let client = JellyfinClient::new(&mock_server.uri(), client_info).unwrap();
let client = client.with_token("test_token".to_string());
let folders = client.get_media_folders(None).await.unwrap();
assert_eq!(folders.len(), 1);
assert_eq!(folders[0].name, "Movies");
}
}

View File

@@ -1,305 +1,6 @@
pub mod client;
pub mod error;
pub mod models;
pub mod storage;
use error::Error;
use models::{AuthResponse, MediaFoldersResponse, User};
use reqwest::{header, Client, StatusCode};
use serde::de::DeserializeOwned;
use serde_json::json;
use std::sync::{Arc, RwLock};
use url::Url;
#[derive(Debug, Clone)]
pub struct ClientInfo {
pub client: String,
pub device: String,
pub device_id: String,
pub version: String,
}
impl Default for ClientInfo {
fn default() -> Self {
Self {
client: "Jellyfin API Client".to_string(),
device: "Unknown".to_string(),
device_id: "unknown-device-id".to_string(),
version: "0.0.0".to_string(),
}
}
}
#[derive(Clone)]
pub struct JellyfinClient {
base_url: Url,
client_info: ClientInfo,
http_client: Client,
auth_token: Arc<RwLock<Option<String>>>,
}
impl JellyfinClient {
pub fn new(base_url: &str, client_info: ClientInfo) -> Result<Self, Error> {
let mut url = Url::parse(base_url)?;
// Ensure no trailing slash for consistent joining
if url.path().ends_with('/') {
url.path_segments_mut()
.map_err(|_| Error::UrlParse(url::ParseError::EmptyHost))?
.pop_if_empty();
}
let http_client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
Ok(Self {
base_url: url,
client_info,
http_client,
auth_token: Arc::new(RwLock::new(None)),
})
}
pub fn with_token(self, token: String) -> Self {
*self.auth_token.write().unwrap() = Some(token);
self
}
pub fn get_token(&self) -> Option<String> {
self.auth_token.read().unwrap().clone()
}
fn build_auth_header(&self) -> String {
let mut header = format!(
"MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"",
self.client_info.client,
self.client_info.device,
self.client_info.device_id,
self.client_info.version
);
if let Some(token) = self.auth_token.read().unwrap().as_ref() {
header.push_str(&format!(", Token=\"{}\"", token));
}
// println!("DEBUG HEADER: {}", header);
header
}
async fn request<T: DeserializeOwned>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<T, Error> {
let url = self.base_url.join(path)?;
let auth_header = self.build_auth_header();
let mut request = self
.http_client
.request(method, url)
.header(header::AUTHORIZATION, auth_header);
if let Some(b) = body {
request = request.json(b);
}
let response = request.send().await?;
let status = response.status();
if status.is_success() {
let data = response.json::<T>().await?;
Ok(data)
} else {
match status {
StatusCode::UNAUTHORIZED => Err(Error::Unauthorized),
StatusCode::FORBIDDEN => Err(Error::Forbidden),
StatusCode::NOT_FOUND => Err(Error::NotFound),
_ => {
let text = response.text().await.unwrap_or_default();
Err(Error::ServerError(format!("{} - {}", status, text)))
}
}
}
}
async fn request_no_content(
&self,
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<(), Error> {
let url = self.base_url.join(path)?;
let auth_header = self.build_auth_header();
let mut request = self
.http_client
.request(method, url)
.header(header::AUTHORIZATION, auth_header);
if let Some(b) = body {
request = request.json(b);
}
let response = request.send().await?;
let status = response.status();
if status.is_success() {
Ok(())
} else {
match status {
StatusCode::UNAUTHORIZED => Err(Error::Unauthorized),
StatusCode::FORBIDDEN => Err(Error::Forbidden),
StatusCode::NOT_FOUND => Err(Error::NotFound),
_ => {
let text = response.text().await.unwrap_or_default();
Err(Error::ServerError(format!("{} - {}", status, text)))
}
}
}
}
pub async fn authenticate_by_name(
&self,
username: &str,
password: &str,
) -> Result<User, Error> {
let body = json!({
"Username": username,
"Pw": password
});
let response: AuthResponse = self
.request(
reqwest::Method::POST,
"Users/AuthenticateByName",
Some(&body),
)
.await
.map_err(|e| match e {
Error::Unauthorized => {
Error::AuthenticationFailed("Invalid credentials".to_string())
}
_ => e,
})?;
*self.auth_token.write().unwrap() = Some(response.access_token);
Ok(response.user)
}
pub async fn logout(&self) -> Result<(), Error> {
self.request_no_content(reqwest::Method::POST, "Sessions/Logout", None)
.await?;
*self.auth_token.write().unwrap() = None;
Ok(())
}
pub async fn get_me(&self) -> Result<User, Error> {
self.request(reqwest::Method::GET, "Users/Me", None).await
}
pub async fn get_media_folders(&self) -> Result<Vec<models::MediaFolder>, Error> {
let response: MediaFoldersResponse = self
.request(reqwest::Method::GET, "Library/MediaFolders", None)
.await?;
Ok(response.items)
}
pub async fn get_public_system_info(&self) -> Result<models::PublicSystemInfo, Error> {
self.request(reqwest::Method::GET, "System/Info/Public", None)
.await
}
// Admin methods
pub async fn get_users(&self) -> Result<Vec<User>, Error> {
self.request(reqwest::Method::GET, "Users", None).await
}
pub async fn create_user(&self, username: &str, password: Option<&str>) -> Result<User, Error> {
let body = json!({
"Name": username,
"Password": password
});
let user: User = self
.request(reqwest::Method::POST, "Users/New", Some(&body))
.await?;
Ok(user)
}
pub async fn delete_user(&self, user_id: &str) -> Result<(), Error> {
let path = format!("Users/{}", user_id);
self.request_no_content(reqwest::Method::DELETE, &path, None)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_authenticate_success() {
let mock_server = MockServer::start().await;
let auth_response = json!({
"AccessToken": "test_token",
"User": {
"Id": "user_id",
"Name": "test_user",
"ServerId": "server_id"
}
});
Mock::given(method("POST"))
.and(path("/Users/AuthenticateByName"))
.respond_with(ResponseTemplate::new(200).set_body_json(auth_response))
.mount(&mock_server)
.await;
let client_info = ClientInfo::default();
let client = JellyfinClient::new(&mock_server.uri(), client_info).unwrap();
let user = client
.authenticate_by_name("test_user", "password")
.await
.unwrap();
assert_eq!(user.name, "test_user");
assert_eq!(client.get_token().as_deref(), Some("test_token"));
}
#[tokio::test]
async fn test_get_media_folders() {
let mock_server = MockServer::start().await;
let folders_response = json!({
"Items": [
{
"Name": "Movies",
"CollectionType": "movies",
"Id": "folder_1"
}
]
});
Mock::given(method("GET"))
.and(path("/Library/MediaFolders"))
//.and(header("Authorization", "MediaBrowser Client=\"Jellyfin API Client\", Device=\"Unknown\", DeviceId=\"unknown-device-id\", Version=\"0.0.0\", Token=\"test_token\""))
.respond_with(ResponseTemplate::new(200).set_body_json(folders_response))
.mount(&mock_server)
.await;
let client_info = ClientInfo::default();
let client = JellyfinClient::new(&mock_server.uri(), client_info)
.unwrap()
.with_token("test_token".to_string());
let folders = client.get_media_folders().await.unwrap();
assert_eq!(folders.len(), 1);
assert_eq!(folders[0].name, "Movies");
}
}
pub use client::{ClientInfo, JellyfinClient};

View File

@@ -65,3 +65,29 @@ pub struct PublicSystemInfo {
#[serde(rename = "StartupWizardCompleted")]
pub startup_wizard_completed: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaseItem {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "Type")]
pub type_: String,
#[serde(rename = "ImageTags")]
pub image_tags: Option<std::collections::HashMap<String, String>>,
#[serde(rename = "ProductionYear")]
pub production_year: Option<i32>,
#[serde(rename = "RunTimeTicks")]
pub run_time_ticks: Option<i64>,
#[serde(rename = "CommunityRating")]
pub community_rating: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemsResponse {
#[serde(rename = "Items")]
pub items: Vec<BaseItem>,
#[serde(rename = "TotalRecordCount")]
pub total_record_count: i32,
}

View File

@@ -0,0 +1,141 @@
use std::sync::Arc;
use crate::{error::Error, ClientInfo, JellyfinClient};
use moka::future::Cache;
use url::Url;
#[derive(Clone)]
pub struct JellyfinClientStorage {
cache: Cache<(String, ClientInfo, String), Arc<JellyfinClient>>,
}
impl JellyfinClientStorage {
pub fn new(capacity: u64, ttl: std::time::Duration) -> Self {
let cache = Cache::builder()
.max_capacity(capacity)
.time_to_idle(ttl)
.eviction_listener(|_key, value: Arc<JellyfinClient>, _cause| {
if value.get_token().is_some() {
tokio::spawn(async move {
if let Err(e) = value.logout().await {
tracing::error!("Failed to logout evicted client: {:?}", e);
}
});
}
})
.build();
Self { cache }
}
pub async fn get(
&self,
base_url: &str,
client_info: ClientInfo,
id: Option<&str>,
) -> Result<Arc<JellyfinClient>, Error> {
let mut url = Url::parse(base_url)?;
if url.path().ends_with('/') {
url.path_segments_mut()
.map_err(|_| Error::UrlParse(url::ParseError::EmptyHost))?
.pop_if_empty();
}
let normalized_url = url.to_string();
let id = id.unwrap_or_default().to_string();
let key = (normalized_url.clone(), client_info.clone(), id);
if let Some(client) = self.cache.get(&key).await {
return Ok(client);
}
let client = Arc::new(JellyfinClient::new(&normalized_url, client_info)?);
self.cache.insert(key, client.clone()).await;
Ok(client)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_client_eviction_logout() {
let mock_server = MockServer::start().await;
// Mock the logout endpoint
Mock::given(method("POST"))
.and(path("/Sessions/Logout"))
.respond_with(ResponseTemplate::new(204))
.expect(1) // Expect exactly one call
.mount(&mock_server)
.await;
// Create storage with capacity 1
let storage = JellyfinClientStorage::new(1, Duration::from_secs(60));
let client_info = ClientInfo::default();
// 1. Get first client
let client1 = storage
.get(&mock_server.uri(), client_info.clone(), None)
.await
.unwrap();
// Simulate authentication (this updates the shared Arc<RwLock>)
let _ = client1.with_token("test_token".to_string());
// 2. Manually invalidate the client to force eviction
// We need to reconstruct the key used in storage
let mut url = Url::parse(&mock_server.uri()).unwrap();
if url.path().ends_with('/') {
url.path_segments_mut().unwrap().pop_if_empty();
}
let normalized_url = url.to_string();
let key = (normalized_url, client_info, "".to_string());
storage.cache.invalidate(&key).await;
// Force maintenance to ensure eviction happens (invalidate might be lazy or listener might be async)
storage.cache.run_pending_tasks().await;
// We need to wait for the background task to complete.
tokio::time::sleep(Duration::from_millis(500)).await;
// The expectation on the Mock will verify that the request was received.
}
#[tokio::test]
async fn test_client_ttl_eviction() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/Sessions/Logout"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&mock_server)
.await;
// Create storage with short TTL
let storage = JellyfinClientStorage::new(10, Duration::from_millis(100));
let client_info = ClientInfo::default();
let client = storage
.get(&mock_server.uri(), client_info, None)
.await
.unwrap();
let _ = client.with_token("test_token".to_string());
// Wait for TTL to expire + some buffer
tokio::time::sleep(Duration::from_millis(200)).await;
// Trigger maintenance/eviction check
storage.cache.run_pending_tasks().await;
// Wait for the eviction listener to run
tokio::time::sleep(Duration::from_millis(500)).await;
}
}

View File

@@ -34,6 +34,7 @@ sha2.workspace = true
hex.workspace = true
chrono.workspace = true
rand.workspace = true
moka.workspace = true
dashmap = "6.1.0"
uuid = { version = "1.0", features = ["v4"] }
anyhow = "1.0.98"
@@ -58,7 +59,7 @@ base64 = "0.22.1"
serde-aux = "4.7.0"
async-trait = "0.1.89"
async-recursion = "1.1.1"
moka = { version = "0.12.11", features = ["future"] }
aes-gcm = "0.10.3"
serde_with = "3.16.1"
jellyfin-api = { path = "../jellyfin-api" }

View File

@@ -14,6 +14,8 @@ use once_cell::sync::Lazy;
use base64::prelude::*;
use crate::encryption::Password;
pub static MIGRATOR: Migrator = sqlx::migrate!();
pub static CLIENT_INFO: Lazy<ClientInfo> = Lazy::new(|| ClientInfo {
@@ -23,6 +25,11 @@ pub static CLIENT_INFO: Lazy<ClientInfo> = Lazy::new(|| ClientInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
});
pub static CLIENT_STORAGE: Lazy<jellyfin_api::storage::JellyfinClientStorage> = Lazy::new(|| {
jellyfin_api::storage::JellyfinClientStorage::new(300, std::time::Duration::from_secs(60 * 15))
// 15 minutes
});
// Lazily-resolved data directory shared across the application.
// Priority: env var JELLYSWARRM_DATA_DIR, else "./data" relative to current working dir.
// The directory is created on first access.
@@ -64,8 +71,8 @@ fn default_username() -> String {
"admin".to_string()
}
fn default_password() -> String {
"jellyswarrm".to_string()
fn default_password() -> Password {
"jellyswarrm".to_string().into()
}
fn default_session_key() -> Vec<u8> {
@@ -186,7 +193,7 @@ pub struct AppConfig {
#[serde(default = "default_username")]
pub username: String,
#[serde(default = "default_password")]
pub password: String,
pub password: Password,
#[serde(default)]
pub preconfigured_servers: Vec<PreconfiguredServer>,

View File

@@ -9,8 +9,115 @@ use aes_gcm::{
};
use base64::{engine::general_purpose, Engine as _};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::string::ToString;
/// A wrapper type for plaintext passwords.
#[derive(Clone, PartialEq, Eq, Hash, sqlx::Type, Serialize, Deserialize)]
#[sqlx(transparent)]
pub struct Password(String);
impl std::fmt::Debug for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("Password").field(&"***").finish()
}
}
impl std::fmt::Display for Password {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "***")
}
}
impl Password {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl From<String> for Password {
fn from(password: String) -> Self {
Self(password)
}
}
impl From<&str> for Password {
fn from(password: &str) -> Self {
Self(password.to_string())
}
}
/// Hash a password using SHA-256
pub fn hash_password(password: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hex::encode(hasher.finalize())
}
/// A wrapper type for hashed passwords.
/// This does not contain the plaintext password, only the hashed version.
#[derive(Clone, PartialEq, Eq, Debug, Hash, sqlx::Type, Serialize, Deserialize)]
#[sqlx(transparent)]
pub struct HashedPassword(String);
impl HashedPassword {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
pub fn from_password(password: &str) -> Self {
Self(hash_password(password))
}
pub fn verify(&self, password: &str) -> bool {
self.0 == hash_password(password)
}
pub fn from_hashed(hashed: String) -> Self {
Self(hashed)
}
}
impl From<Password> for HashedPassword {
fn from(password: Password) -> Self {
Self::from_password(password.as_str())
}
}
impl From<&Password> for HashedPassword {
fn from(password: &Password) -> Self {
Self::from_password(password.as_str())
}
}
/// A wrapper type for encrypted passwords.
#[derive(Clone, PartialEq, Eq, Debug, Hash, sqlx::Type, Serialize, Deserialize)]
#[sqlx(transparent)]
pub struct EncryptedPassword(String);
impl EncryptedPassword {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
pub fn from_raw(raw: String) -> Self {
Self(raw)
}
}
/// Custom error type for encryption/decryption operations
#[derive(Debug, thiserror::Error)]
pub enum EncryptionError {
@@ -30,15 +137,18 @@ pub enum EncryptionError {
///
/// # Arguments
/// * `plaintext` - The password to encrypt
/// * `master_password` - The master password used as encryption key
/// * `master_password` - The hashed master password used as encryption key
///
/// # Returns
/// Base64-encoded string containing the nonce and encrypted data
pub fn encrypt_password(plaintext: &str, master_password: &str) -> Result<String, EncryptionError> {
pub fn encrypt_password(
plaintext: &Password,
master_password: &HashedPassword,
) -> Result<EncryptedPassword, EncryptionError> {
tracing::debug!("Encrypting password with master password");
// Derive a 32-byte key from the master password using SHA-256
let key = derive_key(master_password);
let key = derive_key(master_password.as_str());
let cipher = Aes256Gcm::new(&key.into());
// Generate a random 12-byte nonce
@@ -47,7 +157,7 @@ pub fn encrypt_password(plaintext: &str, master_password: &str) -> Result<String
let nonce = Nonce::from_slice(&nonce_bytes);
// Encrypt the plaintext
let plaintext_bytes = plaintext.as_bytes();
let plaintext_bytes = plaintext.as_str().as_bytes();
let ciphertext = cipher.encrypt(nonce, plaintext_bytes).map_err(|e| {
tracing::error!("Encryption failed: {}", e);
EncryptionError::EncryptionFailed(e.to_string())
@@ -59,7 +169,7 @@ pub fn encrypt_password(plaintext: &str, master_password: &str) -> Result<String
combined.extend_from_slice(&ciphertext);
// Encode as base64 for storage
let encoded = general_purpose::STANDARD.encode(&combined);
let encoded = EncryptedPassword(general_purpose::STANDARD.encode(&combined));
tracing::debug!("Password encrypted successfully");
Ok(encoded)
}
@@ -68,19 +178,19 @@ pub fn encrypt_password(plaintext: &str, master_password: &str) -> Result<String
///
/// # Arguments
/// * `encrypted_data` - Base64-encoded string containing nonce and encrypted data
/// * `master_password` - The master password used as decryption key
/// * `master_password` - The hashed master password used as decryption key
///
/// # Returns
/// The decrypted password as a String
pub fn decrypt_password(
encrypted_data: &str,
master_password: &str,
) -> Result<String, EncryptionError> {
encrypted_data: &EncryptedPassword,
master_password: &HashedPassword,
) -> Result<Password, EncryptionError> {
tracing::debug!("Decrypting password with master password");
// Decode the base64 data
let combined = general_purpose::STANDARD
.decode(encrypted_data)
.decode(encrypted_data.as_str())
.map_err(|e| {
tracing::error!("Base64 decoding failed: {}", e);
EncryptionError::Base64DecodeFailed(e)
@@ -99,7 +209,7 @@ pub fn decrypt_password(
let nonce = Nonce::from_slice(nonce_bytes);
// Derive the same key from the master password
let key = derive_key(master_password);
let key = derive_key(master_password.as_str());
let cipher = Aes256Gcm::new(&key.into());
// Decrypt the ciphertext
@@ -109,10 +219,10 @@ pub fn decrypt_password(
})?;
// Convert to string
let result = String::from_utf8(plaintext_bytes).map_err(|e| {
let result = Password(String::from_utf8(plaintext_bytes).map_err(|e| {
tracing::error!("Invalid UTF-8 in decrypted data: {}", e);
EncryptionError::DecryptionFailed(format!("Invalid UTF-8: {}", e))
})?;
})?);
tracing::debug!("Password decrypted successfully");
Ok(result)
@@ -120,7 +230,6 @@ pub fn decrypt_password(
/// Derives a 32-byte key from a password using SHA-256
fn derive_key(password: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
let result = hasher.finalize();
@@ -135,23 +244,23 @@ mod tests {
#[test]
fn test_encrypt_decrypt() {
let password = "my_secret_password";
let master_password = "master_key_123";
let password = Password("my_secret_password".into());
let master_password = HashedPassword::from_password("master_key_123");
let encrypted = encrypt_password(password, master_password).unwrap();
let decrypted = decrypt_password(&encrypted, master_password).unwrap();
let encrypted = encrypt_password(&password, &master_password).unwrap();
let decrypted = decrypt_password(&encrypted, &master_password).unwrap();
assert_eq!(password, decrypted);
}
#[test]
fn test_decrypt_with_wrong_key() {
let password = "my_secret_password";
let master_password = "master_key_123";
let wrong_password = "wrong_key_456";
let password = Password("my_secret_password".into());
let master_password = HashedPassword::from_password("master_key_123");
let wrong_password = HashedPassword::from_password("wrong_key_456");
let encrypted = encrypt_password(password, master_password).unwrap();
let result = decrypt_password(&encrypted, wrong_password);
let encrypted = encrypt_password(&password, &master_password).unwrap();
let result = decrypt_password(&encrypted, &wrong_password);
assert!(result.is_err());
matches!(
@@ -162,22 +271,22 @@ mod tests {
#[test]
fn test_empty_password() {
let password = "";
let master_password = "master_key_123";
let password = Password("".into());
let master_password = HashedPassword::from_password("master_key_123");
let encrypted = encrypt_password(password, master_password).unwrap();
let decrypted = decrypt_password(&encrypted, master_password).unwrap();
let encrypted = encrypt_password(&password, &master_password).unwrap();
let decrypted = decrypt_password(&encrypted, &master_password).unwrap();
assert_eq!(password, decrypted);
}
#[test]
fn test_special_characters() {
let password = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?";
let master_password = "m@st3r_k3y!@#$%^&*()";
let password = Password("p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?".into());
let master_password = HashedPassword::from_password("m@st3r_k3y!@#$%^&*()");
let encrypted = encrypt_password(password, master_password).unwrap();
let decrypted = decrypt_password(&encrypted, master_password).unwrap();
let encrypted = encrypt_password(&password, &master_password).unwrap();
let decrypted = decrypt_password(&encrypted, &master_password).unwrap();
assert_eq!(password, decrypted);
}

View File

@@ -3,8 +3,10 @@ use std::sync::Arc;
use tracing::{error, info, warn};
use crate::{
encryption::decrypt_password, server_storage::ServerStorageService,
user_authorization_service::UserAuthorizationService, AppState,
encryption::{decrypt_password, HashedPassword, Password},
server_storage::ServerStorageService,
user_authorization_service::UserAuthorizationService,
AppState,
};
use jellyfin_api::JellyfinClient;
@@ -60,7 +62,7 @@ impl FederatedUserService {
pub async fn sync_user_to_all_servers(
&self,
username: &str,
password: &str,
password: &Password,
user_id: &str,
) -> Vec<ServerSyncResult> {
let mut results = Vec::new();
@@ -73,7 +75,8 @@ impl FederatedUserService {
};
let config = self.config.read().await;
let admin_password = config.password.clone();
let admin_password: HashedPassword = config.password.clone().into();
drop(config);
for server in servers {
@@ -124,7 +127,7 @@ impl FederatedUserService {
// Authenticate as admin to get token
match client
.authenticate_by_name(&admin.username, &decrypted_admin_password)
.authenticate_by_name(&admin.username, decrypted_admin_password.as_str())
.await
{
Ok(_) => {}
@@ -167,11 +170,13 @@ impl FederatedUserService {
Err(_) => continue,
};
let (status, should_map) =
match user_client.authenticate_by_name(username, password).await {
Ok(_) => (SyncStatus::AlreadyExists, true),
Err(_) => (SyncStatus::ExistsWithDifferentPassword, false),
};
let (status, should_map) = match user_client
.authenticate_by_name(username, password.as_str())
.await
{
Ok(_) => (SyncStatus::AlreadyExists, true),
Err(_) => (SyncStatus::ExistsWithDifferentPassword, false),
};
info!(
"Synced user {} to server {} (Remote ID: {}, Status: {:?})",
@@ -186,7 +191,7 @@ impl FederatedUserService {
server.url.as_str(),
username,
password,
Some(password), // Encrypt with their own password so they can use it
Some(&password.into()), // Encrypt with their own password so they can use it
)
.await
{
@@ -215,7 +220,7 @@ impl FederatedUserService {
}
} else {
// Create user
match client.create_user(username, Some(password)).await {
match client.create_user(username, Some(password.as_str())).await {
Ok(new_user) => {
info!(
"Synced user {} to server {} (Remote ID: {}, Status: Created)",
@@ -229,7 +234,7 @@ impl FederatedUserService {
server.url.as_str(),
username,
password,
Some(password), // Encrypt with their own password so they can use it
Some(&password.into()), // Encrypt with their own password so they can use it
)
.await
{
@@ -305,7 +310,7 @@ impl FederatedUserService {
}
} {
let decrypted_admin_password =
match decrypt_password(&admin.password, admin_password) {
match decrypt_password(&admin.password, &admin_password.into()) {
Ok(p) => p,
Err(e) => {
error!(
@@ -337,7 +342,7 @@ impl FederatedUserService {
};
match client
.authenticate_by_name(&admin.username, &decrypted_admin_password)
.authenticate_by_name(&admin.username, decrypted_admin_password.as_str())
.await
{
Ok(_) => {}

View File

@@ -249,13 +249,15 @@ async fn authenticate_on_server(
let config = state.config.read().await;
let admin_password = &config.password;
let given_password = payload.password.clone();
let (final_username, final_password) = if let Some(mapping) = &server_mapping {
(
mapping.mapped_username.clone(),
state.user_authorization.decrypt_server_mapping_password(
mapping,
&payload.password,
admin_password,
&given_password.clone().into(),
&admin_password.into(),
),
)
} else {
@@ -325,7 +327,7 @@ async fn authenticate_on_server(
// We authenticated sucessfully, now we need to get the user or create it
let user = state
.user_authorization
.get_or_create_user(&payload.username, &payload.password)
.get_or_create_user(&payload.username, &given_password)
.await
.map_err(|e| {
tracing::error!("Error getting user: {}", e);
@@ -345,7 +347,7 @@ async fn authenticate_on_server(
server.url.as_str(),
&final_username,
&final_password,
Some(&payload.password),
Some(&given_password.into()),
)
.await
.map_err(|e| {

View File

@@ -46,8 +46,8 @@ use server_storage::ServerStorageService;
use user_authorization_service::UserAuthorizationService;
use crate::{
config::DATA_DIR, request_preprocessing::preprocess_request, session_storage::SessionStorage,
ui::ui_routes,
config::DATA_DIR, encryption::Password, request_preprocessing::preprocess_request,
session_storage::SessionStorage, ui::ui_routes,
};
use crate::{
config::{AppConfig, MIGRATOR},
@@ -112,7 +112,7 @@ impl AppState {
config.url_prefix.as_ref().map(|prefix| prefix.to_string())
}
pub async fn get_admin_password(&self) -> String {
pub async fn get_admin_password(&self) -> Password {
let config = self.config.read().await;
config.password.clone()
}

View File

@@ -6,7 +6,10 @@ use jellyswarrm_macros::multi_case_struct;
use serde::{Deserialize, Serialize, Serializer};
use serde_with::skip_serializing_none;
use crate::models::{enums::CollectionType, jellyfin::enums::BaseItemKind};
use crate::{
encryption::Password,
models::{enums::CollectionType, jellyfin::enums::BaseItemKind},
};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
@@ -110,7 +113,7 @@ pub struct NowPlayingQueueItem {
pub struct AuthenticateRequest {
pub username: String,
#[serde(rename = "Pw")]
pub password: String,
pub password: Password,
}
#[multi_case_struct(pascal, camel)]

View File

@@ -3,6 +3,8 @@ use sqlx::{FromRow, Row, SqlitePool};
use tracing::info;
use url::Url;
use crate::encryption::EncryptedPassword;
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
pub struct Server {
pub id: i64,
@@ -18,7 +20,7 @@ pub struct ServerAdmin {
pub id: i64,
pub server_id: i64,
pub username: String,
pub password: String,
pub password: EncryptedPassword,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -200,7 +202,7 @@ impl ServerStorageService {
&self,
server_id: i64,
username: &str,
password: &str,
password: &EncryptedPassword,
) -> Result<i64, sqlx::Error> {
let now = chrono::Utc::now();

View File

@@ -8,7 +8,11 @@ use axum::{
use serde::Deserialize;
use tracing::{error, info};
use crate::{encryption::encrypt_password, server_storage::Server, AppState};
use crate::{
encryption::{encrypt_password, Password},
server_storage::Server,
AppState,
};
#[derive(Template)]
#[template(path = "admin/servers.html")]
@@ -43,7 +47,7 @@ pub struct UpdatePriorityForm {
#[derive(Deserialize)]
pub struct AddServerAdminForm {
pub username: String,
pub password: String,
pub password: Password,
}
async fn render_server_list(state: &AppState) -> Result<String, String> {
@@ -270,7 +274,7 @@ pub async fn add_server_admin(
};
match client
.authenticate_by_name(&form.username, &form.password)
.authenticate_by_name(&form.username, form.password.as_str())
.await
{
Ok(user) => {
@@ -287,7 +291,7 @@ pub async fn add_server_admin(
// 3. Encrypt password with admin master password
let config = state.config.read().await;
let encrypted_password = match encrypt_password(&form.password, &config.password) {
let encrypted_password = match encrypt_password(&form.password, &config.password.clone().into()) {
Ok(p) => p,
Err(e) => {
error!("Encryption failed: {}", e);

View File

@@ -10,6 +10,7 @@ use std::collections::HashMap;
use tracing::{error, info};
use crate::{
encryption::Password,
federated_users::ServerSyncResult,
server_storage::Server,
user_authorization_service::{ServerMapping, User},
@@ -48,7 +49,7 @@ pub struct UserItememplate {
#[derive(Deserialize)]
pub struct AddUserForm {
pub username: String,
pub password: String,
pub password: Password,
#[serde(default)]
pub enable_federation: bool,
}
@@ -58,7 +59,7 @@ pub struct AddMappingForm {
pub user_id: String,
pub server_url: String,
pub mapped_username: String,
pub mapped_password: String,
pub mapped_password: Password,
}
pub async fn create_user_with_mappings(
@@ -228,7 +229,7 @@ pub async fn get_user_list(State(state): State<AppState>) -> impl IntoResponse {
/// Add user
pub async fn add_user(State(state): State<AppState>, Form(form): Form<AddUserForm>) -> Response {
if form.username.trim().is_empty() || form.password.is_empty() {
if form.username.trim().is_empty() || form.password.as_str().is_empty() {
return (
StatusCode::BAD_REQUEST,
Html("<div class=\"alert alert-error\">Username and password required</div>"),
@@ -336,7 +337,7 @@ pub async fn add_mapping(
State(state): State<AppState>,
Form(form): Form<AddMappingForm>,
) -> Response {
if form.mapped_username.trim().is_empty() || form.mapped_password.is_empty() {
if form.mapped_username.trim().is_empty() || form.mapped_password.as_str().is_empty() {
return (
StatusCode::BAD_REQUEST,
Html("<div class=\"alert alert-error\">Mapping credentials required</div>"),
@@ -354,7 +355,7 @@ pub async fn add_mapping(
&form.server_url,
&form.mapped_username,
&form.mapped_password,
Some(admin_password),
Some(&admin_password.into()),
)
.await
{

View File

@@ -9,7 +9,10 @@ use serde::{Deserialize, Serialize};
use tokio::{sync::RwLock, task};
use tracing::{error, info};
use crate::{config::AppConfig, user_authorization_service::UserAuthorizationService};
use crate::{
config::AppConfig, encryption::HashedPassword,
user_authorization_service::UserAuthorizationService,
};
mod routes;
@@ -25,7 +28,7 @@ pub enum UserRole {
pub struct User {
pub id: String,
pub username: String,
pub password: String,
pub password_hash: HashedPassword,
pub role: UserRole,
}
@@ -70,10 +73,10 @@ impl AuthUser for User {
}
fn session_auth_hash(&self) -> &[u8] {
self.password.as_bytes() // We use the password hash as the auth
// hash--what this means
// is when the user changes their password the
// auth session becomes invalid.
self.password_hash.as_str().as_bytes() // We use the password hash as the auth
// hash--what this means
// is when the user changes their password the
// auth session becomes invalid.
}
}
@@ -115,15 +118,16 @@ impl AuthnBackend for Backend {
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let password = creds.password.into();
let config = self.config.read().await;
info!("Authenticating user: {}", creds.username);
if creds.username == config.username && creds.password == config.password {
if creds.username == config.username && password == config.password {
info!("Admin authentication successful");
// If the password is correct, we return the default user.
let user = User {
id: "admin".to_string(),
username: creds.username,
password: config.password.clone(),
password_hash: config.password.clone().into(),
role: UserRole::Admin,
};
return Ok(Some(user));
@@ -131,14 +135,14 @@ impl AuthnBackend for Backend {
if let Some(user) = self
.user_auth
.get_user_by_credentials(&creds.username, &creds.password)
.get_user_by_credentials(&creds.username, &password)
.await?
{
info!("User authentication successful: {}", user.original_username);
let user = User {
id: user.id,
username: user.original_username,
password: user.original_password_hash,
password_hash: user.original_password_hash,
role: UserRole::User,
};
return Ok(Some(user));
@@ -154,7 +158,7 @@ impl AuthnBackend for Backend {
return Ok(Some(User {
id: "admin".to_string(),
username: config.username.clone(),
password: config.password.clone(),
password_hash: config.password.clone().into(),
role: UserRole::Admin,
}));
}
@@ -163,7 +167,7 @@ impl AuthnBackend for Backend {
let user = User {
id: user.id,
username: user.original_username,
password: user.original_password_hash,
password_hash: user.original_password_hash,
role: UserRole::User,
};
return Ok(Some(user));

View File

@@ -137,6 +137,18 @@ pub fn ui_routes() -> axum::Router<AppState> {
post(user::servers::connect_server),
)
.route("/user/media", get(user::media::get_user_media))
.route(
"/user/media/server/{server_id}/libraries",
get(user::media::get_server_libraries),
)
.route(
"/user/media/server/{server_id}/library/{library_id}/items",
get(user::media::get_library_items),
)
.route(
"/user/media/image/{server_id}/{item_id}",
get(user::media::proxy_media_image),
)
.route("/user/profile", get(user::profile::get_user_profile))
.route(
"/user/profile/password",

View File

@@ -0,0 +1,25 @@
{% for item in items %}
<article class="media-card">
{% if let Some(tags) = item.image_tags %}
{% if tags.contains_key("Primary") %}
<img src="/{{ ui_route }}/user/media/image/{{ server_id }}/{{ item.id }}" loading="lazy" alt="{{ item.name }}">
{% else %}
<div class="placeholder-image"><i class="fas fa-film"></i></div>
{% endif %}
{% else %}
<div class="placeholder-image"><i class="fas fa-film"></i></div>
{% endif %}
<footer title="{{ item.name }}">
{{ item.name }}
</footer>
</article>
{% endfor %}
{% if let Some(page) = next_page %}
<div class="load-more-sentinel"
hx-get="/{{ ui_route }}/user/media/server/{{ server_id }}/library/{{ library_id }}/items?page={{ page }}"
hx-trigger="revealed"
hx-swap="outerHTML">
<div aria-busy="true" style="min-width: 100px; height: 100%; display: flex; align-items: center; justify-content: center;">Loading...</div>
</div>
{% endif %}

View File

@@ -0,0 +1,14 @@
<div class="libraries-grid">
{% for library in libraries %}
<details>
<summary><strong>{{ library.name }} ({{ library.count }})</strong></summary>
<div class="library-items-container horizontal-scroll-container"
id="library-{{ server_id }}-{{ library.id }}"
hx-get="/{{ ui_route }}/user/media/server/{{ server_id }}/library/{{ library.id }}/items"
hx-trigger="intersect once"
hx-swap="innerHTML">
<div aria-busy="true">Loading items...</div>
</div>
</details>
{% endfor %}
</div>

View File

@@ -1,38 +1,104 @@
{% for server in servers %}
<article>
<header>
<strong>{{ server.server_name }}</strong>
{% if let Some(error) = server.error %}
<span style="color: #dc3545; float: right;">{{ error }}</span>
{% endif %}
</header>
{% if !server.libraries.is_empty() %}
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem;">
{% for lib in server.libraries %}
<div style="text-align: center; padding: 1rem; border: 1px solid var(--pico-muted-border-color); border-radius: 0.5rem;">
<i class="fas fa-folder fa-2x" style="margin-bottom: 0.5rem; color: var(--pico-primary-background);"></i>
<div style="font-weight: bold;">{{ lib.name }}</div>
<small style="color: var(--pico-muted-color);">
{% match lib.collection_type %}
{% when Some with (ctype) %}
{{ ctype }}
{% when None %}
unknown
{% endmatch %}
</small>
</div>
{% endfor %}
<div class="container">
<h1>My Media</h1>
<div id="servers-container">
{% for server in servers %}
<article class="server-section" id="server-{{ server.id }}">
<header>
<h2><i class="fas fa-server"></i> {{ server.name }}</h2>
</header>
<div class="libraries-container"
hx-get="/{{ ui_route }}/user/media/server/{{ server.id }}/libraries"
hx-trigger="load"
hx-swap="innerHTML">
<div aria-busy="true">Loading libraries...</div>
</div>
{% else %}
{% if server.error.is_none() %}
<p style="color: var(--pico-muted-color);">No libraries found.</p>
{% endif %}
{% endif %}
</article>
{% endfor %}
{% if servers.is_empty() %}
<div style="text-align: center; padding: 2rem;">
<p>No servers mapped.</p>
</article>
{% endfor %}
</div>
{% endif %}
</div>
<style>
.server-section {
margin-bottom: 2rem;
}
.libraries-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.horizontal-scroll-container {
display: flex;
flex-direction: row;
overflow-x: auto;
gap: 1rem;
padding: 0.5rem 0.5rem 1rem 0.5rem;
align-items: start;
scrollbar-width: thin; /* Firefox */
}
/* Webkit scrollbar styling */
.horizontal-scroll-container::-webkit-scrollbar {
height: 8px;
}
.horizontal-scroll-container::-webkit-scrollbar-track {
background: var(--card-background-color);
border-radius: 4px;
}
.horizontal-scroll-container::-webkit-scrollbar-thumb {
background-color: var(--secondary);
border-radius: 4px;
}
.media-card {
flex: 0 0 140px;
width: 140px;
background: var(--card-background-color);
border-radius: var(--border-radius);
overflow: hidden;
transition: transform 0.2s;
}
.media-card:hover {
transform: scale(1.05);
}
.media-card img {
width: 100%;
aspect-ratio: 2/3;
object-fit: cover;
display: block;
}
.media-card footer {
padding: 0.5rem;
font-size: 0.8rem;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.placeholder-image {
width: 100%;
aspect-ratio: 2/3;
background-color: var(--secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: var(--contrast);
}
.load-more-sentinel {
flex: 0 0 50px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -1,6 +1,9 @@
use jellyfin_api::JellyfinClient;
use std::sync::Arc;
use crate::{server_storage::Server, AppState};
use jellyfin_api::JellyfinClient;
use tracing::info;
use crate::{config::CLIENT_STORAGE, server_storage::Server, AppState};
pub async fn authenticate_user_on_server(
state: &AppState,
@@ -8,22 +11,29 @@ pub async fn authenticate_user_on_server(
server: &Server,
) -> Result<
(
JellyfinClient,
Arc<JellyfinClient>,
jellyfin_api::models::User,
jellyfin_api::models::PublicSystemInfo,
),
String,
> {
// Always check public system info first to get version and name
let server_url = server.url.clone();
let client_info = crate::config::CLIENT_INFO.clone();
let server_url = server.url.clone();
let (public_info, client) = match JellyfinClient::new(server_url.as_str(), client_info) {
Ok(c) => match c.get_public_system_info().await {
Ok(info) => (info, c),
Err(_) => return Err("Server offline or unreachable".to_string()),
},
Err(e) => return Err(format!("Failed to create jellyfin client: {}", e)),
// Check cache first
let client = CLIENT_STORAGE
.get(
server_url.as_ref(),
client_info,
Some(user.username.as_str()),
)
.await
.map_err(|e| format!("Failed to get client from storage: {}", e))?;
// Always check public system info first to get version and name
let public_info = match client.get_public_system_info().await {
Ok(info) => info,
Err(_) => return Err("Server offline or unreachable".to_string()),
};
// Check for mapping and try to authenticate
@@ -39,14 +49,32 @@ pub async fn authenticate_user_on_server(
let admin_password = state.get_admin_password().await;
let password = state.user_authorization.decrypt_server_mapping_password(
&mapping,
&user.password,
&admin_password,
info!(
"Authenticating user '{}' on server '{}'.",
user.username, server.id
);
let password = state.user_authorization.decrypt_server_mapping_password(
&mapping,
&user.password_hash,
&admin_password.into(),
);
if client.get_token().is_some() {
// Try to validate existing session
match client.get_me().await {
Ok(jellyfin_user) => {
return Ok((client, jellyfin_user, public_info));
}
Err(e) => {
tracing::warn!("Existing session invalid for server {}: {}", server.id, e);
// Fall through to re-authenticate
}
}
}
match client
.authenticate_by_name(&mapping.mapped_username, &password)
.authenticate_by_name(&mapping.mapped_username, password.as_str())
.await
{
Ok(jellyfin_user) => Ok((client, jellyfin_user, public_info)),

View File

@@ -1,10 +1,11 @@
use askama::Template;
use axum::{
extract::State,
http::StatusCode,
response::{Html, IntoResponse},
body::Body,
extract::{Path, Query, State},
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
};
use jellyfin_api::models::MediaFolder;
use jellyfin_api::models::BaseItem;
use tracing::error;
use crate::{
@@ -12,16 +13,45 @@ use crate::{
AppState,
};
pub struct ServerLibraries {
pub server_name: String,
pub libraries: Vec<MediaFolder>,
pub error: Option<String>,
pub struct ServerInfo {
pub id: i64,
pub name: String,
}
pub struct LibraryWithCount {
pub id: String,
pub name: String,
pub count: i32,
}
#[derive(Template)]
#[template(path = "user/user_media.html")]
pub struct UserMediaTemplate {
pub servers: Vec<ServerLibraries>,
pub servers: Vec<ServerInfo>,
pub ui_route: String,
}
#[derive(Template)]
#[template(path = "user/server_libraries.html")]
pub struct ServerLibrariesTemplate {
pub server_id: i64,
pub libraries: Vec<LibraryWithCount>,
pub ui_route: String,
}
#[derive(Template)]
#[template(path = "user/library_items.html")]
pub struct LibraryItemsTemplate {
pub server_id: i64,
pub library_id: String,
pub items: Vec<BaseItem>,
pub next_page: Option<i32>,
pub ui_route: String,
}
#[derive(serde::Deserialize)]
pub struct Pagination {
pub page: Option<i32>,
}
pub async fn get_user_media(
@@ -36,42 +66,17 @@ pub async fn get_user_media(
}
};
let mut server_libraries: Vec<ServerLibraries> = Vec::new();
for server in servers {
let mut libraries = Vec::new();
let mut error_msg = None;
// Authenticate user on the server
if let Ok((client, _, _)) = authenticate_user_on_server(&state, &user, &server).await {
match client.get_media_folders().await {
Ok(folders) => {
libraries = folders;
if let Err(e) = client.logout().await {
error!("Failed to logout from server {}: {}", server.name, e);
}
}
Err(e) => {
error!(
"Failed to get media folders from server {}: {}",
server.name, e
);
error_msg = Some(format!("Error fetching media folders: {}", e));
}
}
} else {
error_msg = Some("Failed to authenticate on server".to_string());
}
server_libraries.push(ServerLibraries {
server_name: server.name,
libraries,
error: error_msg,
});
}
let server_infos = servers
.into_iter()
.map(|s| ServerInfo {
id: s.id,
name: s.name,
})
.collect();
let template = UserMediaTemplate {
servers: server_libraries,
servers: server_infos,
ui_route: state.get_ui_route().await,
};
match template.render() {
Ok(html) => Html(html).into_response(),
@@ -81,3 +86,206 @@ pub async fn get_user_media(
}
}
}
pub async fn get_server_libraries(
State(state): State<AppState>,
AuthenticatedUser(user): AuthenticatedUser,
Path(server_id): Path<i64>,
) -> impl IntoResponse {
let server = match state.server_storage.get_server_by_id(server_id).await {
Ok(Some(s)) => s,
_ => return StatusCode::NOT_FOUND.into_response(),
};
if let Ok((client, jellyfin_user, _)) =
authenticate_user_on_server(&state, &user, &server).await
{
match client.get_media_folders(Some(&jellyfin_user.id)).await {
Ok(folders) => {
let mut libraries = Vec::new();
for folder in folders {
if folder.collection_type.as_deref() == Some("playlists")
|| folder.name.to_lowercase() == "playlists"
{
continue;
}
let count = match client
.get_items(
&jellyfin_user.id,
Some(&folder.id),
true,
Some(vec!["Movie".to_string(), "Series".to_string()]),
Some(0),
None,
None,
None,
)
.await
{
Ok(resp) => resp.total_record_count,
Err(_) => 0,
};
libraries.push(LibraryWithCount {
id: folder.id,
name: folder.name,
count,
});
}
let template = ServerLibrariesTemplate {
server_id,
libraries,
ui_route: state.get_ui_route().await,
};
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render server libraries template: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}
Err(e) => {
error!("Failed to get media folders: {}", e);
(StatusCode::BAD_GATEWAY, "Failed to fetch libraries").into_response()
}
}
} else {
(StatusCode::UNAUTHORIZED, "Failed to authenticate").into_response()
}
}
pub async fn get_library_items(
State(state): State<AppState>,
AuthenticatedUser(user): AuthenticatedUser,
Path((server_id, library_id)): Path<(i64, String)>,
Query(pagination): Query<Pagination>,
) -> impl IntoResponse {
let server = match state.server_storage.get_server_by_id(server_id).await {
Ok(Some(s)) => s,
_ => return StatusCode::NOT_FOUND.into_response(),
};
let page = pagination.page.unwrap_or(0);
let limit = 20;
let start_index = page * limit;
if let Ok((client, jellyfin_user, _)) =
authenticate_user_on_server(&state, &user, &server).await
{
match client
.get_items(
&jellyfin_user.id,
Some(&library_id),
true,
Some(vec!["Movie".to_string(), "Series".to_string()]),
Some(limit),
Some(start_index),
Some("DateCreated".to_string()),
Some("Descending".to_string()),
)
.await
{
Ok(response) => {
let total_items = response.total_record_count;
let total_pages = (total_items as f64 / limit as f64).ceil() as i32;
let next_page = if (page + 1) < total_pages {
Some(page + 1)
} else {
None
};
let template = LibraryItemsTemplate {
server_id,
library_id,
items: response.items,
next_page,
ui_route: state.get_ui_route().await,
};
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render library items template: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
}
}
}
Err(e) => {
error!("Failed to get items: {}", e);
(StatusCode::BAD_GATEWAY, "Failed to fetch items").into_response()
}
}
} else {
(StatusCode::UNAUTHORIZED, "Failed to authenticate").into_response()
}
}
pub async fn proxy_media_image(
State(state): State<AppState>,
AuthenticatedUser(user): AuthenticatedUser,
Path((server_id, item_id)): Path<(i64, String)>,
) -> impl IntoResponse {
// Get server
let server = match state.server_storage.get_server_by_id(server_id).await {
Ok(Some(s)) => s,
_ => return StatusCode::NOT_FOUND.into_response(),
};
// Authenticate
let (client, _, _) = match authenticate_user_on_server(&state, &user, &server).await {
Ok(res) => res,
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
};
// Construct image URL
// We need to access the base_url from client, but it's private.
// However, we have server.url.
let image_url = format!(
"{}/Items/{}/Images/Primary",
server.url.as_str().trim_end_matches('/'),
item_id
);
// Fetch image using the client's internal http client would be best, but we can't access it.
// We can use state.reqwest_client but we need the token.
let token = client.get_token().unwrap_or_default();
// Build auth header manually since we are using a raw request
// Or we can add a method to JellyfinClient to fetch raw resource.
// For now, let's use state.reqwest_client
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\", Token=\"{}\"",
env!("CARGO_PKG_VERSION"),
token
);
match state
.reqwest_client
.get(&image_url)
.header(header::AUTHORIZATION, auth_header)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let headers = resp.headers().clone();
let body = resp.bytes().await.unwrap_or_default();
let mut response = Response::builder().status(status);
if let Some(ct) = headers.get(header::CONTENT_TYPE) {
response = response.header(header::CONTENT_TYPE, ct);
}
// Cache control
response = response.header(header::CACHE_CONTROL, "public, max-age=3600");
response
.body(Body::from(body))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
Err(_) => StatusCode::BAD_GATEWAY.into_response(),
}
}

View File

@@ -1,4 +1,4 @@
mod common;
pub mod common;
pub mod media;
pub mod profile;
pub mod servers;

View File

@@ -8,7 +8,7 @@ use axum::{
use serde::Deserialize;
use tracing::error;
use crate::{ui::auth::AuthenticatedUser, AppState};
use crate::{encryption::Password, ui::auth::AuthenticatedUser, AppState};
#[derive(Template)]
#[template(path = "user/user_profile.html")]
@@ -19,9 +19,9 @@ pub struct UserProfileTemplate {
#[derive(Deserialize)]
pub struct ChangePasswordForm {
pub current_password: String,
pub new_password: String,
pub confirm_password: String,
pub current_password: Password,
pub new_password: Password,
pub confirm_password: Password,
}
pub async fn get_user_profile(

View File

@@ -10,6 +10,7 @@ use serde::Deserialize;
use tracing::{error, info, warn};
use crate::{
encryption::Password,
server_storage::Server,
ui::{auth::AuthenticatedUser, user::common::authenticate_user_on_server},
AppState,
@@ -27,7 +28,7 @@ pub struct UserServerListTemplate {
#[derive(Deserialize)]
pub struct ConnectServerForm {
pub username: String,
pub password: String,
pub password: Password,
}
#[derive(Template)]
@@ -122,7 +123,7 @@ pub async fn connect_server(
}
};
match client.authenticate_by_name(&form.username, &form.password).await {
match client.authenticate_by_name(&form.username, form.password.as_str()).await {
Ok(_) => {
// Credentials valid, create mapping
match state
@@ -132,7 +133,7 @@ pub async fn connect_server(
server.url.as_str(),
&form.username,
&form.password,
Some(&user.password),
Some(&user.password_hash),
)
.await
{

View File

@@ -1,8 +1,9 @@
use sha2::{Digest, Sha256};
use sqlx::{FromRow, Row, SqlitePool};
use tracing::{error, info, warn};
use crate::encryption::{decrypt_password, encrypt_password};
use crate::encryption::{
decrypt_password, encrypt_password, EncryptedPassword, HashedPassword, Password,
};
use crate::models::{generate_token, Authorization};
use crate::server_storage::Server;
@@ -11,7 +12,7 @@ pub struct User {
pub id: String,
pub virtual_key: String,
pub original_username: String,
pub original_password_hash: String,
pub original_password_hash: HashedPassword,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -22,7 +23,7 @@ pub struct ServerMapping {
pub user_id: String,
pub server_url: String,
pub mapped_username: String,
pub mapped_password: String,
pub mapped_password: EncryptedPassword,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -184,9 +185,9 @@ impl UserAuthorizationService {
pub async fn get_or_create_user(
&self,
username: &str,
password: &str,
password: &Password,
) -> Result<User, sqlx::Error> {
let password_hash = Self::hash_password(password);
let password_hash: HashedPassword = password.into();
// Try to find existing user
if let Some(user) = self.get_user_by_credentials(username, password).await? {
@@ -264,9 +265,9 @@ impl UserAuthorizationService {
pub async fn get_user_by_credentials(
&self,
username: &str,
password: &str,
password: &Password,
) -> Result<Option<User>, sqlx::Error> {
let password_hash = Self::hash_password(password);
let password_hash: HashedPassword = password.into();
let user = sqlx::query_as::<_, User>(
r#"
@@ -289,8 +290,8 @@ impl UserAuthorizationService {
user_id: &str,
server_url: &str,
mapped_username: &str,
mapped_password: &str,
master_password: Option<&str>,
mapped_password: &Password,
master_password: Option<&HashedPassword>,
) -> Result<i64, sqlx::Error> {
let now = chrono::Utc::now();
@@ -299,11 +300,12 @@ impl UserAuthorizationService {
Ok(encrypted) => encrypted,
Err(e) => {
warn!("Failed to encrypt password: {}. Storing as plaintext.", e);
mapped_password.to_string()
EncryptedPassword::from_raw(mapped_password.as_str().into())
}
}
} else {
mapped_password.to_string()
warn!("No encryption password provided. Storing as plaintext!");
EncryptedPassword::from_raw(mapped_password.as_str().into())
};
let result = sqlx::query(
@@ -334,9 +336,9 @@ impl UserAuthorizationService {
pub fn decrypt_server_mapping_password(
&self,
mapping: &ServerMapping,
user_password: &str,
admin_password: &str,
) -> String {
user_password: &HashedPassword,
admin_password: &HashedPassword,
) -> Password {
// Try user password first
if let Ok(decrypted) = decrypt_password(&mapping.mapped_password, user_password) {
return decrypted;
@@ -352,7 +354,7 @@ impl UserAuthorizationService {
"Failed to decrypt password for mapping {}. Assuming plaintext.",
mapping.id
);
mapping.mapped_password.clone()
mapping.mapped_password.clone().into_inner().into()
}
/// Get server mapping
@@ -613,13 +615,6 @@ impl UserAuthorizationService {
Ok(sessions)
}
/// Hash a password using SHA-256
fn hash_password(password: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hex::encode(hasher.finalize())
}
/// List all users
pub async fn list_users(&self) -> Result<Vec<User>, sqlx::Error> {
let users = sqlx::query_as::<_, User>(
@@ -656,14 +651,14 @@ impl UserAuthorizationService {
pub async fn update_user_password(
&self,
user_id: &str,
old_password: &str,
new_password: &str,
admin_password: &str,
old_password: &Password,
new_password: &Password,
admin_password: &Password,
) -> Result<bool, sqlx::Error> {
let mut transaction = self.pool.begin().await?;
// 1. Update user password hash
let password_hash = Self::hash_password(new_password);
let password_hash: HashedPassword = new_password.into();
let now = chrono::Utc::now();
let res = sqlx::query(
@@ -695,19 +690,23 @@ impl UserAuthorizationService {
.fetch_all(&mut *transaction)
.await?;
let old_password = old_password.into();
let admin_password = admin_password.into();
for mapping in mappings {
// Decrypt with old credentials
let decrypted_password =
self.decrypt_server_mapping_password(&mapping, old_password, admin_password);
self.decrypt_server_mapping_password(&mapping, &old_password, &admin_password);
// Encrypt with new password
let new_encrypted_password = match encrypt_password(&decrypted_password, new_password) {
Ok(p) => p,
Err(e) => {
error!("Failed to encrypt password during update: {}", e);
return Err(sqlx::Error::Protocol(format!("Encryption failed: {}", e)));
}
};
let new_encrypted_password =
match encrypt_password(&decrypted_password, &new_password.into()) {
Ok(p) => p,
Err(e) => {
error!("Failed to encrypt password during update: {}", e);
return Err(sqlx::Error::Protocol(format!("Encryption failed: {}", e)));
}
};
// Update mapping in DB
sqlx::query(
@@ -733,13 +732,12 @@ impl UserAuthorizationService {
pub async fn verify_user_password(
&self,
user_id: &str,
password: &str,
password: &Password,
) -> Result<bool, sqlx::Error> {
let user = self.get_user_by_id(user_id).await?;
if let Some(user) = user {
let password_hash = Self::hash_password(password);
Ok(user.original_password_hash == password_hash)
Ok(user.original_password_hash.verify(password.as_str()))
} else {
Ok(false)
}
@@ -917,7 +915,7 @@ mod tests {
// Create user
let user = service
.get_or_create_user("testuser", "testpass")
.get_or_create_user("testuser", &"testpass".into())
.await
.unwrap();
@@ -927,7 +925,7 @@ mod tests {
&user.id,
"http://localhost:8096",
"mappeduser",
"mappedpass",
&"mappedpass".into(),
None,
)
.await
@@ -1060,7 +1058,7 @@ mod tests {
// Create user
let user = service
.get_or_create_user("testuser", "testpass")
.get_or_create_user("testuser", &"testpass".into())
.await
.unwrap();
@@ -1070,7 +1068,7 @@ mod tests {
&user.id,
"http://localhost:8096",
"mappeduser",
"mappedpass",
&"mappedpass".into(),
None,
)
.await
@@ -1154,7 +1152,7 @@ mod tests {
// Create user
let user = service
.get_or_create_user("testuser", "testpass")
.get_or_create_user("testuser", &"testpass".into())
.await
.unwrap();
@@ -1164,7 +1162,7 @@ mod tests {
&user.id,
"http://localhost:8096",
"mappeduser",
"mappedpass",
&"mappedpass".into(),
None,
)
.await
@@ -1269,7 +1267,7 @@ mod tests {
// Create user
let user = service
.get_or_create_user("testuser", "testpass")
.get_or_create_user("testuser", &"testpass".into())
.await
.unwrap();
@@ -1279,7 +1277,7 @@ mod tests {
&user.id,
"http://localhost:8096",
"mappeduser1",
"mappedpass1",
&"mappedpass1".into(),
None,
)
.await
@@ -1290,7 +1288,7 @@ mod tests {
&user.id,
"http://localhost:8097",
"mappeduser2",
"mappedpass2",
&"mappedpass2".into(),
None,
)
.await
@@ -1395,7 +1393,7 @@ mod tests {
// Create user
let user = service
.get_or_create_user("testuser", "testpass")
.get_or_create_user("testuser", &"testpass".into())
.await
.unwrap();
@@ -1405,7 +1403,7 @@ mod tests {
&user.id,
"http://localhost:8096",
"mappeduser",
"mappedpass",
&"mappedpass".into(),
None,
)
.await
@@ -1502,14 +1500,14 @@ mod tests {
// Create user
let user = service
.get_or_create_user("testuser", "testpass")
.get_or_create_user("testuser", &"testpass".into())
.await
.unwrap();
// Add mappings for both servers
for url in ["http://localhost:8096", "http://localhost:8097"] {
service
.add_server_mapping(&user.id, url, "mappeduser", "mappedpass", None)
.add_server_mapping(&user.id, url, "mappeduser", &"mappedpass".into(), None)
.await
.unwrap();
}