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}