fix(qt): wire the source registry to its stores — it never was

This is the regression report from the previous build, and the cause is
that stage 1 built three injection sockets and nothing ever plugged into
them. `set_creds`, `set_ephemeral` and `bind_user` had zero call sites
in the entire frontend.

It stayed invisible for two stages because nothing needed them:
`claim` is pure, and `PlexSource::tracks`/`meta` read plex_cache.db
through qbz-plex's own global. The first consumers that DO need them are
playback and artwork — stages 3 and 4 — so routing those onto the seam
turned a latent gap into a live break:

- every Plex row: "plex: not configured (no Plex credentials
  configured)", so Plex playback died and every Plex cover in the Local
  Library grid resolved to ArtRef::Unavailable and vanished;
- every LOCAL row: an unbound DbPool refuses all reads, so LocalSource
  found no track and local playback died too;
- ephemeral rows had no session store to resolve against.

Wired now, each to the ONE authority that already existed: PlexCreds
over local_plex's settings store (read per call, never cached, so a
connect/disconnect mid-session is picked up), EphemeralTracks over the
session store, bind_user as the first statement of
bind_per_user_stores, teardown on logout, and the unbound-window
fallback so pre-login reads resolve the way local_state::with_db already
resolves them.

Also wired: the album identity mode. The grouping IS the query, so a
metadata-mode user's album play would have resolved a different track
list than the grid rendered.

`report_wiring` logs one line at bind time asking each socket a question
only a wired one can answer. The failure this fixes is silent by
construction — an unbound pool and a disabled Plex look identical to a
user with no local tracks and Plex switched off — and it stayed hidden
because the tests are pure-function tests and the structural gate only
proves the OLD path is gone, never that the new one is connected.

Verified against the running binary, not the test suite:
  source registry bound: local_tracks=617 plex=connected
  ephemeral_store=yes

qbz-qt 423/423, qbz-source 59/59.

cc
This commit is contained in:
vicrodh committed 2026-08-20 02:12:17 -06:00
1 parent 68235a4012
commit 604f336e24
6 files changed
+234

No files matched your search

+13
View File
@@ -208,6 +208,14 @@ where
/// logs on failure, never blocks shell entry) — one entry point, so the caller
/// cannot bind the uid and forget the schema.
async fn bind_per_user_stores(dir: &std::path::Path, user_id: u64) {
// THE SOURCE REGISTRY FIRST, before any other store. `myqbz_qt::
// init_for_user` below runs the mixtape migrations through
// `library_db_qt::with_db(true, …)`, and against an unbound pool a fresh
// account can never create a collection. It is also what makes local and
// Plex playback work at all: an unbound `DbPool` refuses every read, and
// `PlexSource` with no credentials answers `NotConfigured` — which is
// exactly what shipped for one build when this line was missing.
crate::source_wiring::bind_user(dir, user_id);
// MyQBZ: branding (label + icon, read by the sidebar/nav flyout on frame
// 1) and the grids + mixtape schema.
crate::myqbz_prefs_qt::init_for_user(dir);
@@ -459,6 +467,11 @@ where
crate::myqbz_builder_qt::teardown();
crate::artist_blacklist::teardown();
crate::reco_dismiss_qt::teardown();
// Every source's per-user handle and cache: the pooled `library.db`
// handles, the Plex art memo, the ephemeral session store. Without this
// the next account inherits the previous one's handles out of a `'static`
// registry.
crate::source_wiring::teardown();
crate::offline_fwd::teardown();
// The offline download cache (index.db/library connections + the cached
// id set) — torn down so the next account never reads this one's rows.
+8
View File
@@ -95,6 +95,14 @@ fn album_key_of(t: &LocalTrack) -> String {
}
/// The tracks of one album block, in scan order.
///
/// `pub(crate)` under a second name for `source_wiring::EphemeralGlue`: the
/// seam's `LocalSource` resolves an ephemeral album through this store rather
/// than through `library.db`, and it needs the same scan order the pane shows.
pub(crate) fn album_tracks_for(group_key: &str) -> Vec<LocalTrack> {
album_tracks(group_key)
}
fn album_tracks(group_key: &str) -> Vec<LocalTrack> {
STATE
.tracks_snapshot()
+5
View File
@@ -279,6 +279,11 @@ pub fn set_album_mode(mode: &str) {
let mode = if mode == "metadata" { "metadata" } else { "folder" };
state(|s| s.album_mode = mode.to_string());
update_prefs(|p| p.albums_id_mode = mode.to_string());
// The grouping IS the query: `LocalSource` resolves an album's tracks
// through `get_album_tracks_metadata` or `get_album_tracks` depending on
// it. Leaving the source on its default would make album PLAYBACK use a
// different track list than the grid just rendered.
crate::source_wiring::sync_album_group_mode();
}
pub fn album_mode() -> String {
+8
View File
@@ -125,6 +125,9 @@ mod atmosphere_qt;
// `play_audible` that each matched on `track.source` by hand.
mod audible_qt;
mod bridge;
// The registry's injections: Plex credentials, the ephemeral store, the user
// binding and the album-identity mode. See its header.
mod source_wiring;
mod custom_theme_qt;
mod diagnostics_qt;
mod discover_config_qt;
@@ -509,6 +512,11 @@ pub(crate) fn on_boot() {
guard.as_ref().cloned()
})
}));
// ...and its INJECTIONS immediately after. The registry is built lazily by
// whoever touches it first, so publishing these here — before any view can
// resolve a row — is what keeps a Plex cover or a local play from asking an
// unwired source (see `source_wiring`'s header for what that looked like).
source_wiring::install();
// Offline guardrails FIRST (before the login screen can show): engine +
// connectivity actor, then the live status forwarder. Both need the
+191
View File
@@ -0,0 +1,191 @@
//! Wiring the `qbz-source` registry to this frontend's stores.
//!
//! # Why this file exists, and what it cost not to have it
//!
//! `qbz-source` deliberately does not link `qbz-app` / `qbz-core` / `qbz-player`
//! (design 02 §8), so it cannot open the Plex settings store, cannot reach the
//! ephemeral session store, and does not know which user's `library.db` to
//! read. All three are INJECTED by the frontend. Stage 1 built the sockets and
//! nothing ever plugged into them.
//!
//! That was invisible for two stages. `SourceRegistry::claim` is pure, and
//! `PlexSource::tracks` / `meta` read `plex_cache.db` through `qbz-plex`'s own
//! global — neither needs a credential or a bound pool. The first consumers
//! that DO need them are playback and artwork, i.e. stages 3 and 4, and the
//! moment those landed the seam started answering:
//!
//! ```text
//! ERROR local play: track 1099511674716 not playable: plex: not configured
//! ```
//!
//! for every Plex row, nothing at all for every local row (an unbound
//! [`DbPool`] refuses reads), and `ArtRef::Unavailable` for every Plex cover —
//! which is why the Local Library grid lost its Plex artwork. The unit tests
//! could not see any of it: they exercise pure ladders (`rating_key`,
//! `artwork_token`, `claim`), and there is no bound user in a test binary.
//!
//! **The lesson, recorded because it is the expensive one:** a structural gate
//! ("the old code is gone") and a green test suite prove that the OLD path was
//! removed, never that the NEW path is connected. A seam has to be exercised
//! against a bound user before anything is routed onto it.
//!
//! # What is injected
//!
//! | socket | source | this frontend's authority |
//! |---|---|---|
//! | [`PlexCreds`] | `PlexSource` | `local_plex::is_enabled` / `settings` |
//! | [`EphemeralTracks`] | `LocalSource` | `local_ephemeral`'s session store |
//! | user dir | every source, via `bind_user` | `auth_qt::bind_per_user_stores` |
//! | album group mode | `LocalSource` | `local_state::group_mode` (a persisted UI pref) |
//!
//! Each one has exactly ONE authority, and it is the one that already existed:
//! opening a second copy of the Plex settings DB or a second ephemeral store
//! would be a second source of truth, which is the thing the seam exists to
//! remove.
use std::sync::Arc;
use qbz_library::LocalTrack;
/// `PlexSource`'s view of the credentials store this frontend already owns.
struct PlexCredsGlue;
impl qbz_source::PlexCreds for PlexCredsGlue {
fn is_enabled(&self) -> bool {
crate::local_plex::is_enabled()
}
fn server(&self) -> Option<(String, String)> {
// Checked exactly as `local_playback`'s old Plex arm checked it: both
// halves non-empty, or Plex is not usable right now.
let cfg = crate::local_plex::settings();
if cfg.base_url.is_empty() || cfg.token.is_empty() {
return None;
}
Some((cfg.base_url, cfg.token))
}
}
/// `LocalSource`'s view of the session-scoped ephemeral store.
struct EphemeralGlue;
impl qbz_source::EphemeralTracks for EphemeralGlue {
fn get_track(&self, id: i64) -> Option<LocalTrack> {
crate::local_ephemeral::get_track(id)
}
fn album_tracks(&self, group_key: &str) -> Vec<LocalTrack> {
crate::local_ephemeral::album_tracks_for(group_key)
}
}
/// Publish the process-lifetime injections: the Plex credentials lens, the
/// ephemeral store, and the unbound-window fallback.
///
/// Called from `on_boot`, right after `init_registry`. None of these is
/// per-user — the creds glue READS the active store on every call rather than
/// caching it, so a Plex connect/disconnect mid-session is picked up with no
/// re-publish (the same clone-then-drop discipline the Qobuz client lens uses,
/// and for the same reason: a cached copy goes stale silently).
pub(crate) fn install() {
let registry = qbz_source::registry();
registry.plex().set_creds(Some(Arc::new(PlexCredsGlue)));
registry.local().set_ephemeral(Some(Arc::new(EphemeralGlue)));
// Reads BEFORE a user is bound (splash, session restore) must resolve the
// way `local_state::with_db` already resolves them — through
// `load_last_user_id` — or the window between boot and
// `bind_per_user_stores` answers "no library" instead of the last user's
// rows. This mirrors today's behaviour rather than changing it.
registry
.local()
.set_unbound_fallback(Some(Box::new(crate::local_state::db_path)));
sync_album_group_mode();
log::info!("[qbz-qt] source registry: creds + ephemeral store published");
}
/// Push the persisted album-identity pref into `LocalSource`.
///
/// The grouping IS the query: in metadata mode an album's tracks come from
/// `get_album_tracks_metadata`, in folder mode from `get_album_tracks`. The
/// source defaults to Folder, so without this a metadata-mode user's album
/// play resolves a DIFFERENT track list than the grid showed them.
///
/// Called at boot, on every user bind, and from `local_state::set_album_mode`
/// when the user flips the toolbar.
pub(crate) fn sync_album_group_mode() {
qbz_source::registry()
.local()
.set_album_group_mode(crate::local_state::group_mode());
}
/// Bind every source to the active user's data directory.
///
/// MUST be the FIRST statement of `auth_qt::bind_per_user_stores`:
/// `myqbz_qt::init_for_user` runs the mixtape migrations through
/// `library_db_qt::with_db(true, …)`, and against an unbound pool a fresh
/// account can never create a collection.
pub(crate) fn bind_user(dir: &std::path::Path, user_id: u64) {
qbz_source::registry().bind_user(user_id, dir);
sync_album_group_mode();
report_wiring();
}
/// ASK each socket a question only a wired one can answer, and log the answers.
///
/// This is not decoration. The failure it exists to catch is silent by
/// construction: an unbound [`DbPool`] returns `None` from every read and an
/// uncredentialed `PlexSource` returns `ArtRef::Unavailable`, so an unwired
/// registry looks exactly like "this user has no local tracks and Plex is
/// switched off" — right up until the first playback attempt, several screens
/// later, blames a service that has nothing to do with the track. One line at
/// bind time turns that into something greppable at the moment it happens.
///
/// Both probes are cheap: one indexed COUNT, and one pure string
/// interpretation with no I/O at all.
fn report_wiring() {
use qbz_source::{ArtRef, ArtSize, Source};
let registry = qbz_source::registry();
let tracks = registry
.local()
.with(|db| db.count_all_local_tracks())
.map(|n| n.to_string())
.unwrap_or_else(|| "UNBOUND".to_string());
// The exact call the Local Library grid makes for a Plex row. `Fetch`
// means credentials resolved; `Unavailable` is the "no Plex credentials
// configured" state that cost a release build.
let plex = match registry
.plex()
.artwork_token("/library/metadata/0/thumb/0", ArtSize::Card)
{
ArtRef::Fetch { .. } => "connected",
ArtRef::Unavailable(why) => why,
_ => "not recognised",
};
let ephemeral = if registry.local().has_ephemeral_store() {
"yes"
} else {
"NO"
};
log::info!(
"[qbz-qt] source registry bound: local_tracks={tracks} plex={plex} ephemeral_store={ephemeral}"
);
}
/// Drop every per-user handle and cache. Called from the logout path.
///
/// Overriding `teardown` is mandatory for any source holding per-user state —
/// the default is a no-op, so a source that forgets it leaks the previous
/// account's handle into a `'static` registry.
pub(crate) fn teardown() {
qbz_source::registry().teardown();
// The creds lens and the ephemeral glue are process-lifetime and stateless
// (they read their authority on every call), so they are re-published
// rather than left cleared — `PlexSource::teardown` drops the handle.
let registry = qbz_source::registry();
registry.plex().set_creds(Some(Arc::new(PlexCredsGlue)));
registry.local().set_ephemeral(Some(Arc::new(EphemeralGlue)));
registry
.local()
.set_unbound_fallback(Some(Box::new(crate::local_state::db_path)));
}