First working POC
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
use log::{debug, error, info};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
const APP_NAME: &str = "JellyTau";
|
||||
const APP_VERSION: &str = "0.1.0";
|
||||
|
||||
/// Jellyfin API client for playback reporting
|
||||
#[derive(Clone)]
|
||||
pub struct JellyfinClient {
|
||||
config: Arc<JellyfinConfig>,
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
impl JellyfinClient {
|
||||
/// Create a new Jellyfin API client
|
||||
pub fn new(config: JellyfinConfig) -> Result<Self, String> {
|
||||
let http_client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
Ok(Self {
|
||||
config: Arc::new(config),
|
||||
http_client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get device name based on platform
|
||||
fn get_device_name() -> &'static str {
|
||||
#[cfg(target_os = "android")]
|
||||
return "Android";
|
||||
#[cfg(target_os = "linux")]
|
||||
return "Linux";
|
||||
#[cfg(target_os = "windows")]
|
||||
return "Windows";
|
||||
#[cfg(target_os = "macos")]
|
||||
return "macOS";
|
||||
#[cfg(target_os = "ios")]
|
||||
return "iOS";
|
||||
#[cfg(not(any(
|
||||
target_os = "android",
|
||||
target_os = "linux",
|
||||
target_os = "windows",
|
||||
target_os = "macos",
|
||||
target_os = "ios"
|
||||
)))]
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/// Build the X-Emby-Authorization header value
|
||||
fn get_auth_header(&self) -> String {
|
||||
format!(
|
||||
"MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"",
|
||||
APP_NAME,
|
||||
APP_VERSION,
|
||||
Self::get_device_name(),
|
||||
self.config.device_id,
|
||||
self.config.access_token
|
||||
)
|
||||
}
|
||||
|
||||
/// Make a GET request to the Jellyfin API
|
||||
async fn get<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, String> {
|
||||
let url = format!("{}{}", self.config.server_url, endpoint);
|
||||
|
||||
log::debug!("[JellyfinClient] GET {}", endpoint);
|
||||
|
||||
let response = self.http_client
|
||||
.get(&url)
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
|
||||
format!("Network request failed: {}", e)
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
|
||||
log::error!("[JellyfinClient] Response: {}", error_text);
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
}
|
||||
|
||||
// Get the response text first so we can log it
|
||||
let response_text = response.text().await.map_err(|e| {
|
||||
log::error!("[JellyfinClient] Failed to read response body: {}", e);
|
||||
format!("Failed to read response: {}", e)
|
||||
})?;
|
||||
|
||||
// Log the raw response for sessions endpoint to help debug
|
||||
if endpoint.contains("/Sessions") {
|
||||
debug!("[JellyfinClient] Raw response for {}: {}", endpoint,
|
||||
if response_text.len() > 500 {
|
||||
format!("{}... (truncated, {} bytes total)", &response_text[..500], response_text.len())
|
||||
} else {
|
||||
response_text.clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the response text as JSON
|
||||
let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
|
||||
log::error!("[JellyfinClient] Failed to parse response: {}", e);
|
||||
log::error!("[JellyfinClient] Response was: {}",
|
||||
if response_text.len() > 200 {
|
||||
format!("{}...", &response_text[..200])
|
||||
} else {
|
||||
response_text.clone()
|
||||
}
|
||||
);
|
||||
format!("Failed to parse response: {}", e)
|
||||
})?;
|
||||
|
||||
log::debug!("[JellyfinClient] Request successful for {}", endpoint);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Make a POST request to the Jellyfin API
|
||||
async fn post<T: serde::Serialize>(&self, endpoint: &str, body: &T) -> Result<(), String> {
|
||||
let url = format!("{}{}", self.config.server_url, endpoint);
|
||||
|
||||
log::debug!("[JellyfinClient] POST {} to {}", endpoint, url);
|
||||
|
||||
let response: reqwest::Response = self.http_client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
|
||||
format!("Network request failed: {}", e)
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text: String = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
|
||||
log::error!("[JellyfinClient] Response: {}", error_text);
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
}
|
||||
|
||||
log::debug!("[JellyfinClient] Request successful for {}", endpoint);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report playback start to Jellyfin
|
||||
pub async fn report_playback_start(
|
||||
&self,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
play_session_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let request = PlaybackStartRequest {
|
||||
item_id,
|
||||
position_ticks,
|
||||
play_session_id,
|
||||
play_command: "PlayNow".to_string(),
|
||||
is_paused: false,
|
||||
};
|
||||
|
||||
self.post("/Sessions/Playing", &request).await
|
||||
}
|
||||
|
||||
/// Report playback stopped to Jellyfin
|
||||
pub async fn report_playback_stopped(
|
||||
&self,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
play_session_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let request = PlaybackStoppedRequest {
|
||||
item_id,
|
||||
position_ticks,
|
||||
play_session_id,
|
||||
};
|
||||
|
||||
self.post("/Sessions/Playing/Stopped", &request).await
|
||||
}
|
||||
|
||||
/// Report playback progress to Jellyfin
|
||||
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||
pub async fn report_playback_progress(
|
||||
&self,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
is_paused: bool,
|
||||
play_session_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let request = PlaybackProgressRequest {
|
||||
item_id,
|
||||
position_ticks,
|
||||
is_paused,
|
||||
play_session_id,
|
||||
};
|
||||
|
||||
self.post("/Sessions/Playing/Progress", &request).await
|
||||
}
|
||||
|
||||
/// Play items on a remote session (casting)
|
||||
pub async fn play_on_session(
|
||||
&self,
|
||||
session_id: String,
|
||||
item_ids: Vec<String>,
|
||||
start_index: usize,
|
||||
start_position_ticks: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[JellyfinClient] Playing on session: {}", session_id);
|
||||
log::info!("[JellyfinClient] Item IDs: {:?}, Start index: {}", item_ids, start_index);
|
||||
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
|
||||
session_id, item_ids.len(), start_index);
|
||||
|
||||
// Build URL with query parameters (Jellyfin expects query params, not JSON body!)
|
||||
let mut url = format!(
|
||||
"{}/Sessions/{}/Playing?playCommand=PlayNow&startIndex={}",
|
||||
self.config.server_url, session_id, start_index
|
||||
);
|
||||
|
||||
// Add item IDs as repeated query parameters
|
||||
for item_id in &item_ids {
|
||||
url.push_str(&format!("&itemIds={}", item_id));
|
||||
}
|
||||
|
||||
// Add start position if provided
|
||||
if let Some(ticks) = start_position_ticks {
|
||||
url.push_str(&format!("&startPositionTicks={}", ticks));
|
||||
log::info!("[JellyfinClient] Starting at position: {} ticks", ticks);
|
||||
}
|
||||
|
||||
log::info!("[JellyfinClient] POST {}", url);
|
||||
debug!("[JellyfinClient] Full URL length: {} chars", url.len());
|
||||
// Don't log full URL as it may contain sensitive tokens, just log the endpoint
|
||||
debug!("[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds", session_id, item_ids.len());
|
||||
|
||||
debug!("[JellyfinClient] Sending HTTP POST request...");
|
||||
let response = self.http_client
|
||||
.post(&url)
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("[JellyfinClient] Request failed: {}", e);
|
||||
error!("[JellyfinClient] HTTP request failed: {}", e);
|
||||
format!("Network request failed: {}", e)
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("[JellyfinClient] Response status: {}", status);
|
||||
debug!("[JellyfinClient] Response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("[JellyfinClient] Request failed: {}", error_text);
|
||||
error!("[JellyfinClient] API error {}: {}", status.as_u16(), error_text);
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
}
|
||||
|
||||
log::info!("[JellyfinClient] Successfully sent play command to remote session");
|
||||
info!("[JellyfinClient] Play command sent to remote session");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a playback command to a remote session
|
||||
pub async fn send_session_command(
|
||||
&self,
|
||||
session_id: String,
|
||||
command: &str,
|
||||
) -> Result<(), String> {
|
||||
self.post(&format!("/Sessions/{}/Playing/{}", session_id, command), &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
/// Seek on a remote session
|
||||
pub async fn session_seek(
|
||||
&self,
|
||||
session_id: String,
|
||||
position_ticks: i64,
|
||||
) -> Result<(), String> {
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct SeekRequest {
|
||||
seek_position_ticks: i64,
|
||||
}
|
||||
|
||||
let request = SeekRequest {
|
||||
seek_position_ticks: position_ticks,
|
||||
};
|
||||
|
||||
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
|
||||
}
|
||||
|
||||
/// Set volume on a remote session
|
||||
pub async fn session_set_volume(
|
||||
&self,
|
||||
session_id: String,
|
||||
volume: i32,
|
||||
) -> Result<(), String> {
|
||||
let payload = serde_json::json!({
|
||||
"Arguments": {
|
||||
"Volume": volume.to_string()
|
||||
}
|
||||
});
|
||||
|
||||
log::info!("[JellyfinClient] Setting volume on session {} to {} with payload: {}",
|
||||
session_id, volume, serde_json::to_string(&payload).unwrap_or_default());
|
||||
|
||||
self.post(
|
||||
&format!("/Sessions/{}/Command/SetVolume", session_id),
|
||||
&payload
|
||||
).await
|
||||
}
|
||||
|
||||
/// Toggle mute on a remote session
|
||||
pub async fn session_toggle_mute(
|
||||
&self,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
|
||||
|
||||
self.post(
|
||||
&format!("/Sessions/{}/Command/ToggleMute", session_id),
|
||||
&serde_json::json!({})
|
||||
).await
|
||||
}
|
||||
|
||||
/// Get all active sessions
|
||||
pub async fn get_sessions(&self) -> Result<Vec<SessionInfo>, String> {
|
||||
let sessions: Vec<SessionInfo> = self.get("/Sessions").await?;
|
||||
info!("[JellyfinClient] Fetched {} sessions from API", sessions.len());
|
||||
for session in &sessions {
|
||||
debug!("[JellyfinClient] Session: id={:?}, device={:?}, client={:?}, supportsRemoteControl={}",
|
||||
session.id, session.device_name, session.client, session.supports_remote_control);
|
||||
}
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// Get a specific session by ID
|
||||
pub async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>, String> {
|
||||
let sessions = self.get_sessions().await?;
|
||||
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Default value for supports_remote_control when missing from API
|
||||
/// We default to true to show all sessions. If a session explicitly doesn't
|
||||
/// support remote control, the Jellyfin API will set this field to false.
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Session information from Jellyfin
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
|
||||
pub struct SessionInfo {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub client: Option<String>,
|
||||
#[serde(default)]
|
||||
pub device_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub application_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_active: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub supports_media_control: Option<bool>,
|
||||
#[serde(default = "default_true")]
|
||||
pub supports_remote_control: bool,
|
||||
#[serde(default)]
|
||||
pub now_playing_item: Option<NowPlayingItem>,
|
||||
#[serde(default)]
|
||||
pub play_state: Option<PlayState>,
|
||||
#[serde(default)]
|
||||
pub playable_media_types: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub supported_commands: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
|
||||
pub struct NowPlayingItem {
|
||||
pub id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub run_time_ticks: Option<i64>,
|
||||
pub album: Option<String>,
|
||||
pub album_id: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub artists: Option<Vec<String>>,
|
||||
pub image_tags: Option<std::collections::HashMap<String, String>>,
|
||||
pub primary_image_tag: Option<String>,
|
||||
pub album_primary_image_tag: Option<String>,
|
||||
#[serde(rename = "Type")]
|
||||
pub item_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
|
||||
pub struct PlayState {
|
||||
#[serde(default)]
|
||||
pub position_ticks: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub can_seek: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub is_paused: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub is_muted: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub volume_level: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub repeat_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub shuffle_mode: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_auth_header_format() {
|
||||
let config = JellyfinConfig {
|
||||
server_url: "http://localhost:8096".to_string(),
|
||||
access_token: "test_token".to_string(),
|
||||
device_id: "device456".to_string(),
|
||||
};
|
||||
|
||||
let client = JellyfinClient::new(config).unwrap();
|
||||
let header = client.get_auth_header();
|
||||
|
||||
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
|
||||
assert!(header.contains("Token=\"test_token\""));
|
||||
assert!(header.contains("DeviceId=\"device456\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
use reqwest::{Client, Request, Response, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::time::Duration;
|
||||
|
||||
const APP_NAME: &str = "JellyTau";
|
||||
const APP_VERSION: &str = "0.1.0";
|
||||
|
||||
// Default timeout for requests (10 seconds)
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 10000;
|
||||
|
||||
// Retry configuration - matches TypeScript exactly
|
||||
const DEFAULT_MAX_RETRIES: u32 = 3;
|
||||
const RETRY_DELAYS_MS: [u64; 3] = [1000, 2000, 4000]; // Exponential backoff
|
||||
|
||||
/// HTTP client configuration
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HttpConfig {
|
||||
pub timeout: Duration,
|
||||
pub max_retries: u32,
|
||||
}
|
||||
|
||||
impl Default for HttpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS),
|
||||
max_retries: DEFAULT_MAX_RETRIES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error classification for retry logic
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ErrorKind {
|
||||
Network,
|
||||
Authentication,
|
||||
Server,
|
||||
Client,
|
||||
}
|
||||
|
||||
/// Enhanced HTTP client with retry logic and error classification
|
||||
#[derive(Clone)]
|
||||
pub struct HttpClient {
|
||||
pub(crate) client: Client, // Make accessible within crate for custom requests
|
||||
config: HttpConfig,
|
||||
}
|
||||
|
||||
impl HttpClient {
|
||||
/// Create a new HTTP client with default configuration
|
||||
pub fn new(config: HttpConfig) -> Result<Self, String> {
|
||||
let client = Client::builder()
|
||||
.timeout(config.timeout)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
Ok(Self { client, config })
|
||||
}
|
||||
|
||||
/// Get device name based on platform
|
||||
fn get_device_name() -> &'static str {
|
||||
#[cfg(target_os = "android")]
|
||||
return "Android";
|
||||
#[cfg(target_os = "linux")]
|
||||
return "Linux";
|
||||
#[cfg(target_os = "windows")]
|
||||
return "Windows";
|
||||
#[cfg(target_os = "macos")]
|
||||
return "macOS";
|
||||
#[cfg(target_os = "ios")]
|
||||
return "iOS";
|
||||
#[cfg(not(any(
|
||||
target_os = "android",
|
||||
target_os = "linux",
|
||||
target_os = "windows",
|
||||
target_os = "macos",
|
||||
target_os = "ios"
|
||||
)))]
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/// Build the X-Emby-Authorization header value
|
||||
pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
|
||||
let mut parts = vec![
|
||||
format!("MediaBrowser Client=\"{}\"", APP_NAME),
|
||||
format!("Version=\"{}\"", APP_VERSION),
|
||||
format!("Device=\"{}\"", Self::get_device_name()),
|
||||
format!("DeviceId=\"{}\"", device_id),
|
||||
];
|
||||
|
||||
if let Some(token) = access_token {
|
||||
parts.push(format!("Token=\"{}\"", token));
|
||||
}
|
||||
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
/// Classify an error for retry logic
|
||||
pub fn classify_error(error: &reqwest::Error) -> ErrorKind {
|
||||
// Network errors (connection failures, timeouts, DNS failures)
|
||||
if error.is_timeout() || error.is_connect() {
|
||||
return ErrorKind::Network;
|
||||
}
|
||||
|
||||
// Check status code if available
|
||||
if let Some(status) = error.status() {
|
||||
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
||||
return ErrorKind::Authentication;
|
||||
} else if status.is_server_error() {
|
||||
return ErrorKind::Server;
|
||||
} else if status.is_client_error() {
|
||||
return ErrorKind::Client;
|
||||
}
|
||||
}
|
||||
|
||||
// If no status code, check error message for network-related keywords
|
||||
let error_msg = error.to_string().to_lowercase();
|
||||
if error_msg.contains("network")
|
||||
|| error_msg.contains("connection")
|
||||
|| error_msg.contains("timeout")
|
||||
|| error_msg.contains("dns")
|
||||
|| error_msg.contains("refused")
|
||||
|| error_msg.contains("reset")
|
||||
{
|
||||
return ErrorKind::Network;
|
||||
}
|
||||
|
||||
// Default to client error
|
||||
ErrorKind::Client
|
||||
}
|
||||
|
||||
/// Check if a request should be retried based on the error
|
||||
pub fn should_retry(error: &reqwest::Error) -> bool {
|
||||
match Self::classify_error(error) {
|
||||
ErrorKind::Network => true, // Retry network errors
|
||||
ErrorKind::Server => true, // Retry 5xx server errors
|
||||
ErrorKind::Authentication => false, // Don't retry 401/403
|
||||
ErrorKind::Client => false, // Don't retry other 4xx errors
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a request with automatic retry on network errors
|
||||
pub async fn request_with_retry(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Response, reqwest::Error> {
|
||||
let max_retries = self.config.max_retries;
|
||||
let mut last_error: Option<reqwest::Error> = None;
|
||||
|
||||
for attempt in 0..=max_retries {
|
||||
// Clone the request for retry attempts
|
||||
// If request cannot be cloned (e.g., streaming body), we cannot retry
|
||||
let Some(req) = request.try_clone() else {
|
||||
log::warn!("[HttpClient] Request body cannot be cloned, retries not possible");
|
||||
return self.client.execute(request).await;
|
||||
};
|
||||
|
||||
match self.client.execute(req).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(error) => {
|
||||
last_error = Some(error);
|
||||
let err = last_error.as_ref().unwrap();
|
||||
|
||||
// Don't retry if it's not a retryable error
|
||||
if !Self::should_retry(err) {
|
||||
return Err(last_error.unwrap());
|
||||
}
|
||||
|
||||
// Don't retry on last attempt
|
||||
if attempt == max_retries {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait before retrying (exponential backoff)
|
||||
let delay_ms = RETRY_DELAYS_MS
|
||||
.get(attempt as usize)
|
||||
.copied()
|
||||
.unwrap_or(*RETRY_DELAYS_MS.last().unwrap());
|
||||
|
||||
log::info!(
|
||||
"[HttpClient] Retry {}/{} after {}ms (error: {})",
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
delay_ms,
|
||||
err
|
||||
);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap())
|
||||
}
|
||||
|
||||
/// Make a GET request with retry
|
||||
pub async fn get_with_retry(&self, url: &str) -> Result<Response, reqwest::Error> {
|
||||
let request = self.client.get(url).build()?;
|
||||
self.request_with_retry(request).await
|
||||
}
|
||||
|
||||
/// Make a GET request and deserialize JSON response with retry
|
||||
pub async fn get_json_with_retry<T: DeserializeOwned>(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> Result<T, String> {
|
||||
let response = self.get_with_retry(url).await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
response.json::<T>().await
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))
|
||||
}
|
||||
|
||||
/// Quick ping to check if a server is reachable (no retry)
|
||||
pub async fn ping(&self, url: &str) -> bool {
|
||||
let request = self.client.get(url)
|
||||
.timeout(Duration::from_secs(5)) // Shorter timeout for ping
|
||||
.build();
|
||||
|
||||
match request {
|
||||
Ok(req) => {
|
||||
match self.client.execute(req).await {
|
||||
Ok(response) => response.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_auth_header_format() {
|
||||
let header = HttpClient::build_auth_header(Some("test_token"), "device456");
|
||||
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
|
||||
assert!(header.contains("Token=\"test_token\""));
|
||||
assert!(header.contains("DeviceId=\"device456\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_header_without_token() {
|
||||
let header = HttpClient::build_auth_header(None, "device456");
|
||||
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
|
||||
assert!(!header.contains("Token="));
|
||||
assert!(header.contains("DeviceId=\"device456\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_delays() {
|
||||
// Verify retry delays match TypeScript
|
||||
assert_eq!(RETRY_DELAYS_MS, [1000, 2000, 4000]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod client;
|
||||
pub mod http_client;
|
||||
pub mod types;
|
||||
|
||||
pub use client::{JellyfinClient, NowPlayingItem};
|
||||
pub use http_client::{HttpClient, HttpConfig};
|
||||
pub use types::*;
|
||||
@@ -0,0 +1,43 @@
|
||||
use serde::Serialize;
|
||||
|
||||
/// Configuration for Jellyfin API client
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JellyfinConfig {
|
||||
pub server_url: String,
|
||||
pub access_token: String,
|
||||
pub device_id: String,
|
||||
}
|
||||
|
||||
/// Request body for reporting playback start
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct PlaybackStartRequest {
|
||||
pub item_id: String,
|
||||
pub position_ticks: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub play_session_id: Option<String>,
|
||||
pub play_command: String,
|
||||
pub is_paused: bool,
|
||||
}
|
||||
|
||||
/// Request body for reporting playback stopped
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct PlaybackStoppedRequest {
|
||||
pub item_id: String,
|
||||
pub position_ticks: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub play_session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for reporting playback progress
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||
pub struct PlaybackProgressRequest {
|
||||
pub item_id: String,
|
||||
pub position_ticks: i64,
|
||||
pub is_paused: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub play_session_id: Option<String>,
|
||||
}
|
||||
Reference in New Issue
Block a user