1use 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#[derive(Clone)]
15pub struct JellyfinClient {
16 config: Arc<JellyfinConfig>,
17 http_client: Client,
18}
19
20impl JellyfinClient {
21 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 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 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 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 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 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 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 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 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 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 #[allow(dead_code)] 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 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 let mut url = format!(
271 "{}/Sessions/{}/Playing?PlayCommand=PlayNow&StartIndex={}",
272 self.config.server_url, session_id, start_index
273 );
274
275 for item_id in &item_ids {
277 url.push_str(&format!("&ItemIds={}", item_id));
278 }
279
280 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 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 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 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 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 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 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 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 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 pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String> {
471 self.get("/JellyLms/SyncGroups").await
472 }
473
474 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 pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
490 self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac))
491 .await
492 }
493
494 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 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#[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
547fn default_true() -> bool {
551 true
552}
553
554#[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}