cleanup a bit more

This commit is contained in:
Lukas Kreussel
2025-11-23 18:24:26 +01:00
parent a89227b7c7
commit 67f556ed42
6 changed files with 362 additions and 159 deletions

View File

@@ -12,6 +12,7 @@ use crate::{
pub enum SyncStatus {
Created,
AlreadyExists,
ExistsWithDifferentPassword,
Failed,
Skipped,
Deleted,
@@ -145,42 +146,65 @@ impl FederatedUserService {
.await
{
Ok((remote_user_id, created)) => {
let status = if created {
SyncStatus::Created
let (status, should_map) = if created {
(SyncStatus::Created, true)
} else {
SyncStatus::AlreadyExists
// User exists. Check if password matches.
match self
.check_user_password(server.url.as_ref(), username, password)
.await
{
Ok(true) => (SyncStatus::AlreadyExists, true),
Ok(false) => (SyncStatus::ExistsWithDifferentPassword, false),
Err(e) => {
warn!(
"Failed to check password for existing user {} on {}: {}",
username, server.name, e
);
// Assume mismatch or failure, don't map to be safe
(SyncStatus::ExistsWithDifferentPassword, false)
}
}
};
info!(
"Synced user {} to server {} (Remote ID: {}, Created: {})",
username, server.name, remote_user_id, created
"Synced user {} to server {} (Remote ID: {}, Status: {:?})",
username, server.name, remote_user_id, status
);
if let Err(e) = self
.user_authorization
.add_server_mapping(
user_id,
server.url.as_str(),
username,
password,
Some(password), // Encrypt with their own password so they can use it
)
.await
{
error!(
"Failed to create local mapping for synced user on server {}: {}",
server.name, e
);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status: SyncStatus::Failed,
message: Some(format!("Failed to save local mapping: {}", e)),
});
if should_map {
if let Err(e) = self
.user_authorization
.add_server_mapping(
user_id,
server.url.as_str(),
username,
password,
Some(password), // Encrypt with their own password so they can use it
)
.await
{
error!(
"Failed to create local mapping for synced user on server {}: {}",
server.name, e
);
results.push(ServerSyncResult {
server_name: server.name.clone(),
status: SyncStatus::Failed,
message: Some(format!("Failed to save local mapping: {}", e)),
});
} else {
results.push(ServerSyncResult {
server_name: server.name.clone(),
status,
message: None,
});
}
} else {
results.push(ServerSyncResult {
server_name: server.name.clone(),
status,
message: None,
message: Some("User exists with different password".to_string()),
});
}
}
@@ -321,6 +345,45 @@ impl FederatedUserService {
results
}
async fn check_user_password(
&self,
server_url: &str,
username: &str,
password: &str,
) -> Result<bool, anyhow::Error> {
let auth_url = format!(
"{}/Users/AuthenticateByName",
server_url.trim_end_matches('/')
);
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\"",
env!("CARGO_PKG_VERSION")
);
let response = self
.reqwest_client
.post(&auth_url)
.header("Authorization", auth_header)
.json(&serde_json::json!({
"Username": username,
"Pw": password
}))
.send()
.await?;
if response.status().is_success() {
Ok(true)
} else if response.status() == reqwest::StatusCode::UNAUTHORIZED {
Ok(false)
} else {
Err(anyhow::anyhow!(
"Authentication check failed: {}",
response.status()
))
}
}
async fn authenticate_as_admin(
&self,
server_url: &str,

View File

@@ -0,0 +1,135 @@
/* Global Styles for Jellyswarrm */
/* --- Components --- */
/* Icon Buttons */
.icon-btn {
--_size: 1.1rem;
font-size: var(--_size);
line-height: 1;
padding: .5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--pico-muted-border-color, #555);
background: transparent;
cursor: pointer;
border-radius: 0.25rem;
transition: all 0.2s;
color: var(--pico-color);
}
.icon-btn:hover {
background: var(--pico-card-background-color);
transform: translateY(-1px);
}
.icon-btn.danger {
color: var(--pico-del-color, #e55353);
border-color: var(--pico-del-color, #e55353);
}
.icon-btn.danger:hover {
background: rgba(229, 83, 83, 0.1);
}
/* Badges & Chips */
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: .25em .55em;
font-size: .75rem;
font-weight: 500;
line-height: 1;
border-radius: 1rem;
color: #fff;
background: var(--pico-primary-background);
}
.badge.muted, .status-chip {
background: var(--pico-muted-border-color, rgba(127, 127, 127, .15));
color: var(--pico-color);
}
.badge.success { background-color: #2e7d32; color: white; }
.badge.warning { background-color: #ffc107; color: black; }
.badge.danger { background-color: #dc3545; color: white; }
.badge.secondary { background-color: #6c757d; color: white; }
/* Status Container */
.status-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Filter Bar */
.filter-bar {
margin-bottom: 1rem;
}
/* User Key */
.user-key {
font-size: .6rem;
opacity: .6;
word-break: break-all;
padding-top: .25em;
}
/* Danger Text/Icon */
.text-danger, .danger {
color: var(--pico-del-color, #d9534f);
}
i.danger {
cursor: pointer;
}
/* Empty State */
.empty-state {
padding: 1rem;
border: 2px dashed var(--pico-border-color, #ccc);
border-radius: .6rem;
text-align: center;
}
/* Sync Report */
.sync-report {
margin-bottom: 1rem;
padding: 1rem;
background: var(--pico-card-background-color);
border: 1px solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius);
}
.sync-report ul {
margin-bottom: 0;
padding-left: 1rem;
}
/* --- Animations --- */
.htmx-added {
animation: fadeInSoft 160ms ease-out, highlightSoft 900ms ease-out;
}
.htmx-swapping {
animation: fadeOutSoft 140ms ease-in forwards;
pointer-events: none;
}
@keyframes fadeInSoft {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeOutSoft {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-2px); }
}
@keyframes highlightSoft {
0% { background: var(--pico-mark-background-color, #fff8d9); }
100% { background: transparent; }
}
@media (prefers-reduced-motion: reduce) {
.htmx-added, .htmx-swapping { animation: none; }
}

View File

@@ -1,13 +1,3 @@
<style>
/* Lightweight shared styles (can be moved to global if reused) */
.icon-btn {--_size:1.1rem; font-size:var(--_size); line-height:1; padding:.5rem; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--muted-border,#555); background:transparent; cursor:pointer; border-radius: 0.25rem; transition: all 0.2s;}
.icon-btn:hover {background:rgba(255,255,255,.1); transform: translateY(-1px);}
.icon-btn.danger {color:var(--del,#e55353); border-color:var(--del,#e55353);}
.icon-btn.danger:hover {background:rgba(229, 83, 83, 0.1);}
.status-chip {font-size:.75rem; padding:.25rem .75rem; border-radius:1rem; background:var(--pill-bg,rgba(127,127,127,.15)); display:inline-flex; align-items: center; justify-content: center; min-width:80px; font-weight: 500;}
.status-container { display: flex; align-items: center; gap: 0.75rem; }
</style>
{% if servers.is_empty() %}
<article>
<header><h4>No servers configured</h4></header>
@@ -47,7 +37,7 @@
<span style="color: #666;">Checking...</span>
</span>
{% if item.has_admin %}
<span class="status-chip" style="background-color: #2e7d32; color: white;">
<span class="badge success">
<i class="fas fa-shield-alt" style="margin-right: 0.3rem; font-size: 0.7rem;"></i> Federated
</span>
{% endif %}

View File

@@ -1,84 +1,3 @@
<style>
/* Minimal additions on top of Pico defaults */
.filter-bar {
margin-bottom: 1rem;
}
.badge {
display: inline-block;
padding: .25em .55em;
font-size: .65em;
background: var(--pico-primary, #0b6efd);
color: #fff;
border-radius: 1rem;
line-height: 1;
}
.user-key {
font-size: .6rem;
opacity: .6;
word-break: break-all;
padding-top: .25em;
}
.pill {
display: inline-block;
padding: .25em .7em;
background: var(--pico-muted-border-color, rgba(127, 127, 127, .15));
border-radius: 1rem;
font-size: .7em;
}
.danger {
color: var(--pico-del-color, #d9534f);
border-color: var(--pico-del-color, #d9534f);
cursor: pointer;
}
.empty-state {
padding: 1rem;
border: 2px dashed var(--pico-border-color, #ccc);
border-radius: .6rem;
text-align: center;
}
/* Softer animations */
.htmx-added {
animation: fadeInSoft 160ms ease-out, highlightSoft 900ms ease-out;
}
.htmx-swapping {
animation: fadeOutSoft 140ms ease-in forwards;
pointer-events:none;
}
@keyframes fadeInSoft {
from { opacity:0; transform:translateY(2px); }
to { opacity:1; transform:translateY(0); }
}
@keyframes fadeOutSoft {
from { opacity:1; transform:translateY(0); }
to { opacity:0; transform:translateY(-2px); }
}
@keyframes highlightSoft {
0% { background:#fff8d9; }
100% { background:transparent; }
}
@media (prefers-reduced-motion: reduce) {
.htmx-added, .htmx-swapping { animation:none; }
}
.sync-report {
margin-bottom: 1rem;
padding: 1rem;
background: var(--pico-card-background-color);
border: 1px solid var(--pico-card-border-color);
border-radius: var(--pico-border-radius);
}
.sync-report ul {
margin-bottom: 0;
padding-left: 1rem;
}
</style>
{% if let Some(report) = sync_report %}
<div class="sync-report">
<strong>Federated Sync Results:</strong>
@@ -88,17 +7,19 @@
{{ result.server_name }}:
{% match result.status %}
{% when crate::federated_users::SyncStatus::Created %}
<span class="badge" style="background-color: #28a745;">Created</span>
<span class="badge success">Created</span>
{% when crate::federated_users::SyncStatus::AlreadyExists %}
<span class="badge" style="background-color: #ffc107; color: black;">Exists</span>
<span class="badge warning">Exists</span>
{% when crate::federated_users::SyncStatus::ExistsWithDifferentPassword %}
<span class="badge danger">Exists (Password Mismatch)</span>
{% when crate::federated_users::SyncStatus::Failed %}
<span class="badge" style="background-color: #dc3545;">Failed</span>
<span class="badge danger">Failed</span>
{% when crate::federated_users::SyncStatus::Skipped %}
<span class="badge" style="background-color: #6c757d;">Skipped</span>
<span class="badge secondary">Skipped</span>
{% when crate::federated_users::SyncStatus::Deleted %}
<span class="badge" style="background-color: #dc3545;">Deleted</span>
<span class="badge danger">Deleted</span>
{% when crate::federated_users::SyncStatus::NotFound %}
<span class="badge" style="background-color: #6c757d;">Not Found</span>
<span class="badge secondary">Not Found</span>
{% endmatch %}
{% if let Some(msg) = result.message %}
<small>({{ msg }})</small>

View File

@@ -11,6 +11,9 @@
<!-- Font Awesome -->
<link rel="stylesheet" href="/{{ ui_route }}/resources/fontawesome/css/all.min.css">
<!-- Custom Styles -->
<link rel="stylesheet" href="/{{ ui_route }}/resources/custom.css">
<!-- HTMX -->
<script src="/{{ ui_route }}/resources/htmx.min.js"></script>

View File

@@ -23,6 +23,20 @@ struct MediaFoldersResponse {
items: Vec<MediaFolder>,
}
#[derive(Deserialize)]
struct AuthResponse {
#[serde(rename = "AccessToken")]
access_token: String,
#[serde(rename = "User")]
user: JellyfinUser,
}
#[derive(Deserialize)]
struct JellyfinUser {
#[serde(rename = "Id")]
id: String,
}
pub struct ServerLibraries {
pub server_name: String,
pub libraries: Vec<MediaFolder>,
@@ -67,18 +81,24 @@ pub async fn get_user_media(
};
for server in servers {
let mut libraries = Vec::new();
let mut error_msg = None;
// Find session for this server
let session = sessions
.iter()
.filter(|(_, s)| s.id == server.id)
.max_by_key(|(auth, _)| auth.updated_at);
if let Some((auth, _)) = session {
let mut token = session.map(|(auth, _)| auth.jellyfin_token.clone());
// 1. Try to use existing token if available
if let Some(t) = &token {
let url = join_server_url(&server.url, "/Library/MediaFolders");
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\", Token=\"{}\"",
env!("CARGO_PKG_VERSION"),
auth.jellyfin_token
t
);
match client
@@ -90,50 +110,121 @@ pub async fn get_user_media(
Ok(resp) if resp.status().is_success() => {
match resp.json::<MediaFoldersResponse>().await {
Ok(folders) => {
server_libraries.push(ServerLibraries {
server_name: server.name.clone(),
libraries: folders.items,
error: None,
});
libraries = folders.items;
}
Err(e) => {
server_libraries.push(ServerLibraries {
server_name: server.name.clone(),
libraries: Vec::new(),
error: Some(format!("Failed to parse: {}", e)),
});
error_msg = Some(format!("Failed to parse: {}", e));
}
}
}
Ok(resp) if resp.status() == StatusCode::FORBIDDEN || resp.status() == StatusCode::UNAUTHORIZED => {
// Token expired, clear it to trigger re-login
token = None;
}
Ok(resp) => {
let error_msg = if resp.status() == StatusCode::FORBIDDEN
|| resp.status() == StatusCode::UNAUTHORIZED
{
"Session expired, please reconnect".to_string()
} else {
format!("HTTP {}", resp.status())
};
server_libraries.push(ServerLibraries {
server_name: server.name.clone(),
libraries: Vec::new(),
error: Some(error_msg),
});
error_msg = Some(format!("HTTP {}", resp.status()));
}
Err(e) => {
server_libraries.push(ServerLibraries {
server_name: server.name.clone(),
libraries: Vec::new(),
error: Some(format!("Network error: {}", e)),
});
error_msg = Some(format!("Network error: {}", e));
}
}
} else {
server_libraries.push(ServerLibraries {
server_name: server.name.clone(),
libraries: Vec::new(),
error: Some("Not connected".to_string()),
});
}
// 2. If no token or expired, try to login using mapping
if token.is_none() && (libraries.is_empty() && error_msg.is_none() || error_msg.as_deref() == Some("HTTP 401") || error_msg.as_deref() == Some("HTTP 403")) {
// Clear previous error if we are retrying
error_msg = None;
match state.user_authorization.get_server_mapping(&user.id, &server.url.as_str()).await {
Ok(Some(mapping)) => {
// Decrypt password
let config = state.config.read().await;
let admin_password = &config.password;
let decrypted_password = state.user_authorization.decrypt_server_mapping_password(
&mapping,
&user.password,
admin_password
);
// Perform login
let auth_url = join_server_url(&server.url, "/Users/AuthenticateByName");
let body = serde_json::json!({
"Username": mapping.mapped_username,
"Pw": decrypted_password
});
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\"",
env!("CARGO_PKG_VERSION")
);
match client.post(auth_url.as_str()).header("Authorization", auth_header).json(&body).send().await {
Ok(resp) if resp.status().is_success() => {
match resp.json::<AuthResponse>().await {
Ok(auth_resp) => {
// Store new session
let auth = crate::models::Authorization {
client: "Jellyswarrm Proxy".to_string(),
device: "Server".to_string(),
device_id: "jellyswarrm-proxy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
token: None,
};
if let Err(e) = state.user_authorization.store_authorization_session(
&user.id,
server.url.as_str(),
&auth,
auth_resp.access_token.clone(),
auth_resp.user.id,
None
).await {
error!("Failed to store session: {}", e);
}
// Fetch libraries with new token
let url = join_server_url(&server.url, "/Library/MediaFolders");
let auth_header = format!(
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\", Token=\"{}\"",
env!("CARGO_PKG_VERSION"),
auth_resp.access_token
);
match client.get(url.as_str()).header("Authorization", auth_header).send().await {
Ok(resp) if resp.status().is_success() => {
match resp.json::<MediaFoldersResponse>().await {
Ok(folders) => {
libraries = folders.items;
}
Err(e) => error_msg = Some(format!("Failed to parse: {}", e)),
}
}
Ok(resp) => error_msg = Some(format!("HTTP {}", resp.status())),
Err(e) => error_msg = Some(format!("Network error: {}", e)),
}
}
Err(e) => error_msg = Some(format!("Login response error: {}", e)),
}
}
Ok(resp) => error_msg = Some(format!("Login failed: HTTP {}", resp.status())),
Err(e) => error_msg = Some(format!("Login network error: {}", e)),
}
}
Ok(None) => {
error_msg = Some("Not connected".to_string());
}
Err(e) => {
error_msg = Some(format!("Database error: {}", e));
}
}
}
server_libraries.push(ServerLibraries {
server_name: server.name.clone(),
libraries,
error: error_msg,
});
}
let template = UserMediaTemplate {