Duncan Tourolle ea342d76e3 Fix download + casting breakages from the specta migration
Casting: restore camelCase serialization on SessionInfo/NowPlayingItem/PlayState
(container rename_all = "camelCase" + per-field PascalCase serde aliases so Jellyfin
PascalCase still deserializes). Bindings and the existing frontend are camelCase
again — casting works with no frontend session changes.

Downloads: migrate DownloadButton.svelte and downloads.ts to the typed
commands.downloadItemAndStart / downloadItem / downloadVideo wrappers (request
objects), matching the bundled backend signatures.

Tooling:
- Enable tauri-specta ErrorHandlingMode::Throw so commands.* return Promise<T>
  and throw (drop-in for invoke()).
- Disambiguate specta name collisions: player MediaItem/MediaSource ->
  PlayerMediaItem/PlayerMediaSource, auth ServerInfo -> AuthServerInfo.
- Regenerate bindings.ts; svelte-check passes (0 errors).
2026-06-20 19:27:29 +02:00

412 lines
13 KiB
Rust

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(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[specta(rename = "AuthServerInfo")]
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(specta::Type, 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(specta::Type, 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(specta::Type, 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(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct PublicSystemInfo {
server_name: String,
version: String,
id: String,
}
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AuthenticateByNameResponse {
user: JellyfinUser,
access_token: String,
server_id: String,
}
#[derive(specta::Type, 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.
/// Enforces HTTPS — plain HTTP is rejected for security.
pub fn normalize_url(url: &str) -> Result<String, String> {
let mut normalized = url.trim().to_string();
// Reject plain HTTP — all connections must use HTTPS
if normalized.starts_with("http://") {
return Err("HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).".to_string());
}
// Add https:// if no protocol specified
if !normalized.starts_with("https://") {
normalized = format!("https://{}", normalized);
}
// Remove trailing slash
if normalized.ends_with('/') {
normalized.pop();
}
Ok(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
#[test]
fn test_normalize_url_adds_https() {
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com").unwrap(),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("192.168.1.100:8096").unwrap(),
"https://192.168.1.100:8096"
);
}
/// Test URL normalization - preserves existing https
#[test]
fn test_normalize_url_preserves_https() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - rejects HTTP
#[test]
fn test_normalize_url_rejects_http() {
assert!(AuthManager::normalize_url("http://localhost:8096").is_err());
assert!(AuthManager::normalize_url("http://jellyfin.example.com").is_err());
}
/// Test URL normalization - removes trailing slash
#[test]
fn test_normalize_url_removes_trailing_slash() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - trims whitespace
#[test]
fn test_normalize_url_trims_whitespace() {
assert_eq!(
AuthManager::normalize_url(" jellyfin.example.com ").unwrap(),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url(" https://jellyfin.example.com/ ").unwrap(),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - real world case
#[test]
fn test_normalize_url_real_world_case() {
let input = "jellyfin.tourolle.paris";
let normalized = AuthManager::normalize_url(input).unwrap();
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
assert!(normalized.starts_with("https://"));
assert!(!normalized.ends_with('/'));
}
}