Skip to main content

#
jellytau_lib/jellyfin/
client.rs

    1//! 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
    2
    3use log::{debug, error, info};
    4use reqwest::Client;
    5use serde::Deserialize;
    6use std::sync::Arc;
    7
    8use super::types::*;
    9
    10const APP_NAME: &str = "JellyTau";
    11const APP_VERSION: &str = "0.1.0";
    12
    13/// Jellyfin API client for playback reporting
    14#[derive(Clone)]
    15pub struct JellyfinClient {
    16    config: Arc<JellyfinConfig>,
    17    http_client: Client,
    18}
    19
    20impl JellyfinClient {
    21    /// Create a new Jellyfin API client
    22    pub fn new(config: JellyfinConfig) -> Result<Self, String> {
    23        let http_client = Client::builder()
    24            .timeout(std::time::Duration::from_secs(10))
    25            .https_only(true)
    26            .build()
    27            .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
    28
    29        Ok(Self {
    30            config: Arc::new(config),
    31            http_client,
    32        })
    33    }
    34
    35    /// Get device name based on platform
    36    fn get_device_name() -> &'static str {
    37        #[cfg(target_os = "android")]
    38        return "Android";
    39        #[cfg(target_os = "linux")]
    40        return "Linux";
    41        #[cfg(target_os = "windows")]
    42        return "Windows";
    43        #[cfg(target_os = "macos")]
    44        return "macOS";
    45        #[cfg(target_os = "ios")]
    46        return "iOS";
    47        #[cfg(not(any(
    48            target_os = "android",
    49            target_os = "linux",
    50            target_os = "windows",
    51            target_os = "macos",
    52            target_os = "ios"
    53        )))]
    54        return "Unknown";
    55    }
    56
    57    /// Build the X-Emby-Authorization header value
    58    fn get_auth_header(&self) -> String {
    59        format!(
    60            "MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"",
    61            APP_NAME,
    62            APP_VERSION,
    63            Self::get_device_name(),
    64            self.config.device_id,
    65            self.config.access_token
    66        )
    67    }
    68
    69    /// Make a GET request to the Jellyfin API
    70    async fn get<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, String> {
    71        let url = format!("{}{}", self.config.server_url, endpoint);
    72
    73        log::debug!("[JellyfinClient] GET {}", endpoint);
    74
    75        let response = self
    76            .http_client
    77            .get(&url)
    78            .header("X-Emby-Authorization", self.get_auth_header())
    79            .send()
    80            .await
    81            .map_err(|e| {
    82                log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
    83                format!("Network request failed: {}", e)
    84            })?;
    85
    86        let status = response.status();
    87        log::debug!(
    88            "[JellyfinClient] Response status for {}: {}",
    89            endpoint,
    90            status
    91        );
    92
    93        if !status.is_success() {
    94            let error_text = response
    95                .text()
    96                .await
    97                .unwrap_or_else(|_| "Unknown error".to_string());
    98            log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
    99            log::error!("[JellyfinClient] Response: {}", error_text);
    100            return Err(format!(
    101                "Jellyfin API error {}: {}",
    102                status.as_u16(),
    103                error_text
    104            ));
    105        }
    106
    107        // Get the response text first so we can log it
    108        let response_text = response.text().await.map_err(|e| {
    109            log::error!("[JellyfinClient] Failed to read response body: {}", e);
    110            format!("Failed to read response: {}", e)
    111        })?;
    112
    113        // Log the raw response for sessions endpoint to help debug
    114        if endpoint.contains("/Sessions") {
    115            debug!(
    116                "[JellyfinClient] Raw response for {}: {}",
    117                endpoint,
    118                if response_text.len() > 500 {
    119                    format!(
    120                        "{}... (truncated, {} bytes total)",
    121                        &response_text[..500],
    122                        response_text.len()
    123                    )
    124                } else {
    125                    response_text.clone()
    126                }
    127            );
    128        }
    129
    130        // Parse the response text as JSON
    131        let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
    132            log::error!("[JellyfinClient] Failed to parse response: {}", e);
    133            log::error!(
    134                "[JellyfinClient] Response was: {}",
    135                if response_text.len() > 200 {
    136                    format!("{}...", &response_text[..200])
    137                } else {
    138                    response_text.clone()
    139                }
    140            );
    141            format!("Failed to parse response: {}", e)
    142        })?;
    143
    144        log::debug!("[JellyfinClient] Request successful for {}", endpoint);
    145        Ok(data)
    146    }
    147
    148    /// Make a POST request to the Jellyfin API
    149    async fn post<T: serde::Serialize>(&self, endpoint: &str, body: &T) -> Result<(), String> {
    150        let url = format!("{}{}", self.config.server_url, endpoint);
    151
    152        log::debug!("[JellyfinClient] POST {} to {}", endpoint, url);
    153
    154        let response: reqwest::Response = self
    155            .http_client
    156            .post(&url)
    157            .header("Content-Type", "application/json")
    158            .header("X-Emby-Authorization", self.get_auth_header())
    159            .json(body)
    160            .send()
    161            .await
    162            .map_err(|e| {
    163                log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
    164                format!("Network request failed: {}", e)
    165            })?;
    166
    167        let status = response.status();
    168        log::debug!(
    169            "[JellyfinClient] Response status for {}: {}",
    170            endpoint,
    171            status
    172        );
    173
    174        if !status.is_success() {
    175            let error_text: String = response
    176                .text()
    177                .await
    178                .unwrap_or_else(|_| "Unknown error".to_string());
    179            log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
    180            log::error!("[JellyfinClient] Response: {}", error_text);
    181            return Err(format!(
    182                "Jellyfin API error {}: {}",
    183                status.as_u16(),
    184                error_text
    185            ));
    186        }
    187
    188        log::debug!("[JellyfinClient] Request successful for {}", endpoint);
    189        Ok(())
    190    }
    191
    192    /// Report playback start to Jellyfin
    193    pub async fn report_playback_start(
    194        &self,
    195        item_id: String,
    196        position_ticks: i64,
    197        play_session_id: Option<String>,
    198    ) -> Result<(), String> {
    199        let request = PlaybackStartRequest {
    200            item_id,
    201            position_ticks,
    202            play_session_id,
    203            play_command: "PlayNow".to_string(),
    204            is_paused: false,
    205        };
    206
    207        self.post("/Sessions/Playing", &request).await
    208    }
    209
    210    /// Report playback stopped to Jellyfin
    211    pub async fn report_playback_stopped(
    212        &self,
    213        item_id: String,
    214        position_ticks: i64,
    215        play_session_id: Option<String>,
    216    ) -> Result<(), String> {
    217        let request = PlaybackStoppedRequest {
    218            item_id,
    219            position_ticks,
    220            play_session_id,
    221        };
    222
    223        self.post("/Sessions/Playing/Stopped", &request).await
    224    }
    225
    226    /// Report playback progress to Jellyfin
    227    #[allow(dead_code)] // Will be used when playback_reporting is integrated
    228    pub async fn report_playback_progress(
    229        &self,
    230        item_id: String,
    231        position_ticks: i64,
    232        is_paused: bool,
    233        play_session_id: Option<String>,
    234    ) -> Result<(), String> {
    235        let request = PlaybackProgressRequest {
    236            item_id,
    237            position_ticks,
    238            is_paused,
    239            play_session_id,
    240        };
    241
    242        self.post("/Sessions/Playing/Progress", &request).await
    243    }
    244
    245    /// Play items on a remote session (casting)
    246    pub async fn play_on_session(
    247        &self,
    248        session_id: String,
    249        item_ids: Vec<String>,
    250        start_index: usize,
    251        start_position_ticks: Option<i64>,
    252    ) -> Result<(), String> {
    253        log::info!("[JellyfinClient] Playing on session: {}", session_id);
    254        log::info!(
    255            "[JellyfinClient] Item IDs: {:?}, Start index: {}",
    256            item_ids,
    257            start_index
    258        );
    259        debug!(
    260            "[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
    261            session_id,
    262            item_ids.len(),
    263            start_index
    264        );
    265
    266        // Build URL with query parameters (Jellyfin expects PascalCase query params)
    267        let mut url = format!(
    268            "{}/Sessions/{}/Playing?PlayCommand=PlayNow&StartIndex={}",
    269            self.config.server_url, session_id, start_index
    270        );
    271
    272        // Add item IDs as repeated query parameters
    273        for item_id in &item_ids {
    274            url.push_str(&format!("&ItemIds={}", item_id));
    275        }
    276
    277        // Add start position if provided
    278        if let Some(ticks) = start_position_ticks {
    279            url.push_str(&format!("&StartPositionTicks={}", ticks));
    280            log::info!("[JellyfinClient] Starting at position: {} ticks", ticks);
    281        }
    282
    283        log::info!("[JellyfinClient] POST {}", url);
    284        debug!("[JellyfinClient] Full URL length: {} chars", url.len());
    285        // Don't log full URL as it may contain sensitive tokens, just log the endpoint
    286        debug!(
    287            "[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds",
    288            session_id,
    289            item_ids.len()
    290        );
    291
    292        debug!("[JellyfinClient] Sending HTTP POST request...");
    293        let response = self
    294            .http_client
    295            .post(&url)
    296            .header("X-Emby-Authorization", self.get_auth_header())
    297            .send()
    298            .await
    299            .map_err(|e| {
    300                log::error!("[JellyfinClient] Request failed: {}", e);
    301                error!("[JellyfinClient] HTTP request failed: {}", e);
    302                format!("Network request failed: {}", e)
    303            })?;
    304
    305        let status = response.status();
    306        log::debug!("[JellyfinClient] Response status: {}", status);
    307        debug!("[JellyfinClient] Response status: {}", status);
    308
    309        if !status.is_success() {
    310            let error_text = response
    311                .text()
    312                .await
    313                .unwrap_or_else(|_| "Unknown error".to_string());
    314            log::error!("[JellyfinClient] Request failed: {}", error_text);
    315            error!(
    316                "[JellyfinClient] API error {}: {}",
    317                status.as_u16(),
    318                error_text
    319            );
    320            return Err(format!(
    321                "Jellyfin API error {}: {}",
    322                status.as_u16(),
    323                error_text
    324            ));
    325        }
    326
    327        log::info!("[JellyfinClient] Successfully sent play command to remote session");
    328        info!("[JellyfinClient] Play command sent to remote session");
    329        Ok(())
    330    }
    331
    332    /// Send a playback command to a remote session
    333    pub async fn send_session_command(
    334        &self,
    335        session_id: String,
    336        command: &str,
    337    ) -> Result<(), String> {
    338        self.post(
    339            &format!("/Sessions/{}/Playing/{}", session_id, command),
    340            &serde_json::json!({}),
    341        )
    342        .await
    343    }
    344
    345    /// Seek on a remote session
    346    ///
    347    /// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the
    348    /// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the
    349    /// body (as we used to) is silently ignored and the remote never seeks.
    350    pub async fn session_seek(
    351        &self,
    352        session_id: String,
    353        position_ticks: i64,
    354    ) -> Result<(), String> {
    355        let url = format!(
    356            "{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
    357            self.config.server_url, session_id, position_ticks
    358        );
    359
    360        let response = self
    361            .http_client
    362            .post(&url)
    363            .header("X-Emby-Authorization", self.get_auth_header())
    364            .send()
    365            .await
    366            .map_err(|e| format!("Network request failed: {}", e))?;
    367
    368        let status = response.status();
    369        if !status.is_success() {
    370            let error_text = response
    371                .text()
    372                .await
    373                .unwrap_or_else(|_| "Unknown error".to_string());
    374            return Err(format!(
    375                "Jellyfin API error {}: {}",
    376                status.as_u16(),
    377                error_text
    378            ));
    379        }
    380
    381        log::info!(
    382            "[JellyfinClient] Seek to {} ticks on session {}",
    383            position_ticks,
    384            session_id
    385        );
    386        Ok(())
    387    }
    388
    389    /// Send a full GeneralCommand to a remote session.
    390    /// Uses POST /Sessions/{id}/Command with a body containing Name and Arguments.
    391    /// This is required for commands that need arguments (e.g. SetVolume).
    392    async fn send_general_command(
    393        &self,
    394        session_id: &str,
    395        command_name: &str,
    396        arguments: Option<serde_json::Value>,
    397    ) -> Result<(), String> {
    398        let mut payload = serde_json::json!({
    399            "Name": command_name,
    400        });
    401
    402        if let Some(args) = arguments {
    403            payload["Arguments"] = args;
    404        }
    405
    406        log::info!(
    407            "[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
    408            command_name,
    409            session_id,
    410            serde_json::to_string(&payload).unwrap_or_default()
    411        );
    412
    413        self.post(&format!("/Sessions/{}/Command", session_id), &payload)
    414            .await
    415    }
    416
    417    /// Set volume on a remote session
    418    pub async fn session_set_volume(&self, session_id: String, volume: i32) -> Result<(), String> {
    419        self.send_general_command(
    420            &session_id,
    421            "SetVolume",
    422            Some(serde_json::json!({ "Volume": volume.to_string() })),
    423        )
    424        .await
    425    }
    426
    427    /// Toggle mute on a remote session
    428    pub async fn session_toggle_mute(&self, session_id: String) -> Result<(), String> {
    429        log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
    430
    431        self.send_general_command(&session_id, "ToggleMute", None)
    432            .await
    433    }
    434
    435    /// Get all active sessions
    436    pub async fn get_sessions(&self) -> Result<Vec<SessionInfo>, String> {
    437        let sessions: Vec<SessionInfo> = self.get("/Sessions").await?;
    438        info!(
    439            "[JellyfinClient] Fetched {} sessions from API",
    440            sessions.len()
    441        );
    442        for session in &sessions {
    443            debug!("[JellyfinClient] Session: id={:?}, device={:?}, client={:?}, supportsRemoteControl={}",
    444                session.id, session.device_name, session.client, session.supports_remote_control);
    445        }
    446        Ok(sessions)
    447    }
    448
    449    /// Get a specific session by ID
    450    pub async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>, String> {
    451        let sessions = self.get_sessions().await?;
    452        Ok(sessions
    453            .into_iter()
    454            .find(|s| s.id.as_deref() == Some(session_id)))
    455    }
    456
    457    // --- JellyLMS multi-room sync groups -----------------------------------
    458    //
    459    // The JellyLMS plugin exposes a REST API under `/JellyLms` for grouping LMS
    460    // players ("zones") into synchronized multi-room sync groups. Players are
    461    // addressed by MAC address; JellyTau maps a Jellyfin session to a MAC by
    462    // stripping the `lms-` prefix off the session's device id (see
    463    // LmsDeviceDiscoveryService in the jellyLMS repo, which registers each player
    464    // with deviceId = "lms-{MacAddress}").
    465
    466    /// List current LMS sync groups.
    467    pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String> {
    468        self.get("/JellyLms/SyncGroups").await
    469    }
    470
    471    /// Fuse LMS zones: create a sync group with `master_mac` as the sync master
    472    /// and `slave_macs` joining it. The master keeps playing; slaves follow.
    473    pub async fn lms_create_sync_group(
    474        &self,
    475        master_mac: &str,
    476        slave_macs: Vec<String>,
    477    ) -> Result<(), String> {
    478        let payload = serde_json::json!({
    479            "MasterMac": master_mac,
    480            "SlaveMacs": slave_macs,
    481        });
    482        self.post("/JellyLms/SyncGroups", &payload).await
    483    }
    484
    485    /// Remove a single LMS player from whatever sync group it's in.
    486    pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
    487        self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac))
    488            .await
    489    }
    490
    491    /// Dissolve an entire LMS sync group, identified by its master's MAC.
    492    pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
    493        self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac))
    494            .await
    495    }
    496
    497    /// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
    498    async fn delete(&self, endpoint: &str) -> Result<(), String> {
    499        let url = format!("{}{}", self.config.server_url, endpoint);
    500
    501        log::debug!("[JellyfinClient] DELETE {}", endpoint);
    502
    503        let response = self
    504            .http_client
    505            .delete(&url)
    506            .header("X-Emby-Authorization", self.get_auth_header())
    507            .send()
    508            .await
    509            .map_err(|e| format!("Network request failed: {}", e))?;
    510
    511        let status = response.status();
    512        if !status.is_success() {
    513            let error_text = response
    514                .text()
    515                .await
    516                .unwrap_or_else(|_| "Unknown error".to_string());
    517            return Err(format!(
    518                "Jellyfin API error {}: {}",
    519                status.as_u16(),
    520                error_text
    521            ));
    522        }
    523        Ok(())
    524    }
    525}
    526
    527/// An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
    528///
    529/// Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
    530/// follow it in lockstep.
    531#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
    532#[serde(rename_all = "camelCase")]
    533pub struct LmsSyncGroup {
    534    #[serde(alias = "MasterMac")]
    535    pub master_mac: String,
    536    #[serde(default, alias = "MasterName")]
    537    pub master_name: String,
    538    #[serde(default, alias = "SlaveMacs")]
    539    pub slave_macs: Vec<String>,
    540    #[serde(default, alias = "SlaveNames")]
    541    pub slave_names: Vec<String>,
    542}
    543
    544/// Default value for supports_remote_control when missing from API
    545/// We default to true to show all sessions. If a session explicitly doesn't
    546/// support remote control, the Jellyfin API will set this field to false.
    547fn default_true() -> bool {
    548    true
    549}
    550
    551/// Session information from Jellyfin
    552#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
    553#[serde(rename_all = "camelCase")]
    554pub struct SessionInfo {
    555    #[serde(default)]
    556    #[serde(alias = "Id")]
    557    pub id: Option<String>,
    558    #[serde(default)]
    559    #[serde(alias = "UserId")]
    560    pub user_id: Option<String>,
    561    #[serde(default)]
    562    #[serde(alias = "UserName")]
    563    pub user_name: Option<String>,
    564    #[serde(default)]
    565    #[serde(alias = "Client")]
    566    pub client: Option<String>,
    567    #[serde(default)]
    568    #[serde(alias = "DeviceName")]
    569    pub device_name: Option<String>,
    570    #[serde(default)]
    571    #[serde(alias = "DeviceId")]
    572    pub device_id: Option<String>,
    573    #[serde(default)]
    574    #[serde(alias = "ApplicationVersion")]
    575    pub application_version: Option<String>,
    576    #[serde(default)]
    577    #[serde(alias = "IsActive")]
    578    pub is_active: Option<bool>,
    579    #[serde(default)]
    580    #[serde(alias = "SupportsMediaControl")]
    581    pub supports_media_control: Option<bool>,
    582    #[serde(default = "default_true")]
    583    #[serde(alias = "SupportsRemoteControl")]
    584    pub supports_remote_control: bool,
    585    #[serde(default)]
    586    #[serde(alias = "NowPlayingItem")]
    587    pub now_playing_item: Option<NowPlayingItem>,
    588    #[serde(default)]
    589    #[serde(alias = "PlayState")]
    590    pub play_state: Option<PlayState>,
    591    #[serde(default)]
    592    #[serde(alias = "PlayableMediaTypes")]
    593    pub playable_media_types: Option<Vec<String>>,
    594    #[serde(default)]
    595    #[serde(alias = "SupportedCommands")]
    596    pub supported_commands: Option<Vec<String>>,
    597}
    598
    599#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
    600#[serde(rename_all = "camelCase")]
    601pub struct NowPlayingItem {
    602    #[serde(alias = "Id")]
    603    pub id: Option<String>,
    604    #[serde(alias = "Name")]
    605    pub name: Option<String>,
    606    #[serde(alias = "RunTimeTicks")]
    607    pub run_time_ticks: Option<i64>,
    608    #[serde(alias = "Album")]
    609    pub album: Option<String>,
    610    #[serde(alias = "AlbumId")]
    611    pub album_id: Option<String>,
    612    #[serde(alias = "AlbumArtist")]
    613    pub album_artist: Option<String>,
    614    #[serde(alias = "Artists")]
    615    pub artists: Option<Vec<String>>,
    616    #[serde(alias = "ImageTags")]
    617    pub image_tags: Option<std::collections::HashMap<String, String>>,
    618    #[serde(alias = "PrimaryImageTag")]
    619    pub primary_image_tag: Option<String>,
    620    #[serde(alias = "AlbumPrimaryImageTag")]
    621    pub album_primary_image_tag: Option<String>,
    622    #[serde(rename = "Type")]
    623    pub item_type: Option<String>,
    624}
    625
    626#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
    627#[serde(rename_all = "camelCase")]
    628pub struct PlayState {
    629    #[serde(default)]
    630    #[serde(alias = "PositionTicks")]
    631    pub position_ticks: Option<i64>,
    632    #[serde(default)]
    633    #[serde(alias = "CanSeek")]
    634    pub can_seek: Option<bool>,
    635    #[serde(default)]
    636    #[serde(alias = "IsPaused")]
    637    pub is_paused: Option<bool>,
    638    #[serde(default)]
    639    #[serde(alias = "IsMuted")]
    640    pub is_muted: Option<bool>,
    641    #[serde(default)]
    642    #[serde(alias = "VolumeLevel")]
    643    pub volume_level: Option<i32>,
    644    #[serde(default)]
    645    #[serde(alias = "RepeatMode")]
    646    pub repeat_mode: Option<String>,
    647    #[serde(default)]
    648    #[serde(alias = "ShuffleMode")]
    649    pub shuffle_mode: Option<String>,
    650}
    651
    652#[cfg(test)]
    653mod tests {
    654    use super::*;
    655
    656    #[test]
    657    fn test_auth_header_format() {
    658        let config = JellyfinConfig {
    659            server_url: "http://localhost:8096".to_string(),
    660            access_token: "test_token".to_string(),
    661            device_id: "device456".to_string(),
    662        };
    663
    664        let client = JellyfinClient::new(config).unwrap();
    665        let header = client.get_auth_header();
    666
    667        assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
    668        assert!(header.contains("Token=\"test_token\""));
    669        assert!(header.contains("DeviceId=\"device456\""));
    670    }
    671}