Hand video playback off to a native audio-only stream when the app is backgrounded or locked, with no on-device video decode (UR-040). Adds player_enter/exit_background_audio commands, an audio-only stream URL for video items across the repository layer, and the frontend handoff state machine wired into VideoPlayer. Includes accompanying repository/offline/player refactoring and regenerates the traceability matrix.
672 lines
22 KiB
Rust
672 lines
22 KiB
Rust
//! 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
|
|
|
|
use log::{debug, error, info};
|
|
use reqwest::Client;
|
|
use serde::Deserialize;
|
|
use std::sync::Arc;
|
|
|
|
use super::types::*;
|
|
|
|
const APP_NAME: &str = "JellyTau";
|
|
const APP_VERSION: &str = "0.1.0";
|
|
|
|
/// Jellyfin API client for playback reporting
|
|
#[derive(Clone)]
|
|
pub struct JellyfinClient {
|
|
config: Arc<JellyfinConfig>,
|
|
http_client: Client,
|
|
}
|
|
|
|
impl JellyfinClient {
|
|
/// Create a new Jellyfin API client
|
|
pub fn new(config: JellyfinConfig) -> Result<Self, String> {
|
|
let http_client = Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.https_only(true)
|
|
.build()
|
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
|
|
|
Ok(Self {
|
|
config: Arc::new(config),
|
|
http_client,
|
|
})
|
|
}
|
|
|
|
/// Get device name based on platform
|
|
fn get_device_name() -> &'static str {
|
|
#[cfg(target_os = "android")]
|
|
return "Android";
|
|
#[cfg(target_os = "linux")]
|
|
return "Linux";
|
|
#[cfg(target_os = "windows")]
|
|
return "Windows";
|
|
#[cfg(target_os = "macos")]
|
|
return "macOS";
|
|
#[cfg(target_os = "ios")]
|
|
return "iOS";
|
|
#[cfg(not(any(
|
|
target_os = "android",
|
|
target_os = "linux",
|
|
target_os = "windows",
|
|
target_os = "macos",
|
|
target_os = "ios"
|
|
)))]
|
|
return "Unknown";
|
|
}
|
|
|
|
/// Build the X-Emby-Authorization header value
|
|
fn get_auth_header(&self) -> String {
|
|
format!(
|
|
"MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"",
|
|
APP_NAME,
|
|
APP_VERSION,
|
|
Self::get_device_name(),
|
|
self.config.device_id,
|
|
self.config.access_token
|
|
)
|
|
}
|
|
|
|
/// Make a GET request to the Jellyfin API
|
|
async fn get<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, String> {
|
|
let url = format!("{}{}", self.config.server_url, endpoint);
|
|
|
|
log::debug!("[JellyfinClient] GET {}", endpoint);
|
|
|
|
let response = self
|
|
.http_client
|
|
.get(&url)
|
|
.header("X-Emby-Authorization", self.get_auth_header())
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
|
|
format!("Network request failed: {}", e)
|
|
})?;
|
|
|
|
let status = response.status();
|
|
log::debug!(
|
|
"[JellyfinClient] Response status for {}: {}",
|
|
endpoint,
|
|
status
|
|
);
|
|
|
|
if !status.is_success() {
|
|
let error_text = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
|
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
|
|
log::error!("[JellyfinClient] Response: {}", error_text);
|
|
return Err(format!(
|
|
"Jellyfin API error {}: {}",
|
|
status.as_u16(),
|
|
error_text
|
|
));
|
|
}
|
|
|
|
// Get the response text first so we can log it
|
|
let response_text = response.text().await.map_err(|e| {
|
|
log::error!("[JellyfinClient] Failed to read response body: {}", e);
|
|
format!("Failed to read response: {}", e)
|
|
})?;
|
|
|
|
// Log the raw response for sessions endpoint to help debug
|
|
if endpoint.contains("/Sessions") {
|
|
debug!(
|
|
"[JellyfinClient] Raw response for {}: {}",
|
|
endpoint,
|
|
if response_text.len() > 500 {
|
|
format!(
|
|
"{}... (truncated, {} bytes total)",
|
|
&response_text[..500],
|
|
response_text.len()
|
|
)
|
|
} else {
|
|
response_text.clone()
|
|
}
|
|
);
|
|
}
|
|
|
|
// Parse the response text as JSON
|
|
let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
|
|
log::error!("[JellyfinClient] Failed to parse response: {}", e);
|
|
log::error!(
|
|
"[JellyfinClient] Response was: {}",
|
|
if response_text.len() > 200 {
|
|
format!("{}...", &response_text[..200])
|
|
} else {
|
|
response_text.clone()
|
|
}
|
|
);
|
|
format!("Failed to parse response: {}", e)
|
|
})?;
|
|
|
|
log::debug!("[JellyfinClient] Request successful for {}", endpoint);
|
|
Ok(data)
|
|
}
|
|
|
|
/// Make a POST request to the Jellyfin API
|
|
async fn post<T: serde::Serialize>(&self, endpoint: &str, body: &T) -> Result<(), String> {
|
|
let url = format!("{}{}", self.config.server_url, endpoint);
|
|
|
|
log::debug!("[JellyfinClient] POST {} to {}", endpoint, url);
|
|
|
|
let response: reqwest::Response = self
|
|
.http_client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("X-Emby-Authorization", self.get_auth_header())
|
|
.json(body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
|
|
format!("Network request failed: {}", e)
|
|
})?;
|
|
|
|
let status = response.status();
|
|
log::debug!(
|
|
"[JellyfinClient] Response status for {}: {}",
|
|
endpoint,
|
|
status
|
|
);
|
|
|
|
if !status.is_success() {
|
|
let error_text: String = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
|
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
|
|
log::error!("[JellyfinClient] Response: {}", error_text);
|
|
return Err(format!(
|
|
"Jellyfin API error {}: {}",
|
|
status.as_u16(),
|
|
error_text
|
|
));
|
|
}
|
|
|
|
log::debug!("[JellyfinClient] Request successful for {}", endpoint);
|
|
Ok(())
|
|
}
|
|
|
|
/// Report playback start to Jellyfin
|
|
pub async fn report_playback_start(
|
|
&self,
|
|
item_id: String,
|
|
position_ticks: i64,
|
|
play_session_id: Option<String>,
|
|
) -> Result<(), String> {
|
|
let request = PlaybackStartRequest {
|
|
item_id,
|
|
position_ticks,
|
|
play_session_id,
|
|
play_command: "PlayNow".to_string(),
|
|
is_paused: false,
|
|
};
|
|
|
|
self.post("/Sessions/Playing", &request).await
|
|
}
|
|
|
|
/// Report playback stopped to Jellyfin
|
|
pub async fn report_playback_stopped(
|
|
&self,
|
|
item_id: String,
|
|
position_ticks: i64,
|
|
play_session_id: Option<String>,
|
|
) -> Result<(), String> {
|
|
let request = PlaybackStoppedRequest {
|
|
item_id,
|
|
position_ticks,
|
|
play_session_id,
|
|
};
|
|
|
|
self.post("/Sessions/Playing/Stopped", &request).await
|
|
}
|
|
|
|
/// Report playback progress to Jellyfin
|
|
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
|
pub async fn report_playback_progress(
|
|
&self,
|
|
item_id: String,
|
|
position_ticks: i64,
|
|
is_paused: bool,
|
|
play_session_id: Option<String>,
|
|
) -> Result<(), String> {
|
|
let request = PlaybackProgressRequest {
|
|
item_id,
|
|
position_ticks,
|
|
is_paused,
|
|
play_session_id,
|
|
};
|
|
|
|
self.post("/Sessions/Playing/Progress", &request).await
|
|
}
|
|
|
|
/// Play items on a remote session (casting)
|
|
pub async fn play_on_session(
|
|
&self,
|
|
session_id: String,
|
|
item_ids: Vec<String>,
|
|
start_index: usize,
|
|
start_position_ticks: Option<i64>,
|
|
) -> Result<(), String> {
|
|
log::info!("[JellyfinClient] Playing on session: {}", session_id);
|
|
log::info!(
|
|
"[JellyfinClient] Item IDs: {:?}, Start index: {}",
|
|
item_ids,
|
|
start_index
|
|
);
|
|
debug!(
|
|
"[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
|
|
session_id,
|
|
item_ids.len(),
|
|
start_index
|
|
);
|
|
|
|
// Build URL with query parameters (Jellyfin expects PascalCase query params)
|
|
let mut url = format!(
|
|
"{}/Sessions/{}/Playing?PlayCommand=PlayNow&StartIndex={}",
|
|
self.config.server_url, session_id, start_index
|
|
);
|
|
|
|
// Add item IDs as repeated query parameters
|
|
for item_id in &item_ids {
|
|
url.push_str(&format!("&ItemIds={}", item_id));
|
|
}
|
|
|
|
// Add start position if provided
|
|
if let Some(ticks) = start_position_ticks {
|
|
url.push_str(&format!("&StartPositionTicks={}", ticks));
|
|
log::info!("[JellyfinClient] Starting at position: {} ticks", ticks);
|
|
}
|
|
|
|
log::info!("[JellyfinClient] POST {}", url);
|
|
debug!("[JellyfinClient] Full URL length: {} chars", url.len());
|
|
// Don't log full URL as it may contain sensitive tokens, just log the endpoint
|
|
debug!(
|
|
"[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds",
|
|
session_id,
|
|
item_ids.len()
|
|
);
|
|
|
|
debug!("[JellyfinClient] Sending HTTP POST request...");
|
|
let response = self
|
|
.http_client
|
|
.post(&url)
|
|
.header("X-Emby-Authorization", self.get_auth_header())
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
log::error!("[JellyfinClient] Request failed: {}", e);
|
|
error!("[JellyfinClient] HTTP request failed: {}", e);
|
|
format!("Network request failed: {}", e)
|
|
})?;
|
|
|
|
let status = response.status();
|
|
log::debug!("[JellyfinClient] Response status: {}", status);
|
|
debug!("[JellyfinClient] Response status: {}", status);
|
|
|
|
if !status.is_success() {
|
|
let error_text = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
|
log::error!("[JellyfinClient] Request failed: {}", error_text);
|
|
error!(
|
|
"[JellyfinClient] API error {}: {}",
|
|
status.as_u16(),
|
|
error_text
|
|
);
|
|
return Err(format!(
|
|
"Jellyfin API error {}: {}",
|
|
status.as_u16(),
|
|
error_text
|
|
));
|
|
}
|
|
|
|
log::info!("[JellyfinClient] Successfully sent play command to remote session");
|
|
info!("[JellyfinClient] Play command sent to remote session");
|
|
Ok(())
|
|
}
|
|
|
|
/// Send a playback command to a remote session
|
|
pub async fn send_session_command(
|
|
&self,
|
|
session_id: String,
|
|
command: &str,
|
|
) -> Result<(), String> {
|
|
self.post(
|
|
&format!("/Sessions/{}/Playing/{}", session_id, command),
|
|
&serde_json::json!({}),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Seek on a remote session
|
|
///
|
|
/// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the
|
|
/// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the
|
|
/// body (as we used to) is silently ignored and the remote never seeks.
|
|
pub async fn session_seek(
|
|
&self,
|
|
session_id: String,
|
|
position_ticks: i64,
|
|
) -> Result<(), String> {
|
|
let url = format!(
|
|
"{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
|
|
self.config.server_url, session_id, position_ticks
|
|
);
|
|
|
|
let response = self
|
|
.http_client
|
|
.post(&url)
|
|
.header("X-Emby-Authorization", self.get_auth_header())
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Network request failed: {}", e))?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let error_text = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
|
return Err(format!(
|
|
"Jellyfin API error {}: {}",
|
|
status.as_u16(),
|
|
error_text
|
|
));
|
|
}
|
|
|
|
log::info!(
|
|
"[JellyfinClient] Seek to {} ticks on session {}",
|
|
position_ticks,
|
|
session_id
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Send a full GeneralCommand to a remote session.
|
|
/// Uses POST /Sessions/{id}/Command with a body containing Name and Arguments.
|
|
/// This is required for commands that need arguments (e.g. SetVolume).
|
|
async fn send_general_command(
|
|
&self,
|
|
session_id: &str,
|
|
command_name: &str,
|
|
arguments: Option<serde_json::Value>,
|
|
) -> Result<(), String> {
|
|
let mut payload = serde_json::json!({
|
|
"Name": command_name,
|
|
});
|
|
|
|
if let Some(args) = arguments {
|
|
payload["Arguments"] = args;
|
|
}
|
|
|
|
log::info!(
|
|
"[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
|
|
command_name,
|
|
session_id,
|
|
serde_json::to_string(&payload).unwrap_or_default()
|
|
);
|
|
|
|
self.post(&format!("/Sessions/{}/Command", session_id), &payload)
|
|
.await
|
|
}
|
|
|
|
/// Set volume on a remote session
|
|
pub async fn session_set_volume(&self, session_id: String, volume: i32) -> Result<(), String> {
|
|
self.send_general_command(
|
|
&session_id,
|
|
"SetVolume",
|
|
Some(serde_json::json!({ "Volume": volume.to_string() })),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Toggle mute on a remote session
|
|
pub async fn session_toggle_mute(&self, session_id: String) -> Result<(), String> {
|
|
log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
|
|
|
|
self.send_general_command(&session_id, "ToggleMute", None)
|
|
.await
|
|
}
|
|
|
|
/// Get all active sessions
|
|
pub async fn get_sessions(&self) -> Result<Vec<SessionInfo>, String> {
|
|
let sessions: Vec<SessionInfo> = self.get("/Sessions").await?;
|
|
info!(
|
|
"[JellyfinClient] Fetched {} sessions from API",
|
|
sessions.len()
|
|
);
|
|
for session in &sessions {
|
|
debug!("[JellyfinClient] Session: id={:?}, device={:?}, client={:?}, supportsRemoteControl={}",
|
|
session.id, session.device_name, session.client, session.supports_remote_control);
|
|
}
|
|
Ok(sessions)
|
|
}
|
|
|
|
/// Get a specific session by ID
|
|
pub async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>, String> {
|
|
let sessions = self.get_sessions().await?;
|
|
Ok(sessions
|
|
.into_iter()
|
|
.find(|s| s.id.as_deref() == Some(session_id)))
|
|
}
|
|
|
|
// --- JellyLMS multi-room sync groups -----------------------------------
|
|
//
|
|
// The JellyLMS plugin exposes a REST API under `/JellyLms` for grouping LMS
|
|
// players ("zones") into synchronized multi-room sync groups. Players are
|
|
// addressed by MAC address; JellyTau maps a Jellyfin session to a MAC by
|
|
// stripping the `lms-` prefix off the session's device id (see
|
|
// LmsDeviceDiscoveryService in the jellyLMS repo, which registers each player
|
|
// with deviceId = "lms-{MacAddress}").
|
|
|
|
/// List current LMS sync groups.
|
|
pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String> {
|
|
self.get("/JellyLms/SyncGroups").await
|
|
}
|
|
|
|
/// Fuse LMS zones: create a sync group with `master_mac` as the sync master
|
|
/// and `slave_macs` joining it. The master keeps playing; slaves follow.
|
|
pub async fn lms_create_sync_group(
|
|
&self,
|
|
master_mac: &str,
|
|
slave_macs: Vec<String>,
|
|
) -> Result<(), String> {
|
|
let payload = serde_json::json!({
|
|
"MasterMac": master_mac,
|
|
"SlaveMacs": slave_macs,
|
|
});
|
|
self.post("/JellyLms/SyncGroups", &payload).await
|
|
}
|
|
|
|
/// Remove a single LMS player from whatever sync group it's in.
|
|
pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
|
|
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac))
|
|
.await
|
|
}
|
|
|
|
/// Dissolve an entire LMS sync group, identified by its master's MAC.
|
|
pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
|
|
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac))
|
|
.await
|
|
}
|
|
|
|
/// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
|
|
async fn delete(&self, endpoint: &str) -> Result<(), String> {
|
|
let url = format!("{}{}", self.config.server_url, endpoint);
|
|
|
|
log::debug!("[JellyfinClient] DELETE {}", endpoint);
|
|
|
|
let response = self
|
|
.http_client
|
|
.delete(&url)
|
|
.header("X-Emby-Authorization", self.get_auth_header())
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Network request failed: {}", e))?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let error_text = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
|
return Err(format!(
|
|
"Jellyfin API error {}: {}",
|
|
status.as_u16(),
|
|
error_text
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
|
|
///
|
|
/// Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
|
|
/// follow it in lockstep.
|
|
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct LmsSyncGroup {
|
|
#[serde(alias = "MasterMac")]
|
|
pub master_mac: String,
|
|
#[serde(default, alias = "MasterName")]
|
|
pub master_name: String,
|
|
#[serde(default, alias = "SlaveMacs")]
|
|
pub slave_macs: Vec<String>,
|
|
#[serde(default, alias = "SlaveNames")]
|
|
pub slave_names: Vec<String>,
|
|
}
|
|
|
|
/// Default value for supports_remote_control when missing from API
|
|
/// We default to true to show all sessions. If a session explicitly doesn't
|
|
/// support remote control, the Jellyfin API will set this field to false.
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
/// Session information from Jellyfin
|
|
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionInfo {
|
|
#[serde(default)]
|
|
#[serde(alias = "Id")]
|
|
pub id: Option<String>,
|
|
#[serde(default)]
|
|
#[serde(alias = "UserId")]
|
|
pub user_id: Option<String>,
|
|
#[serde(default)]
|
|
#[serde(alias = "UserName")]
|
|
pub user_name: Option<String>,
|
|
#[serde(default)]
|
|
#[serde(alias = "Client")]
|
|
pub client: Option<String>,
|
|
#[serde(default)]
|
|
#[serde(alias = "DeviceName")]
|
|
pub device_name: Option<String>,
|
|
#[serde(default)]
|
|
#[serde(alias = "DeviceId")]
|
|
pub device_id: Option<String>,
|
|
#[serde(default)]
|
|
#[serde(alias = "ApplicationVersion")]
|
|
pub application_version: Option<String>,
|
|
#[serde(default)]
|
|
#[serde(alias = "IsActive")]
|
|
pub is_active: Option<bool>,
|
|
#[serde(default)]
|
|
#[serde(alias = "SupportsMediaControl")]
|
|
pub supports_media_control: Option<bool>,
|
|
#[serde(default = "default_true")]
|
|
#[serde(alias = "SupportsRemoteControl")]
|
|
pub supports_remote_control: bool,
|
|
#[serde(default)]
|
|
#[serde(alias = "NowPlayingItem")]
|
|
pub now_playing_item: Option<NowPlayingItem>,
|
|
#[serde(default)]
|
|
#[serde(alias = "PlayState")]
|
|
pub play_state: Option<PlayState>,
|
|
#[serde(default)]
|
|
#[serde(alias = "PlayableMediaTypes")]
|
|
pub playable_media_types: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
#[serde(alias = "SupportedCommands")]
|
|
pub supported_commands: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct NowPlayingItem {
|
|
#[serde(alias = "Id")]
|
|
pub id: Option<String>,
|
|
#[serde(alias = "Name")]
|
|
pub name: Option<String>,
|
|
#[serde(alias = "RunTimeTicks")]
|
|
pub run_time_ticks: Option<i64>,
|
|
#[serde(alias = "Album")]
|
|
pub album: Option<String>,
|
|
#[serde(alias = "AlbumId")]
|
|
pub album_id: Option<String>,
|
|
#[serde(alias = "AlbumArtist")]
|
|
pub album_artist: Option<String>,
|
|
#[serde(alias = "Artists")]
|
|
pub artists: Option<Vec<String>>,
|
|
#[serde(alias = "ImageTags")]
|
|
pub image_tags: Option<std::collections::HashMap<String, String>>,
|
|
#[serde(alias = "PrimaryImageTag")]
|
|
pub primary_image_tag: Option<String>,
|
|
#[serde(alias = "AlbumPrimaryImageTag")]
|
|
pub album_primary_image_tag: Option<String>,
|
|
#[serde(rename = "Type")]
|
|
pub item_type: Option<String>,
|
|
}
|
|
|
|
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PlayState {
|
|
#[serde(default)]
|
|
#[serde(alias = "PositionTicks")]
|
|
pub position_ticks: Option<i64>,
|
|
#[serde(default)]
|
|
#[serde(alias = "CanSeek")]
|
|
pub can_seek: Option<bool>,
|
|
#[serde(default)]
|
|
#[serde(alias = "IsPaused")]
|
|
pub is_paused: Option<bool>,
|
|
#[serde(default)]
|
|
#[serde(alias = "IsMuted")]
|
|
pub is_muted: Option<bool>,
|
|
#[serde(default)]
|
|
#[serde(alias = "VolumeLevel")]
|
|
pub volume_level: Option<i32>,
|
|
#[serde(default)]
|
|
#[serde(alias = "RepeatMode")]
|
|
pub repeat_mode: Option<String>,
|
|
#[serde(default)]
|
|
#[serde(alias = "ShuffleMode")]
|
|
pub shuffle_mode: Option<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_auth_header_format() {
|
|
let config = JellyfinConfig {
|
|
server_url: "http://localhost:8096".to_string(),
|
|
access_token: "test_token".to_string(),
|
|
device_id: "device456".to_string(),
|
|
};
|
|
|
|
let client = JellyfinClient::new(config).unwrap();
|
|
let header = client.get_auth_header();
|
|
|
|
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
|
|
assert!(header.contains("Token=\"test_token\""));
|
|
assert!(header.contains("DeviceId=\"device456\""));
|
|
}
|
|
}
|