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