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:
5 files changed
+1023
No files matched your search
@@ -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"] }
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user