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:
20 files changed
+1574
-172
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user