//! TRACES: UR-009 | JA-001, JA-002, JA-003, JA-004, JA-007, JA-010, JA-011, JA-012, JA-017, JA-021 | IR-009, IR-010, IR-011 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, http_client: Client, } impl JellyfinClient { /// Create a new Jellyfin API client pub fn new(config: JellyfinConfig) -> Result { let http_client = Client::builder() .timeout(std::time::Duration::from_secs(10)) .https_only(true) .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 Deserialize<'de>>(&self, endpoint: &str) -> Result { 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::(&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(&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, ) -> 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, ) -> 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, ) -> 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, start_index: usize, start_position_ticks: Option, ) -> 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 PascalCase query params) 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 /// /// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the /// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the /// body (as we used to) is silently ignored and the remote never seeks. pub async fn session_seek( &self, session_id: String, position_ticks: i64, ) -> Result<(), String> { let url = format!( "{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}", self.config.server_url, session_id, position_ticks ); let response = self .http_client .post(&url) .header("X-Emby-Authorization", self.get_auth_header()) .send() .await .map_err(|e| format!("Network request failed: {}", e))?; let status = response.status(); if !status.is_success() { let error_text = response .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); return Err(format!( "Jellyfin API error {}: {}", status.as_u16(), error_text )); } log::info!( "[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id ); Ok(()) } /// Send a full GeneralCommand to a remote session. /// Uses POST /Sessions/{id}/Command with a body containing Name and Arguments. /// This is required for commands that need arguments (e.g. SetVolume). async fn send_general_command( &self, session_id: &str, command_name: &str, arguments: Option, ) -> Result<(), String> { let mut payload = serde_json::json!({ "Name": command_name, }); if let Some(args) = arguments { payload["Arguments"] = args; } log::info!( "[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}", command_name, session_id, serde_json::to_string(&payload).unwrap_or_default() ); self.post(&format!("/Sessions/{}/Command", session_id), &payload) .await } /// Set volume on a remote session pub async fn session_set_volume(&self, session_id: String, volume: i32) -> Result<(), String> { self.send_general_command( &session_id, "SetVolume", Some(serde_json::json!({ "Volume": volume.to_string() })), ) .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.send_general_command(&session_id, "ToggleMute", None) .await } /// Get all active sessions pub async fn get_sessions(&self) -> Result, String> { let sessions: Vec = 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, String> { let sessions = self.get_sessions().await?; Ok(sessions .into_iter() .find(|s| s.id.as_deref() == Some(session_id))) } // --- JellyLMS multi-room sync groups ----------------------------------- // // The JellyLMS plugin exposes a REST API under `/JellyLms` for grouping LMS // players ("zones") into synchronized multi-room sync groups. Players are // addressed by MAC address; JellyTau maps a Jellyfin session to a MAC by // stripping the `lms-` prefix off the session's device id (see // LmsDeviceDiscoveryService in the jellyLMS repo, which registers each player // with deviceId = "lms-{MacAddress}"). /// List current LMS sync groups. pub async fn lms_get_sync_groups(&self) -> Result, String> { self.get("/JellyLms/SyncGroups").await } /// Fuse LMS zones: create a sync group with `master_mac` as the sync master /// and `slave_macs` joining it. The master keeps playing; slaves follow. pub async fn lms_create_sync_group( &self, master_mac: &str, slave_macs: Vec, ) -> Result<(), String> { let payload = serde_json::json!({ "MasterMac": master_mac, "SlaveMacs": slave_macs, }); self.post("/JellyLms/SyncGroups", &payload).await } /// Remove a single LMS player from whatever sync group it's in. pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> { self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)) .await } /// Dissolve an entire LMS sync group, identified by its master's MAC. pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> { self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)) .await } /// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints). async fn delete(&self, endpoint: &str) -> Result<(), String> { let url = format!("{}{}", self.config.server_url, endpoint); log::debug!("[JellyfinClient] DELETE {}", endpoint); let response = self .http_client .delete(&url) .header("X-Emby-Authorization", self.get_auth_header()) .send() .await .map_err(|e| format!("Network request failed: {}", e))?; let status = response.status(); if !status.is_success() { let error_text = response .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); return Err(format!( "Jellyfin API error {}: {}", status.as_u16(), error_text )); } Ok(()) } } /// An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`. /// /// Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves /// follow it in lockstep. #[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct LmsSyncGroup { #[serde(alias = "MasterMac")] pub master_mac: String, #[serde(default, alias = "MasterName")] pub master_name: String, #[serde(default, alias = "SlaveMacs")] pub slave_macs: Vec, #[serde(default, alias = "SlaveNames")] pub slave_names: Vec, } /// 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(specta::Type, Debug, Clone, Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct SessionInfo { #[serde(default)] #[serde(alias = "Id")] pub id: Option, #[serde(default)] #[serde(alias = "UserId")] pub user_id: Option, #[serde(default)] #[serde(alias = "UserName")] pub user_name: Option, #[serde(default)] #[serde(alias = "Client")] pub client: Option, #[serde(default)] #[serde(alias = "DeviceName")] pub device_name: Option, #[serde(default)] #[serde(alias = "DeviceId")] pub device_id: Option, #[serde(default)] #[serde(alias = "ApplicationVersion")] pub application_version: Option, #[serde(default)] #[serde(alias = "IsActive")] pub is_active: Option, #[serde(default)] #[serde(alias = "SupportsMediaControl")] pub supports_media_control: Option, #[serde(default = "default_true")] #[serde(alias = "SupportsRemoteControl")] pub supports_remote_control: bool, #[serde(default)] #[serde(alias = "NowPlayingItem")] pub now_playing_item: Option, #[serde(default)] #[serde(alias = "PlayState")] pub play_state: Option, #[serde(default)] #[serde(alias = "PlayableMediaTypes")] pub playable_media_types: Option>, #[serde(default)] #[serde(alias = "SupportedCommands")] pub supported_commands: Option>, } #[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct NowPlayingItem { #[serde(alias = "Id")] pub id: Option, #[serde(alias = "Name")] pub name: Option, #[serde(alias = "RunTimeTicks")] pub run_time_ticks: Option, #[serde(alias = "Album")] pub album: Option, #[serde(alias = "AlbumId")] pub album_id: Option, #[serde(alias = "AlbumArtist")] pub album_artist: Option, #[serde(alias = "Artists")] pub artists: Option>, #[serde(alias = "ImageTags")] pub image_tags: Option>, #[serde(alias = "PrimaryImageTag")] pub primary_image_tag: Option, #[serde(alias = "AlbumPrimaryImageTag")] pub album_primary_image_tag: Option, #[serde(rename = "Type")] pub item_type: Option, } #[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct PlayState { #[serde(default)] #[serde(alias = "PositionTicks")] pub position_ticks: Option, #[serde(default)] #[serde(alias = "CanSeek")] pub can_seek: Option, #[serde(default)] #[serde(alias = "IsPaused")] pub is_paused: Option, #[serde(default)] #[serde(alias = "IsMuted")] pub is_muted: Option, #[serde(default)] #[serde(alias = "VolumeLevel")] pub volume_level: Option, #[serde(default)] #[serde(alias = "RepeatMode")] pub repeat_mode: Option, #[serde(default)] #[serde(alias = "ShuffleMode")] pub shuffle_mode: Option, } #[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\"")); } }