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    /// A client that will also talk plain HTTP, for tests only.
60    ///
61    /// `new` sets `https_only(true)` and that must stay: it is what stops a
62    /// downgrade putting a session token on the wire in clear. `wiremock` serves
63    /// plain HTTP on loopback, so the alternative to this constructor is either
64    /// weakening the real one or not testing the repository against a server at
65    /// all — and the latter is what DR-281 exists to end.
66    ///
67    /// `#[cfg(test)]` so it cannot reach a shipped binary.
68    ///
69    /// TRACES: UR-085 | DR-281
70    #[cfg(test)]
71    pub fn new_allowing_plaintext_for_tests(config: HttpConfig) -> Result<Self, String> {
72        let client = Client::builder()
73            .timeout(config.timeout)
74            .build()
75            .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
76
77        Ok(Self { client, config })
78    }
79
80    /// Get device name based on platform
81    fn get_device_name() -> &'static str {
82        #[cfg(target_os = "android")]
83        return "Android";
84        #[cfg(target_os = "linux")]
85        return "Linux";
86        #[cfg(target_os = "windows")]
87        return "Windows";
88        #[cfg(target_os = "macos")]
89        return "macOS";
90        #[cfg(target_os = "ios")]
91        return "iOS";
92        #[cfg(not(any(
93            target_os = "android",
94            target_os = "linux",
95            target_os = "windows",
96            target_os = "macos",
97            target_os = "ios"
98        )))]
99        return "Unknown";
100    }
101
102    /// Build the value for the `Authorization` header.
103    ///
104    /// The `MediaBrowser` scheme, which is the non-deprecated one: Jellyfin 12.0
105    /// disables `X-Emby-Authorization` (and the `Emby` scheme, `X-Emby-Token`
106    /// and `X-MediaBrowser-Token`) by default, and a migration turns it off on
107    /// upgraded servers too. `Authorization: MediaBrowser …` is ungated on both
108    /// 10.11.x and 12.x, so this is one value for both generations rather than a
109    /// capability branch.
110    ///
111    /// TRACES: UR-085 | DR-287
112    pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
113        let mut parts = vec![
114            format!("MediaBrowser Client=\"{}\"", APP_NAME),
115            format!("Version=\"{}\"", APP_VERSION),
116            format!("Device=\"{}\"", Self::get_device_name()),
117            format!("DeviceId=\"{}\"", device_id),
118        ];
119
120        if let Some(token) = access_token {
121            parts.push(format!("Token=\"{}\"", token));
122        }
123
124        parts.join(", ")
125    }
126
127    /// Classify an error for retry logic
128    pub fn classify_error(error: &reqwest::Error) -> ErrorKind {
129        // Network errors (connection failures, timeouts, DNS failures)
130        if error.is_timeout() || error.is_connect() {
131            return ErrorKind::Network;
132        }
133
134        // Check status code if available
135        if let Some(status) = error.status() {
136            if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
137                return ErrorKind::Authentication;
138            } else if status.is_server_error() {
139                return ErrorKind::Server;
140            } else if status.is_client_error() {
141                return ErrorKind::Client;
142            }
143        }
144
145        // If no status code, check error message for network-related keywords
146        let error_msg = error.to_string().to_lowercase();
147        if error_msg.contains("network")
148            || error_msg.contains("connection")
149            || error_msg.contains("timeout")
150            || error_msg.contains("dns")
151            || error_msg.contains("refused")
152            || error_msg.contains("reset")
153        {
154            return ErrorKind::Network;
155        }
156
157        // Default to client error
158        ErrorKind::Client
159    }
160
161    /// Check if a request should be retried based on the error
162    pub fn should_retry(error: &reqwest::Error) -> bool {
163        match Self::classify_error(error) {
164            ErrorKind::Network => true,         // Retry network errors
165            ErrorKind::Server => true,          // Retry 5xx server errors
166            ErrorKind::Authentication => false, // Don't retry 401/403
167            ErrorKind::Client => false,         // Don't retry other 4xx errors
168        }
169    }
170
171    /// Make a request with automatic retry on network errors
172    pub async fn request_with_retry(&self, request: Request) -> Result<Response, reqwest::Error> {
173        let max_retries = self.config.max_retries;
174        let mut last_error: Option<reqwest::Error> = None;
175
176        for attempt in 0..=max_retries {
177            // Clone the request for retry attempts
178            // If request cannot be cloned (e.g., streaming body), we cannot retry
179            let Some(req) = request.try_clone() else {
180                log::warn!("[HttpClient] Request body cannot be cloned, retries not possible");
181                return self.client.execute(request).await;
182            };
183
184            match self.client.execute(req).await {
185                Ok(response) => return Ok(response),
186                Err(error) => {
187                    last_error = Some(error);
188                    let err = last_error.as_ref().unwrap();
189
190                    // Don't retry if it's not a retryable error
191                    if !Self::should_retry(err) {
192                        return Err(last_error.unwrap());
193                    }
194
195                    // Don't retry on last attempt
196                    if attempt == max_retries {
197                        break;
198                    }
199
200                    // Wait before retrying (exponential backoff)
201                    let delay_ms = RETRY_DELAYS_MS
202                        .get(attempt as usize)
203                        .copied()
204                        .unwrap_or(*RETRY_DELAYS_MS.last().unwrap());
205
206                    log::info!(
207                        "[HttpClient] Retry {}/{} after {}ms (error: {})",
208                        attempt + 1,
209                        max_retries,
210                        delay_ms,
211                        err
212                    );
213
214                    tokio::time::sleep(Duration::from_millis(delay_ms)).await;
215                }
216            }
217        }
218
219        Err(last_error.unwrap())
220    }
221
222    /// Make a GET request and deserialize JSON with a short timeout and no retries.
223    ///
224    /// Intended for the initial "connect to server" probe on the login screen:
225    /// a wrong/unreachable URL must fail fast instead of burning through the
226    /// default 30s-per-attempt timeout and exponential backoff retries.
227    pub async fn get_json_fast<T: DeserializeOwned>(&self, url: &str) -> Result<T, String> {
228        // Short timeout so an unreachable host fails quickly.
229        const FAST_TIMEOUT: Duration = Duration::from_secs(10);
230
231        let request = self
232            .client
233            .get(url)
234            .timeout(FAST_TIMEOUT)
235            .build()
236            .map_err(|e| format!("Failed to build request: {}", e))?;
237
238        // No retry: connection failures on a wrong URL won't succeed on retry,
239        // they'd only multiply the wait the user sees before an error.
240        let response = self
241            .client
242            .execute(request)
243            .await
244            .map_err(|e| format!("Request failed: {}", e))?;
245
246        if !response.status().is_success() {
247            let status = response.status();
248            let error_text = response
249                .text()
250                .await
251                .unwrap_or_else(|_| "Unknown error".to_string());
252            return Err(format!("HTTP {}: {}", status, error_text));
253        }
254
255        response
256            .json::<T>()
257            .await
258            .map_err(|e| format!("Failed to parse JSON: {}", e))
259    }
260
261    /// Quick ping to check if a server is reachable (no retry)
262    pub async fn ping(&self, url: &str) -> bool {
263        let request = self
264            .client
265            .get(url)
266            .timeout(Duration::from_secs(5)) // Shorter timeout for ping
267            .build();
268
269        match request {
270            Ok(req) => match self.client.execute(req).await {
271                Ok(response) => response.status().is_success(),
272                Err(_) => false,
273            },
274            Err(_) => false,
275        }
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_auth_header_format() {
285        let header = HttpClient::build_auth_header(Some("test_token"), "device456");
286        assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
287        assert!(header.contains("Token=\"test_token\""));
288        assert!(header.contains("DeviceId=\"device456\""));
289    }
290
291    #[test]
292    fn test_auth_header_without_token() {
293        let header = HttpClient::build_auth_header(None, "device456");
294        assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
295        assert!(!header.contains("Token="));
296        assert!(header.contains("DeviceId=\"device456\""));
297    }
298
299    #[test]
300    fn test_retry_delays() {
301        // Verify retry delays match TypeScript
302        assert_eq!(RETRY_DELAYS_MS, [1000, 2000, 4000]);
303    }
304}