Skip to main content

#
jellytau_lib/jellyfin/
http_client.rs

    1use reqwest::{Client, Request, Response, StatusCode};
    2use serde::de::DeserializeOwned;
    3use std::time::Duration;
    4
    5const APP_NAME: &str = "JellyTau";
    6const APP_VERSION: &str = "0.1.0";
    7
    8// Default timeout for requests (30 seconds - large library queries can be slow)
    9const DEFAULT_TIMEOUT_MS: u64 = 30000;
    10
    11// Retry configuration - matches TypeScript exactly
    12const DEFAULT_MAX_RETRIES: u32 = 3;
    13const RETRY_DELAYS_MS: [u64; 3] = [1000, 2000, 4000]; // Exponential backoff
    14
    15/// HTTP client configuration
    16#[derive(Clone, Debug)]
    17pub struct HttpConfig {
    18    pub timeout: Duration,
    19    pub max_retries: u32,
    20}
    21
    22impl Default for HttpConfig {
    23    fn default() -> Self {
    24        Self {
    25            timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS),
    26            max_retries: DEFAULT_MAX_RETRIES,
    27        }
    28    }
    29}
    30
    31/// Error classification for retry logic
    32#[derive(Debug, Clone, PartialEq)]
    33pub enum ErrorKind {
    34    Network,
    35    Authentication,
    36    Server,
    37    Client,
    38}
    39
    40/// Enhanced HTTP client with retry logic and error classification
    41#[derive(Clone)]
    42pub struct HttpClient {
    43    pub(crate) client: Client, // Make accessible within crate for custom requests
    44    config: HttpConfig,
    45}
    46
    47impl HttpClient {
    48    /// Create a new HTTP client with default configuration
    49    pub fn new(config: HttpConfig) -> Result<Self, String> {
    50        let client = Client::builder()
    51            .timeout(config.timeout)
    52            .https_only(true)
    53            .build()
    54            .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
    55
    56        Ok(Self { client, config })
    57    }
    58
    59    /// Get device name based on platform
    60    fn get_device_name() -> &'static str {
    61        #[cfg(target_os = "android")]
    62        return "Android";
    63        #[cfg(target_os = "linux")]
    64        return "Linux";
    65        #[cfg(target_os = "windows")]
    66        return "Windows";
    67        #[cfg(target_os = "macos")]
    68        return "macOS";
    69        #[cfg(target_os = "ios")]
    70        return "iOS";
    71        #[cfg(not(any(
    72            target_os = "android",
    73            target_os = "linux",
    74            target_os = "windows",
    75            target_os = "macos",
    76            target_os = "ios"
    77        )))]
    78        return "Unknown";
    79    }
    80
    81    /// Build the X-Emby-Authorization header value
    82    pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
    83        let mut parts = vec![
    84            format!("MediaBrowser Client=\"{}\"", APP_NAME),
    85            format!("Version=\"{}\"", APP_VERSION),
    86            format!("Device=\"{}\"", Self::get_device_name()),
    87            format!("DeviceId=\"{}\"", device_id),
    88        ];
    89
    90        if let Some(token) = access_token {
    91            parts.push(format!("Token=\"{}\"", token));
    92        }
    93
    94        parts.join(", ")
    95    }
    96
    97    /// Classify an error for retry logic
    98    pub fn classify_error(error: &reqwest::Error) -> ErrorKind {
    99        // Network errors (connection failures, timeouts, DNS failures)
    100        if error.is_timeout() || error.is_connect() {
    101            return ErrorKind::Network;
    102        }
    103
    104        // Check status code if available
    105        if let Some(status) = error.status() {
    106            if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
    107                return ErrorKind::Authentication;
    108            } else if status.is_server_error() {
    109                return ErrorKind::Server;
    110            } else if status.is_client_error() {
    111                return ErrorKind::Client;
    112            }
    113        }
    114
    115        // If no status code, check error message for network-related keywords
    116        let error_msg = error.to_string().to_lowercase();
    117        if error_msg.contains("network")
    118            || error_msg.contains("connection")
    119            || error_msg.contains("timeout")
    120            || error_msg.contains("dns")
    121            || error_msg.contains("refused")
    122            || error_msg.contains("reset")
    123        {
    124            return ErrorKind::Network;
    125        }
    126
    127        // Default to client error
    128        ErrorKind::Client
    129    }
    130
    131    /// Check if a request should be retried based on the error
    132    pub fn should_retry(error: &reqwest::Error) -> bool {
    133        match Self::classify_error(error) {
    134            ErrorKind::Network => true,         // Retry network errors
    135            ErrorKind::Server => true,          // Retry 5xx server errors
    136            ErrorKind::Authentication => false, // Don't retry 401/403
    137            ErrorKind::Client => false,         // Don't retry other 4xx errors
    138        }
    139    }
    140
    141    /// Make a request with automatic retry on network errors
    142    pub async fn request_with_retry(&self, request: Request) -> Result<Response, reqwest::Error> {
    143        let max_retries = self.config.max_retries;
    144        let mut last_error: Option<reqwest::Error> = None;
    145
    146        for attempt in 0..=max_retries {
    147            // Clone the request for retry attempts
    148            // If request cannot be cloned (e.g., streaming body), we cannot retry
    149            let Some(req) = request.try_clone() else {
    150                log::warn!("[HttpClient] Request body cannot be cloned, retries not possible");
    151                return self.client.execute(request).await;
    152            };
    153
    154            match self.client.execute(req).await {
    155                Ok(response) => return Ok(response),
    156                Err(error) => {
    157                    last_error = Some(error);
    158                    let err = last_error.as_ref().unwrap();
    159
    160                    // Don't retry if it's not a retryable error
    161                    if !Self::should_retry(err) {
    162                        return Err(last_error.unwrap());
    163                    }
    164
    165                    // Don't retry on last attempt
    166                    if attempt == max_retries {
    167                        break;
    168                    }
    169
    170                    // Wait before retrying (exponential backoff)
    171                    let delay_ms = RETRY_DELAYS_MS
    172                        .get(attempt as usize)
    173                        .copied()
    174                        .unwrap_or(*RETRY_DELAYS_MS.last().unwrap());
    175
    176                    log::info!(
    177                        "[HttpClient] Retry {}/{} after {}ms (error: {})",
    178                        attempt + 1,
    179                        max_retries,
    180                        delay_ms,
    181                        err
    182                    );
    183
    184                    tokio::time::sleep(Duration::from_millis(delay_ms)).await;
    185                }
    186            }
    187        }
    188
    189        Err(last_error.unwrap())
    190    }
    191
    192    /// Make a GET request and deserialize JSON with a short timeout and no retries.
    193    ///
    194    /// Intended for the initial "connect to server" probe on the login screen:
    195    /// a wrong/unreachable URL must fail fast instead of burning through the
    196    /// default 30s-per-attempt timeout and exponential backoff retries.
    197    pub async fn get_json_fast<T: DeserializeOwned>(&self, url: &str) -> Result<T, String> {
    198        // Short timeout so an unreachable host fails quickly.
    199        const FAST_TIMEOUT: Duration = Duration::from_secs(10);
    200
    201        let request = self
    202            .client
    203            .get(url)
    204            .timeout(FAST_TIMEOUT)
    205            .build()
    206            .map_err(|e| format!("Failed to build request: {}", e))?;
    207
    208        // No retry: connection failures on a wrong URL won't succeed on retry,
    209        // they'd only multiply the wait the user sees before an error.
    210        let response = self
    211            .client
    212            .execute(request)
    213            .await
    214            .map_err(|e| format!("Request failed: {}", e))?;
    215
    216        if !response.status().is_success() {
    217            let status = response.status();
    218            let error_text = response
    219                .text()
    220                .await
    221                .unwrap_or_else(|_| "Unknown error".to_string());
    222            return Err(format!("HTTP {}: {}", status, error_text));
    223        }
    224
    225        response
    226            .json::<T>()
    227            .await
    228            .map_err(|e| format!("Failed to parse JSON: {}", e))
    229    }
    230
    231    /// Quick ping to check if a server is reachable (no retry)
    232    pub async fn ping(&self, url: &str) -> bool {
    233        let request = self
    234            .client
    235            .get(url)
    236            .timeout(Duration::from_secs(5)) // Shorter timeout for ping
    237            .build();
    238
    239        match request {
    240            Ok(req) => match self.client.execute(req).await {
    241                Ok(response) => response.status().is_success(),
    242                Err(_) => false,
    243            },
    244            Err(_) => false,
    245        }
    246    }
    247}
    248
    249#[cfg(test)]
    250mod tests {
    251    use super::*;
    252
    253    #[test]
    254    fn test_auth_header_format() {
    255        let header = HttpClient::build_auth_header(Some("test_token"), "device456");
    256        assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
    257        assert!(header.contains("Token=\"test_token\""));
    258        assert!(header.contains("DeviceId=\"device456\""));
    259    }
    260
    261    #[test]
    262    fn test_auth_header_without_token() {
    263        let header = HttpClient::build_auth_header(None, "device456");
    264        assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
    265        assert!(!header.contains("Token="));
    266        assert!(header.contains("DeviceId=\"device456\""));
    267    }
    268
    269    #[test]
    270    fn test_retry_delays() {
    271        // Verify retry delays match TypeScript
    272        assert_eq!(RETRY_DELAYS_MS, [1000, 2000, 4000]);
    273    }
    274}