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 {
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 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 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 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 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 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 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 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 #[allow(dead_code)] 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 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 let mut url = format!(
268 "{}/Sessions/{}/Playing?PlayCommand=PlayNow&StartIndex={}",
269 self.config.server_url, session_id, start_index
270 );
271
272 for item_id in &item_ids {
274 url.push_str(&format!("&ItemIds={}", item_id));
275 }
276
277 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 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 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 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 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 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 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 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 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 pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String> {
468 self.get("/JellyLms/SyncGroups").await
469 }
470
471 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 pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
487 self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac))
488 .await
489 }
490
491 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 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#[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
544fn default_true() -> bool {
548 true
549}
550
551#[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}