Files
jellytau/src-tauri/src/repository/online.rs
T
dtourolle ac4fccd499 fix(downloads,playback): re-encode undecodable audio and carry the media source through
Work from a parallel session in the same working tree, committed here so the
branch is not left half-written. Attribution note: authored in a concurrent
Claude session, not by the author of the preceding commit.

- DR-171: a downloaded video keeps audio the device can actually decode.
  `original` quality asked for a straight copy, so an E-AC-3/AC-3/DTS/TrueHD
  track came down untouched and the webview had nothing to play it with.
- `get_video_download_url` gains the media source, so the URL is built against
  the source actually chosen rather than the item's default.
- Device profile and repository plumbing updated to match.

Verified green as a whole: 656 Rust tests, 945 frontend tests, svelte-check clean.
2026-08-15 23:54:00 +02:00

3281 lines
124 KiB
Rust

//! TRACES: UR-002, UR-007 | DR-013 | IR-010
use async_trait::async_trait;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
use super::{types::*, MediaRepository};
use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient;
use crate::settings::StreamingQuality;
use crate::utils::lock::RwLockSafe;
/// The bandwidth ceiling every video stream this process opens is built against.
///
/// Process-wide rather than a field on [`OnlineRepository`] because it is a user
/// preference about *this device's connection*, not about a server session: it
/// must survive a repository being rebuilt on re-login, and every URL builder and
/// the `PlaybackInfo` negotiation have to agree on it or the cap leaks (the
/// negotiation would authorise a direct play the URL builder then never gets to
/// constrain). Same shape as `offline::INCLUDE_CATALOG_BROWSE`.
///
/// Set from `player_set_video_settings` / `player_set_stream_quality`, and
/// restored from the database at startup.
///
/// TRACES: UR-074 | DR-162
static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
/// Apply a bandwidth ceiling to every subsequently-opened video stream.
///
/// Streams already playing keep the bitrate they were opened at — a cap is a
/// property of the URL the server is transcoding for, so changing it mid-stream
/// requires re-opening at the new quality (`player_set_stream_quality`).
///
/// TRACES: UR-074 | DR-162
pub fn set_streaming_quality(quality: StreamingQuality) {
*STREAMING_QUALITY.write_safe() = quality;
}
/// The ceiling currently applied to new video streams.
///
/// TRACES: UR-074 | DR-162
pub fn streaming_quality() -> StreamingQuality {
*STREAMING_QUALITY.read_safe()
}
/// 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 {
/// The signed-in user these requests are made as. Needed by the favourites
/// drain, which reads this user's queued rows. TRACES: UR-069 | DR-120
pub fn user_id(&self) -> &str {
&self.user_id
}
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.
///
/// The stream is built against the current [`streaming_quality`] ceiling:
/// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight`
/// that suits the budget. `Original` keeps the historical 20/18 Mbps
/// allowance, which is a transcode ceiling rather than a user-facing limit.
///
/// TRACES: UR-004, UR-074 | DR-140, DR-162 | UT-130, UT-156
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);
let quality = streaming_quality();
// `Original` is uncapped as a *user* setting, but a transcode still needs
// a ceiling to encode against — keep the values this endpoint has always
// used so nothing changes for the default.
let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
// 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()),
("MaxStreamingBitrate", max_bitrate.to_string()),
("VideoBitrate", video_bitrate.to_string()),
("AudioBitrate", quality.audio_bitrate().to_string()),
(
"TranscodingMaxAudioChannels",
super::device_profile::max_audio_channels().to_string(),
),
("SegmentContainer", "ts".to_string()),
("TranscodingContainer", "ts".to_string()),
("TranscodingProtocol", "hls".to_string()),
];
// Scale the picture down to what the budget can carry. Omitted for the
// uncapped steps so the source resolution is preserved.
if let Some(height) = quality.max_height() {
params.push(("MaxHeight", height.to_string()));
}
// Only pin an audio track when the user actually picked one. Jellyfin's
// `MediaStream.Index` is global across *all* streams in a media source, so
// index 0 is the video stream on virtually every file — defaulting to 0
// asks the server to transcode the video stream as the audio track, which
// yields a picture with no sound. Omitting the param lets the server use
// the source's `DefaultAudioStreamIndex`.
if let Some(index) = audio_stream_index {
params.push(("AudioStreamIndex", index.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)
}
/// Get an **audio-only** stream URL for a *video* item, for the
/// background-audio handoff (UR-040).
///
/// TRACES: UR-040 | JA-032, DR-140 | UT-059, UT-130
///
/// This deliberately targets `/Audio/{id}/universal`, NOT the video stream:
/// the server extracts/transcodes only the item's audio track and streams
/// pure audio bytes — no video frames reach the device, so there is no client
/// video decode while backgrounded. Do NOT "optimize" this to reuse the
/// `/Videos/.../master.m3u8` URL: that would keep the device decoding video,
/// defeating the entire point of the feature.
///
/// `AudioStreamIndex` carries the user's currently-selected audio track over
/// from the video player; `StartTimeTicks` resumes at the handoff position.
/// `universal` lets the server pick direct-play vs transcode per codec/device.
///
/// The stream is a **progressive** container (mp3 over plain HTTP), NOT HLS:
/// ExoPlayer plays this natively, whereas an HLS/`ts` transcode on the
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
/// decodable and supports mid-stream `StartTimeTicks`.
pub async fn build_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
let mut params = vec![
("UserId", self.user_id.clone()),
("api_key", self.access_token.clone()),
("DeviceId", "jellytau-tauri".to_string()),
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
("Container", "mp3".to_string()),
("AudioCodec", "mp3".to_string()),
("TranscodingContainer", "mp3".to_string()),
("TranscodingProtocol", "http".to_string()),
// Audio-only is already far under any video cap, but a user on the
// bottom rungs of the ladder asked for *less traffic*, so take the
// lower of the two rather than always 384 kbps.
// TRACES: UR-074 | DR-162
(
"MaxStreamingBitrate",
streaming_quality().audio_bitrate().min(384_000).to_string(),
),
];
// Carry the track over only if one was actually selected — index 0 is the
// video stream, not "the first audio track" (see `get_video_stream_url`).
if let Some(index) = audio_stream_index {
params.push(("AudioStreamIndex", index.to_string()));
}
if let Some(source_id) = media_source_id {
params.push(("MediaSourceId", source_id.to_string()));
}
if let Some(seconds) = start_time_seconds {
let ticks = (seconds * 10_000_000.0) as i64;
params.push(("StartTimeTicks", ticks.to_string()));
}
let query = params
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
let url = format!("{}/Audio/{}/universal?{}", 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>>,
user_data: Option<JellyfinUserData>,
}
/// Per-user state Jellyfin attaches to an item (favourite, played, resume).
///
/// Returned on every `/Users/{uid}/Items*` response; we additionally name
/// `UserData` in the `Fields=` list so the shape is explicit rather than
/// dependent on the server's default field set.
///
/// TRACES: UR-069 | DR-113, JA-034 | UT-099
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "PascalCase")]
struct JellyfinUserData {
playback_position_ticks: Option<i64>,
#[serde(rename = "Played")]
is_played: Option<bool>,
is_favorite: Option<bool>,
play_count: Option<i32>,
last_played_date: Option<String>,
}
impl From<JellyfinUserData> for UserData {
fn from(jf: JellyfinUserData) -> Self {
UserData {
playback_position_ticks: jf.playback_position_ticks,
playback_position_ms: jf.playback_position_ticks.map(crate::domain::ticks_to_ms),
is_played: jf.is_played,
is_favorite: jf.is_favorite,
play_count: jf.play_count,
last_played_date: jf.last_played_date,
playback_context_type: None,
playback_context_id: None,
}
}
}
/// Build the Jellyfin endpoint for a folder listing.
///
/// Extracted from `get_items` so the query it produces — in particular the
/// favourites filter — can be asserted without standing up an HTTP server.
///
/// TRACES: UR-007, UR-067 | DR-116 | UT-104
fn build_get_items_endpoint(
user_id: &str,
parent_id: &str,
options: Option<&GetItemsOptions>,
) -> String {
let mut endpoint = format!("/Users/{}/Items?ParentId={}", 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("|")));
}
}
// TRACES: UR-067 | DR-116 | UT-104
if opts.favorites_only == Some(true) {
endpoint.push_str("&Filters=IsFavorite");
}
}
// 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,UserData");
endpoint
}
/// Build the Jellyfin endpoint for a "recently added" listing.
///
/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
/// `false`, which returns each newly-added *leaf* separately, so importing one
/// 14-track album pushed 14 rows into "recently added" and buried everything
/// else. With grouping on, the server collapses children into the container
/// that was added — an album appears once, while movies (which have no such
/// container) are unaffected.
///
/// Pulled out of `get_latest_items` so the query can be asserted without an
/// HTTP server, matching `build_favorites_endpoint`.
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
format!(
"/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
user_id,
parent_id,
limit.unwrap_or(16)
)
}
/// Build the Jellyfin endpoint for a favourites listing.
///
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
/// server. `scope` is expanded here — `SearchScope::All` yields `None`, and the
/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
/// union, which would silently drop every type nobody enumerated (see
/// `SearchScope::item_types`).
///
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
fn build_favorites_endpoint(
user_id: &str,
scope: SearchScope,
options: Option<&GetItemsOptions>,
) -> String {
let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
if let Some(types) = scope.item_types() {
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
}
// Jellyfin has no "date favourited", so name order is the only stable sort
// available; callers may still override it.
let sort_by = options
.and_then(|o| o.sort_by.as_deref())
.unwrap_or("SortName");
let sort_order = options
.and_then(|o| o.sort_order.as_deref())
.unwrap_or("Ascending");
endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
if let Some(limit) = options.and_then(|o| o.limit) {
endpoint.push_str(&format!("&Limit={}", limit));
}
if let Some(start_index) = options.and_then(|o| o.start_index) {
endpoint.push_str(&format!("&StartIndex={}", start_index));
}
endpoint
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
endpoint
}
// 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;
let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
MediaItem {
id: self.id,
name: self.name,
item_type: self.item_type,
kind,
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,
duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
primary_image_tag: primary_tag.clone(),
image_id: 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,
// Favourite/played/resume state as the server sees it. TRACES:
// UR-069 | DR-113, JA-034
user_data: self.user_data.map(UserData::from),
media_streams: self.media_streams.map(|streams| {
streams
.into_iter()
.map(|s| crate::repository::types::MediaStream {
kind: crate::domain::stream_kind_from_jellyfin(&s.stream_type),
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 endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
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,UserData", 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 endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
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,UserData",
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,UserData",
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,UserData",
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(),
kind: crate::domain::MediaKind::Album,
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,
duration_ms: None,
primary_image_tag: first_track.primary_image_tag.clone(),
image_id: 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,UserData",
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,UserData",
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,UserData",
);
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,
/// Omitted so the server resolves the source's default audio stream.
/// Never send 0 here: the index is global across all streams, so 0 is
/// the video stream and the negotiated source comes back soundless.
#[serde(skip_serializing_if = "Option::is_none")]
audio_stream_index: Option<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,
/// Channels the device's audio route can actually voice. Without it
/// the server may direct-play a 5.1 track to a two-channel sink,
/// which is silence or inaudible dialogue depending on the device.
max_audio_channels: String,
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,
max_audio_channels: 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>,
/// The track the server serves when the client pins none.
#[serde(default)]
is_default: bool,
}
// Get detected codecs from Android MediaCodecList or use platform defaults
#[cfg(target_os = "android")]
let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
.map(|(video, audio, _channels)| (video, audio))
.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; these codecs are what
// both renderers handle, and the audio profile keeps them in full while
// the video profile is narrowed below.)
#[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(),
);
// Video plays in a webview <video> element on every platform, which
// decodes a narrower audio set than the platform does — so the video
// profile must claim less than the audio-only profile. Without this a
// Dolby-licensed device advertises eac3, gets a direct play, and shows
// picture with no sound.
let video_audio_codecs = super::device_profile::video_audio_codecs(&audio_codecs);
info!("[DeviceProfile] Using video codecs: {}", video_codecs);
info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
info!(
"[DeviceProfile] Audio codecs for video direct play: {}",
video_audio_codecs
);
// Bound every profile by what the audio route can actually voice, so a
// multichannel track is downmixed by the server rather than direct-played
// into a sink that has nowhere to put the extra channels.
let max_audio_channels = super::device_profile::max_audio_channels().to_string();
info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
// The user's bandwidth ceiling has to be part of the *negotiation*, not
// just the transcode URL: `max_static_bitrate` is what makes the server
// refuse to direct-play a source fatter than the cap, and without it a
// 30 Mbps remux is handed over untouched and every URL parameter
// downstream is moot. `Original` keeps the historical "no ceiling"
// sentinel so the default path negotiates exactly as before.
//
// TRACES: UR-074 | DR-162
let quality = streaming_quality();
let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
if let Some(cap) = quality.max_bitrate() {
info!(
"[DeviceProfile] Streaming quality cap active: {} ({} bps)",
quality.label(),
cap
);
}
// Create device profile with detected hardware capabilities
let device_profile = DeviceProfile {
name: "JellyTau Native Player".to_string(),
max_streaming_bitrate: negotiated_bitrate,
max_static_bitrate: negotiated_bitrate,
max_audio_channels: max_audio_channels.clone(),
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()),
// The webview decodes this stream, not ExoPlayer/MPV.
audio_codec: video_audio_codecs.clone(),
},
DirectPlayProfile {
profile_type: "Audio".to_string(),
container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
video_codec: None,
// Audio-only really is the native player's, so it keeps the
// full platform list — narrowing it would transcode music
// that plays perfectly well.
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(),
max_audio_channels: max_audio_channels.clone(),
},
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(),
max_audio_channels: max_audio_channels.clone(),
},
],
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: None, // Let the server pick the source default
subtitle_stream_index: None,
start_time_ticks: 0,
is_playback: true,
auto_open_live_stream: true,
// The user's cap, or the historical 20 Mbps allowance when uncapped.
max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
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
);
}
// Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec
// but ignores its audio codec, so it offers an E-AC-3 track for direct
// play even though DR-148 advertises only AAC — and the webview renders
// the picture in silence. Judge the track we would actually be served
// against what the webview can decode, and override the server's answer.
let audio_streams: Vec<(Option<&str>, bool)> = source
.media_streams
.iter()
.filter(|stream| stream.stream_type == "Audio")
.map(|stream| (stream.codec.as_deref(), stream.is_default))
.collect();
let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
// 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 if audio_forces_transcode {
warn!(
"[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
audio_streams.first().and_then(|(codec, _)| *codec)
);
self.get_video_stream_url(item_id, Some(&source.id), None, None)
.await?
} else {
// Fall back to direct stream URL. No audioStreamIndex: static=true
// serves the original file untouched, and pinning index 0 (the video
// stream) only misleads servers that do honour it.
format!(
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&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 && !audio_forces_transcode,
needs_transcoding: audio_forces_transcode
|| (!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_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
self.build_audio_only_stream_url_for_video(
item_id,
media_source_id,
start_time_seconds,
audio_stream_index,
)
.await
}
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,
// Live TV is video like any other, so the user's cap applies here
// too — a channel opened at the source bitrate would walk straight
// past a limit set for the connection. TRACES: UR-074 | DR-162
max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(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
)
}
/// TRACES: UR-071 | DR-123
fn get_video_download_url(
&self,
item_id: &str,
quality: &str,
media_source_id: Option<&str>,
source_audio_codec: 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.
//
// 🔴 It is `videoBitRate`/`audioBitRate` — **capital R**. Jellyfin binds
// query keys case-insensitively, so `maxHeight`/`videoCodec` casing is
// free, but `videoBitrate` (lowercase r) is a *different token*: it
// fails to bind, is silently dropped, and the requested cap vanishes
// with no error. That is why every "480p"/"720p" download came back at
// full original quality. See `Jellyfin.Api` BaseEncodingJobOptions.
//
// `allowVideoStreamCopy=false` forces a real re-encode. Without it the
// server may stream-copy the source when it already satisfies the cap —
// fine in itself, but it also means a mis-typed cap degrades silently.
// Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
// video copy is gated by `allowVideoStreamCopy`.
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());
params.push("allowVideoStreamCopy=false".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());
params.push("allowVideoStreamCopy=false".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());
params.push("allowVideoStreamCopy=false".to_string());
}
// "original" (and any unknown value) → direct, resumable copy —
// unless the audio in that copy is undecodable where the file will
// be played back. A download is watched with no server in reach, so
// it has to satisfy the same constraint DR-149 applies to streams:
// the webview `<video>` element renders video on both platforms and
// decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
// disk is what made a downloaded film play offline as picture with
// no sound while the same film had sound when streamed.
//
// Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
// h264 source's picture byte-for-byte, so "original" still means
// original quality, and no bitrate or resolution cap is added. A
// source the webview could not have rendered anyway (HEVC) is
// re-encoded to h264 as a side effect, which is the only form of it
// that would have played.
//
// The cost of the transcode is that the response is no longer
// range-resumable, which is exactly why this is decided per item
// rather than applied to every `original` download.
//
// TRACES: UR-071, UR-004 | DR-171 | UT-166
_ => match source_audio_codec {
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
params.push("videoCodec=h264".to_string());
params.push("allowVideoStreamCopy=true".to_string());
params.push("audioCodec=aac".to_string());
params.push("audioBitRate=384000".to_string());
}
// Decodable, or unknown: an unknown codec must not provoke a
// transcode — that would burn server CPU on a guess for files
// that play perfectly well.
_ => 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
}
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
async fn get_favorites(
&self,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
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 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
}
/// `DELETE /Users/{userId}/PlayedItems/{itemId}` — Jellyfin's "mark
/// unplayed", which also zeroes the resume position. On a folder (series,
/// season) the server applies it recursively to the children.
///
/// TRACES: UR-064 | DR-106, JA-033
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = format!("/Users/{}/PlayedItems/{}", 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
}
/// `POST /Users/{userId}/PlayedItems/{itemId}` — the mirror image of
/// `clear_watch_history`.
///
/// TRACES: UR-025 | DR-131 | JA-035
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
let request = self
.http_client
.client
.post(&url)
.header("X-Emby-Authorization", self.auth_header())
.header("Content-Length", "0")
.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,UserData",
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,UserData",
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 crate::utils::lock::MutexSafe;
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"
);
}
/// Serialises every test whose expectations depend on the process-wide
/// streaming ceiling, and restores the uncapped default afterwards — without
/// it, a capped test running concurrently changes what an uncapped one sees.
///
/// TRACES: UR-074 | DR-162
static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
impl QualityFixture {
fn set(quality: StreamingQuality) -> Self {
let guard = QUALITY_LOCK.lock_safe();
set_streaming_quality(quality);
Self(guard)
}
}
impl Drop for QualityFixture {
fn drop(&mut self) {
set_streaming_quality(StreamingQuality::Original);
}
}
/// A cap has to reach the transcode URL as all four of its parts: the total
/// ceiling, the split between video and audio, and the resolution the budget
/// can carry. Capping only `MaxStreamingBitrate` would leave the server
/// encoding 1080p into 2 Mbps.
///
/// TRACES: UR-074 | DR-162 | UT-156
#[tokio::test]
async fn test_video_stream_url_applies_bitrate_cap() {
let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None, None)
.await
.unwrap();
assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
// 2 Mbps total less the 192 kbps audio share — the two must not sum to
// more than the cap the user asked for.
assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
assert!(url.contains("AudioBitrate=192000"), "url: {url}");
assert!(url.contains("MaxHeight=720"), "url: {url}");
}
/// The uncapped default must keep the exact transcode allowance this
/// endpoint has always used, and must not start constraining resolution.
///
/// TRACES: UR-074 | DR-162 | UT-156
#[tokio::test]
async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
let _fixture = QualityFixture::set(StreamingQuality::Original);
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None, None)
.await
.unwrap();
assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
assert!(url.contains("AudioBitrate=384000"), "url: {url}");
assert!(
!url.contains("MaxHeight"),
"uncapped must not scale the picture down: {url}"
);
}
/// The background-audio handoff is already cheap, but someone who capped the
/// connection at 720 kbps asked for less traffic than its fixed 384 kbps.
///
/// TRACES: UR-040, UR-074 | DR-162 | UT-156
#[tokio::test]
async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
{
let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
let repo = create_test_repository();
let url = repo
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
.await
.unwrap();
assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
}
let _fixture = QualityFixture::set(StreamingQuality::Original);
let repo = create_test_repository();
let url = repo
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
.await
.unwrap();
assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
}
#[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 _fixture = QualityFixture::set(StreamingQuality::Original);
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 _fixture = QualityFixture::set(StreamingQuality::Original);
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"));
// With no track chosen, the param must be OMITTED so the server picks the
// source's DefaultAudioStreamIndex. `MediaStream.Index` is global across
// all streams of a source, so index 0 is the *video* stream on virtually
// every file — sending it asks for a "audio track" that has no audio.
assert!(
!url.contains("AudioStreamIndex"),
"must not pin an audio index when none was chosen: {url}"
);
}
#[tokio::test]
async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
// TRACES: UR-040 | JA-032 | UT-059
// Background-audio handoff must request an audio-only stream (no video
// decode) that resumes at the current position and keeps the selected
// audio track.
let repo = create_test_repository();
let url = repo
.get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
.await
.unwrap();
assert!(
url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
"expected audio-only universal endpoint, got: {url}"
);
// Must NOT be a video stream (no client video decode in background).
assert!(
!url.contains("/Videos/"),
"url must not hit the video endpoint: {url}"
);
assert!(
!url.contains("master.m3u8"),
"url must not be a video HLS playlist: {url}"
);
assert!(url.contains("AudioStreamIndex=2"));
assert!(url.contains("MediaSourceId=source-1"));
// 193.0 seconds * 10_000_000 ticks/sec
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
// Progressive mp3 over HTTP — NOT HLS/ts, or ExoPlayer's progressive
// loader fails with ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED.
assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
assert!(
!url.contains("TranscodingProtocol=hls"),
"url must not be HLS: {url}"
);
assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
}
#[tokio::test]
async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
// TRACES: UR-040 | JA-032 | UT-059
let repo = create_test_repository();
let url = repo
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
.await
.unwrap();
assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Same as the video path: omit rather than pin index 0 (the video stream),
// and let the server fall back to the source's default audio stream.
assert!(
!url.contains("AudioStreamIndex"),
"must not pin an audio index when none was chosen: {url}"
);
}
#[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, 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, 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, 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}"
);
}
}
/// The bitrate params are spelled `videoBitRate`/`audioBitRate` — **capital
/// R**. Jellyfin binds query keys case-insensitively, so this is not a
/// casing preference: `videoBitrate` is a *different token* that fails to
/// bind and is silently discarded, taking the user's quality cap with it.
/// Nothing errors — the download just returns the full-size original, which
/// is exactly how this bug went unnoticed.
#[test]
fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
let repo = create_test_repository();
for quality in ["high", "medium", "low"] {
let url = repo.get_video_download_url("item123", quality, None, None);
assert!(
url.contains("videoBitRate="),
"{quality} must spell it videoBitRate (capital R): {url}"
);
assert!(
url.contains("audioBitRate="),
"{quality} must spell it audioBitRate (capital R): {url}"
);
// The lowercase-r spellings never bind — they must not appear at
// all, or the cap is silently dropped by the server.
assert!(
!url.contains("videoBitrate="),
"{quality} emits the unbindable lowercase-r spelling: {url}"
);
assert!(
!url.contains("audioBitrate="),
"{quality} emits the unbindable lowercase-r spelling: {url}"
);
}
}
/// A correctly-spelled cap is still only *conditionally* honored: the server
/// may stream-copy the source when it already satisfies the cap. Video copy
/// is gated by `allowVideoStreamCopy` (NOT `enableAutoStreamCopy`, which
/// only governs audio), so the transcode presets must disable it to
/// guarantee a real re-encode at the requested bitrate.
#[test]
fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
let repo = create_test_repository();
for quality in ["high", "medium", "low"] {
let url = repo.get_video_download_url("item123", quality, None, None);
assert!(
url.contains("allowVideoStreamCopy=false"),
"{quality} must forbid video stream copy: {url}"
);
}
// "original" is a deliberate direct copy — it must NOT disable copying.
let original = repo.get_video_download_url("item123", "original", None, None);
assert!(
!original.contains("allowVideoStreamCopy=false"),
"original must remain a direct copy: {original}"
);
}
/// A downloaded file is played with no server in reach, so `original`
/// quality cannot mean "copy whatever the source holds" when the source
/// holds audio this device cannot decode.
///
/// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
/// track included, and video plays through the webview `<video>` element on
/// both platforms — which decodes none of them. Streaming already knows this
/// (DR-149 forces a transcode over the server's own direct-play offer); the
/// download path did not, so a downloaded film played offline as picture with
/// no sound while the very same film had sound when streamed.
///
/// TRACES: UR-071, UR-004 | DR-171 | UT-166
#[test]
fn test_video_download_url_original_transcodes_undecodable_audio() {
let repo = create_test_repository();
for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
let url = repo.get_video_download_url("item123", "original", None, Some(codec));
assert!(
!url.contains("Static=true"),
"{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
);
assert!(
url.contains("audioCodec=aac"),
"{codec} must be re-encoded to aac on the way down: {url}"
);
// "Original" still has to mean original picture: the video stream is
// copied when it can be, so no bitrate or resolution cap appears.
assert!(
url.contains("allowVideoStreamCopy=true"),
"the video stream must still be copied where possible: {url}"
);
assert!(
!url.contains("videoBitRate") && !url.contains("maxHeight"),
"original must not degrade the picture to fix the audio: {url}"
);
}
}
/// The converse, and the reason the policy is per-item rather than blanket:
/// audio that plays here keeps the byte-exact, range-resumable copy that the
/// download worker's resume depends on.
///
/// TRACES: UR-071 | DR-171 | UT-166
#[test]
fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
let repo = create_test_repository();
for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
let url = repo.get_video_download_url("item123", "original", None, Some(codec));
assert!(
url.contains("Static=true"),
"{codec} plays here — the download must stay a direct copy: {url}"
);
assert!(
!url.contains("audioCodec="),
"{codec} needs no transcode: {url}"
);
}
// Unknown codec: the policy only ever *adds* a transcode, so an item we
// could not look up behaves exactly as it did before.
let unknown = repo.get_video_download_url("item123", "original", None, None);
assert!(unknown.contains("Static=true"), "url: {unknown}");
}
/// The explicit quality presets already transcode audio to AAC, so the
/// policy has nothing to add — and must not start overriding a chosen cap.
///
/// TRACES: UR-071 | DR-171 | UT-166
#[test]
fn test_video_download_url_presets_ignore_the_audio_policy() {
let repo = create_test_repository();
for quality in ["high", "medium", "low"] {
let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
let without = repo.get_video_download_url("item123", quality, None, None);
assert_eq!(with, without, "{quality} must not vary with source audio");
assert!(with.contains("audioCodec=aac"), "url: {with}");
}
}
#[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"), None);
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())
);
}
/// UT-100 — the favourites endpoint asks the server for favourites, scoped.
///
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
#[test]
fn test_build_favorites_endpoint_scopes_and_filters() {
let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
assert!(movies.contains("&IncludeItemTypes=Movie"));
// Jellyfin has no favourite timestamp, so name order is the default.
assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
// Hearts must render on the returned cards.
assert!(movies.contains("UserData"));
// Tv covers both the show and any individually favourited episode.
let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
let music = build_favorites_endpoint("u1", SearchScope::Music, None);
assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
}
/// `All` must omit the type filter entirely rather than send a union, which
/// would silently drop every type nobody enumerated.
///
/// TRACES: UR-067 | DR-115 | UT-100
#[test]
fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
let all = build_favorites_endpoint("u1", SearchScope::All, None);
assert!(!all.contains("IncludeItemTypes"));
}
/// Paging and an explicit sort still reach the server.
///
/// TRACES: UR-067 | DR-115 | UT-100
#[test]
fn test_build_favorites_endpoint_honours_paging_and_sort() {
let endpoint = build_favorites_endpoint(
"u1",
SearchScope::All,
Some(&GetItemsOptions {
limit: Some(20),
start_index: Some(40),
sort_by: Some("Random".to_string()),
sort_order: Some("Descending".to_string()),
..Default::default()
}),
);
assert!(endpoint.contains("&Limit=20"));
assert!(endpoint.contains("&StartIndex=40"));
assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
}
/// UT-104 — the in-library favourites toggle reaches the server as
/// `Filters=IsFavorite`, and is absent unless asked for.
///
/// TRACES: UR-067 | DR-116 | UT-104
#[test]
fn test_get_items_endpoint_applies_favorites_only() {
let plain = build_get_items_endpoint("u1", "lib-1", None);
assert!(!plain.contains("Filters=IsFavorite"));
let filtered = build_get_items_endpoint(
"u1",
"lib-1",
Some(&GetItemsOptions {
favorites_only: Some(true),
include_item_types: Some(vec!["Movie".to_string()]),
..Default::default()
}),
);
assert!(filtered.contains("&Filters=IsFavorite"));
// Composes with the filters already there rather than replacing them.
assert!(filtered.contains("&IncludeItemTypes=Movie"));
assert!(filtered.contains("ParentId=lib-1"));
// Explicitly false is not a request to filter.
let off = build_get_items_endpoint(
"u1",
"lib-1",
Some(&GetItemsOptions {
favorites_only: Some(false),
..Default::default()
}),
);
assert!(!off.contains("Filters=IsFavorite"));
}
/// A newly-added album must arrive as one entry, not one per track.
///
/// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
/// every new Audio track individually — so ripping a 14-track album filled
/// the whole "recently added" row with that one album. `GroupItems=true`
/// makes the server collapse children into their parent container.
#[test]
fn test_latest_items_endpoint_groups_children_into_containers() {
let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
assert!(
endpoint.contains("GroupItems=true"),
"latest items must be grouped so an album counts once, got: {}",
endpoint
);
assert!(endpoint.contains("ParentId=lib-1"));
assert!(endpoint.contains("Limit=16"));
}
/// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
///
/// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
/// the mini player could know an item was favourited.
///
/// TRACES: UR-069 | DR-113, JA-034 | UT-099
#[test]
fn test_jellyfin_item_maps_user_data_favorite() {
let json = r#"{
"Id": "movie123",
"Name": "Test Movie",
"Type": "Movie",
"UserData": {
"PlaybackPositionTicks": 6000000000,
"Played": false,
"IsFavorite": true,
"PlayCount": 2,
"LastPlayedDate": "2026-08-01T12:00:00Z"
}
}"#;
let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
let media = item.to_media_item("server1".to_string());
let user_data = media.user_data.expect("user data should be mapped");
assert_eq!(user_data.is_favorite, Some(true));
assert_eq!(user_data.is_played, Some(false));
assert_eq!(user_data.play_count, Some(2));
assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
// Ticks are converted for the frontend, which never divides them itself.
assert_eq!(user_data.playback_position_ms, Some(600_000));
}
/// An item without `UserData` still maps — the field is optional, and every
/// non-user-scoped endpoint omits it.
///
/// TRACES: UR-069 | DR-113 | UT-099
#[test]
fn test_jellyfin_item_without_user_data_maps_to_none() {
let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
let media = item.to_media_item("server1".to_string());
assert!(media.user_data.is_none());
}
#[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, "");
}
}