Files
qbz/crates/qbz-qt/src/media_servers_qt.rs
T
vicrodh 6328994c00 fix(library): read "DSD" back out of the database as DSD, not Unknown
`LibraryDatabase::parse_format` is the inverse of `AudioFormat`'s Display,
which is what the scanner stores. It had no arm for "DSD", so every DSD row
came back out of the DB as `Unknown`: the album header printed "UNKNOWN" as
its format, the quality badge said CD (depth 1 IS a known depth below 24) and
the detail line read "1-bit / 2822.4 kHz". The now-playing bar was right all
along because it reads the decoder, not the row — which is exactly the clue
that separated the two data sources.

A fold to `Unknown` is a VALID answer, so nothing failed: not the build, not
`cargo check` (it cannot see the match at all), not a single test.

Three sites had the same hole and all three are fixed:
  - `database.rs`, the one that bit;
  - `local_plex.rs`, same fold for a Plex server serving DSD;
  - `media_servers_qt.rs`, worse than the others — its fallback is `Flac`, so
    a .dsf from Jellyfin or Navidrome was not losing its format, it was being
    mislabelled as lossless PCM.

The guard is a round-trip over EVERY variant rather than a "DSD parses" test:
adding a variant without an arm now fails here instead of shipping the same
silent loss again. Verified against the real rows: format is stored as "DSD",
so this is a READ-side fix and no rescan is needed.

cc
2026-08-20 21:13:51 -06:00

461 lines
19 KiB
Rust

//! Jellyfin / Subsonic glue for this frontend.
//!
//! The Qt-side sibling of `local_plex`, and deliberately smaller than it: the
//! persisted model lives in `qbz_app::settings::media_servers` and every
//! runtime concern lives in `qbz-jellyfin` / `qbz-subsonic` / `qbz-source`
//! (ADR-006). This file owns three things only:
//!
//! 1. a process-global `MediaServerState` bound to the ACTIVE user
//! (`<data_dir>/qbz/users/<uid>/media_servers.db`);
//! 2. the gates the Local Library reads — [`configured_words`] and
//! [`remote_cache_path`], which together decide whether a remote row
//! appears in the grid at all;
//! 3. the identifiers whose STABILITY is load-bearing — Jellyfin's
//! `DeviceId` and Subsonic's salt — generated once and persisted.
//!
//! The credential glue that `qbz-source` consumes lives in `source_wiring`
//! beside the rest of the registry's injections, so there is one place to look
//! when a source answers "not configured".
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{LazyLock, RwLock};
use qbz_app::settings::media_servers::{MediaServerKind, MediaServerSettings, MediaServerState};
static STATE: LazyLock<MediaServerState> = LazyLock::new(MediaServerState::new);
/// The last read per server, so [`get`] is not a database hit.
///
/// SAME defect as `local_plex`'s, introduced the same way: `local_rows::art_ref`
/// asks the owning source per ROW what an artwork token means, and both media
/// sources answer by looking at whether they are connected. Without this, every
/// remote row in the grid costs a mutex plus a SQLite query — measured at
/// 39-92 µs/row against 2 µs for a row whose source needs no credentials.
///
/// Invalidated by [`put`], [`disconnect`], [`init_for_user`] and [`reset`], so
/// a connect or a master-toggle flip is still seen on the very next read.
static CACHE: LazyLock<RwLock<HashMap<&'static str, MediaServerSettings>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
fn invalidate_cache() {
if let Ok(mut c) = CACHE.write() {
c.clear();
}
}
/// Bind the store to the active user. Called from `auth_qt`.
pub fn init_for_user(base_dir: &std::path::Path) {
invalidate_cache();
STATE.init_at(base_dir);
// Mint the two stable identifiers ONCE, now, rather than at first use.
// Both are load-bearing and both are easy to get wrong lazily: a Jellyfin
// DeviceId minted per connection attempt revokes the previous token, and a
// salt minted per request re-downloads every cover.
ensure_identities();
}
pub fn reset() {
invalidate_cache();
STATE.reset();
}
pub fn get(kind: MediaServerKind) -> MediaServerSettings {
if let Ok(c) = CACHE.read() {
if let Some(s) = c.get(kind.as_str()) {
return s.clone();
}
}
let fresh = STATE.get(kind);
if let Ok(mut c) = CACHE.write() {
c.insert(kind.as_str(), fresh.clone());
}
fresh
}
pub fn put(kind: MediaServerKind, s: &MediaServerSettings) {
STATE.put(kind, s);
invalidate_cache();
}
pub fn disconnect(kind: MediaServerKind) {
STATE.disconnect(kind);
invalidate_cache();
}
/// Which remote sources the Local Library union may show.
///
/// ENABLED **and** finished configuring — see
/// `MediaServerSettings::is_configured`. A server that is toggled on but has no
/// credentials yet would widen the union's `source IN (…)` over rows nothing
/// ever swept.
pub fn configured_words() -> Vec<&'static str> {
// Through the CACHED `get`, not `STATE.configured_words()` — this is called
// once per albums-page load and twice more via `remote_cache_path`.
MediaServerKind::ALL
.iter()
.filter(|k| get(**k).is_configured(**k))
.map(|k| k.as_str())
.collect()
}
/// The shared remote mirror for the active user, or `None` when no remote
/// source is configured.
///
/// `None` short-circuits the ATTACH in `get_albums_metadata_page`: a user with
/// no media server should not pay for a database open on every page.
pub fn remote_cache_path() -> Option<PathBuf> {
if configured_words().is_empty() {
return None;
}
let uid = qbz_app::user_data::UserDataPaths::load_last_user_id().unwrap_or(0);
Some(
dirs::data_dir()?
.join("qbz")
.join("users")
.join(uid.to_string())
.join("remote_cache.db"),
)
}
/// Generate-once, persist-forever identifiers.
///
/// **Jellyfin's `DeviceId`.** The server keys its SESSION on it, and a second
/// `AuthenticateByName` under the same one REVOKES the previous token —
/// measured against 10.11.11:
///
/// ```text
/// same DeviceId: auth -> T1, auth -> T2; T1 = 401, T2 = 200
/// different DeviceId: auth -> A, auth -> B; A = 200, B = 200
/// ```
///
/// So it must be stable per INSTALL: a constant shared by every QBZ would make
/// two installs log each other out, and a fresh one per launch drops the
/// previous run's token and litters the server's devices page.
///
/// **Subsonic's salt.** `t = md5(password + salt)`, and the salt travels in
/// clear — it is not a secret. Fixing it per install is deliberate: rolling it
/// per request would make every cover URL unique, and the artwork cache keys on
/// the URL, so each pass would re-download every cover.
fn ensure_identities() {
let mut jf = STATE.get(MediaServerKind::Jellyfin);
if jf.device_id.is_empty() {
jf.device_id = format!("qbz-{}", random_hex(16));
STATE.put(MediaServerKind::Jellyfin, &jf);
log::info!("[qbz-qt] jellyfin: minted a device id for this install");
}
let mut sub = STATE.get(MediaServerKind::Subsonic);
if sub.salt.is_empty() {
sub.salt = random_hex(8);
STATE.put(MediaServerKind::Subsonic, &sub);
log::info!("[qbz-qt] subsonic: minted a salt for this install");
}
}
/// `len` hex characters of process-and-time entropy.
///
/// Not cryptographic, and it does not need to be: both consumers want
/// UNIQUENESS across installs, not unpredictability. The salt is transmitted in
/// clear by the protocol itself, and the device id is a label the server files
/// a session under. Pulling in an RNG crate for two strings that are generated
/// once per install would be more dependency than the job.
fn random_hex(len: usize) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut out = String::with_capacity(len);
let mut seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
^ (std::process::id() as u64) << 32;
while out.len() < len {
let mut h = DefaultHasher::new();
seed.hash(&mut h);
seed = h.finish();
out.push_str(&format!("{seed:016x}"));
}
out.truncate(len);
out
}
/// The `Credentials` a Subsonic call needs, derived from the stored password
/// and the install's fixed salt.
pub fn subsonic_credentials() -> Option<(String, qbz_subsonic::Credentials)> {
let s = STATE.get(MediaServerKind::Subsonic);
if !s.is_configured(MediaServerKind::Subsonic) {
return None;
}
Some((
s.base_url.clone(),
qbz_subsonic::Credentials::new(&s.username, &s.password, &s.salt),
))
}
/// `(base_url, access_token)` when Jellyfin is usable right now.
pub fn jellyfin_server() -> Option<(String, String)> {
let s = STATE.get(MediaServerKind::Jellyfin);
s.is_configured(MediaServerKind::Jellyfin)
.then(|| (s.base_url.clone(), s.token.clone()))
}
// ---------------------------------------------------------------------------
// Connect / probe / purge — the settings panel's verbs
// ---------------------------------------------------------------------------
/// "Test connection", BEFORE the user is asked for a password.
///
/// Returns the server's display name. The point is to separate "wrong address"
/// from "wrong password": without it, every failure looks like bad credentials
/// and the user retypes a password that was never the problem.
///
/// Jellyfin has an unauthenticated probe (`/System/Info/Public`), so its test is
/// genuinely credential-free. Subsonic has none — `ping.view` needs credentials
/// — so its "test" can only confirm the address is reachable, and it reports
/// that honestly rather than claiming more.
pub async fn probe(kind: MediaServerKind, url: &str) -> Result<String, String> {
match kind {
MediaServerKind::Jellyfin => qbz_jellyfin::probe(url)
.await
.map(|i| format!("{} {}", i.server_name, i.version))
.map_err(|e| e.to_string()),
MediaServerKind::Subsonic => {
// No credential-free endpoint exists. A `ping` with an empty user
// returns the protocol's own "missing parameter" — which still
// proves a Subsonic server answered at this address, and that is
// exactly what the test is for.
let creds = qbz_subsonic::Credentials::new("", "", "probe");
let client = qbz_subsonic::SubsonicClient::new(url, creds).map_err(|e| e.to_string())?;
match client.ping().await {
Ok(i) => Ok(format!("{} {}", i.kind, i.version)),
// A protocol-level error means a Subsonic server IS there.
Err(qbz_subsonic::SubsonicError::Unauthorized)
| Err(qbz_subsonic::SubsonicError::Api { .. }) => {
Ok("a Subsonic server (sign in to continue)".to_string())
}
Err(e) => Err(e.to_string()),
}
}
}
}
/// Authenticate and persist.
///
/// What gets stored differs per protocol, and the asymmetry is the protocol's:
/// Jellyfin issues a token and never needs the password again; Subsonic has no
/// session, so the password is kept and its token is re-derived per request.
pub async fn connect(
kind: MediaServerKind,
url: &str,
username: &str,
password: &str,
) -> Result<(), String> {
let mut cfg = get(kind);
match kind {
MediaServerKind::Jellyfin => {
// The DeviceId must be the PERSISTED one: authenticating under a
// fresh id every time revokes the previous token and litters the
// server's device list. `init_for_user` minted it.
let session =
qbz_jellyfin::authenticate(url, &cfg.device_id, username, password)
.await
.map_err(|e| e.to_string())?;
let info = qbz_jellyfin::probe(url).await.ok();
cfg.base_url = qbz_jellyfin::normalize_base_url(url);
cfg.token = session.access_token;
// The USER ID, not the typed name: every `/Items` call keys on it,
// and the two are not interchangeable.
cfg.username = session.user_id;
cfg.server_id = session.server_id;
cfg.server_name = info.map(|i| i.server_name).unwrap_or_default();
// The password is deliberately NOT stored — the token replaced it.
cfg.password = String::new();
}
MediaServerKind::Subsonic => {
let creds = qbz_subsonic::Credentials::new(username, password, &cfg.salt);
let client = qbz_subsonic::SubsonicClient::new(url, creds).map_err(|e| e.to_string())?;
let info = client.ping().await.map_err(|e| e.to_string())?;
if !info.open_subsonic {
// Not fatal: the library still lists and still plays. But
// without the OpenSubsonic fields there is no bitDepth or
// samplingRate, so every quality badge degrades to unknown —
// and a user who cares about that deserves to hear it now
// rather than wonder later.
log::warn!(
"[qbz-qt] subsonic: {} is not OpenSubsonic — quality badges will be unavailable",
info.kind
);
}
cfg.base_url = qbz_subsonic::normalize_base_url(url);
cfg.username = username.to_string();
// KEPT, because the protocol re-derives its token on every request.
cfg.password = password.to_string();
cfg.token = String::new();
cfg.server_name = format!("{} {}", info.kind, info.version);
}
}
cfg.enabled = true;
put(kind, &cfg);
Ok(())
}
/// Drop this server's cached rows.
///
/// Only from DISCONNECT. The master toggle deliberately keeps them: turning a
/// server off should be cheap to undo, and re-sweeping a 5000-track Jellyfin
/// library costs 46 seconds.
pub fn purge_cache(kind: MediaServerKind) {
let source = match kind {
MediaServerKind::Jellyfin => qbz_media_cache::RemoteSource::Jellyfin,
MediaServerKind::Subsonic => qbz_media_cache::RemoteSource::Subsonic,
};
let handle = match kind {
MediaServerKind::Jellyfin => qbz_source::registry().jellyfin().cache(),
MediaServerKind::Subsonic => qbz_source::registry().subsonic().cache(),
};
match handle.with_mut(|c| qbz_media_cache::clear(c, source)) {
Some(Ok(n)) => log::info!("[qbz-qt] {} cache purged: {n} rows", kind.as_str()),
Some(Err(e)) => log::error!("[qbz-qt] {} cache purge failed: {e}", kind.as_str()),
None => log::warn!("[qbz-qt] {} cache purge: no user bound", kind.as_str()),
}
}
// ---------------------------------------------------------------------------
// The `LocalTrack` shape — what every Local Library surface consumes
// ---------------------------------------------------------------------------
/// Map a cached remote row into the `LocalTrack` shape the Local Library's
/// album page, per-disc menu, bulk bar and row cache all read.
///
/// The Plex sibling is `local_plex::map_cached_to_local_track`. Two fields are
/// decided differently and both matter:
///
/// - `file_path` carries the SERVER'S OWN id, not a path. A remote track has no
/// file on this machine, and this is the slot `local_queue_track` stamps into
/// `source_item_id_hint` — which is exactly what `claim` prefers, because a
/// server id survives a cache rebuild that renumbers rowids. Nothing ever
/// opens it as a path; `LocalSource` never sees these rows.
/// - `album_group_key` is the PREFIXED key (`jellyfin:<albumId>`), matching the
/// one the grid's SQL union publishes, so "go to album" from a track row
/// lands on the same page the card opens.
pub fn cached_to_local_track(t: qbz_media_cache::CachedTrack) -> qbz_library::LocalTrack {
qbz_library::LocalTrack {
id: t.id,
file_path: t.item_id,
title: t.title,
artist: t.artist.clone(),
album_artist: (!t.album_artist.is_empty()).then_some(t.album_artist),
album: t.album.clone(),
album_group_key: format!("{}:{}", t.source, t.album_id),
album_group_title: t.album,
track_number: t.track_number,
disc_number: t.disc_number,
duration_secs: t.duration_ms / 1000,
format: parse_audio_format(&t.container, t.codec.as_deref()),
bit_depth: t.bit_depth,
sample_rate: t.sample_rate_hz.unwrap_or(0) as f64,
year: t.year,
artwork_path: t.artwork_token,
source: Some(t.source),
..Default::default()
}
}
/// Container/codec name -> the enum the quality badge derives from.
///
/// Both are consulted because the two protocols disagree about which one is
/// populated: Jellyfin reports a `Container` and often a codec, Subsonic
/// reports a `suffix` and a MIME type.
fn parse_audio_format(container: &str, codec: Option<&str>) -> qbz_library::AudioFormat {
use qbz_library::AudioFormat as F;
let probe = |s: &str| -> Option<F> {
let s = s.to_ascii_lowercase();
// DSD first, and it matters MORE here than in the sibling parsers:
// this one falls back to `Flac`, not `Unknown`, so an unrecognised DSD
// track from Jellyfin/Navidrome does not merely lose its format — it
// is actively mislabelled lossless-PCM, with a "24-bit / …" badge it
// never had. Jellyfin reports `Container: "dsf"`, Subsonic a `suffix`
// of `dsf`/`dff` and a MIME of `audio/x-dsf`.
if s.contains("dsd") || s.contains("dsf") || s.contains("dff") {
Some(F::Dsd)
} else if s.contains("flac") {
Some(F::Flac)
} else if s.contains("mp3") || s.contains("mpeg") {
Some(F::Mp3)
} else if s.contains("alac") || s.contains("m4a") || s.contains("mp4") {
Some(F::Alac)
} else if s.contains("wav") {
Some(F::Wav)
} else if s.contains("aiff") {
Some(F::Aiff)
} else {
None
}
};
probe(container)
.or_else(|| codec.and_then(probe))
.unwrap_or(F::Flac)
}
/// The tracks of one remote album, by its PREFIXED grid key
/// (`jellyfin:<albumId>` / `subsonic:<albumId>`), in disc/track order.
///
/// `None` when the key is not a remote one, so the caller falls through to
/// `library.db` exactly as before.
pub fn album_tracks(prefixed_key: &str) -> Option<Vec<qbz_library::LocalTrack>> {
let (word, album_id) = prefixed_key.split_once(':')?;
let source = qbz_media_cache::RemoteSource::from_word(word)?;
let handle = match source {
qbz_media_cache::RemoteSource::Jellyfin => qbz_source::registry().jellyfin().cache(),
qbz_media_cache::RemoteSource::Subsonic => qbz_source::registry().subsonic().cache(),
};
let rows = handle
.with(|c| qbz_media_cache::album_tracks(c, source, album_id).unwrap_or_default())
.unwrap_or_default();
Some(rows.into_iter().map(cached_to_local_track).collect())
}
/// Substring search across one remote source, in the `LocalTrack` shape.
pub fn search_tracks(query: &str, limit: Option<u32>) -> Vec<qbz_library::LocalTrack> {
let mut out = Vec::new();
for kind in MediaServerKind::ALL {
if !get(kind).is_configured(kind) {
continue;
}
let (source, handle) = match kind {
MediaServerKind::Jellyfin => (
qbz_media_cache::RemoteSource::Jellyfin,
qbz_source::registry().jellyfin().cache(),
),
MediaServerKind::Subsonic => (
qbz_media_cache::RemoteSource::Subsonic,
qbz_source::registry().subsonic().cache(),
),
};
if let Some(rows) = handle
.with(|c| qbz_media_cache::search(c, source, query, limit).unwrap_or_default())
{
out.extend(rows.into_iter().map(cached_to_local_track));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The identifiers must be unique per install. They are generated once and
/// persisted, so the only property worth pinning here is that two draws do
/// not collide — a fixed string would silently make every install share a
/// Jellyfin session.
#[test]
fn the_generated_identifiers_are_the_right_length_and_differ() {
let a = random_hex(16);
let b = random_hex(16);
assert_eq!(a.len(), 16);
assert_eq!(random_hex(8).len(), 8);
assert_ne!(a, b, "two draws collided — every install would share a session");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
}