First working POC

This commit is contained in:
2026-01-26 22:21:54 +01:00
commit cfddc1edea
255 changed files with 77606 additions and 0 deletions
+409
View File
@@ -0,0 +1,409 @@
pub mod session_verifier;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::jellyfin::http_client::HttpClient;
use crate::connectivity::ConnectivityMonitor;
pub use session_verifier::SessionVerifier;
/// Server information returned from Jellyfin
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerInfo {
pub name: String,
pub version: String,
pub id: String,
/// Normalized server URL with protocol and no trailing slash
pub normalized_url: String,
}
/// User information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: String,
pub name: String,
pub server_id: String,
pub primary_image_tag: Option<String>,
}
/// Authentication result
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthResult {
pub user: User,
pub access_token: String,
pub server_id: String,
}
/// Active session for restoration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub user_id: String,
pub username: String,
pub server_id: String,
pub server_url: String,
pub server_name: String,
pub access_token: String,
pub verified: bool,
pub needs_reauth: bool,
}
// Jellyfin API response types (PascalCase from server)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct PublicSystemInfo {
server_name: String,
version: String,
id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AuthenticateByNameResponse {
user: JellyfinUser,
access_token: String,
server_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinUser {
id: String,
name: String,
server_id: String,
primary_image_tag: Option<String>,
}
/// Authentication manager
pub struct AuthManager {
http_client: Arc<HttpClient>,
current_session: Arc<RwLock<Option<Session>>>,
connectivity_monitor: Option<Arc<tokio::sync::Mutex<ConnectivityMonitor>>>,
}
impl AuthManager {
/// Create a new auth manager
pub fn new(http_client: HttpClient) -> Self {
Self {
http_client: Arc::new(http_client),
current_session: Arc::new(RwLock::new(None)),
connectivity_monitor: None,
}
}
/// Set the connectivity monitor (for marking server reachability)
pub fn set_connectivity_monitor(&mut self, monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>) {
self.connectivity_monitor = Some(monitor);
}
/// Normalize and validate server URL
pub fn normalize_url(url: &str) -> String {
let mut normalized = url.trim().to_string();
// Add https:// if no protocol specified
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
normalized = format!("https://{}", normalized);
}
// Remove trailing slash
if normalized.ends_with('/') {
normalized.pop();
}
normalized
}
/// Connect to server and get server info
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
let normalized_url = Self::normalize_url(server_url);
let endpoint = format!("{}/System/Info/Public", normalized_url);
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
match self.http_client.get_json_with_retry::<PublicSystemInfo>(&endpoint).await {
Ok(info) => {
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_reachable().await;
}
Ok(ServerInfo {
name: info.server_name,
version: info.version,
id: info.id,
normalized_url,
})
}
Err(e) => {
log::error!("[AuthManager] Failed to connect to server: {}", e);
// Mark server as unreachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_unreachable(Some(e.clone())).await;
}
Err(e)
}
}
}
/// Authenticate by username and password
pub async fn login(
&self,
server_url: &str,
username: &str,
password: &str,
device_id: &str,
) -> Result<AuthResult, String> {
let url = Self::normalize_url(server_url);
let endpoint = format!("{}/Users/AuthenticateByName", url);
log::info!("[AuthManager] Authenticating user: {}", username);
// Build auth header for login request
let auth_header = HttpClient::build_auth_header(None, device_id);
// Build request manually for custom headers
let request = self.http_client.client.post(&endpoint)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", auth_header)
.json(&serde_json::json!({
"Username": username,
"Pw": password,
}))
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// Use retry logic
let response = self.http_client.request_with_retry(request).await
.map_err(|e| format!("Login request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Login failed: HTTP {}: {}", status, error_text));
}
let auth_response: AuthenticateByNameResponse = response.json().await
.map_err(|e| format!("Failed to parse login response: {}", e))?;
log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_reachable().await;
}
let user = User {
id: auth_response.user.id,
name: auth_response.user.name,
server_id: auth_response.user.server_id,
primary_image_tag: auth_response.user.primary_image_tag,
};
Ok(AuthResult {
user,
access_token: auth_response.access_token,
server_id: auth_response.server_id,
})
}
/// Verify current session by fetching user info
pub async fn verify_session(
&self,
server_url: &str,
user_id: &str,
access_token: &str,
device_id: &str,
) -> Result<User, String> {
let url = Self::normalize_url(server_url);
let endpoint = format!("{}/Users/{}", url, user_id);
log::info!("[AuthManager] Verifying session for user: {}", user_id);
// Build auth header
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
// Build request manually for custom headers
let request = self.http_client.client.get(&endpoint)
.header("X-Emby-Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// Use retry logic
let response = self.http_client.request_with_retry(request).await
.map_err(|e| {
log::warn!("[AuthManager] Session verification failed: {}", e);
format!("Session verification failed: {}", e)
})?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
// Mark server as unreachable for auth errors
if status.as_u16() == 401 || status.as_u16() == 403 {
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await;
}
}
return Err(format!("HTTP {}: {}", status, error_text));
}
let user_response: JellyfinUser = response.json().await
.map_err(|e| format!("Failed to parse user response: {}", e))?;
log::info!("[AuthManager] Session verified successfully for: {}", user_response.name);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_reachable().await;
}
Ok(User {
id: user_response.id,
name: user_response.name,
server_id: user_response.server_id,
primary_image_tag: user_response.primary_image_tag,
})
}
/// Logout (call Jellyfin logout endpoint)
pub async fn logout(
&self,
server_url: &str,
access_token: &str,
device_id: &str,
) -> Result<(), String> {
let url = Self::normalize_url(server_url);
let endpoint = format!("{}/Sessions/Logout", url);
log::info!("[AuthManager] Logging out");
// Build auth header
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
// Build request
let request = self.http_client.client.post(&endpoint)
.header("X-Emby-Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// Don't retry logout - if it fails, we'll still clear local state
match self.http_client.client.execute(request).await {
Ok(response) => {
if response.status().is_success() {
log::info!("[AuthManager] Logout successful");
} else {
log::warn!("[AuthManager] Logout request failed: {}", response.status());
}
}
Err(e) => {
log::warn!("[AuthManager] Logout request failed: {}", e);
}
}
Ok(())
}
/// Get current session
pub async fn get_session(&self) -> Option<Session> {
self.current_session.read().await.clone()
}
/// Set current session
pub async fn set_session(&self, session: Option<Session>) {
*self.current_session.write().await = session;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Test URL normalization - adds https:// when missing
///
/// Ensures that URLs without protocol are normalized to https://
/// This prevents "builder error" when constructing HTTP requests.
#[test]
fn test_normalize_url_adds_https() {
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com"),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("192.168.1.100:8096"),
"https://192.168.1.100:8096"
);
}
/// Test URL normalization - preserves existing protocol
#[test]
fn test_normalize_url_preserves_protocol() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com"),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("http://localhost:8096"),
"http://localhost:8096"
);
}
/// Test URL normalization - removes trailing slash
#[test]
fn test_normalize_url_removes_trailing_slash() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com/"),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com/"),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - trims whitespace
#[test]
fn test_normalize_url_trims_whitespace() {
assert_eq!(
AuthManager::normalize_url(" jellyfin.example.com "),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url(" https://jellyfin.example.com/ "),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - complex case
///
/// This is the bug that caused the login issue: user enters URL
/// without protocol, it gets stored in DB, then fails when building
/// HTTP requests.
#[test]
fn test_normalize_url_real_world_case() {
// User input: "jellyfin.tourolle.paris"
let input = "jellyfin.tourolle.paris";
let normalized = AuthManager::normalize_url(input);
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
assert!(normalized.starts_with("https://"));
assert!(!normalized.ends_with('/'));
}
}
+158
View File
@@ -0,0 +1,158 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use serde::Serialize;
use super::{AuthManager, User};
// Verification interval (5 minutes)
const VERIFICATION_INTERVAL_MS: u64 = 300000;
/// Session verification result event emitted to frontend
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum SessionVerificationEvent {
Verified { user: User },
NeedsReauth { reason: String },
NetworkError { message: String },
}
/// Background session verifier
pub struct SessionVerifier {
auth_manager: Arc<AuthManager>,
is_running: Arc<AtomicBool>,
device_id: String,
app_handle: Option<AppHandle>,
}
impl SessionVerifier {
/// Create a new session verifier
pub fn new(auth_manager: Arc<AuthManager>, device_id: String) -> Self {
Self {
auth_manager,
is_running: Arc::new(AtomicBool::new(false)),
device_id,
app_handle: None,
}
}
/// Set the Tauri app handle for event emission
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
self.app_handle = Some(app_handle);
}
/// Start periodic session verification
pub async fn start(&self) {
if self.is_running.swap(true, Ordering::SeqCst) {
log::info!("[SessionVerifier] Already running");
return;
}
log::info!("[SessionVerifier] Starting background verification");
let auth_manager = Arc::clone(&self.auth_manager);
let is_running = Arc::clone(&self.is_running);
let device_id = self.device_id.clone();
let app_handle = self.app_handle.clone();
tokio::spawn(async move {
// Initial verification after short delay
tokio::time::sleep(Duration::from_millis(2000)).await;
while is_running.load(Ordering::SeqCst) {
// Get current session
let session = auth_manager.get_session().await;
if let Some(session) = session {
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
// Verify the session
match auth_manager
.verify_session(
&session.server_url,
&session.user_id,
&session.access_token,
&device_id,
)
.await
{
Ok(user) => {
log::info!("[SessionVerifier] Session verified successfully");
// Emit success event
if let Some(app) = &app_handle {
let event = SessionVerificationEvent::Verified { user };
if let Err(e) = app.emit("auth:session-verified", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
}
}
// Update session as verified
let mut updated_session = session;
updated_session.verified = true;
updated_session.needs_reauth = false;
auth_manager.set_session(Some(updated_session)).await;
}
Err(e) => {
log::warn!("[SessionVerifier] Verification failed: {}", e);
// Classify error
let is_auth_error = e.contains("401") || e.contains("403");
let is_network_error = e.contains("network")
|| e.contains("timeout")
|| e.contains("connection")
|| e.contains("DNS");
if is_auth_error {
// Token is invalid - need re-authentication
log::warn!("[SessionVerifier] Session requires re-authentication");
if let Some(app) = &app_handle {
let event = SessionVerificationEvent::NeedsReauth {
reason: "Session expired".to_string(),
};
if let Err(e) = app.emit("auth:needs-reauth", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
}
}
// Update session
let mut updated_session = session;
updated_session.verified = false;
updated_session.needs_reauth = true;
auth_manager.set_session(Some(updated_session)).await;
} else if is_network_error {
// Network error - keep using cached session
log::info!("[SessionVerifier] Network error during verification, keeping cached session");
if let Some(app) = &app_handle {
let event = SessionVerificationEvent::NetworkError {
message: e.clone(),
};
if let Err(e) = app.emit("auth:network-error", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
}
}
} else {
// Unknown error - log but don't invalidate
log::error!("[SessionVerifier] Unknown error during verification: {}", e);
}
}
}
}
// Wait for next verification
tokio::time::sleep(Duration::from_millis(VERIFICATION_INTERVAL_MS)).await;
}
log::info!("[SessionVerifier] Stopped");
});
}
/// Stop periodic verification
pub fn stop(&self) {
log::info!("[SessionVerifier] Stopping background verification");
self.is_running.store(false, Ordering::SeqCst);
}
}