First working POC

This commit is contained in:
2026-01-26 22:21:54 +01:00
commit cfddc1edea
255 changed files with 77606 additions and 0 deletions
+451
View File
@@ -0,0 +1,451 @@
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))
.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 query params, not JSON body!)
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
pub async fn session_seek(
&self,
session_id: String,
position_ticks: i64,
) -> Result<(), String> {
#[derive(serde::Serialize)]
#[serde(rename_all = "PascalCase")]
struct SeekRequest {
seek_position_ticks: i64,
}
let request = SeekRequest {
seek_position_ticks: position_ticks,
};
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
}
/// Set volume on a remote session
pub async fn session_set_volume(
&self,
session_id: String,
volume: i32,
) -> Result<(), String> {
let payload = serde_json::json!({
"Arguments": {
"Volume": volume.to_string()
}
});
log::info!("[JellyfinClient] Setting volume on session {} to {} with payload: {}",
session_id, volume, serde_json::to_string(&payload).unwrap_or_default());
self.post(
&format!("/Sessions/{}/Command/SetVolume", session_id),
&payload
).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.post(
&format!("/Sessions/{}/Command/ToggleMute", session_id),
&serde_json::json!({})
).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)))
}
}
/// 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(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
pub struct SessionInfo {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub user_name: Option<String>,
#[serde(default)]
pub client: Option<String>,
#[serde(default)]
pub device_name: Option<String>,
#[serde(default)]
pub device_id: Option<String>,
#[serde(default)]
pub application_version: Option<String>,
#[serde(default)]
pub is_active: Option<bool>,
#[serde(default)]
pub supports_media_control: Option<bool>,
#[serde(default = "default_true")]
pub supports_remote_control: bool,
#[serde(default)]
pub now_playing_item: Option<NowPlayingItem>,
#[serde(default)]
pub play_state: Option<PlayState>,
#[serde(default)]
pub playable_media_types: Option<Vec<String>>,
#[serde(default)]
pub supported_commands: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
pub struct NowPlayingItem {
pub id: Option<String>,
pub name: Option<String>,
pub run_time_ticks: Option<i64>,
pub album: Option<String>,
pub album_id: Option<String>,
pub album_artist: Option<String>,
pub artists: Option<Vec<String>>,
pub image_tags: Option<std::collections::HashMap<String, String>>,
pub primary_image_tag: Option<String>,
pub album_primary_image_tag: Option<String>,
#[serde(rename = "Type")]
pub item_type: Option<String>,
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
pub struct PlayState {
#[serde(default)]
pub position_ticks: Option<i64>,
#[serde(default)]
pub can_seek: Option<bool>,
#[serde(default)]
pub is_paused: Option<bool>,
#[serde(default)]
pub is_muted: Option<bool>,
#[serde(default)]
pub volume_level: Option<i32>,
#[serde(default)]
pub repeat_mode: Option<String>,
#[serde(default)]
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\""));
}
}