Duncan Tourolle 2d141e5bf4
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m21s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m54s
Fix for offline mode
2026-07-03 19:37:34 +02:00

2160 lines
80 KiB
Rust

//! 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<JRayActor>,
}
/// Online repository - fetches data from Jellyfin server via HTTP
pub struct OnlineRepository {
http_client: Arc<HttpClient>,
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<ConnectivityReporter>,
}
impl OnlineRepository {
pub fn new(
http_client: Arc<HttpClient>,
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<T>(&self, result: &Result<T, RepoError>) {
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<Vec<u8>, 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<Vec<JRayActor>, RepoError> {
let endpoint = format!("/Plugins/JRay/Items/{}/jray?t={}", item_id, t);
match self.get_json::<JRayContext>(&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<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
// 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<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
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<T: Serialize>(&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<T: Serialize>(&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<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
endpoint: &str,
body: &T,
) -> Result<R, RepoError> {
let result = self.post_json_response_inner(endpoint, body).await;
self.report_outcome(&result).await;
result
}
async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
endpoint: &str,
body: &T,
) -> Result<R, RepoError> {
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 `<video>` element (via HLS.js) starts playing within seconds and can
/// seek within the stream, whereas a progressive MP4 transcode of HEVC source
/// forces the server to transcode the whole file before playback can begin —
/// which manifests as playback never starting. `StartTimeTicks` makes the
/// server begin the transcode at the requested position.
pub async fn get_video_stream_url(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Convert seconds to ticks (10,000,000 ticks per second)
let start_time_ticks = start_time_seconds
.map(|seconds| (seconds * 10_000_000.0) as i64);
// Use provided audio stream index, or default to 0
let audio_index = audio_stream_index.unwrap_or(0).to_string();
// Build an HLS transcode URL. VideoCodec lists h264 first so the server
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
let mut params = vec![
("api_key", self.access_token.clone()),
("DeviceId", "jellytau-tauri".to_string()),
("VideoCodec", "h264".to_string()),
("AudioCodec", "aac".to_string()),
("AudioStreamIndex", audio_index),
("MaxStreamingBitrate", "20000000".to_string()),
("VideoBitrate", "18000000".to_string()),
("AudioBitrate", "384000".to_string()),
("TranscodingMaxAudioChannels", "2".to_string()),
("SegmentContainer", "ts".to_string()),
("TranscodingContainer", "ts".to_string()),
("TranscodingProtocol", "hls".to_string()),
];
if let Some(source_id) = media_source_id {
params.push(("MediaSourceId", source_id.to_string()));
}
if let Some(ticks) = start_time_ticks {
params.push(("StartTimeTicks", ticks.to_string()));
}
// Build query string (values are already safe, no encoding needed)
let query = params
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
let url = format!(
"{}/Videos/{}/master.m3u8?{}",
self.server_url, item_id, query
);
Ok(url)
}
}
// Jellyfin API response types (PascalCase from server)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct ItemsResponse {
items: Vec<JellyfinItem>,
total_record_count: usize,
}
/// Jellyfin playlist creation response
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct CreatePlaylistResponse {
id: String,
}
/// Jellyfin playlist items response — items include PlaylistItemId
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
#[allow(dead_code)]
struct PlaylistItemsResponse {
items: Vec<JellyfinPlaylistItem>,
total_record_count: usize,
}
/// A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinPlaylistItem {
playlist_item_id: String,
#[serde(flatten)]
item: JellyfinItem,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinItem {
id: String,
name: String,
#[serde(rename = "Type")]
item_type: String,
#[serde(default)]
is_folder: bool,
parent_id: Option<String>,
overview: Option<String>,
genres: Option<Vec<String>>,
production_year: Option<i32>,
premiere_date: Option<String>,
community_rating: Option<f64>,
official_rating: Option<String>,
run_time_ticks: Option<i64>,
image_tags: Option<ImageTags>,
backdrop_image_tags: Option<Vec<String>>,
parent_backdrop_image_tags: Option<Vec<String>>,
album_id: Option<String>,
album: Option<String>,
album_artist: Option<String>,
artists: Option<Vec<String>>,
artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
index_number: Option<i32>,
parent_index_number: Option<i32>,
series_id: Option<String>,
series_name: Option<String>,
season_id: Option<String>,
season_name: Option<String>,
media_streams: Option<Vec<JellyfinMediaStream>>,
media_sources: Option<Vec<JellyfinMediaSource>>,
people: Option<Vec<crate::repository::types::Person>>,
}
// ImageTags from Jellyfin API - can be a HashMap with various image type keys
// We use a wrapper to extract just the Primary tag we need
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ImageTags {
// Modern format: HashMap
Map(std::collections::HashMap<String, String>),
// Legacy/alternative format: structured
Structured {
#[serde(rename = "Primary")]
primary: Option<String>,
},
}
impl ImageTags {
fn primary(&self) -> Option<String> {
match self {
ImageTags::Map(map) => map.get("Primary").cloned(),
ImageTags::Structured { primary } => primary.clone(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "PascalCase")]
struct JellyfinMediaStream {
#[serde(rename = "Type")]
stream_type: String,
codec: Option<String>,
language: Option<String>,
display_title: Option<String>,
index: i32,
is_default: bool,
#[serde(default)]
is_forced: bool,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "PascalCase")]
struct JellyfinMediaSource {
id: String,
name: String,
container: Option<String>,
size: Option<i64>,
bitrate: Option<i32>,
supports_direct_play: bool,
supports_direct_stream: bool,
supports_transcoding: bool,
direct_stream_url: Option<String>,
}
impl JellyfinItem {
fn to_media_item(self, server_id: String) -> MediaItem {
// Extract image tags before consuming self
let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
let backdrop_tags = self.backdrop_image_tags;
MediaItem {
id: self.id,
name: self.name,
item_type: self.item_type,
is_folder: self.is_folder,
server_id,
parent_id: self.parent_id,
library_id: None, // Not provided by Jellyfin API directly
overview: self.overview,
genres: self.genres,
production_year: self.production_year,
premiere_date: self.premiere_date,
community_rating: self.community_rating,
official_rating: self.official_rating,
runtime_ticks: self.run_time_ticks,
primary_image_tag: primary_tag,
backdrop_image_tags: backdrop_tags,
parent_backdrop_image_tags: self.parent_backdrop_image_tags,
album_id: self.album_id,
album_name: self.album,
album_artist: self.album_artist,
artists: self.artists,
artist_items: self.artist_items,
index_number: self.index_number,
parent_index_number: self.parent_index_number,
series_id: self.series_id,
series_name: self.series_name,
season_id: self.season_id,
season_name: self.season_name,
user_data: None, // User data not included in basic item responses
media_streams: self.media_streams.map(|streams| {
streams
.into_iter()
.map(|s| crate::repository::types::MediaStream {
stream_type: s.stream_type,
codec: s.codec,
language: s.language,
display_title: s.display_title,
index: s.index,
is_default: s.is_default,
is_forced: s.is_forced,
})
.collect()
}),
media_sources: self.media_sources.map(|sources| {
sources
.into_iter()
.map(|s| crate::repository::types::MediaSource {
id: s.id,
name: s.name,
container: s.container,
size: s.size,
bitrate: s.bitrate,
supports_direct_play: s.supports_direct_play,
supports_direct_stream: s.supports_direct_stream,
supports_transcoding: s.supports_transcoding,
direct_stream_url: s.direct_stream_url,
})
.collect()
}),
people: self.people,
}
}
}
#[async_trait]
impl MediaRepository for OnlineRepository {
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct LibrariesResponse {
items: Vec<JellyfinLibrary>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinLibrary {
id: String,
name: String,
collection_type: Option<String>,
image_tags: Option<ImageTags>,
}
let endpoint = format!("/Users/{}/Views", self.user_id);
let response: LibrariesResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|lib| Library {
id: lib.id,
name: lib.name,
collection_type: lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
image_tag: lib.image_tags.and_then(|tags| tags.primary()),
})
.collect())
}
async fn get_items(
&self,
parent_id: &str,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let mut endpoint = format!("/Users/{}/Items?ParentId={}", self.user_id, parent_id);
if let Some(opts) = options {
if let Some(limit) = opts.limit {
endpoint.push_str(&format!("&Limit={}", limit));
}
if let Some(start_index) = opts.start_index {
endpoint.push_str(&format!("&StartIndex={}", start_index));
}
if let Some(types) = opts.include_item_types {
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
}
if let Some(sort_by) = opts.sort_by {
endpoint.push_str(&format!("&SortBy={}", sort_by));
}
if let Some(sort_order) = opts.sort_order {
endpoint.push_str(&format!("&SortOrder={}", sort_order));
}
if let Some(recursive) = opts.recursive {
endpoint.push_str(&format!("&Recursive={}", recursive));
}
if let Some(genres) = opts.genres {
if !genres.is_empty() {
// Genre names may contain spaces/ampersands, so percent-encode each.
let encoded: Vec<String> = genres
.iter()
.map(|g| urlencoding::encode(g).into_owned())
.collect();
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
}
}
}
// Request image fields for list views (People only needed in get_item
// detail view). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts.
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
items: response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect(),
total_record_count: response.total_record_count,
})
}
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate", self.user_id, item_id);
let item: JellyfinItem = self.get_json(&endpoint).await?;
let media_item = item.to_media_item(self.user_id.clone());
Ok(media_item)
}
async fn get_latest_items(
&self,
parent_id: &str,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let endpoint = format!(
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, parent_id, limit_str
);
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
Ok(items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect())
}
async fn get_resume_items(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let mut endpoint = format!(
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_str
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect())
}
async fn get_next_up_episodes(
&self,
series_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let mut endpoint = format!("/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, limit_str);
if let Some(sid) = series_id {
endpoint.push_str(&format!("&SeriesId={}", sid));
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect())
}
async fn get_recently_played_audio(
&self,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
// Fetch more items to account for grouping reducing the count
let fetch_limit = limit_val * 3;
let endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, fetch_limit
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
let items: Vec<MediaItem> = response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect();
debug!("[get_recently_played_audio] Fetched {} items", items.len());
for item in &items {
debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
item.name, item.item_type, item.album_id, item.album_name);
}
// Group by album - create pseudo-album entries for tracks with same albumId
use std::collections::BTreeMap;
let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
let mut ungrouped = Vec::new();
for item in items {
// Use album_id if available, fall back to album_name for grouping
let group_key = item.album_id.clone()
.or_else(|| item.album_name.clone());
if let Some(key) = group_key {
debug!("[get_recently_played_audio] Grouping item '{}' into album '{}'", item.name, key);
album_map.entry(key).or_insert_with(Vec::new).push(item);
} else {
debug!("[get_recently_played_audio] No album_id or album_name for item: '{}'", item.name);
ungrouped.push(item);
}
}
// Create album entries from grouped tracks
let mut result: Vec<MediaItem> = album_map
.into_iter()
.map(|(album_id, tracks)| {
let first_track = &tracks[0];
let most_recent = tracks.iter()
.max_by(|a, b| {
let date_a = a.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
let date_b = b.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
date_b.cmp(date_a)
})
.unwrap_or(first_track);
MediaItem {
id: album_id,
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
item_type: "MusicAlbum".to_string(),
is_folder: true,
server_id: first_track.server_id.clone(),
parent_id: None,
library_id: None,
overview: None,
genres: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
runtime_ticks: None,
primary_image_tag: first_track.primary_image_tag.clone(),
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
album_name: None,
album_artist: None,
artists: first_track.artists.clone(),
artist_items: first_track.artist_items.clone(),
index_number: None,
parent_index_number: None,
series_id: None,
series_name: None,
season_id: None,
season_name: None,
user_data: most_recent.user_data.clone(),
media_streams: None,
media_sources: None,
people: None,
}
})
.collect();
// Append ungrouped tracks
result.extend(ungrouped);
// Return only the requested limit
let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
debug!("[get_recently_played_audio] Returning {} items after grouping", final_result.len());
for item in &final_result {
debug!("[get_recently_played_audio] Return: name={}, type={}", item.name, item.item_type);
}
Ok(final_result)
}
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
// Ask Jellyfin for played albums sorted by least-recently played first.
// Filters=IsPlayed keeps only albums the user has actually listened to,
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
let mut endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_val
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect())
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let endpoint = format!(
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_str
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect())
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
// Ask Jellyfin to scope counts to albums and include them, so the
// frontend can rank genres by popularity without probing each one.
let mut endpoint = format!(
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
self.user_id
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct GenresResponse {
items: Vec<JellyfinGenre>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinGenre {
id: String,
name: String,
// Which count field Jellyfin populates for a genre under
// Fields=ItemCounts varies by server/version: scoped queries may
// fill AlbumCount, others only ChildCount. Read whichever is
// present so ranking still works. Absent on servers that ignore
// Fields=ItemCounts entirely, so all stay optional.
album_count: Option<u32>,
child_count: Option<u32>,
}
let response: GenresResponse = self.get_json(&endpoint).await?;
let genres: Vec<Genre> = response
.items
.into_iter()
.map(|g| Genre {
id: g.id,
name: g.name,
album_count: g.album_count.or(g.child_count),
})
.collect();
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
// TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
// see whether the server populates any count field. Remove once known.
log::warn!(
"get_genres: {} genres, {} carry counts. sample: {:?}",
genres.len(),
with_counts,
genres
.iter()
.take(8)
.map(|g| (g.name.as_str(), g.album_count))
.collect::<Vec<_>>()
);
Ok(genres)
}
async fn search(
&self,
query: &str,
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
// SearchTerm is arbitrary user input and must be percent-encoded so that
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
// search like "Star Wars" would otherwise produce a malformed URL).
let mut endpoint = format!(
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
self.user_id,
urlencoding::encode(query),
limit
);
if let Some(opts) = options {
if let Some(types) = opts.include_item_types {
let encoded_types = types
.iter()
.map(|t| urlencoding::encode(t).into_owned())
.collect::<Vec<_>>()
.join(",");
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
}
}
// Request image fields for list views (plus Genres so cached items
// carry genres for offline genre lists/counts).
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
items: response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect(),
total_record_count: response.total_record_count,
})
}
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
struct PlaybackInfoRequest {
user_id: String,
audio_stream_index: i32,
#[serde(skip_serializing_if = "Option::is_none")]
subtitle_stream_index: Option<i32>,
start_time_ticks: i64,
is_playback: bool,
auto_open_live_stream: bool,
max_streaming_bitrate: i64,
#[serde(skip_serializing_if = "Option::is_none")]
device_profile: Option<DeviceProfile>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
struct DeviceProfile {
name: String,
max_streaming_bitrate: i64,
max_static_bitrate: i64,
direct_play_profiles: Vec<DirectPlayProfile>,
transcoding_profiles: Vec<TranscodingProfile>,
subtitle_profiles: Vec<SubtitleProfile>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
struct DirectPlayProfile {
#[serde(rename = "Type")]
profile_type: String,
container: String,
#[serde(skip_serializing_if = "Option::is_none")]
video_codec: Option<String>,
audio_codec: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
struct TranscodingProfile {
#[serde(rename = "Type")]
profile_type: String,
context: String,
protocol: String,
container: String,
#[serde(skip_serializing_if = "Option::is_none")]
video_codec: Option<String>,
audio_codec: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
struct SubtitleProfile {
format: String,
method: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct PlaybackInfoResponse {
media_sources: Vec<MediaSource>,
play_session_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct MediaSource {
id: String,
supports_direct_play: bool,
supports_transcoding: bool,
transcoding_url: Option<String>,
#[serde(default)]
media_streams: Vec<MediaStream>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct MediaStream {
#[serde(rename = "Type")]
stream_type: String,
#[serde(default)]
index: i32,
#[serde(default)]
codec: Option<String>,
}
// Get detected codecs from Android MediaCodecList or use platform defaults
#[cfg(target_os = "android")]
let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
.unwrap_or_else(|| {
warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
("h264,hevc".to_string(), "aac,mp3".to_string())
});
// Linux desktop plays video through the WebKitGTK HTML5 <video> element,
// which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
// WebView can decode so Jellyfin transcodes anything else to h264 HLS.
// (Audio-only files still direct-play via MPV, but the PlaybackInfo
// profile is shared, so we keep the broadly-supported audio codecs.)
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
let (video_codecs, audio_codecs) = (
"h264".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(),
);
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
let (video_codecs, audio_codecs) = (
"h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(),
);
info!("[DeviceProfile] Using video codecs: {}", video_codecs);
info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
// Create device profile with detected hardware capabilities
let device_profile = DeviceProfile {
name: "JellyTau Native Player".to_string(),
max_streaming_bitrate: 999_999_999,
max_static_bitrate: 999_999_999,
direct_play_profiles: vec![
DirectPlayProfile {
profile_type: "Video".to_string(),
container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
video_codec: Some(video_codecs.clone()),
audio_codec: audio_codecs.clone(),
},
DirectPlayProfile {
profile_type: "Audio".to_string(),
container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
video_codec: None,
audio_codec: audio_codecs.clone(),
},
],
transcoding_profiles: vec![
TranscodingProfile {
profile_type: "Video".to_string(),
context: "Streaming".to_string(),
protocol: "hls".to_string(),
container: "ts".to_string(),
video_codec: Some("h264,hevc".to_string()),
audio_codec: "aac,mp3".to_string(),
},
TranscodingProfile {
profile_type: "Audio".to_string(),
context: "Streaming".to_string(),
protocol: "http".to_string(),
container: "mp3".to_string(),
video_codec: None,
audio_codec: "mp3".to_string(),
},
],
subtitle_profiles: vec![
SubtitleProfile {
format: "srt".to_string(),
method: "External".to_string(),
},
SubtitleProfile {
format: "vtt".to_string(),
method: "External".to_string(),
},
],
};
// POST to PlaybackInfo with device profile containing detected codecs
let request_body = PlaybackInfoRequest {
user_id: self.user_id.clone(),
audio_stream_index: 0, // Request first audio stream
subtitle_stream_index: None,
start_time_ticks: 0,
is_playback: true,
auto_open_live_stream: true,
max_streaming_bitrate: 20_000_000, // 20 Mbps
device_profile: Some(device_profile), // Now sending profile with detected codecs
};
let response: PlaybackInfoResponse = self.post_json_response(&endpoint, &request_body).await?;
let source = response.media_sources.first().ok_or(RepoError::NotFound {
message: "No media sources available".to_string(),
})?;
// Log available media streams for debugging
info!("PlaybackInfo MediaSource has {} streams", source.media_streams.len());
for stream in &source.media_streams {
info!(" Stream type={}, index={}, codec={:?}",
stream.stream_type, stream.index, stream.codec);
}
// Use TranscodingUrl from response if available (Streamyfin pattern)
let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
format!("{}{}", self.server_url, transcoding_url)
} else {
// Fall back to direct stream URL
format!(
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&audioStreamIndex=0&userId={}",
self.server_url,
item_id,
source.id,
self.access_token,
self.user_id
)
};
info!("Final stream URL: {}", stream_url);
Ok(PlaybackInfo {
media_source_id: source.id.clone(),
play_session_id: response.play_session_id,
stream_url,
direct_play: source.supports_direct_play,
needs_transcoding: !source.supports_direct_play && source.supports_transcoding,
})
}
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
// Construct direct audio stream URL
let url = format!(
"{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
self.server_url, item_id, self.user_id, self.access_token
);
Ok(url)
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
// type "TvChannel" — playable via open_live_stream.
let endpoint = format!(
"/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
self.user_id
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.server_url.clone()))
.collect())
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
// Root list of plugin "Channels". Drill-down into a channel folder reuses
// get_items(channel_id, ...).
let endpoint = format!("/Channels?UserId={}", self.user_id);
let response: ItemsResponse = self.get_json(&endpoint).await?;
let total = response.total_record_count;
let items = response
.items
.into_iter()
.map(|item| item.to_media_item(self.server_url.clone()))
.collect();
Ok(SearchResult {
items,
total_record_count: total,
})
}
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
// Live channels require a PlaybackInfo call with AutoOpenLiveStream so the
// server opens the live stream and returns a ready-to-play transcoding URL.
// We send a minimal request; the server applies its own defaults for live.
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
struct OpenLiveStreamRequest {
user_id: String,
#[serde(rename = "AutoOpenLiveStream")]
auto_open_live_stream: bool,
is_playback: bool,
max_streaming_bitrate: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct OpenLiveStreamResponse {
#[serde(default)]
media_sources: Vec<LiveMediaSource>,
play_session_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct LiveMediaSource {
id: String,
transcoding_url: Option<String>,
live_stream_id: Option<String>,
}
let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
let request = OpenLiveStreamRequest {
user_id: self.user_id.clone(),
auto_open_live_stream: true,
is_playback: true,
max_streaming_bitrate: 20_000_000,
};
let response: OpenLiveStreamResponse =
self.post_json_response(&endpoint, &request).await?;
let source = response.media_sources.into_iter().next().ok_or(RepoError::NotFound {
message: "No live media source returned".to_string(),
})?;
// The transcoding URL is server-relative; make it absolute. If the server
// did not provide one (rare for live), fall back to the HLS master endpoint.
let stream_url = match source.transcoding_url {
Some(url) => format!("{}{}", self.server_url, url),
None => format!(
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts",
self.server_url,
item_id,
self.access_token,
source.id,
source.live_stream_id.clone().unwrap_or_default(),
),
};
Ok(LiveStreamInfo {
stream_url,
play_session_id: response.play_session_id,
live_stream_id: source.live_stream_id,
media_source_id: Some(source.id),
})
}
async fn report_playback_start(
&self,
item_id: &str,
position_ticks: i64,
) -> Result<(), RepoError> {
#[derive(Serialize)]
#[serde(rename_all = "PascalCase")]
struct PlaybackStartRequest {
item_id: String,
position_ticks: i64,
play_command: String,
is_paused: bool,
}
let request = PlaybackStartRequest {
item_id: item_id.to_string(),
position_ticks,
play_command: "PlayNow".to_string(),
is_paused: false,
};
self.post_json("/Sessions/Playing", &request).await
}
async fn report_playback_progress(
&self,
item_id: &str,
position_ticks: i64,
) -> Result<(), RepoError> {
#[derive(Serialize)]
#[serde(rename_all = "PascalCase")]
struct PlaybackProgressRequest {
item_id: String,
position_ticks: i64,
is_paused: bool,
}
let request = PlaybackProgressRequest {
item_id: item_id.to_string(),
position_ticks,
is_paused: false,
};
self.post_json("/Sessions/Playing/Progress", &request).await
}
async fn report_playback_stopped(
&self,
item_id: &str,
position_ticks: i64,
) -> Result<(), RepoError> {
#[derive(Serialize)]
#[serde(rename_all = "PascalCase")]
struct PlaybackStoppedRequest {
item_id: String,
position_ticks: i64,
}
let request = PlaybackStoppedRequest {
item_id: item_id.to_string(),
position_ticks,
};
self.post_json("/Sessions/Playing/Stopped", &request).await
}
fn get_image_url(
&self,
item_id: &str,
image_type: ImageType,
options: Option<ImageOptions>,
) -> String {
let mut url = format!(
"{}/Items/{}/Images/{}",
self.server_url,
item_id,
image_type.as_str()
);
// Authentication is handled by X-Emby-Authorization header in download_bytes()
// Do NOT include api_key here — some Jellyfin servers reject requests when
// api_key is present but the token doesn't match the expected format.
let mut params: Vec<String> = Vec::new();
if let Some(opts) = options {
if let Some(width) = opts.max_width {
params.push(format!("maxWidth={}", width));
}
if let Some(height) = opts.max_height {
params.push(format!("maxHeight={}", height));
}
if let Some(quality) = opts.quality {
params.push(format!("quality={}", quality));
}
if let Some(tag) = opts.tag {
params.push(format!("tag={}", tag));
}
}
if !params.is_empty() {
url.push('?');
url.push_str(&params.join("&"));
}
url
}
fn get_subtitle_url(
&self,
item_id: &str,
media_source_id: &str,
stream_index: i32,
format: &str,
) -> String {
format!(
"{}/Videos/{}/{}/Subtitles/{}/{}",
self.server_url,
item_id,
media_source_id,
stream_index,
format
)
}
fn get_video_download_url(
&self,
item_id: &str,
quality: &str,
media_source_id: Option<&str>,
) -> String {
// NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
// available (returns 404 on many server configs), which silently broke
// every movie/TV download. Use the progressive `stream.mp4` endpoint
// instead — it is always present and supports HTTP Range, which the
// download worker relies on for resume.
let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
let mut params = vec![format!("api_key={}", self.access_token)];
// Map the frontend quality preset to concrete transcode params. For
// "original" we request a direct static copy (no transcode) which is
// byte-range resumable; other presets ask the server to transcode.
match quality {
"high" => {
params.push("videoBitrate=8000000".to_string());
params.push("maxHeight=1080".to_string());
params.push("audioBitrate=384000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
}
"medium" => {
params.push("videoBitrate=4000000".to_string());
params.push("maxHeight=720".to_string());
params.push("audioBitrate=256000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
}
"low" => {
params.push("videoBitrate=1500000".to_string());
params.push("maxHeight=480".to_string());
params.push("audioBitrate=128000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
}
// "original" (and any unknown value) → direct, resumable copy.
_ => {
params.push("Static=true".to_string());
}
}
// Add media source ID if provided
if let Some(source_id) = media_source_id {
params.push(format!("mediaSourceId={}", source_id));
}
url.push('?');
url.push_str(&params.join("&"));
url
}
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
self.post_json(&endpoint, &serde_json::json!({})).await
}
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
let request = self.http_client.client.delete(&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() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
}
Ok(())
}
.await;
self.report_outcome(&result).await;
result
}
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
let item: JellyfinItem = self.get_json(&endpoint).await?;
Ok(item.to_media_item(self.user_id.clone()))
}
async fn get_items_by_person(
&self,
person_id: &str,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
let mut endpoint = format!(
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, person_id, limit
);
// Add item type filtering if specified in options
if let Some(ref opts) = options {
if let Some(ref include_types) = opts.include_item_types {
if !include_types.is_empty() {
let types_param = include_types.join(",");
endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
}
}
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
items: response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect(),
total_record_count: response.total_record_count,
})
}
async fn get_similar_items(
&self,
item_id: &str,
limit: Option<usize>,
) -> Result<SearchResult, RepoError> {
let limit_str = limit.unwrap_or(20);
// Try the /Similar endpoint which works for most items
let endpoint = format!(
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
item_id, self.user_id, limit_str
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
items: response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect(),
total_record_count: response.total_record_count,
})
}
// ===== Playlist Methods =====
async fn create_playlist(
&self,
name: &str,
item_ids: &[String],
) -> Result<PlaylistCreatedResult, RepoError> {
info!("[OnlineRepo] Creating playlist '{}' with {} items", name, item_ids.len());
let body = serde_json::json!({
"Name": name,
"Ids": item_ids,
"MediaType": "Audio",
"UserId": self.user_id,
});
let response: CreatePlaylistResponse =
self.post_json_response("/Playlists", &body).await?;
Ok(PlaylistCreatedResult { id: response.id })
}
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
let endpoint = format!("/Items/{}", playlist_id);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&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() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
}
Ok(())
}
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
info!("[OnlineRepo] Renaming playlist {} to '{}'", playlist_id, name);
let endpoint = format!("/Items/{}", playlist_id);
self.post_json(&endpoint, &serde_json::json!({ "Name": name })).await
}
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError> {
let endpoint = format!(
"/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
playlist_id, self.user_id
);
let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
debug!(
"[OnlineRepo] Got {} playlist items for {}",
response.items.len(),
playlist_id
);
Ok(response
.items
.into_iter()
.map(|pi| PlaylistEntry {
playlist_item_id: pi.playlist_item_id,
item: pi.item.to_media_item(self.user_id.clone()),
})
.collect())
}
async fn add_to_playlist(
&self,
playlist_id: &str,
item_ids: &[String],
) -> Result<(), RepoError> {
info!(
"[OnlineRepo] Adding {} items to playlist {}",
item_ids.len(),
playlist_id
);
let ids_param = item_ids.join(",");
let endpoint = format!("/Playlists/{}/Items?Ids={}", playlist_id, ids_param);
self.post_json(&endpoint, &serde_json::json!({})).await
}
async fn remove_from_playlist(
&self,
playlist_id: &str,
entry_ids: &[String],
) -> Result<(), RepoError> {
info!(
"[OnlineRepo] Removing {} entries from playlist {}",
entry_ids.len(),
playlist_id
);
let ids_param = entry_ids.join(",");
let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&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() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
}
Ok(())
}
async fn move_playlist_item(
&self,
playlist_id: &str,
item_id: &str,
new_index: u32,
) -> Result<(), RepoError> {
info!(
"[OnlineRepo] Moving item {} in playlist {} to index {}",
item_id, playlist_id, new_index
);
let endpoint = format!(
"/Playlists/{}/Items/{}/Move/{}",
playlist_id, item_id, new_index
);
self.post_json(&endpoint, &serde_json::json!({})).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn create_test_repository() -> OnlineRepository {
let http_config = crate::jellyfin::HttpConfig::default();
let http_client = Arc::new(
HttpClient::new(http_config).expect("Failed to create HTTP client for test")
);
OnlineRepository::new(
http_client,
"https://test.server.com".to_string(),
"test-user-id".to_string(),
"test-access-token".to_string(),
)
}
/// Build a repository wired to a real ConnectivityReporter so we can assert
/// how `report_outcome` classifies each `RepoError` into reachability.
/// (No app handle → event emission is a harmless no-op.)
fn create_test_repository_with_connectivity(
) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
.expect("Failed to create HTTP client for monitor");
let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
let reporter = monitor.reporter();
let repo = create_test_repository().with_connectivity(reporter.clone());
(repo, reporter)
}
/// `report_outcome` is the seam between repository traffic and the
/// connectivity monitor. Verify each `RepoError` variant routes correctly:
/// - the server answering at all (Ok / 401 / 404 / 5xx) ⇒ reachable
/// - a network-level failure ⇒ marked unreachable (debounce reduced for test)
/// - local-side errors (Database / Offline) ⇒ no effect on reachability
///
/// @req-test: UR-002 - Access media when online or offline
/// @req-test: DR-013 - Repository pattern for online/offline data access
#[tokio::test]
async fn test_report_outcome_classifies_server_answered_as_reachable() {
let (repo, reporter) = create_test_repository_with_connectivity();
// Drive offline first so we can observe "recover to reachable".
for err in [
RepoError::Authentication { message: "401".into() },
RepoError::NotFound { message: "404".into() },
RepoError::Server { message: "500".into() },
] {
reporter.mark_unreachable_for_test().await;
assert!(!reporter.is_reachable().await, "precondition: offline");
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
reporter.is_reachable().await,
"a server that answers should be reported reachable"
);
}
// Ok should also report reachable.
reporter.mark_unreachable_for_test().await;
let ok: Result<(), RepoError> = Ok(());
repo.report_outcome(&ok).await;
assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
}
/// Local-side errors must NOT flip reachability — they say nothing about the
/// server.
#[tokio::test]
async fn test_report_outcome_ignores_local_errors() {
let (repo, reporter) = create_test_repository_with_connectivity();
// Force offline, then a Database/Offline error must leave it offline
// (not falsely report reachable).
reporter.mark_unreachable_for_test().await;
for err in [RepoError::Database { message: "cache".into() }, RepoError::Offline] {
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
!reporter.is_reachable().await,
"local-side error must not change reachability"
);
}
}
/// When connectivity is known-offline, `get_json` must fast-fail with
/// `RepoError::Offline` instead of running the full HTTP retry cycle (~7s).
/// This is what keeps offline browsing snappy. `test.server.com` is
/// unroutable, so if the guard were absent this would hang on retries; the
/// assertion returning promptly with `Offline` proves the short-circuit.
#[tokio::test]
async fn test_get_json_fast_fails_when_offline() {
let (repo, reporter) = create_test_repository_with_connectivity();
reporter.mark_unreachable_for_test().await;
assert!(!reporter.is_reachable().await, "precondition: offline");
let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
assert!(
matches!(result, Err(RepoError::Offline)),
"known-offline get_json should return Offline immediately, got {:?}",
result
);
}
/// A network error routes through the debounced path. A single failure stays
/// online (debounce window not yet elapsed).
#[tokio::test]
async fn test_report_outcome_network_error_is_debounced() {
let (repo, reporter) = create_test_repository_with_connectivity();
assert!(reporter.is_reachable().await, "starts online");
let result: Result<(), RepoError> = Err(RepoError::Network { message: "timeout".into() });
repo.report_outcome(&result).await;
assert!(
reporter.is_reachable().await,
"a single network failure stays online (debounced)"
);
}
#[tokio::test]
async fn test_get_audio_stream_url_formats_correctly() {
let repo = create_test_repository();
let item_id = "test-track-123";
let result = repo.get_audio_stream_url(item_id).await;
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(
url,
"https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
);
}
#[tokio::test]
async fn test_get_video_stream_url_returns_hls_with_position() {
// Transcoded video resume/seek must produce an HLS master playlist with
// StartTimeTicks, not a progressive stream.mp4 (which never starts playing
// for HEVC sources). See get_video_stream_url docs.
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", Some("source-1"), Some(193.0), Some(1))
.await
.unwrap();
assert!(
url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
"expected HLS master playlist, got: {url}"
);
assert!(url.contains("VideoCodec=h264"));
assert!(url.contains("MediaSourceId=source-1"));
assert!(url.contains("AudioStreamIndex=1"));
// 193.0 seconds * 10_000_000 ticks/sec
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
assert!(!url.contains("stream.mp4"));
}
#[tokio::test]
async fn test_get_video_stream_url_omits_position_when_absent() {
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None, None)
.await
.unwrap();
assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Defaults to first audio stream
assert!(url.contains("AudioStreamIndex=0"));
}
#[tokio::test]
async fn test_get_audio_stream_url_with_special_characters() {
let repo = create_test_repository();
let item_id = "track-with-special-chars-!@#";
let result = repo.get_audio_stream_url(item_id).await;
assert!(result.is_ok());
let url = result.unwrap();
assert!(url.contains("track-with-special-chars-!@#"));
assert!(url.starts_with("https://test.server.com/Audio/"));
}
#[test]
fn test_image_tags_deserialize_hashmap_format() {
// Test modern HashMap format with Primary tag
let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
let result: Result<ImageTags, _> = serde_json::from_str(json);
assert!(result.is_ok());
let tags = result.unwrap();
assert_eq!(tags.primary(), Some("abc123".to_string()));
}
#[test]
fn test_image_tags_deserialize_structured_format() {
// Test legacy structured format with Primary field
let json = r#"{"Primary":"xyz789"}"#;
let result: Result<ImageTags, _> = serde_json::from_str(json);
assert!(result.is_ok());
let tags = result.unwrap();
assert_eq!(tags.primary(), Some("xyz789".to_string()));
}
#[test]
fn test_image_tags_deserialize_missing_primary() {
// Test HashMap without Primary tag
let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
let result: Result<ImageTags, _> = serde_json::from_str(json);
assert!(result.is_ok());
let tags = result.unwrap();
assert_eq!(tags.primary(), None);
}
#[test]
fn test_image_tags_deserialize_empty_map() {
// Test empty HashMap
let json = r#"{}"#;
let result: Result<ImageTags, _> = serde_json::from_str(json);
assert!(result.is_ok());
let tags = result.unwrap();
assert_eq!(tags.primary(), None);
}
// ===== Video download URL (real impl) =====
//
// These exercise the PRODUCTION `OnlineRepository::get_video_download_url`,
// not a mock. A prior mock in online_integration_test.rs used the correct
// `stream.mp4` endpoint while the real impl shipped `/Videos/{id}/download`,
// which returns 404 on real servers and silently broke every movie/TV
// download. Assert the real builder targets the resumable stream endpoint.
//
// @req-test: DR-013 - Repository pattern for online/offline data access
#[test]
fn test_video_download_url_uses_stream_not_download_endpoint() {
let repo = create_test_repository();
let url = repo.get_video_download_url("item123", "original", None);
// Must NOT use the /download endpoint (404 on real servers).
assert!(
!url.contains("/download"),
"download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
);
// Must use the progressive, range-resumable stream endpoint.
assert!(
url.contains("/Videos/item123/stream.mp4"),
"download URL must target /Videos/{{id}}/stream.mp4: {url}"
);
assert!(url.contains("api_key=test-access-token"), "url: {url}");
}
#[test]
fn test_video_download_url_original_is_static_direct_copy() {
let repo = create_test_repository();
let url = repo.get_video_download_url("item123", "original", None);
// "original" must request a direct static copy (byte-range resumable),
// with no transcode params.
assert!(url.contains("Static=true"), "url: {url}");
assert!(!url.contains("videoBitrate"), "original must not transcode: {url}");
assert!(!url.contains("maxHeight"), "original must not transcode: {url}");
}
#[test]
fn test_video_download_url_quality_presets_transcode() {
let repo = create_test_repository();
for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
let url = repo.get_video_download_url("item123", quality, None);
assert!(
url.contains("/Videos/item123/stream.mp4"),
"{quality} must use stream.mp4: {url}"
);
assert!(url.contains("videoBitrate="), "{quality} must set bitrate: {url}");
assert!(
url.contains(&format!("maxHeight={height}")),
"{quality} must cap height at {height}: {url}"
);
assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
// Transcoded presets must not also ask for a static copy.
assert!(!url.contains("Static=true"), "{quality} must not be Static: {url}");
}
}
#[test]
fn test_video_download_url_passes_media_source_id() {
let repo = create_test_repository();
let url = repo.get_video_download_url("item123", "original", Some("src-42"));
assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
}
#[test]
fn test_jellyfin_item_deserialize_with_image_tags() {
// Test full JellyfinItem deserialization with ImageTags
let json = r#"{
"Id": "album123",
"Name": "Test Album",
"Type": "MusicAlbum",
"ImageTags": {"Primary": "tag123"},
"ArtistItems": [
{"Id": "artist1", "Name": "Artist One"},
{"Id": "artist2", "Name": "Artist Two"}
]
}"#;
let result: Result<JellyfinItem, _> = serde_json::from_str(json);
assert!(result.is_ok());
let item = result.unwrap();
assert_eq!(item.id, "album123");
assert_eq!(item.name, "Test Album");
assert_eq!(item.item_type, "MusicAlbum");
assert!(item.image_tags.is_some());
assert_eq!(item.image_tags.unwrap().primary(), Some("tag123".to_string()));
}
#[test]
fn test_jellyfin_item_deserialize_with_artist_items() {
// Test that ArtistItems with PascalCase fields deserialize correctly
let json = r#"{
"Id": "track123",
"Name": "Test Track",
"Type": "Audio",
"ArtistItems": [
{"Id": "artist1", "Name": "Bob Dylan"},
{"Id": "artist2", "Name": "Johnny Cash"}
]
}"#;
let result: Result<JellyfinItem, _> = serde_json::from_str(json);
assert!(result.is_ok());
let item = result.unwrap();
let artist_items = item.artist_items.expect("Expected artist items");
assert_eq!(artist_items.len(), 2);
assert_eq!(artist_items[0].id, "artist1");
assert_eq!(artist_items[0].name, "Bob Dylan");
assert_eq!(artist_items[1].id, "artist2");
assert_eq!(artist_items[1].name, "Johnny Cash");
}
#[test]
fn test_jellyfin_item_to_media_item_conversion() {
// Test conversion from JellyfinItem to MediaItem preserves image tags
let json = r#"{
"Id": "album456",
"Name": "Love and Theft",
"Type": "MusicAlbum",
"ImageTags": {"Primary": "7ebab4f6a80cd09d"},
"Artists": ["Bob Dylan"],
"ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
"RunTimeTicks": 33900137190
}"#;
let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
let media_item = jellyfin_item.to_media_item("test-server-id".to_string());
assert_eq!(media_item.id, "album456");
assert_eq!(media_item.name, "Love and Theft");
assert_eq!(media_item.item_type, "MusicAlbum");
assert_eq!(media_item.primary_image_tag, Some("7ebab4f6a80cd09d".to_string()));
assert_eq!(media_item.server_id, "test-server-id");
}
#[test]
fn test_items_response_deserialize() {
// Test full ItemsResponse with multiple items
let json = r#"{
"Items": [
{
"Id": "item1",
"Name": "Item One",
"Type": "MusicAlbum",
"ImageTags": {"Primary": "tag1"}
},
{
"Id": "item2",
"Name": "Item Two",
"Type": "Audio",
"ImageTags": {"Primary": "tag2"}
}
],
"TotalRecordCount": 2
}"#;
let result: Result<ItemsResponse, _> = serde_json::from_str(json);
assert!(result.is_ok());
let response = result.unwrap();
assert_eq!(response.total_record_count, 2);
assert_eq!(response.items.len(), 2);
assert_eq!(response.items[0].id, "item1");
assert_eq!(response.items[1].id, "item2");
}
#[test]
fn test_search_term_is_url_encoded() {
// A multi-word query (and one with a reserved character) must be
// percent-encoded before being placed in the SearchTerm query param,
// otherwise the request URL is malformed and search returns nothing.
assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
}
#[test]
fn test_jray_context_deserializes_actors() {
// The jray?t= envelope as documented in the JRay truth file format.
let json = r#"{
"actors": [
{ "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
]
}"#;
let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
assert_eq!(ctx.actors.len(), 1);
assert_eq!(ctx.actors[0].name, "Tom Hanks");
assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
}
#[test]
fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
// Future fields (locations/trivia) must be ignored, and absent id keys
// must default to "" rather than failing to parse.
let json = r#"{
"actors": [ { "name": "Extra" } ],
"locations": ["Beach"],
"trivia": "filmed in 1994"
}"#;
let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
assert_eq!(ctx.actors.len(), 1);
assert_eq!(ctx.actors[0].name, "Extra");
assert_eq!(ctx.actors[0].imdb_id, "");
assert_eq!(ctx.actors[0].jellyfin_id, "");
}
}