jellytau_lib/jellyfin/
http_client.rs1use 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
8const DEFAULT_TIMEOUT_MS: u64 = 30000;
10
11const DEFAULT_MAX_RETRIES: u32 = 3;
13const RETRY_DELAYS_MS: [u64; 3] = [1000, 2000, 4000]; #[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#[derive(Debug, Clone, PartialEq)]
33pub enum ErrorKind {
34 Network,
35 Authentication,
36 Server,
37 Client,
38}
39
40#[derive(Clone)]
42pub struct HttpClient {
43 pub(crate) client: Client, config: HttpConfig,
45}
46
47impl HttpClient {
48 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 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 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 pub fn classify_error(error: &reqwest::Error) -> ErrorKind {
99 if error.is_timeout() || error.is_connect() {
101 return ErrorKind::Network;
102 }
103
104 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 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 ErrorKind::Client
129 }
130
131 pub fn should_retry(error: &reqwest::Error) -> bool {
133 match Self::classify_error(error) {
134 ErrorKind::Network => true, ErrorKind::Server => true, ErrorKind::Authentication => false, ErrorKind::Client => false, }
139 }
140
141 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 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 if !Self::should_retry(err) {
162 return Err(last_error.unwrap());
163 }
164
165 if attempt == max_retries {
167 break;
168 }
169
170 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 pub async fn get_json_fast<T: DeserializeOwned>(&self, url: &str) -> Result<T, String> {
198 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 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 pub async fn ping(&self, url: &str) -> bool {
233 let request = self
234 .client
235 .get(url)
236 .timeout(Duration::from_secs(5)) .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 assert_eq!(RETRY_DELAYS_MS, [1000, 2000, 4000]);
273 }
274}