feat(source): wire My QBZ through the seam, and land the owner's container rules

Stage 2. The measured win: opening a 34-item collection now costs **2** library.db
opens instead of 23-34 — the local source owns a pooled handle and hands out no
guard, only `with(|db| ...)`, so a `!Send` `&LibraryDatabase` cannot cross an
await either. Bug 1 from the seam design is dead by construction, not by care.

The owner's rules, decided today, now live in one place instead of eight:
- **Collection** takes albums, singles and EPs from ANY source — qobuz, folders,
  Plex, and whatever is added next. **Mixtape** takes albums, playlists and
  tracks. This was a single boolean (`restrict_to_mixtape`) that could express
  neither release types nor "collections accept local".
- An unsupported combination must never be VISIBLE: the picker stops offering
  the impossible action, and the error stays a log-only safety net. Owner: "el
  bloqueo no debería ser visible nunca, más bien esto hay que ajustarlo en la UI".
- **Ephemeral honours its name** — playable, queueable, and offerable to nothing
  else.
- A row's source comes from the ROW. Eight QML sites built the add-to-mixtape
  payload with `"source": "qobuz"` written as a literal, so a Plex or local
  track was stored as Qobuz and afterwards could not play at all: the app asked
  the Qobuz API for an id that never existed there.

`QobuzSource` reads the client through a lens on every call instead of holding a
clone. That is not style: `qbz-core` REPLACES its client (core.rs:346 and :384,
both on paths that run before `set_session`), so a cached clone goes stale and
fails silently — the hazard the crate's own doc comment described.

The lens is installed in `on_boot`, first of everything, and finding out why is
the lesson of this commit. The gate was green — build, 142 tests, both QML
audits, a clean offscreen smoke — and the feature was visibly broken: every
Qobuz row showed "No results found", an empty Quality cell and a wrong "SINGLE"
type, while the Plex rows resolved fine. The registry is a OnceLock, so whoever
touches it first BUILDS it, and one built without the lens leaves Qobuz
permanently detached. Only driving the real app surfaced it; the crate's error
string named the cause exactly, which is the one thing that made it a ten-minute
fix instead of an afternoon.

cc
This commit is contained in:
vicrodh committed 2026-07-30 17:14:40 -06:00
1 parent f1b4241342
commit b6cf06ff2a
20 files changed
+1574 -172

No files matched your search

+575
View File
@@ -0,0 +1,575 @@
//! **R1 / R3 — which container accepts which item.** One rule, one predicate,
//! one place.
//!
//! # The rules, verbatim
//!
//! - **R1.** A **Collection** takes albums, singles and EPs, from ANY source —
//! `qobuz` or `local`, and `local` covers folders, Plex and anything added
//! later. A **Mixtape** takes albums, playlists and tracks.
//! - **R2.** An unsupported combination must NEVER be visible. The `Err` a
//! resolver returns stays where it is as the safety net that only reaches the
//! log; the fix belongs in the UI, which must simply not offer the impossible
//! action. That is what this module is for: [`Container::accepts`] is the
//! filter the UI applies BEFORE it renders an action.
//! - **R3.** Ephemeral honours its name. An ephemeral item can only be PLAYED
//! and only be added to the QUEUE — never to a playlist, a mixtape or a
//! collection. It leaves no trace in any library.
//! - **R4.** A row's source comes from the ROW, never from a literal. Note how
//! the rule below reads: the Collection arm never looks at
//! [`ItemFacts::source`] at all (R1 says "any source"), and the one arm that
//! does needs POSITIVE evidence of Qobuz. There is no `"qobuz"` literal for a
//! caller to get wrong — build [`ItemFacts`] from the row with
//! [`ItemFacts::from_raw`] / [`ItemFacts::from_media`] / [`ItemFacts::with_source_word`].
//!
//! # What this replaces
//!
//! `myqbz_add_qt`'s `restrict_to_mixtape` boolean, which could only say
//! "mixtapes only" vs "everything" — it could not say that a LOCAL album
//! belongs in a Collection while a LOCAL playlist belongs nowhere, and it could
//! not say anything at all about a per-row flyout. The rule is a property of
//! the ITEM, so it lives with the item's other properties.
//!
//! # "album", "single" and "EP" are RELEASE TYPES, not kinds
//!
//! The codebase already spells this out: `myqbz_builder_qt::classify_release`
//! (myqbz_builder_qt.rs:287-327) returns one of `"album" | "ep" | "single" |
//! "live" | "compilation"` for something that is one `ItemType::Album`, and
//! `qbz_models`' `Album.release_type` (types.rs:470) carries the same word off
//! the wire. So R1's "albums, singles, EPs" is ONE [`ItemKind::Album`] slot
//! ([`Accepted::Release`]), not three kinds — see [`RELEASE_TYPES`].
use qbz_library::ephemeral::EPHEMERAL_ID_FLOOR;
use crate::id::{ItemKind, MediaRef, RawRef, SourceId};
use crate::meta::ItemMeta;
/// Every release-type word the codebase can produce for an
/// [`ItemKind::Album`], from `classify_release` (myqbz_builder_qt.rs:287-327)
/// and `Album.release_type` (`qbz-models/src/types.rs:470`).
///
/// R1 names the first three. The predicate does **not** gate on this list: a
/// live record and a compilation are still albums, and an unrecognised or
/// missing word must not make a legitimate album disappear from the picker
/// (that would be R2 in reverse — an invisible refusal). The list is here so
/// the vocabulary has ONE home and so the tests can pin it.
pub const RELEASE_TYPES: [&str; 5] = ["album", "single", "ep", "live", "compilation"];
/// Is `w` a release-type word this codebase knows?
pub fn is_release_word(w: &str) -> bool {
RELEASE_TYPES
.iter()
.any(|r| w.trim().eq_ignore_ascii_case(r))
}
/// Is this id a session-scoped EPHEMERAL id (R3)?
///
/// The test is on the ID ALONE, deliberately — never on the row's source word.
/// Several call sites stamp a literal `"source": "qobuz"` onto whatever is
/// playing (`PlayerBar.qml:559`, `NowPlayingBarSmall.qml:508`), so a
/// source-gated test would wave an ephemeral now-playing track straight
/// through. Ephemeral ids are synthetic and high (`>= 2^48`,
/// `qbz_library::ephemeral::EPHEMERAL_ID_FLOOR`); neither a `local_tracks` row
/// id, nor a Plex namespaced id (`2^40`), nor a Qobuz track id ever reaches
/// that floor. Same test as `LocalSource::is_ephemeral`
/// (`sources/local.rs:244`) and `local_ephemeral::is_ephemeral_id`.
pub fn is_ephemeral_id(id: &str) -> bool {
matches!(id.trim().parse::<i64>(), Ok(n) if n >= EPHEMERAL_ID_FLOOR)
}
/// A container the user can put an item INTO.
///
/// [`Collection`](Container::Collection) and [`Mixtape`](Container::Mixtape)
/// are R1's two. [`Queue`](Container::Queue) and
/// [`Playlist`](Container::Playlist) are named by R3 ("only be added to the
/// QUEUE… never to a playlist"), and are needed for R3 to be expressible at
/// all.
///
/// The MyQBZ picker's third row kind, an *artist collection* (a built
/// discography), is a [`Collection`](Container::Collection) here: it holds
/// whole albums and accepts exactly what a collection accepts. The only
/// difference is that it cannot be created from the picker, which is a UI
/// property, not an acceptance rule.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Container {
/// The play queue. The only container R3 lets an ephemeral item reach.
Queue,
/// A MyQBZ collection (and an artist collection).
Collection,
/// A MyQBZ mixtape.
Mixtape,
/// A playlist.
Playlist,
}
/// One thing a container accepts — R1's vocabulary, as data.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Accepted {
/// A release: an album, a single or an EP. All three are RELEASE TYPES of
/// the one [`ItemKind::Album`] (see [`RELEASE_TYPES`]), so this is one
/// slot, not three.
Release,
/// A playlist — and only one this build can actually resolve.
///
/// `qbz_mixtape::enqueue::resolve_local_item` answers `(Playlist, Local)`
/// with the hard error `"local playlists not supported in this release"`
/// (enqueue.rs:418-424: the schema has no local-only playlist id to
/// resolve against), and neither [`crate::LocalSource`] nor
/// [`crate::PlexSource`] claims a playlist either (`local.rs:601-606`,
/// `plex.rs:307-310` both answer `Unsupported`). That error is exactly the
/// unsupported combination R2 talks about; it stays where it is as the
/// safety net, and this slot is what makes it unreachable from the UI.
Playlist,
/// A single track.
Track,
/// A filesystem folder. `local_playback::enqueue` routes kind `"folder"`
/// and [`crate::LocalSource`] resolves it (`local.rs:589-591`); it is
/// playable, so it is queueable, and it is storable nowhere.
Folder,
}
/// Why a container refused an item.
///
/// **Log-only, by R2.** These strings are never rendered: an unsupported
/// combination must never be visible, so a refusal means the UI should not have
/// offered the action in the first place, and reaching one is a bug report to
/// the log — not a message to a user. There is deliberately no msgid for any of
/// it and there must not be one.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Refusal {
/// R3: ephemeral leaves no trace.
Ephemeral,
/// R1: this container does not take this kind of item at all.
Kind(ItemKind),
/// The container takes playlists, but not one from this source — nothing
/// outside Qobuz resolves to a playlist (see [`Accepted::Playlist`]).
/// `None` = the row did not say where it came from.
UnresolvablePlaylist(Option<SourceId>),
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::Ephemeral => f.write_str("ephemeral items are play/queue only (R3)"),
Refusal::Kind(k) => write!(f, "this container does not accept a {} (R1)", k.label()),
Refusal::UnresolvablePlaylist(src) => match src {
Some(s) => write!(
f,
"a {s} playlist cannot be resolved; only qobuz playlists can"
),
None => f.write_str("a playlist with no source cannot be resolved"),
},
}
}
}
/// The facts a row carries about ONE item — everything the rule needs, and
/// nothing else.
///
/// Every field comes from the ROW (R4). Build it with [`ItemFacts::from_raw`]
/// or [`ItemFacts::from_media`] where a reference is already in hand, or with
/// [`ItemFacts::new`] plus the `with_*` builders where the caller is holding
/// loose row fields.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct ItemFacts<'a> {
/// What the row is.
pub kind: ItemKind,
/// The release-type word for an [`ItemKind::Album`] — one of
/// [`RELEASE_TYPES`], as `classify_release` spells it. Informational: see
/// [`RELEASE_TYPES`] for why the rule does not gate on it.
pub release: Option<&'a str>,
/// Where the row came from, normalised. `None` = the row did not say.
pub source: Option<SourceId>,
/// R3. Normally derived from the id by [`is_ephemeral_id`].
pub ephemeral: bool,
}
impl<'a> ItemFacts<'a> {
/// The minimum: a kind, no source word, not ephemeral.
pub fn new(kind: ItemKind) -> Self {
Self {
kind,
release: None,
source: None,
ephemeral: false,
}
}
/// Stamp the row's source, already typed.
pub fn with_source(mut self, source: SourceId) -> Self {
self.source = Some(source);
self
}
/// Stamp the row's source WORD, normalised through the one table
/// ([`SourceId::from_word`]). An unknown word stays `None` — it is never
/// guessed into Qobuz.
pub fn with_source_word(mut self, word: &str) -> Self {
self.source = SourceId::from_word(word);
self
}
/// Stamp the release-type word (`classify_release`'s output, or
/// `Album.release_type` off the wire).
pub fn with_release(mut self, release: &'a str) -> Self {
self.release = Some(release);
self
}
/// Force the R3 bit, for a caller that knows an item is ephemeral by other
/// means than its id — e.g. an ephemeral ALBUM, whose group key
/// `<album>|<artist>` is not numeric and so cannot carry the floor.
pub fn with_ephemeral(mut self, ephemeral: bool) -> Self {
self.ephemeral = ephemeral;
self
}
/// The facts of a caller-side reference. `None` when the row does not know
/// its own kind — there is nothing to offer for an item nobody can
/// classify, and guessing is what this crate exists to stop.
pub fn from_raw(raw: &RawRef) -> Option<ItemFacts<'static>> {
Some(ItemFacts {
kind: raw.kind?,
release: None,
source: raw.source,
ephemeral: is_ephemeral_id(&raw.id),
})
}
/// The facts of a claimed reference.
pub fn from_media(item: &MediaRef) -> ItemFacts<'static> {
ItemFacts {
kind: item.kind(),
release: None,
source: Some(item.source()),
ephemeral: is_ephemeral_id(item.id()),
}
}
/// The facts of a claimed reference plus the metadata its source produced —
/// which is where the release-type word comes from
/// ([`ItemMeta::kind_label`], stamped by `QobuzSource::meta` from the V2
/// wire's `release_type`).
pub fn from_meta(item: &MediaRef, meta: &ItemMeta) -> ItemFacts<'static> {
ItemFacts {
release: Some(meta.kind_label),
..ItemFacts::from_media(item)
}
}
}
// ── The rule, as data ───────────────────────────────────────────────────────
//
// Four lines. R1 is the middle two, verbatim; R3 is the `Queue` line plus the
// ephemeral veto in `refusal` below.
/// R1: "Collection: albums, singles, EPs."
const COLLECTION: &[Accepted] = &[Accepted::Release];
/// R1: "Mixtape: albums, playlists, tracks."
const MIXTAPE: &[Accepted] = &[Accepted::Release, Accepted::Playlist, Accepted::Track];
/// R3: an ephemeral item may be played and queued. Everything else that a
/// source can expand into tracks may be queued too — `Artist` is absent
/// because no source resolves one (`local.rs:601-606`, `plex.rs:307-310`, and
/// `QobuzSource` has no artist arm either).
const QUEUE: &[Accepted] = &[
Accepted::Release,
Accepted::Playlist,
Accepted::Track,
Accepted::Folder,
];
/// A playlist holds tracks. R1 does not describe playlists as containers — this
/// line exists so R3's "never to a playlist" is expressible.
const PLAYLIST: &[Accepted] = &[Accepted::Track];
impl Accepted {
/// Does this slot match the item?
fn matches(self, item: &ItemFacts<'_>) -> bool {
match self {
// Any release type of the one album kind — see `RELEASE_TYPES`.
Accepted::Release => item.kind == ItemKind::Album,
// POSITIVE evidence of Qobuz, never "not local" (R4, and the
// `_ => Qobuz` guess this crate refuses to reproduce).
Accepted::Playlist => {
item.kind == ItemKind::Playlist && item.source == Some(SourceId::QOBUZ)
}
Accepted::Track => item.kind == ItemKind::Track,
Accepted::Folder => item.kind == ItemKind::Folder,
}
}
}
impl Container {
/// Every container, for a caller that wants to project the rule onto its
/// own row list in one pass.
pub const ALL: [Container; 4] = [
Container::Queue,
Container::Collection,
Container::Mixtape,
Container::Playlist,
];
/// What this container accepts — R1/R3 as data, readable and testable
/// without going through the predicate.
pub fn accepted(self) -> &'static [Accepted] {
match self {
Container::Queue => QUEUE,
Container::Collection => COLLECTION,
Container::Mixtape => MIXTAPE,
Container::Playlist => PLAYLIST,
}
}
/// **THE predicate.** May this item go in this container?
///
/// This is what the UI asks before it renders an action. A `false` here
/// means the action is NOT OFFERED — never offered and then refused (R2).
pub fn accepts(self, item: &ItemFacts<'_>) -> bool {
self.refusal(item).is_none()
}
/// The same question, with the reason — for the LOG only (see [`Refusal`]).
pub fn refusal(self, item: &ItemFacts<'_>) -> Option<Refusal> {
// R3 first, and it vetoes every container but the queue.
if item.ephemeral && self != Container::Queue {
return Some(Refusal::Ephemeral);
}
let slots = self.accepted();
if slots.iter().any(|s| s.matches(item)) {
return None;
}
if item.kind == ItemKind::Playlist && slots.contains(&Accepted::Playlist) {
// The container does take playlists; this one just cannot be
// resolved. Worth its own reason — it is the arm R2 is about.
return Some(Refusal::UnresolvablePlaylist(item.source));
}
Some(Refusal::Kind(item.kind))
}
/// The BATCH form: a container is offered only when it accepts EVERY item.
///
/// An empty payload accepts nothing — there is nothing to add, so no
/// container should light up.
pub fn accepts_all(self, items: &[ItemFacts<'_>]) -> bool {
!items.is_empty() && items.iter().all(|it| self.accepts(it))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn album(source: SourceId) -> ItemFacts<'static> {
ItemFacts::new(ItemKind::Album).with_source(source)
}
// ── R1: what a Collection takes ────────────────────────────────────────
#[test]
fn a_collection_takes_albums_singles_and_eps_from_any_source() {
for source in [SourceId::QOBUZ, SourceId::LOCAL, SourceId::PLEX] {
for release in ["album", "single", "ep"] {
let it = album(source).with_release(release);
assert!(
Container::Collection.accepts(&it),
"a {release} from {source} belongs in a collection (R1)"
);
}
}
}
#[test]
fn a_plex_album_is_accepted_by_a_collection() {
// R1: "from ANY source — qobuz, local (which means folders AND Plex
// AND anything added later)".
assert!(Container::Collection.accepts(&album(SourceId::PLEX)));
assert_eq!(Container::Collection.refusal(&album(SourceId::PLEX)), None);
}
#[test]
fn a_release_word_never_hides_an_album() {
// "live" and "compilation" are release types of the SAME kind. R1
// names three of the five; refusing the other two would be an
// invisible refusal of a legitimate album.
for release in RELEASE_TYPES {
assert!(is_release_word(release));
assert!(Container::Collection.accepts(&album(SourceId::QOBUZ).with_release(release)));
}
// An unclassified album, and one carrying a word nobody knows.
assert!(Container::Collection.accepts(&album(SourceId::LOCAL)));
assert!(Container::Collection.accepts(&album(SourceId::LOCAL).with_release("box set")));
}
#[test]
fn a_collection_takes_neither_tracks_nor_playlists() {
let track = ItemFacts::new(ItemKind::Track).with_source(SourceId::QOBUZ);
assert!(!Container::Collection.accepts(&track));
assert_eq!(
Container::Collection.refusal(&track),
Some(Refusal::Kind(ItemKind::Track))
);
let playlist = ItemFacts::new(ItemKind::Playlist).with_source(SourceId::QOBUZ);
assert!(!Container::Collection.accepts(&playlist));
assert_eq!(
Container::Collection.refusal(&playlist),
Some(Refusal::Kind(ItemKind::Playlist)),
"a collection does not take playlists at all — not even qobuz ones"
);
}
// ── R1: what a Mixtape takes ───────────────────────────────────────────
#[test]
fn a_playlist_is_refused_by_a_collection_and_accepted_by_a_mixtape() {
let playlist = ItemFacts::new(ItemKind::Playlist).with_source_word("qobuz");
assert!(!Container::Collection.accepts(&playlist));
assert!(Container::Mixtape.accepts(&playlist));
}
#[test]
fn a_mixtape_takes_albums_playlists_and_tracks() {
assert!(Container::Mixtape.accepts(&album(SourceId::LOCAL)));
assert!(Container::Mixtape
.accepts(&ItemFacts::new(ItemKind::Track).with_source(SourceId::LOCAL)));
assert!(Container::Mixtape
.accepts(&ItemFacts::new(ItemKind::Playlist).with_source(SourceId::QOBUZ)));
}
#[test]
fn a_non_qobuz_playlist_belongs_nowhere() {
// enqueue.rs:418-424 — "local playlists not supported in this
// release". R2: the error stays as the safety net; the UI simply never
// offers the action.
for source in [Some(SourceId::LOCAL), Some(SourceId::PLEX), None] {
let mut it = ItemFacts::new(ItemKind::Playlist);
it.source = source;
assert!(!Container::Mixtape.accepts(&it), "{source:?} playlist");
assert_eq!(
Container::Mixtape.refusal(&it),
Some(Refusal::UnresolvablePlaylist(source))
);
assert!(!Container::Collection.accepts(&it));
assert!(!Container::Queue.accepts(&it));
}
}
// ── R3: ephemeral ──────────────────────────────────────────────────────
#[test]
fn an_ephemeral_item_is_refused_by_both_collection_and_mixtape() {
// qbz-library/src/ephemeral.rs:43 — EPHEMERAL_ID_FLOOR = 2^48.
let raw = RawRef {
kind: Some(ItemKind::Track),
id: "281474976710656".into(),
..Default::default()
};
let it = ItemFacts::from_raw(&raw).expect("a track kind");
assert!(it.ephemeral);
for c in [
Container::Collection,
Container::Mixtape,
Container::Playlist,
] {
assert!(!c.accepts(&it), "{c:?} must refuse an ephemeral item (R3)");
assert_eq!(c.refusal(&it), Some(Refusal::Ephemeral));
}
}
#[test]
fn an_ephemeral_item_can_still_be_queued() {
// R3: "only be played and only be added to the QUEUE".
let it = ItemFacts::new(ItemKind::Track).with_ephemeral(true);
assert!(Container::Queue.accepts(&it));
assert_eq!(Container::Queue.refusal(&it), None);
}
#[test]
fn the_ephemeral_test_reads_the_id_not_the_source_word() {
// PlayerBar.qml:559 / NowPlayingBarSmall.qml:508 stamp a literal
// "qobuz" onto whatever is playing; a source-gated test would wave the
// ephemeral row straight through.
let lying = RawRef::new("qobuz", ItemKind::Track, "281474976710657");
let it = ItemFacts::from_raw(&lying).expect("a track kind");
assert!(it.ephemeral);
assert_eq!(it.source, Some(SourceId::QOBUZ));
assert!(!Container::Mixtape.accepts(&it));
// …and a normal library row id is NOT ephemeral.
assert!(!is_ephemeral_id("2954"));
assert!(!is_ephemeral_id("139578884"));
assert!(!is_ephemeral_id("HIT ME HARD AND SOFT|Billie Eilish"));
assert!(is_ephemeral_id(&EPHEMERAL_ID_FLOOR.to_string()));
}
#[test]
fn an_ephemeral_album_needs_the_explicit_bit() {
// An ephemeral ALBUM is grouped by `<album>|<artist>`, which carries no
// floor. The caller that knows says so; without it the id test cannot.
let key = RawRef {
kind: Some(ItemKind::Album),
id: "Folder Drop|Some Artist".into(),
..Default::default()
};
let derived = ItemFacts::from_raw(&key).expect("an album kind");
assert!(
!derived.ephemeral,
"no floor in a group key — see the report"
);
assert!(!Container::Collection.accepts(&derived.with_ephemeral(true)));
}
// ── Shape ──────────────────────────────────────────────────────────────
#[test]
fn a_row_with_no_kind_has_no_facts() {
let raw = RawRef {
id: "2954".into(),
..Default::default()
};
assert!(ItemFacts::from_raw(&raw).is_none());
}
#[test]
fn a_batch_is_offered_only_when_every_item_fits() {
let items = [
album(SourceId::QOBUZ),
album(SourceId::PLEX),
ItemFacts::new(ItemKind::Track).with_source(SourceId::LOCAL),
];
assert!(Container::Mixtape.accepts_all(&items));
assert!(
!Container::Collection.accepts_all(&items),
"one track in the batch closes the collection"
);
assert!(Container::Collection.accepts_all(&items[..2]));
// An empty payload lights nothing up.
assert!(!Container::Collection.accepts_all(&[]));
assert!(!Container::Mixtape.accepts_all(&[]));
}
#[test]
fn the_rule_is_readable_as_data() {
assert_eq!(Container::Collection.accepted(), &[Accepted::Release]);
assert_eq!(
Container::Mixtape.accepted(),
&[Accepted::Release, Accepted::Playlist, Accepted::Track]
);
assert_eq!(Container::ALL.len(), 4);
}
#[test]
fn facts_come_from_a_claimed_reference_too() {
let item = MediaRef::new(SourceId::PLEX, ItemKind::Album, "plex:5677211365378243606");
let facts = ItemFacts::from_media(&item);
assert_eq!(facts.source, Some(SourceId::PLEX));
assert!(Container::Collection.accepts(&facts));
let meta = ItemMeta {
kind_label: "ep",
..Default::default()
};
let with_meta = ItemFacts::from_meta(&item, &meta);
assert_eq!(with_meta.release, Some("ep"));
assert!(Container::Collection.accepts(&with_meta));
}
}
+30 -4
View File
@@ -38,10 +38,30 @@
//! is on the trait, so a hybrid list makes ONE call per row regardless of
//! source and cannot wire fewer pipelines than it has sources.
//!
//! # The owner's rules, and where they live
//!
//! [`acceptance`] is the ONE home of **R1** (a Collection takes albums, singles
//! and EPs from any source; a Mixtape takes albums, playlists and tracks) and
//! **R3** (an ephemeral item may only be played and queued — never stored in a
//! playlist, a mixtape or a collection). Ask it with [`Container::accepts`].
//!
//! **R2** — an unsupported combination must never be VISIBLE — is why the rule
//! belongs here rather than in a view: the resolver errors
//! (`"local playlists not supported in this release"`, [`SourceError`]) stay
//! exactly where they are as the safety net that only reaches the log, and the
//! UI simply does not offer what cannot work. **R4** — a row's source comes
//! from the ROW, never a literal — is enforced by shape: [`ItemFacts`] is built
//! from a [`RawRef`] / [`MediaRef`], and the rule contains no source literal to
//! get wrong.
//!
//! # Shape rules
//!
//! - **Additive.** Nothing in `qbz/src` (Slint) or `qbz-mixtape` changes; this
//! crate implements `qbz_mixtape::ItemResolver`, it does not absorb it.
//! - **Nothing long-lived is cached that another crate owns.** The Qobuz client
//! is READ THROUGH a [`ClientLens`] on every call, because `qbz-core`
//! replaces its client wholesale (`core.rs:346`, `:384`) and a cached clone
//! fails silently. Install it once with [`init_registry`].
//! - **The PROTECTED audio backend is not linked.** `qbz-audio` and
//! `qbz-player` are not dependencies, so sample rate, resampling and device
//! selection are unreachable from here. [`PlaybackTicket`] is data; the
@@ -57,10 +77,12 @@
//! `sources/jellyfin.rs` (one impl), one `pub mod` + `pub use` in
//! [`sources`], one [`SourceId`] constant, one word in
//! [`SourceId::from_word`], one [`SourceBadge`] variant, and one line in
//! [`SourceRegistry::with_defaults`]. **Zero** changes in any view, any
//! [`SourceRegistry`]'s `build`. **Zero** changes in any view, any
//! playback path, any artwork path — because all four concerns hang off the one
//! trait.
//! trait. And zero changes in [`acceptance`]: R1 says a Collection takes
//! releases "from ANY source", so the rule has no per-source arm to extend.
pub mod acceptance;
pub mod art;
pub mod error;
pub mod id;
@@ -71,14 +93,18 @@ pub mod registry;
pub mod source;
pub mod sources;
pub use acceptance::{
is_ephemeral_id, is_release_word, Accepted, Container, ItemFacts, Refusal, RELEASE_TYPES,
};
pub use art::{ArtRef, ArtSize, PLEX_THUMB_PX};
pub use error::SourceError;
pub use id::{ItemKind, MediaRef, RawRef, SourceId};
pub use meta::{ItemMeta, QualityHint, SourceBadge};
pub use mixtape_adapter::RegistryResolver;
pub use playback::PlaybackTicket;
pub use registry::{registry, SourceRegistry};
pub use registry::{init_registry, registry, SourceRegistry};
pub use source::Source;
pub use sources::{
local_queue_track, EphemeralTracks, LocalSource, PlexCreds, PlexSource, QobuzSource,
local_queue_track, ClientFuture, ClientLens, EphemeralTracks, LocalSource, PlexCreds,
PlexSource, QobuzSource,
};
+47 -3
View File
@@ -11,7 +11,7 @@ use crate::id::{MediaRef, RawRef, SourceId};
use crate::meta::ItemMeta;
use crate::playback::PlaybackTicket;
use crate::source::Source;
use crate::sources::{LocalSource, PlexSource, QobuzSource};
use crate::sources::{ClientLens, LocalSource, PlexSource, QobuzSource};
/// Every source in the process.
pub struct SourceRegistry {
@@ -32,8 +32,23 @@ impl SourceRegistry {
/// Order is NOT load-bearing: [`SourceRegistry::claim`] asks everyone (see
/// its docs), which removes an invariant a future author could break by
/// inserting a source in the wrong slot.
///
/// The Qobuz source is DETACHED — it has no way to reach a client and every
/// catalog call answers `NotConfigured`. The process wires the live one
/// with [`init_registry`]; see [`crate::ClientLens`] for why the client is
/// read through rather than published into the source.
pub fn with_defaults() -> Self {
let qobuz = Arc::new(QobuzSource::new());
Self::build(QobuzSource::detached())
}
/// The registry the process actually runs: Qobuz reads its client through
/// `lens` at CALL time.
pub fn with_client_lens(lens: ClientLens) -> Self {
Self::build(QobuzSource::new(lens))
}
fn build(qobuz: QobuzSource) -> Self {
let qobuz = Arc::new(qobuz);
let plex = Arc::new(PlexSource::new());
let local = Arc::new(LocalSource::new());
Self {
@@ -53,7 +68,12 @@ impl SourceRegistry {
self.sources.iter().find(|s| s.id() == id)
}
/// The Qobuz source, for `set_client` from the auth path.
/// The Qobuz source.
///
/// It takes NO configuration from the auth path any more: its client is
/// read through the lens installed at construction ([`init_registry`]), so
/// there is nothing to publish into it and nothing to re-publish when
/// `qbz-core` replaces its client.
pub fn qobuz(&self) -> &QobuzSource {
&self.qobuz
}
@@ -219,6 +239,30 @@ pub fn registry() -> &'static SourceRegistry {
REGISTRY.get_or_init(SourceRegistry::with_defaults)
}
/// Build the process-wide registry with a live Qobuz [`ClientLens`].
///
/// Call it ONCE, as early as the process can name its core — before anything
/// else touches [`registry()`], and in particular before
/// `auth_qt::bind_per_user_stores`. It is idempotent and cheap to call again;
/// what it cannot do is re-attach a lens to a registry that was already built
/// without one, so a late call logs an error and leaves Qobuz detached (every
/// catalog call then answers `NotConfigured` — loudly, and never with a stale
/// client).
pub fn init_registry(lens: ClientLens) -> &'static SourceRegistry {
let mut installed = false;
let reg = REGISTRY.get_or_init(|| {
installed = true;
SourceRegistry::with_client_lens(lens)
});
if !installed {
log::error!(
"[qbz-source] init_registry ran after the registry was already built; \
the Qobuz source stays detached and every catalog call will answer NotConfigured"
);
}
reg
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -10,4 +10,4 @@ pub mod qobuz;
pub use local::{local_queue_track, EphemeralTracks, LocalSource};
pub use plex::{is_thumb_path, PlexCreds, PlexSource, PLEX_TRACK_ID_FLOOR};
pub use qobuz::QobuzSource;
pub use qobuz::{ClientFuture, ClientLens, QobuzSource};
+170 -45
View File
@@ -3,9 +3,16 @@
//! Track/meta resolution REUSES `qbz-mixtape`'s existing async free fns
//! (`resolve_qobuz_album` / `_track` / `_playlist`, enqueue.rs:218-327) rather
//! than re-deriving the mappers; that crate is not modified and stays a leaf.
//!
//! This source holds **no client**. It holds a [`ClientLens`] — a read-through
//! handle it asks again on every call — because `qbz-core` replaces its client
//! wholesale (`core.rs:346` and `:384`, the only two writers) at points that
//! run BEFORE the session is set. See [`ClientLens`] for the full argument.
use std::collections::HashMap;
use std::sync::{Mutex, RwLock};
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex, RwLock};
use qbz_models::QueueTrack;
@@ -19,21 +26,74 @@ use crate::source::Source;
/// Memo ceiling, mirroring `artwork_qt::MEMO_CAP` (artwork_qt.rs:82).
const MEMO_CAP: usize = 8192;
/// The future a [`ClientLens`] hands back: a FRESH clone of whatever client the
/// process holds *right now*.
///
/// `Pin<Box<dyn Future + Send>>` rather than a `futures` alias — this crate
/// depends on neither `tokio` nor `futures` (design §8), and `async-trait`
/// generates exactly this shape anyway.
pub type ClientFuture = Pin<Box<dyn Future<Output = Option<qbz_qobuz::QobuzClient>> + Send>>;
/// A READ-THROUGH handle to the process's live Qobuz client.
///
/// It is supplied ONCE, at construction ([`QobuzSource::new`], normally through
/// [`crate::init_registry`]), and it is read at CALL time — every `tracks` /
/// `meta` invocation asks it again.
///
/// # Why this is not an `Option<QobuzClient>` field
///
/// `qbz-core` REPLACES its whole client, twice over: `QbzCore::init`
/// (`qbz-core/src/core.rs:346`) and `QbzCore::try_init_api` (`:384`) both write
/// a freshly constructed `QobuzClient` into the shared slot, and
/// `try_init_api` runs BEFORE `set_session` on both the login
/// (`auth_qt.rs:83`) and the silent-restore (`:277`) paths. Those are the only
/// two writers in the process — so a source that CACHED a clone would go stale
/// and then fail **silently**, and the "re-inject it after every replacement"
/// setter that used to live here was a discipline every future call site had to
/// remember, not an invariant. Reading through removes the failure mode instead
/// of documenting it.
///
/// A second property comes free: there is no per-user client held here at all,
/// so the "previous account's authenticated client survives logout in a
/// `'static` registry" leak (design §12.1 defect 4) is unreachable for the
/// client. Only the artwork memo is per-user, and [`Source::teardown`] clears
/// it.
///
/// # The load-bearing contract for the lens body
///
/// The lens MUST return an owned clone and MUST NOT hold a lock guard past its
/// own future. That is the property `myqbz_play_qt.rs:117-130` documents:
/// holding `QbzCore::client()`'s `RwLockReadGuard` across the catalog awaits
/// DEADLOCKS against the playback path. The frontend's lens is therefore
/// exactly today's `qobuz_client()` body:
///
/// ```ignore
/// let lens: ClientLens = Arc::new(|| {
/// Box::pin(async {
/// let lock = runtime().core().client(); // Arc<tokio RwLock<Option<..>>>
/// let guard = lock.read().await; // guard lives INSIDE this future
/// guard.as_ref().cloned() // …and is dropped when it ends
/// })
/// });
/// ```
pub type ClientLens = Arc<dyn Fn() -> ClientFuture + Send + Sync>;
/// The Qobuz source.
pub struct QobuzSource {
/// `std::sync::RwLock`, NOT tokio's — this crate has no tokio dep (§8).
/// Its guard is `!Send`, so `#[async_trait]`'s `Send` future bound makes
/// "hold the guard across an await" a COMPILE ERROR rather than a rule.
/// Every accessor clones the client inside a block and drops the guard
/// before the first `.await`: `myqbz_play_qt.rs:117-130` records that
/// holding the guard instead DEADLOCKS against the playback path.
client: RwLock<Option<qbz_qobuz::QobuzClient>>,
/// The uid the client belongs to. A `bind_user` with a different uid clears
/// the client even if nobody remembered to call `teardown` — the leak class
/// Read-through access to the live client. Immutable after construction —
/// there is deliberately no setter, so there is no ordering rule a caller
/// can break (see [`ClientLens`]).
lens: Option<ClientLens>,
/// The uid the artwork memo belongs to. A `bind_user` with a different uid
/// clears it even if nobody remembered to call `teardown` — the leak class
/// this port already shipped once (auth_qt.rs:193-197).
owner: Mutex<Option<u64>>,
/// MediaRef id → its CDN cover url, filled by `tracks`/`meta`.
///
/// `std::sync::RwLock`, NOT tokio's — this crate has no tokio dep (§8).
/// Its guard is `!Send`, so `#[async_trait]`'s `Send` future bound makes
/// "hold the guard across an await" a COMPILE ERROR rather than a rule.
///
/// `artwork` is the CHEAP phase and must not hit the network, and a Qobuz
/// id carries no cover on its own — so a cold id answers
/// `Unavailable` rather than blocking a row on an HTTP round-trip.
@@ -41,36 +101,41 @@ pub struct QobuzSource {
}
impl QobuzSource {
/// A source with no client published yet.
pub fn new() -> Self {
/// A source that reads the live client through `lens` on every call.
pub fn new(lens: ClientLens) -> Self {
Self {
client: RwLock::new(None),
lens: Some(lens),
owner: Mutex::new(None),
art: RwLock::new(HashMap::new()),
}
}
/// Publish the shared Qobuz client. `None` clears it.
/// A source with NO way to reach a client: every catalog call answers
/// [`SourceError::NotConfigured`].
///
/// MUST be called AFTER `core.set_session` (auth_qt.rs:145, :284 and the
/// offline entry), never after `try_init_api`: `qbz-core` replaces its own
/// client inside `try_init_api` (`qbz-core/src/core.rs:384`), which
/// `ensure_api_initialized` calls at auth_qt.rs:83 and :277 — i.e. BEFORE
/// `set_session` on both the login and the silent-restore paths. A
/// `QobuzSource` holding a client `qbz-core` has since replaced fails
/// SILENTLY.
pub fn set_client(&self, c: Option<qbz_qobuz::QobuzClient>) {
if let Ok(mut slot) = self.client.write() {
*slot = c;
/// This is what [`crate::SourceRegistry::with_defaults`] builds, and it is
/// an honest dead end rather than a stale one — the process wires the real
/// lens with [`crate::init_registry`].
pub fn detached() -> Self {
Self {
lens: None,
owner: Mutex::new(None),
art: RwLock::new(HashMap::new()),
}
}
/// A CLONE of the client — one clone per call site, replacing the one clone
/// per call the frontend does today. The guard is dropped before the
/// caller's first `.await`.
fn client(&self) -> Result<qbz_qobuz::QobuzClient, SourceError> {
let c = { self.client.read().ok().and_then(|g| g.clone()) };
c.ok_or(SourceError::NotConfigured {
/// A CLONE of the client as it is AT THIS MOMENT, obtained through the
/// lens. No guard of ours is alive when this returns, and nothing is
/// cached between calls.
async fn client(&self) -> Result<qbz_qobuz::QobuzClient, SourceError> {
let Some(lens) = self.lens.as_ref() else {
return Err(SourceError::NotConfigured {
by: SourceId::QOBUZ,
why: "no client lens installed (the registry was built before qbz_source::init_registry ran)",
});
};
let fut = (**lens)();
fut.await.ok_or(SourceError::NotConfigured {
by: SourceId::QOBUZ,
why: "no client",
})
@@ -134,7 +199,7 @@ impl QobuzSource {
impl Default for QobuzSource {
fn default() -> Self {
Self::new()
Self::detached()
}
}
@@ -196,7 +261,7 @@ impl Source for QobuzSource {
}
async fn tracks(&self, item: &MediaRef) -> Result<Vec<QueueTrack>, SourceError> {
let client = self.client()?;
let client = self.client().await?;
// The mappers stay in `qbz-mixtape`; this is the routing only. Their
// "album X has 0 tracks" case comes back as an Err STRING, so it lands
// in `Backend` rather than `NotFound` — converting it would mean
@@ -231,7 +296,7 @@ impl Source for QobuzSource {
}
async fn meta(&self, item: &MediaRef) -> Result<ItemMeta, SourceError> {
let client = self.client()?;
let client = self.client().await?;
let meta = match item.kind() {
ItemKind::Album => {
let a = client
@@ -361,10 +426,10 @@ impl Source for QobuzSource {
fn bind_user(&self, uid: u64, _dir: &std::path::Path) {
let previous = self.owner.lock().ok().and_then(|mut o| o.replace(uid));
if previous.is_some() && previous != Some(uid) {
// A rebind to a DIFFERENT uid clears the client even if nobody
// remembered to tear down; the new one arrives separately, after
// `set_session`.
self.set_client(None);
// A rebind to a DIFFERENT uid drops the previous account's covers
// even if nobody remembered to tear down. There is no client to
// drop: it is read through the lens, so the account swap that
// `qbz-core` performs IS the swap this source sees.
if let Ok(mut m) = self.art.write() {
m.clear();
}
@@ -372,11 +437,10 @@ impl Source for QobuzSource {
}
fn teardown(&self) {
// MANDATORY override: the trait default is a no-op, and inheriting it
// here would leave the PREVIOUS ACCOUNT's authenticated `QobuzClient`
// alive in a `'static` registry across a logout, serving the next
// account's requests.
self.set_client(None);
// MANDATORY override: the trait default is a no-op, and this source
// holds a per-user artwork memo. The PREVIOUS ACCOUNT's authenticated
// client cannot leak here — nothing caches it (see `ClientLens`) — so
// the memo and the owner are all there is to drop.
if let Ok(mut o) = self.owner.lock() {
*o = None;
}
@@ -403,12 +467,36 @@ fn with_art(
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn claimed(raw: &RawRef) -> Result<MediaRef, SourceError> {
QobuzSource::new()
QobuzSource::detached()
.claim(raw)
.expect("QobuzSource should claim this reference")
}
/// Poll a future that is READY on its first poll, with no executor.
///
/// This crate depends on neither `tokio` nor `futures` (design §8), and the
/// paths exercised below all return before their first suspension point.
fn block_on<F: std::future::Future>(fut: F) -> F::Output {
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
unsafe fn nop(_: *const ()) {}
unsafe fn clone_raw(p: *const ()) -> RawWaker {
RawWaker::new(p, &VTABLE)
}
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone_raw, nop, nop, nop);
let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
let mut cx = Context::from_waker(&waker);
let mut fut = Box::pin(fut);
match fut.as_mut().poll(&mut cx) {
Poll::Ready(v) => v,
Poll::Pending => panic!("this path must not suspend"),
}
}
#[test]
fn owns_opaque_album_ids_verbatim() {
// Real ids from `mixtape_collection_items` / `album_play_history`
@@ -443,7 +531,7 @@ mod tests {
#[test]
fn never_claims_by_elimination() {
let q = QobuzSource::new();
let q = QobuzSource::detached();
// survey IC-4/IC-6: a bare numeric with no source word is NOT Qobuz's.
assert!(q
.claim(&RawRef {
@@ -499,11 +587,48 @@ mod tests {
#[test]
fn artwork_is_unavailable_not_none_before_a_resolve() {
let q = QobuzSource::new();
let q = QobuzSource::detached();
let item = MediaRef::new(SourceId::QOBUZ, ItemKind::Album, "0060254702523");
assert!(matches!(
Source::artwork(&q, &item, ArtSize::Row),
ArtRef::Unavailable(_)
));
}
#[test]
fn a_detached_source_says_not_configured_instead_of_serving_a_stale_client() {
let q = QobuzSource::detached();
let item = MediaRef::new(SourceId::QOBUZ, ItemKind::Album, "0060254702523");
let err = block_on(q.tracks(&item)).unwrap_err();
assert!(matches!(
err,
SourceError::NotConfigured {
by: SourceId::QOBUZ,
..
}
));
}
#[test]
fn the_lens_is_read_on_every_call_never_cached() {
// THE property that replaces `set_client`: `qbz-core` swaps its whole
// client at core.rs:346 / :384, so a clone cached here would go stale
// and fail silently. Two calls ⇒ two reads.
let calls = Arc::new(AtomicUsize::new(0));
let seen = calls.clone();
let lens: ClientLens = Arc::new(move || {
let seen = seen.clone();
Box::pin(async move {
seen.fetch_add(1, Ordering::SeqCst);
None
})
});
let q = QobuzSource::new(lens);
let album = MediaRef::new(SourceId::QOBUZ, ItemKind::Album, "0060254702523");
let track = MediaRef::new(SourceId::QOBUZ, ItemKind::Track, "139578884");
assert!(block_on(q.tracks(&album)).is_err());
assert!(block_on(q.meta(&track)).is_err());
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
}