Files
jellytau/src-tauri/src/repository/online.rs
T
dtourolleandClaude Opus 5 8027fd5fac feat(repository): a route table and resolved server capabilities
Endpoints were 57 inline format! literals with their query strings baked in at
the point of use. That is workable against exactly one server and hostile to
anything else: a second route shape would mean a conditional at every one of
them. They now live in repository/endpoints.rs, one function each, taking
&ServerCapabilities.

Two things fall out of the move:

  - A small Endpoint builder replaces the manual ?/& juggling, so a double or
    trailing separator is structurally impossible rather than something four
    assertions in a deleted test file used to watch for.
  - Both user-scoped route shapes (/Users/{uid}/Items and /Items?userId=) are
    built and tested, though nothing selects the second yet. The family still
    works on 12.0, so migrating is optional; having both means it is a one-line
    change if 13.0 removes them, as the newly written removal policy allows.

ServerCapabilities is resolved once per connection from the version the server
already reported at connect. The version-to-flags mapping lives in exactly one
function and nothing else in the crate compares a version number: a `version < N`
at the point of use re-derives a domain fact where it is consumed, is unreadable
by its second occurrence, and cannot express a backport.

An unrecognised version resolves forward to the newest known generation rather
than being refused, because refusing would make every release expire the moment
the server upgrades. Only a version below the floor is refused.

This commit also carries the two fixes that are NOT capability branches, because
they live in the same files:

  - Authorization replaces X-Emby-Authorization, and ApiKey replaces the api_key
    query parameter. Jellyfin 12.0 disables both legacy spellings by default and
    a migration flips them on upgraded servers too, so this is what actually
    breaks against 12.0. The header value this app already built was always the
    correct MediaBrowser scheme, and both new spellings are ungated on 10.11.x —
    so it is a rename, not a branch. The query-parameter spelling is load-bearing
    rather than cosmetic: stream URLs go to mpv, ExoPlayer and the webview's
    <video>, none of which can send a header.
  - A type-filtered listing now states Recursive explicitly. 12.0 defaults it to
    true for a library parent with IncludeItemTypes where 10.11 returned
    immediate children, so the identical request returned a different result set
    with nothing in the response to say which rule applied. The value sent is the
    one that shipped, so this is a compatibility fix and not a silent behaviour
    change.

A structural test refuses any deprecated auth spelling reaching a request
builder, verified to fail when one is reintroduced. Behaviour is otherwise
preserved: the four previous endpoint builders become test-only shims over the
new table, so the ~20 existing tests encoding DR-116/DR-212/DR-257 now exercise
the production path rather than being deleted.

TRACES: UR-085 | IR-035, DR-279, DR-280, DR-287, DR-288

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:09:34 +02:00

4645 lines
182 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::capabilities::ServerCapabilities;
use super::endpoints;
use super::stream_selection::{
quality_options_for_source, PlaybackKind, Rendition, StreamSelection, Transport,
};
use super::{types::*, MediaRepository};
use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient;
use crate::settings::StreamingQuality;
use crate::utils::lock::RwLockSafe;
/// The viewer's **durable** bandwidth ceiling — the device default.
///
/// 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` (Settings), and restored from the
/// database at startup. **Not** set by the in-player picker — see
/// [`PLAYBACK_QUALITY_OVERRIDE`].
///
/// TRACES: UR-074 | DR-162
static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
/// The ceiling in force for *the playback happening right now*, when the viewer
/// has moved this one item off the device default.
///
/// This exists because a single global cannot express "this 4K remux needs a
/// ceiling, that podcast does not". The in-player picker is a "this film, this
/// connection" control — its own doc has said so since it was written — but it
/// was implemented by writing the device default, so choosing 2 Mbps to get one
/// awkward film moving silently capped every video played afterwards, for the
/// rest of the process, with the Settings screen still displaying the old value.
///
/// Cleared when a new item starts playing, so the override cannot outlive the
/// playback it was chosen for. The device default is never touched by it.
///
/// TRACES: UR-074, UR-079 | DR-226
static PLAYBACK_QUALITY_OVERRIDE: RwLock<Option<StreamingQuality>> = RwLock::new(None);
/// Set the durable device default. Applies to every stream opened afterwards
/// that has no per-playback override.
///
/// 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 durable device default, ignoring any per-playback override.
///
/// Read this only where the *setting* is the subject — the Settings screen, and
/// persistence. Everything that opens a stream wants
/// [`effective_streaming_quality`] instead.
///
/// TRACES: UR-074 | DR-162
pub fn streaming_quality() -> StreamingQuality {
*STREAMING_QUALITY.read_safe()
}
/// Move *this playback* to a different ceiling without disturbing the default.
///
/// TRACES: UR-074, UR-079 | DR-226
pub fn set_playback_quality_override(quality: StreamingQuality) {
*PLAYBACK_QUALITY_OVERRIDE.write_safe() = Some(quality);
}
/// Drop any per-playback override, returning to the device default.
///
/// Called when playback moves to a new item: a ceiling chosen for one film must
/// not silently govern the next one. Autoplaying the next episode is the case
/// that matters — nobody re-opens the picker between episodes.
///
/// TRACES: UR-074, UR-079 | DR-226
pub fn clear_playback_quality_override() {
*PLAYBACK_QUALITY_OVERRIDE.write_safe() = None;
}
/// The per-playback override, if one is in force.
///
/// TRACES: UR-074, UR-079 | DR-226
pub fn playback_quality_override() -> Option<StreamingQuality> {
*PLAYBACK_QUALITY_OVERRIDE.read_safe()
}
/// The ceiling that actually applies to a stream opened now: the per-playback
/// override if the viewer set one, otherwise the device default.
///
/// This is the single resolution point. Every URL builder and the `PlaybackInfo`
/// negotiation must go through it, for the same reason they all had to agree on
/// the old static: a negotiation that authorises a direct play the URL builder
/// then constrains (or vice versa) leaks the cap.
///
/// TRACES: UR-074, UR-079 | DR-226
pub fn effective_streaming_quality() -> StreamingQuality {
playback_quality_override().unwrap_or_else(streaming_quality)
}
/// Every request this app makes identifies the same device, so the device id
/// alone cannot tell two transcodes of the same item apart — see
/// [`begin_video_play_session`].
const DEVICE_ID: &str = "jellytau-tauri";
/// The `PlaySessionId` of the video transcode most recently opened, so the next
/// open can stop it.
///
/// Process-wide for the same reason as [`STREAMING_QUALITY`]: it describes what
/// *this device* currently has running on the server, and must survive the
/// repository being rebuilt on re-login.
///
/// TRACES: UR-074 | DR-162
static VIDEO_PLAY_SESSION: RwLock<Option<String>> = RwLock::new(None);
/// Claim a transcode identity for a stream about to be opened, returning the new
/// `PlaySessionId` and the one it replaces (if any).
///
/// Jellyfin keys a transcode job by device *and* play session. Without a session
/// id every open of the same item on this device looked like the same job, so
/// re-opening a stream — a quality switch, a transcoded seek, an audio-track
/// switch — left the old ffmpeg running and the server intermittently rejected
/// segment requests for the new one (`400` on `hls1/main/0.ts`) while the two
/// fought over one transcode path. The caller stops the returned previous
/// session before the new stream's segments are fetched.
///
/// TRACES: UR-074 | DR-177 | UT-173
pub fn begin_video_play_session() -> (String, Option<String>) {
let new_session = uuid::Uuid::new_v4().to_string();
let mut current = VIDEO_PLAY_SESSION.write_safe();
let previous = current.replace(new_session.clone());
(new_session, previous)
}
/// Take ownership of a transcode this process did not build a URL for, returning
/// the session it replaces.
///
/// When `PlaybackInfo` answers with a `TranscodingUrl` the server has already
/// started the job and named the session; that id is the only handle on it we
/// will ever have. Without adopting it, the first re-open of that stream has no
/// previous session to stop and collides with the very job that was playing.
///
/// TRACES: UR-074 | DR-177 | UT-173
pub fn adopt_video_play_session(session_id: String) -> Option<String> {
VIDEO_PLAY_SESSION.write_safe().replace(session_id)
}
/// 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>,
/// What this server can do, resolved once from the version it reported at
/// connect. Every route and every version-dependent decision reads a named
/// flag from here; nothing compares a version number.
///
/// TRACES: UR-085 | IR-035, DR-280
capabilities: ServerCapabilities,
}
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,
// Assumed until the caller supplies what the server reported. The
// assumption is the current target, which is what it will be in
// nearly every case.
capabilities: ServerCapabilities::assumed(),
}
}
/// Adopt the capabilities resolved from the version the server reported at
/// connect. Without this the repository assumes the current target.
///
/// TRACES: UR-085 | IR-035, DR-280
pub fn with_capabilities(mut self, capabilities: ServerCapabilities) -> Self {
self.capabilities = capabilities;
self
}
/// What the server on the other end can do.
///
/// Test-only: production reads the flags through the route table and the
/// playback paths rather than asking the repository for them, so exposing
/// this outside tests would be an accessor nobody calls.
///
/// TRACES: UR-085 | DR-280
#[cfg(test)]
pub fn capabilities(&self) -> &ServerCapabilities {
&self.capabilities
}
/// 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("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 = endpoints::jray_context(&self.capabilities, 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("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("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("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),
})
}
/// Ask the server to tear down a transcode this device started.
///
/// Best-effort and deliberately un-retried: it runs on the path that opens a
/// replacement stream, so a slow or failed stop must not delay playback. The
/// worst case if it does fail is the job Jellyfin would have reaped on its
/// own idle timer anyway — the new stream still has its own session id, so it
/// no longer collides with the old one.
///
/// TRACES: UR-074 | DR-177
async fn stop_transcode(&self, play_session_id: &str) {
let url = format!(
"{}/Videos/ActiveEncodings?deviceId={}&playSessionId={}",
self.server_url, DEVICE_ID, play_session_id
);
let request = self
.http_client
.client
.delete(&url)
.header("Authorization", self.auth_header())
.send();
match request.await {
Ok(response) if response.status().is_success() => {
debug!("[Transcode] Stopped previous encoding {}", play_session_id);
}
Ok(response) => {
debug!(
"[Transcode] Server declined to stop encoding {}: HTTP {}",
play_session_id,
response.status()
);
}
Err(e) => {
debug!(
"[Transcode] Could not stop encoding {}: {}",
play_session_id, e
);
}
}
}
/// Get a video stream URL (initial play, 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.
///
/// **There is deliberately no start-position parameter.** A playlist covers
/// the whole item and asking for segment N *is* the seek, so a position would
/// be redundant — and actively fatal: Jellyfin builds every segment URI by
/// echoing this playlist's query string into it, while its segment handler
/// rejects `StartTimeTicks > 0` outright (`ArgumentException` → `400`). One
/// resume position here therefore 400s every segment of the stream, which
/// presents as a resumed episode that simply never plays while the same
/// episode from the beginning is fine. Resume by seeking the player once it
/// has loaded. (The progressive `/Audio/universal` builder below has no
/// segments and keeps its `StartTimeTicks`.)
///
/// The stream is built against the current [`effective_streaming_quality`]
/// ceiling (the per-playback override if one is set, else the device default):
/// `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, DR-177, DR-181 | UT-130, UT-156, UT-173, UT-182
pub async fn get_video_stream_url(
&self,
item_id: &str,
media_source_id: Option<&str>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
let quality = effective_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);
// Claim a distinct transcode identity and retire the one it replaces, so
// the server is never running two jobs for this device at once. Doing it
// here covers every path that re-opens a stream (quality switch,
// transcoded seek, audio-track switch) rather than each remembering to.
let (play_session_id, superseded) = begin_video_play_session();
if let Some(previous) = superseded {
self.stop_transcode(&previous).await;
}
// Build an HLS transcode URL, naming every codec this renderer can
// decode rather than only h264.
//
// The list is what lets the server *copy* the video stream instead of
// re-encoding it. Hardcoding h264 meant an hevc source whose only
// problem was its audio — eac3 on a device with no Dolby licence — got
// its picture fully re-encoded to satisfy a sound problem. The server's
// own transcoding URL already did the right thing (`h264,hevc`, video
// copied, `TranscodeReasons=AudioCodecNotSupported`); this builder,
// which takes over whenever a quality change or track switch re-opens
// the stream, quietly did not — so changing quality turned a cheap
// remux into a full transcode.
//
// On the webview path this still resolves to "h264" alone, so nothing
// changes there.
//
// TRACES: UR-004, UR-080 | DR-234
let (renderer_video_codecs, _) = super::device_profile::renderer_codecs();
let mut params = vec![
("ApiKey", self.access_token.clone()),
("DeviceId", DEVICE_ID.to_string()),
("PlaySessionId", play_session_id),
("VideoCodec", renderer_video_codecs),
("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()),
// Say "no subtitle" rather than leaving the choice open. An omitted
// index is not neutral: the server then picks the source's own
// default/forced track, and an image-based one can only be delivered
// by burning it into the picture (DR-176). The negotiation already
// sends this sentinel, but most streams are opened by rebuilding
// *this* URL — a quality switch, a transcoded seek, an audio-track
// switch — so it has to hold here too, independently of whatever
// session state the server still holds.
(
"SubtitleStreamIndex",
super::device_profile::playback_subtitle_stream_index().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()));
}
// 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()),
("ApiKey", self.access_token.clone()),
("DeviceId", DEVICE_ID.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",
effective_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)
}
/// Ask the server what it will do with this item, under our device profile
/// and the ceiling currently in force.
///
/// The single place the device profile is built and POSTed. Both
/// [`get_playback_info`](Self::get_playback_info) (the legacy shape) and
/// `get_stream_selection` (the DR-225 contract) go through it, so the
/// profile they negotiate under cannot drift apart.
///
/// TRACES: UR-004, UR-074, UR-079 | DR-225, DR-228
async fn negotiate_playback(
&self,
item_id: &str,
) -> Result<(NegotiatedSource, String), RepoError> {
let endpoint = endpoints::playback_info(&self.capabilities, item_id);
// What the renderer that will decode this can play. One source, shared
// with the transcode URL builder and the client-side audio override, so
// the profile we advertise and the stream we then ask for cannot
// disagree. TRACES: UR-004, UR-080 | DR-234
let (video_codecs, audio_codecs) = super::device_profile::renderer_codecs();
// 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 = effective_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(),
// The server may only transcode *to* something this renderer
// can decode. This said "h264,hevc" unconditionally while the
// direct-play profile claims h264 alone on the webview path —
// a straight contradiction: it tells the server "I cannot
// play hevc, so re-encode it" and then "re-encoding it to
// hevc is fine". When the server took that option the webview
// got a stream it could not decode, which presents as video
// stuck on its first frame rather than as an error.
//
// Capped at the two codecs a Jellyfin server actually
// encodes, so a wider decode list never asks it for an av1
// encode. TRACES: UR-004, UR-080 | DR-234
video_codec: Some(
if video_codecs.contains("hevc") {
"h264,hevc"
} else {
"h264"
}
.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: super::device_profile::subtitle_profiles()
.into_iter()
.map(|(format, method)| SubtitleProfile {
format: format.to_string(),
method: method.to_string(),
})
.collect(),
};
// 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
// Never let the server choose a subtitle track for us. Omitting this
// makes it honour the source's default/forced flag, and an image-based
// default (PGS) it cannot send as a sidecar becomes SubtitleMethod=Encode
// — burn-in, which forces a full video re-encode of a stream that would
// otherwise be remuxed. The app renders subtitles itself (UR-020).
//
// TRACES: UR-020, UR-004 | DR-176 | UT-168
subtitle_stream_index: Some(super::device_profile::playback_subtitle_stream_index()),
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
.into_iter()
.next()
.ok_or(RepoError::NotFound {
message: "No media sources available".to_string(),
})?;
Ok((source, response.play_session_id))
}
/// Decide what stream to play, and describe it fully enough that no consumer
/// has to guess.
///
/// This replaces `get_video_stream_url`'s "always build an HLS transcode"
/// with an actual decision. Measured against the development server, that
/// distinction is worth 85% of plays on Android (ExoPlayer decodes hevc, and
/// the sampled library is ~80% hevc) and about 7% on Linux, where the
/// WebKitGTK profile can only claim h264 until the native-video work lands.
/// The Linux figure is a property of the renderer, not of this code.
///
/// The returned [`StreamSelection`] carries the transport explicitly so the
/// frontend stops testing the URL for `.m3u8`, and the quality ladder for
/// *this* source so the picker stops offering rungs that mean nothing for it.
///
/// TRACES: UR-070, UR-079 | DR-225, DR-226, DR-227, DR-228 | UT-213
pub async fn get_stream_selection(
&self,
item_id: &str,
media_source_id: Option<&str>,
audio_stream_index: Option<i32>,
) -> Result<StreamSelection, RepoError> {
let (source, play_session_id) = self.negotiate_playback(item_id).await?;
let quality = effective_streaming_quality();
let source_bitrate = source.bitrate.and_then(|b| u64::try_from(b).ok());
let available = quality_options_for_source(source_bitrate);
// Judge the audio track we would actually be served against what this
// platform's video renderer can decode, and override the server's
// answer. Jellyfin 10.11.5 honours a DirectPlayProfile's container and
// video codec but ignores its audio codec, so it will offer direct play
// for an E-AC-3 track and the webview renders the picture in silence.
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);
// A pinned audio track is a request the *source file* cannot satisfy: a
// direct play serves the file's own default track, so honouring the
// choice means having the server produce a stream built around it.
let track_pinned = audio_stream_index.is_some();
let effective_source_id = media_source_id.unwrap_or(&source.id).to_string();
let decided = decide_playback_kind(&source, audio_forces_transcode, track_pinned);
let selection = match decided {
PlaybackKind::Transcode => {
let url = if let Some(transcoding_url) = source
.transcoding_url
.as_deref()
.filter(|_| !track_pinned && audio_stream_index.is_none())
{
// The server started this job and named the session — adopt
// it, or a later quality switch / seek has no previous job to
// stop and contends with the one currently playing.
if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
self.stop_transcode(&previous).await;
}
// The server built this URL from its *own* subtitle verdict,
// so it can hand back the burn-in the request declined.
// TRACES: UR-020, UR-004 | DR-176
format!(
"{}{}",
self.server_url,
super::device_profile::without_server_chosen_subtitle(transcoding_url)
)
} else {
// Build our own transcode URL: either the server offered
// none, or the viewer pinned an audio track the server's URL
// does not carry.
self.get_video_stream_url(
item_id,
Some(&effective_source_id),
audio_stream_index,
)
.await?
};
StreamSelection {
url,
// Every transcode this app requests is HLS — a progressive
// transcode of an HEVC source makes the server encode the
// whole file before playback starts, which presents as
// playback never beginning (DR-140).
transport: Transport::Hls,
playback_kind: PlaybackKind::Transcode,
rendition: Some(Rendition {
quality,
max_bitrate: quality.max_bitrate(),
max_height: quality.max_height(),
video_codec: Some("h264".to_string()),
audio_codec: Some("aac".to_string()),
}),
available,
media_source_id: Some(effective_source_id),
play_session_id: Some(play_session_id),
needs_transcoding: PlaybackKind::Transcode.needs_transcoding(),
}
}
kind @ (PlaybackKind::DirectPlay | PlaybackKind::DirectStream) => {
// The original bytes, or a remux of them. No `AudioStreamIndex`:
// `static=true` serves the file untouched, and pinning index 0
// (which is the *video* stream — the index is global across all
// streams) only misleads servers that do honour it.
let url = format!(
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&ApiKey={}&userId={}",
self.server_url,
item_id,
effective_source_id,
DEVICE_ID,
self.access_token,
self.user_id
);
StreamSelection {
url,
// A static file over HTTP, seekable by byte range — not a
// playlist. This is the case the `.m3u8` sniff got wrong in
// the safe direction only by accident.
transport: Transport::Progressive,
playback_kind: kind,
// Direct play *is* the source; there is no chosen rendition
// to report, and reporting the ceiling that happens to be set
// would misdescribe what the viewer is receiving.
rendition: None,
available,
media_source_id: Some(effective_source_id),
play_session_id: Some(play_session_id),
needs_transcoding: kind.needs_transcoding(),
}
}
};
info!(
"[StreamSelection] {} → {:?} over {:?} (source bitrate {:?}, ceiling {})",
item_id,
selection.playback_kind,
selection.transport,
source_bitrate,
quality.label(),
);
Ok(selection)
}
}
// 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.
///
/// `PlaybackPositionTicks` is the server's resume position for the item, and the
/// only place it is published — Jellyfin has no per-item "resume position"
/// endpoint, so reading `UserData` *is* how a resume point is obtained.
///
/// TRACES: UR-019, UR-069 | DR-113, JA-013, 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,
}
}
}
/// Test-only shim over [`endpoints::get_items`].
///
/// The endpoint builders moved to `endpoints.rs` under DR-279. These wrappers
/// keep the existing requirement coverage (DR-116, DR-212, DR-257 and friends)
/// pointed at the production path rather than deleting it, and pin the *default*
/// capability shape — the URLs that shipped before the route table existed.
#[cfg(test)]
fn build_get_items_endpoint(
user_id: &str,
parent_id: &str,
options: Option<&GetItemsOptions>,
) -> String {
endpoints::get_items(&ServerCapabilities::assumed(), user_id, parent_id, options)
}
/// Test-only shim over [`endpoints::latest_items`]. See
/// [`build_get_items_endpoint`].
#[cfg(test)]
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
endpoints::latest_items(&ServerCapabilities::assumed(), user_id, parent_id, limit)
}
/// How many rows to ask the server for, given how many the row will show.
///
/// Collapsing only ever shrinks a listing, so a request for exactly the number
/// of cards the row shows can come back as a handful after one freshly-ripped
/// album folds its tracks together. Over-fetch and truncate after collapsing.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
fn latest_items_fetch_limit(limit: usize) -> usize {
limit.saturating_mul(3)
}
/// Collapse newly-added *tracks* into the album they belong to.
///
/// `GroupItems=true` asks Jellyfin to do this server-side, but it only groups a
/// track whose parent chain actually resolves a `MusicAlbum`, and older servers
/// ignore the parameter outright — so "Recently Added" still filled up with one
/// card per song of a single import. Grouping again here makes the row's shape
/// a property of this app rather than of the server it is talking to.
///
/// Rules: a track collapses only when it names an `album_id` (without one there
/// is no album to open, so a standalone track stays a track); if the server did
/// return the album row itself, that row wins and its tracks are dropped; the
/// album takes the position of the first of its tracks, so recency order
/// survives. Everything else — movies, episodes, folders — passes through
/// untouched.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-242, UT-243
fn collapse_tracks_into_albums(items: Vec<MediaItem>) -> Vec<MediaItem> {
use std::collections::HashSet;
// Albums the server already returned in their own right: their tracks are
// redundant, and the real row carries detail a stand-in cannot.
let server_albums: HashSet<String> = items
.iter()
.filter(|i| i.kind == crate::domain::MediaKind::Album)
.map(|i| i.id.clone())
.collect();
let mut seen_albums: HashSet<String> = HashSet::new();
let mut collapsed = Vec::with_capacity(items.len());
for item in items {
let album_id = match (&item.kind, &item.album_id) {
(crate::domain::MediaKind::Track, Some(id)) => id.clone(),
_ => {
collapsed.push(item);
continue;
}
};
if server_albums.contains(&album_id) || !seen_albums.insert(album_id.clone()) {
continue;
}
collapsed.push(album_from_track(&item, album_id));
}
collapsed
}
/// Build the album card a collapsed group of tracks stands for.
///
/// The track's own artwork tag is reused: Jellyfin serves an item's primary
/// image by id and treats the tag as a cache key, and an embedded-art track
/// carries the album cover anyway.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
fn album_from_track(track: &MediaItem, album_id: String) -> MediaItem {
MediaItem {
id: album_id,
name: 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: track.server_id.clone(),
parent_id: None,
library_id: track.library_id.clone(),
overview: None,
genres: track.genres.clone(),
production_year: track.production_year,
premiere_date: track.premiere_date.clone(),
community_rating: None,
official_rating: None,
// A track's duration says nothing about the album's, and its track
// number, album link and streams belong to the leaf alone.
runtime_ticks: None,
duration_ms: None,
primary_image_tag: track.primary_image_tag.clone(),
image_id: track.image_id.clone(),
backdrop_image_tags: track.backdrop_image_tags.clone(),
parent_backdrop_image_tags: track.parent_backdrop_image_tags.clone(),
album_id: None,
album_name: None,
album_artist: track.album_artist.clone(),
artists: track.artists.clone(),
artist_items: 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: None,
media_streams: None,
media_sources: None,
people: None,
}
}
/// Test-only shim over [`endpoints::next_up`]. See [`build_get_items_endpoint`].
#[cfg(test)]
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
endpoints::next_up(&ServerCapabilities::assumed(), user_id, series_id, limit)
}
/// Test-only shim over [`endpoints::favorites`]. See
/// [`build_get_items_endpoint`].
#[cfg(test)]
fn build_favorites_endpoint(
user_id: &str,
scope: SearchScope,
options: Option<&GetItemsOptions>,
) -> String {
endpoints::favorites(&ServerCapabilities::assumed(), user_id, scope, options)
}
// 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 into_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| {
let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
// Only a subtitle can be a sidecar; asked of anything
// else the question has no answer. TRACES: UR-020 |
// DR-176 | UT-168
let supports_external_delivery =
(kind == crate::domain::StreamKind::Subtitle).then(|| {
super::device_profile::subtitle_supports_external_delivery(
s.codec.as_deref(),
)
});
crate::repository::types::MediaStream {
kind,
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,
supports_external_delivery,
}
})
.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,
}
}
}
// ---------------------------------------------------------------------------
// PlaybackInfo negotiation
//
// These types were local to `get_playback_info`. They are module-scope now
// because `get_stream_selection` negotiates with the same request and has to
// read the same answer — restating the device profile in a second place is
// exactly how the cap used to leak (a negotiation authorising a direct play the
// URL builder then never got to constrain).
//
// TRACES: UR-079 | DR-225, DR-228
// ---------------------------------------------------------------------------
#[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<NegotiatedSource>,
play_session_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct NegotiatedSource {
pub id: String,
pub supports_direct_play: bool,
/// The container can be repackaged without re-encoding — a remux. Distinct
/// from direct play (which copies the file untouched) and from transcoding
/// (which spends encoder time); the distinction is what
/// [`PlaybackKind`](super::stream_selection::PlaybackKind) reports, and it is
/// the difference between "costs the server nothing" and "costs it a core".
#[serde(default)]
pub supports_direct_stream: bool,
pub supports_transcoding: bool,
pub transcoding_url: Option<String>,
/// The source's own bitrate, when the server reports one.
///
/// Fills the quality picker's "this rung is the same as Original" judgement
/// (DR-227). Absent for some containers — the sampled library has `avi`
/// files with no bitrate at all — in which case nothing is judged redundant
/// and every rung stays offered.
#[serde(default)]
pub bitrate: Option<i64>,
#[serde(default)]
pub media_streams: Vec<NegotiatedStream>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct NegotiatedStream {
#[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,
}
/// The direct-play / direct-stream / transcode decision.
///
/// A free function, and pure, so every branch can be tested against
/// `PlaybackInfo` fixtures without a server standing behind it.
///
/// Order matters: the two client-side overrides come first, because both
/// describe cases where the *server's* answer is right about the file and wrong
/// about what this app will do with it. The server judges the file against the
/// profile we sent; it cannot know that this renderer will be handed the audio
/// separately, or that the viewer has pinned a track the file does not default
/// to.
///
/// TRACES: UR-079 | DR-228 | UT-213
pub fn decide_playback_kind(
source: &NegotiatedSource,
audio_forces_transcode: bool,
audio_track_pinned: bool,
) -> PlaybackKind {
if audio_forces_transcode {
warn!(
"[StreamSelection] Server offered direct play for audio this renderer cannot decode — forcing a transcode"
);
return PlaybackKind::Transcode;
}
if audio_track_pinned {
// Not a defect in the server's answer — a different question. The
// file has one default track; the viewer asked for another.
return PlaybackKind::Transcode;
}
if source.supports_direct_play {
PlaybackKind::DirectPlay
} else if source.supports_direct_stream {
PlaybackKind::DirectStream
} else {
PlaybackKind::Transcode
}
}
#[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 = endpoints::user_views(&self.capabilities, &self.user_id);
let response: LibrariesResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|lib| {
Library::new(
lib.id,
lib.name,
lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
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 = endpoints::get_items(
&self.capabilities,
&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.into_media_item(self.user_id.clone()))
.collect(),
total_record_count: response.total_record_count,
})
}
/// Fetch one item with every field the detail and player screens need.
///
/// The `Fields=` list is the load-bearing part: Jellyfin omits these unless
/// they are named. `MediaStreams` is what makes the item's **audio and
/// subtitle tracks** knowable at all — there is no separate "tracks"
/// endpoint, so this single call is how the player learns which audio tracks
/// an item offers (`to_media_item` maps them, and the player's selector
/// filters them by `kind`). `People` is likewise how **cast and crew** are
/// obtained.
///
/// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = endpoints::item_detail(&self.capabilities, &self.user_id, item_id);
let item: JellyfinItem = self.get_json(&endpoint).await?;
let media_item = item.into_media_item(self.user_id.clone());
Ok(media_item)
}
/// Recently Added, one card per thing that was added.
///
/// The server is asked to group (`GroupItems=true`) *and* the answer is
/// grouped again here — see `collapse_tracks_into_albums` for why trusting
/// the server alone left the row full of one album's songs.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-244
async fn get_latest_items(
&self,
parent_id: &str,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(16);
let endpoint = endpoints::latest_items(
&self.capabilities,
&self.user_id,
parent_id,
Some(latest_items_fetch_limit(limit_val)),
);
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
let items = items
.into_iter()
.map(|item| item.into_media_item(self.user_id.clone()))
.collect();
let mut collapsed = collapse_tracks_into_albums(items);
collapsed.truncate(limit_val);
Ok(collapsed)
}
/// Continue Watching: the items this user has started and not finished.
///
/// `/Users/{uid}/Items/Resume` is the server-side answer to both "what goes
/// in the Continue Watching row" and "where was this left off" — each item
/// carries its own `UserData.PlaybackPositionTicks`, which is why `UserData`
/// is named in `Fields=` rather than left to the server's default field set.
///
/// TRACES: UR-019, UR-023 | IR-024, JA-013, JA-015
async fn get_resume_items(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let endpoint = endpoints::resume_items(
&self.capabilities,
&self.user_id,
limit.unwrap_or(16),
None,
parent_id,
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.into_media_item(self.user_id.clone()))
.collect())
}
/// "Next Up": the episode that follows the ones this user has finished,
/// per series — the Shows-scoped counterpart to Continue Watching.
///
/// TRACES: UR-023, UR-059 | IR-024, JA-014
async fn get_next_up_episodes(
&self,
series_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let endpoint = endpoints::next_up(&self.capabilities, &self.user_id, series_id, limit);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.into_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 = endpoints::played_items_by_date(
&self.capabilities,
&self.user_id,
"Audio",
fetch_limit,
"Descending",
None,
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
let items: Vec<MediaItem> = response
.items
.into_iter()
.map(|item| item.into_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_default().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 endpoint = endpoints::played_items_by_date(
&self.capabilities,
&self.user_id,
"MusicAlbum",
limit_val,
"Ascending",
parent_id,
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.into_media_item(self.user_id.clone()))
.collect())
}
/// Continue Watching, narrowed to movies — the home screen's movie row and
/// the movie library's own hero both want the unfinished films without the
/// episodes mixed in.
///
/// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let endpoint = endpoints::resume_items(
&self.capabilities,
&self.user_id,
limit.unwrap_or(16),
Some("Movie"),
None,
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.into_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 endpoint =
endpoints::genres(&self.capabilities, &self.user_id, "MusicAlbum", parent_id);
#[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)
}
/// Search every library the user can see.
///
/// `Recursive=true` with no `ParentId` is what makes this cross-library
/// rather than folder-scoped; a caller narrowing the search passes the item
/// types through `SearchOptions` (already expanded from an opaque
/// `SearchScope` on this side of the boundary).
///
/// TRACES: UR-008 | IR-010, JA-006
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 endpoint = endpoints::search(
&self.capabilities,
&self.user_id,
query,
limit,
options.and_then(|o| o.include_item_types).as_deref(),
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
items: response
.items
.into_iter()
.map(|item| item.into_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 (source, play_session_id) = self.negotiate_playback(item_id).await?;
// 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
);
}
// Name the tracks we are declining to have the server composite. Burn-in
// rules out remuxing the video, so a single image-based track can turn a
// free passthrough into a full re-encode; when that used to happen there
// was nothing in the log connecting the stall to the subtitle.
for stream in &source.media_streams {
if stream.stream_type == "Subtitle" {
if let Some(codec) = stream.codec.as_deref() {
if super::device_profile::subtitle_forces_burn_in(codec) {
info!(
" Subtitle index={} ({}) is image-based — not requested; the app renders text tracks itself rather than have the server burn it in (which would force a video re-encode)",
stream.index, 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 {
// The server started this job and named the session — adopt it, or a
// later quality switch / seek on this stream has no previous job to
// stop and ends up contending with the one currently playing.
if let Some(previous) = adopt_video_play_session(play_session_id.clone()) {
self.stop_transcode(&previous).await;
}
// The server built this URL from its *own* subtitle verdict, so it can
// hand back the burn-in the request above just declined. Strip it: the
// negotiated answer only holds for the stream we actually open.
//
// TRACES: UR-020, UR-004 | DR-176 | UT-168
format!(
"{}{}",
self.server_url,
super::device_profile::without_server_chosen_subtitle(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)
.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&ApiKey={}&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,
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={}&ApiKey={}&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 = endpoints::live_tv_channels(&self.capabilities, &self.user_id);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.into_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 = endpoints::channels(&self.capabilities, &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.into_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,
/// "No subtitle", for the same reason as everywhere else: omitting it
/// lets the server apply the channel's default track, and broadcast
/// subtitles are DVB bitmaps — deliverable only by burning them in,
/// which forces a full re-encode of a stream that is already tight.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
subtitle_stream_index: i32,
}
#[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 = endpoints::playback_info(&self.capabilities, 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: effective_streaming_quality()
.max_bitrate()
.unwrap_or(20_000_000),
subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
};
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 {
// As in `get_playback_info`: the server chose the subtitle in this
// URL, so decline it here too. TRACES: UR-020 | DR-176 | UT-168
Some(url) => format!(
"{}{}",
self.server_url,
super::device_profile::without_server_chosen_subtitle(&url)
),
None => format!(
"{}/Videos/{}/master.m3u8?ApiKey={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
self.server_url,
item_id,
self.access_token,
source.id,
source.live_stream_id.clone().unwrap_or_default(),
super::device_profile::playback_subtitle_stream_index(),
),
};
Ok(LiveStreamInfo {
stream_url,
play_session_id: response.play_session_id,
live_stream_id: source.live_stream_id,
media_source_id: Some(source.id),
// Both branches above produce a playlist: the server's own
// TranscodingUrl, or our `master.m3u8` fallback.
transport: Transport::Hls,
})
}
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(endpoints::sessions_playing(&self.capabilities), &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(
endpoints::sessions_playing_progress(&self.capabilities),
&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(
endpoints::sessions_playing_stopped(&self.capabilities),
&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 the `Authorization` header in
// download_bytes(). Do NOT add a query-parameter token here — some
// Jellyfin servers reject requests carrying one whose format they do not
// expect, and this request can already authenticate by header.
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 {
// `Stream.{format}` is the route, not a filename we get to choose:
// Jellyfin exposes the subtitle as
// `/Videos/{item}/{source}/Subtitles/{index}/Stream.{format}`, and
// stopping at the format alone matches no route and 404s. Every
// sideloaded subtitle failed to load on Android because of it, leaving
// ExoPlayer with no text tracks to select.
// TRACES: UR-020 | JA-008, DR-259 | UT-234
format!(
"{}/Videos/{}/{}/Subtitles/{}/Stream.{}",
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!("ApiKey={}", 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 = endpoints::favorite_item(&self.capabilities, &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 =
endpoints::favorites(&self.capabilities, &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.into_media_item(self.user_id.clone()))
.collect(),
total_record_count: response.total_record_count,
})
}
/// Un-favourite an item: the same `/Users/{uid}/FavoriteItems/{id}` resource
/// as [`Self::mark_favorite`], removed rather than posted. Written out by
/// hand rather than through `post_json` because it is the one favourite call
/// that needs `DELETE`.
///
/// TRACES: UR-017 | JA-018, DR-021
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
let request = self
.http_client
.client
.delete(&url)
.header("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 = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
let request = self
.http_client
.client
.delete(&url)
.header("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 = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
let request = self
.http_client
.client
.post(&url)
.header("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
}
/// A single Person item (actor, director, …) by id.
///
/// Jellyfin models people as ordinary items, so this is the plain item
/// endpoint rather than anything under `/Persons`; the cast entries returned
/// on an item's `People` field carry the ids this is called with.
///
/// TRACES: UR-035, UR-036 | IR-022, JA-030
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = endpoints::person(&self.capabilities, &self.user_id, person_id);
let item: JellyfinItem = self.get_json(&endpoint).await?;
Ok(item.into_media_item(self.user_id.clone()))
}
/// A person's filmography — every item they are credited on.
///
/// TRACES: UR-036 | IR-022, JA-031
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 endpoint = endpoints::items_by_person(
&self.capabilities,
&self.user_id,
person_id,
limit,
options
.as_ref()
.and_then(|o| o.include_item_types.as_deref()),
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
items: response
.items
.into_iter()
.map(|item| item.into_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 =
endpoints::similar_items(&self.capabilities, 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.into_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(endpoints::playlists(&self.capabilities), &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 = endpoints::playlist_as_item(&self.capabilities, playlist_id);
let url = format!("{}{}", self.server_url, endpoint);
let request = self
.http_client
.client
.delete(&url)
.header("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 = endpoints::playlist_as_item(&self.capabilities, 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 = endpoints::playlist_items(&self.capabilities, 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.into_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
);
// Encode each id, not the joined string: the comma separates the list.
let ids_param = item_ids
.iter()
.map(|id| urlencoding::encode(id).into_owned())
.collect::<Vec<_>>()
.join(",");
let endpoint = endpoints::playlist_items_add(&self.capabilities, 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
.iter()
.map(|id| urlencoding::encode(id).into_owned())
.collect::<Vec<_>>()
.join(",");
let endpoint =
endpoints::playlist_items_remove(&self.capabilities, playlist_id, &ids_param);
let url = format!("{}{}", self.server_url, endpoint);
let request = self
.http_client
.client
.delete(&url)
.header("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 =
endpoints::playlist_item_move(&self.capabilities, playlist_id, item_id, new_index);
self.post_json(&endpoint, &serde_json::json!({})).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::MediaKind;
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(),
)
}
/// The reported bug: on Android every subtitle track was inert — the menu
/// listed 42 languages and picking one changed nothing.
///
/// The cause is here rather than in the player. ExoPlayer sideloads each
/// subtitle as its own media source, and since media3 1.5 a sideloaded text
/// track only becomes a *track group* once its file has been fetched and
/// parsed. Every fetch 404ed, so `Tracks` carried no text group at all and
/// `setSubtitleTrack(1)` warned `available: 0` and dropped the request.
///
/// Jellyfin's route is `/Videos/{item}/{source}/Subtitles/{index}/Stream.{fmt}`
/// (verified against a live server: this shape answers 200, the one built
/// here answered 404). The `Stream.` segment is not decoration — without it
/// the path matches no route.
///
/// The old mock-based URL tests could not catch this: they asserted the
/// shape of a *test helper* that duplicated the format string, not of the
/// URL the app actually requests.
///
/// TRACES: UR-020 | JA-008, DR-259 | UT-234
#[test]
fn subtitle_url_uses_jellyfins_stream_route() {
let repo = create_test_repository();
assert_eq!(
repo.get_subtitle_url("item123", "source456", 2, "vtt"),
"https://test.server.com/Videos/item123/source456/Subtitles/2/Stream.vtt"
);
}
/// 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&ApiKey=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 leaked per-playback override would cap every later test's
// expectations without appearing anywhere in its setup.
// TRACES: UR-074, UR-079 | DR-226
clear_playback_quality_override();
}
}
/// 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)
.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)
.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}");
}
/// Transcoded video must be an HLS master playlist, not a progressive
/// `stream.mp4`: a progressive transcode of an HEVC source makes the server
/// convert the whole file before serving a byte, which presents as playback
/// that never starts. The chosen source and audio track ride along with it.
///
/// This is the surviving half of the old
/// `test_get_video_stream_url_returns_hls_with_position`, whose other half
/// asserted the `StartTimeTicks` that DR-181 removed — the position now
/// belongs to a seek after load, never to this URL, so the assertion for it
/// is gone rather than inverted (its inverse is UT-182's own test).
///
/// TRACES: UR-004 | DR-140, DR-181 | UT-130
#[tokio::test]
async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
let _fixture = QualityFixture::set(StreamingQuality::Original);
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", Some("source-1"), 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"));
assert!(!url.contains("stream.mp4"));
}
/// Resuming a transcoded video played nothing at all: every segment came back
/// `400`, hls.js exhausted its retries and gave up. Starting the same episode
/// from the beginning was fine.
///
/// Jellyfin builds each segment URI by echoing the *master playlist's* query
/// string into it (`CreateMainPlaylistRequest(… Request.QueryString …)`), and
/// its segment handler opens with
///
/// ```csharp
/// if ((streamingRequest.StartTimeTicks ?? 0) > 0)
/// throw new ArgumentException("StartTimeTicks is not allowed.");
/// ```
///
/// so a resume position put on the playlist is copied onto every
/// `hls1/main/N.ts` and makes all of them 400. `> 0` is exactly why playing
/// from the beginning survived.
///
/// HLS does not need the parameter: the playlist spans the whole item, and
/// asking for segment N *is* the seek — the server transcodes from there. So
/// the position never belongs in this URL; the player seeks after load. The
/// sibling progressive `/Audio/universal` builder is a different endpoint with
/// no segments, and keeps its `StartTimeTicks`.
///
/// TRACES: UR-004, UR-074 | DR-181 | UT-182
#[tokio::test]
async fn test_video_stream_url_never_carries_start_time_ticks() {
let _fixture = QualityFixture::set(StreamingQuality::Original);
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", Some("source-1"), Some(1))
.await
.unwrap();
assert!(
!url.contains("StartTimeTicks"),
"an HLS playlist must never carry StartTimeTicks — the server copies it \
onto every segment URI and then rejects each one with 400: {url}"
);
}
#[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)
.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}"
);
}
/// Jellyfin keys a transcode job by device *and* play session. Every stream
/// this app opened used the same `DeviceId` and no `PlaySessionId`, so
/// re-opening the same item — what a mid-playback quality switch, a
/// transcoded seek and an audio-track switch all do — handed the server a
/// second job it could not tell apart from the one still running. Observed
/// on-device: the new playlist is served, then `hls1/main/0.ts` 400s
/// intermittently while the two jobs fight over the same transcode path, and
/// playback stalls.
///
/// TRACES: UR-074 | DR-177 | UT-173
#[tokio::test]
async fn test_video_stream_url_carries_a_play_session_id() {
let _fixture = QualityFixture::set(StreamingQuality::Original);
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None)
.await
.unwrap();
assert!(
url.contains("PlaySessionId="),
"every transcode must be openable as its own job: {url}"
);
}
/// Naming no subtitle stream is not the same as asking for none. The server
/// fills the gap with the source's own default/forced track, and an
/// image-based one (PGS/DVD/DVB) can only be delivered by painting it into
/// the picture — the burn-in of DR-176, arriving through the URL rather than
/// through the negotiation.
///
/// The negotiation already sends the sentinel, but it is not what opens most
/// streams: a quality switch, a transcoded seek and an audio-track switch all
/// build this URL again, on their own. Saying it here too makes "no subtitle"
/// a property of the request instead of something inherited from whatever
/// session state the server happens to still hold.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
#[tokio::test]
async fn test_video_stream_url_asks_for_no_subtitle_stream() {
let _fixture = QualityFixture::set(StreamingQuality::Original);
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", Some("source-1"), Some(1))
.await
.unwrap();
assert!(
url.contains("SubtitleStreamIndex=-1"),
"the stream URL must ask for no subtitle, not leave the choice open: {url}"
);
}
/// The picker must not offer a subtitle the app cannot draw. Image-based
/// tracks are bitmaps: the only way to show one is to have the server
/// composite it, which is exactly what DR-176 stopped asking for. Selecting
/// one was therefore a control that could not do anything — so the verdict
/// travels with the stream, decided here where the codec vocabulary lives.
///
/// TRACES: UR-020 | DR-176 | UT-168
#[test]
fn test_media_streams_carry_whether_the_app_can_render_them() {
let item: JellyfinItem = serde_json::from_value(serde_json::json!({
"Id": "ep-1",
"Name": "Partings",
"Type": "Episode",
"MediaStreams": [
{ "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
{ "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
{ "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
{ "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
{ "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
],
}))
.expect("fixture must deserialize");
let streams = item.into_media_item("server-1".to_string()).media_streams;
let streams = streams.expect("the item carries streams");
let deliverable = |index: i32| {
streams
.iter()
.find(|s| s.index == index)
.unwrap_or_else(|| panic!("stream {index} missing"))
.supports_external_delivery
};
// The bitmap track the server would have had to burn in.
assert_eq!(deliverable(2), Some(false));
// Text: fetched as WebVTT and drawn by the app itself.
assert_eq!(deliverable(3), Some(true));
// A subtitle whose format the server did not name could be anything;
// offering it risks a dead control, so it is not offered.
assert_eq!(deliverable(4), Some(false));
// Meaningless for anything that is not a subtitle — and said as `None`
// rather than as a `false` a reader could mistake for a verdict.
assert_eq!(deliverable(0), None);
assert_eq!(deliverable(1), None);
}
/// The session id is what makes two opens *distinguishable*, so a fresh one
/// per open is the whole point — and the open must report the id it replaced
/// so the caller can stop that job instead of leaving it running.
///
/// TRACES: UR-074 | DR-177 | UT-173
#[test]
fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
let _lock = QUALITY_LOCK.lock_safe();
let (first, _) = begin_video_play_session();
let (second, replaced) = begin_video_play_session();
assert_ne!(first, second, "each open needs its own job identity");
assert_eq!(
replaced,
Some(first),
"the open must hand back the job it superseded so it can be stopped"
);
// A server-started transcode (PlaybackInfo answered with a TranscodingUrl)
// has to become the current session too — otherwise the first switch on
// that stream stops nothing and collides with what is playing.
let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
assert_eq!(replaced_by_adoption, Some(second));
let (_, after_adoption) = begin_video_play_session();
assert_eq!(
after_adoption,
Some("server-named-session".to_string()),
"the adopted job must be the one the next open stops"
);
}
#[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 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. That mock lived in
// `online_integration_test.rs`, which was never declared as a module and so
// never compiled — it was deleted for that reason, and this is the lesson it
// left: a mock that reimplements the builder asserts on itself, and passes
// just as happily when production is wrong. 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("ApiKey=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"));
}
/// UT-206 — the values this endpoint builder puts in the query string are
/// percent-encoded, like `Genres` and `SearchTerm` already are.
///
/// Unencoded, a value carrying `&` or `=` splits into an extra query
/// parameter (a parent id containing a space produced a malformed URL
/// outright), so the request the server sees is not the one that was built.
///
/// TRACES: UR-007 | DR-212 | UT-206
#[test]
fn test_get_items_endpoint_encodes_query_values() {
let endpoint = build_get_items_endpoint(
"u1",
"lib 1&Filters=IsFavorite",
Some(&GetItemsOptions {
include_item_types: Some(vec!["Movie&x=1".to_string()]),
sort_by: Some("Sort Name".to_string()),
sort_order: Some("Ascending&y=2".to_string()),
..Default::default()
}),
);
assert!(
endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
"{endpoint}"
);
assert!(
endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
"{endpoint}"
);
assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
assert!(
endpoint.contains("&SortOrder=Ascending%26y%3D2"),
"{endpoint}"
);
// Nothing smuggled in as a parameter of its own.
assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
assert!(!endpoint.contains("&x=1"), "{endpoint}");
assert!(!endpoint.contains("&y=2"), "{endpoint}");
}
/// The separators inside a list parameter must survive encoding: Jellyfin
/// splits `SortBy` and `IncludeItemTypes` on commas, and `hybrid.rs` sends
/// "ParentIndexNumber,IndexNumber,SortName" to order episodes.
///
/// TRACES: UR-007 | DR-212 | UT-206
#[test]
fn test_get_items_endpoint_keeps_list_separators() {
let endpoint = build_get_items_endpoint(
"u1",
"lib-1",
Some(&GetItemsOptions {
sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
..Default::default()
}),
);
assert!(
endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
"{endpoint}"
);
assert!(
endpoint.contains("&IncludeItemTypes=Movie,Series"),
"{endpoint}"
);
// A plain GUID parent id is unchanged by encoding.
assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
}
/// The reported bug: a Jellypod podcast listed its episodes alphabetically,
/// so "[Played] …" titles clumped at the top and a new episode landed
/// wherever its name happened to fall.
///
/// The cause was the frontend asking for `SortBy=SortName` on *every*
/// drill-down, which overrides the order the channel plugin itself would
/// have returned. Which order a container's children take is domain
/// knowledge, so the caller now names the container and the repository
/// answers with the sort: a channel folder is release-date-newest-first,
/// everything else keeps the name order it had.
///
/// TRACES: UR-007 | DR-257 | UT-229
#[test]
fn test_get_items_endpoint_orders_channel_folders_by_release_date() {
let podcast = build_get_items_endpoint(
"u1",
"podcast-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::ChannelFolder),
..Default::default()
}),
);
assert!(
podcast.contains("&SortBy=PremiereDate&SortOrder=Descending"),
"{podcast}"
);
// Every other container keeps the name order the app has always used.
let season = build_get_items_endpoint(
"u1",
"season-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::Season),
..Default::default()
}),
);
assert!(
season.contains("&SortBy=SortName&SortOrder=Ascending"),
"{season}"
);
// An explicit sort still wins — the default only fills a gap.
let explicit = build_get_items_endpoint(
"u1",
"podcast-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::ChannelFolder),
sort_by: Some("SortName".to_string()),
sort_order: Some("Ascending".to_string()),
..Default::default()
}),
);
assert!(
explicit.contains("&SortBy=SortName&SortOrder=Ascending"),
"{explicit}"
);
assert!(!explicit.contains("SortBy=PremiereDate"), "{explicit}");
// A caller that names no container is left alone, so the paths that
// rely on the server's own order (a playlist's stored order) keep it.
let unspecified = build_get_items_endpoint("u1", "lib-1", None);
assert!(!unspecified.contains("SortBy="), "{unspecified}");
}
/// 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"));
}
/// Build a `MediaItem` the way a real listing does — through the Jellyfin
/// payload — so the fixtures cannot drift from the parsed shape.
fn item_from_json(json: &str) -> MediaItem {
let parsed: JellyfinItem = serde_json::from_str(json).expect("fixture must parse");
parsed.into_media_item("srv".to_string())
}
fn track(id: &str, name: &str, album_id: Option<&str>) -> MediaItem {
let album = match album_id {
Some(a) => format!(r#""AlbumId": "{a}", "Album": "Kind of Blue","#),
None => String::new(),
};
item_from_json(&format!(
r#"{{
"Id": "{id}",
"Name": "{name}",
"Type": "Audio",
{album}
"ImageTags": {{"Primary": "art-{id}"}},
"AlbumArtist": "Miles Davis",
"Artists": ["Miles Davis"],
"IndexNumber": 1,
"RunTimeTicks": 1000
}}"#
))
}
/// A newly-imported album must read as *one* new album, not one new song
/// per track — even when the server hands back the raw leaves despite
/// `GroupItems=true` (older servers, and libraries whose tracks resolve no
/// album parent, ignore it).
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
#[test]
fn test_collapse_tracks_into_albums_shows_one_card_per_album() {
let movie = item_from_json(
r#"{"Id": "mov-1", "Name": "Heat", "Type": "Movie", "ImageTags": {"Primary": "art-mov"}}"#,
);
let items = vec![
track("trk-1", "So What", Some("alb-1")),
track("trk-2", "Blue in Green", Some("alb-1")),
movie,
track("trk-3", "Flamenco Sketches", Some("alb-1")),
];
let collapsed = collapse_tracks_into_albums(items);
assert_eq!(
collapsed.len(),
2,
"three tracks of one album plus a movie must read as two cards, got: {:?}",
collapsed.iter().map(|i| &i.name).collect::<Vec<_>>()
);
let album = &collapsed[0];
assert_eq!(album.id, "alb-1", "the card must open the album");
assert_eq!(album.name, "Kind of Blue");
assert_eq!(album.item_type, "MusicAlbum");
assert_eq!(album.kind, crate::domain::MediaKind::Album);
assert!(album.is_folder);
assert_eq!(album.album_artist.as_deref(), Some("Miles Davis"));
assert!(album.image_id.is_some(), "album card needs artwork");
// Track-only detail must not ride along on a container.
assert!(album.index_number.is_none());
assert!(album.album_id.is_none());
assert!(album.runtime_ticks.is_none());
// The movie keeps its place after the album its tracks stood in front of.
assert_eq!(collapsed[1].id, "mov-1");
}
/// When the server *did* group, its own album row wins — the tracks it also
/// returned must not add a second card for the same album.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-242
#[test]
fn test_collapse_prefers_the_album_row_the_server_returned() {
let album = item_from_json(
r#"{"Id": "alb-1", "Name": "Kind of Blue", "Type": "MusicAlbum", "IsFolder": true,
"Overview": "1959", "ImageTags": {"Primary": "art-alb"}}"#,
);
let items = vec![
album,
track("trk-1", "So What", Some("alb-1")),
track("trk-2", "Blue in Green", Some("alb-1")),
];
let collapsed = collapse_tracks_into_albums(items);
assert_eq!(collapsed.len(), 1, "one album, one card");
assert_eq!(collapsed[0].id, "alb-1");
assert_eq!(
collapsed[0].overview.as_deref(),
Some("1959"),
"the server's own album row must survive, not a track-built stand-in"
);
}
/// A track with no album has no container to collapse into, so it stays —
/// same reasoning that leaves movies alone.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-243
#[test]
fn test_collapse_leaves_a_standalone_track_alone() {
let items = vec![track("trk-1", "Field Recording", None)];
let collapsed = collapse_tracks_into_albums(items);
assert_eq!(collapsed.len(), 1);
assert_eq!(collapsed[0].id, "trk-1");
assert_eq!(collapsed[0].item_type, "Audio");
}
/// Collapsing shrinks the listing, so the request has to over-fetch: asking
/// for exactly 16 rows and then folding one 14-track album into them leaves
/// an almost empty "Recently Added".
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
#[test]
fn test_latest_items_over_fetches_before_collapsing() {
assert!(
latest_items_fetch_limit(16) > 16,
"must ask for more rows than the row shows"
);
let endpoint =
build_latest_items_endpoint("u1", "lib-1", Some(latest_items_fetch_limit(16)));
assert!(endpoint.contains(&format!("Limit={}", latest_items_fetch_limit(16))));
}
/// UT-190 — Next Up asks the server to leave resumable episodes out.
///
/// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
/// the *in-progress* episode as a series' next up — exactly the episode
/// `/Items/Resume` already returns, so Continue Watching and Next Up render
/// the same cards.
///
/// TRACES: UR-059 | DR-197, JA-036 | UT-190
#[test]
fn test_build_next_up_endpoint_excludes_resumable() {
let endpoint = build_next_up_endpoint("u1", None, Some(12));
assert!(
endpoint.contains("EnableResumable=false"),
"next up must exclude in-progress episodes, got: {}",
endpoint
);
assert!(endpoint.contains("UserId=u1"));
assert!(endpoint.contains("Limit=12"));
assert!(
!endpoint.contains("SeriesId"),
"no series filter when none was requested, got: {}",
endpoint
);
}
/// UT-191 — a per-series Next Up query keeps the series filter.
///
/// TRACES: UR-059 | DR-197 | UT-191
#[test]
fn test_build_next_up_endpoint_scopes_to_series() {
let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
assert!(endpoint.contains("SeriesId=series-a"));
assert!(endpoint.contains("EnableResumable=false"));
assert!(
endpoint.contains("Limit=16"),
"default limit, got: {}",
endpoint
);
}
/// 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.into_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.into_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.into_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, "");
}
// -----------------------------------------------------------------------
// Direct-play negotiation (DR-228)
//
// Fixtures rather than a live server, but the *shapes* are real: every one
// below was observed in a `PlaybackInfo` response from the development
// server while this was written. The measured yields those shapes produce —
// 7% direct play under the Linux h264-only profile, 85% under the Android
// profile — are recorded in the spec, not asserted here; what is asserted is
// that each shape maps to the kind it should.
// -----------------------------------------------------------------------
/// A `NegotiatedSource` fixture. Defaults describe the common case — a
/// source the server is happy to hand over untouched — so each test varies
/// only the field it is about.
fn source_fixture() -> NegotiatedSource {
NegotiatedSource {
id: "source-1".to_string(),
supports_direct_play: true,
supports_direct_stream: true,
supports_transcoding: true,
transcoding_url: None,
bitrate: Some(6_652_961),
media_streams: Vec::new(),
}
}
/// The whole point of DR-228: a source the server will serve untouched is
/// served untouched. Before this, every video play built an HLS transcode
/// URL regardless.
///
/// TRACES: UR-079 | DR-228 | UT-213
#[test]
fn test_a_supported_source_direct_plays() {
let source = source_fixture();
assert_eq!(
decide_playback_kind(&source, false, false),
PlaybackKind::DirectPlay
);
}
/// The server can remux without re-encoding. That is not a transcode and
/// must not be reported as one — the difference is a whole CPU core.
///
/// TRACES: UR-079 | DR-228 | UT-213
#[test]
fn test_a_remuxable_source_direct_streams() {
let source = NegotiatedSource {
supports_direct_play: false,
supports_direct_stream: true,
..source_fixture()
};
let kind = decide_playback_kind(&source, false, false);
assert_eq!(kind, PlaybackKind::DirectStream);
assert!(
!kind.needs_transcoding(),
"a remux costs no encoder time and must not be reported as transcoding"
);
}
/// An unsupported codec — the hevc that is ~80% of the sampled library,
/// under the Linux h264-only profile — transcodes.
///
/// TRACES: UR-079 | DR-228 | UT-213
#[test]
fn test_an_unsupported_source_transcodes() {
let source = NegotiatedSource {
supports_direct_play: false,
supports_direct_stream: false,
..source_fixture()
};
assert_eq!(
decide_playback_kind(&source, false, false),
PlaybackKind::Transcode
);
}
/// The override that exists because Jellyfin 10.11.5 ignores a
/// DirectPlayProfile's audio codec: it offers direct play for an E-AC-3
/// track the webview cannot decode, which renders as picture with no sound.
/// The client's verdict has to win over the server's.
///
/// TRACES: UR-079 | DR-228, DR-148 | UT-213
#[test]
fn test_undecodable_audio_overrides_the_servers_direct_play_offer() {
let source = source_fixture();
assert!(source.supports_direct_play, "the server said yes");
assert_eq!(
decide_playback_kind(&source, true, false),
PlaybackKind::Transcode,
"silent direct play is worse than a transcode"
);
}
/// A pinned audio track cannot be served by a file whose default track is a
/// different one. Honouring the viewer's choice means asking the server to
/// build a stream around it.
///
/// TRACES: UR-021, UR-079 | DR-228 | UT-213
#[test]
fn test_pinning_an_audio_track_forces_a_transcode() {
let source = source_fixture();
assert_eq!(
decide_playback_kind(&source, false, true),
PlaybackKind::Transcode
);
}
/// A ceiling below the source bitrate has to transcode even though the
/// codec is fine — that is the only way a cap is actually honoured. The
/// server enforces this via `MaxStaticBitrate` in the profile we send, so it
/// arrives here as `supports_direct_play: false`; this pins the mapping so a
/// future refactor cannot quietly direct-play past a cap.
///
/// TRACES: UR-074, UR-079 | DR-226, DR-228 | UT-213
#[test]
fn test_a_ceiling_below_the_source_bitrate_transcodes() {
// 6.65 Mbps source, 2 Mbps ceiling — the server refuses direct play.
let source = NegotiatedSource {
supports_direct_play: false,
supports_direct_stream: false,
bitrate: Some(6_652_961),
..source_fixture()
};
assert_eq!(
decide_playback_kind(&source, false, false),
PlaybackKind::Transcode
);
// And the ladder marks 2 Mbps as genuinely constraining for it.
let options =
crate::repository::stream_selection::quality_options_for_source(Some(6_652_961));
let two_mbps = options
.iter()
.find(|o| o.quality == StreamingQuality::Mbps2)
.expect("2 Mbps is on the ladder");
assert!(!two_mbps.exceeds_source);
}
/// Direct play wins over direct stream when both are on offer: copying the
/// file is strictly cheaper than repackaging it.
///
/// TRACES: UR-079 | DR-228 | UT-213
#[test]
fn test_direct_play_is_preferred_over_direct_stream() {
let source = source_fixture();
assert!(source.supports_direct_play && source.supports_direct_stream);
assert_eq!(
decide_playback_kind(&source, false, false),
PlaybackKind::DirectPlay
);
}
// -----------------------------------------------------------------------
// Per-playback quality ceiling (DR-226)
// -----------------------------------------------------------------------
/// The defect DR-226 exists to fix: the in-player picker documented itself
/// as a "this film, this connection" control but was implemented by writing
/// the device default, so one awkward film silently capped everything played
/// afterwards. The override must not touch the default.
///
/// TRACES: UR-074, UR-079 | DR-226 | UT-213
#[test]
fn test_a_playback_override_does_not_disturb_the_device_default() {
let _guard = QUALITY_LOCK.lock_safe();
set_streaming_quality(StreamingQuality::Mbps10);
clear_playback_quality_override();
assert_eq!(effective_streaming_quality(), StreamingQuality::Mbps10);
set_playback_quality_override(StreamingQuality::Kbps720);
assert_eq!(
effective_streaming_quality(),
StreamingQuality::Kbps720,
"the override governs the stream being opened now"
);
assert_eq!(
streaming_quality(),
StreamingQuality::Mbps10,
"but the durable default the Settings screen shows is untouched"
);
clear_playback_quality_override();
assert_eq!(
effective_streaming_quality(),
StreamingQuality::Mbps10,
"and dropping the override returns to it"
);
set_streaming_quality(StreamingQuality::Original);
}
/// A ceiling chosen for one film must not govern the next one — the
/// autoplayed next episode is the case that matters, since nobody reopens
/// the picker between episodes.
///
/// TRACES: UR-074, UR-079 | DR-226 | UT-213
#[test]
fn test_the_override_is_droppable_so_it_cannot_outlive_its_playback() {
let _guard = QUALITY_LOCK.lock_safe();
set_streaming_quality(StreamingQuality::Original);
set_playback_quality_override(StreamingQuality::Mbps1);
assert_eq!(playback_quality_override(), Some(StreamingQuality::Mbps1));
clear_playback_quality_override();
assert_eq!(playback_quality_override(), None);
assert_eq!(effective_streaming_quality(), StreamingQuality::Original);
}
}