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).
412 lines
13 KiB
Rust
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('/'));
|
|
}
|
|
}
|