Files
jellytau/src-tauri/src/jellyfin/http_client.rs
T
dtourolle 3fbf6afdbc Background-audio handoff for video + repository/player refactor
Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.
2026-07-22 21:52:07 +02:00

275 lines
9.1 KiB
Rust

use reqwest::{Client, Request, Response, StatusCode};
use serde::de::DeserializeOwned;
use std::time::Duration;
const APP_NAME: &str = "JellyTau";
const APP_VERSION: &str = "0.1.0";
// Default timeout for requests (30 seconds - large library queries can be slow)
const DEFAULT_TIMEOUT_MS: u64 = 30000;
// Retry configuration - matches TypeScript exactly
const DEFAULT_MAX_RETRIES: u32 = 3;
const RETRY_DELAYS_MS: [u64; 3] = [1000, 2000, 4000]; // Exponential backoff
/// HTTP client configuration
#[derive(Clone, Debug)]
pub struct HttpConfig {
pub timeout: Duration,
pub max_retries: u32,
}
impl Default for HttpConfig {
fn default() -> Self {
Self {
timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS),
max_retries: DEFAULT_MAX_RETRIES,
}
}
}
/// Error classification for retry logic
#[derive(Debug, Clone, PartialEq)]
pub enum ErrorKind {
Network,
Authentication,
Server,
Client,
}
/// Enhanced HTTP client with retry logic and error classification
#[derive(Clone)]
pub struct HttpClient {
pub(crate) client: Client, // Make accessible within crate for custom requests
config: HttpConfig,
}
impl HttpClient {
/// Create a new HTTP client with default configuration
pub fn new(config: HttpConfig) -> Result<Self, String> {
let client = Client::builder()
.timeout(config.timeout)
.https_only(true)
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
Ok(Self { client, config })
}
/// Get device name based on platform
fn get_device_name() -> &'static str {
#[cfg(target_os = "android")]
return "Android";
#[cfg(target_os = "linux")]
return "Linux";
#[cfg(target_os = "windows")]
return "Windows";
#[cfg(target_os = "macos")]
return "macOS";
#[cfg(target_os = "ios")]
return "iOS";
#[cfg(not(any(
target_os = "android",
target_os = "linux",
target_os = "windows",
target_os = "macos",
target_os = "ios"
)))]
return "Unknown";
}
/// Build the X-Emby-Authorization header value
pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
let mut parts = vec![
format!("MediaBrowser Client=\"{}\"", APP_NAME),
format!("Version=\"{}\"", APP_VERSION),
format!("Device=\"{}\"", Self::get_device_name()),
format!("DeviceId=\"{}\"", device_id),
];
if let Some(token) = access_token {
parts.push(format!("Token=\"{}\"", token));
}
parts.join(", ")
}
/// Classify an error for retry logic
pub fn classify_error(error: &reqwest::Error) -> ErrorKind {
// Network errors (connection failures, timeouts, DNS failures)
if error.is_timeout() || error.is_connect() {
return ErrorKind::Network;
}
// Check status code if available
if let Some(status) = error.status() {
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
return ErrorKind::Authentication;
} else if status.is_server_error() {
return ErrorKind::Server;
} else if status.is_client_error() {
return ErrorKind::Client;
}
}
// If no status code, check error message for network-related keywords
let error_msg = error.to_string().to_lowercase();
if error_msg.contains("network")
|| error_msg.contains("connection")
|| error_msg.contains("timeout")
|| error_msg.contains("dns")
|| error_msg.contains("refused")
|| error_msg.contains("reset")
{
return ErrorKind::Network;
}
// Default to client error
ErrorKind::Client
}
/// Check if a request should be retried based on the error
pub fn should_retry(error: &reqwest::Error) -> bool {
match Self::classify_error(error) {
ErrorKind::Network => true, // Retry network errors
ErrorKind::Server => true, // Retry 5xx server errors
ErrorKind::Authentication => false, // Don't retry 401/403
ErrorKind::Client => false, // Don't retry other 4xx errors
}
}
/// Make a request with automatic retry on network errors
pub async fn request_with_retry(&self, request: Request) -> Result<Response, reqwest::Error> {
let max_retries = self.config.max_retries;
let mut last_error: Option<reqwest::Error> = None;
for attempt in 0..=max_retries {
// Clone the request for retry attempts
// If request cannot be cloned (e.g., streaming body), we cannot retry
let Some(req) = request.try_clone() else {
log::warn!("[HttpClient] Request body cannot be cloned, retries not possible");
return self.client.execute(request).await;
};
match self.client.execute(req).await {
Ok(response) => return Ok(response),
Err(error) => {
last_error = Some(error);
let err = last_error.as_ref().unwrap();
// Don't retry if it's not a retryable error
if !Self::should_retry(err) {
return Err(last_error.unwrap());
}
// Don't retry on last attempt
if attempt == max_retries {
break;
}
// Wait before retrying (exponential backoff)
let delay_ms = RETRY_DELAYS_MS
.get(attempt as usize)
.copied()
.unwrap_or(*RETRY_DELAYS_MS.last().unwrap());
log::info!(
"[HttpClient] Retry {}/{} after {}ms (error: {})",
attempt + 1,
max_retries,
delay_ms,
err
);
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
}
}
}
Err(last_error.unwrap())
}
/// Make a GET request and deserialize JSON with a short timeout and no retries.
///
/// Intended for the initial "connect to server" probe on the login screen:
/// a wrong/unreachable URL must fail fast instead of burning through the
/// default 30s-per-attempt timeout and exponential backoff retries.
pub async fn get_json_fast<T: DeserializeOwned>(&self, url: &str) -> Result<T, String> {
// Short timeout so an unreachable host fails quickly.
const FAST_TIMEOUT: Duration = Duration::from_secs(10);
let request = self
.client
.get(url)
.timeout(FAST_TIMEOUT)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// No retry: connection failures on a wrong URL won't succeed on retry,
// they'd only multiply the wait the user sees before an error.
let response = self
.client
.execute(request)
.await
.map_err(|e| format!("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!("HTTP {}: {}", status, error_text));
}
response
.json::<T>()
.await
.map_err(|e| format!("Failed to parse JSON: {}", e))
}
/// Quick ping to check if a server is reachable (no retry)
pub async fn ping(&self, url: &str) -> bool {
let request = self
.client
.get(url)
.timeout(Duration::from_secs(5)) // Shorter timeout for ping
.build();
match request {
Ok(req) => match self.client.execute(req).await {
Ok(response) => response.status().is_success(),
Err(_) => false,
},
Err(_) => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_header_format() {
let header = HttpClient::build_auth_header(Some("test_token"), "device456");
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
assert!(header.contains("Token=\"test_token\""));
assert!(header.contains("DeviceId=\"device456\""));
}
#[test]
fn test_auth_header_without_token() {
let header = HttpClient::build_auth_header(None, "device456");
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
assert!(!header.contains("Token="));
assert!(header.contains("DeviceId=\"device456\""));
}
#[test]
fn test_retry_delays() {
// Verify retry delays match TypeScript
assert_eq!(RETRY_DELAYS_MS, [1000, 2000, 4000]);
}
}