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 #[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 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 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 pub fn classify_error(error: &reqwest::Error) -> ErrorKind {
129 if error.is_timeout() || error.is_connect() {
131 return ErrorKind::Network;
132 }
133
134 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 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 ErrorKind::Client
159 }
160
161 pub fn should_retry(error: &reqwest::Error) -> bool {
163 match Self::classify_error(error) {
164 ErrorKind::Network => true, ErrorKind::Server => true, ErrorKind::Authentication => false, ErrorKind::Client => false, }
169 }
170
171 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 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 if !Self::should_retry(err) {
192 return Err(last_error.unwrap());
193 }
194
195 if attempt == max_retries {
197 break;
198 }
199
200 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 pub async fn get_json_fast<T: DeserializeOwned>(&self, url: &str) -> Result<T, String> {
228 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 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 pub async fn ping(&self, url: &str) -> bool {
263 let request = self
264 .client
265 .get(url)
266 .timeout(Duration::from_secs(5)) .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 assert_eq!(RETRY_DELAYS_MS, [1000, 2000, 4000]);
303 }
304}