feat(jellyfin): the protocol crate, verified against a live server

Stage 5's first half. Sibling of `qbz-plex` and deliberately shaped like
it, so `qbz-source`'s JellyfinSource will read like PlexSource:
frontend-agnostic, HTTP + DTOs, no cache schema and no settings store.

Three measured facts shape the file, and each one is a comment because
each one is a decision somebody could otherwise undo:

- `?static=true` is bit-perfect. Verified by md5 against the file on the
  server's own disk, not inferred from a content type. `is_direct_
  response` encodes the tell a transcode leaves — chunked with no
  Content-Length — so a server-side policy silently resampling a hi-res
  track is caught rather than played.
- Quality costs 45.8 s per 4924 tracks and there is no cheaper path.
  `Fields=MediaStreams` trims 29% of the bytes and saves nothing: the
  cost is server-side media-info hydration. The scan will own that
  knowingly, which is why `track_count` exists to give a progress
  denominator.
- Cover urls need no credentials, so unlike a Plex thumb they are STABLE
  and their cache key can be the url itself.

`stream_url` / `image_url` are free functions rather than methods. Two
reasons and both are load-bearing: `Source::artwork_token` may not touch
the network by contract, and a `reqwest::Client` cannot even be
CONSTRUCTED without a rustls provider — which a unit test does not have.

The mapper picks the audio stream BY TYPE, not by position: an embedded
cover rides in the same MediaStreams array as an `mjpeg` with BitDepth 8,
and taking [0] would badge a 24-bit FLAC as 8-bit. A lossy row keeps an
absent bit depth rather than defaulting to 16, which would badge every
MP3 as CD quality.

10 unit tests, plus 5 LIVE tests that skip unless QBZ_JELLYFIN_URL names
a server. The unit tests prove the JSON shape is understood; only the
live ones can prove the endpoint still exists, the token still opens it,
and the stream is still direct. Against Jellyfin 10.11.11: 5/5 — 500-row
page with 221 real bit depths of 4924 total, a Range request answered
206 with a `fLaC` bitstream, a tokenless cover, and a future-dated delta
returning zero.

cc
This commit is contained in:
vicrodh committed 2026-08-20 07:15:20 -06:00
1 parent 604f336e24
commit e578d8a54a
5 files changed
+1023

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
[package]
name = "qbz-jellyfin"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Jellyfin integration for QBZ: HTTP client, JSON metadata, bit-perfect stream URLs (frontend-agnostic)"
[dependencies]
# HTTP client for the Jellyfin server API.
reqwest = { workspace = true, features = ["json"] }
# Jellyfin speaks JSON end to end (Plex speaks XML — that is the whole
# difference between this crate's parsing and `qbz-plex`'s).
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
# reqwest is built with `rustls-tls-webpki-roots-no-provider`, so a
# `reqwest::Client` cannot be CONSTRUCTED until a process-level CryptoProvider
# is installed. The app does that in `qbz_app::install_crypto_provider`; a test
# binary has no app, so the live harness installs it itself (same shape as
# `qbz-offline-cache/tests/purchase_download_harness.rs`).
rustls = { version = "0.23", features = ["aws_lc_rs"] }
+763
View File
@@ -0,0 +1,763 @@
//! Jellyfin integration — the protocol half.
//!
//! Sibling of `qbz-plex`, and deliberately shaped like it so `qbz-source`'s
//! `JellyfinSource` reads like `PlexSource`. Frontend-agnostic: no Qt, no
//! settings store, no cache schema — this crate speaks HTTP and returns DTOs.
//!
//! Everything below was MEASURED against Jellyfin 10.11.11 on 2026-08-20; the
//! numbers and the field shapes live in
//! `qbz-nix-docs/qt-frontend/2026-08-20-jellyfin-subsonic/01-research.md`.
//!
//! # The three facts that shape this file
//!
//! **1. `?static=true` is bit-perfect, and it was verified by md5.** The
//! response bytes of `/Audio/{id}/stream?static=true` are byte-identical to the
//! file on disk (checked against the server's own filesystem, not inferred from
//! a content type). It answers with `Content-Length` and `Accept-Ranges: bytes`,
//! which is exactly what QBZ's progressive feeder needs.
//!
//! The transcode path is unmistakable when you hit it: it answers
//! `Transfer-Encoding: chunked` with **no `Content-Length`**. That is not a
//! footnote — it is a cheap runtime assertion, and [`is_direct_response`]
//! exists so a regression is caught rather than quietly resampled. QBZ never
//! asks for a transcode and must never accept one.
//!
//! **2. Quality arrives with the listing, but it is not free.** `BitDepth` and
//! `SampleRate` live in `MediaSources[].MediaStreams[]`, which requires
//! `Fields=MediaSources`. Measured on a 500-item page: 0.25 s without it, 4.56 s
//! with. Full sweep of 4924 tracks: **45.8 s**. `Fields=MediaStreams` trims 29 %
//! of the bytes and saves nothing — the cost is server-side media-info
//! hydration, not transfer. There is no cheaper path, so the scan owns that cost
//! deliberately rather than discovering it.
//!
//! **3. Artwork needs no credentials.** `/Items/{id}/Images/Primary` answers
//! 200 unauthenticated. Unlike Plex, whose thumb url has to be re-tokenized on
//! every pass, a Jellyfin cover url is STABLE — so its cache key and its fetch
//! url are the same string, and it can be memoized forever.
//!
//! # What is deliberately absent
//!
//! No writes. No favourites, no playlists, no playback reporting. Read + play
//! is the 2.1 scope; both are supported by the server and neither is exercised
//! by anything today, so shipping them untested would be inventing surface.
use std::time::Duration;
use serde::Deserialize;
/// Matches `qbz-plex`'s client budget: a hung request on a captive portal must
/// not pin a caller indefinitely. The STREAM is never fetched through this
/// client — the player's feeder owns that, with no deadline, because a hi-res
/// FLAC over a slow link legitimately outlives any probe budget.
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
/// Page size for the library sweep. 500 is the measured sweet spot: the
/// per-request cost is dominated by server-side media-info hydration, so fewer,
/// larger pages win, and 500 items with `MediaSources` is ~1.5 MB of JSON —
/// large enough to amortise, small enough not to spike RSS.
pub const PAGE_SIZE: u32 = 500;
/// Cover size this client requests, app-wide. One size is deliberate, for the
/// same reason `artwork_qt::PLEX_THUMB_PX` is: the cache key is the url, so a
/// second size means a second download of the same cover.
pub const IMAGE_PX: u32 = 256;
/// The larger tier, for hero / immersive slots only.
pub const IMAGE_PX_LARGE: u32 = 1024;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Anything that can go wrong talking to a Jellyfin server.
///
/// A plain `String` would have done what `qbz-plex` does, but the caller has a
/// real decision to make on `Unauthorized` (re-authenticate) that it cannot
/// make by matching on prose.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JellyfinError {
/// The request never completed (DNS, refused, timed out).
Transport(String),
/// 401/403 — the token is gone or the user was disabled.
Unauthorized,
/// Any other non-success status.
Status(u16),
/// The body was not the shape this client expects.
Decode(String),
/// The server answered, and the answer is "no such thing".
NotFound(String),
}
impl std::fmt::Display for JellyfinError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JellyfinError::Transport(e) => write!(f, "jellyfin request failed: {e}"),
JellyfinError::Unauthorized => write!(f, "jellyfin rejected the credentials"),
JellyfinError::Status(s) => write!(f, "jellyfin answered {s}"),
JellyfinError::Decode(e) => write!(f, "jellyfin response not understood: {e}"),
JellyfinError::NotFound(what) => write!(f, "jellyfin has no {what}"),
}
}
}
impl std::error::Error for JellyfinError {}
type Result<T> = std::result::Result<T, JellyfinError>;
// ---------------------------------------------------------------------------
// DTOs — only the fields QBZ consumes
// ---------------------------------------------------------------------------
/// `GET /System/Info/Public` — the unauthenticated probe behind "test
/// connection". It also validates a URL before the user is asked for a
/// password, which is the difference between "wrong address" and "wrong
/// password" in the settings panel.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ServerInfo {
pub server_name: String,
pub version: String,
pub id: String,
#[serde(default)]
pub startup_wizard_completed: bool,
}
/// What `POST /Users/AuthenticateByName` hands back.
#[derive(Debug, Clone)]
pub struct Session {
pub access_token: String,
pub user_id: String,
pub user_name: String,
pub server_id: String,
}
/// One music library ("view"), from `GET /Users/{uid}/Views`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MusicLibrary {
pub id: String,
pub name: String,
}
/// One audio item, flattened out of the `/Items` envelope into the shape the
/// cache stores.
///
/// Field names are QBZ's, not Jellyfin's: this is the boundary where the
/// vendor's vocabulary stops.
#[derive(Debug, Clone, PartialEq)]
pub struct JellyfinTrack {
/// The item id — the ONLY identifier. Everything else is display data.
pub id: String,
pub title: String,
pub artist: String,
pub album_artist: String,
pub album: String,
/// The parent `MusicAlbum` item id. Present on every row measured (0 of
/// 4924 lacked it), and it is what groups a library into albums.
pub album_id: String,
/// `None` on 75 of 4924 measured rows — ordering must tolerate it.
pub track_number: Option<u32>,
pub disc_number: Option<u32>,
pub duration_ms: u64,
pub year: Option<u32>,
pub genre: Option<String>,
/// Container as the server names it (`flac`, `mp3`, `ape`).
pub container: String,
pub codec: Option<String>,
/// `None` for lossy codecs — MP3 reports no bit depth. Subsonic reports 0
/// for the same case; both mean "not applicable" and the mapper folds them.
pub bit_depth: Option<u32>,
pub sample_rate_hz: Option<u32>,
pub channels: Option<u32>,
pub bitrate_bps: Option<u32>,
/// The album's primary-image tag. Absent on 1053 of 4924 measured rows
/// (21 %) — a blank cover is the common case here, not an edge case.
pub album_image_tag: Option<String>,
/// The server's own path. Real, unlike Subsonic's synthesised one, but QBZ
/// never opens it: the file lives on the server. Kept for diagnostics only.
pub server_path: Option<String>,
}
impl JellyfinTrack {
/// Jellyfin counts in 100 ns ticks.
fn ticks_to_ms(ticks: i64) -> u64 {
(ticks.max(0) as u64) / 10_000
}
}
// --- wire shapes (private) --------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct ItemsEnvelope<T> {
items: Vec<T>,
total_record_count: u64,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct ViewDto {
id: String,
name: String,
#[serde(default)]
collection_type: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AuthDto {
access_token: String,
server_id: String,
user: AuthUserDto,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AuthUserDto {
id: String,
name: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AudioDto {
id: String,
#[serde(default)]
name: String,
#[serde(default)]
album: Option<String>,
#[serde(default)]
album_id: Option<String>,
#[serde(default)]
album_artist: Option<String>,
#[serde(default)]
artists: Vec<String>,
#[serde(default)]
index_number: Option<u32>,
#[serde(default)]
parent_index_number: Option<u32>,
#[serde(default)]
run_time_ticks: Option<i64>,
#[serde(default)]
production_year: Option<u32>,
#[serde(default)]
genres: Vec<String>,
#[serde(default)]
container: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
album_primary_image_tag: Option<String>,
#[serde(default)]
media_sources: Vec<MediaSourceDto>,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct MediaSourceDto {
#[serde(default)]
media_streams: Vec<MediaStreamDto>,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct MediaStreamDto {
#[serde(default)]
#[serde(rename = "Type")]
kind: String,
#[serde(default)]
codec: Option<String>,
#[serde(default)]
bit_depth: Option<u32>,
#[serde(default)]
sample_rate: Option<u32>,
#[serde(default)]
channels: Option<u32>,
#[serde(default)]
bit_rate: Option<u32>,
}
impl AudioDto {
fn into_track(self) -> JellyfinTrack {
// The FIRST audio stream. A cover embedded as `EmbeddedImage` shows up
// in the same array, which is why this filters by type rather than
// taking `[0]` — that would read a JPEG's 8-bit depth as the audio's.
let audio = self
.media_sources
.into_iter()
.next()
.map(|ms| ms.media_streams)
.unwrap_or_default()
.into_iter()
.find(|s| s.kind == "Audio");
let artist = self.artists.first().cloned().unwrap_or_default();
let album_artist = self.album_artist.clone().unwrap_or_else(|| artist.clone());
JellyfinTrack {
id: self.id,
title: self.name,
artist: if artist.is_empty() {
album_artist.clone()
} else {
artist
},
album_artist,
album: self.album.unwrap_or_default(),
album_id: self.album_id.unwrap_or_default(),
track_number: self.index_number,
disc_number: self.parent_index_number,
duration_ms: JellyfinTrack::ticks_to_ms(self.run_time_ticks.unwrap_or(0)),
year: self.production_year,
genre: self.genres.into_iter().next(),
container: self.container.unwrap_or_default(),
codec: audio.as_ref().and_then(|a| a.codec.clone()),
// A lossy codec reports no bit depth. Preserved as `None` rather
// than defaulted to 16, which would badge an MP3 as CD quality.
bit_depth: audio.as_ref().and_then(|a| a.bit_depth),
sample_rate_hz: audio.as_ref().and_then(|a| a.sample_rate),
channels: audio.as_ref().and_then(|a| a.channels),
bitrate_bps: audio.as_ref().and_then(|a| a.bit_rate),
album_image_tag: self.album_primary_image_tag,
server_path: self.path,
}
}
}
// ---------------------------------------------------------------------------
// Client
// ---------------------------------------------------------------------------
/// A connected Jellyfin server.
///
/// Holds no credentials store and no cache — those belong to the frontend and
/// to `qbz-source` respectively (ADR-006).
#[derive(Debug, Clone)]
pub struct JellyfinClient {
http: reqwest::Client,
base: String,
token: String,
user_id: String,
}
/// Normalise a user-typed address: strip a trailing slash, default the scheme
/// to http (a LAN server is the overwhelming case and typing `http://` is the
/// most common omission).
pub fn normalize_base_url(input: &str) -> String {
let s = input.trim().trim_end_matches('/');
if s.is_empty() {
return String::new();
}
if s.starts_with("http://") || s.starts_with("https://") {
s.to_string()
} else {
format!("http://{s}")
}
}
/// The `Authorization` header every Jellyfin client must send, with or without
/// a token. `DeviceId` must be STABLE across runs — Jellyfin keys its session
/// list on it, and a fresh id per launch litters the server's devices page.
fn auth_header(device_id: &str, token: Option<&str>) -> String {
let mut h = format!(
r#"MediaBrowser Client="QBZ", Device="{}", DeviceId="{}", Version="{}""#,
std::env::consts::OS,
device_id,
env!("CARGO_PKG_VERSION"),
);
if let Some(t) = token {
h.push_str(&format!(r#", Token="{t}""#));
}
h
}
fn client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(HTTP_TIMEOUT)
.build()
.map_err(|e| JellyfinError::Transport(e.to_string()))
}
fn check(status: reqwest::StatusCode) -> Result<()> {
if status.is_success() {
return Ok(());
}
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(JellyfinError::Unauthorized);
}
Err(JellyfinError::Status(status.as_u16()))
}
/// `GET /System/Info/Public` — no credentials required.
///
/// The settings panel's "test connection": it separates "wrong address" from
/// "wrong password" BEFORE the user is asked for one.
pub async fn probe(base_url: &str) -> Result<ServerInfo> {
let base = normalize_base_url(base_url);
let resp = client()?
.get(format!("{base}/System/Info/Public"))
.send()
.await
.map_err(|e| JellyfinError::Transport(e.to_string()))?;
check(resp.status())?;
resp.json::<ServerInfo>()
.await
.map_err(|e| JellyfinError::Decode(e.to_string()))
}
/// `POST /Users/AuthenticateByName`.
pub async fn authenticate(
base_url: &str,
device_id: &str,
username: &str,
password: &str,
) -> Result<Session> {
let base = normalize_base_url(base_url);
let resp = client()?
.post(format!("{base}/Users/AuthenticateByName"))
.header("Authorization", auth_header(device_id, None))
.json(&serde_json::json!({ "Username": username, "Pw": password }))
.send()
.await
.map_err(|e| JellyfinError::Transport(e.to_string()))?;
check(resp.status())?;
let dto: AuthDto = resp
.json()
.await
.map_err(|e| JellyfinError::Decode(e.to_string()))?;
Ok(Session {
access_token: dto.access_token,
user_id: dto.user.id,
user_name: dto.user.name,
server_id: dto.server_id,
})
}
impl JellyfinClient {
/// Build a client over an EXISTING token (the stored-credentials path).
pub fn new(base_url: &str, token: &str, user_id: &str) -> Result<Self> {
Ok(Self {
http: client()?,
base: normalize_base_url(base_url),
token: token.to_string(),
user_id: user_id.to_string(),
})
}
pub fn base_url(&self) -> &str {
&self.base
}
async fn get_json<T: for<'de> Deserialize<'de>>(&self, path_and_query: &str) -> Result<T> {
let resp = self
.http
.get(format!("{}{}", self.base, path_and_query))
.header("X-Emby-Token", &self.token)
.send()
.await
.map_err(|e| JellyfinError::Transport(e.to_string()))?;
check(resp.status())?;
resp.json::<T>()
.await
.map_err(|e| JellyfinError::Decode(e.to_string()))
}
/// The user's MUSIC libraries (`CollectionType == "music"`).
///
/// A server also exposes a `playlists` view; it is filtered out here rather
/// than by the caller, because "which of these is music" is a question
/// about Jellyfin, and this crate is where Jellyfin's vocabulary lives.
pub async fn music_libraries(&self) -> Result<Vec<MusicLibrary>> {
let env: ItemsEnvelope<ViewDto> = self
.get_json(&format!("/Users/{}/Views", self.user_id))
.await?;
Ok(env
.items
.into_iter()
.filter(|v| v.collection_type.as_deref() == Some("music"))
.map(|v| MusicLibrary {
id: v.id,
name: v.name,
})
.collect())
}
/// How many audio items a library holds — one request, `Limit=0`.
///
/// Worth its own call: the sweep costs ~9.3 ms per track, so a UI that
/// wants to show progress needs the denominator before it starts.
pub async fn track_count(&self, library_id: Option<&str>) -> Result<u64> {
let scope = library_id
.map(|id| format!("&parentId={id}"))
.unwrap_or_default();
let env: ItemsEnvelope<serde_json::Value> = self
.get_json(&format!(
"/Items?userId={}&IncludeItemTypes=Audio&Recursive=true&Limit=0{scope}",
self.user_id
))
.await?;
Ok(env.total_record_count)
}
/// One page of audio items, WITH quality.
///
/// `Fields=MediaSources` is what carries `BitDepth` / `SampleRate`, and it
/// is the expensive part (§ module docs): ~4.5 s per 500-item page against
/// the measured server. There is no cheaper way to get the quality tier, so
/// the caller owns the cost knowingly — it is why [`track_count`] exists.
///
/// `min_date_last_saved` turns the sweep into a DELTA. Jellyfin honours it
/// (verified: a future date returns 0 items), which Plex has no equivalent
/// for — a re-scan after the first one need not pay the full 45.8 s.
pub async fn tracks_page(
&self,
library_id: Option<&str>,
start_index: u64,
min_date_last_saved: Option<&str>,
) -> Result<(Vec<JellyfinTrack>, u64)> {
let scope = library_id
.map(|id| format!("&parentId={id}"))
.unwrap_or_default();
let delta = min_date_last_saved
.map(|d| format!("&minDateLastSaved={d}"))
.unwrap_or_default();
let env: ItemsEnvelope<AudioDto> = self
.get_json(&format!(
"/Items?userId={}&IncludeItemTypes=Audio&Recursive=true\
&Limit={PAGE_SIZE}&StartIndex={start_index}\
&Fields=Path,MediaSources,ParentId,ProductionYear,Genres\
&SortBy=Album,ParentIndexNumber,IndexNumber&SortOrder=Ascending\
&EnableImages=true&ImageTypeLimit=1{scope}{delta}",
self.user_id
))
.await?;
let total = env.total_record_count;
Ok((
env.items.into_iter().map(AudioDto::into_track).collect(),
total,
))
}
/// [`stream_url`] for this client's server.
pub fn stream_url(&self, item_id: &str) -> String {
stream_url(&self.base, &self.token, item_id)
}
/// [`image_url`] for this client's server.
pub fn image_url(&self, item_id: &str, tag: Option<&str>, px: u32) -> String {
image_url(&self.base, item_id, tag, px)
}
}
// ---------------------------------------------------------------------------
// URL builders — PURE, and deliberately outside the client
// ---------------------------------------------------------------------------
//
// `qbz-source`'s `JellyfinSource` builds both from stored settings on paths
// where no client exists (the artwork token interpretation must not touch the
// network, by trait contract), and a `reqwest::Client` cannot even be
// CONSTRUCTED without a TLS provider installed — which a unit test does not
// have. Keeping these pure makes both true at once.
/// The BIT-PERFECT stream url for one item.
///
/// `static=true` is the whole contract: the server hands back the original file
/// bytes (md5-verified against the file on disk), with a `Content-Length` and
/// `Accept-Ranges: bytes` so QBZ's progressive feeder can Range-stream it. No
/// transcode is requested, and none may be added here — `audioCodec` /
/// `audioBitRate` on this endpoint are exactly how a bit-perfect path becomes a
/// resampled one.
///
/// The token rides in the query string because the feeder takes a URL, not a
/// request builder. That makes this a SECRET-BEARING string: never log it whole.
pub fn stream_url(base_url: &str, token: &str, item_id: &str) -> String {
format!(
"{}/Audio/{}/stream?static=true&api_key={}",
normalize_base_url(base_url),
item_id,
token
)
}
/// The cover url for an album (or any item that has a primary image).
///
/// **No credentials.** Verified: `/Items/{id}/Images/Primary` answers 200
/// unauthenticated. That makes the url STABLE — unlike a Plex thumb, whose
/// token is rebuilt every pass — so its cache key and its fetch url can be the
/// SAME string and it can be memoized forever.
///
/// `tag` is appended when known so a re-uploaded cover busts the cache instead
/// of serving the old one until eviction.
pub fn image_url(base_url: &str, item_id: &str, tag: Option<&str>, px: u32) -> String {
let base = normalize_base_url(base_url);
match tag {
Some(t) => format!("{base}/Items/{item_id}/Images/Primary?maxWidth={px}&tag={t}"),
None => format!("{base}/Items/{item_id}/Images/Primary?maxWidth={px}"),
}
}
/// Does this response carry the ORIGINAL bytes, or is the server transcoding?
///
/// The tell measured on 10.11.11: a direct `?static=true` answer has a
/// `Content-Length`; a transcode answers `Transfer-Encoding: chunked` with none.
/// Cheap enough to assert on every stream, and it is the only automatic defence
/// against a server-side policy silently resampling a hi-res track — which is
/// the one failure the audio contract cannot tolerate.
pub fn is_direct_response(content_length: Option<u64>, transfer_encoding: Option<&str>) -> bool {
if let Some(te) = transfer_encoding {
if te.eq_ignore_ascii_case("chunked") {
return false;
}
}
content_length.is_some_and(|n| n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_urls_are_normalised_the_way_people_type_them() {
assert_eq!(normalize_base_url("192.168.0.69:8096"), "http://192.168.0.69:8096");
assert_eq!(normalize_base_url("http://host:8096/"), "http://host:8096");
assert_eq!(normalize_base_url(" https://jf.example.com/ "), "https://jf.example.com");
assert_eq!(normalize_base_url(" "), "");
}
#[test]
fn the_auth_header_carries_a_token_only_when_there_is_one() {
let without = auth_header("qbz-abc", None);
assert!(without.contains(r#"DeviceId="qbz-abc""#));
assert!(!without.contains("Token="));
assert!(auth_header("qbz-abc", Some("sekrit")).contains(r#"Token="sekrit""#));
}
/// A chunked response with no `Content-Length` is the server transcoding.
/// This is the assertion that keeps a bit-perfect path bit-perfect.
#[test]
fn a_chunked_response_is_never_direct() {
assert!(is_direct_response(Some(135_919_471), None));
assert!(!is_direct_response(None, Some("chunked")));
assert!(!is_direct_response(None, None));
assert!(!is_direct_response(Some(0), None));
// A direct response that also names an identity encoding is fine.
assert!(is_direct_response(Some(1024), Some("identity")));
}
/// The cover array of an item carries the EMBEDDED ARTWORK as a stream too
/// (`mjpeg`, `BitDepth: 8`). Taking `MediaStreams[0]` would read the
/// JPEG's depth as the audio's and badge a 24-bit FLAC as 8-bit.
#[test]
fn the_audio_stream_is_picked_by_type_not_by_position() {
let json = serde_json::json!({
"Id": "abc",
"Name": "Harvester Of Sorrow",
"Album": "...And Justice For All",
"AlbumId": "alb",
"AlbumArtist": "Metallica",
"Artists": ["Metallica"],
"IndexNumber": 6,
"ParentIndexNumber": 1,
"RunTimeTicks": 3484800000i64,
"ProductionYear": 1988,
"Container": "flac",
"AlbumPrimaryImageTag": "748607df",
"MediaSources": [{
"MediaStreams": [
{ "Type": "EmbeddedImage", "Codec": "mjpeg", "BitDepth": 8 },
{ "Type": "Audio", "Codec": "flac", "BitDepth": 24,
"SampleRate": 96000, "Channels": 2, "BitRate": 3120281 }
]
}]
});
let t = serde_json::from_value::<AudioDto>(json).unwrap().into_track();
assert_eq!(t.bit_depth, Some(24), "read the cover's depth, not the audio's");
assert_eq!(t.sample_rate_hz, Some(96000));
assert_eq!(t.codec.as_deref(), Some("flac"));
// 3 484 800 000 ticks / 10 000 = 348 480 ms = 348.48 s.
assert_eq!(t.duration_ms, 348_480);
assert_eq!(t.track_number, Some(6));
assert_eq!(t.disc_number, Some(1));
}
/// MP3 reports NO bit depth. Defaulting it to 16 would badge every lossy
/// track as CD quality, which is the one thing this app must not get wrong.
#[test]
fn a_lossy_track_keeps_an_absent_bit_depth() {
let json = serde_json::json!({
"Id": "m", "Name": "x", "Container": "mp3",
"MediaSources": [{ "MediaStreams": [
{ "Type": "Audio", "Codec": "mp3", "SampleRate": 44100, "Channels": 2 }
]}]
});
let t = serde_json::from_value::<AudioDto>(json).unwrap().into_track();
assert_eq!(t.bit_depth, None);
assert_eq!(t.sample_rate_hz, Some(44100));
}
/// 17 of 500 measured rows carried no artist at all, and 75 of 4924 no
/// track number. Neither may drop the row.
#[test]
fn a_row_missing_its_tags_still_maps() {
let json = serde_json::json!({ "Id": "bare", "Name": "Untitled" });
let t = serde_json::from_value::<AudioDto>(json).unwrap().into_track();
assert_eq!(t.id, "bare");
assert_eq!(t.artist, "");
assert_eq!(t.track_number, None);
assert_eq!(t.duration_ms, 0);
assert_eq!(t.album_image_tag, None);
}
/// An artist-less row falls back to the ALBUM artist rather than rendering
/// blank, and vice versa — the two fields disagree often enough on real
/// libraries that picking one is not enough.
#[test]
fn artist_and_album_artist_back_each_other_up() {
let json = serde_json::json!({
"Id": "a", "Name": "t", "AlbumArtist": "Various Artists", "Artists": []
});
let t = serde_json::from_value::<AudioDto>(json).unwrap().into_track();
assert_eq!(t.artist, "Various Artists");
assert_eq!(t.album_artist, "Various Artists");
}
#[test]
fn image_urls_carry_no_credentials_and_bust_on_the_tag() {
let with = image_url("http://h:8096", "alb", Some("deadbeef"), IMAGE_PX);
assert!(!with.contains("tok"), "a cover url must not carry the token");
assert!(with.contains("tag=deadbeef"));
assert!(with.contains("maxWidth=256"));
assert_eq!(
image_url("http://h:8096", "alb", None, 1024),
"http://h:8096/Items/alb/Images/Primary?maxWidth=1024"
);
}
/// `static=true` is the bit-perfect contract. If this assertion ever has to
/// change, the change is a resampled hi-res track.
#[test]
fn the_stream_url_asks_for_the_original_bytes_and_nothing_else() {
let u = stream_url("http://h:8096/", "tok", "item42");
assert_eq!(u, "http://h:8096/Audio/item42/stream?static=true&api_key=tok");
assert!(!u.contains("audioCodec"), "a codec parameter IS a transcode");
assert!(!u.contains("audioBitRate"));
}
#[test]
fn only_music_views_are_returned() {
let json = serde_json::json!({
"Items": [
{ "Id": "1", "Name": "Music", "CollectionType": "music" },
{ "Id": "2", "Name": "Playlists", "CollectionType": "playlists" },
{ "Id": "3", "Name": "Movies", "CollectionType": "movies" },
{ "Id": "4", "Name": "Odd", "CollectionType": null }
],
"TotalRecordCount": 4
});
let env: ItemsEnvelope<ViewDto> = serde_json::from_value(json).unwrap();
let music: Vec<_> = env
.items
.into_iter()
.filter(|v| v.collection_type.as_deref() == Some("music"))
.map(|v| v.name)
.collect();
assert_eq!(music, vec!["Music"]);
}
}
+222
View File
@@ -0,0 +1,222 @@
//! LIVE tests against a real Jellyfin server. Skipped unless the environment
//! names one, so CI and a laptop on a train both stay green.
//!
//! QBZ_JELLYFIN_URL=http://192.168.0.69:8096 \
//! QBZ_JELLYFIN_USER=admin QBZ_JELLYFIN_PASS=... \
//! cargo test -p qbz-jellyfin --test live -- --nocapture
//!
//! These exist because the unit tests above them cannot fail the way this
//! integration really fails. A pure mapper test proves the JSON shape is
//! understood; it cannot prove the endpoint still exists, that the token still
//! opens it, or that `?static=true` still hands back the original bytes. That
//! last one is the audio contract, and the only honest way to check it is to
//! ask a server.
use std::env;
use std::sync::Once;
/// reqwest is built `...-no-provider`, so a `reqwest::Client` cannot be built
/// until a process-level rustls CryptoProvider exists. In the app that is
/// `qbz_app::install_crypto_provider`; here there is no app.
fn install_crypto_provider() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
});
}
fn server() -> Option<(String, String, String)> {
Some((
env::var("QBZ_JELLYFIN_URL").ok()?,
env::var("QBZ_JELLYFIN_USER").ok()?,
env::var("QBZ_JELLYFIN_PASS").ok()?,
))
}
macro_rules! skip_without_server {
() => {
match server() {
Some(v) => {
install_crypto_provider();
v
}
None => {
eprintln!("SKIP: set QBZ_JELLYFIN_URL / _USER / _PASS to run the live tests");
return;
}
}
};
}
#[tokio::test]
async fn probe_reports_a_server_without_credentials() {
let (url, _, _) = skip_without_server!();
let info = qbz_jellyfin::probe(&url).await.expect("probe");
eprintln!("server: {} {}", info.server_name, info.version);
assert!(!info.id.is_empty());
assert!(info.startup_wizard_completed);
}
#[tokio::test]
async fn a_full_round_trip_yields_tracks_with_real_quality() {
let (url, user, pass) = skip_without_server!();
let s = qbz_jellyfin::authenticate(&url, "qbz-live-test", &user, &pass)
.await
.expect("authenticate");
assert!(!s.access_token.is_empty());
let c = qbz_jellyfin::JellyfinClient::new(&url, &s.access_token, &s.user_id).unwrap();
let libs = c.music_libraries().await.expect("libraries");
assert!(!libs.is_empty(), "the server exposes no music library");
let lib = &libs[0];
eprintln!("library: {} ({})", lib.name, lib.id);
let total = c.track_count(Some(&lib.id)).await.expect("count");
assert!(total > 0, "the music library is empty");
let (page, reported) = c.tracks_page(Some(&lib.id), 0, None).await.expect("page");
assert_eq!(reported, total, "the count and the page disagree");
assert!(!page.is_empty());
// The point of `Fields=MediaSources`: SOME row must carry a real bit depth,
// or the quality badge silently degrades to "unknown" for a whole library.
let with_depth = page.iter().filter(|t| t.bit_depth.is_some()).count();
assert!(
with_depth > 0,
"no row in a {}-track page carried a bit depth — MediaSources is not being requested",
page.len()
);
// ...and a lossy row must NOT invent one.
for t in page.iter().filter(|t| t.container == "mp3") {
assert_eq!(t.bit_depth, None, "an mp3 reported a bit depth: {}", t.title);
}
// Every row must be identifiable and groupable, or the cache cannot key it.
for t in &page {
assert!(!t.id.is_empty(), "a row arrived with no id");
assert!(!t.album_id.is_empty(), "row {} has no album id", t.title);
}
eprintln!(
"page: {} rows, {} with a bit depth, {} total in library",
page.len(),
with_depth,
total
);
}
/// THE audio contract. `?static=true` must answer with the ORIGINAL bytes:
/// a `Content-Length`, `Accept-Ranges`, and a 206 to a Range request. A
/// chunked answer means the server decided to transcode, which for a hi-res
/// track is exactly the failure this app cannot ship.
#[tokio::test]
async fn the_stream_endpoint_is_direct_and_seekable() {
let (url, user, pass) = skip_without_server!();
let s = qbz_jellyfin::authenticate(&url, "qbz-live-test", &user, &pass)
.await
.expect("authenticate");
let c = qbz_jellyfin::JellyfinClient::new(&url, &s.access_token, &s.user_id).unwrap();
let libs = c.music_libraries().await.expect("libraries");
let (page, _) = c
.tracks_page(Some(&libs[0].id), 0, None)
.await
.expect("page");
// A LOSSLESS row, so the assertion is about the case that matters.
let track = page
.iter()
.find(|t| t.container == "flac")
.or_else(|| page.first())
.expect("a track");
let http = reqwest::Client::new();
let resp = http
.get(c.stream_url(&track.id))
.header("Range", "bytes=0-65535")
.send()
.await
.expect("range request");
assert_eq!(resp.status().as_u16(), 206, "the server refused a Range request");
let te = resp
.headers()
.get(reqwest::header::TRANSFER_ENCODING)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
assert!(
te.as_deref() != Some("chunked"),
"chunked response — the server is TRANSCODING, not direct-playing"
);
let ctype = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let bytes = resp.bytes().await.expect("body");
assert_eq!(bytes.len(), 65536, "short read on a Range request");
if track.container == "flac" {
assert_eq!(&bytes[..4], b"fLaC", "the body is not a FLAC bitstream");
}
eprintln!(
"stream: {} {} -> 206, {} bytes, {ctype}",
track.container,
track.title,
bytes.len()
);
}
/// Cover urls carry no credentials, and the server really serves them that way.
#[tokio::test]
async fn cover_art_needs_no_token() {
let (url, user, pass) = skip_without_server!();
let s = qbz_jellyfin::authenticate(&url, "qbz-live-test", &user, &pass)
.await
.expect("authenticate");
let c = qbz_jellyfin::JellyfinClient::new(&url, &s.access_token, &s.user_id).unwrap();
let libs = c.music_libraries().await.expect("libraries");
let (page, _) = c
.tracks_page(Some(&libs[0].id), 0, None)
.await
.expect("page");
let track = page
.iter()
.find(|t| t.album_image_tag.is_some())
.expect("no row in the first page has an album cover");
let art = qbz_jellyfin::image_url(
c.base_url(),
&track.album_id,
track.album_image_tag.as_deref(),
qbz_jellyfin::IMAGE_PX,
);
assert!(!art.contains(&s.access_token), "the cover url leaked the token");
let resp = reqwest::Client::new().get(&art).send().await.expect("cover");
assert!(resp.status().is_success(), "cover fetch failed: {}", resp.status());
let ctype = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
assert!(ctype.starts_with("image/"), "cover url returned {ctype}");
let bytes = resp.bytes().await.expect("cover body");
assert!(bytes.len() > 1024, "cover is {} bytes", bytes.len());
eprintln!("cover: {ctype}, {} bytes", bytes.len());
}
/// The delta sweep really filters. Without this, an incremental re-scan would
/// silently be a full one.
#[tokio::test]
async fn a_future_delta_returns_nothing() {
let (url, user, pass) = skip_without_server!();
let s = qbz_jellyfin::authenticate(&url, "qbz-live-test", &user, &pass)
.await
.expect("authenticate");
let c = qbz_jellyfin::JellyfinClient::new(&url, &s.access_token, &s.user_id).unwrap();
let libs = c.music_libraries().await.expect("libraries");
let (page, _) = c
.tracks_page(Some(&libs[0].id), 0, Some("2999-01-01T00:00:00Z"))
.await
.expect("delta page");
assert!(page.is_empty(), "a year-2999 delta returned {} rows", page.len());
}