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:
6 files changed
+234
No files matched your search
@@ -208,6 +208,14 @@ where
|
|||||||
/// logs on failure, never blocks shell entry) — one entry point, so the caller
|
/// logs on failure, never blocks shell entry) — one entry point, so the caller
|
||||||
/// cannot bind the uid and forget the schema.
|
/// cannot bind the uid and forget the schema.
|
||||||
async fn bind_per_user_stores(dir: &std::path::Path, user_id: u64) {
|
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
|
// MyQBZ: branding (label + icon, read by the sidebar/nav flyout on frame
|
||||||
// 1) and the grids + mixtape schema.
|
// 1) and the grids + mixtape schema.
|
||||||
crate::myqbz_prefs_qt::init_for_user(dir);
|
crate::myqbz_prefs_qt::init_for_user(dir);
|
||||||
@@ -459,6 +467,11 @@ where
|
|||||||
crate::myqbz_builder_qt::teardown();
|
crate::myqbz_builder_qt::teardown();
|
||||||
crate::artist_blacklist::teardown();
|
crate::artist_blacklist::teardown();
|
||||||
crate::reco_dismiss_qt::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();
|
crate::offline_fwd::teardown();
|
||||||
// The offline download cache (index.db/library connections + the cached
|
// The offline download cache (index.db/library connections + the cached
|
||||||
// id set) — torn down so the next account never reads this one's rows.
|
// id set) — torn down so the next account never reads this one's rows.
|
||||||
|
|||||||
@@ -95,6 +95,14 @@ fn album_key_of(t: &LocalTrack) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The tracks of one album block, in scan order.
|
/// 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> {
|
fn album_tracks(group_key: &str) -> Vec<LocalTrack> {
|
||||||
STATE
|
STATE
|
||||||
.tracks_snapshot()
|
.tracks_snapshot()
|
||||||
|
|||||||
@@ -279,6 +279,11 @@ pub fn set_album_mode(mode: &str) {
|
|||||||
let mode = if mode == "metadata" { "metadata" } else { "folder" };
|
let mode = if mode == "metadata" { "metadata" } else { "folder" };
|
||||||
state(|s| s.album_mode = mode.to_string());
|
state(|s| s.album_mode = mode.to_string());
|
||||||
update_prefs(|p| p.albums_id_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 {
|
pub fn album_mode() -> String {
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ mod atmosphere_qt;
|
|||||||
// `play_audible` that each matched on `track.source` by hand.
|
// `play_audible` that each matched on `track.source` by hand.
|
||||||
mod audible_qt;
|
mod audible_qt;
|
||||||
mod bridge;
|
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 custom_theme_qt;
|
||||||
mod diagnostics_qt;
|
mod diagnostics_qt;
|
||||||
mod discover_config_qt;
|
mod discover_config_qt;
|
||||||
@@ -509,6 +512,11 @@ pub(crate) fn on_boot() {
|
|||||||
guard.as_ref().cloned()
|
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 +
|
// Offline guardrails FIRST (before the login screen can show): engine +
|
||||||
// connectivity actor, then the live status forwarder. Both need the
|
// connectivity actor, then the live status forwarder. Both need the
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -211,6 +211,15 @@ impl LocalSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is the frontend's ephemeral session store published?
|
||||||
|
///
|
||||||
|
/// For the boot-time wiring report only (`source_wiring::report_wiring`):
|
||||||
|
/// an absent store makes every ephemeral row resolve as "not found", which
|
||||||
|
/// is indistinguishable from an empty session unless somebody asks.
|
||||||
|
pub fn has_ephemeral_store(&self) -> bool {
|
||||||
|
self.ephemeral.read().map(|e| e.is_some()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn ephemeral(&self) -> Option<Arc<dyn EphemeralTracks>> {
|
fn ephemeral(&self) -> Option<Arc<dyn EphemeralTracks>> {
|
||||||
self.ephemeral.read().ok().and_then(|s| s.clone())
|
self.ephemeral.read().ok().and_then(|s| s.clone())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user