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}