//! TRACES: UR-002, UR-007 | DR-013 | IR-010 use std::sync::Arc; use async_trait::async_trait; use log::{debug, error, info}; #[cfg(target_os = "android")] use log::warn; use serde::{Deserialize, Serialize}; use crate::connectivity::ConnectivityReporter; use crate::jellyfin::HttpClient; use super::{MediaRepository, types::*}; /// A single actor returned by the JRay plugin's "context at time t" endpoint. /// /// Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`. /// `jellyfin_id` (a Jellyfin Person item GUID) is preferred for navigation; /// the IMDb/TMDb ids are informational fallbacks. Unknown ids are `""`. #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] pub struct JRayActor { pub name: String, #[serde(default)] pub imdb_id: String, #[serde(default)] pub tmdb_id: String, #[serde(default)] pub jellyfin_id: String, } /// Envelope returned by the JRay `jray?t=` endpoint. Extra keys (future /// `locations`, `trivia`, …) are ignored so the client tolerates schema growth. #[derive(Debug, Clone, Deserialize)] struct JRayContext { #[serde(default)] actors: Vec, } /// Online repository - fetches data from Jellyfin server via HTTP pub struct OnlineRepository { http_client: Arc, server_url: String, user_id: String, access_token: String, /// Reports the outcome of every server request to the connectivity monitor. /// This is the source of truth for the offline/online banner. `None` in /// tests / contexts where connectivity tracking isn't wired up. connectivity: Option, } impl OnlineRepository { pub fn new( http_client: Arc, server_url: String, user_id: String, access_token: String, ) -> Self { Self { http_client, server_url, user_id, access_token, connectivity: None, } } /// Attach a connectivity reporter so server outcomes drive the reachability /// state observed by the UI. See `report_outcome`. pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self { self.connectivity = Some(reporter); self } /// Feed a request outcome into the connectivity monitor. /// /// Classification (matches docs/architecture/07-connectivity.md): /// - `Ok` / `Authentication` / `NotFound` / `Server` → the server answered, /// so it is reachable → `report_success` (instant recovery). /// - `Network` → connection-level failure → `report_network_failure` /// (subject to the time-window debounce before going offline). /// - `Database` → not a server signal → ignored. async fn report_outcome(&self, result: &Result) { let Some(reporter) = &self.connectivity else { return; }; match result { Ok(_) | Err(RepoError::Authentication { .. }) | Err(RepoError::NotFound { .. }) | Err(RepoError::Server { .. }) => { reporter.report_success().await; } Err(RepoError::Network { message }) => { reporter.report_network_failure(Some(message.clone())).await; } Err(RepoError::Database { .. }) | Err(RepoError::Offline) => { // Local-side errors (cache failure / already-offline) — not a // statement about the server's reachability, so ignore them. } } } /// Build authorization header fn auth_header(&self) -> String { HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device") } /// Download raw bytes from a URL using the shared authenticated HTTP client. /// Used by thumbnail cache to download images with proper auth and connection reuse. pub async fn download_bytes(&self, url: &str) -> Result, String> { let request = self.http_client.client.get(url) .header("X-Emby-Authorization", self.auth_header()) .build() .map_err(|e| format!("Failed to build request: {}", e))?; let response = self.http_client.request_with_retry(request).await .map_err(|e| format!("Download failed: {}", e))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); let body_preview = if body.len() > 200 { &body[..200] } else { &body }; return Err(format!("HTTP {} ({})", status, body_preview.trim())); } response.bytes().await .map(|b| b.to_vec()) .map_err(|e| format!("Failed to read bytes: {}", e)) } /// Query the JRay plugin for the actors on screen at time `t` (seconds) in /// the given item. Returns an empty list when the plugin isn't installed or /// has no truth data for the item (HTTP 404), so callers can treat "no JRay" /// and "nobody on screen" identically. Other failures propagate. pub async fn get_jray_actors(&self, item_id: &str, t: f64) -> Result, RepoError> { let endpoint = format!("/Plugins/JRay/Items/{}/jray?t={}", item_id, t); match self.get_json::(&endpoint).await { Ok(context) => Ok(context.actors), // No plugin / no truth data for this item — not an error to the user. Err(RepoError::NotFound { .. }) => Ok(Vec::new()), Err(e) => Err(e), } } /// Make authenticated GET request async fn get_json Deserialize<'de>>(&self, endpoint: &str) -> Result { // Fast-fail when connectivity is known-offline. Without this every request // still runs the full HTTP retry/backoff cycle (~7s) before giving up, // which stalls cache-miss paths and makes offline browsing feel janky. // The offline recovery probe (connectivity monitor) flips us back to // reachable the moment the server returns, so this never sticks. if let Some(reporter) = &self.connectivity { if !reporter.is_reachable().await { return Err(RepoError::Offline); } } let result = self.get_json_inner(endpoint).await; self.report_outcome(&result).await; result } async fn get_json_inner Deserialize<'de>>(&self, endpoint: &str) -> Result { let url = format!("{}{}", self.server_url, endpoint); let request = self.http_client.client.get(&url) .header("X-Emby-Authorization", self.auth_header()) .build() .map_err(|e| RepoError::Network { message: format!("Failed to build request: {}", e), })?; let response = self.http_client.request_with_retry(request).await .map_err(|e| RepoError::Network { message: e.to_string() })?; if !response.status().is_success() { let status = response.status(); if status.as_u16() == 401 || status.as_u16() == 403 { return Err(RepoError::Authentication { message: format!("HTTP {}", status), }); } else if status.as_u16() == 404 { return Err(RepoError::NotFound { message: "Resource not found".to_string(), }); } else { return Err(RepoError::Server { message: format!("HTTP {}", status), }); } } // Get the response text first for better error reporting let text = response.text().await.map_err(|e| RepoError::Server { message: format!("Failed to read response: {}", e), })?; // Try to deserialize and log the raw JSON on error serde_json::from_str(&text).map_err(|e| { error!("[OnlineRepo] Failed to deserialize {} response: {}", endpoint, e); error!("[OnlineRepo] Response body (first 1000 chars): {}", if text.len() > 1000 { &text[..1000] } else { &text }); RepoError::Server { message: format!("Failed to parse response: {}", e), } }) } /// Make authenticated POST request async fn post_json(&self, endpoint: &str, body: &T) -> Result<(), RepoError> { let result = self.post_json_inner(endpoint, body).await; self.report_outcome(&result).await; result } async fn post_json_inner(&self, endpoint: &str, body: &T) -> Result<(), RepoError> { let url = format!("{}{}", self.server_url, endpoint); let request = self.http_client.client.post(&url) .header("Content-Type", "application/json") .header("X-Emby-Authorization", self.auth_header()) .json(body) .build() .map_err(|e| RepoError::Network { message: format!("Failed to build request: {}", e), })?; let response = self.http_client.request_with_retry(request).await .map_err(|e| RepoError::Network { message: e.to_string() })?; if !response.status().is_success() { let status = response.status(); if status.as_u16() == 401 || status.as_u16() == 403 { return Err(RepoError::Authentication { message: format!("HTTP {}", status), }); } else { return Err(RepoError::Server { message: format!("HTTP {}", status), }); } } Ok(()) } /// Make authenticated POST request and return response async fn post_json_response Deserialize<'de>>( &self, endpoint: &str, body: &T, ) -> Result { let result = self.post_json_response_inner(endpoint, body).await; self.report_outcome(&result).await; result } async fn post_json_response_inner Deserialize<'de>>( &self, endpoint: &str, body: &T, ) -> Result { let url = format!("{}{}", self.server_url, endpoint); // Log request body for debugging if let Ok(json) = serde_json::to_string_pretty(body) { debug!("[HTTP] POST {}", endpoint); debug!("[HTTP] Request body:\n{}", json); } let request = self.http_client.client.post(&url) .header("Content-Type", "application/json") .header("X-Emby-Authorization", self.auth_header()) .json(body) .build() .map_err(|e| RepoError::Network { message: format!("Failed to build request: {}", e), })?; let response = self.http_client.request_with_retry(request).await .map_err(|e| RepoError::Network { message: e.to_string() })?; if !response.status().is_success() { let status = response.status(); // Capture response body for error details let error_body = response.text().await.unwrap_or_else(|_| "Failed to read error body".to_string()); error!("[HTTP] Error response ({}): {}", status, error_body); if status.as_u16() == 401 || status.as_u16() == 403 { return Err(RepoError::Authentication { message: format!("HTTP {}: {}", status, error_body), }); } else if status.as_u16() == 404 { return Err(RepoError::NotFound { message: format!("Resource not found: {}", error_body), }); } else { return Err(RepoError::Server { message: format!("HTTP {}: {}", status, error_body), }); } } response.json().await.map_err(|e| RepoError::Server { message: format!("Failed to parse response: {}", e), }) } /// Get a video stream URL for playback at an arbitrary position (resume, /// transcoded seeking, audio-track switching). /// /// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to /// h264/aac. HLS is used rather than a progressive `stream.mp4` because the /// HTML5 `