Master allocated DR-224 and UT-211 while this branch was in flight — the third collision on this work. Everything here moves up by one: DR-224..236 become DR-225..237, UT-211..213 become UT-212..214. UR-079, UR-080 and IR-033 were still free and are unchanged. Mechanical, and matched on each row's own text rather than on its number, so a row cannot be shifted twice or the wrong one caught. Master's DR-224 (the background-audio toggle) and UT-211 are untouched.
4426 lines
174 KiB
Rust
4426 lines
174 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::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>,
|
|
}
|
|
|
|
impl OnlineRepository {
|
|
/// The signed-in user these requests are made as. Needed by the favourites
|
|
/// drain, which reads this user's queued rows. TRACES: UR-069 | DR-120
|
|
pub fn user_id(&self) -> &str {
|
|
&self.user_id
|
|
}
|
|
|
|
pub fn new(
|
|
http_client: Arc<HttpClient>,
|
|
server_url: String,
|
|
user_id: String,
|
|
access_token: String,
|
|
) -> Self {
|
|
Self {
|
|
http_client,
|
|
server_url,
|
|
user_id,
|
|
access_token,
|
|
connectivity: None,
|
|
}
|
|
}
|
|
|
|
/// Attach a connectivity reporter so server outcomes drive the reachability
|
|
/// state observed by the UI. See `report_outcome`.
|
|
pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
|
|
self.connectivity = Some(reporter);
|
|
self
|
|
}
|
|
|
|
/// Feed a request outcome into the connectivity monitor.
|
|
///
|
|
/// Classification (matches docs/architecture/07-connectivity.md):
|
|
/// - `Ok` / `Authentication` / `NotFound` / `Server` → the server answered,
|
|
/// so it is reachable → `report_success` (instant recovery).
|
|
/// - `Network` → connection-level failure → `report_network_failure`
|
|
/// (subject to the time-window debounce before going offline).
|
|
/// - `Database` → not a server signal → ignored.
|
|
async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
|
|
let Some(reporter) = &self.connectivity else {
|
|
return;
|
|
};
|
|
|
|
match result {
|
|
Ok(_)
|
|
| Err(RepoError::Authentication { .. })
|
|
| Err(RepoError::NotFound { .. })
|
|
| Err(RepoError::Server { .. }) => {
|
|
reporter.report_success().await;
|
|
}
|
|
Err(RepoError::Network { message }) => {
|
|
reporter.report_network_failure(Some(message.clone())).await;
|
|
}
|
|
Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
|
|
// Local-side errors (cache failure / already-offline) — not a
|
|
// statement about the server's reachability, so ignore them.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build authorization header
|
|
fn auth_header(&self) -> String {
|
|
HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
|
|
}
|
|
|
|
/// Download raw bytes from a URL using the shared authenticated HTTP client.
|
|
/// Used by thumbnail cache to download images with proper auth and connection reuse.
|
|
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
|
|
let request = self
|
|
.http_client
|
|
.client
|
|
.get(url)
|
|
.header("X-Emby-Authorization", self.auth_header())
|
|
.build()
|
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
|
|
|
let response = self
|
|
.http_client
|
|
.request_with_retry(request)
|
|
.await
|
|
.map_err(|e| format!("Download failed: {}", e))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
let body_preview = if body.len() > 200 {
|
|
&body[..200]
|
|
} else {
|
|
&body
|
|
};
|
|
return Err(format!("HTTP {} ({})", status, body_preview.trim()));
|
|
}
|
|
|
|
response
|
|
.bytes()
|
|
.await
|
|
.map(|b| b.to_vec())
|
|
.map_err(|e| format!("Failed to read bytes: {}", e))
|
|
}
|
|
|
|
/// Query the JRay plugin for the actors on screen at time `t` (seconds) in
|
|
/// the given item. Returns an empty list when the plugin isn't installed or
|
|
/// has no truth data for the item (HTTP 404), so callers can treat "no JRay"
|
|
/// and "nobody on screen" identically. Other failures propagate.
|
|
pub async fn get_jray_actors(
|
|
&self,
|
|
item_id: &str,
|
|
t: f64,
|
|
) -> Result<Vec<JRayActor>, RepoError> {
|
|
let endpoint = format!(
|
|
"/Plugins/JRay/Items/{}/jray?t={}",
|
|
urlencoding::encode(item_id),
|
|
t
|
|
);
|
|
match self.get_json::<JRayContext>(&endpoint).await {
|
|
Ok(context) => Ok(context.actors),
|
|
// No plugin / no truth data for this item — not an error to the user.
|
|
Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// Make authenticated GET request
|
|
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
|
|
// Fast-fail when connectivity is known-offline. Without this every request
|
|
// still runs the full HTTP retry/backoff cycle (~7s) before giving up,
|
|
// which stalls cache-miss paths and makes offline browsing feel janky.
|
|
// The offline recovery probe (connectivity monitor) flips us back to
|
|
// reachable the moment the server returns, so this never sticks.
|
|
if let Some(reporter) = &self.connectivity {
|
|
if !reporter.is_reachable().await {
|
|
return Err(RepoError::Offline);
|
|
}
|
|
}
|
|
|
|
let result = self.get_json_inner(endpoint).await;
|
|
self.report_outcome(&result).await;
|
|
result
|
|
}
|
|
|
|
async fn get_json_inner<T: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
endpoint: &str,
|
|
) -> Result<T, RepoError> {
|
|
let url = format!("{}{}", self.server_url, endpoint);
|
|
|
|
let request = self
|
|
.http_client
|
|
.client
|
|
.get(&url)
|
|
.header("X-Emby-Authorization", self.auth_header())
|
|
.build()
|
|
.map_err(|e| RepoError::Network {
|
|
message: format!("Failed to build request: {}", e),
|
|
})?;
|
|
|
|
let response = self
|
|
.http_client
|
|
.request_with_retry(request)
|
|
.await
|
|
.map_err(|e| RepoError::Network {
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
if status.as_u16() == 401 || status.as_u16() == 403 {
|
|
return Err(RepoError::Authentication {
|
|
message: format!("HTTP {}", status),
|
|
});
|
|
} else if status.as_u16() == 404 {
|
|
return Err(RepoError::NotFound {
|
|
message: "Resource not found".to_string(),
|
|
});
|
|
} else {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", status),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Get the response text first for better error reporting
|
|
let text = response.text().await.map_err(|e| RepoError::Server {
|
|
message: format!("Failed to read response: {}", e),
|
|
})?;
|
|
|
|
// Try to deserialize and log the raw JSON on error
|
|
serde_json::from_str(&text).map_err(|e| {
|
|
error!(
|
|
"[OnlineRepo] Failed to deserialize {} response: {}",
|
|
endpoint, e
|
|
);
|
|
error!(
|
|
"[OnlineRepo] Response body (first 1000 chars): {}",
|
|
if text.len() > 1000 {
|
|
&text[..1000]
|
|
} else {
|
|
&text
|
|
}
|
|
);
|
|
RepoError::Server {
|
|
message: format!("Failed to parse response: {}", e),
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Make authenticated POST request
|
|
async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
|
|
let result = self.post_json_inner(endpoint, body).await;
|
|
self.report_outcome(&result).await;
|
|
result
|
|
}
|
|
|
|
async fn post_json_inner<T: Serialize>(
|
|
&self,
|
|
endpoint: &str,
|
|
body: &T,
|
|
) -> Result<(), RepoError> {
|
|
let url = format!("{}{}", self.server_url, endpoint);
|
|
|
|
let request = self
|
|
.http_client
|
|
.client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("X-Emby-Authorization", self.auth_header())
|
|
.json(body)
|
|
.build()
|
|
.map_err(|e| RepoError::Network {
|
|
message: format!("Failed to build request: {}", e),
|
|
})?;
|
|
|
|
let response = self
|
|
.http_client
|
|
.request_with_retry(request)
|
|
.await
|
|
.map_err(|e| RepoError::Network {
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
if status.as_u16() == 401 || status.as_u16() == 403 {
|
|
return Err(RepoError::Authentication {
|
|
message: format!("HTTP {}", status),
|
|
});
|
|
} else {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", status),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Make authenticated POST request and return response
|
|
async fn post_json_response<T: Serialize, R: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
endpoint: &str,
|
|
body: &T,
|
|
) -> Result<R, RepoError> {
|
|
let result = self.post_json_response_inner(endpoint, body).await;
|
|
self.report_outcome(&result).await;
|
|
result
|
|
}
|
|
|
|
async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
endpoint: &str,
|
|
body: &T,
|
|
) -> Result<R, RepoError> {
|
|
let url = format!("{}{}", self.server_url, endpoint);
|
|
|
|
// Log request body for debugging
|
|
if let Ok(json) = serde_json::to_string_pretty(body) {
|
|
debug!("[HTTP] POST {}", endpoint);
|
|
debug!("[HTTP] Request body:\n{}", json);
|
|
}
|
|
|
|
let request = self
|
|
.http_client
|
|
.client
|
|
.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("X-Emby-Authorization", self.auth_header())
|
|
.json(body)
|
|
.build()
|
|
.map_err(|e| RepoError::Network {
|
|
message: format!("Failed to build request: {}", e),
|
|
})?;
|
|
|
|
let response = self
|
|
.http_client
|
|
.request_with_retry(request)
|
|
.await
|
|
.map_err(|e| RepoError::Network {
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
|
|
// Capture response body for error details
|
|
let error_body = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Failed to read error body".to_string());
|
|
error!("[HTTP] Error response ({}): {}", status, error_body);
|
|
|
|
if status.as_u16() == 401 || status.as_u16() == 403 {
|
|
return Err(RepoError::Authentication {
|
|
message: format!("HTTP {}: {}", status, error_body),
|
|
});
|
|
} else if status.as_u16() == 404 {
|
|
return Err(RepoError::NotFound {
|
|
message: format!("Resource not found: {}", error_body),
|
|
});
|
|
} else {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}: {}", status, error_body),
|
|
});
|
|
}
|
|
}
|
|
|
|
response.json().await.map_err(|e| RepoError::Server {
|
|
message: format!("Failed to parse response: {}", e),
|
|
})
|
|
}
|
|
|
|
/// 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("X-Emby-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![
|
|
("api_key", 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()),
|
|
("api_key", 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 = format!("/Items/{}/PlaybackInfo", urlencoding::encode(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={}&api_key={}&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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build the Jellyfin endpoint for a folder listing.
|
|
///
|
|
/// Extracted from `get_items` so the query it produces — in particular the
|
|
/// favourites filter — can be asserted without standing up an HTTP server.
|
|
///
|
|
/// TRACES: UR-007, UR-067 | DR-116 | UT-104
|
|
fn build_get_items_endpoint(
|
|
user_id: &str,
|
|
parent_id: &str,
|
|
options: Option<&GetItemsOptions>,
|
|
) -> String {
|
|
// Every value below is percent-encoded before it goes into the query
|
|
// string, the same way `Genres` and `SearchTerm` already are: these are
|
|
// values, not URL syntax, so a space or an `&` in one must not split it
|
|
// into another parameter.
|
|
//
|
|
// TRACES: UR-007 | DR-212 | UT-206
|
|
let mut endpoint = format!(
|
|
"/Users/{}/Items?ParentId={}",
|
|
user_id,
|
|
urlencoding::encode(parent_id)
|
|
);
|
|
|
|
if let Some(opts) = options {
|
|
if let Some(limit) = opts.limit {
|
|
endpoint.push_str(&format!("&Limit={}", limit));
|
|
}
|
|
if let Some(start_index) = opts.start_index {
|
|
endpoint.push_str(&format!("&StartIndex={}", start_index));
|
|
}
|
|
if let Some(types) = &opts.include_item_types {
|
|
// Encode each type, not the joined string: the comma is the
|
|
// list separator Jellyfin splits on.
|
|
let encoded: Vec<String> = types
|
|
.iter()
|
|
.map(|t| urlencoding::encode(t).into_owned())
|
|
.collect();
|
|
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
|
|
}
|
|
if let Some(sort_by) = &opts.sort_by {
|
|
// SortBy is likewise a comma-delimited list (`hybrid.rs` sends
|
|
// "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
|
|
let encoded: Vec<String> = sort_by
|
|
.split(',')
|
|
.map(|field| urlencoding::encode(field).into_owned())
|
|
.collect();
|
|
endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
|
|
}
|
|
if let Some(sort_order) = &opts.sort_order {
|
|
endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
|
|
}
|
|
if let Some(recursive) = opts.recursive {
|
|
endpoint.push_str(&format!("&Recursive={}", recursive));
|
|
}
|
|
if let Some(genres) = &opts.genres {
|
|
if !genres.is_empty() {
|
|
// Genre names may contain spaces/ampersands, so percent-encode each.
|
|
let encoded: Vec<String> = genres
|
|
.iter()
|
|
.map(|g| urlencoding::encode(g).into_owned())
|
|
.collect();
|
|
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
|
|
}
|
|
}
|
|
// TRACES: UR-067 | DR-116 | UT-104
|
|
if opts.favorites_only == Some(true) {
|
|
endpoint.push_str("&Filters=IsFavorite");
|
|
}
|
|
}
|
|
|
|
// Request image fields for list views (People only needed in get_item
|
|
// detail view). Genres is needed so cached items carry their genres,
|
|
// which lets the offline store derive genre lists + per-genre counts.
|
|
endpoint
|
|
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
|
|
endpoint
|
|
}
|
|
|
|
/// Build the Jellyfin endpoint for a "recently added" listing.
|
|
///
|
|
/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
|
|
/// `false`, which returns each newly-added *leaf* separately, so importing one
|
|
/// 14-track album pushed 14 rows into "recently added" and buried everything
|
|
/// else. With grouping on, the server collapses children into the container
|
|
/// that was added — an album appears once, while movies (which have no such
|
|
/// container) are unaffected.
|
|
///
|
|
/// Pulled out of `get_latest_items` so the query can be asserted without an
|
|
/// HTTP server, matching `build_favorites_endpoint`.
|
|
///
|
|
/// TRACES: UR-024, UR-034 | IR-024, JA-016
|
|
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
|
|
format!(
|
|
"/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
user_id,
|
|
parent_id,
|
|
limit.unwrap_or(16)
|
|
)
|
|
}
|
|
|
|
/// Build the Jellyfin endpoint for a Next Up listing.
|
|
///
|
|
/// `EnableResumable=false` is the point of this query: the server default is
|
|
/// `true`, which makes a partially-watched episode its own series' "next up" —
|
|
/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
|
|
/// end up showing the same cards. Next Up should only ever offer episodes the
|
|
/// viewer has not started. Servers predating the parameter ignore it, which is
|
|
/// why the frontend also drops in-progress entries (DR-197).
|
|
///
|
|
/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
|
|
/// HTTP server, matching `build_favorites_endpoint`.
|
|
///
|
|
/// TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191
|
|
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
|
|
let mut endpoint = format!(
|
|
"/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
user_id,
|
|
limit.unwrap_or(16)
|
|
);
|
|
|
|
if let Some(sid) = series_id {
|
|
endpoint.push_str(&format!("&SeriesId={}", sid));
|
|
}
|
|
|
|
endpoint
|
|
}
|
|
|
|
/// Build the Jellyfin endpoint for a favourites listing.
|
|
///
|
|
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
|
|
/// server. `scope` is expanded here — `SearchScope::All` yields `None`, and the
|
|
/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
|
|
/// union, which would silently drop every type nobody enumerated (see
|
|
/// `SearchScope::item_types`).
|
|
///
|
|
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
|
|
fn build_favorites_endpoint(
|
|
user_id: &str,
|
|
scope: SearchScope,
|
|
options: Option<&GetItemsOptions>,
|
|
) -> String {
|
|
let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
|
|
|
|
if let Some(types) = scope.item_types() {
|
|
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
|
|
}
|
|
|
|
// Jellyfin has no "date favourited", so name order is the only stable sort
|
|
// available; callers may still override it.
|
|
let sort_by = options
|
|
.and_then(|o| o.sort_by.as_deref())
|
|
.unwrap_or("SortName");
|
|
let sort_order = options
|
|
.and_then(|o| o.sort_order.as_deref())
|
|
.unwrap_or("Ascending");
|
|
endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
|
|
|
|
if let Some(limit) = options.and_then(|o| o.limit) {
|
|
endpoint.push_str(&format!("&Limit={}", limit));
|
|
}
|
|
if let Some(start_index) = options.and_then(|o| o.start_index) {
|
|
endpoint.push_str(&format!("&StartIndex={}", start_index));
|
|
}
|
|
|
|
endpoint
|
|
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
|
|
endpoint
|
|
}
|
|
|
|
// ImageTags from Jellyfin API - can be a HashMap with various image type keys
|
|
// We use a wrapper to extract just the Primary tag we need
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(untagged)]
|
|
enum ImageTags {
|
|
// Modern format: HashMap
|
|
Map(std::collections::HashMap<String, String>),
|
|
// Legacy/alternative format: structured
|
|
Structured {
|
|
#[serde(rename = "Primary")]
|
|
primary: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl ImageTags {
|
|
fn primary(&self) -> Option<String> {
|
|
match self {
|
|
ImageTags::Map(map) => map.get("Primary").cloned(),
|
|
ImageTags::Structured { primary } => primary.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct JellyfinMediaStream {
|
|
#[serde(rename = "Type")]
|
|
stream_type: String,
|
|
codec: Option<String>,
|
|
language: Option<String>,
|
|
display_title: Option<String>,
|
|
index: i32,
|
|
is_default: bool,
|
|
#[serde(default)]
|
|
is_forced: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct JellyfinMediaSource {
|
|
id: String,
|
|
name: String,
|
|
container: Option<String>,
|
|
size: Option<i64>,
|
|
bitrate: Option<i32>,
|
|
supports_direct_play: bool,
|
|
supports_direct_stream: bool,
|
|
supports_transcoding: bool,
|
|
direct_stream_url: Option<String>,
|
|
}
|
|
|
|
impl JellyfinItem {
|
|
fn 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 = format!("/Users/{}/Views", 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 = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
|
|
Ok(SearchResult {
|
|
items: response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.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 = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
|
|
|
|
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
|
let media_item = item.into_media_item(self.user_id.clone());
|
|
|
|
Ok(media_item)
|
|
}
|
|
|
|
async fn get_latest_items(
|
|
&self,
|
|
parent_id: &str,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, RepoError> {
|
|
let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
|
|
|
|
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
|
Ok(items
|
|
.into_iter()
|
|
.map(|item| item.into_media_item(self.user_id.clone()))
|
|
.collect())
|
|
}
|
|
|
|
/// 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 limit_str = limit.unwrap_or(16);
|
|
let mut endpoint = format!(
|
|
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
self.user_id, limit_str
|
|
);
|
|
|
|
if let Some(pid) = parent_id {
|
|
endpoint.push_str(&format!("&ParentId={}", pid));
|
|
}
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.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 = build_next_up_endpoint(&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 = format!(
|
|
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
self.user_id, fetch_limit
|
|
);
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
let items: Vec<MediaItem> = response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.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 mut endpoint = format!(
|
|
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
self.user_id, limit_val
|
|
);
|
|
|
|
if let Some(pid) = parent_id {
|
|
endpoint.push_str(&format!("&ParentId={}", pid));
|
|
}
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.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 limit_str = limit.unwrap_or(16);
|
|
let endpoint = format!(
|
|
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
self.user_id, limit_str
|
|
);
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.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 mut endpoint = format!(
|
|
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
|
|
self.user_id
|
|
);
|
|
|
|
if let Some(pid) = parent_id {
|
|
endpoint.push_str(&format!("&ParentId={}", pid));
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct GenresResponse {
|
|
items: Vec<JellyfinGenre>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct JellyfinGenre {
|
|
id: String,
|
|
name: String,
|
|
// Which count field Jellyfin populates for a genre under
|
|
// Fields=ItemCounts varies by server/version: scoped queries may
|
|
// fill AlbumCount, others only ChildCount. Read whichever is
|
|
// present so ranking still works. Absent on servers that ignore
|
|
// Fields=ItemCounts entirely, so all stay optional.
|
|
album_count: Option<u32>,
|
|
child_count: Option<u32>,
|
|
}
|
|
|
|
let response: GenresResponse = self.get_json(&endpoint).await?;
|
|
let genres: Vec<Genre> = response
|
|
.items
|
|
.into_iter()
|
|
.map(|g| Genre {
|
|
id: g.id,
|
|
name: g.name,
|
|
album_count: g.album_count.or(g.child_count),
|
|
})
|
|
.collect();
|
|
|
|
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
|
|
// TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
|
|
// see whether the server populates any count field. Remove once known.
|
|
log::warn!(
|
|
"get_genres: {} genres, {} carry counts. sample: {:?}",
|
|
genres.len(),
|
|
with_counts,
|
|
genres
|
|
.iter()
|
|
.take(8)
|
|
.map(|g| (g.name.as_str(), g.album_count))
|
|
.collect::<Vec<_>>()
|
|
);
|
|
|
|
Ok(genres)
|
|
}
|
|
|
|
/// 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 mut endpoint = format!(
|
|
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
|
|
self.user_id,
|
|
urlencoding::encode(query),
|
|
limit
|
|
);
|
|
|
|
if let Some(opts) = options {
|
|
if let Some(types) = opts.include_item_types {
|
|
let encoded_types = types
|
|
.iter()
|
|
.map(|t| urlencoding::encode(t).into_owned())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
|
|
}
|
|
}
|
|
|
|
// Request image fields for list views (plus Genres so cached items
|
|
// carry genres for offline genre lists/counts).
|
|
endpoint.push_str(
|
|
"&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
|
|
);
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(SearchResult {
|
|
items: response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.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&api_key={}&userId={}",
|
|
self.server_url,
|
|
item_id,
|
|
source.id,
|
|
self.access_token,
|
|
self.user_id
|
|
)
|
|
};
|
|
|
|
info!("Final stream URL: {}", stream_url);
|
|
|
|
Ok(PlaybackInfo {
|
|
media_source_id: source.id.clone(),
|
|
play_session_id,
|
|
stream_url,
|
|
direct_play: source.supports_direct_play && !audio_forces_transcode,
|
|
needs_transcoding: audio_forces_transcode
|
|
|| (!source.supports_direct_play && source.supports_transcoding),
|
|
})
|
|
}
|
|
|
|
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
|
|
// Construct direct audio stream URL
|
|
let url = format!(
|
|
"{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
|
|
self.server_url, item_id, self.user_id, self.access_token
|
|
);
|
|
Ok(url)
|
|
}
|
|
|
|
async fn get_audio_only_stream_url_for_video(
|
|
&self,
|
|
item_id: &str,
|
|
media_source_id: Option<&str>,
|
|
start_time_seconds: Option<f64>,
|
|
audio_stream_index: Option<i32>,
|
|
) -> Result<String, RepoError> {
|
|
self.build_audio_only_stream_url_for_video(
|
|
item_id,
|
|
media_source_id,
|
|
start_time_seconds,
|
|
audio_stream_index,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
|
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
|
|
// type "TvChannel" — playable via open_live_stream.
|
|
let endpoint = format!(
|
|
"/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
|
|
self.user_id
|
|
);
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.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 = format!("/Channels?UserId={}", self.user_id);
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
let total = response.total_record_count;
|
|
let items = response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.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 = format!("/Items/{}/PlaybackInfo", urlencoding::encode(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?api_key={}&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("/Sessions/Playing", &request).await
|
|
}
|
|
|
|
async fn report_playback_progress(
|
|
&self,
|
|
item_id: &str,
|
|
position_ticks: i64,
|
|
) -> Result<(), RepoError> {
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct PlaybackProgressRequest {
|
|
item_id: String,
|
|
position_ticks: i64,
|
|
is_paused: bool,
|
|
}
|
|
|
|
let request = PlaybackProgressRequest {
|
|
item_id: item_id.to_string(),
|
|
position_ticks,
|
|
is_paused: false,
|
|
};
|
|
|
|
self.post_json("/Sessions/Playing/Progress", &request).await
|
|
}
|
|
|
|
async fn report_playback_stopped(
|
|
&self,
|
|
item_id: &str,
|
|
position_ticks: i64,
|
|
) -> Result<(), RepoError> {
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct PlaybackStoppedRequest {
|
|
item_id: String,
|
|
position_ticks: i64,
|
|
}
|
|
|
|
let request = PlaybackStoppedRequest {
|
|
item_id: item_id.to_string(),
|
|
position_ticks,
|
|
};
|
|
|
|
self.post_json("/Sessions/Playing/Stopped", &request).await
|
|
}
|
|
|
|
fn get_image_url(
|
|
&self,
|
|
item_id: &str,
|
|
image_type: ImageType,
|
|
options: Option<ImageOptions>,
|
|
) -> String {
|
|
let mut url = format!(
|
|
"{}/Items/{}/Images/{}",
|
|
self.server_url,
|
|
item_id,
|
|
image_type.as_str()
|
|
);
|
|
|
|
// Authentication is handled by X-Emby-Authorization header in download_bytes()
|
|
// Do NOT include api_key here — some Jellyfin servers reject requests when
|
|
// api_key is present but the token doesn't match the expected format.
|
|
let mut params: Vec<String> = Vec::new();
|
|
|
|
if let Some(opts) = options {
|
|
if let Some(width) = opts.max_width {
|
|
params.push(format!("maxWidth={}", width));
|
|
}
|
|
if let Some(height) = opts.max_height {
|
|
params.push(format!("maxHeight={}", height));
|
|
}
|
|
if let Some(quality) = opts.quality {
|
|
params.push(format!("quality={}", quality));
|
|
}
|
|
if let Some(tag) = opts.tag {
|
|
params.push(format!("tag={}", tag));
|
|
}
|
|
}
|
|
|
|
if !params.is_empty() {
|
|
url.push('?');
|
|
url.push_str(¶ms.join("&"));
|
|
}
|
|
|
|
url
|
|
}
|
|
|
|
fn get_subtitle_url(
|
|
&self,
|
|
item_id: &str,
|
|
media_source_id: &str,
|
|
stream_index: i32,
|
|
format: &str,
|
|
) -> String {
|
|
format!(
|
|
"{}/Videos/{}/{}/Subtitles/{}/{}",
|
|
self.server_url, item_id, media_source_id, stream_index, format
|
|
)
|
|
}
|
|
|
|
/// TRACES: UR-071 | DR-123
|
|
fn get_video_download_url(
|
|
&self,
|
|
item_id: &str,
|
|
quality: &str,
|
|
media_source_id: Option<&str>,
|
|
source_audio_codec: Option<&str>,
|
|
) -> String {
|
|
// NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
|
|
// available (returns 404 on many server configs), which silently broke
|
|
// every movie/TV download. Use the progressive `stream.mp4` endpoint
|
|
// instead — it is always present and supports HTTP Range, which the
|
|
// download worker relies on for resume.
|
|
let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
|
|
let mut params = vec![format!("api_key={}", self.access_token)];
|
|
|
|
// Map the frontend quality preset to concrete transcode params. For
|
|
// "original" we request a direct static copy (no transcode) which is
|
|
// byte-range resumable; other presets ask the server to transcode.
|
|
//
|
|
// 🔴 It is `videoBitRate`/`audioBitRate` — **capital R**. Jellyfin binds
|
|
// query keys case-insensitively, so `maxHeight`/`videoCodec` casing is
|
|
// free, but `videoBitrate` (lowercase r) is a *different token*: it
|
|
// fails to bind, is silently dropped, and the requested cap vanishes
|
|
// with no error. That is why every "480p"/"720p" download came back at
|
|
// full original quality. See `Jellyfin.Api` BaseEncodingJobOptions.
|
|
//
|
|
// `allowVideoStreamCopy=false` forces a real re-encode. Without it the
|
|
// server may stream-copy the source when it already satisfies the cap —
|
|
// fine in itself, but it also means a mis-typed cap degrades silently.
|
|
// Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
|
|
// video copy is gated by `allowVideoStreamCopy`.
|
|
match quality {
|
|
"high" => {
|
|
params.push("videoBitRate=8000000".to_string());
|
|
params.push("maxHeight=1080".to_string());
|
|
params.push("audioBitRate=384000".to_string());
|
|
params.push("videoCodec=h264".to_string());
|
|
params.push("audioCodec=aac".to_string());
|
|
params.push("allowVideoStreamCopy=false".to_string());
|
|
}
|
|
"medium" => {
|
|
params.push("videoBitRate=4000000".to_string());
|
|
params.push("maxHeight=720".to_string());
|
|
params.push("audioBitRate=256000".to_string());
|
|
params.push("videoCodec=h264".to_string());
|
|
params.push("audioCodec=aac".to_string());
|
|
params.push("allowVideoStreamCopy=false".to_string());
|
|
}
|
|
"low" => {
|
|
params.push("videoBitRate=1500000".to_string());
|
|
params.push("maxHeight=480".to_string());
|
|
params.push("audioBitRate=128000".to_string());
|
|
params.push("videoCodec=h264".to_string());
|
|
params.push("audioCodec=aac".to_string());
|
|
params.push("allowVideoStreamCopy=false".to_string());
|
|
}
|
|
// "original" (and any unknown value) → direct, resumable copy —
|
|
// unless the audio in that copy is undecodable where the file will
|
|
// be played back. A download is watched with no server in reach, so
|
|
// it has to satisfy the same constraint DR-149 applies to streams:
|
|
// the webview `<video>` element renders video on both platforms and
|
|
// decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
|
|
// disk is what made a downloaded film play offline as picture with
|
|
// no sound while the same film had sound when streamed.
|
|
//
|
|
// Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
|
|
// h264 source's picture byte-for-byte, so "original" still means
|
|
// original quality, and no bitrate or resolution cap is added. A
|
|
// source the webview could not have rendered anyway (HEVC) is
|
|
// re-encoded to h264 as a side effect, which is the only form of it
|
|
// that would have played.
|
|
//
|
|
// The cost of the transcode is that the response is no longer
|
|
// range-resumable, which is exactly why this is decided per item
|
|
// rather than applied to every `original` download.
|
|
//
|
|
// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
|
_ => match source_audio_codec {
|
|
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
|
|
params.push("videoCodec=h264".to_string());
|
|
params.push("allowVideoStreamCopy=true".to_string());
|
|
params.push("audioCodec=aac".to_string());
|
|
params.push("audioBitRate=384000".to_string());
|
|
}
|
|
// Decodable, or unknown: an unknown codec must not provoke a
|
|
// transcode — that would burn server CPU on a guess for files
|
|
// that play perfectly well.
|
|
_ => params.push("Static=true".to_string()),
|
|
},
|
|
}
|
|
|
|
// Add media source ID if provided
|
|
if let Some(source_id) = media_source_id {
|
|
params.push(format!("mediaSourceId={}", source_id));
|
|
}
|
|
|
|
url.push('?');
|
|
url.push_str(¶ms.join("&"));
|
|
|
|
url
|
|
}
|
|
|
|
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
|
let endpoint = format!(
|
|
"/Users/{}/FavoriteItems/{}",
|
|
self.user_id,
|
|
urlencoding::encode(item_id)
|
|
);
|
|
self.post_json(&endpoint, &serde_json::json!({})).await
|
|
}
|
|
|
|
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
|
|
async fn get_favorites(
|
|
&self,
|
|
scope: SearchScope,
|
|
options: Option<GetItemsOptions>,
|
|
) -> Result<SearchResult, RepoError> {
|
|
let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
|
|
Ok(SearchResult {
|
|
items: response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.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 = format!(
|
|
"/Users/{}/FavoriteItems/{}",
|
|
self.user_id,
|
|
urlencoding::encode(item_id)
|
|
);
|
|
let url = format!("{}{}", self.server_url, endpoint);
|
|
|
|
let result = async {
|
|
let request = self
|
|
.http_client
|
|
.client
|
|
.delete(&url)
|
|
.header("X-Emby-Authorization", self.auth_header())
|
|
.build()
|
|
.map_err(|e| RepoError::Network {
|
|
message: format!("Failed to build request: {}", e),
|
|
})?;
|
|
|
|
let response = self
|
|
.http_client
|
|
.request_with_retry(request)
|
|
.await
|
|
.map_err(|e| RepoError::Network {
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", response.status()),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
self.report_outcome(&result).await;
|
|
result
|
|
}
|
|
|
|
/// `DELETE /Users/{userId}/PlayedItems/{itemId}` — Jellyfin's "mark
|
|
/// unplayed", which also zeroes the resume position. On a folder (series,
|
|
/// season) the server applies it recursively to the children.
|
|
///
|
|
/// TRACES: UR-064 | DR-106, JA-033
|
|
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
|
let endpoint = format!(
|
|
"/Users/{}/PlayedItems/{}",
|
|
self.user_id,
|
|
urlencoding::encode(item_id)
|
|
);
|
|
let url = format!("{}{}", self.server_url, endpoint);
|
|
|
|
let result = async {
|
|
let request = self
|
|
.http_client
|
|
.client
|
|
.delete(&url)
|
|
.header("X-Emby-Authorization", self.auth_header())
|
|
.build()
|
|
.map_err(|e| RepoError::Network {
|
|
message: format!("Failed to build request: {}", e),
|
|
})?;
|
|
|
|
let response = self
|
|
.http_client
|
|
.request_with_retry(request)
|
|
.await
|
|
.map_err(|e| RepoError::Network {
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", response.status()),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
self.report_outcome(&result).await;
|
|
result
|
|
}
|
|
|
|
/// `POST /Users/{userId}/PlayedItems/{itemId}` — the mirror image of
|
|
/// `clear_watch_history`.
|
|
///
|
|
/// TRACES: UR-025 | DR-131 | JA-035
|
|
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
|
|
let endpoint = format!(
|
|
"/Users/{}/PlayedItems/{}",
|
|
self.user_id,
|
|
urlencoding::encode(item_id)
|
|
);
|
|
let url = format!("{}{}", self.server_url, endpoint);
|
|
|
|
let result = async {
|
|
let request = self
|
|
.http_client
|
|
.client
|
|
.post(&url)
|
|
.header("X-Emby-Authorization", self.auth_header())
|
|
.header("Content-Length", "0")
|
|
.build()
|
|
.map_err(|e| RepoError::Network {
|
|
message: format!("Failed to build request: {}", e),
|
|
})?;
|
|
|
|
let response = self
|
|
.http_client
|
|
.request_with_retry(request)
|
|
.await
|
|
.map_err(|e| RepoError::Network {
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", response.status()),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
self.report_outcome(&result).await;
|
|
result
|
|
}
|
|
|
|
/// 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 = format!(
|
|
"/Users/{}/Items/{}",
|
|
self.user_id,
|
|
urlencoding::encode(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 mut endpoint = format!(
|
|
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
self.user_id, person_id, limit
|
|
);
|
|
|
|
// Add item type filtering if specified in options
|
|
if let Some(ref opts) = options {
|
|
if let Some(ref include_types) = opts.include_item_types {
|
|
if !include_types.is_empty() {
|
|
let types_param = include_types.join(",");
|
|
endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
|
|
}
|
|
}
|
|
}
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(SearchResult {
|
|
items: response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.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 = format!(
|
|
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
item_id, self.user_id, limit_str
|
|
);
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(SearchResult {
|
|
items: response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.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("/Playlists", &body).await?;
|
|
Ok(PlaylistCreatedResult { id: response.id })
|
|
}
|
|
|
|
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
|
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
|
|
let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
|
|
let url = format!("{}{}", self.server_url, endpoint);
|
|
|
|
let request = self
|
|
.http_client
|
|
.client
|
|
.delete(&url)
|
|
.header("X-Emby-Authorization", self.auth_header())
|
|
.build()
|
|
.map_err(|e| RepoError::Network {
|
|
message: format!("Failed to build request: {}", e),
|
|
})?;
|
|
|
|
let response = self
|
|
.http_client
|
|
.request_with_retry(request)
|
|
.await
|
|
.map_err(|e| RepoError::Network {
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", response.status()),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
|
|
info!(
|
|
"[OnlineRepo] Renaming playlist {} to '{}'",
|
|
playlist_id, name
|
|
);
|
|
let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
|
|
self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
|
|
.await
|
|
}
|
|
|
|
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
|
|
let endpoint = format!(
|
|
"/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
|
|
playlist_id, self.user_id
|
|
);
|
|
|
|
let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
|
|
debug!(
|
|
"[OnlineRepo] Got {} playlist items for {}",
|
|
response.items.len(),
|
|
playlist_id
|
|
);
|
|
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|pi| PlaylistEntry {
|
|
playlist_item_id: pi.playlist_item_id,
|
|
item: pi.item.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 = format!(
|
|
"/Playlists/{}/Items?Ids={}",
|
|
urlencoding::encode(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 = format!(
|
|
"/Playlists/{}/Items?EntryIds={}",
|
|
urlencoding::encode(playlist_id),
|
|
ids_param
|
|
);
|
|
let url = format!("{}{}", self.server_url, endpoint);
|
|
|
|
let request = self
|
|
.http_client
|
|
.client
|
|
.delete(&url)
|
|
.header("X-Emby-Authorization", self.auth_header())
|
|
.build()
|
|
.map_err(|e| RepoError::Network {
|
|
message: format!("Failed to build request: {}", e),
|
|
})?;
|
|
|
|
let response = self
|
|
.http_client
|
|
.request_with_retry(request)
|
|
.await
|
|
.map_err(|e| RepoError::Network {
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", response.status()),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn move_playlist_item(
|
|
&self,
|
|
playlist_id: &str,
|
|
item_id: &str,
|
|
new_index: u32,
|
|
) -> Result<(), RepoError> {
|
|
info!(
|
|
"[OnlineRepo] Moving item {} in playlist {} to index {}",
|
|
item_id, playlist_id, new_index
|
|
);
|
|
let endpoint = format!(
|
|
"/Playlists/{}/Items/{}/Move/{}",
|
|
playlist_id, item_id, new_index
|
|
);
|
|
self.post_json(&endpoint, &serde_json::json!({})).await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::utils::lock::MutexSafe;
|
|
use std::sync::Arc;
|
|
|
|
fn create_test_repository() -> OnlineRepository {
|
|
let http_config = crate::jellyfin::HttpConfig::default();
|
|
let http_client =
|
|
Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
|
|
OnlineRepository::new(
|
|
http_client,
|
|
"https://test.server.com".to_string(),
|
|
"test-user-id".to_string(),
|
|
"test-access-token".to_string(),
|
|
)
|
|
}
|
|
|
|
/// Build a repository wired to a real ConnectivityReporter so we can assert
|
|
/// how `report_outcome` classifies each `RepoError` into reachability.
|
|
/// (No app handle → event emission is a harmless no-op.)
|
|
fn create_test_repository_with_connectivity(
|
|
) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
|
|
let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
|
|
.expect("Failed to create HTTP client for monitor");
|
|
let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
|
|
let reporter = monitor.reporter();
|
|
let repo = create_test_repository().with_connectivity(reporter.clone());
|
|
(repo, reporter)
|
|
}
|
|
|
|
/// `report_outcome` is the seam between repository traffic and the
|
|
/// connectivity monitor. Verify each `RepoError` variant routes correctly:
|
|
/// - the server answering at all (Ok / 401 / 404 / 5xx) ⇒ reachable
|
|
/// - a network-level failure ⇒ marked unreachable (debounce reduced for test)
|
|
/// - local-side errors (Database / Offline) ⇒ no effect on reachability
|
|
///
|
|
/// @req-test: UR-002 - Access media when online or offline
|
|
/// @req-test: DR-013 - Repository pattern for online/offline data access
|
|
#[tokio::test]
|
|
async fn test_report_outcome_classifies_server_answered_as_reachable() {
|
|
let (repo, reporter) = create_test_repository_with_connectivity();
|
|
|
|
// Drive offline first so we can observe "recover to reachable".
|
|
for err in [
|
|
RepoError::Authentication {
|
|
message: "401".into(),
|
|
},
|
|
RepoError::NotFound {
|
|
message: "404".into(),
|
|
},
|
|
RepoError::Server {
|
|
message: "500".into(),
|
|
},
|
|
] {
|
|
reporter.mark_unreachable_for_test().await;
|
|
assert!(!reporter.is_reachable().await, "precondition: offline");
|
|
|
|
let result: Result<(), RepoError> = Err(err);
|
|
repo.report_outcome(&result).await;
|
|
|
|
assert!(
|
|
reporter.is_reachable().await,
|
|
"a server that answers should be reported reachable"
|
|
);
|
|
}
|
|
|
|
// Ok should also report reachable.
|
|
reporter.mark_unreachable_for_test().await;
|
|
let ok: Result<(), RepoError> = Ok(());
|
|
repo.report_outcome(&ok).await;
|
|
assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
|
|
}
|
|
|
|
/// Local-side errors must NOT flip reachability — they say nothing about the
|
|
/// server.
|
|
#[tokio::test]
|
|
async fn test_report_outcome_ignores_local_errors() {
|
|
let (repo, reporter) = create_test_repository_with_connectivity();
|
|
|
|
// Force offline, then a Database/Offline error must leave it offline
|
|
// (not falsely report reachable).
|
|
reporter.mark_unreachable_for_test().await;
|
|
for err in [
|
|
RepoError::Database {
|
|
message: "cache".into(),
|
|
},
|
|
RepoError::Offline,
|
|
] {
|
|
let result: Result<(), RepoError> = Err(err);
|
|
repo.report_outcome(&result).await;
|
|
assert!(
|
|
!reporter.is_reachable().await,
|
|
"local-side error must not change reachability"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// When connectivity is known-offline, `get_json` must fast-fail with
|
|
/// `RepoError::Offline` instead of running the full HTTP retry cycle (~7s).
|
|
/// This is what keeps offline browsing snappy. `test.server.com` is
|
|
/// unroutable, so if the guard were absent this would hang on retries; the
|
|
/// assertion returning promptly with `Offline` proves the short-circuit.
|
|
#[tokio::test]
|
|
async fn test_get_json_fast_fails_when_offline() {
|
|
let (repo, reporter) = create_test_repository_with_connectivity();
|
|
reporter.mark_unreachable_for_test().await;
|
|
assert!(!reporter.is_reachable().await, "precondition: offline");
|
|
|
|
let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
|
|
assert!(
|
|
matches!(result, Err(RepoError::Offline)),
|
|
"known-offline get_json should return Offline immediately, got {:?}",
|
|
result
|
|
);
|
|
}
|
|
|
|
/// A network error routes through the debounced path. A single failure stays
|
|
/// online (debounce window not yet elapsed).
|
|
#[tokio::test]
|
|
async fn test_report_outcome_network_error_is_debounced() {
|
|
let (repo, reporter) = create_test_repository_with_connectivity();
|
|
assert!(reporter.is_reachable().await, "starts online");
|
|
|
|
let result: Result<(), RepoError> = Err(RepoError::Network {
|
|
message: "timeout".into(),
|
|
});
|
|
repo.report_outcome(&result).await;
|
|
|
|
assert!(
|
|
reporter.is_reachable().await,
|
|
"a single network failure stays online (debounced)"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_audio_stream_url_formats_correctly() {
|
|
let repo = create_test_repository();
|
|
let item_id = "test-track-123";
|
|
|
|
let result = repo.get_audio_stream_url(item_id).await;
|
|
|
|
assert!(result.is_ok());
|
|
let url = result.unwrap();
|
|
assert_eq!(
|
|
url,
|
|
"https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
|
|
);
|
|
}
|
|
|
|
/// Serialises every test whose expectations depend on the process-wide
|
|
/// streaming ceiling, and restores the uncapped default afterwards — without
|
|
/// it, a capped test running concurrently changes what an uncapped one sees.
|
|
///
|
|
/// TRACES: UR-074 | DR-162
|
|
static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
|
|
struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
|
|
|
|
impl QualityFixture {
|
|
fn set(quality: StreamingQuality) -> Self {
|
|
let guard = QUALITY_LOCK.lock_safe();
|
|
set_streaming_quality(quality);
|
|
Self(guard)
|
|
}
|
|
}
|
|
|
|
impl Drop for QualityFixture {
|
|
fn drop(&mut self) {
|
|
set_streaming_quality(StreamingQuality::Original);
|
|
// A 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 in online_integration_test.rs used the correct
|
|
// `stream.mp4` endpoint while the real impl shipped `/Videos/{id}/download`,
|
|
// which returns 404 on real servers and silently broke every movie/TV
|
|
// download. Assert the real builder targets the resumable stream endpoint.
|
|
//
|
|
// @req-test: DR-013 - Repository pattern for online/offline data access
|
|
|
|
#[test]
|
|
fn test_video_download_url_uses_stream_not_download_endpoint() {
|
|
let repo = create_test_repository();
|
|
let url = repo.get_video_download_url("item123", "original", None, None);
|
|
|
|
// Must NOT use the /download endpoint (404 on real servers).
|
|
assert!(
|
|
!url.contains("/download"),
|
|
"download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
|
|
);
|
|
// Must use the progressive, range-resumable stream endpoint.
|
|
assert!(
|
|
url.contains("/Videos/item123/stream.mp4"),
|
|
"download URL must target /Videos/{{id}}/stream.mp4: {url}"
|
|
);
|
|
assert!(url.contains("api_key=test-access-token"), "url: {url}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_video_download_url_original_is_static_direct_copy() {
|
|
let repo = create_test_repository();
|
|
let url = repo.get_video_download_url("item123", "original", None, None);
|
|
|
|
// "original" must request a direct static copy (byte-range resumable),
|
|
// with no transcode params.
|
|
assert!(url.contains("Static=true"), "url: {url}");
|
|
assert!(
|
|
!url.contains("videoBitRate"),
|
|
"original must not transcode: {url}"
|
|
);
|
|
assert!(
|
|
!url.contains("maxHeight"),
|
|
"original must not transcode: {url}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_video_download_url_quality_presets_transcode() {
|
|
let repo = create_test_repository();
|
|
|
|
for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
|
|
let url = repo.get_video_download_url("item123", quality, None, None);
|
|
assert!(
|
|
url.contains("/Videos/item123/stream.mp4"),
|
|
"{quality} must use stream.mp4: {url}"
|
|
);
|
|
assert!(
|
|
url.contains("videoBitRate="),
|
|
"{quality} must set bitrate: {url}"
|
|
);
|
|
assert!(
|
|
url.contains(&format!("maxHeight={height}")),
|
|
"{quality} must cap height at {height}: {url}"
|
|
);
|
|
assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
|
|
// Transcoded presets must not also ask for a static copy.
|
|
assert!(
|
|
!url.contains("Static=true"),
|
|
"{quality} must not be Static: {url}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The bitrate params are spelled `videoBitRate`/`audioBitRate` — **capital
|
|
/// R**. Jellyfin binds query keys case-insensitively, so this is not a
|
|
/// casing preference: `videoBitrate` is a *different token* that fails to
|
|
/// bind and is silently discarded, taking the user's quality cap with it.
|
|
/// Nothing errors — the download just returns the full-size original, which
|
|
/// is exactly how this bug went unnoticed.
|
|
#[test]
|
|
fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
|
|
let repo = create_test_repository();
|
|
|
|
for quality in ["high", "medium", "low"] {
|
|
let url = repo.get_video_download_url("item123", quality, None, None);
|
|
|
|
assert!(
|
|
url.contains("videoBitRate="),
|
|
"{quality} must spell it videoBitRate (capital R): {url}"
|
|
);
|
|
assert!(
|
|
url.contains("audioBitRate="),
|
|
"{quality} must spell it audioBitRate (capital R): {url}"
|
|
);
|
|
|
|
// The lowercase-r spellings never bind — they must not appear at
|
|
// all, or the cap is silently dropped by the server.
|
|
assert!(
|
|
!url.contains("videoBitrate="),
|
|
"{quality} emits the unbindable lowercase-r spelling: {url}"
|
|
);
|
|
assert!(
|
|
!url.contains("audioBitrate="),
|
|
"{quality} emits the unbindable lowercase-r spelling: {url}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A correctly-spelled cap is still only *conditionally* honored: the server
|
|
/// may stream-copy the source when it already satisfies the cap. Video copy
|
|
/// is gated by `allowVideoStreamCopy` (NOT `enableAutoStreamCopy`, which
|
|
/// only governs audio), so the transcode presets must disable it to
|
|
/// guarantee a real re-encode at the requested bitrate.
|
|
#[test]
|
|
fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
|
|
let repo = create_test_repository();
|
|
|
|
for quality in ["high", "medium", "low"] {
|
|
let url = repo.get_video_download_url("item123", quality, None, None);
|
|
assert!(
|
|
url.contains("allowVideoStreamCopy=false"),
|
|
"{quality} must forbid video stream copy: {url}"
|
|
);
|
|
}
|
|
|
|
// "original" is a deliberate direct copy — it must NOT disable copying.
|
|
let original = repo.get_video_download_url("item123", "original", None, None);
|
|
assert!(
|
|
!original.contains("allowVideoStreamCopy=false"),
|
|
"original must remain a direct copy: {original}"
|
|
);
|
|
}
|
|
|
|
/// A downloaded file is played with no server in reach, so `original`
|
|
/// quality cannot mean "copy whatever the source holds" when the source
|
|
/// holds audio this device cannot decode.
|
|
///
|
|
/// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
|
|
/// track included, and video plays through the webview `<video>` element on
|
|
/// both platforms — which decodes none of them. Streaming already knows this
|
|
/// (DR-149 forces a transcode over the server's own direct-play offer); the
|
|
/// download path did not, so a downloaded film played offline as picture with
|
|
/// no sound while the very same film had sound when streamed.
|
|
///
|
|
/// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
|
#[test]
|
|
fn test_video_download_url_original_transcodes_undecodable_audio() {
|
|
let repo = create_test_repository();
|
|
|
|
for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
|
|
let url = repo.get_video_download_url("item123", "original", None, Some(codec));
|
|
assert!(
|
|
!url.contains("Static=true"),
|
|
"{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
|
|
);
|
|
assert!(
|
|
url.contains("audioCodec=aac"),
|
|
"{codec} must be re-encoded to aac on the way down: {url}"
|
|
);
|
|
// "Original" still has to mean original picture: the video stream is
|
|
// copied when it can be, so no bitrate or resolution cap appears.
|
|
assert!(
|
|
url.contains("allowVideoStreamCopy=true"),
|
|
"the video stream must still be copied where possible: {url}"
|
|
);
|
|
assert!(
|
|
!url.contains("videoBitRate") && !url.contains("maxHeight"),
|
|
"original must not degrade the picture to fix the audio: {url}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The converse, and the reason the policy is per-item rather than blanket:
|
|
/// audio that plays here keeps the byte-exact, range-resumable copy that the
|
|
/// download worker's resume depends on.
|
|
///
|
|
/// TRACES: UR-071 | DR-171 | UT-166
|
|
#[test]
|
|
fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
|
|
let repo = create_test_repository();
|
|
|
|
for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
|
|
let url = repo.get_video_download_url("item123", "original", None, Some(codec));
|
|
assert!(
|
|
url.contains("Static=true"),
|
|
"{codec} plays here — the download must stay a direct copy: {url}"
|
|
);
|
|
assert!(
|
|
!url.contains("audioCodec="),
|
|
"{codec} needs no transcode: {url}"
|
|
);
|
|
}
|
|
|
|
// Unknown codec: the policy only ever *adds* a transcode, so an item we
|
|
// could not look up behaves exactly as it did before.
|
|
let unknown = repo.get_video_download_url("item123", "original", None, None);
|
|
assert!(unknown.contains("Static=true"), "url: {unknown}");
|
|
}
|
|
|
|
/// The explicit quality presets already transcode audio to AAC, so the
|
|
/// policy has nothing to add — and must not start overriding a chosen cap.
|
|
///
|
|
/// TRACES: UR-071 | DR-171 | UT-166
|
|
#[test]
|
|
fn test_video_download_url_presets_ignore_the_audio_policy() {
|
|
let repo = create_test_repository();
|
|
|
|
for quality in ["high", "medium", "low"] {
|
|
let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
|
|
let without = repo.get_video_download_url("item123", quality, None, None);
|
|
assert_eq!(with, without, "{quality} must not vary with source audio");
|
|
assert!(with.contains("audioCodec=aac"), "url: {with}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_video_download_url_passes_media_source_id() {
|
|
let repo = create_test_repository();
|
|
let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
|
|
assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_jellyfin_item_deserialize_with_image_tags() {
|
|
// Test full JellyfinItem deserialization with ImageTags
|
|
let json = r#"{
|
|
"Id": "album123",
|
|
"Name": "Test Album",
|
|
"Type": "MusicAlbum",
|
|
"ImageTags": {"Primary": "tag123"},
|
|
"ArtistItems": [
|
|
{"Id": "artist1", "Name": "Artist One"},
|
|
{"Id": "artist2", "Name": "Artist Two"}
|
|
]
|
|
}"#;
|
|
|
|
let result: Result<JellyfinItem, _> = serde_json::from_str(json);
|
|
assert!(result.is_ok());
|
|
|
|
let item = result.unwrap();
|
|
assert_eq!(item.id, "album123");
|
|
assert_eq!(item.name, "Test Album");
|
|
assert_eq!(item.item_type, "MusicAlbum");
|
|
assert!(item.image_tags.is_some());
|
|
assert_eq!(
|
|
item.image_tags.unwrap().primary(),
|
|
Some("tag123".to_string())
|
|
);
|
|
}
|
|
|
|
/// UT-100 — the favourites endpoint asks the server for favourites, scoped.
|
|
///
|
|
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
|
|
#[test]
|
|
fn test_build_favorites_endpoint_scopes_and_filters() {
|
|
let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
|
|
assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
|
|
assert!(movies.contains("&IncludeItemTypes=Movie"));
|
|
// Jellyfin has no favourite timestamp, so name order is the default.
|
|
assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
|
|
// Hearts must render on the returned cards.
|
|
assert!(movies.contains("UserData"));
|
|
|
|
// Tv covers both the show and any individually favourited episode.
|
|
let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
|
|
assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
|
|
|
|
let music = build_favorites_endpoint("u1", SearchScope::Music, None);
|
|
assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
|
|
}
|
|
|
|
/// `All` must omit the type filter entirely rather than send a union, which
|
|
/// would silently drop every type nobody enumerated.
|
|
///
|
|
/// TRACES: UR-067 | DR-115 | UT-100
|
|
#[test]
|
|
fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
|
|
let all = build_favorites_endpoint("u1", SearchScope::All, None);
|
|
assert!(!all.contains("IncludeItemTypes"));
|
|
}
|
|
|
|
/// Paging and an explicit sort still reach the server.
|
|
///
|
|
/// TRACES: UR-067 | DR-115 | UT-100
|
|
#[test]
|
|
fn test_build_favorites_endpoint_honours_paging_and_sort() {
|
|
let endpoint = build_favorites_endpoint(
|
|
"u1",
|
|
SearchScope::All,
|
|
Some(&GetItemsOptions {
|
|
limit: Some(20),
|
|
start_index: Some(40),
|
|
sort_by: Some("Random".to_string()),
|
|
sort_order: Some("Descending".to_string()),
|
|
..Default::default()
|
|
}),
|
|
);
|
|
assert!(endpoint.contains("&Limit=20"));
|
|
assert!(endpoint.contains("&StartIndex=40"));
|
|
assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
|
|
}
|
|
|
|
/// UT-104 — the in-library favourites toggle reaches the server as
|
|
/// `Filters=IsFavorite`, and is absent unless asked for.
|
|
///
|
|
/// TRACES: UR-067 | DR-116 | UT-104
|
|
#[test]
|
|
fn test_get_items_endpoint_applies_favorites_only() {
|
|
let plain = build_get_items_endpoint("u1", "lib-1", None);
|
|
assert!(!plain.contains("Filters=IsFavorite"));
|
|
|
|
let filtered = build_get_items_endpoint(
|
|
"u1",
|
|
"lib-1",
|
|
Some(&GetItemsOptions {
|
|
favorites_only: Some(true),
|
|
include_item_types: Some(vec!["Movie".to_string()]),
|
|
..Default::default()
|
|
}),
|
|
);
|
|
assert!(filtered.contains("&Filters=IsFavorite"));
|
|
// Composes with the filters already there rather than replacing them.
|
|
assert!(filtered.contains("&IncludeItemTypes=Movie"));
|
|
assert!(filtered.contains("ParentId=lib-1"));
|
|
|
|
// Explicitly false is not a request to filter.
|
|
let off = build_get_items_endpoint(
|
|
"u1",
|
|
"lib-1",
|
|
Some(&GetItemsOptions {
|
|
favorites_only: Some(false),
|
|
..Default::default()
|
|
}),
|
|
);
|
|
assert!(!off.contains("Filters=IsFavorite"));
|
|
}
|
|
|
|
/// 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}");
|
|
}
|
|
|
|
/// A newly-added album must arrive as one entry, not one per track.
|
|
///
|
|
/// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
|
|
/// every new Audio track individually — so ripping a 14-track album filled
|
|
/// the whole "recently added" row with that one album. `GroupItems=true`
|
|
/// makes the server collapse children into their parent container.
|
|
#[test]
|
|
fn test_latest_items_endpoint_groups_children_into_containers() {
|
|
let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
|
|
|
|
assert!(
|
|
endpoint.contains("GroupItems=true"),
|
|
"latest items must be grouped so an album counts once, got: {}",
|
|
endpoint
|
|
);
|
|
assert!(endpoint.contains("ParentId=lib-1"));
|
|
assert!(endpoint.contains("Limit=16"));
|
|
}
|
|
|
|
/// UT-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);
|
|
}
|
|
}
|