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
223 lines
8.1 KiB
Rust
223 lines
8.1 KiB
Rust
//! 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());
|
|
}
|