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

+1
View File
@@ -37,6 +37,7 @@ qbz-library = { path = "../qbz-library" }
# Slint app uses (crates/qbz/Cargo.toml:96), so the two frontends share
# library.db's mixtape tables rather than each growing their own.
qbz-mixtape = { path = "../qbz-mixtape" }
qbz-source = { path = "../qbz-source" }
# Cast: Chromecast + DLNA discovery, the two device families and the local
# media server (src/cast_qt.rs drives it — nothing is reimplemented).
qbz-cast = { path = "../qbz-cast" }
@@ -23,6 +23,17 @@
// title+subtitle; bulk -> "{count} items" + "{first} + N more"), so this file
// never inspects the pending items.
//
// WHAT MAY GO WHERE (R1) IS ALSO RUST'S ANSWER, not a rule this file re-derives
// — see myqbz_add_qt.rs `payload_accepts`. The picker LIST arrives already
// filtered (a container that cannot take the payload is never a row), and the
// two creatable kinds arrive as `allowMixtape` / `allowCollection`, which gate
// the footer chips AND the create-panel radios. That second half matters: with
// the old single `restrictToMixtape` flag the chips narrowed to one but the
// create panel still showed BOTH radios, so a track payload could still be
// flipped to "Collections" and written into one. Nothing here ever renders an
// action the payload cannot perform, which is the whole point — the backend
// block is a safety net that must stay invisible.
//
// The name typed into the create panel is QML-LOCAL: the bridge takes it as an
// argument (`createAndAdd(kind, name)`) and has no name property. Same for the
// create KIND, which the two radios flip locally over the doc's value — there
@@ -53,7 +64,11 @@ Item {
readonly property bool loading: root.doc.loading === true
readonly property bool creating: root.doc.creating === true
readonly property bool createBusy: root.doc.createBusy === true
readonly property bool restrictToMixtape: root.doc.restrictToMixtape === true
// R1, as Rust computed it for the pending payload. Both may be true, one
// may be true; both false cannot reach a visible modal (Rust refuses to
// open the picker when nothing accepts the payload).
readonly property bool allowMixtape: root.doc.allowMixtape === true
readonly property bool allowCollection: root.doc.allowCollection === true
readonly property string busyId: root.doc.busyId || ""
readonly property string search: root.doc.search || ""
readonly property var rows: root.doc.rows || []
@@ -65,8 +80,14 @@ Item {
readonly property string createKind: root.createKindOverride !== ""
? root.createKindOverride : root.docCreateKind
readonly property bool createMixtape: root.createKind === "mixtape"
// R1 for the kind the panel is currently on. The forbidden radio is not
// rendered, so this can only go false on a stale frame — but Create & Add
// must not be pressable in that frame either.
readonly property bool createKindAllowed: root.createKind === "collection"
? root.allowCollection : root.allowMixtape
// :207 — the raw non-empty check, exactly as Slint gates it (Rust trims).
readonly property bool canCreate: root.createName !== "" && !root.createBusy
&& root.createKindAllowed
// A fresh panel starts empty and follows the kind Rust was asked for; a
// closed modal must not keep a half-typed name for the next caller.
@@ -266,21 +287,29 @@ Item {
anchors.margins: 12
spacing: 8
// Both chips carry `horizontal-stretch: 1`, so one fills
// the row and two split it.
readonly property int chips: root.restrictToMixtape ? 1 : 2
readonly property real chipW: (width - (chips - 1) * 8) / chips
// the row and two split it. R1 decides how many there are:
// one chip per CREATABLE container, never zero (a payload
// nothing accepts never opened this modal). The guard on
// `chips > 0` is only so a pre-publish frame cannot divide
// by zero.
readonly property int chips:
(root.allowMixtape ? 1 : 0) + (root.allowCollection ? 1 : 0)
readonly property real chipW:
chips > 0 ? (width - (chips - 1) * 8) / chips : 0
FooterIconButton {
anchors.verticalCenter: parent.verticalCenter
width: parent.chipW
visible: root.allowMixtape
width: visible ? parent.chipW : 0
label: root.t("Mixtapes")
onClicked: QbzMyQbzAdd.showCreate("mixtape")
}
FooterIconButton {
// Hidden for track/playlist payloads: collections hold
// whole albums only (:423).
// Offered only when a Collection can take the payload:
// albums / singles / EPs, from any source. Tracks and
// playlists have no Collection slot (R1).
anchors.verticalCenter: parent.verticalCenter
visible: !root.restrictToMixtape
visible: root.allowCollection
width: visible ? parent.chipW : 0
label: root.t("Collections")
onClicked: QbzMyQbzAdd.showCreate("collection")
@@ -426,14 +455,21 @@ Item {
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
// R1 again, and this is the half the old flag missed: a
// radio for a kind the payload cannot go into is NOT
// rendered. A Row skips invisible children, so the single
// remaining (and already selected) radio simply reads as
// the kind being created.
Row {
spacing: 16
QbzRadioOption {
visible: root.allowMixtape
label: root.t("Mixtapes")
selected: root.createMixtape
onClicked: if (!root.createBusy) root.createKindOverride = "mixtape"
}
QbzRadioOption {
visible: root.allowCollection
label: root.t("Collections")
selected: !root.createMixtape
onClicked: if (!root.createBusy) root.createKindOverride = "collection"
+31 -3
View File
@@ -89,6 +89,25 @@ Rectangle {
}
}
// SOURCE of the now-playing track for the MyQBZ AddItem payload
// ("qobuz" | "local"), read off the queue document's `current` row — the
// same row the heart above reads — and never written as a literal. The
// full bar carries the long-form rationale (PlayerBar.qml `npSource`); the
// short version: `QbzPlayer` publishes no source word, `isLocal === false`
// proves a Qobuz catalog id, and `isLocal === true` conflates four id
// namespaces (local row / Plex / ephemeral / offline-cached Qobuz) that
// this bar cannot separate without sniffing the id. "" = unknown, and the
// menu then does not offer "Add to mixtape" at all.
readonly property string npSource: {
try {
var d = JSON.parse(QbzQueue.queueJson)
if (d && d.current && d.current.id === QbzPlayer.npTrackId)
return d.current.isLocal === true ? "" : "qobuz"
} catch (e) {
}
return ""
}
function fmt(secs) {
var m = Math.floor(secs / 60)
var s = Math.floor(secs % 60)
@@ -480,8 +499,15 @@ Rectangle {
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" },
]
// Only when the track's source is KNOWN (see `npSource`).
if (root.npSource !== "") {
m.push({
"label": QbzSession.tr("Add to mixtape", QbzSession.trRev),
"icon": "cassette-tape",
"action": "mixtape"
})
}
if (QbzPlayer.npAlbumId !== "") {
m.push({
"label": QbzSession.tr("Add album to collection", QbzSession.trRev),
@@ -504,9 +530,11 @@ Rectangle {
// MyQBZ AddItem, built here from the now-playing state:
// `npArtworkPath` is a file:// CACHE path, so it is NOT the
// artworkUrl — the store would keep a dead local path.
if (id !== "")
// Source from the queue's current ROW (`npSource`), not a
// literal; the entry is absent when that row cannot answer.
if (id !== "" && root.npSource !== "")
QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"itemType": "track", "source": root.npSource,
"sourceItemId": id, "title": QbzPlayer.npTitle,
"subtitle": QbzPlayer.npArtist, "artworkUrl": "",
"year": null, "trackCount": null
+48 -3
View File
@@ -134,6 +134,40 @@ Rectangle {
}
}
// SOURCE of the now-playing track — the word the MyQBZ AddItem payload
// needs ("qobuz" | "local"), NEVER a literal.
//
// This bar's "row" is the queue document's `current` row, the same row the
// heart above reads. `QbzPlayer` publishes no source word at all
// (player_bridge.rs:31-114 — there is no `np_source`), so the only
// published fact about the current track's provenance is that row's
// `isLocal` (queue_qt.rs:46-50, straight off `QueueTrack::is_local`).
//
// `isLocal === false` is unambiguous: the queue id IS a Qobuz catalog id.
// `isLocal === true` is NOT one thing — it covers a `library.db` row id, a
// `PLEX_TRACK_ID_FLOOR`-namespaced id, an `EPHEMERAL_ID_FLOOR` id, and the
// offline cache, whose queue id is a QOBUZ id (local_playback.rs:53-58).
// Those four need four different payloads and this bar cannot tell them
// apart without sniffing the id shape, which is exactly the scattered
// knowledge the source seam exists to end. So it answers "" = UNKNOWN, and
// the menu below drops "Add to mixtape" instead of stamping a guess: an
// action that cannot work must not be offered, and an ephemeral track must
// leave no trace outside the queue.
//
// The id guard is not paranoia-for-free: it is what keeps a stale queue
// document from lending the PREVIOUS track's provenance to this one. Every
// track edge republishes both (playback_qt.rs:1119-1120 and siblings), so
// it holds in steady state.
readonly property string npSource: {
try {
var d = JSON.parse(QbzQueue.queueJson)
if (d && d.current && d.current.id === QbzPlayer.npTrackId)
return d.current.isLocal === true ? "" : "qobuz"
} catch (e) {
}
return ""
}
// Audio settings — the button OPENS A FLYOUT (Normalization + Gapless),
// it is not itself a toggle: `PlayerBar.slint:666-706`. Its `active`
// colour does mirror normalization here (`:677
@@ -531,8 +565,16 @@ Rectangle {
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" },
]
// Only when the track's source is KNOWN (see `npSource`): a row we
// cannot address is not offered.
if (root.npSource !== "") {
m.push({
"label": QbzSession.tr("Add to mixtape", QbzSession.trRev),
"icon": "cassette-tape",
"action": "mixtape"
})
}
if (QbzPlayer.npAlbumId !== "") {
m.push({
"label": QbzSession.tr("Add album to collection", QbzSession.trRev),
@@ -555,9 +597,12 @@ Rectangle {
// MyQBZ AddItem, built here from the now-playing state:
// `npArtworkPath` is a file:// CACHE path, so it is NOT the
// artworkUrl — the store would keep a dead local path.
if (id !== "")
// The source comes from the queue's current ROW (`npSource`),
// never from a literal; the entry is not even in the menu when
// that row cannot answer.
if (id !== "" && root.npSource !== "")
QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"itemType": "track", "source": root.npSource,
"sourceItemId": id, "title": QbzPlayer.npTitle,
"subtitle": QbzPlayer.npArtist, "artworkUrl": "",
"year": null, "trackCount": null
+10
View File
@@ -962,6 +962,16 @@ Rectangle {
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
//
// SOURCE: this view never shows a local album.
// `open_album` (main.rs:538-548) routes any id
// that `is_local_feed_id("album", …)` accepts
// — a Plex `plex:` key, a group key, a path —
// to the LocalAlbum view instead, so what
// reaches `album_qt::TrackRow` here is always
// a `/album/get` response. `item.id` is a
// Qobuz catalog id by construction of the
// route, not by assumption at this call site.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
+8
View File
@@ -525,6 +525,14 @@ Rectangle {
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
//
// SOURCE: a label page is a Qobuz catalog surface.
// These rows are the `/label/page` response's
// `top_tracks` mapped to track rows
// (label_qt.rs:280-287) — there is no local label
// library and no non-Qobuz row can reach this
// Repeater, so `modelData.id` is a Qobuz catalog
// id by construction of the document.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": modelData.id,
+21 -1
View File
@@ -1466,8 +1466,28 @@ Rectangle {
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
//
// The source comes off the ROW: every feed row
// carries its own word (library_qt.rs:54 —
// "qobuz" | "local" | "plex"), so a Plex or local
// row can never be stored as a Qobuz id again.
// "plex" is folded into "local" because the store
// has no Plex source: `AddItem.source` is
// "qobuz" | "local" and `source_from_str`
// (myqbz_add_qt.rs:85-90) maps everything that is
// not "local" to Qobuz — passing "plex" verbatim
// would silently become "qobuz" again.
//
// Today this list only ever holds Qobuz rows (the
// Tracks tab keeps `kind === "track" && group ===
// "favorites"` and the local layer lands in group
// "local"), so this is a no-op until that filter
// changes — which is the point: it stays right
// when it does.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"itemType": "track",
"source": item.source === "local" || item.source === "plex"
? "local" : "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": "",
"year": null, "trackCount": null
+11
View File
@@ -285,6 +285,17 @@ Rectangle {
}
// MyQBZ "Add to mixtape" — the HOST builds the AddItem
// array (TrackRow does not know itemType/source).
//
// SOURCE: these rows carry no source field, and they do
// not need one — every one of the four mixes is built
// from `qbz_models::Track` values that came back from the
// Qobuz API (foryou_qt.rs:855-873: dynamic/suggest for
// daily+weekly, `get_favorites("tracks")` for fav,
// `get_playlist` for top), and the DailyQ/WeeklyQ seed
// explicitly drops local / Plex / ephemeral recents
// (foryou_qt.rs:774-776). `modelData.id` is therefore a
// Qobuz catalog id by construction of the document, not
// by assumption at this call site.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": modelData.id,
+10
View File
@@ -647,6 +647,16 @@ Rectangle {
onRemoveRequested: QbzBridge.playlistRemoveTrack(item.playlistTrackId)
// MyQBZ "Add to mixtape" — the HOST builds the AddItem
// array (TrackRow does not know itemType/source).
//
// SOURCE: `PlaylistTrackRow` (playlist_qt.rs:47-78) has no
// source field because this whole view has one source —
// LOCAL playlists are out of scope everywhere in it
// (playlist_qt.rs:23-25), the document is mapped from
// Qobuz `Track` values, and `playlistTrackId` is the
// Qobuz membership row id. `item.id` is a Qobuz catalog
// id by construction of the view, not by assumption here.
// If local playlists are ever ported, the row must start
// carrying its source and this must read it.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
+11
View File
@@ -705,6 +705,17 @@ Rectangle {
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
//
// SOURCE: the results page has ONE source.
// `search_qt::TrackRow` (:171) carries no
// source word because every row is mapped from
// a Qobuz `Track` (`map_track`, :320-336) and
// the local "on this device" sections are not
// ported (search_qt.rs:24-27) — the cortinilla
// rows, which CAN be local, carry their own
// `source` field and are a different document.
// `item.id` is a Qobuz catalog id by
// construction, not by assumption here.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
+22
View File
@@ -185,6 +185,28 @@ pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzBridge>) + Send + 'static) {
/// Component.onCompleted — the Qt-thread handle is registered first, so
/// every `ui()` hop below lands).
pub(crate) fn on_boot() {
// The source registry, FIRST of all — before anything can call
// `qbz_source::registry()`. It is a OnceLock, so whoever touches it first
// BUILDS it, and a registry built without the client lens leaves the Qobuz
// source permanently detached: every catalog call then answers
// `NotConfigured`, loudly but uselessly. Measured exactly that way on the
// first wiring pass — the local and Plex rows resolved while all 26 Qobuz
// albums came back "no client lens installed".
//
// A LENS, not a cached clone: `qbz-core` REPLACES its client (core.rs:346
// and :384, both from paths that run before `set_session`), so anything
// holding a clone goes stale and fails silently. Reading through on every
// call is what makes that unrepresentable. The read guard lives inside the
// returned future and is dropped with it, which is the same discipline
// `myqbz_play_qt` documents for its own clone-then-drop.
qbz_source::init_registry(std::sync::Arc::new(|| {
Box::pin(async {
let lock = app().core().client();
let guard = lock.read().await;
guard.as_ref().cloned()
})
}));
// Offline guardrails FIRST (before the login screen can show): engine +
// connectivity actor, then the live status forwarder. Both need the
// tokio runtime context for their spawns.
+399 -31
View File
@@ -5,10 +5,17 @@
//! of [`AddItem`]s (from QML as a JSON array through `QbzMyQbzAdd.open`, or
//! directly from a Rust seam such as the Local Library bulk bar). The batch is
//! held in a process-global PENDING store; the picker list is loaded
//! kind-restricted + recency-sorted + `item_exists`-resolved, and on pick /
//! R1-filtered + recency-sorted + `item_exists`-resolved, and on pick /
//! create-and-add the items are written into the chosen collection through the
//! shared `qbz_mixtape::repo`.
//!
//! WHAT MAY GO WHERE is decided ONCE, by [`payload_accepts`] (the R1 rule
//! below). The picker never renders a container that cannot accept the payload,
//! never offers a create chip or a create radio for one, and refuses to open at
//! all when NOTHING can accept it. Every refusal is a `log::warn!` and nothing
//! else: an impossible combination must not be reachable, so the user never
//! sees a block — the block only exists as a safety net behind the UI (R2).
//!
//! Dedup is the backend's job: `add_item_with(allow_duplicate = false)` returns
//! `Ok(false)` for an exact `(collection_id, source, source_item_id)` duplicate
//! — that is NOT an error. We count added vs skipped and surface the net result
@@ -89,6 +96,170 @@ fn source_from_str(s: &str) -> AlbumSource {
}
}
// ---------------------------------------------------------------------------
// R1 — which container accepts which item. THE ONE PLACE.
// ---------------------------------------------------------------------------
//
// >>> TEMPORARY HOME. This rule MUST move to `qbz-source` <<<
//
// It is a property of the ITEM (its kind and its source), not of the MyQBZ
// picker, and the picker is only its first caller: the per-row flyouts
// (TrackRow, PlayerBar, NowPlayingBarSmall, the Local Library bulk bars) have
// to ask the same question to decide whether to render "Add to mixtape" at all.
// The moment a second call site re-derives it by hand, the rule has been
// scattered and the class of bug this replaces is back. When `qbz-source`
// grows the predicate, delete `Accepts` / `accepts_item` / `payload_accepts`
// here and forward to it; NOTHING else in this file reads the rule directly.
/// The set of container kinds that may accept a payload.
///
/// Read it as three independent yes/no answers rather than a "restriction
/// level": the old `restrict_to_mixtape` boolean could only express "mixtapes
/// only" vs "everything", which cannot say that a LOCAL album belongs in a
/// Collection while a LOCAL playlist belongs nowhere.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Accepts {
pub mixtape: bool,
pub collection: bool,
/// A built discography. It is a Collection in every way that matters here
/// (it holds whole albums), it is just not creatable from this picker —
/// hence a third flag rather than reusing `collection`.
pub artist_collection: bool,
}
impl Accepts {
const NONE: Accepts = Accepts {
mixtape: false,
collection: false,
artist_collection: false,
};
const ALL: Accepts = Accepts {
mixtape: true,
collection: true,
artist_collection: true,
};
const MIXTAPE_ONLY: Accepts = Accepts {
mixtape: true,
collection: false,
artist_collection: false,
};
/// Anything at all can take this payload.
fn any(self) -> bool {
self.mixtape || self.collection || self.artist_collection
}
/// A batch is offerable to a container only if EVERY item is.
fn intersect(self, other: Accepts) -> Accepts {
Accepts {
mixtape: self.mixtape && other.mixtape,
collection: self.collection && other.collection,
artist_collection: self.artist_collection && other.artist_collection,
}
}
/// The picker's row filter.
fn allows(self, kind: CollectionKind) -> bool {
match kind {
CollectionKind::Mixtape => self.mixtape,
CollectionKind::Collection => self.collection,
CollectionKind::ArtistCollection => self.artist_collection,
}
}
/// The create sub-panel's filter. `"collection"` maps to `Collection`,
/// anything else to `Mixtape` — the same mapping [`create_collection`]
/// applies, so the gate and the write can never disagree.
fn allows_create(self, kind: &str) -> bool {
if kind == "collection" {
self.collection
} else {
self.mixtape
}
}
/// The kind the create panel should open on when `wanted` is not offered:
/// the one that IS. `None` = nothing is creatable for this payload.
fn create_fallback(self) -> Option<&'static str> {
if self.mixtape {
Some("mixtape")
} else if self.collection {
Some("collection")
} else {
None
}
}
}
/// R3 — an ephemeral row leaves NO trace: it can be played and queued, never
/// stored. Ephemeral ids are synthetic and high (`>= 2^48`,
/// `local_ephemeral::EPHEMERAL_ID_FLOOR`), and neither a `local_tracks` row id
/// (`< 2^40`, below even `local_plex::PLEX_TRACK_ID_FLOOR`) nor a Qobuz track
/// id ever reaches that floor.
///
/// The test is on the ID ALONE, deliberately — NOT on `it.source`. Several
/// call sites stamp a literal `"source": "qobuz"` onto whatever is playing
/// (PlayerBar.qml:559, NowPlayingBarSmall.qml:508), so a source-gated check
/// would wave an ephemeral now-playing track straight through.
fn is_ephemeral_item(it: &AddItem) -> bool {
it.source_item_id
.trim()
.parse::<i64>()
.is_ok_and(crate::local_ephemeral::is_ephemeral_id)
}
/// R1 for ONE item.
///
/// - **Collection** (and artist collection): albums, singles and EPs — which
/// in this payload vocabulary are all one thing, `itemType == "album"`; there
/// is no release-type field and R1 draws no line between them. From ANY
/// source: `"local"` covers folders, Plex and Qobuz downloads alike
/// (`qbz-models/src/mixtape.rs` — `AlbumSource::Local` is an umbrella), so
/// the source is NOT consulted for an album.
/// - **Mixtape**: albums, playlists and tracks.
/// - **A LOCAL playlist belongs nowhere.** `qbz_mixtape::enqueue::
/// resolve_local_item` (`enqueue.rs:404,417-422`) answers `(Playlist, Local)`
/// with the hard error `"local playlists not supported in this release"` —
/// there is no local-only playlist id to resolve against. That error is THE
/// unsupported combination R2 talks about; it stays where it is, as the
/// safety net, and this arm is what makes it unreachable from the UI.
/// - **Ephemeral: nothing** (R3).
///
/// Kind and source are read through `item_type_from_str` / `source_from_str`,
/// the same two functions [`add_items`] writes with, so the gate can never
/// classify an item differently from the row that would be inserted.
fn accepts_item(it: &AddItem) -> Accepts {
if is_ephemeral_item(it) {
return Accepts::NONE;
}
match item_type_from_str(&it.item_type) {
ItemType::Album => Accepts::ALL,
ItemType::Track => Accepts::MIXTAPE_ONLY,
ItemType::Playlist => match source_from_str(&it.source) {
AlbumSource::Qobuz => Accepts::MIXTAPE_ONLY,
AlbumSource::Local => Accepts::NONE,
},
}
}
/// R1 for a BATCH: a container is offered only when it accepts every item.
/// An empty payload accepts nothing (the picker has nothing to add).
pub(crate) fn payload_accepts(items: &[AddItem]) -> Accepts {
if items.is_empty() {
return Accepts::NONE;
}
items
.iter()
.fold(Accepts::ALL, |acc, it| acc.intersect(accepts_item(it)))
}
/// The rule applied to whatever is currently pending. Used by the mutating
/// handlers so they re-derive from the payload rather than trusting a document
/// flag a stale QML frame could still be holding.
fn pending_accepts() -> Accepts {
payload_accepts(&pending_snapshot())
}
// ---------------------------------------------------------------------------
// Document (spec 02 §5.3)
// ---------------------------------------------------------------------------
@@ -121,10 +292,16 @@ struct AddDoc {
header_subtitle: String,
#[serde(rename = "bulkMode")]
bulk_mode: bool,
/// True when ANY pending item is not an album (collections hold whole
/// albums only, so the list narrows to mixtapes).
#[serde(rename = "restrictToMixtape")]
restrict_to_mixtape: bool,
/// R1, projected for the create surfaces: whether a NEW mixtape / a NEW
/// collection may be created for this payload. They are the create chips'
/// and the create radios' visibility, and they are NOT a "restriction
/// level" — both can be true, both can be false (in which case the picker
/// never opened at all). The ROW list is filtered in Rust, so QML has no
/// second copy of the rule to keep in sync.
#[serde(rename = "allowMixtape")]
allow_mixtape: bool,
#[serde(rename = "allowCollection")]
allow_collection: bool,
search: String,
/// The collection currently being written to ("" = idle).
#[serde(rename = "busyId")]
@@ -146,7 +323,10 @@ impl Default for AddDoc {
header_title: String::new(),
header_subtitle: String::new(),
bulk_mode: false,
restrict_to_mixtape: false,
// A closed document offers nothing; `open_items` seeds the real
// answer before `open` ever becomes true.
allow_mixtape: false,
allow_collection: false,
search: String::new(),
busy_id: String::new(),
creating: false,
@@ -187,18 +367,18 @@ pub struct LoadedRow {
pub already_has: bool,
}
/// Load the collections offered as targets, kind-restricted + recency-sorted +
/// Load the collections offered as targets, R1-filtered + recency-sorted +
/// `item_exists`-resolved. BLOCKING (DB) — call from `spawn_blocking`.
///
/// - `restrict_to_mixtape` keeps `kind == mixtape` only (which also excludes
/// artist_collections);
/// - otherwise there is NO kind restriction at all: an album may be added to a
/// Collection AND to an artist_collection, so the user can augment a built
/// discography (`myqbz_add.rs:159-167`);
/// - `accepts` is [`payload_accepts`] for this batch and is the ONLY row
/// filter. A container the payload cannot go into is dropped here, in Rust,
/// so it never reaches the model and cannot be clicked (R2). An album batch
/// still sees artist_collections, so a built discography can be augmented
/// (`myqbz_add.rs:159-167`);
/// - sort = `last_played_at ?? updated_at` DESC;
/// - `already_has` = every pending item's `(source, source_item_id)` is already
/// in that collection.
pub(crate) fn load_rows(restrict_to_mixtape: bool, items: &[AddItem]) -> Vec<LoadedRow> {
pub(crate) fn load_rows(accepts: Accepts, items: &[AddItem]) -> Vec<LoadedRow> {
crate::library_db_qt::with_db(false, |db| {
Ok(db.with_connection(|conn| {
let mut cols: Vec<MixtapeCollection> =
@@ -207,9 +387,7 @@ pub(crate) fn load_rows(restrict_to_mixtape: bool, items: &[AddItem]) -> Vec<Loa
Vec::new()
});
if restrict_to_mixtape {
cols.retain(|c| c.kind == CollectionKind::Mixtape);
}
cols.retain(|c| accepts.allows(c.kind));
cols.sort_by(|a, b| {
let ra = a.last_played_at.unwrap_or(a.updated_at);
@@ -296,15 +474,32 @@ pub(crate) fn open(items_json: &str) {
/// Open the picker for one or more items. Empty input is a no-op (1:1 with
/// `openAddToMixtape([])`). Stores the payload, computes the header strings and
/// the kind restriction, marks loading, then loads the rows on a worker.
/// the R1 answer, marks loading, then loads the rows on a worker.
///
/// **It does not always open.** When [`payload_accepts`] answers "nothing"
/// — an ephemeral row (R3), a local playlist — the picker stays shut and the
/// refusal goes to the log only (R2): an empty picker is a visible block, and
/// a visible block is the thing being removed. The right fix for a caller that
/// trips this is upstream — do not offer the action for that row.
pub(crate) fn open_items(items: Vec<AddItem>) {
if items.is_empty() {
return;
}
let bulk = items.len() > 1;
// Restrict to mixtapes when ANY pending item is a track/playlist.
let restrict = items.iter().any(|it| it.item_type != "album");
let accepts = payload_accepts(&items);
if !accepts.any() {
log::warn!(
"[qbz-qt] myqbz_add: no container accepts this payload, picker not opened \
({} item(s), first = {}/{} id {}) — the caller should not be offering \
\"Add to Mixtape/Collection\" for it",
items.len(),
items[0].item_type,
items[0].source,
items[0].source_item_id
);
return;
}
let first_title = items[0].title.clone();
let first_subtitle = items[0].subtitle.clone().unwrap_or_default();
@@ -333,11 +528,14 @@ pub(crate) fn open_items(items: Vec<AddItem>) {
doc.header_title = header_title;
doc.header_subtitle = header_subtitle;
doc.bulk_mode = bulk;
doc.restrict_to_mixtape = restrict;
doc.allow_mixtape = accepts.mixtape;
doc.allow_collection = accepts.collection;
doc.search = String::new();
doc.busy_id = String::new();
doc.creating = false;
doc.create_kind = "mixtape".to_string();
// Never preset the panel to a kind this payload cannot create;
// `accepts.any()` above guarantees the fallback exists.
doc.create_kind = accepts.create_fallback().unwrap_or("mixtape").to_string();
doc.create_busy = false;
doc.loading = true;
doc.open = true;
@@ -346,7 +544,7 @@ pub(crate) fn open_items(items: Vec<AddItem>) {
let items = pending_snapshot();
crate::spawn(async move {
let rows = tokio::task::spawn_blocking(move || load_rows(restrict, &items))
let rows = tokio::task::spawn_blocking(move || load_rows(accepts, &items))
.await
.unwrap_or_default();
*ROWS_CACHE.lock().unwrap_or_else(|e| e.into_inner()) = rows;
@@ -396,13 +594,32 @@ pub(crate) fn search(query: &str) {
}
/// Open the create sub-panel preset to a kind ("mixtape" | "collection").
///
/// R1-clamped: a kind the pending payload cannot go into is replaced by one it
/// can, and if it can go nowhere the panel does not open. The chip that would
/// ask for a forbidden kind is not rendered in the first place — this is the
/// safety net behind it, and like every other one here it is log-only.
pub(crate) fn show_create(kind: &str) {
let accepts = pending_accepts();
let wanted = if kind == "collection" {
"collection"
} else {
"mixtape"
};
let resolved = if accepts.allows_create(wanted) {
Some(wanted)
} else {
accepts.create_fallback()
};
let Some(resolved) = resolved else {
log::warn!("[qbz-qt] myqbz_add: create '{kind}' refused — nothing is creatable for this payload");
return;
};
if resolved != wanted {
log::warn!("[qbz-qt] myqbz_add: create '{wanted}' is not offered for this payload, using '{resolved}'");
}
with_doc(|doc| {
doc.create_kind = if kind == "collection" {
"collection".to_string()
} else {
"mixtape".to_string()
};
doc.create_kind = resolved.to_string();
doc.create_busy = false;
doc.creating = true;
});
@@ -485,20 +702,33 @@ fn toast_outcome(name: &str, outcome: &AddOutcome) {
crate::toast_qt::success(msg);
}
/// The chosen collection's display name, for the toast.
fn row_name(collection_id: &str) -> String {
/// The chosen collection's display name, for the toast. `None` when the id is
/// not one of the rows this picker loaded.
fn row_name(collection_id: &str) -> Option<String> {
ROWS_CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.find(|r| r.id == collection_id)
.map(|r| r.name.clone())
.unwrap_or_default()
}
/// Pick a target: add every pending item, toast the outcome, close.
/// Re-entrant clicks while a write is in flight are ignored.
///
/// The id MUST be one of the loaded rows, and those are already R1-filtered by
/// [`load_rows`] — so this one lookup is also the R2 net on the write path: an
/// invokable called with any other collection id (a stale QML frame, a row that
/// was filtered out) writes nothing. Log-only, per R2.
pub(crate) fn pick(collection_id: &str) {
let Some(name) = row_name(collection_id) else {
log::warn!(
"[qbz-qt] myqbz_add: pick('{collection_id}') is not an offered row for this \
payload, ignored"
);
return;
};
let accepted = with_doc(|doc| {
if !doc.busy_id.is_empty() {
return false;
@@ -511,7 +741,6 @@ pub(crate) fn pick(collection_id: &str) {
}
publish();
let name = row_name(collection_id);
let items = pending_snapshot();
let cid = collection_id.to_string();
crate::spawn(async move {
@@ -548,6 +777,19 @@ pub(crate) fn create_collection(kind: &str, name: &str) -> Option<(String, Strin
/// raw English literal the reference uses (`main.rs:6009`, absent from all
/// eight catalogs, kept 1:1).
pub(crate) fn create_and_add(kind: &str, name: &str) {
// R1 net on the create path: the radio for a forbidden kind is not
// rendered, so reaching here means a stale frame or a direct invokable
// call. Log-only (R2) — never a toast.
if !pending_accepts().allows_create(kind) {
log::warn!(
"[qbz-qt] myqbz_add: createAndAdd('{kind}') refused — this payload cannot go \
into a {kind}"
);
with_doc(|doc| doc.create_busy = false);
publish();
return;
}
let trimmed = name.trim().to_string();
let accepted = with_doc(|doc| {
if trimmed.is_empty() || doc.create_busy {
@@ -662,6 +904,132 @@ mod tests {
assert_eq!(item_type_from_str("playlist"), ItemType::Playlist);
}
fn add(item_type: &str, source: &str, id: &str) -> AddItem {
AddItem {
item_type: item_type.into(),
source: source.into(),
source_item_id: id.into(),
title: "t".into(),
subtitle: None,
artwork_url: None,
year: None,
track_count: None,
}
}
#[test]
fn r1_albums_go_everywhere_from_every_source() {
// "Collection: albums, singles, EPs. From ANY source — qobuz, local
// (folders AND Plex AND anything added later)."
for source in ["qobuz", "local"] {
let a = payload_accepts(&[add("album", source, "12345")]);
assert_eq!(a, Accepts::ALL, "album/{source} must reach every container");
}
// A Plex album key is a `local` album — the id shape changes nothing.
assert_eq!(
payload_accepts(&[add("album", "local", "plex:5677211365378243606")]),
Accepts::ALL
);
}
#[test]
fn r1_tracks_are_mixtape_only() {
for source in ["qobuz", "local"] {
assert_eq!(
payload_accepts(&[add("track", source, "42")]),
Accepts::MIXTAPE_ONLY
);
}
}
#[test]
fn r1_qobuz_playlists_are_mixtape_only_and_local_playlists_go_nowhere() {
assert_eq!(
payload_accepts(&[add("playlist", "qobuz", "998877")]),
Accepts::MIXTAPE_ONLY
);
// R2: `qbz_mixtape::enqueue::resolve_local_item` hard-errors on
// (Playlist, Local). Offering ANY container for it is what makes that
// error reachable, so nothing is offered and the picker never opens.
let local_playlist = payload_accepts(&[add("playlist", "local", "whatever")]);
assert_eq!(local_playlist, Accepts::NONE);
assert!(!local_playlist.any());
}
#[test]
fn r3_ephemeral_items_are_accepted_by_nothing() {
let eph = (1i64 << 48) + 7;
let a = payload_accepts(&[add("track", "local", &eph.to_string())]);
assert_eq!(a, Accepts::NONE, "an ephemeral row leaves no trace");
// And a caller that stamps a literal "qobuz" source onto whatever is
// playing (PlayerBar.qml:559) must not slip past — the ID decides.
assert_eq!(
payload_accepts(&[add("track", "qobuz", &eph.to_string())]),
Accepts::NONE
);
// An ephemeral ALBUM is still ephemeral, despite being album-shaped.
assert_eq!(
payload_accepts(&[add("album", "local", &eph.to_string())]),
Accepts::NONE
);
}
#[test]
fn ids_below_the_ephemeral_floor_are_untouched() {
// A Plex track id is namespaced at 2^40 — well under 2^48, so it stays
// a normal local track.
let plex = (1i64 << 40) | 4444;
assert_eq!(
payload_accepts(&[add("track", "local", &plex.to_string())]),
Accepts::MIXTAPE_ONLY
);
// A non-numeric id is not an ephemeral id.
assert_eq!(
payload_accepts(&[add("album", "qobuz", "e94no900otyrz")]),
Accepts::ALL
);
}
#[test]
fn a_batch_is_offered_only_what_every_item_accepts() {
// Album + track -> mixtape only (the album's Collection slot loses).
assert_eq!(
payload_accepts(&[add("album", "qobuz", "1"), add("track", "qobuz", "2")]),
Accepts::MIXTAPE_ONLY
);
// One local playlist poisons the batch — exactly the item that would
// hard-error at resolve time.
assert_eq!(
payload_accepts(&[add("album", "qobuz", "1"), add("playlist", "local", "p")]),
Accepts::NONE
);
// Empty is nothing, not everything.
assert_eq!(payload_accepts(&[]), Accepts::NONE);
}
#[test]
fn accepts_drives_rows_and_create_consistently() {
let tracks = Accepts::MIXTAPE_ONLY;
assert!(tracks.allows(CollectionKind::Mixtape));
assert!(!tracks.allows(CollectionKind::Collection));
assert!(
!tracks.allows(CollectionKind::ArtistCollection),
"an artist collection is a discography — albums only"
);
assert!(tracks.allows_create("mixtape"));
assert!(!tracks.allows_create("collection"));
assert_eq!(tracks.create_fallback(), Some("mixtape"));
let albums = Accepts::ALL;
assert!(albums.allows(CollectionKind::ArtistCollection));
assert!(albums.allows_create("collection"));
assert_eq!(Accepts::NONE.create_fallback(), None);
// `allows_create` uses create_collection's own mapping: unknown = mixtape.
assert!(albums.allows_create("nonsense"));
assert!(!Accepts::NONE.allows_create("nonsense"));
}
#[test]
fn kind_icons_match_the_reference() {
assert_eq!(kind_icon(CollectionKind::Mixtape), "cassette");
+52 -17
View File
@@ -83,7 +83,11 @@ pub struct InlineTrackRow {
/// Always "" — the rows are mounted with `showArtwork: false`.
#[serde(rename = "artPath")]
pub art_path: String,
/// "qobuz" | "local" | "plex" — the delegate's `draggable` gate.
/// "qobuz" | "local" | "offline" | "plex" — the delegate's `draggable`
/// gate, and a `SourceIcon.qml` kind. `"offline"` is the badge of a Qobuz
/// download/purchase, which is a local FILE with a cloud glyph; it appears
/// here because the resolved rows now carry the source's real word instead
/// of a flattened `"local"` (see `source_kind_of`).
pub source: String,
}
@@ -107,8 +111,11 @@ pub struct DetailRow {
/// local/Plex items, whose tracks carry none).
#[serde(rename = "artistId")]
pub artist_id: String,
/// RESOLVED source ("qobuz" | "plex" | "local") — this is how a Plex item
/// stored as `AlbumSource::Local` finally reads "plex".
/// RESOLVED source ("qobuz" | "plex" | "local" | "offline") — this is how a
/// Plex item stored as `AlbumSource::Local` finally reads "plex", and a
/// Qobuz download stored the same way reads "offline". Both come from the
/// registry's claim on the resolved row (`source_kind_of`), never from a
/// literal.
#[serde(rename = "sourceKind")]
pub source_kind: String,
#[serde(rename = "typeLabel")]
@@ -575,6 +582,35 @@ fn build_rows(items: &[MixtapeCollectionItem]) -> Vec<DetailRow> {
.collect()
}
/// The source word a RESOLVED track renders as — the glyph kind in
/// `SourceIcon.qml` and the `sourceKind` field of a detail row.
///
/// It comes from the ROW, never from a literal. What it replaces (twice) was
///
/// ```ignore
/// track.source.clone().unwrap_or_else(|| if track.is_local { "local" } else { "qobuz" })
/// ```
///
/// — a hand-written fallback that guessed a source out of one boolean, and
/// which drops every word that is not exactly `"local"` or `"qobuz"`.
/// `SourceRegistry::claim` applies the ONE source-word table
/// (`qbz_source::SourceId::from_word`) to all six words that really occur, and
/// for a LOCAL row the BADGE keeps the distinction the glyph needs: a Qobuz
/// download is a local FILE (source `local`) but must draw the cloud glyph
/// (badge `offline`), which the source id alone collapses.
///
/// A row nothing claims (`Unclaimed` / `Ambiguous`) falls back to its badge,
/// which is `"local"` for an unknown word — never `"qobuz"`, because guessing
/// Qobuz from "not otherwise recognised" is exactly the mis-attribution the
/// seam deletes.
fn source_kind_of(track: &QueueTrack) -> &'static str {
let raw = qbz_source::RawRef::from_queue_track(track);
match qbz_source::registry().claim(&raw).map(|m| m.source()) {
Ok(id) if id != qbz_source::SourceId::LOCAL => id.as_str(),
_ => raw.badge.as_str(),
}
}
/// One inline track row from a resolved `QueueTrack` (spec 02 §5.2).
fn track_to_item(track: &QueueTrack) -> InlineTrackRow {
let quality_tier = match track.bit_depth {
@@ -588,13 +624,7 @@ fn track_to_item(track: &QueueTrack) -> InlineTrackRow {
} else {
crate::home_qt::quality_detail_from_parts(track.bit_depth, track.sample_rate)
};
let source = track.source.clone().unwrap_or_else(|| {
if track.is_local {
"local".to_string()
} else {
"qobuz".to_string()
}
});
let source = source_kind_of(track).to_string();
InlineTrackRow {
id: track.id.to_string(),
@@ -1012,14 +1042,10 @@ fn resolve_from_tracks(item: &MixtapeCollectionItem, tracks: &[QueueTrack]) -> R
let stored = source_str(item.source);
let first = tracks.first();
// The RESOLVED source, from the row itself (`source_kind_of`). An item that
// resolved to nothing keeps its STORED source — there is no row to ask.
let source_kind = match first {
Some(t) => t.source.clone().unwrap_or_else(|| {
if t.is_local {
"local".to_string()
} else {
"qobuz".to_string()
}
}),
Some(t) => source_kind_of(t).to_string(),
None => stored.to_string(),
};
@@ -1122,6 +1148,15 @@ fn apply_resolved(item: &MixtapeCollectionItem, resolved: ResolvedItem, collecti
/// Resolve every not-yet-cached item's tracks and hydrate its row.
/// Fire-and-forget: a failure leaves the stored-source defaults in place.
///
/// Since the `qbz-source` wiring, this pass NO LONGER NEEDS A QOBUZ CLIENT:
/// `fetch_item_tracks` used to return early with an empty vec when there was
/// none, so a local or Plex row rendered a grey square, an empty quality
/// skeleton and no inline tracks for the whole session whenever it was offline
/// or logged out. Each item now resolves against its own source, and only the
/// Qobuz ones fail when the catalog is unreachable. `runtime` survives as the
/// per-task handle the resolve tasks are spawned with; the resolver itself is
/// the process-wide registry.
fn resolve_items(runtime: Arc<AppRuntime<LoggingAdapter>>) {
let pending: Vec<MixtapeCollectionItem> = {
let items = FULL_ITEMS.lock().unwrap();
+82 -56
View File
@@ -7,6 +7,31 @@
//! `qbz-mixtape` enqueue resolver, then drives the queue through this port's
//! stamping seam (`playback_qt::set_queue_stamped` / `stamped`).
//!
//! RESOLUTION GOES THROUGH `qbz-source` (design 02 §9 stage 2). Every path
//! here builds ONE `qbz_source::RegistryResolver` over the process-wide
//! registry instead of a per-call `ProdItemResolver { qobuz client, local
//! closure }`. Three things follow, and all three are the point:
//! - the hand-rolled `resolve_local` closure is GONE, and with it the
//! `library_db_qt::with_db` open it performed once per item (23-34 opens for
//! one collection); `LocalSource` owns one pooled handle.
//! - NO path is gated on a Qobuz client any more. A collection's local / Plex
//! items RESOLVE — quality, artwork, tracks, and into the queue — offline,
//! logged out or during an API hiccup, which they did not before.
//! - a Qobuz item with no client now fails PER ITEM (logged + skipped by
//! `resolve_collection_tracks`) instead of emptying the whole resolve.
//!
//! No source is branched on by hand here: `SourceRegistry::claim` is the one
//! normalisation point, and the source of a row always comes from the ROW.
//!
//! STILL OPEN, and NOT this stage's job: every `Play` arm below starts the
//! first track with `core().play_track_resolved`, which is the Qobuz-only
//! entry (`qbz-core/src/core.rs:730-736` errors "No Qobuz client available").
//! A local or Plex first track therefore still fails AT THE PLAYER even though
//! it now resolves. That router is design 02 §9 stage 3
//! (`SourceRegistry::playback` → `PlaybackTicket`), which owns both copies of
//! `play_audible` as well; nothing here should grow a source branch to
//! anticipate it.
//!
//! Contract, all load-bearing and 1:1 with the reference:
//! - "Resolve all" uses the collection's persisted `play_mode`; the hero
//! Shuffle forces `AlbumShuffle`.
@@ -35,9 +60,10 @@ use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_mixtape::enqueue::{resolve_collection_tracks, ItemResolver, ProdItemResolver};
use qbz_mixtape::enqueue::{resolve_collection_tracks, ItemResolver};
use qbz_models::mixtape::{CollectionPlayMode, MixtapeCollection, MixtapeCollectionItem};
use qbz_models::QueueTrack;
use qbz_source::{RawRef, RegistryResolver, SourceId};
type Runtime = Arc<AppRuntime<LoggingAdapter>>;
@@ -100,40 +126,37 @@ impl InlineTrackMode {
// Resolver construction
// ---------------------------------------------------------------------------
/// The SYNCHRONOUS local-item resolver handed to `ProdItemResolver`.
/// THE resolver for every path in this file.
///
/// A plain `fn` item, which satisfies the resolver's `Send + Sync` bound.
/// `with_db` is synchronous, so `&LibraryDatabase` never crosses an `.await`
/// (T1 — this is exactly why `qbz-mixtape` split the local resolver into a sync
/// free fn). A DB-open failure becomes its own `Err` so the item is SKIPPED, not
/// treated as an empty success.
fn resolve_local(item: &MixtapeCollectionItem) -> Result<Vec<QueueTrack>, String> {
crate::library_db_qt::with_db(false, |db| {
Ok(qbz_mixtape::enqueue::resolve_local_item(db, item))
})
.unwrap_or_else(|| Err("library database unavailable".to_string()))
}
/// Snapshot the shared Qobuz client. The CLONE is what keeps the `&` handed to
/// `ProdItemResolver` alive across every Qobuz `.await`; holding the
/// `RwLockReadGuard` instead deadlocks against the playback path (T3).
async fn qobuz_client(runtime: &Runtime, site: &str) -> Option<qbz_qobuz::QobuzClient> {
let client_lock = runtime.core().client();
let client = {
let guard = client_lock.read().await;
guard.as_ref().cloned()
};
if client.is_none() {
log::warn!("[qbz-qt] myqbz_play: no Qobuz client; cannot {site}");
}
client
/// It replaces `ProdItemResolver::new(&client, resolve_local)` at all five
/// former construction sites. Two functions died with them:
///
/// - `resolve_local` — the synchronous local closure, whose body was
/// `library_db_qt::with_db(false, |db| resolve_local_item(db, item))`. It ran
/// ONCE PER ITEM, and `with_db` was `LibraryDatabase::open` per call: 23-34
/// opens for one collection, 200 for a 200-item one. `LocalSource` pools the
/// handle, so the same sequential resolve loop (`enqueue.rs:50-68`) now
/// reuses ONE.
/// - `qobuz_client` — the per-call client snapshot, and the four `let-else`
/// guards it fed. `RegistryResolver` needs no client: `QobuzSource` holds the
/// shared one (published by the auth path) and only the QOBUZ arm needs it.
///
/// `&LibraryDatabase` still never crosses an `.await` — `LocalSource::tracks`
/// has no await in its body and the pool hands out no guard, so the T1
/// constraint is now enforced by the type system instead of by convention.
fn registry_resolver() -> RegistryResolver {
RegistryResolver::new(qbz_source::registry())
}
/// Resolve a whole collection's items into a flat queue. `force_shuffle`
/// overrides the persisted mode with `AlbumShuffle` (the hero Shuffle CTA).
/// No client ⇒ empty (the caller toasts).
///
/// Items that fail are logged and SKIPPED by `resolve_collection_tracks`, so a
/// collection whose Qobuz items cannot resolve (offline, logged out) still
/// plays its local and Plex ones. `_runtime` is kept in the signature because
/// `myqbz_mix_qt::…` calls this and the registry is process-wide.
pub(crate) async fn resolve_collection(
runtime: &Runtime,
_runtime: &Runtime,
collection: &MixtapeCollection,
force_shuffle: bool,
) -> Vec<QueueTrack> {
@@ -142,21 +165,15 @@ pub(crate) async fn resolve_collection(
} else {
collection.play_mode
};
let Some(client) = qobuz_client(runtime, "resolve collection").await else {
return Vec::new();
};
let resolver = ProdItemResolver::new(&client, resolve_local);
let resolver = registry_resolver();
resolve_collection_tracks(collection.items.clone(), play_mode, &resolver).await
}
/// Resolve a SINGLE item (per-row actions). This path bypasses
/// `resolve_collection_tracks`, so it must stamp `source_item_id_hint` INLINE —
/// missing it silently breaks item-boundary detection.
async fn resolve_single_item(runtime: &Runtime, item: &MixtapeCollectionItem) -> Vec<QueueTrack> {
let Some(client) = qobuz_client(runtime, "resolve item").await else {
return Vec::new();
};
let resolver = ProdItemResolver::new(&client, resolve_local);
async fn resolve_single_item(item: &MixtapeCollectionItem) -> Vec<QueueTrack> {
let resolver = registry_resolver();
match resolver.resolve(item).await {
Ok(mut tracks) => {
let hint = item.source_item_id.clone();
@@ -183,14 +200,18 @@ async fn resolve_single_item(runtime: &Runtime, item: &MixtapeCollectionItem) ->
/// The offline check does NOT short-circuit anything: the resolve is attempted
/// and the check only picks the LOG LEVEL of the failure, because the offline
/// gate refuses the Qobuz arm by design (spec 02 §1.1).
///
/// THE CLIENT GATE IS GONE, and that is a behaviour change, not a cleanup. This
/// function used to return early with an empty vec when there was no Qobuz
/// client, so every LOCAL and PLEX row of a collection showed no quality, no
/// artwork and no tracks — and refused to play — whenever the session was
/// offline, pre-login or mid-hiccup. The local arm never needed that client;
/// only `QobuzSource` does, and it now reports its own absence per item.
pub(crate) async fn fetch_item_tracks(
runtime: &Runtime,
_runtime: &Runtime,
item: &MixtapeCollectionItem,
) -> Vec<QueueTrack> {
let Some(client) = qobuz_client(runtime, "fetch item tracks").await else {
return Vec::new();
};
let resolver = ProdItemResolver::new(&client, resolve_local);
let resolver = registry_resolver();
match resolver.resolve(item).await {
Ok(tracks) => tracks,
Err(e) => {
@@ -226,21 +247,27 @@ pub(crate) async fn fetch_item_tracks(
/// verbatim so wiring the picker is a one-line call, not a re-port.
#[allow(dead_code)]
pub(crate) async fn resolve_bulk_qobuz_track_ids(
runtime: &Runtime,
_runtime: &Runtime,
items: &[MixtapeCollectionItem],
) -> Vec<String> {
let Some(client) = qobuz_client(runtime, "resolve bulk ids").await else {
return Vec::new();
};
let resolver = ProdItemResolver::new(&client, resolve_local);
let resolver = registry_resolver();
let registry = qbz_source::registry();
let mut ids: Vec<String> = Vec::new();
for item in items {
match resolver.resolve(item).await {
Ok(tracks) => {
for t in tracks {
let is_qobuz = t.source.as_deref() == Some("qobuz")
|| (t.source.is_none() && !t.is_local);
// The row decides. This was a hand-written
// `source == "qobuz" || (source.is_none() && !is_local)`
// — the one correct rule for an unlabelled queue row, and
// now the rule `QobuzSource::claim` applies, in ONE place,
// for every source word (`qobuz_download` and `offline`
// are local FILES and are correctly excluded here).
let is_qobuz = registry
.claim(&RawRef::from_queue_track(&t))
.map(|m| m.source() == SourceId::QOBUZ)
.unwrap_or(false);
if is_qobuz {
ids.push(t.id.to_string());
}
@@ -436,7 +463,7 @@ pub(crate) fn item_action(source_item_id: String, action: String) {
return;
};
let tracks = resolve_single_item(&runtime, &item).await;
let tracks = resolve_single_item(&item).await;
if tracks.is_empty() {
crate::toast_qt::error(qbz_i18n::t("This item resolved to 0 playable tracks"));
return;
@@ -526,11 +553,10 @@ fn bulk_enqueue_inner(items: Vec<MixtapeCollectionItem>, mode: BulkEnqueueMode)
}
let runtime = crate::app();
crate::spawn(async move {
let Some(client) = qobuz_client(&runtime, "bulk-enqueue").await else {
crate::toast_qt::error(qbz_i18n::t("These items resolved to 0 playable tracks"));
return;
};
let resolver = ProdItemResolver::new(&client, resolve_local);
// No client gate: a selection of local / Plex items enqueues with no
// Qobuz session at all, and a Qobuz item that cannot resolve is skipped
// per item (below) instead of failing the whole batch.
let resolver = registry_resolver();
let mut tracks: Vec<QueueTrack> = Vec::new();
for item in &items {