diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 779b577fd..00f420b87 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -6756,6 +6756,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "qbz-subsonic" +version = "2.0.2" +dependencies = [ + "md-5", + "reqwest", + "rustls", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "qbz-text-utils" version = "2.0.2" diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 306710bc9..eab5c27e0 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -55,6 +55,7 @@ members = [ # Media-server protocol crates: siblings of `qbz-plex`, same shape. HTTP + # DTOs only — no cache schema, no settings store, no Qt (ADR-006). "qbz-jellyfin", + "qbz-subsonic", ] # Qt-frontend POC worktree: plain `cargo build`/`cargo check` in this workspace diff --git a/crates/qbz-subsonic/Cargo.toml b/crates/qbz-subsonic/Cargo.toml new file mode 100644 index 000000000..ccfdd68da --- /dev/null +++ b/crates/qbz-subsonic/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "qbz-subsonic" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Subsonic / OpenSubsonic integration for QBZ: HTTP client, envelope parsing, bit-perfect stream URLs (frontend-agnostic)" + +[dependencies] +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true } +serde_json = { workspace = true } + +# Subsonic token auth is md5(password + salt). Not a security choice of ours — +# it is what the protocol specifies, and the alternative it replaces is sending +# the password in clear text on every request. +md-5 = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +rustls = { version = "0.23", features = ["aws_lc_rs"] } diff --git a/crates/qbz-subsonic/src/lib.rs b/crates/qbz-subsonic/src/lib.rs new file mode 100644 index 000000000..715eeb5a5 --- /dev/null +++ b/crates/qbz-subsonic/src/lib.rs @@ -0,0 +1,896 @@ +//! Subsonic / OpenSubsonic integration — the protocol half. +//! +//! Sibling of `qbz-plex` and `qbz-jellyfin`. Frontend-agnostic: HTTP + DTOs, no +//! cache schema, no settings store, no Qt. +//! +//! One client, many servers. The same API is spoken by **Navidrome, Gonic, +//! Airsonic, Airsonic-Advanced, Astiga and Ampache**, which is why the owner's +//! scope decision was "Subsonic, not just Navidrome". Only Navidrome 0.63.2 was +//! on the bench, so everything measured below is measured *there* and the +//! places where servers are likely to differ are called out rather than +//! smoothed over. +//! +//! Measurements: `qbz-nix-docs/qt-frontend/2026-08-20-jellyfin-subsonic/01-research.md`. +//! +//! # THE TRAP: this protocol reports failure with HTTP 200 +//! +//! Measured, twice, and it is the single most dangerous property of Subsonic: +//! +//! ```text +//! GET /rest/getCoverArt.view (no credentials) +//! -> HTTP 200, 251 bytes: … +//! GET /rest/stream.view (no credentials) +//! -> HTTP 200, 196 bytes: {"subsonic-response":{"status":"failed","error":{"code":10,…}}} +//! ``` +//! +//! Three consequences, and all three are enforced in code below rather than +//! left to a reviewer's memory: +//! +//! 1. **The HTTP status is not the result.** `reqwest`'s `error_for_status()` +//! passes on a failure. Every response goes through [`parse_envelope`], +//! which checks `subsonic-response.status` before anything else. +//! 2. **A refused stream is a ~200-byte JSON blob served as the audio body.** +//! Handed to a decoder it is garbage in the audio path. [`looks_like_audio`] +//! exists so the caller can refuse it, and the frontend's download fallback +//! checks Content-Type for the same reason. +//! 3. **The error format ignores `f=json`.** The no-credential `getCoverArt` +//! case came back as **XML** even though the request asked for JSON, so the +//! envelope parser must not assume it is parsing JSON when it fails. +//! +//! # The other trap: `path` is synthesised, not read from disk +//! +//! Navidrome reported +//! `Rob Zombie/The Lunar Injection Kool Aid Eclipse Conspiracy/01-09 - ….flac` +//! for a file that actually lives at +//! `Rob Zombie/(2021) Rob Zombie - … [FLAC] [24B-44.1kHz]/09. Rob Zombie - ….flac` +//! — different directory, different filename. The field is built from tags. It +//! is a DISPLAY STRING and must never reach a `file_path` slot; the only +//! identifier a Subsonic track has is its `id`. +//! +//! # What is good here +//! +//! - **Bit-perfect, md5-verified.** `stream.view?format=raw` returned bytes +//! identical to the file on disk. +//! - **Quality is free.** `bitDepth` / `samplingRate` / `channelCount` are +//! OpenSubsonic fields that ride along with every song, so a full sweep of +//! 6678 tracks took **0.81 s** — 0.122 ms per track, about 76× cheaper per +//! track than Jellyfin's, which has to hydrate media info server-side. + +use std::time::Duration; + +use md5::{Digest, Md5}; +use serde::Deserialize; + +/// The protocol version this client claims. 1.16.1 is the last Subsonic +/// version and what every OpenSubsonic server implements; asking for it is +/// what makes `bitDepth` / `samplingRate` available. +pub const API_VERSION: &str = "1.16.1"; + +/// The client name every request carries (`c=`). Servers show it in their +/// session lists. +pub const CLIENT_NAME: &str = "QBZ"; + +/// `getAlbumList2` CAPS AT 500 regardless of what `size` asks for — measured: +/// 501 and 1000 both returned 500. Pagination via `offset` is mandatory, not +/// optional, and a caller that trusts its own `size` silently loses the tail. +pub const PAGE_SIZE: u32 = 500; + +/// Cover size this client requests. **A ceiling, not a promise:** Navidrome +/// returned a 200×201 image for `size=400` because it will not upscale past +/// the embedded art. The grid must handle getting fewer pixels than it asked. +pub const IMAGE_PX: u32 = 256; + +/// The larger tier, for hero / immersive slots. +pub const IMAGE_PX_LARGE: u32 = 1024; + +const HTTP_TIMEOUT: Duration = Duration::from_secs(15); + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// A Subsonic failure. `code` is the protocol's own +/// (10 = missing parameter, 40 = wrong username or password, 70 = not found). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubsonicError { + Transport(String), + /// `code 40` — the caller can act on this one, so it is not folded into + /// [`SubsonicError::Api`]. + Unauthorized, + /// `code 70`. + NotFound(String), + /// Any other `` the server returned, WITH the 200 it came in. + Api { code: i64, message: String }, + /// The body was neither a JSON envelope nor an XML one this client + /// understands. + Decode(String), + /// An HTTP status that was genuinely not 2xx. Rare on this protocol — + /// which is exactly the problem. + Status(u16), +} + +impl std::fmt::Display for SubsonicError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SubsonicError::Transport(e) => write!(f, "subsonic request failed: {e}"), + SubsonicError::Unauthorized => write!(f, "subsonic rejected the credentials"), + SubsonicError::NotFound(w) => write!(f, "subsonic has no {w}"), + SubsonicError::Api { code, message } => { + write!(f, "subsonic error {code}: {message}") + } + SubsonicError::Decode(e) => write!(f, "subsonic response not understood: {e}"), + SubsonicError::Status(s) => write!(f, "subsonic answered HTTP {s}"), + } + } +} + +impl std::error::Error for SubsonicError {} + +type Result = std::result::Result; + +// --------------------------------------------------------------------------- +// The envelope — the safety gate +// --------------------------------------------------------------------------- + +/// Pull the payload out of a `subsonic-response`, or turn its `` into a +/// real [`SubsonicError`]. +/// +/// **Every response body must pass through here.** The protocol answers 200 on +/// failure, so this — not the HTTP status — is what decides whether a call +/// worked. +/// +/// It accepts XML as well as JSON on the failure path. That is not defensive +/// padding: the measured no-credential `getCoverArt` reply was XML *despite* +/// the request carrying `f=json`, so a JSON-only parser would have reported +/// "not understood" for a perfectly clear "missing parameter: 'u'". +pub fn parse_envelope(body: &[u8]) -> Result { + let text = String::from_utf8_lossy(body); + let trimmed = text.trim_start(); + + // --- the XML failure shape ------------------------------------------- + if trimmed.starts_with('<') { + if let Some(err) = xml_error(trimmed) { + return Err(err); + } + return Err(SubsonicError::Decode( + "server answered XML where JSON was requested".into(), + )); + } + + let root: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| SubsonicError::Decode(format!("{e} (body starts: {:.80})", text)))?; + let resp = root + .get("subsonic-response") + .ok_or_else(|| SubsonicError::Decode("no subsonic-response member".into()))?; + + if resp.get("status").and_then(|s| s.as_str()) == Some("failed") { + let e = resp.get("error"); + let code = e.and_then(|e| e.get("code")).and_then(|c| c.as_i64()).unwrap_or(-1); + let message = e + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("unspecified") + .to_string(); + return Err(api_error(code, message)); + } + Ok(resp.clone()) +} + +/// Map a protocol error code onto the variant a caller can act on. +fn api_error(code: i64, message: String) -> SubsonicError { + match code { + 40 | 41 => SubsonicError::Unauthorized, + 70 => SubsonicError::NotFound(message), + _ => SubsonicError::Api { code, message }, + } +} + +/// Dig `code`/`message` out of the XML failure shape without pulling in an XML +/// parser: the document is one flat `` element and the two +/// attributes are all this crate needs from it. +fn xml_error(xml: &str) -> Option { + let at = xml.find(" Option { + let key = format!("{name}=\""); + let i = tail.find(&key)? + key.len(); + let rest = &tail[i..]; + let j = rest.find('"')?; + Some(rest[..j].to_string()) + }; + let code = attr("code")?.parse::().ok()?; + let message = attr("message").unwrap_or_else(|| "unspecified".into()); + // The XML escapes apostrophes, which show up in the common "missing + // parameter: 'u'" message. + let message = message + .replace("'", "'") + .replace(""", "\"") + .replace("&", "&"); + Some(api_error(code, message)) +} + +/// Does this response body plausibly carry AUDIO rather than an error envelope? +/// +/// Necessary because a refused `stream.view` answers **HTTP 200** with a +/// ~200-byte JSON error, and that would otherwise be handed to the decoder. +/// Two independent checks, because either alone has a hole: a content type can +/// be absent or wrong, and a very short body can be a legitimate (tiny) file in +/// theory — together they are decisive for anything a music server serves. +pub fn looks_like_audio(content_type: Option<&str>, byte_len: usize) -> bool { + if let Some(ct) = content_type { + let ct = ct.to_ascii_lowercase(); + if ct.starts_with("application/json") || ct.starts_with("text/") || ct.contains("xml") { + return false; + } + } + // Every measured error envelope was under 300 bytes; the smallest real + // track on the bench is orders of magnitude larger. + byte_len >= 4096 +} + +// --------------------------------------------------------------------------- +// Credentials +// --------------------------------------------------------------------------- + +/// Subsonic token auth: `t = md5(password + salt)`, sent with the salt. +/// +/// The salt is chosen by the CLIENT and travels in clear, so it is not a +/// secret — its job is to keep the same password from hashing to the same token +/// on two different installs. +/// +/// **QBZ pins one salt per install rather than rolling it per request.** The +/// protocol allows either, and rolling it looks more careful, but it would make +/// every cover URL unique per request: the artwork cache keys on the URL, so a +/// rolling salt re-downloads every cover on every pass. A fixed salt costs +/// nothing here — the password is not transmitted either way, and an attacker +/// who can read the query string can read the token whichever salt produced it. +#[derive(Debug, Clone)] +pub struct Credentials { + pub username: String, + /// `md5(password + salt)`, hex, lowercase. + pub token: String, + pub salt: String, +} + +impl Credentials { + /// Derive the token for `password` under `salt`. + pub fn new(username: &str, password: &str, salt: &str) -> Self { + let mut h = Md5::new(); + h.update(password.as_bytes()); + h.update(salt.as_bytes()); + Self { + username: username.to_string(), + token: format!("{:x}", h.finalize()), + salt: salt.to_string(), + } + } + + /// The auth + protocol query parameters every request carries. + /// + /// `f=json` is included even though the failure path may answer XML anyway + /// (see [`parse_envelope`]) — the SUCCESS path honours it, and that is the + /// path that has to be parsed. + pub fn query(&self) -> String { + format!( + "u={}&t={}&s={}&v={}&c={}&f=json", + urlencode(&self.username), + self.token, + urlencode(&self.salt), + API_VERSION, + CLIENT_NAME + ) + } +} + +/// Percent-encode the characters that would break a query string. Deliberately +/// minimal — a username or salt is not a URL path, and pulling in a full +/// encoder for two fields is more surface than it saves. +fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +// --------------------------------------------------------------------------- +// DTOs +// --------------------------------------------------------------------------- + +/// What `ping.view` reports. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerInfo { + /// e.g. `navidrome`, `gonic`, `airsonic`. + pub kind: String, + pub version: String, + /// The server's own build string, when it offers one. + pub server_version: Option, + /// True when the server implements the OpenSubsonic extensions — which is + /// what makes `bitDepth` / `samplingRate` available. A plain Subsonic + /// server answers without them and every track's quality tier degrades to + /// "unknown", so this flag is worth surfacing in the settings panel. + pub open_subsonic: bool, +} + +/// One music folder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MusicFolder { + pub id: String, + pub name: String, +} + +/// One song, flattened into the shape the cache stores. Field names are QBZ's. +#[derive(Debug, Clone, PartialEq)] +pub struct SubsonicTrack { + /// The ONLY identifier. Opaque — never parse it, never construct it. + pub id: String, + pub title: String, + pub artist: String, + pub album_artist: String, + pub album: String, + pub album_id: String, + pub track_number: Option, + pub disc_number: Option, + pub duration_ms: u64, + pub year: Option, + pub genre: Option, + /// `flac`, `mp3`, … as the server names it. + pub suffix: String, + pub content_type: Option, + /// `None` for lossy. The wire reports **0** for that case (Jellyfin reports + /// null); both mean "not applicable" and both are folded to `None` here so + /// downstream sees one vocabulary. + pub bit_depth: Option, + pub sample_rate_hz: Option, + pub channels: Option, + /// kbps, as the protocol reports it. + pub bitrate_kbps: Option, + /// The OPAQUE cover-art id (`al-_` for an album, + /// `dc-:_` for a track). Store it; never build one. + pub cover_art: Option, + /// File size in bytes, as reported. Useful as a sanity check against a + /// stream's `Content-Length`. + pub size: Option, +} + +#[derive(Deserialize)] +struct SongDto { + id: String, + #[serde(default)] + title: String, + #[serde(default)] + artist: Option, + #[serde(default)] + album: Option, + #[serde(default, rename = "albumId")] + album_id: Option, + #[serde(default, rename = "displayAlbumArtist")] + display_album_artist: Option, + #[serde(default)] + track: Option, + #[serde(default, rename = "discNumber")] + disc_number: Option, + /// SECONDS on the wire. + #[serde(default)] + duration: Option, + #[serde(default)] + year: Option, + #[serde(default)] + genre: Option, + #[serde(default)] + suffix: Option, + #[serde(default, rename = "contentType")] + content_type: Option, + #[serde(default, rename = "bitDepth")] + bit_depth: Option, + #[serde(default, rename = "samplingRate")] + sampling_rate: Option, + #[serde(default, rename = "channelCount")] + channel_count: Option, + #[serde(default, rename = "bitRate")] + bit_rate: Option, + #[serde(default, rename = "coverArt")] + cover_art: Option, + #[serde(default)] + size: Option, +} + +impl From for SubsonicTrack { + fn from(s: SongDto) -> Self { + let artist = s.artist.unwrap_or_default(); + let album_artist = s.display_album_artist.unwrap_or_else(|| artist.clone()); + SubsonicTrack { + id: s.id, + title: s.title, + artist: if artist.is_empty() { + album_artist.clone() + } else { + artist + }, + album_artist, + album: s.album.unwrap_or_default(), + album_id: s.album_id.unwrap_or_default(), + track_number: s.track, + disc_number: s.disc_number, + duration_ms: s.duration.unwrap_or(0) * 1000, + year: s.year, + genre: s.genre, + suffix: s.suffix.unwrap_or_default(), + content_type: s.content_type, + // 0 means "not applicable" on this wire, not "zero bits". + bit_depth: s.bit_depth.filter(|d| *d > 0), + sample_rate_hz: s.sampling_rate.filter(|r| *r > 0), + channels: s.channel_count.filter(|c| *c > 0), + bitrate_kbps: s.bit_rate.filter(|b| *b > 0), + cover_art: s.cover_art.filter(|c| !c.is_empty()), + size: s.size, + } + } +} + +/// How this client enumerated a library, so the caller can log which path a +/// given server actually supported. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SweepMode { + /// `search3` with an empty query: 14 requests for 6678 tracks on the bench. + Search3, + /// `getAlbumList2` + `getAlbum` per album: 675 requests for the same + /// library. Portable, and the fallback when the fast path is not honoured. + PerAlbum, +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// A connected Subsonic-compatible server. +#[derive(Debug, Clone)] +pub struct SubsonicClient { + http: reqwest::Client, + base: String, + creds: Credentials, +} + +/// Normalise a user-typed address and append `/rest` if it is not there. +/// +/// People paste the address of the web UI, which is the server root; the API +/// lives one segment down. Doing it here rather than in the settings panel +/// means every caller gets it, including the ones written later. +pub fn normalize_base_url(input: &str) -> String { + let s = input.trim().trim_end_matches('/'); + if s.is_empty() { + return String::new(); + } + let with_scheme = if s.starts_with("http://") || s.starts_with("https://") { + s.to_string() + } else { + format!("http://{s}") + }; + if with_scheme.ends_with("/rest") { + with_scheme + } else { + format!("{with_scheme}/rest") + } +} + +fn client() -> Result { + reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|e| SubsonicError::Transport(e.to_string())) +} + +impl SubsonicClient { + pub fn new(base_url: &str, creds: Credentials) -> Result { + Ok(Self { + http: client()?, + base: normalize_base_url(base_url), + creds, + }) + } + + /// The `/rest` base, e.g. `http://host:4533/rest`. + pub fn base_url(&self) -> &str { + &self.base + } + + pub fn credentials(&self) -> &Credentials { + &self.creds + } + + /// GET one endpoint and return the unwrapped `subsonic-response`. + async fn get(&self, endpoint: &str, extra: &str) -> Result { + let url = format!("{}/{}?{}{}", self.base, endpoint, self.creds.query(), extra); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| SubsonicError::Transport(e.to_string()))?; + // Checked, but NOT trusted: a 200 here says nothing about success. + let status = resp.status(); + let body = resp + .bytes() + .await + .map_err(|e| SubsonicError::Transport(e.to_string()))?; + match parse_envelope(&body) { + Ok(v) => Ok(v), + // A genuine non-2xx with an unparseable body is worth reporting as + // the status it was, rather than as "not understood". + Err(SubsonicError::Decode(d)) if !status.is_success() => { + let _ = d; + Err(SubsonicError::Status(status.as_u16())) + } + Err(e) => Err(e), + } + } + + /// `ping.view` — the credentials check AND the OpenSubsonic probe. + pub async fn ping(&self) -> Result { + let r = self.get("ping.view", "").await?; + Ok(ServerInfo { + kind: r + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("subsonic") + .to_string(), + version: r + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or(API_VERSION) + .to_string(), + server_version: r + .get("serverVersion") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + open_subsonic: r + .get("openSubsonic") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + }) + } + + /// `getMusicFolders.view`. + pub async fn music_folders(&self) -> Result> { + let r = self.get("getMusicFolders.view", "").await?; + let arr = r + .get("musicFolders") + .and_then(|m| m.get("musicFolder")) + .and_then(|f| f.as_array()) + .cloned() + .unwrap_or_default(); + Ok(arr + .into_iter() + .filter_map(|v| { + // `id` is an INTEGER here and a STRING elsewhere in the same + // protocol. Accept both rather than pick a side. + let id = v.get("id").map(json_id)?; + let name = v + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or_default() + .to_string(); + Some(MusicFolder { id, name }) + }) + .collect()) + } + + /// One page of the FAST sweep: `search3` with an empty query. + /// + /// Measured on Navidrome: 500 songs in 0.095 s, paginating correctly at + /// `songOffset=6000`. Whether an empty query means "everything" is the + /// behaviour most likely to differ across servers, so a caller must be + /// prepared for this to return nothing on the first page and fall back to + /// [`SubsonicClient::album_ids`] + [`SubsonicClient::album_tracks`]. + pub async fn search_page(&self, offset: u32) -> Result> { + let r = self + .get( + "search3.view", + &format!( + "&query=%22%22&artistCount=0&albumCount=0&songCount={PAGE_SIZE}&songOffset={offset}" + ), + ) + .await?; + Ok(songs_of(r.get("searchResult3"))) + } + + /// One page of album ids, for the PORTABLE sweep. + /// + /// `size` is capped at 500 by the server whatever is asked, so this always + /// pages. + pub async fn album_ids(&self, offset: u32) -> Result> { + let r = self + .get( + "getAlbumList2.view", + &format!("&type=alphabeticalByName&size={PAGE_SIZE}&offset={offset}"), + ) + .await?; + Ok(r.get("albumList2") + .and_then(|a| a.get("album")) + .and_then(|a| a.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.get("id").map(json_id)).collect()) + .unwrap_or_default()) + } + + /// The tracks of one album (`getAlbum.view`). + pub async fn album_tracks(&self, album_id: &str) -> Result> { + let r = self + .get("getAlbum.view", &format!("&id={}", urlencode(album_id))) + .await?; + Ok(songs_of(r.get("album"))) + } + + /// Which sweep this server supports, decided by ASKING rather than by + /// checking its name. + /// + /// The fast path is the Navidrome behaviour and the OpenSubsonic spec's + /// intent, but Gonic and Airsonic were never on the bench. Probing one page + /// costs ~100 ms and turns "we think this works" into "this works here". + pub async fn detect_sweep_mode(&self) -> SweepMode { + match self.search_page(0).await { + Ok(rows) if !rows.is_empty() => SweepMode::Search3, + _ => SweepMode::PerAlbum, + } + } + + /// The BIT-PERFECT stream url. + /// + /// `format=raw` is the explicit contract. The bare `stream.view` happened to + /// return raw bytes on the bench, but only because that server had no + /// transcoding policy for this client — that is CONFIGURATION, not a + /// guarantee, and relying on it is how a hi-res track quietly becomes an + /// mp3. `maxBitRate` must never be added here. + /// + /// Carries credentials in the query string: SECRET-BEARING, never log whole. + pub fn stream_url(&self, track_id: &str) -> String { + stream_url(&self.base, &self.creds, track_id) + } + + /// The cover url for a `coverArt` id. + pub fn cover_url(&self, cover_art: &str, px: u32) -> String { + cover_url(&self.base, &self.creds, cover_art, px) + } +} + +// --------------------------------------------------------------------------- +// URL builders — PURE (see `qbz-jellyfin` for why these are free functions) +// --------------------------------------------------------------------------- + +/// See [`SubsonicClient::stream_url`]. +pub fn stream_url(base_url: &str, creds: &Credentials, track_id: &str) -> String { + format!( + "{}/stream.view?{}&id={}&format=raw", + normalize_base_url(base_url), + creds.query(), + urlencode(track_id) + ) +} + +/// See [`SubsonicClient::cover_url`]. +/// +/// Unlike Jellyfin's, this URL carries credentials — so the caller must key its +/// artwork cache on the opaque `coverArt` id, NOT on this string. +pub fn cover_url(base_url: &str, creds: &Credentials, cover_art: &str, px: u32) -> String { + format!( + "{}/getCoverArt.view?{}&id={}&size={}", + normalize_base_url(base_url), + creds.query(), + urlencode(cover_art), + px + ) +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +/// `id` is an integer in `getMusicFolders` and a string almost everywhere else. +fn json_id(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string().trim_matches('"').to_string(), + } +} + +/// Pull a `song` array out of whichever container holds it. +fn songs_of(container: Option<&serde_json::Value>) -> Vec { + container + .and_then(|c| c.get("song")) + .and_then(|s| s.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| serde_json::from_value::(v.clone()).ok()) + .map(SubsonicTrack::from) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_urls_get_a_scheme_and_the_rest_segment() { + assert_eq!(normalize_base_url("192.168.0.69:4533"), "http://192.168.0.69:4533/rest"); + assert_eq!(normalize_base_url("http://h:4533/"), "http://h:4533/rest"); + // Already pointed at the API: not doubled. + assert_eq!(normalize_base_url("http://h:4533/rest"), "http://h:4533/rest"); + assert_eq!(normalize_base_url(""), ""); + } + + /// The known-good pair from the bench doc: password `qbz-navidrome`, + /// salt `9dafc0` -> token `9874f6e89692e31267ff84eb5c2d5745`. If this + /// assertion ever fails, the auth is broken in a way no live test would + /// explain clearly. + #[test] + fn the_token_is_md5_of_password_then_salt() { + let c = Credentials::new("admin", "qbz-navidrome", "9dafc0"); + assert_eq!(c.token, "9874f6e89692e31267ff84eb5c2d5745"); + assert!(c.query().contains("u=admin")); + assert!(c.query().contains("s=9dafc0")); + assert!(c.query().contains("f=json")); + // The PASSWORD itself must never appear in a query string. + assert!(!c.query().contains("qbz-navidrome")); + } + + #[test] + fn usernames_and_salts_are_percent_encoded() { + let c = Credentials::new("user name+&", "p", "sa lt"); + assert!(c.query().contains("u=user%20name%2B%26")); + assert!(c.query().contains("s=sa%20lt")); + } + + // ── THE trap: failure arrives as HTTP 200 ────────────────────────────── + + /// The measured JSON failure body, verbatim. + #[test] + fn a_json_failure_envelope_is_an_error_not_a_payload() { + let body = br#"{"subsonic-response":{"status":"failed","version":"1.16.1","type":"navidrome","error":{"code":10,"message":"missing parameter: 'u'"}}}"#; + match parse_envelope(body) { + Err(SubsonicError::Api { code, message }) => { + assert_eq!(code, 10); + assert!(message.contains("missing parameter")); + } + other => panic!("expected an Api error, got {other:?}"), + } + } + + /// The measured XML failure body — returned even though the request asked + /// for JSON. A JSON-only parser reports "not understood" for a perfectly + /// clear error. + #[test] + fn an_xml_failure_envelope_is_understood_despite_f_json() { + let body = br#""#; + match parse_envelope(body) { + Err(SubsonicError::Api { code, message }) => { + assert_eq!(code, 10); + assert_eq!(message, "missing parameter: 'u'"); + } + other => panic!("expected an Api error, got {other:?}"), + } + } + + /// Code 40 is actionable — the caller re-authenticates — so it does not + /// hide inside the generic variant. + #[test] + fn wrong_credentials_are_their_own_variant() { + let body = br#"{"subsonic-response":{"status":"failed","error":{"code":40,"message":"Wrong username or password"}}}"#; + assert_eq!(parse_envelope(body), Err(SubsonicError::Unauthorized)); + let missing = br#"{"subsonic-response":{"status":"failed","error":{"code":70,"message":"Song not found"}}}"#; + assert!(matches!(parse_envelope(missing), Err(SubsonicError::NotFound(_)))); + } + + #[test] + fn a_successful_envelope_unwraps_to_its_payload() { + let body = br#"{"subsonic-response":{"status":"ok","version":"1.16.1","musicFolders":{"musicFolder":[{"id":1,"name":"Music Library"}]}}}"#; + let v = parse_envelope(body).expect("ok envelope"); + assert_eq!(v["musicFolders"]["musicFolder"][0]["name"], "Music Library"); + } + + /// A refused stream is ~200 bytes of JSON served as the audio body. This is + /// the guard that keeps it out of the decoder. + #[test] + fn an_error_body_never_looks_like_audio() { + assert!(!looks_like_audio(Some("application/json"), 196)); + assert!(!looks_like_audio(Some("text/xml"), 251)); + assert!(!looks_like_audio(None, 196)); + // A real track. + assert!(looks_like_audio(Some("audio/flac"), 13_445_275)); + // Right type, absurd length: still refused. + assert!(!looks_like_audio(Some("audio/flac"), 120)); + } + + // ── Mapping ──────────────────────────────────────────────────────────── + + /// The measured hi-res row. `duration` is SECONDS on the wire and + /// milliseconds in QBZ, which is exactly the kind of unit slip that shows + /// up as a progress bar running 1000× too fast. + #[test] + fn a_hires_song_maps_with_its_quality_and_a_ms_duration() { + let json = serde_json::json!({ + "id": "dRnm19VjnLhA30hExxaCGA", + "title": "Matilda Mother", + "artist": "Pink Floyd", + "album": "The Piper at the Gates of Dawn", + "albumId": "alb1", + "displayAlbumArtist": "Pink Floyd", + "track": 3, "discNumber": 1, "duration": 188, "year": 1967, + "suffix": "flac", "contentType": "audio/flac", + "bitDepth": 24, "samplingRate": 192000, "channelCount": 2, + "coverArt": "al-alb1_59fec8ff", "size": 137389889u64 + }); + let t: SubsonicTrack = serde_json::from_value::(json).unwrap().into(); + assert_eq!(t.bit_depth, Some(24)); + assert_eq!(t.sample_rate_hz, Some(192_000)); + assert_eq!(t.duration_ms, 188_000, "seconds were not converted to ms"); + assert_eq!(t.cover_art.as_deref(), Some("al-alb1_59fec8ff")); + assert_eq!(t.size, Some(137_389_889)); + } + + /// The wire reports **0** for a lossy track's bit depth (Jellyfin reports + /// null). Left as 0 it would badge every MP3 as a 0-bit oddity or, worse, + /// be read as "known". Both sentinels fold to None. + #[test] + fn a_zero_bit_depth_means_not_applicable_not_zero() { + let json = serde_json::json!({ + "id": "x", "title": "Dime", "suffix": "mp3", + "bitDepth": 0, "samplingRate": 44100, "channelCount": 2, "bitRate": 160 + }); + let t: SubsonicTrack = serde_json::from_value::(json).unwrap().into(); + assert_eq!(t.bit_depth, None); + assert_eq!(t.sample_rate_hz, Some(44100)); + assert_eq!(t.bitrate_kbps, Some(160)); + } + + #[test] + fn a_song_missing_everything_optional_still_maps() { + let json = serde_json::json!({ "id": "bare" }); + let t: SubsonicTrack = serde_json::from_value::(json).unwrap().into(); + assert_eq!(t.id, "bare"); + assert_eq!(t.duration_ms, 0); + assert_eq!(t.cover_art, None); + assert_eq!(t.track_number, None); + } + + /// `id` is an integer in `getMusicFolders` and a string elsewhere. Picking + /// one shape drops the other silently. + #[test] + fn ids_are_accepted_as_both_numbers_and_strings() { + assert_eq!(json_id(&serde_json::json!(1)), "1"); + assert_eq!(json_id(&serde_json::json!("al-abc")), "al-abc"); + } + + // ── URLs ─────────────────────────────────────────────────────────────── + + /// `format=raw` is the bit-perfect contract, and `maxBitRate` is how it + /// would be lost. + #[test] + fn the_stream_url_demands_raw_and_never_caps_the_bitrate() { + let c = Credentials::new("admin", "pw", "abc"); + let u = stream_url("http://h:4533", &c, "trk1"); + assert!(u.starts_with("http://h:4533/rest/stream.view?")); + assert!(u.contains("&format=raw"), "raw is the contract: {u}"); + assert!(!u.contains("maxBitRate"), "a bitrate cap IS a transcode"); + assert!(u.contains("&id=trk1")); + assert!(!u.contains("pw"), "the password reached the url"); + } + + /// The cover url carries credentials, which is precisely why the CACHE KEY + /// must be the opaque coverArt id and not this string. + #[test] + fn the_cover_url_carries_credentials_and_a_size_ceiling() { + let c = Credentials::new("admin", "pw", "abc"); + let u = cover_url("http://h:4533", &c, "al-abc_1:2", IMAGE_PX); + assert!(u.contains("getCoverArt.view")); + assert!(u.contains("t="), "an unauthenticated cover request is refused"); + assert!(u.contains("size=256")); + // The id is opaque and can contain `:` — it must be encoded, not pasted. + assert!(u.contains("id=al-abc_1%3A2"), "{u}"); + } +} diff --git a/crates/qbz-subsonic/tests/live.rs b/crates/qbz-subsonic/tests/live.rs new file mode 100644 index 000000000..237e360f5 --- /dev/null +++ b/crates/qbz-subsonic/tests/live.rs @@ -0,0 +1,259 @@ +//! LIVE tests against a real Subsonic-compatible server. Skipped unless the +//! environment names one. +//! +//! QBZ_SUBSONIC_URL=http://192.168.0.69:4533 \ +//! QBZ_SUBSONIC_USER=admin QBZ_SUBSONIC_PASS=... \ +//! cargo test -p qbz-subsonic --test live -- --nocapture +//! +//! Unlike Jellyfin's, these run happily in PARALLEL: Subsonic auth is +//! stateless — credentials ride on every request and there is no server-side +//! session to invalidate — so nothing here can revoke anything else's token. +//! That difference is itself worth knowing, and it is why this file has no +//! shared-session machinery. + +use std::env; +use std::sync::Once; + +use qbz_subsonic::{Credentials, SubsonicClient, SubsonicError, SweepMode}; + +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)> { + let v = ( + env::var("QBZ_SUBSONIC_URL").ok()?, + env::var("QBZ_SUBSONIC_USER").ok()?, + env::var("QBZ_SUBSONIC_PASS").ok()?, + ); + install_crypto_provider(); + Some(v) +} + +macro_rules! skip_without_server { + () => { + match server() { + Some(v) => v, + None => { + eprintln!("SKIP: set QBZ_SUBSONIC_URL / _USER / _PASS to run the live tests"); + return; + } + } + }; +} + +fn connect(url: &str, user: &str, pass: &str) -> SubsonicClient { + // A FIXED salt, as the app uses one — see `Credentials`' docs for why a + // rolling salt would re-download every cover on every pass. + SubsonicClient::new(url, Credentials::new(user, pass, "qbzlive")).unwrap() +} + +#[tokio::test] +async fn ping_reports_an_opensubsonic_server() { + let (url, user, pass) = skip_without_server!(); + let info = connect(&url, &user, &pass).ping().await.expect("ping"); + eprintln!( + "server: {} {} (openSubsonic={})", + info.kind, + info.server_version.as_deref().unwrap_or(&info.version), + info.open_subsonic + ); + // Without the OpenSubsonic extensions there is no bitDepth / samplingRate + // and every quality badge in the app degrades to unknown. Worth asserting + // on the bench so a server downgrade is loud. + assert!(info.open_subsonic, "this server is not OpenSubsonic"); +} + +/// THE TRAP, end to end. A wrong password is answered with **HTTP 200** and a +/// failure envelope; only the envelope parser can tell. If this ever starts +/// passing as success, every other assertion in the suite is worthless. +#[tokio::test] +async fn wrong_credentials_fail_despite_an_http_200() { + let (url, user, _) = skip_without_server!(); + let c = SubsonicClient::new( + &url, + Credentials::new(&user, "definitely-not-the-password", "qbzlive"), + ) + .unwrap(); + match c.ping().await { + Err(SubsonicError::Unauthorized) => {} + other => panic!("expected Unauthorized, got {other:?}"), + } + + // And the raw truth underneath it: the transport really did say 200. + let bad = Credentials::new(&user, "definitely-not-the-password", "qbzlive"); + let raw = format!( + "{}/ping.view?{}", + qbz_subsonic::normalize_base_url(&url), + bad.query() + ); + let status = reqwest::get(&raw).await.expect("raw ping").status(); + assert_eq!( + status.as_u16(), + 200, + "the protocol stopped answering 200 on failure — the envelope guard may be redundant now" + ); +} + +#[tokio::test] +async fn the_fast_sweep_is_detected_and_paginates() { + let (url, user, pass) = skip_without_server!(); + let c = connect(&url, &user, &pass); + let mode = c.detect_sweep_mode().await; + eprintln!("sweep mode: {mode:?}"); + assert_eq!(mode, SweepMode::Search3, "Navidrome should honour search3"); + + let first = c.search_page(0).await.expect("page 0"); + assert!(!first.is_empty()); + let second = c.search_page(500).await.expect("page 500"); + assert!(!second.is_empty(), "the second page came back empty"); + // Real pagination, not the same page twice. + assert_ne!(first[0].id, second[0].id, "songOffset did not move the window"); + + let hires = first + .iter() + .filter(|t| t.bit_depth.unwrap_or(0) > 16 || t.sample_rate_hz.unwrap_or(0) > 48_000) + .count(); + let with_depth = first.iter().filter(|t| t.bit_depth.is_some()).count(); + assert!( + with_depth > 0, + "no row carried a bit depth — this server is not returning OpenSubsonic fields" + ); + for t in first.iter().filter(|t| t.suffix == "mp3") { + assert_eq!(t.bit_depth, None, "an mp3 reported a bit depth: {}", t.title); + } + eprintln!( + "page 0: {} rows, {} with a bit depth, {} hi-res", + first.len(), + with_depth, + hires + ); +} + +/// The PORTABLE path has to work too — it is the fallback for every server +/// that was never on the bench. +#[tokio::test] +async fn the_per_album_sweep_also_returns_tracks() { + let (url, user, pass) = skip_without_server!(); + let c = connect(&url, &user, &pass); + let ids = c.album_ids(0).await.expect("album ids"); + assert!(!ids.is_empty(), "no albums"); + let tracks = c.album_tracks(&ids[0]).await.expect("album tracks"); + assert!(!tracks.is_empty(), "album {} has no tracks", ids[0]); + for t in &tracks { + assert!(!t.id.is_empty()); + assert!(!t.album_id.is_empty(), "track {} has no album id", t.title); + } + // getAlbumList2 caps at 500 whatever `size` asks for. + assert!(ids.len() <= 500, "the server returned {} albums", ids.len()); + eprintln!("per-album: {} album ids, first album has {} tracks", ids.len(), tracks.len()); +} + +/// THE audio contract. `format=raw` must hand back the ORIGINAL bytes, and the +/// server's own reported `size` is an independent check that nothing was +/// re-encoded on the way out. +#[tokio::test] +async fn the_stream_is_raw_seekable_and_the_reported_size() { + let (url, user, pass) = skip_without_server!(); + let c = connect(&url, &user, &pass); + let page = c.search_page(0).await.expect("page"); + let track = page + .iter() + .find(|t| t.suffix == "flac" && t.size.is_some()) + .expect("no flac with a reported size in the first page"); + + let resp = reqwest::Client::new() + .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 range = resp + .headers() + .get(reqwest::header::CONTENT_RANGE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + 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!( + qbz_subsonic::looks_like_audio(Some(&ctype), bytes.len()), + "the body looks like an error envelope, not audio: {ctype}" + ); + assert_eq!(&bytes[..4], b"fLaC", "not a FLAC bitstream"); + // `Content-Range: bytes 0-65535/` — the total must be the size the + // API reported for the file. A transcode could not match it. + let total: u64 = range + .rsplit('/') + .next() + .and_then(|t| t.parse().ok()) + .expect("no total in Content-Range"); + assert_eq!( + total, + track.size.unwrap(), + "streamed length differs from the reported file size — the server re-encoded" + ); + eprintln!("stream: {} -> 206 {ctype}, total {total} == reported size", track.title); +} + +/// Cover art works WITH credentials, and — the trap again — an unauthenticated +/// request answers 200 with something that is not an image. +#[tokio::test] +async fn cover_art_needs_credentials_and_says_so_with_a_200() { + let (url, user, pass) = skip_without_server!(); + let c = connect(&url, &user, &pass); + let page = c.search_page(0).await.expect("page"); + let track = page + .iter() + .find(|t| t.cover_art.is_some()) + .expect("no row in the first page has cover art"); + let art = track.cover_art.as_deref().unwrap(); + + let resp = reqwest::get(c.cover_url(art, qbz_subsonic::IMAGE_PX)) + .await + .expect("cover"); + 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/"), "authenticated cover returned {ctype}"); + let len = resp.bytes().await.expect("cover body").len(); + assert!(len > 1024); + + // Same endpoint, no credentials: HTTP 200, and NOT an image. + let naked = format!( + "{}/getCoverArt.view?v={}&c=QBZ&id={}", + qbz_subsonic::normalize_base_url(&url), + qbz_subsonic::API_VERSION, + art + ); + let bad = reqwest::get(&naked).await.expect("naked cover"); + assert_eq!(bad.status().as_u16(), 200, "an unauthenticated cover was NOT a 200"); + let bad_ct = bad + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let bad_body = bad.bytes().await.expect("naked body"); + assert!( + !qbz_subsonic::looks_like_audio(Some(&bad_ct), bad_body.len()), + "the guard failed to reject an error envelope" + ); + eprintln!( + "cover: authed {ctype} {len} B · unauthed HTTP 200 {bad_ct} {} B", + bad_body.len() + ); +}