feat(qt): mark tracks Qobuz pulled, and keep them out of the queue

Qobuz withdraws tracks: sometimes a recording, sometimes only a quality tier,
sometimes it republishes the same recording under a new album id. The row keeps
appearing and simply cannot play. Until now the user found out by clicking it.

The app already knew and threw the answer away: eight builders hardcoded
`streamable: true`, so the API's own flag never reached a row model. The
reactive half was already here and stays -- a terminal failure still toasts and
skips, bounded at five consecutive, with the 403 back-off kept separate so a
403 storm cannot burn five good tracks.

Detection has two clauses. The API's `streamable: false`, and a session-only
memory of tracks that died terminally under the player this run: /album/get is
honest, but plenty of rows only fail at the stream-url step, and that memory is
what stops the queue re-offering them. Session-only on purpose -- the reference
kept the equivalent set in localStorage, never expired it and never revalidated
it, so a track Qobuz restored stayed dead forever.

Dead rows are dimmed, get an alert glyph and a tooltip, lose click, drag,
favourite and download, and STAY visible and right-clickable: hiding them would
defeat the point and remove the only route to a replacement. Qobuz's own player
does exactly this, and the pulled track carries displayable:false while still
being rendered -- that flag is not a hide instruction.

A downloaded copy is exempt everywhere. The offline tier resolves before any
availability question, so a pulled track already on disk plays fine; dropping it
from the queue would take away something the user downloaded, and it would bite
hardest offline. Those rows say they are playing the local copy and keep their
play affordance.

Two seams filter, not one, and both had to learn the surviving anchor: every
caller read `first_id` from the UNFILTERED list, so the seam dropped that row
and the app then asked the player for the id it had just discarded -- silence,
no toast. That was already reachable today through the artist blacklist. The
seams now return the surviving start and its id.

Two id bugs surfaced on the way and are fixed here. A playlist row's
`playlist_track_id` was filled with the CATALOG id; they are different numbers
for the same row, one naming the recording and the other its slot in that
playlist, and Qobuz's delete and reposition verbs both take the membership id --
so "remove from playlist" was addressing a row that does not exist. And the For
You rows were built with `..default()`, claiming availability while the queue
built from the same track read the truth.

Catalogs: the two strings this needed already existed in all seven translated
locales and are reused; the skipped-tracks toast is a plural pair, and the
replacement feature's strings ride along here since they share the files.

cc
This commit is contained in:
vicrodh committed 2026-08-17 14:41:26 -06:00
1 parent 8e52157299
commit 7780efa5db
25 files changed
+1666 -161

No files matched your search

+268 -27
View File
@@ -13,7 +13,9 @@
// duration, qualityTier, qualityDetail, explicit, isFavorite, artPath? }
// (plus playlistTrackId for the remove-from-playlist arm, and the OPTIONAL
// `source` / `unavailable` a mixed-source producer carries — the Add to
// playlist gate below is the one arm that reads them). `qualityDetail`
// playlist gate below is the one arm that reads them, plus the OPTIONAL
// `qobuzUnavailable`, which is a DIFFERENT thing from `unavailable` and is
// documented where it is read). `qualityDetail`
// is the bare exact-quality string ("16-bit / 44.1 kHz"); when a producer
// does not carry it yet the badge degrades to the tier label alone rather
// than to a blank cell. `item.isFavorite` SEEDS the row's own `favorite`
@@ -30,7 +32,9 @@
// dragStart — the #589 reorder pre-hook), mixtapeRequested() (the host
// builds the MyQBZ AddItem payload), goToRequested(kind) (only when
// `routeGoToExternally` is set), playlistAddRequested() (only when
// `routePlaylistAddExternally` is set — the local-mode "Add to playlist").
// `routePlaylistAddExternally` is set — the local-mode "Add to playlist"),
// replaceRequested() (only when `routeReplaceExternally` is set — the
// "Find available version" repair for a track Qobuz pulled).
// Favorite toggling, Go-to-artist/album, Share and Track info are
// identical on every site — handled internally. The row BODY is the drag
// source (6px threshold, ghost + sidebar drops in main.rs) and its RIGHT
@@ -58,6 +62,11 @@
// labelled columns, so every label sat right of the column it named).
import QtQuick
// Qt's own attached ToolTip, for the pulled-track glyphs ONLY. The shell's
// shared bubble (controls/QbzTooltip.qml) is reached by naming the AppShell
// instance, which a recycled delegate cannot do — the same trade
// controls/QbzMultiSelectBar.qml and views/AlbumView.qml already made.
import QtQuick.Controls
import com.blitzfc.qbz
import "../controls"
import "../immersive"
@@ -163,6 +172,50 @@ Rectangle {
}
}
// --- PULLED FROM THE QOBUZ CATALOGUE (contract §5.2) ------------------
// `item.qobuzUnavailable` is the API saying `streamable: false` for THIS
// track. The producer stamps it from `qbz_models::Track::is_streamable()`
// — never from an absent key (that is exactly what the contract's §3.1
// `Option<bool>` exists to keep apart), never from a 403, never from a
// network error. A producer that does not carry the field yet leaves it
// `undefined`, which reads as "available" here: absence is not a claim.
//
// It is a NEW field and deliberately NOT `item.unavailable`, which this
// file already reads a few lines below. That one is a BROKEN LOCAL REF (a
// Plex key the cache does not know, a stored id outside the catalog
// range). The two look alike and behave oppositely: a broken ref stays
// fully SELECTABLE so the user can delete it and can never heal on its
// own, while a pulled Qobuz track is inert and CAN be repaired by the
// replacement search. One flag for both would collapse two treatments into
// whichever was written last.
readonly property bool pulled: root.item.qobuzUnavailable === true
/// THE DEAD ROW — dim, inert, alert glyph.
///
/// `cacheStatus === 3` (downloaded) is excluded on purpose: a pulled track
/// the user already downloaded STILL PLAYS. `qbz-core/src/core.rs:716`
/// resolves the offline tier before any network call and before any
/// availability question, so the file on disk is served regardless of what
/// the catalogue says. Treating it as dead would take away a song the user
/// owns — a worse bug than the one this treatment fixes, and it would bite
/// hardest in offline mode. It gets `pulledCached` below instead.
readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3
/// Pulled from the catalogue, but playable from the downloaded copy. Keeps
/// its play cell, its heart and its click; gains only an honest badge,
/// because the user still needs to know the track is gone from Qobuz —
/// the moment they clear the download it dies.
readonly property bool pulledCached: root.pulled && root.cacheStatus === 3
/// "Find available version" — HOST-routed, the `routeGoToExternally`
/// idiom. Only the host knows which playlist is open, whether the user
/// OWNS it (Qobuz refuses a write to anyone else's) and that the row is a
/// catalog membership rather than a local ref. Default off, so no other
/// surface grows the entry.
property bool routeReplaceExternally: false
signal replaceRequested()
readonly property bool hasReplaceSeam: root.pulled && root.routeReplaceExternally
// --- Add to playlist -------------------------------------------------
// LIVE since QbzPlaylistPicker landed. It is NOT a flat constant, because
// the entry's correctness depends on WHICH ID SPACE this row's `item.id`
@@ -307,7 +360,13 @@ Rectangle {
width: parent ? parent.width : 0
height: 50
radius: 8
color: hovered ? rowHoverBg : (zebra && number % 2 === 0 ? "#07ffffff" : "transparent")
// Hover fill is OFF on a dead row (views/purchases/PurchaseListRow.qml
// does the same on its unavailable rows): a row that lights up under the
// pointer reads as clickable, and this one is not. The zebra stripe stays
// — it is structure, not affordance.
color: (hovered && !root.pulledDead)
? rowHoverBg
: (zebra && number % 2 === 0 ? "#07ffffff" : "transparent")
// `playArea` is in here for the reason TrackPlayCell.slint:76-79 spells
// out: the cell owns a hover-enabled area of its own, and "Slint's
@@ -426,6 +485,12 @@ Rectangle {
anchors.leftMargin: cols.padH
anchors.rightMargin: cols.padH
spacing: cols.gap
// THE DIM (contract §5.2). On the Row, not on `root`: the row's menu
// and its now-playing edge mark are siblings of this item, and neither
// should fade. 0.5 is views/purchases/PurchaseListRow.qml's value —
// the only precedent for this state in the tree, so the two screens
// agree.
opacity: root.pulledDead ? 0.5 : 1.0
// --- Reorder gutter (primitives/TrackRow.slint:270-311) ----------
// The LEADING cell on a reorderable list: a 22px column with an
@@ -503,7 +568,10 @@ Rectangle {
// :93) — the playing row keeps the affordance at rest, which is
// what the accent ring is FOR. The port showed it on hover only,
// so the ring never appeared.
readonly property bool showOverlay: root.hovered || root.isActive
// A dead row never offers the play disc: the affordance IS the
// claim that clicking does something.
readonly property bool showOverlay: (root.hovered || root.isActive)
&& !root.pulledDead
// `accent-ring` (:108): the playing row's static form — only with
// the animation pref OFF; with it ON the eq bars carry the state.
readonly property bool accentRing: !root.playIndicatorAnim
@@ -550,12 +618,59 @@ Rectangle {
}
}
Text {
visible: !playCell.showOverlay && !root.selectMode
visible: !playCell.showOverlay && !root.selectMode && !root.pulledDead
anchors.centerIn: parent
text: root.number
color: theme.textMuted
font.pixelSize: 13
}
// PULLED FROM THE CATALOGUE — the alert glyph takes the track
// number's slot, so the leading column still says "what this row
// is" and nothing reflows.
//
// `circle-alert`, NOT `triangle-alert` (contract §A F11): the
// download column of THIS row already draws `triangle-alert` for
// `cacheStatus === 4` (a failed cache), and two identical glyphs
// meaning two different things on one 50px row is a defect, not a
// style. `circle-alert` is also what the Tauri frontend used for
// exactly this state (TrackRow.svelte's CircleAlert), so the
// meaning is not being invented here. Both glyphs exist in every
// tint dir (qml/assets/icons/*/circle-alert.svg).
//
// A Loader, so a normal row instantiates NOTHING — not the glyph,
// not the MouseArea, and not the attached ToolTip, which is a
// QObject per item the moment it is referenced. A 16K-row list
// must not pay for a state a handful of rows are ever in.
Loader {
anchors.centerIn: parent
width: 20
height: 20
active: root.pulledDead && !root.selectMode
visible: active
sourceComponent: Item {
QbzIcon {
anchors.centerIn: parent
name: "circle-alert"
width: 15
height: 15
tintName: "favorite"
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
// No cursorShape override: an Arrow over an inert row
// is the honest signal, and PurchaseListRow.qml does
// the same on its unavailable rows.
ToolTip.visible: containsMouse
ToolTip.delay: 300
// REUSED msgid — already translated in all 7
// translated locales (es:3658); it is the same
// sentence the reactive skip path toasts.
ToolTip.text: QbzSession.tr(
"This track is no longer available", QbzSession.trRev)
}
}
}
Rectangle {
visible: playCell.showOverlay && !root.showBars && !root.selectMode
anchors.centerIn: parent
@@ -592,7 +707,12 @@ Rectangle {
// it would swallow the checkbox's press. Disabling it in
// select mode is what hands the cell over (the play
// affordance is invisible there anyway).
enabled: !root.selectMode
//
// The cell is its OWN click target (TrackPlayCell.slint
// :234-243), so disabling the row body is not enough: without
// the `pulledDead` term, clicking the alert glyph would start
// a play that cannot succeed.
enabled: !root.selectMode && !root.pulledDead
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
@@ -639,11 +759,16 @@ Rectangle {
spacing: 6
Text {
text: root.item.title || ""
color: theme.textPrimary
// Muted, not just dimmed: the Row's 0.5 opacity fades the
// whole cell uniformly, and the TITLE is the part that has
// to stop reading as live content.
color: root.pulledDead ? theme.textMuted : theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
elide: Text.ElideRight
width: Math.min(implicitWidth, parent.parent.width - (root.item.explicit ? 22 : 0))
width: Math.min(implicitWidth, parent.parent.width
- (root.item.explicit ? 22 : 0)
- (root.pulledCached ? 22 : 0))
}
Rectangle {
visible: root.item.explicit === true
@@ -660,6 +785,50 @@ Rectangle {
font.weight: theme.weightSemibold
}
}
// CACHED BUT PULLED (contract §5.3, finding F5). Qobuz removed
// the track, but the user DOWNLOADED it and `qbz-core` resolves
// the offline tier before any availability question, so it
// still plays perfectly. This row is therefore NOT the dead
// row — it keeps its play cell, its heart and its click, and
// all it gains is an honest badge, because the user still needs
// to know it is gone from the catalogue: the moment they clear
// the download, it dies.
//
// `cloud-off`, which reads as "not in the cloud any more" and
// collides with nothing on this row (the download column shows
// `circle-check-big` for the copy that makes this state
// possible). Loader for the same per-row cost reason as the
// dead glyph above.
Loader {
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
active: root.pulledCached
visible: active
sourceComponent: Item {
QbzIcon {
anchors.centerIn: parent
name: "cloud-off"
width: 14
height: 14
tintName: "muted"
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
ToolTip.visible: containsMouse
ToolTip.delay: 300
// NEW msgid — the one string this state needs and
// the catalogs do not carry (the i18n lane adds it
// to all eight). Not foldable into "This track is
// no longer available": that sentence would be a
// lie on a row that is playing right now.
ToolTip.text: QbzSession.tr(
"No longer on Qobuz — playing your downloaded copy",
QbzSession.trRev)
}
}
}
}
Text {
width: parent.width
@@ -756,6 +925,15 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
color: favArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
// The SLOT stays (the cell keeps its `visible` arm) and only
// the glyph goes. Hiding the whole cell would collapse it out
// of the Row and shift every column left of it — and
// rows/TrackListHeader.qml lays out from the same TrackCols
// object, so the header would no longer name the columns under
// it. Suppressing the affordance without moving the grid is
// what the reference did too (Tauri PlaylistDetailView
// :2712-2713).
visible: !root.pulledDead
anchors.centerIn: parent
name: root.favorite ? "heart-filled" : "heart"
width: 16
@@ -772,8 +950,10 @@ Rectangle {
// TrackRow.slint:527/558/623/688 — the in-row controls stand
// down in select mode so their click reaches the row body.
// Without it, aiming at a row and hitting the heart
// un-favourites a track the user meant to tick.
enabled: !root.selectMode
// un-favourites a track the user meant to tick. A dead row's
// heart is not drawn at all (above), so the area under it must
// not stay live either.
enabled: !root.selectMode && !root.pulledDead
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleFavorite()
@@ -793,7 +973,10 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
QbzIcon {
visible: root.cacheStatus === 0
// A pulled track has no file url to fetch, so the download
// affordance could only ever fail — the slot keeps its width
// (see the favourite cell for why) and loses its glyph.
visible: root.cacheStatus === 0 && !root.pulledDead
anchors.centerIn: parent
name: "cloud-download"
width: 16
@@ -814,7 +997,9 @@ Rectangle {
tintName: "accent"
}
QbzIcon {
visible: root.cacheStatus === 4
// Never alongside the pulled-track alert in the number cell:
// one row, one alert.
visible: root.cacheStatus === 4 && !root.pulledDead
anchors.centerIn: parent
name: "triangle-alert"
width: 16
@@ -824,7 +1009,7 @@ Rectangle {
MouseArea {
id: dlArea
anchors.fill: parent
enabled: root.hasOfflineCacheSeam && !root.selectMode
enabled: root.hasOfflineCacheSeam && !root.selectMode && !root.pulledDead
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
@@ -870,20 +1055,35 @@ Rectangle {
function menuModel() {
var t = QbzSession.tr
var r = QbzSession.trRev
var m = [
{ "label": t("Play now", r), "icon": "play-fill", "action": "play" },
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
]
// #442 "Play later" — the end of the MANUAL block (after everything
// already queued by hand, before the source resumes).
if (root.menuShowLater)
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" })
var m = []
// A DEAD row does not get transport entries. D5 keeps an unavailable
// track out of the queue at the seam, so "Play next" here would render
// and do nothing — the rendered-and-inert defect the `has*Seam`
// constants above already refuse ("OFF = the row is not built at all").
// The row keeps its menu because the menu is the ONLY way to reach the
// repair, which is exactly why the row must stay right-clickable.
if (!root.pulledDead) {
m.push({ "label": t("Play now", r), "icon": "play-fill", "action": "play" })
m.push({ "label": t("Play next", r), "icon": "list-start", "action": "next" })
// #442 "Play later" — the end of the MANUAL block (after everything
// already queued by hand, before the source resumes).
if (root.menuShowLater)
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" })
}
// THE REPAIR. First entry on a dead row, because it is the only reason
// that row's menu is worth opening at all. Host-routed — see
// `routeReplaceExternally`.
if (root.hasReplaceSeam)
m.push({ "label": t("Find available version", r), "icon": "refresh-cw",
"action": "find-replacement" })
if (root.hasRadioSeam) {
m.push({ "label": t("Create QBZ radio", r), "icon": "radio", "action": "radio-qbz" })
m.push({ "label": t("Create Qobuz radio", r), "icon": "radio", "action": "radio-qobuz" })
}
if (root.menuShowFavorite)
// The menu and the heart cell agree: neither offers the favourite on a
// dead row.
if (root.menuShowFavorite && !root.pulledDead)
m.push({ "label": root.favorite ? t("Remove from Library", r) : t("Add to Library", r),
"icon": root.favorite ? "heart-filled" : "heart", "action": "favorite" })
if (root.hasMixtapeSeam)
@@ -897,12 +1097,20 @@ Rectangle {
m.push({ "label": t("Share Qobuz link", r), "icon": "link", "action": "share-qobuz" })
if (root.hasSonglinkSeam)
m.push({ "label": t("Share Song.link", r), "icon": "link", "action": "share-songlink" })
if (root.hasOfflineCacheSeam) {
// The dead row offers no cache action at all (there is no stream url
// left to fetch), and the cached-but-pulled row must not be able to
// destroy its only copy with a refresh.
if (root.hasOfflineCacheSeam && !root.pulledDead) {
// TrackContextMenu.slint:174-199 — the offline block tracks the
// row's live status: not-cached rows get the download arm, ready
// rows get refresh + remove.
if (root.cacheStatus === 3) {
m.push({ "label": t("Refresh offline copy", r), "icon": "refresh-cw", "action": "recache" })
// A REFRESH on a pulled track would re-fetch a stream url that
// no longer exists and can only end with the copy gone — the
// one copy that is still playing. Remove stays: the user may
// legitimately want the disk space, and it is their call.
if (!root.pulledCached)
m.push({ "label": t("Refresh offline copy", r), "icon": "refresh-cw", "action": "recache" })
m.push({ "label": t("Remove offline copy", r), "icon": "trash-2", "action": "uncache", "danger": true })
} else {
m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" })
@@ -943,6 +1151,12 @@ Rectangle {
else if (root.catalogRow && !root.localSourceRow)
QbzPlaylistPicker.openForTrack(root.item.id)
}
// "Find available version" — the HOST answers: only it knows the
// playlist id, whether the user owns it, and that this row is a
// catalog membership. `hasReplaceSeam` already gates the entry on the
// host having taken the routing, so there is no internal arm to fall
// through to.
else if (a === "find-replacement") root.replaceRequested()
else if (a === "remove") root.removeRequested()
else if (a === "cache") QbzPlayer.cacheTrack(root.item.id)
else if (a === "uncache") QbzPlayer.uncacheTrack(root.item.id)
@@ -1057,7 +1271,10 @@ Rectangle {
propagateComposedEvents: true
// Right press opens the SAME menu as ⋯ (rowMenu), at the pointer.
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: (root.clickPlays || root.selectMode) ? Qt.PointingHandCursor : Qt.ArrowCursor
// An Arrow over a dead row, whatever the surface's click policy — the
// pointer must not promise a play that is blocked below.
cursorShape: (!root.pulledDead && (root.clickPlays || root.selectMode))
? Qt.PointingHandCursor : Qt.ArrowCursor
// Keep the grab so the enclosing Flickable cannot turn our drag into a
// scroll — see the block above.
//
@@ -1095,8 +1312,11 @@ Rectangle {
// Only a LEFT press drags — a right press is the context gesture.
// Dragging a row onto a sidebar playlist is off in select mode
// (TrackRow.slint keeps the whole body as a selection target).
// A dead row is not a drag source either: dropping it on a sidebar
// playlist would add a track that cannot play, which is precisely
// the state this treatment exists to stop spreading.
if (!pressed || !(pressedButtons & Qt.LeftButton) || !root.draggable
|| root.selectMode) return
|| root.selectMode || root.pulledDead) return
const g = mapToItem(null, mouse.x, mouse.y)
const dx = mouse.x - root.downPos.x
const dy = mouse.y - root.downPos.y
@@ -1139,6 +1359,23 @@ Rectangle {
mouse.accepted = true
return
}
// THE CLICK IS BLOCKED UP FRONT (D4): no request, no toast, no
// error — the row already SAYS it is dead, so clicking it must be
// a no-op rather than a failure the user has to read. The right
// press never reaches here (it returns above), so the context menu
// — the only route to the replacement action — still opens.
//
// AFTER the select-mode arm, not before it: in select mode a click
// means "tick this row", not "play it", so blocking it there would
// buy nothing (no play is issued either way) while leaving the row
// half-selectable — its checkbox has its own live MouseArea in the
// leading cell, and the bulk bar's "Select all" reaches it
// regardless. A row the user cannot tick by clicking but CAN tick
// by hitting the 14px disc is the worse of the two states.
if (root.pulledDead) {
mouse.accepted = true
return
}
if (wasDragging) {
mouse.accepted = true
} else if (root.clickPlays) {
@@ -1158,6 +1395,10 @@ Rectangle {
// double-click must not add a third.
if (root.selectMode)
return
// The album view plays on DOUBLE-click (clickPlays:false there),
// so the single-click block above is not enough on its own.
if (root.pulledDead)
return
if (mouse.button === Qt.LeftButton)
root.playRequested()
}
+51 -4
View File
@@ -320,6 +320,22 @@ Rectangle {
property bool reorderable: false
readonly property bool hovered: qrArea.containsMouse || menuArea.containsMouse
/// Pulled from the catalogue, and not playable from disk. D5 keeps
/// these out of a queue BUILD, so the only way one reaches this list is
/// a session restored from disk or a listing whose endpoint never
/// reported availability — the two cases the reactive backstop exists
/// for. Rare is not never, and an unmarked dead row in the queue is the
/// same lie D4 forbids everywhere else.
///
/// `cacheStatus` is read the same way rows/TrackRow.qml reads it and
/// for the same reason (a pulled track the user DOWNLOADED still plays
/// — contract §5.3/F5). `queue_qt::QueueRow` does not carry the field
/// today, so it reads `undefined !== 3` = true and a downloaded-but-
/// pulled queue row would be dimmed; the cost of that is cosmetic
/// (nothing here gates playback on it) and it corrects itself the day
/// the producer carries it.
readonly property bool pulledDead: row.qobuzUnavailable === true
&& row.cacheStatus !== 3
readonly property bool isActive: inQueue && QbzPlayer.npTrackId !== "" && QbzPlayer.npTrackId === row.id
readonly property bool dragSource: reorderable && root.reorderActive
&& root.reorderFrom === rowIndex
@@ -333,11 +349,14 @@ Rectangle {
height: 44
radius: theme.radiusSm
color: hovered ? theme.surfaceHover
color: (hovered && !qrRoot.pulledDead) ? theme.surfaceHover
: (rowIndex % 2 === 1 ? "#592a2a2a" : "transparent")
border.width: dragSource ? 1 : 0
border.color: theme.accent
opacity: dragSource ? 0.4 : 1.0
// The dead-row dim rides the SAME channel as the drag-source dim so the
// two cannot fight; a dragged dead row is dimmer still, which is
// harmless and honest.
opacity: dragSource ? 0.4 : (qrRoot.pulledDead ? 0.5 : 1.0)
Row {
anchors.fill: parent
@@ -358,7 +377,7 @@ Rectangle {
readonly property bool marked: qrRoot.inQueue
&& root.stopAfterId !== "" && row.id === root.stopAfterId
Text {
visible: !parent.marked
visible: !parent.marked && !qrRoot.pulledDead
anchors.centerIn: parent
width: parent.width
text: rowIndex + 1 + (doc.page || 0) * 40
@@ -374,6 +393,25 @@ Rectangle {
height: 13
tintName: "accent"
}
// Same glyph and the same reason as rows/TrackRow.qml: the
// alert takes the number's slot so the list does not reflow,
// and it is `circle-alert` rather than `triangle-alert` to stay
// clear of the cache-failed meaning that row's download column
// gives the triangle.
//
// The stop-after marker wins the slot when both apply — it is
// a marker the USER placed on this exact row and removing it
// silently would be worse than leaving one dead row unglyphed
// (the dim still marks it, and D5 means the pair is close to
// unreachable anyway).
QbzIcon {
visible: qrRoot.pulledDead && !parent.marked
anchors.centerIn: parent
name: "circle-alert"
width: 13
height: 13
tintName: "favorite"
}
}
Rectangle {
visible: !showNumber
@@ -563,7 +601,9 @@ Rectangle {
// content) as rows/TrackRow.qml's trArea.
z: -1
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Arrow on a dead row, for the reason rows/TrackRow.qml gives: the
// pointer must not promise a play the click below refuses.
cursorShape: qrRoot.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
// THE reason drag-reorder scrolled instead of reordering: the
// enclosing Flickable filters its children's mouse events and
// grabs the press once the pointer moves past
@@ -626,6 +666,13 @@ Rectangle {
qrArea.suppressClick = false
return
}
// D4 again: a dead row is inert on click. The ⋯ menu is
// untouched, so "Remove from queue" — the one thing the user
// actually wants to do with this row — still works, and the
// press-drag reorder above still works too (it commits before
// this handler ever runs).
if (qrRoot.pulledDead)
return
if (inQueue) QbzQueue.queuePlayUpcoming(rowIndex)
else QbzQueue.queuePlayHistory(rowIndex)
}
+141 -32
View File
@@ -1065,10 +1065,31 @@ Rectangle {
readonly property bool isActive: QbzPlayer.npTrackId !== "" && QbzPlayer.npTrackId === row.id
readonly property bool hovered: trArea.containsMouse || favArea.containsMouse || moreArea.containsMouse
// --- PULLED FROM THE QOBUZ CATALOGUE (contract §5.2) --------------
// The artist page is one of the seven surfaces §5.2 names, and this
// component is a documented FORK of rows/TrackRow.qml (see the header
// above), so the treatment has to be repeated here rather than
// inherited. It is repeated EXACTLY — same field, same predicate, same
// `circle-alert` in the number slot, same 0.5 dim — because two
// screens that disagree about what a dead row looks like is the
// fork-drift this file has already paid for once.
//
// `row.qobuzUnavailable` is the API's `streamable: false` for THIS
// track and nothing else: an absent field is not a claim, so a
// producer that does not carry it yet leaves every row alive.
// `cacheStatus === 3` (downloaded) is excluded because a pulled track
// the user already downloaded still plays from disk (§5.3, F5) — this
// fork has no cached-but-pulled badge of its own, so such a row simply
// keeps behaving normally, which is the safe half of that split.
readonly property bool pulled: row.qobuzUnavailable === true
readonly property bool pulledDead: popRow.pulled && popRow.cacheStatus !== 3
width: parent ? parent.width : 0
height: 50
radius: 8
color: hovered ? root.rowHoverBg : (rowIndex % 2 === 1 ? "#07ffffff" : "transparent")
// Hover fill off on a dead row — a row that lights up reads clickable.
color: (hovered && !popRow.pulledDead)
? root.rowHoverBg : (rowIndex % 2 === 1 ? "#07ffffff" : "transparent")
Rectangle {
visible: isActive
@@ -1085,17 +1106,61 @@ Rectangle {
anchors.leftMargin: cols.padH
anchors.rightMargin: cols.padH
spacing: cols.gap
// THE DIM. On the Row, not on `popRow`: the now-playing edge mark
// and the row menu are siblings of this item and neither fades.
// 0.5 is views/purchases/PurchaseListRow.qml's value, the same one
// rows/TrackRow.qml adopted.
opacity: popRow.pulledDead ? 0.5 : 1.0
// Position number (artwork rows carry it separate from the cover).
Text {
// On a dead row the alert glyph takes this slot — same cell, same
// width, so nothing reflows. `circle-alert`, NOT `triangle-alert`:
// the download column below already draws the triangle for a
// FAILED CACHE (`cacheStatus === 4`), and one row must not carry
// two identical glyphs meaning two different things (§A F11).
Item {
visible: showAlbum
width: cols.colNumber
anchors.verticalCenter: parent.verticalCenter
text: row.number
color: theme.textMuted
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
height: parent.height
Text {
visible: !popRow.pulledDead
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
text: row.number
color: theme.textMuted
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
Loader {
anchors.centerIn: parent
width: 20
height: 20
// A Loader, so a live row instantiates neither the glyph
// nor the attached ToolTip (one QObject per row the moment
// it is referenced).
active: popRow.pulledDead
visible: active
sourceComponent: Item {
QbzIcon {
anchors.centerIn: parent
name: "circle-alert"
width: 15
height: 15
tintName: "favorite"
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
ToolTip.visible: containsMouse
ToolTip.delay: 300
// REUSED msgid (es:3658) — the same sentence the
// reactive skip path toasts.
ToolTip.text: QbzSession.tr(
"This track is no longer available", QbzSession.trRev)
}
}
}
}
// Cover with hover play overlay — in select mode this cell is
// the row's round checkbox instead.
@@ -1149,8 +1214,13 @@ Rectangle {
source: root.coverMap[row.artUrl] || ""
radius: 4
}
// The hover scrim and its play glyph are the cover cell's play
// AFFORDANCE, and an affordance is a claim that clicking does
// something — so a dead row shows neither, and the area under
// them is disabled rather than left to issue a play that can
// only fail.
Rectangle {
visible: !popRow.selectMode
visible: !popRow.selectMode && !popRow.pulledDead
anchors.fill: parent
radius: 4
color: "#000000"
@@ -1158,7 +1228,8 @@ Rectangle {
Behavior on opacity { NumberAnimation { duration: 150 } }
}
QbzIcon {
visible: !popRow.selectMode && (trArea.containsMouse || isActive)
visible: !popRow.selectMode && !popRow.pulledDead
&& (trArea.containsMouse || isActive)
anchors.centerIn: parent
name: isActive && QbzPlayer.npPlaying ? "pause" : "play-fill"
width: 16
@@ -1169,7 +1240,7 @@ Rectangle {
}
MouseArea {
anchors.fill: parent
enabled: !popRow.selectMode
enabled: !popRow.selectMode && !popRow.pulledDead
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playArtistTrack(row.id)
}
@@ -1195,7 +1266,10 @@ Rectangle {
spacing: 6
Text {
text: row.title
color: theme.textPrimary
// Muted, not merely dimmed: the Row's 0.5 fades the
// cell uniformly, and the TITLE is the part that has to
// stop reading as live content.
color: popRow.pulledDead ? theme.textMuted : theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
elide: Text.ElideRight
@@ -1272,6 +1346,11 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
color: favArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
// The SLOT stays and only the glyph goes: this fork lays
// out from the same TrackCols object the shared row and its
// header use, so collapsing the cell would shift every
// column left of it.
visible: !popRow.pulledDead
anchors.centerIn: parent
name: parent.favorite ? "heart-filled" : "heart"
width: 16
@@ -1283,7 +1362,7 @@ Rectangle {
MouseArea {
id: favArea
anchors.fill: parent
enabled: !popRow.selectMode
enabled: !popRow.selectMode && !popRow.pulledDead
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
@@ -1301,7 +1380,10 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
QbzIcon {
visible: popRow.cacheStatus === 0
// A pulled track has no file url to fetch, so the download
// affordance could only fail — the slot keeps its width,
// the glyph goes.
visible: popRow.cacheStatus === 0 && !popRow.pulledDead
anchors.centerIn: parent
name: "cloud-download"
width: 16
@@ -1322,7 +1404,9 @@ Rectangle {
tintName: "accent"
}
QbzIcon {
visible: popRow.cacheStatus === 4
// Never alongside the pulled-track alert in the number
// cell: one row, one alert.
visible: popRow.cacheStatus === 4 && !popRow.pulledDead
anchors.centerIn: parent
name: "triangle-alert"
width: 16
@@ -1332,7 +1416,7 @@ Rectangle {
MouseArea {
id: popDlArea
anchors.fill: parent
enabled: !popRow.selectMode
enabled: !popRow.selectMode && !popRow.pulledDead
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
@@ -1388,21 +1472,42 @@ Rectangle {
var t = QbzSession.tr
var r = QbzSession.trRev
var fav = root.toggleState("track:" + popRow.row.id, popRow.row.isFavorite)
var m = [
{ "label": t("Play now", r), "icon": "play-fill", "action": "play" },
{ "label": t("Play next", r), "icon": "list-start", "action": "next" },
{ "label": t("Play later", r), "icon": "list-plus", "action": "later" },
{ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" },
{ "label": fav ? t("Remove from Library", r) : t("Add to Library", r),
"icon": fav ? "heart-filled" : "heart", "action": "favorite" },
{ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" },
{ "label": t("Add to playlist", r), "icon": "list-music", "action": "add-to-playlist" },
]
if (popRow.cacheStatus === 3) {
m.push({ "label": t("Refresh offline copy", r), "icon": "refresh-cw", "action": "recache" })
m.push({ "label": t("Remove offline copy", r), "icon": "trash-2", "action": "uncache", "danger": true })
} else {
m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" })
var m = []
// A DEAD row gets no transport and no favourite entries: D5
// keeps an unavailable track out of the queue at the seam, so
// "Play next" here would render and no-op — the
// rendered-and-inert defect this menu's own header refuses
// ("ABSENT, not dead"). The row keeps its menu because the
// rest of it (add to playlist / mixtape, go-to) still works and
// because a right-clickable dead row is what §5.2 asks for.
if (!popRow.pulledDead) {
m.push({ "label": t("Play now", r), "icon": "play-fill", "action": "play" })
m.push({ "label": t("Play next", r), "icon": "list-start", "action": "next" })
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" })
m.push({ "label": fav ? t("Remove from Library", r) : t("Add to Library", r),
"icon": fav ? "heart-filled" : "heart", "action": "favorite" })
}
m.push({ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" })
m.push({ "label": t("Add to playlist", r), "icon": "list-music", "action": "add-to-playlist" })
// The offline block goes with the transport, and for the same
// reason: a pulled track has no stream url left, so the
// download arm would fail and a REFRESH would destroy the only
// copy the user still has.
if (!popRow.pulledDead) {
if (popRow.cacheStatus === 3) {
// `popRow.pulled` and NOT `pulledDead` here: this arm
// IS the cached-but-pulled row (pulled + cacheStatus 3
// is exactly what `pulledDead` excludes). A refresh on
// it would re-fetch a url that no longer exists and end
// with the copy gone — the one copy still playing.
// Remove stays: the disk space is the user's call.
if (!popRow.pulled)
m.push({ "label": t("Refresh offline copy", r), "icon": "refresh-cw", "action": "recache" })
m.push({ "label": t("Remove offline copy", r), "icon": "trash-2", "action": "uncache", "danger": true })
} else {
m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" })
}
}
if ((popRow.row.albumId || "") !== "")
m.push({ "label": t("Go to album", r), "icon": "disc-3", "action": "go-album" })
@@ -1448,7 +1553,11 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
propagateComposedEvents: true
onDoubleClicked: if (!popRow.selectMode) QbzPlayer.playArtistTrack(row.id)
// The dead row is inert on double-click too (D4): no request, no
// toast, no error. The right-press area below is untouched, so the
// menu still opens.
onDoubleClicked: if (!popRow.selectMode && !popRow.pulledDead)
QbzPlayer.playArtistTrack(row.id)
onClicked: {
if (popRow.selectMode) popRow.toggleSelect()
else mouse.accepted = false
+27
View File
@@ -902,6 +902,33 @@ Rectangle {
// queue id — or 0 on an unresolved one — and the removal
// has to be keyed on the id the position map knows.
onRemoveRequested: QbzBridge.playlistRemoveTrack(String(item.id))
// "Find available version" (contract §6.1) — the ONE
// surface that offers it, and the gate is the reference's:
// a QOBUZ playlist the signed-in user OWNS. Qobuz refuses
// a write to anyone else's playlist, so offering it there
// would be a dead action; a LOCAL playlist's rows are not
// catalog memberships at all, and a mixed local/plex row in
// this same view carries its own `source` word, which is
// why the third term reads the row and not just the doc.
routeReplaceExternally: root.isOwner && !root.isLocal
&& (modelData.source || "") === ""
// Everything Rust needs, in ONE JSON object (the
// `QbzMyQbzAdd.open(JSON.stringify(...))` idiom). No row
// INDEX is passed on purpose: `index` here is the index
// into the DISPLAYED list, which under a search filter or a
// non-default sort is not the playlist's own order — the
// reposition step derives the real slot from the
// authoritative playlist it re-fetches anyway.
onReplaceRequested: QbzTrackReplace.open(JSON.stringify({
"playlistId": String(root.doc.id || ""),
"playlistTrackId": String(item.playlistTrackId || item.id),
"trackId": String(item.id),
"title": item.title || "",
"artist": item.artist || "",
"album": item.album || "",
"isrc": item.isrc || "",
"durationSecs": item.durationSecs || 0
}))
// "Add to playlist" — this view serves BOTH a Qobuz
// playlist and a LOCAL one (`local_playlist_qt::load`
// publishes into this same document through
+20
View File
@@ -101,6 +101,21 @@ pub struct TrackRow {
/// from the session cached-id set, Slint `album.rs:583`.
#[serde(rename = "cacheStatus")]
pub cache_status: i32,
/// Qobuz PULLED this track (`streamable: false` — contract §5.1). Shared by
/// the ALBUM, ARTIST and LABEL track lists, which all render through
/// `qml/rows/TrackRow.qml`, so one field covers three surfaces.
///
/// Absence from the API is NOT this: each of the three builders resolves it
/// through the rule of §3.1 — a missing key reads as AVAILABLE. Greying out
/// an entire catalogue because one endpoint was terse is the failure mode
/// that would sink this feature, and it is why none of the three writes a
/// bare `unwrap_or(false)`.
///
/// Not `skip_serializing_if`: this is a render input on the busiest
/// delegate in the app and it serializes unconditionally, like its
/// neighbours `isFavorite` and `cacheStatus`.
#[serde(rename = "qobuzUnavailable")]
pub not_streamable: bool,
}
/// `Deserialize` rides along with `Serialize` so a card can be read BACK out of
@@ -528,6 +543,11 @@ fn map_track(track: &Track) -> TrackRow {
} else {
0
},
// §5.1 via §3.1's single interpreter of absence. The live capture
// confirms `/album/get` carries the flat key on every track item, so on
// THIS endpoint the answer is real rather than inferred — which is what
// makes the owner's known-bad album a usable smoke.
not_streamable: !track.is_streamable(),
}
}
+15 -1
View File
@@ -544,6 +544,16 @@ fn map_track(index: usize, track: PageArtistTrack) -> TrackRow {
.unwrap_or_default();
let bit_depth = track.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
let sample_rate = track.audio_info.as_ref().and_then(|a| a.maximum_sampling_rate);
// /artist/page reports availability on the nested `rights` object, not as a
// flat key — the same read `playback_qt::artist_top_queue_tracks` (:731)
// already does when it builds the queue row from this payload. `unwrap_or
// (true)` is §3.1's rule at the parse site: a thin page payload must never
// grey out an artist's whole track list.
let not_streamable = !track
.rights
.as_ref()
.and_then(|r| r.streamable)
.unwrap_or(true);
TrackRow {
is_favorite: crate::fav_cache_qt::contains_track(track.id),
id: track.id.to_string(),
@@ -568,6 +578,7 @@ fn map_track(index: usize, track: PageArtistTrack) -> TrackRow {
work_composer_name: String::new(),
work_composer_id: String::new(),
artwork_url,
not_streamable,
}
}
@@ -900,7 +911,10 @@ fn track_row_to_queue(row: &TrackRow) -> QueueTrack {
Some(row.album_id.clone())
},
artist_id: row.artist_id.parse::<u64>().ok(),
streamable: true,
// D5: carried from the row, which read it off `/artist/page`'s nested
// `rights.streamable` (see `map_track`). Hardcoding `true` here threw
// away an answer the payload had already given us.
streamable: !row.not_streamable,
source: Some("qobuz".to_string()),
parental_warning: row.explicit,
source_item_id_hint: None,
+3 -1
View File
@@ -80,7 +80,9 @@ fn queue_track(track: &Track) -> qbz_models::QueueTrack {
is_local: false,
album_id: album_key.clone(),
artist_id: track.performer.as_ref().map(|p| p.id),
streamable: track.streamable,
// §3.1: absence is resolved in ONE place. `is_streamable()` reads a
// missing key as AVAILABLE — only an explicit `false` marks a pull.
streamable: track.is_streamable(),
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning,
source_item_id_hint: album_key,
+9 -1
View File
@@ -1003,6 +1003,12 @@ fn to_row(track: &Track) -> TrackRow {
.unwrap_or_default(),
is_favorite: crate::fav_cache_qt::is_favorite("track", &track.id.to_string()),
cache_status: if crate::offline_qt::is_cached(&track.id.to_string()) { 3 } else { 0 },
// NOT covered by `..default()`: that would leave every For You / mix row
// affirmatively claiming to be available, while `to_queue_track` two
// functions down reads the real value — the row and the queue built from
// the SAME track would disagree, which is the one thing the row model
// exists to prevent.
not_streamable: !track.is_streamable(),
..TrackRow::default()
}
}
@@ -1056,7 +1062,9 @@ pub(crate) fn to_queue_track(track: &Track) -> QueueTrack {
is_local: false,
album_id: album_key.clone(),
artist_id: track.performer.as_ref().map(|p| p.id),
streamable: track.streamable,
// §3.1: absence is resolved in ONE place. `is_streamable()` reads a
// missing key as AVAILABLE — only an explicit `false` marks a pull.
streamable: track.is_streamable(),
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning,
source_item_id_hint: album_key,
+18 -1
View File
@@ -1274,6 +1274,20 @@ fn parse_top_track(index: usize, raw: &Value) -> TrackRow {
.and_then(|a| a.get("maximum_sampling_rate"))
.and_then(|v| v.as_f64())
.or_else(|| raw.get("maximum_sampling_rate").and_then(|v| v.as_f64()));
// Parsed from raw JSON, so the key is read directly, with the nested
// `rights` object as a fallback — the label payload is the least documented
// of the three track shapes and Qobuz uses both spellings elsewhere.
// `unwrap_or(true)` is §3.1 spelled out at the parse site: a missing key
// means the endpoint was terse, never that the track is dead.
let not_streamable = !raw
.get("streamable")
.and_then(|v| v.as_bool())
.or_else(|| {
raw.get("rights")
.and_then(|r| r.get("streamable"))
.and_then(|v| v.as_bool())
})
.unwrap_or(true);
TrackRow {
is_favorite: id.parse::<u64>().map(crate::fav_cache_qt::contains_track).unwrap_or(false),
cache_status: if crate::offline_qt::is_cached(&id) { 3 } else { 0 },
@@ -1301,6 +1315,7 @@ fn parse_top_track(index: usize, raw: &Value) -> TrackRow {
work_composer_name: String::new(),
work_composer_id: String::new(),
artwork_url,
not_streamable,
}
}
@@ -1424,7 +1439,9 @@ fn track_row_to_queue(row: &TrackRow) -> QueueTrack {
Some(row.album_id.clone())
},
artist_id: row.artist_id.parse::<u64>().ok(),
streamable: true,
// D5: carried from the row (see `parse_top_track`). Hardcoding `true`
// here discarded an answer the label payload had already given us.
streamable: !row.not_streamable,
source: Some("qobuz".to_string()),
parental_warning: row.explicit,
source_item_id_hint: None,
+28 -3
View File
@@ -87,6 +87,19 @@ pub struct FeedItem {
pub sample_rate: Option<f64>,
#[serde(rename = "isFavorite")]
pub is_favorite: bool,
/// Qobuz PULLED this track (`streamable: false` — contract §5.1). Track
/// rows only; meaningless on album / artist / playlist rows and skipped
/// when false, so the ~10k-row feed JSON does not grow a key per row.
///
/// BOTH feed -> queue builders read it (`feed_track_to_queue` below and
/// `playback_qt::feed_queue_track`), for the same reason they both read
/// `bit_depth`: the two must not disagree about what a row is.
#[serde(
default,
rename = "qobuzUnavailable",
skip_serializing_if = "std::ops::Not::not"
)]
pub not_streamable: bool,
pub genre: String,
pub year: String,
pub duration: String,
@@ -327,6 +340,12 @@ fn mmss(secs: u32) -> String {
// ---- mappers (favorites.rs / album_map.rs / search.rs ports) --------------
fn map_track(track: Track) -> FeedItem {
// §5.1 via §3.1's single interpreter of absence, read HERE and not down in
// the literal: `is_streamable()` borrows the whole `Track`, and the very
// next line moves a field out of it. A favourited track Qobuz later pulled
// is one of the most likely places a user meets this — they hearted it
// while it was still there.
let not_streamable = !track.is_streamable();
let mut title = track.title;
if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) {
title = format!("{title} ({version})");
@@ -378,6 +397,7 @@ fn map_track(track: Track) -> FeedItem {
explicit: track.parental_warning,
image_url: artwork_url,
is_favorite: true,
not_streamable,
..Default::default()
}
.keyed()
@@ -1647,7 +1667,10 @@ pub(crate) fn feed_track_to_queue(item: &FeedItem) -> Option<QueueTrack> {
Some(item.album_id.clone())
},
artist_id: item.artist_id.parse::<u64>().ok(),
streamable: true,
// D5: the feed row's own answer. Twin of `playback_qt::feed_queue_track`
// over the SAME `FeedItem` — both read this field rather than hardcoding
// a yes, so a Library row and a queue row cannot disagree.
streamable: !item.not_streamable,
source: Some("qobuz".to_string()),
parental_warning: item.explicit,
source_item_id_hint: None,
@@ -1698,8 +1721,10 @@ fn order_by_visible(
///
/// Fire-and-forget like every other `*_qt` play entry point (`label_qt::
/// play_track`): the queue is built synchronously off the feed, the play is
/// spawned. Blacklisted rows are dropped and the start index remapped inside
/// `playback_qt::set_queue_stamped`, so nothing to do here.
/// spawned. Blacklisted rows AND rows Qobuz pulled (contract §5.3 D5) are
/// dropped inside `playback_qt::set_queue_stamped`, which also remaps the start
/// index and hands back the id it actually anchored on — so nothing to do here
/// beyond passing the list through `play_track_list`.
/// Library header "Play all tracks" / "Shuffle all tracks" — the visible set
/// as the queue, from the top (`FavoritesActions.play_all_tracks` /
/// `shuffle_tracks`).
+22 -1
View File
@@ -183,6 +183,15 @@ pub fn local_queue_track(t: &LocalTrack) -> QueueTrack {
t.album_group_key.clone()
}),
artist_id: None,
// GENUINELY always true, and this is not a stub (contract §5.3 F5).
// This row is a FILE ON DISK — a local scan, a Plex item, or a
// `qobuz_download` — and Qobuz's streaming rights do not reach it.
// `qbz-core` takes the offline tier BEFORE it asks any availability
// question (core.rs:716), which is exactly why a track Qobuz PULLED
// still plays from here. Marking these `false` would delete the user's
// own downloads from the queue — a worse bug than the one D5 fixes, and
// it would bite hardest in offline mode, where this is the only copy
// that exists.
streamable: true,
source: Some(src.to_string()),
parental_warning: false,
@@ -494,7 +503,19 @@ pub(crate) async fn play_rows(
// Through the SHARED seam (not `core().set_queue`) so the origin is derived
// from the queue: one album -> ("album", group_key), a mixed folder queue ->
// nothing, and the per-track album fallback lands it on the same view.
crate::playback_qt::set_queue_stamped(runtime, queue, Some(start), None).await;
// Local rows are never dropped by the seam — `local_queue_track` sets
// `streamable: true` (a file on disk is outside Qobuz's rights) and
// `is_track_blacklisted` fails open for any source that is not "qobuz" — so
// the anchor always comes back and always names `first`. Answered anyway
// rather than discarded with a `let _`: the day a Qobuz row reaches this
// path, this returns instead of trying to play a track the core dropped.
if crate::playback_qt::set_queue_stamped(runtime, queue, Some(start), None)
.await
.is_none()
{
log::warn!("[qbz-qt] local play: the queue was filtered to nothing");
return;
}
crate::playback_qt::publish_queue(runtime).await;
play_audible(runtime, &first).await;
crate::playback_qt::refresh_now_playing(runtime).await;
+44 -6
View File
@@ -341,9 +341,18 @@ pub(crate) async fn play_all_tracks(
));
return;
}
let first_id = tracks[0].id;
// Context `None` — `derive_context` infers album-or-artist (T16).
crate::playback_qt::set_queue_stamped(runtime, tracks, Some(0), None).await;
// F1: the anchor comes back from the seam, never from `tracks[0]`.
let Some(anchor) =
crate::playback_qt::set_queue_stamped(runtime, tracks, Some(0), None).await
else {
// The seam already toasted the count and left the previous queue alone.
// `set_queue_source_collection` and `touch_play` below are bookkeeping
// for a play that did not happen, so they are skipped with it.
log::info!("[qbz-qt] myqbz_play: every track of {collection_id} was filtered");
return;
};
let first_id = anchor.track_id;
runtime
.runtime()
.set_queue_source_collection(Some(collection_id.to_string()))
@@ -492,6 +501,13 @@ pub(crate) fn item_action(source_item_id: String, action: String) {
// are idempotent (filter drops nothing twice; stamping skips
// pre-stamped tracks).
let tracks = crate::playback_qt::stamped(tracks, None);
// The seam can empty a batch that was non-empty on entry. Guarding here
// covers all four arms below — including the Play arm's `tracks[0]`,
// which is a LIVE index panic today for a batch the blacklist empties.
if tracks.is_empty() {
log::info!("[qbz-qt] myqbz_play: the item's tracks were all filtered");
return;
}
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
// queue — mode-matched to the row's LOCAL placement. Early-returns
@@ -515,8 +531,14 @@ pub(crate) fn item_action(source_item_id: String, action: String) {
RowMode::Play => {
// Replace-play this ONE item — no queue-source stamp, no
// touch_play (per-row action, not "play the whole collection").
let first_id = tracks[0].id;
crate::playback_qt::set_queue_stamped(&runtime, tracks, Some(0), None).await;
// F1: the anchor comes back from the seam.
let Some(anchor) =
crate::playback_qt::set_queue_stamped(&runtime, tracks, Some(0), None).await
else {
log::info!("[qbz-qt] myqbz_play: the item's tracks were all filtered");
return;
};
let first_id = anchor.track_id;
// QConnect CONTROLLER mode (§7): route the play to the peer
// (after the funnel, before the local audible step).
if crate::playback_qt::route_play_to_peer(&runtime, first_id).await {
@@ -627,6 +649,13 @@ fn bulk_enqueue_inner(items: Vec<MixtapeCollectionItem>, mode: BulkEnqueueMode)
return;
}
let tracks = crate::playback_qt::stamped(tracks, None);
// The emptiness check above ran BEFORE the seam, so re-ask after it:
// routing an empty batch to a peer and calling `add_tracks(vec![])`
// would be a no-op dressed up as an action. The seam already said why.
if tracks.is_empty() {
log::info!("[qbz-qt] myqbz_play: every selected track was filtered");
return;
}
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
// queue — mode-matched to the bulk bar's LOCAL placement (Next /
@@ -737,8 +766,17 @@ pub(crate) fn play_inline_track(
match mode {
InlineTrackMode::Play => {
let first_id = track.id;
crate::playback_qt::set_queue_stamped(&runtime, vec![track], Some(0), None).await;
// F1: the anchor comes back from the seam. No extra toast here —
// the seam already raised the skipped-tracks one, and this
// module's toast slot is single-shot (a second would replace it).
let Some(anchor) =
crate::playback_qt::set_queue_stamped(&runtime, vec![track], Some(0), None)
.await
else {
log::info!("[qbz-qt] myqbz_play: inline track {track_id} is not playable");
return;
};
let first_id = anchor.track_id;
// QConnect CONTROLLER mode (§7): route the play to the peer
// (after the funnel, before the local audible step).
if crate::playback_qt::route_play_to_peer(&runtime, first_id).await {
+16 -6
View File
@@ -122,13 +122,23 @@ fn cached_ids() -> &'static StdMutex<HashSet<u64>> {
CACHED_IDS.get_or_init(|| StdMutex::new(HashSet::new()))
}
/// True if `track_id` (string form, as the rows carry it) has a ready offline
/// copy. Used to seed each track row's cache status at document build time.
/// True if `track_id` has a ready offline copy. Used to seed each track row's
/// cache status at document build time — the rows carry the id as a STRING, so
/// this is the string-keyed front door onto [`is_cached_id`].
pub fn is_cached(track_id: &str) -> bool {
let Ok(id) = track_id.parse::<u64>() else {
return false;
};
cached_ids().lock().map(|s| s.contains(&id)).unwrap_or(false)
track_id.parse::<u64>().map(is_cached_id).unwrap_or(false)
}
/// [`is_cached`] for an id the caller already holds as a number — the QUEUE
/// path. `QueueTrack.id` is a `u64`, and the unavailability drop predicate
/// (`playback_qt::queue_track_unavailable`) asks this question for EVERY row of
/// every queue build; routing it through `is_cached` would format a string per
/// row only to parse it straight back. Same set, same lock, no allocation.
pub fn is_cached_id(track_id: u64) -> bool {
cached_ids()
.lock()
.map(|s| s.contains(&track_id))
.unwrap_or(false)
}
/// Drop the whole session set — after a cache purge, where marking each id
+380 -66
View File
@@ -29,9 +29,13 @@
//! - Gapless prefetch: the engine raises `gapless_ready` ~10s out and this
//! module now answers it with `play_next` (both the streaming and the
//! local/DSD arms), so transitions are sample-accurate again.
//! - Blacklist filtering of the QUEUE (#1) — `filter_blacklisted_queue` +
//! - Blacklist filtering of the QUEUE (#1) + the unavailability drop (contract
//! 2026-08-17 §5.3 D5) — `filter_unplayable_queue` behind
//! `set_queue_stamped` / `stamped`, the two seams every play/enqueue path
//! already went through.
//! already went through. Both seams DROP; `set_queue_stamped` additionally
//! returns the surviving anchor, because a caller that computes its own
//! `first_id` off the pre-filter list plays a track the core does not hold
//! (F1 — silent, and reachable today through the blacklist alone).
//! - The bounded skip-unavailable walk (#2) — `auto_skip_unavailable`.
//! - Volume persistence (#3) — persisted on drag-end, restored at shell entry.
//! - The stream-failure toast with the Flatpak/Snap ALSA rewrite (#7) —
@@ -311,44 +315,146 @@ fn queue_track_blacklisted(track: &QueueTrack) -> bool {
)
}
/// Drop blacklisted entries from a freshly-built queue AND remap the start
/// index onto the surviving track (playback.rs:2634-2641 `filter_blacklisted_queue`).
/// THE unavailable drop predicate for a built queue row (contract §5.1 with the
/// §5.3 / F5 exemption). Two halves, and the second is what keeps this feature
/// from becoming a worse bug than the one it fixes:
///
/// The Slint applies this at ~18 separate builder sites. This port funnels every
/// play path through `set_queue_stamped`, so it needs exactly one — which is
/// also why its absence was total rather than partial: the store was open and
/// the manager worked, but nothing between the two ever asked.
/// 1. `!track.streamable` — the API said `streamable: false` for this track.
/// `QueueTrack.streamable` defaults **true** (`qbz-models/src/playback.rs`
/// `default_streamable`), so a builder that never learned the answer fails
/// OPEN and the track plays. That default is deliberate, not laziness:
/// refusing to play because an endpoint was terse is strictly worse than
/// playing something that turns out to be dead, and the reactive
/// `auto_skip_unavailable` walk still catches the second case.
/// 2. `&& !is_cached_id(..)` — minus the tracks we already downloaded.
/// `qbz-core`'s play resolution takes the offline tier BEFORE any network
/// call and before any availability question, and every Qt play hands the
/// handle down (`play_resolved_offline_aware`), so a track Qobuz pulled from
/// the catalogue plays perfectly from disk TODAY. Dropping it would take
/// away a song the user deliberately downloaded, and it would bite hardest
/// in offline mode, where that copy is the only one that exists. The Slint
/// reference gates on exactly this (`crates/qbz/src/playback.rs:426`).
///
/// LOCAL / Plex rows can never be dropped here, and that is not luck: their
/// builder (`local_playback::local_queue_track`) sets `streamable: true`
/// because a file on disk is outside Qobuz's streaming rights entirely.
fn queue_track_unavailable(track: &QueueTrack) -> bool {
// Clause 2 (D3): a track the player already found terminally unavailable
// THIS RUN counts as dead even while the API keeps advertising it as
// streamable — which is the owner's test case exactly, since `/album/get`
// is honest but plenty of rows only fail at the stream-url step. The cache
// exemption applies to both clauses: downloaded bytes play regardless.
let dead = !track.streamable || crate::track_replace_qt::session_unavailable::contains(track.id);
dead && !crate::offline_qt::is_cached_id(track.id)
}
/// Why rows were dropped, counted per reason. The two are NOT interchangeable
/// and must not collapse into one number: a blacklist drop is what the user
/// asked for and stays SILENT, while an unavailability drop is news they never
/// asked for and owes them a toast (D4 — "el usuario siempre debe saber"). One
/// counter would force one policy on both.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct QueueDrops {
/// Dropped by the artist blacklist. Silent by design.
pub blacklisted: usize,
/// Dropped because Qobuz pulled the track AND we hold no offline copy.
pub unavailable: usize,
}
/// The ONE toast a queue build owes when it silently shortened itself
/// (§5.3). Once per build, never once per track — an album with six pulled
/// tracks must not raise six toasts.
///
/// PLURAL msgid pair, per acceptance §8-6: a singular-only string reads wrong
/// in every locale with plural rules, Russian worst of all. The pair follows
/// the catalog's existing convention (`"{} duplicate skipped"` /
/// `"{} duplicates skipped"`). The i18n lane lands it in all EIGHT locales;
/// nothing else in this module is a new msgid.
fn toast_unavailable_dropped(count: usize) {
if count == 0 {
return;
}
crate::toast_qt::info(qbz_i18n::tf(
"{} track skipped — no longer available",
"{} tracks skipped — no longer available",
count as i64,
&[&count.to_string()],
));
}
/// Drop the rows that must never reach the queue AND remap the start index onto
/// the surviving track (playback.rs:2634-2641 `filter_blacklisted_queue`, plus
/// the unavailability drop of contract §5.3 D5).
///
/// The Slint applies the blacklist at ~18 separate builder sites. This port
/// funnels every play path through `set_queue_stamped`, so it needs exactly one
/// — which is also why its absence was total rather than partial: the store was
/// open and the manager worked, but nothing between the two ever asked.
///
/// The start remap is the part the Slint gets for free by filtering before it
/// computes the index. Here the caller has already picked an index into the
/// UNFILTERED list, so dropping rows ahead of it would silently start the wrong
/// track. The clicked row surviving is the common case; when the clicked row is
/// itself blocked, the walk lands on the next survivor, which is what the user
/// asking for "play from here" means once "here" is hidden.
fn filter_blacklisted_queue(
/// itself dropped, the walk lands on the next survivor, which is what the user
/// asking for "play from here" means once "here" is gone.
///
/// Renamed from `filter_blacklisted_queue`: it now drops for two reasons and
/// the old name named only one of them.
fn filter_unplayable_queue(
tracks: Vec<QueueTrack>,
start: Option<usize>,
) -> (Vec<QueueTrack>, Option<usize>) {
let (kept, start) = filter_queue_with(tracks, start, queue_track_blacklisted);
(kept, start)
) -> (Vec<QueueTrack>, Option<usize>, QueueDrops) {
let (kept, start, dropped) = filter_queue_with(
tracks,
start,
queue_track_blacklisted,
queue_track_unavailable,
);
if dropped.blacklisted > 0 {
log::info!(
"[qbz-qt] blacklist: dropped {} track(s) from the queue",
dropped.blacklisted
);
}
if dropped.unavailable > 0 {
log::info!(
"[qbz-qt] unavailable: dropped {} pulled track(s) from the queue",
dropped.unavailable
);
}
(kept, start, dropped)
}
/// The pure half, split out so the index remap is unit-testable without a bound
/// session (the blacklist store needs one). `drop` decides; everything else is
/// arithmetic.
/// session (the blacklist store needs one). The two predicates decide;
/// everything else is arithmetic, and that arithmetic is unchanged.
///
/// TWO predicates, not one OR'd closure, because the caller has to know WHICH
/// one claimed each row (see [`QueueDrops`]). `blacklisted` is tested FIRST and
/// wins ties: a pulled track by an artist the user blocked is a row they
/// already chose not to see, and toasting "1 track skipped" for it would
/// announce exactly the content the blacklist exists to hide.
///
/// The log line that used to live here moved to `filter_unplayable_queue`: it
/// said "blacklist", which a second reason turns into a lie.
fn filter_queue_with<T>(
tracks: Vec<T>,
start: Option<usize>,
drop: impl Fn(&T) -> bool,
) -> (Vec<T>, Option<usize>) {
blacklisted: impl Fn(&T) -> bool,
unavailable: impl Fn(&T) -> bool,
) -> (Vec<T>, Option<usize>, QueueDrops) {
let clicked = start.unwrap_or(0);
let mut kept = Vec::with_capacity(tracks.len());
// Surviving index for the clicked row, or for the first survivor after it.
let mut new_start: Option<usize> = None;
let mut dropped = 0usize;
let mut dropped = QueueDrops::default();
for (i, track) in tracks.into_iter().enumerate() {
if drop(&track) {
dropped += 1;
if blacklisted(&track) {
dropped.blacklisted += 1;
continue;
}
if unavailable(&track) {
dropped.unavailable += 1;
continue;
}
if new_start.is_none() && i >= clicked {
@@ -356,32 +462,81 @@ fn filter_queue_with<T>(
}
kept.push(track);
}
if dropped > 0 {
log::info!("[qbz-qt] blacklist: dropped {dropped} track(s) from the queue");
}
// Every survivor sits BEFORE the clicked row (the whole tail was blocked):
// Every survivor sits BEFORE the clicked row (the whole tail was dropped):
// start at the last one rather than past the end.
let start = new_start
.or_else(|| kept.len().checked_sub(1))
.filter(|_| !kept.is_empty());
(kept, start)
(kept, start, dropped)
}
/// What the queue seam actually published: the start index AFTER the filter,
/// and the id of the track sitting at it.
///
/// F1, and it is a blocker (contract §5.3). Every caller used to read `first_id`
/// from the UNFILTERED list, hand the list to this seam, and then play the id
/// the seam had just dropped. `play_track_resolved` returns `Err`, the caller
/// `log::error!`s it, and the USER GETS SILENCE with no toast. That is not a
/// hypothetical introduced by the availability work — it is reachable today
/// through the artist blacklist: click a blocked artist's album and nothing
/// plays. Returning the post-filter anchor is what makes "the id the caller
/// plays" and "the queue the core holds" one list again.
///
/// `None` means NOTHING survived. The caller must not play and must not report
/// an error: the seam has already told the user why (the skipped toast) and has
/// already left the previous queue untouched rather than publishing an empty
/// one over it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct QueueStart {
/// Index into the queue the core now holds.
pub index: usize,
/// The track at `index` — the id to hand to `play_resolved_offline_aware`
/// and to `route_play_to_peer`.
pub track_id: u64,
}
/// The ONLY `core().set_queue` call in this module — every play path goes
/// through it, so the origin can never be dropped on the floor, and (since
/// PARITY-DEBT #1) neither can the blacklist.
/// through it, so the origin can never be dropped on the floor, and neither can
/// the blacklist (PARITY-DEBT #1) nor a track Qobuz pulled (contract §5.3 D5).
///
/// Returns the surviving anchor; see [`QueueStart`] for why no caller may
/// compute it itself any more.
pub(crate) async fn set_queue_stamped(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
start: Option<usize>,
context: Option<PlayContext>,
) {
) -> Option<QueueStart> {
let asked = tracks.len();
// Filter BEFORE stamping: a dropped row needs no origin, and the start
// index has to be remapped against the list the core actually receives.
let (mut tracks, start) = filter_blacklisted_queue(tracks, start);
let (mut tracks, start, dropped) = filter_unplayable_queue(tracks, start);
// Everything was filtered out of a non-empty build: do NOT publish an empty
// queue over whatever is playing (§5.3, last bullet). Say why and leave the
// core alone — wiping a working queue because the new one was all-dead is a
// second failure stacked on the first.
if tracks.is_empty() && asked > 0 {
log::info!(
"[qbz-qt] set_queue: all {asked} track(s) filtered ({} blacklisted, {} unavailable) \
— keeping the previous queue",
dropped.blacklisted,
dropped.unavailable
);
toast_unavailable_dropped(dropped.unavailable);
return None;
}
stamp_context(&mut tracks, context);
warn_dead_context(&tracks, "set_queue");
// Read the anchor off the list the CORE receives, not off the caller's —
// that difference IS F1. Computed before the move into `set_queue`.
let anchor = start.and_then(|index| {
tracks.get(index).map(|track| QueueStart {
index,
track_id: track.id,
})
});
runtime.core().set_queue(tracks, start).await;
toast_unavailable_dropped(dropped.unavailable);
// QConnect (contract §6.3, Slint playback.rs:3697-3703): when WE are the
// active renderer, push the new queue to the session immediately so a
// controller's UI reflects the freshly-built queue — covers infinite-play
@@ -390,6 +545,7 @@ pub(crate) async fn set_queue_stamped(
if let Some(svc) = crate::qconnect_qt::service() {
svc.sync_local_queue_if_changed().await;
}
anchor
}
/// Same guarantee for the ADD paths (play-next / add-to-queue): appended tracks
@@ -400,20 +556,44 @@ pub(crate) async fn set_queue_stamped(
/// other modules and must be able to reach the SAME stamping seam — a private
/// helper is what forced them to hand-roll their context in the first place.
pub(crate) fn stamped(tracks: Vec<QueueTrack>, context: Option<PlayContext>) -> Vec<QueueTrack> {
// Same blacklist drop as `set_queue_stamped` — play-next / add-to-queue /
// play-later must not be the back door that puts a blocked artist in the
// queue. No start index to remap here: these APPEND.
let before = tracks.len();
// The SECOND seam (F2). `set_queue_stamped` guards the REPLACE paths; this
// guards play-next / play-later / add-to-queue, and its own reason for
// existing is that enqueue "must not be the back door" for the blacklist.
// D5 makes the same demand of a pulled track: a row the user cannot click
// in the list must not be reachable by dragging it into the queue either.
// No start index to remap here — these APPEND.
let mut dropped = QueueDrops::default();
let mut tracks: Vec<QueueTrack> = tracks
.into_iter()
.filter(|t| !queue_track_blacklisted(t))
.filter(|t| {
if queue_track_blacklisted(t) {
dropped.blacklisted += 1;
return false;
}
if queue_track_unavailable(t) {
dropped.unavailable += 1;
return false;
}
true
})
.collect();
if tracks.len() < before {
if dropped.blacklisted > 0 {
log::info!(
"[qbz-qt] blacklist: dropped {} track(s) from an enqueue",
before - tracks.len()
dropped.blacklisted
);
}
if dropped.unavailable > 0 {
log::info!(
"[qbz-qt] unavailable: dropped {} pulled track(s) from an enqueue",
dropped.unavailable
);
}
// Idempotent with the seam below it: `myqbz_play_qt` stamps and THEN calls
// `set_queue_stamped`, whose second pass finds nothing left to drop and so
// raises no second toast. That module's existing "both passes are
// idempotent" comment stays true.
toast_unavailable_dropped(dropped.unavailable);
stamp_context(&mut tracks, context);
warn_dead_context(&tracks, "enqueue");
tracks
@@ -668,7 +848,9 @@ pub(crate) async fn fetch_album_queue(
is_local: false,
album_id: Some(album.id.clone()),
artist_id: track.performer.as_ref().map(|p| p.id),
streamable: track.streamable,
// §3.1: absence is resolved in ONE place. `is_streamable()` reads a
// missing key as AVAILABLE — only an explicit `false` marks a pull.
streamable: track.is_streamable(),
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning,
source_item_id_hint: Some(album.id.clone()),
@@ -765,8 +947,19 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
let top: Vec<QueueTrack> =
artist_top_queue_tracks(page.top_tracks.as_deref().unwrap_or(&[]), artist_id, &artist_name);
if !top.is_empty() {
let first_id = top[0].id;
set_queue_stamped(runtime, top, Some(0), PlayContext::artist(artist_id)).await;
// F1: the anchor comes back from the seam, never from `top` — the
// filter may have dropped `top[0]`.
let Some(anchor) =
set_queue_stamped(runtime, top, Some(0), PlayContext::artist(artist_id)).await
else {
// Every popular track was filtered. The seam already toasted the
// count; falling through to the discography below is not possible
// (`top` is consumed by the seam) and is not worth restructuring
// for a case that means the artist's whole top list is dead.
log::info!("[qbz-qt] play_artist {artist_id}: every popular track was filtered");
return Ok(());
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
// QConnect CONTROLLER mode (§7): a peer owns playback — route the play
// to it AFTER the funnel, never run the local audible step.
@@ -807,7 +1000,6 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
return Err(format!("artist {artist_id} studio discography produced no playable tracks"));
}
log::info!("[qbz-qt] artist-play {artist_id}: discography fallback, {} tracks", queue.len());
let first_id = queue[0].id;
// The discography queue arrives pre-stamped per ALBUM (fetch_album_queue);
// the artist origin is what the user launched, so it wins here — the
// explicit context overrides the per-album stamp for the whole queue.
@@ -815,7 +1007,15 @@ pub async fn play_artist(runtime: &Arc<AppRuntime<LoggingAdapter>>, artist_id: &
track.context_kind = None;
track.context_id = None;
}
set_queue_stamped(runtime, queue, Some(0), PlayContext::artist(artist_id)).await;
// F1: the anchor is read off the queue the core received.
let Some(anchor) =
set_queue_stamped(runtime, queue, Some(0), PlayContext::artist(artist_id)).await
else {
return Err(format!(
"artist {artist_id} discography resolved to no playable tracks"
));
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
// funnel, before the local audible step).
@@ -862,6 +1062,12 @@ pub async fn enqueue_album(
fetch_album_queue(runtime, album_id).await?,
PlayContext::album(album_id),
);
// Empty after the seam: nothing to route, nothing to insert. The seam has
// already toasted how many rows it dropped.
if tracks.is_empty() {
log::info!("[qbz-qt] enqueue_album {album_id}: every track was filtered");
return Ok(());
}
log::info!("[qbz-qt] enqueue_album {album_id} ({mode}): {} tracks", tracks.len());
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
// queue — early-returns when handled, so the local insert + sync tail
@@ -917,9 +1123,19 @@ pub async fn play_album_from(
log::info!("[qbz-qt] play_album: resolving {album_id} (start {start_index})");
let tracks = fetch_album_queue(runtime, album_id).await?;
let start = start_index.min(tracks.len() - 1);
let first_id = tracks[start].id;
let count = tracks.len();
set_queue_stamped(runtime, tracks, Some(start), PlayContext::album(album_id)).await;
// F1: the id to play is whatever SURVIVED at the remapped index. Reading
// `tracks[start].id` here and playing it after the seam is the defect the
// owner's acceptance test 7 catches — "play the album and skip the dead
// track" used to do nothing at all, silently, because the play call was
// handed an id the core no longer held.
let Some(anchor) =
set_queue_stamped(runtime, tracks, Some(start), PlayContext::album(album_id)).await
else {
log::info!("[qbz-qt] play_album {album_id}: every track was filtered, queue untouched");
return Ok(());
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
log::info!("[qbz-qt] play_album: queue set ({count} tracks), playing track {first_id}");
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
@@ -955,8 +1171,17 @@ pub async fn play_album_from_track(
}
let tracks = fetch_album_queue(runtime, album_id).await?;
let start = tracks.iter().position(|t| t.id == track_id).unwrap_or(0);
let first_id = tracks[start].id;
set_queue_stamped(runtime, tracks, Some(start), PlayContext::album(album_id)).await;
// F1: the anchor comes from the seam. Clicking the album's DEAD row lands
// on the next survivor rather than playing an id the core dropped.
let Some(anchor) =
set_queue_stamped(runtime, tracks, Some(start), PlayContext::album(album_id)).await
else {
log::info!(
"[qbz-qt] play_album_from_track {album_id}: every track was filtered, queue untouched"
);
return Ok(());
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
// funnel, before the local audible step).
@@ -1074,8 +1299,14 @@ pub async fn play_track_list_in(
(tracks, start)
};
let start = start.min(tracks.len() - 1);
let first_id = tracks[start].id;
set_queue_stamped(runtime, tracks, Some(start), context).await;
// F1: the anchor comes from the seam. `Err` and not `Ok` when nothing
// survives, deliberately: `try_infinite_refill` reads this Result to decide
// whether a radio actually started, and an `Ok` over an empty publish would
// make it report a chain that never played.
let Some(anchor) = set_queue_stamped(runtime, tracks, Some(start), context).await else {
return Err("every track in the list was filtered (blacklist / unavailable)".to_string());
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
// funnel, before the local audible step).
@@ -1101,8 +1332,10 @@ pub async fn play_track_list_in(
/// intended behaviour and chains: each radio's end re-seeds the next one, which
/// is what makes it infinite rather than one extra radio.
///
/// `play_track_list` runs the queue through `filter_blacklisted_queue`, so a
/// radio made entirely of blocked artists cannot come back as playback.
/// `play_track_list` runs the queue through `filter_unplayable_queue`, so a
/// radio made entirely of blocked artists — or of tracks Qobuz has pulled —
/// cannot come back as playback: the seam publishes nothing and
/// `play_track_list_in` returns `Err`, which this fn reports as "no chain".
async fn try_infinite_refill(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
seed_track_id: u64,
@@ -1171,6 +1404,13 @@ pub async fn enqueue_track_list_mode(
return Err("empty track list".to_string());
}
let tracks = stamped(tracks, None);
// The seam can empty a batch that was non-empty on entry (the check above
// ran BEFORE the filter). Stop here rather than routing an empty add to a
// peer and calling `add_tracks(vec![])`: the seam already said why.
if tracks.is_empty() {
log::info!("[qbz-qt] enqueue_track_list_mode: every track was filtered");
return Ok(());
}
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
// queue — early-returns when handled, so the local insert + sync tail
// below only run in local/renderer mode.
@@ -1373,7 +1613,11 @@ fn feed_queue_track(track_id: u64) -> Result<QueueTrack, String> {
Some(item.album_id.clone())
},
artist_id: item.artist_id.parse::<u64>().ok(),
streamable: true,
// D5: the feed row's own answer. Twin of
// `library_qt::feed_track_to_queue` over the SAME `FeedItem` — the two
// must not disagree about what a row is, which is the whole reason both
// read the same field instead of hardcoding a yes.
streamable: !item.not_streamable,
source: Some("qobuz".to_string()),
parental_warning: item.explicit,
source_item_id_hint: None,
@@ -1443,7 +1687,9 @@ async fn catalog_queue_track(
is_local: false,
album_id: album_key.clone(),
artist_id: track.performer.as_ref().map(|p| p.id),
streamable: track.streamable,
// §3.1: absence is resolved in ONE place. `is_streamable()` reads a
// missing key as AVAILABLE — only an explicit `false` marks a pull.
streamable: track.is_streamable(),
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning,
source_item_id_hint: album_key,
@@ -1484,17 +1730,24 @@ pub async fn play_single_track(
let qt = queue_track_for(runtime, track_id).await?;
// Bare single-track play: no container origin, so the derive falls to the
// track's own album — same landing spot as the Slint fallback.
set_queue_stamped(runtime, vec![qt], Some(0), None).await;
// F1 in its smallest form: when the seam drops this one row there is
// nothing to play, and the previous queue keeps going. `track_id` is the id
// the CALLER asked for; the anchor is the id the core actually holds.
let Some(anchor) = set_queue_stamped(runtime, vec![qt], Some(0), None).await else {
log::info!("[qbz-qt] play_single_track: {track_id} was filtered, queue untouched");
return Ok(());
};
let first_id = anchor.track_id;
publish_queue(runtime).await;
log::info!("[qbz-qt] play_single_track: playing {track_id}");
log::info!("[qbz-qt] play_single_track: playing {first_id}");
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
// funnel, before the local audible step).
if route_play_to_peer(runtime, track_id).await {
if route_play_to_peer(runtime, first_id).await {
return Ok(());
}
play_resolved_offline_aware(runtime, track_id, 0)
play_resolved_offline_aware(runtime, first_id, 0)
.await
.map_err(|e| format!("play_track {track_id} failed: {e}"))?;
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
Ok(())
}
@@ -1507,9 +1760,15 @@ pub async fn enqueue_single_track(
track_id: u64,
mode: &str,
) -> Result<(), String> {
let qt = stamped(vec![queue_track_for(runtime, track_id).await?], None)
.pop()
.expect("one track in, one track out");
// NOT `.expect("one track in, one track out")` any more: since the enqueue
// seam filters (F2 + D5), one row in can legitimately be ZERO rows out — a
// blocked artist, or a track Qobuz pulled that we hold no download for. The
// `expect` would have turned that into a PANIC on a menu click. The seam has
// already toasted the reason, so this is a quiet Ok.
let Some(qt) = stamped(vec![queue_track_for(runtime, track_id).await?], None).pop() else {
log::info!("[qbz-qt] enqueue_single_track: {track_id} was filtered out");
return Ok(());
};
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
// queue — early-returns when handled, so the local insert + sync tail
// below only run in local/renderer mode.
@@ -1735,7 +1994,7 @@ async fn play_queue_track(
// how a failed advance left a stale card over a silent player.
refresh_now_playing(runtime).await;
publish_queue(runtime).await;
auto_skip_unavailable(runtime, e).await;
auto_skip_unavailable(runtime, e, Some(track_id)).await;
return;
}
// A track actually started: the consecutive-skip budget is spent per RUN of
@@ -1784,6 +2043,7 @@ const MAX_UNAVAILABLE_SKIPS: u32 = 5;
fn auto_skip_unavailable<'a>(
runtime: &'a Arc<AppRuntime<LoggingAdapter>>,
error: String,
failed_track_id: Option<u64>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
// A 403 / back-off is not this track's fault: stop cleanly and say so
@@ -1798,6 +2058,14 @@ fn auto_skip_unavailable<'a>(
if !is_terminal_unavailable(&error) {
return;
}
// §5.1 clause 2 (D3): remember it for the rest of the run, so the queue
// filter stops offering it to the player. The API flag covers the
// tracks Qobuz already admits are gone; THIS covers the ones it still
// advertises as streamable and then refuses to serve — which is exactly
// what the owner's test track does at the stream-url step.
if let Some(id) = failed_track_id {
crate::track_replace_qt::session_unavailable::mark(id);
}
crate::toast_qt::warning(qbz_i18n::t("This track is no longer available"));
let skips = UNAVAILABLE_SKIPS.fetch_add(1, Ordering::SeqCst) + 1;
@@ -3088,7 +3356,8 @@ mod tests {
/// the one the user clicked — silently, and only when something was blocked.
#[test]
fn start_index_follows_the_clicked_track() {
let (kept, start) = filter_queue_with(vec![0, 1, 2, 3, 4], Some(2), |n| n % 2 == 1);
let (kept, start, _) =
filter_queue_with(vec![0, 1, 2, 3, 4], Some(2), |n| n % 2 == 1, |_| false);
assert_eq!(kept, vec![0, 2, 4]);
assert_eq!(start, Some(1)); // the 2 moved from index 2 to index 1
}
@@ -3096,7 +3365,8 @@ mod tests {
/// Clicking a row that is itself blocked lands on the next survivor.
#[test]
fn a_blocked_clicked_row_falls_forward() {
let (kept, start) = filter_queue_with(vec![0, 1, 2, 3], Some(1), |n| n % 2 == 1);
let (kept, start, _) =
filter_queue_with(vec![0, 1, 2, 3], Some(1), |n| n % 2 == 1, |_| false);
assert_eq!(kept, vec![0, 2]);
assert_eq!(start, Some(1)); // the 1 was dropped, so start on the 2
}
@@ -3105,7 +3375,8 @@ mod tests {
/// (an out-of-range index is what makes the core start nothing at all).
#[test]
fn a_fully_blocked_tail_clamps_to_the_last_survivor() {
let (kept, start) = filter_queue_with(vec![0, 1, 3, 5], Some(2), |n| n % 2 == 1);
let (kept, start, _) =
filter_queue_with(vec![0, 1, 3, 5], Some(2), |n| n % 2 == 1, |_| false);
assert_eq!(kept, vec![0]);
assert_eq!(start, Some(0));
}
@@ -3113,7 +3384,8 @@ mod tests {
/// Everything blocked: an empty queue carries NO start index.
#[test]
fn everything_blocked_yields_no_start() {
let (kept, start) = filter_queue_with(vec![1, 3, 5], Some(1), |n| n % 2 == 1);
let (kept, start, _) =
filter_queue_with(vec![1, 3, 5], Some(1), |n| n % 2 == 1, |_| false);
assert!(kept.is_empty());
assert_eq!(start, None);
}
@@ -3122,8 +3394,50 @@ mod tests {
/// user without a blacklist takes.
#[test]
fn nothing_blocked_is_identity() {
let (kept, start) = filter_queue_with(vec![0, 2, 4], Some(2), |_| false);
let (kept, start, _) = filter_queue_with(vec![0, 2, 4], Some(2), |_| false, |_| false);
assert_eq!(kept, vec![0, 2, 4]);
assert_eq!(start, Some(2));
}
/// Acceptance §8-5: a build containing dead tracks drops them AND re-maps
/// the start index to the track the user actually clicked. Same arithmetic
/// as the blacklist, different predicate — which is why the helper took a
/// second closure instead of a rewrite.
#[test]
fn unavailable_rows_drop_and_remap_the_start_index() {
// Rows 1 and 3 are "pulled"; the user clicked row 4.
let (kept, start, dropped) = filter_queue_with(
vec![0, 1, 2, 3, 4],
Some(4),
|_| false,
|n| *n == 1 || *n == 3,
);
assert_eq!(kept, vec![0, 2, 4]);
assert_eq!(start, Some(2)); // the 4 moved from index 4 to index 2
assert_eq!(dropped.unavailable, 2);
assert_eq!(dropped.blacklisted, 0);
}
/// The two reasons are counted apart because only ONE of them speaks: the
/// blacklist drop is what the user asked for, the unavailability drop is
/// news. A single counter would force one policy on both.
#[test]
fn the_two_drop_reasons_are_counted_separately() {
let (kept, _, dropped) =
filter_queue_with(vec![0, 1, 2, 3], Some(0), |n| *n == 1, |n| *n == 2);
assert_eq!(kept, vec![0, 3]);
assert_eq!(dropped.blacklisted, 1);
assert_eq!(dropped.unavailable, 1);
}
/// A row that is BOTH blocked and pulled counts as blacklisted, so no toast
/// is raised for it: "1 track skipped" would tell the user about exactly the
/// content the blacklist exists to keep off their screen.
#[test]
fn blacklist_wins_the_tie_and_stays_silent() {
let (kept, _, dropped) = filter_queue_with(vec![0, 1], Some(0), |n| *n == 1, |n| *n == 1);
assert_eq!(kept, vec![0]);
assert_eq!(dropped.blacklisted, 1);
assert_eq!(dropped.unavailable, 0);
}
}
+103 -8
View File
@@ -31,7 +31,10 @@
//! id-confusion class `playlist_picker_qt.rs`'s header exists to prevent.
//! - `unavailable` rows carry a ref that cannot resolve at all; they render
//! honestly and every "copy this track somewhere" affordance must be absent
//! on them.
//! on them. `notStreamable` is a DIFFERENT thing and lives beside it: Qobuz
//! pulled the recording. That one can heal, it is the only one the
//! replacement search can act on, and a row carrying it plus
//! `cacheStatus == 3` is not dead at all — it plays from the download.
//!
//! POC-NOTEs:
//! - The custom drag order persists to a per-user `playlist_orders.json`
@@ -118,6 +121,45 @@ pub struct PlaylistTrackRow {
/// is broken rather than just that something is.
#[serde(default, rename = "unavailableRef", skip_serializing_if = "String::is_empty")]
pub unavailable_ref: String,
/// Qobuz PULLED this track: the catalog reports `streamable: false` for it
/// (contract §5.1). DISTINCT from [`Self::unavailable`] above, and the two
/// must never be merged (§2): that one is a broken LOCAL ref — a Plex key
/// the cache does not know, a stored id outside the catalog range — which
/// can never heal, has no replacement, and says nothing about the Qobuz
/// catalogue. This one names a recording Qobuz removed: it CAN come back
/// (rights are restored), and it is the only one of the two the replacement
/// search can act on. A row can carry either, and their treatments differ.
///
/// Absence from the API is NOT this. `qbz_models::Track::is_streamable()`
/// reads a missing key as AVAILABLE (§3.1), so an endpoint that stays quiet
/// leaves this `false` and the row renders normally — greying out a whole
/// view because one endpoint was terse is the failure that would sink the
/// feature.
#[serde(
default,
rename = "qobuzUnavailable",
skip_serializing_if = "std::ops::Not::not"
)]
pub not_streamable: bool,
/// The recording identifier, carried so the replacement search's ISRC
/// short-circuit can fire (`qbz-playlist-import/src/match_qobuz.rs:156-159`
/// scores an ISRC hit 1.0). That is the owner's "a veces cambia el ID del
/// álbum" case: same recording, new catalog id, identical ISRC — an exact
/// relink needing no human judgement. Without it the feature degrades to
/// title/artist scoring and loses the one CERTAIN match. The live capture
/// confirms a pulled track keeps it
/// (`album-get-unavailable-track-captured-2026-08-17.json`: `SEWCE0900201`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub isrc: String,
/// Offline-cache status at build time (0 none / 3 ready) — the twin of
/// `album_qt::TrackRow::cache_status`. Playlist rows never carried it, so
/// `TrackRow.qml:154` read `undefined` and fell back to 0 on every playlist
/// row. That was cosmetic until F5: a PULLED track that is already
/// DOWNLOADED still plays from disk and must render as "no longer on Qobuz —
/// playing your downloaded copy", not as dead, and the row cannot tell the
/// two apart without this.
#[serde(default, rename = "cacheStatus")]
pub cache_status: i32,
}
#[derive(Clone, Default, Serialize)]
@@ -496,7 +538,18 @@ pub(crate) fn map_track(track: &Track) -> PlaylistTrackRow {
// click sent `favorite/delete`, REMOVING it from the library.
is_favorite: crate::fav_cache_qt::contains_track(track.id),
id: track.id.to_string(),
playlist_track_id: track.id,
// The MEMBERSHIP id, which is NOT the catalog id. This used to be
// `track.id`, and the two are different numbers for the same row: the
// catalog id names the recording, `playlist_track_id` names THIS row's
// slot in THIS playlist. Qobuz's `playlist/deleteTracks` and
// `playlist/updateTracksPosition` both take the membership id, so the
// old value made "remove from playlist" address a row that does not
// exist and made the replacement flow's reposition silently never fire
// (it looks the dead row up BY this id and found nothing).
//
// Falls back to the catalog id only when the endpoint omits it, which
// is what the previous code assumed unconditionally.
playlist_track_id: track.playlist_track_id.unwrap_or(track.id),
title,
artist,
artist_id,
@@ -519,6 +572,20 @@ pub(crate) fn map_track(track: &Track) -> PlaylistTrackRow {
art_url: album
.and_then(|a| a.image.best().cloned())
.unwrap_or_default(),
// §5.1: the API's own answer, resolved through the ONE helper that is
// allowed to interpret absence (§3.1). Playlists are the surface the
// owner says pulled tracks show up on MOST, which is why this row model
// is the first one plumbed.
not_streamable: !track.is_streamable(),
// Carried for §6's ISRC short-circuit; see the field's doc comment.
isrc: track.isrc.clone().unwrap_or_default(),
// F5: a pulled track we already downloaded is NOT dead. The same O(1)
// session-set read `album_qt` / `artist_qt` / `label_qt` rows use.
cache_status: if crate::offline_qt::is_cached(&track.id.to_string()) {
3
} else {
0
},
..Default::default()
}
}
@@ -575,7 +642,13 @@ fn row_to_queue(row: &PlaylistTrackRow) -> QueueTrack {
Some(row.album_id.clone())
},
artist_id: row.artist_id.parse::<u64>().ok(),
streamable: true,
// D5: the row's own answer, not a hardcoded yes. This function is the
// ONLY thing between a playlist row and the queue, so `true` here made
// both seams blind on the surface where the owner says pulled tracks
// turn up MOST. A LOCAL/Plex row never sets `not_streamable` (nothing on
// that path does), so a file on disk still queues — which is correct:
// Qobuz's streaming rights do not reach it.
streamable: !row.not_streamable,
source: Some(if is_local_row {
provenance.to_string()
} else {
@@ -1580,6 +1653,12 @@ pub async fn enqueue_playlist_by_id(
queue_for(runtime, playlist_id).await?,
crate::playback_qt::PlayContext::playlist(&playlist_id.to_string()),
);
// Empty after the seam: nothing to route, nothing to insert. The seam has
// already toasted how many rows it dropped.
if tracks.is_empty() {
log::info!("[qbz-qt] enqueue_playlist {playlist_id}: every track was filtered");
return Ok(());
}
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
// queue — early-returns when handled, so the local insert + sync tail
// below only run in local/renderer mode.
@@ -1715,8 +1794,18 @@ async fn play_queue_at(
return Err("playlist has no playable tracks".to_string());
}
let start = start.min(tracks.len() - 1);
let first_id = tracks[start].id;
crate::playback_qt::set_queue_stamped(runtime, tracks, Some(start), context).await;
// F1 (contract §5.3): the anchor is whatever survived the seam's filter at
// the remapped index. This is the playlist path, and playlists are where
// the owner says pulled tracks show up MOST — reading the id off the
// pre-filter list here is the difference between "skips the dead track" and
// "plays nothing, says nothing".
let Some(anchor) =
crate::playback_qt::set_queue_stamped(runtime, tracks, Some(start), context).await
else {
log::info!("[qbz-qt] playlist play: every track was filtered, queue untouched");
return Ok(());
};
let first_id = anchor.track_id;
crate::queue_qt::publish(runtime).await;
// QConnect CONTROLLER mode (§7): route the play to the peer (after the
// funnel, before the local audible step).
@@ -1775,9 +1864,15 @@ pub async fn enqueue_track(
let row = with_doc(|d| d.tracks.iter().find(|t| t.id == track_id).cloned())
.flatten()
.ok_or_else(|| format!("track {track_id} not in the open playlist"))?;
let qt = crate::playback_qt::stamped(vec![row_to_queue(&row)], open_context())
.pop()
.expect("one row in, one row out");
// NOT `.expect("one row in, one row out")` any more: the enqueue seam
// filters now (F2 + D5), so one row in can legitimately be ZERO rows out.
// The `expect` would have PANICKED on a right-click of a pulled row — the
// exact row the owner's smoke right-clicks to reach the replacement action.
let Some(qt) = crate::playback_qt::stamped(vec![row_to_queue(&row)], open_context()).pop()
else {
log::info!("[qbz-qt] playlist enqueue: track {track_id} was filtered out");
return Ok(());
};
// QConnect CONTROLLER mode (contract §7): route the add to the peer's
// queue — early-returns when handled, so the local insert + sync tail
// below only run in local/renderer mode.
+46 -4
View File
@@ -63,6 +63,24 @@ pub struct QueueRow {
/// gates on `item.is-ephemeral`.
#[serde(rename = "isEphemeral")]
pub is_ephemeral: bool,
/// The API said `streamable: false` for THIS track (#638-adjacent, the
/// unavailable-tracks port). Distinct from a broken local ref.
///
/// The queue panel is where this matters MOST, not least: both queue seams
/// filter pulled tracks out, so the only way one reaches this list is the
/// path that bypasses them BY DESIGN — a session restored from disk
/// (`playback_driver`'s `set_queue_with_order`). Without this field the
/// backstop's own shopfront renders a dead row as a perfectly normal one,
/// which is exactly the "learns by clicking" failure the feature exists to
/// remove.
#[serde(rename = "qobuzUnavailable")]
pub qobuz_unavailable: bool,
/// Offline-cache state, same encoding as `album_qt::TrackRow::cache_status`
/// (`3` = downloaded). Paired with the flag above: a pulled track we
/// already downloaded still plays from disk and must NOT get the dead
/// treatment.
#[serde(rename = "cacheStatus")]
pub cache_status: i32,
}
#[derive(Default, Serialize)]
@@ -203,6 +221,15 @@ fn row_from(track: &QueueTrack, favorites: &HashSet<u64>) -> QueueRow {
// reached the queue without one.
is_ephemeral: track.source.as_deref() == Some("ephemeral")
|| crate::local_ephemeral::is_ephemeral_id(track.id as i64),
// `QueueTrack.streamable` defaults TRUE (unlike catalog `Track`), so a
// row that never learned its availability reads as available — the
// permissive direction, deliberately.
qobuz_unavailable: !track.is_local && !track.streamable,
cache_status: if crate::offline_qt::is_cached(&track.id.to_string()) {
3
} else {
0
},
}
}
@@ -737,12 +764,19 @@ pub async fn insert_dragged_track(
track_id: u64,
to_slot: usize,
) -> Result<(), String> {
let qt = crate::playback_qt::stamped(
// NOT `.expect("one track in, one track out")` any more: the enqueue seam
// filters now (F2 + D5), so one row in can legitimately be ZERO rows out —
// and a DRAG of a pulled track into the queue would have PANICKED. The seam
// has already toasted the reason.
let Some(qt) = crate::playback_qt::stamped(
vec![crate::playback_qt::queue_track_for(runtime, track_id).await?],
None,
)
.pop()
.expect("one track in, one track out");
else {
log::info!("[qbz-qt] queue: dragged track {track_id} was filtered out");
return Ok(());
};
// QConnect CONTROLLER mode (contract §7): the peer owns the queue, so the
// insert is routed there — AT THE DROPPED POSITION. `CtrlSrvrQueueInsertTracks`
@@ -801,8 +835,16 @@ pub async fn play_history(runtime: &Arc<AppRuntime<LoggingAdapter>>, index: usiz
// Through the stamping seam: a history row already carries the origin it
// was played from, so it survives the replay; a legacy row with none falls
// back to its own album instead of publishing a dead layers glyph.
crate::playback_qt::set_queue_stamped(runtime, vec![track.clone()], Some(0), None).await;
crate::playback_qt::play_queue_track_public(runtime, track.id).await;
// F1: a history row can be a track Qobuz pulled since it played, or an
// artist blocked since. Replaying the id the seam just dropped is silence
// with no toast — the exact failure this return value exists to end.
let Some(anchor) =
crate::playback_qt::set_queue_stamped(runtime, vec![track], Some(0), None).await
else {
log::info!("[qbz-qt] queue: history row {index} is no longer playable");
return;
};
crate::playback_qt::play_queue_track_public(runtime, anchor.track_id).await;
}
/// Empty the queue — `queue.rs::clear` verbatim.
+13
View File
@@ -207,6 +207,17 @@ pub struct TrackRow {
pub art_path: String,
#[serde(rename = "isFavorite")]
pub is_favorite: bool,
/// Qobuz PULLED this track (`streamable: false` — contract §5.1). Search is
/// the one surface the Tauri reference never treated (§4.2) and D4 does not
/// exempt it: a dead row here is as clickable and as silent as anywhere
/// else. Search payloads DO carry the flat key (all 10 in
/// `qobuz-api/search-results-response.json`).
#[serde(
default,
rename = "qobuzUnavailable",
skip_serializing_if = "std::ops::Not::not"
)]
pub not_streamable: bool,
}
#[derive(Clone, Default, Serialize)]
@@ -389,6 +400,8 @@ fn map_track(track: &Track) -> TrackRow {
),
explicit: track.parental_warning,
art_url: artwork_url,
// §5.1 via §3.1's single interpreter of absence.
not_streamable: !track.is_streamable(),
..Default::default()
}
}
@@ -5195,3 +5195,62 @@ msgstr "Wähle einen Künstler aus, um seine Alben zu sehen"
msgid "{} / {} artists"
msgstr "{} / {} Künstler"
# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17
# §8-6). "Unavailable" and "This track is no longer available" already
# exist in this file and are REUSED by the render and the reactive skip toast
# — nothing below twins them.
#
# The skipped-tracks toast is a plural PAIR, per the convention already set by
# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build
# drops N rows at once, and a singular-only string reads wrong in every locale
# with plural rules. Russian carries all three forms.
#
# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled
# state (contract §5.3/F5): the track left the catalogue but the user owns a
# downloaded copy, so it still plays. It cannot fold into "This track is no
# longer available" — that sentence would be a lie on a row playing right now.
#
# Em dash is U+2014 with spaces on both sides, as everywhere else in this file.
# "Titel" is invariant in the plural, so both forms are identical (same as
# "Caching {} track offline…").
msgid "Find available version"
msgstr "Verfügbare Version suchen"
msgid "Replace unavailable track"
msgstr "Nicht verfügbaren Titel ersetzen"
msgid "No available version found"
msgstr "Keine verfügbare Version gefunden"
msgid "Track replaced"
msgstr "Titel ersetzt"
msgid "Could not replace the track"
msgstr "Titel konnte nicht ersetzt werden"
msgid "The replacement was added at the end of the playlist"
msgstr "Der Ersatz wurde am Ende der Playlist hinzugefügt"
msgid "{} track skipped — no longer available"
msgid_plural "{} tracks skipped — no longer available"
msgstr[0] "{} Titel übersprungen — nicht mehr verfügbar"
msgstr[1] "{} Titel übersprungen — nicht mehr verfügbar"
msgid "No longer on Qobuz — playing your downloaded copy"
msgstr "Nicht mehr bei Qobuz — deine heruntergeladene Kopie wird abgespielt"
msgid "Exact match"
msgstr "Exakte Übereinstimmung"
msgid "Low confidence"
msgstr "Geringe Sicherheit"
msgid "Replace"
msgstr "Ersetzen"
msgid "Replacing..."
msgstr "Wird ersetzt …"
msgid "The replacement takes the unavailable track's place in the playlist."
msgstr "Der Ersatz nimmt den Platz des nicht verfügbaren Titels in der Playlist ein."
@@ -808,3 +808,62 @@ msgstr "Tunnel Flow"
# --- Purchases (Slice 13, Qt port) ---
msgid "Download goodies"
msgstr "Download goodies"
# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17
# §8-6). "Unavailable" and "This track is no longer available" already
# exist in this file and are REUSED by the render and the reactive skip toast
# — nothing below twins them.
#
# The skipped-tracks toast is a plural PAIR, per the convention already set by
# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build
# drops N rows at once, and a singular-only string reads wrong in every locale
# with plural rules. Russian carries all three forms.
#
# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled
# state (contract §5.3/F5): the track left the catalogue but the user owns a
# downloaded copy, so it still plays. It cannot fold into "This track is no
# longer available" — that sentence would be a lie on a row playing right now.
#
# Em dash is U+2014 with spaces on both sides, as everywhere else in this file.
# en is the source-language stub: msgstr == msgid, as every batch that
# introduced a genuinely new msgid here has done.
msgid "Find available version"
msgstr "Find available version"
msgid "Replace unavailable track"
msgstr "Replace unavailable track"
msgid "No available version found"
msgstr "No available version found"
msgid "Track replaced"
msgstr "Track replaced"
msgid "Could not replace the track"
msgstr "Could not replace the track"
msgid "The replacement was added at the end of the playlist"
msgstr "The replacement was added at the end of the playlist"
msgid "{} track skipped — no longer available"
msgid_plural "{} tracks skipped — no longer available"
msgstr[0] "{} track skipped — no longer available"
msgstr[1] "{} tracks skipped — no longer available"
msgid "No longer on Qobuz — playing your downloaded copy"
msgstr "No longer on Qobuz — playing your downloaded copy"
msgid "Exact match"
msgstr "Exact match"
msgid "Low confidence"
msgstr "Low confidence"
msgid "Replace"
msgstr "Replace"
msgid "Replacing..."
msgstr "Replacing..."
msgid "The replacement takes the unavailable track's place in the playlist."
msgstr "The replacement takes the unavailable track's place in the playlist."
@@ -5203,3 +5203,60 @@ msgstr "Selecciona un artista para ver sus álbumes"
msgid "{} / {} artists"
msgstr "{} / {} artistas"
# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17
# §8-6). "Unavailable" and "This track is no longer available" already
# exist in this file and are REUSED by the render and the reactive skip toast
# — nothing below twins them.
#
# The skipped-tracks toast is a plural PAIR, per the convention already set by
# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build
# drops N rows at once, and a singular-only string reads wrong in every locale
# with plural rules. Russian carries all three forms.
#
# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled
# state (contract §5.3/F5): the track left the catalogue but the user owns a
# downloaded copy, so it still plays. It cannot fold into "This track is no
# longer available" — that sentence would be a lie on a row playing right now.
#
# Em dash is U+2014 with spaces on both sides, as everywhere else in this file.
msgid "Find available version"
msgstr "Buscar versión disponible"
msgid "Replace unavailable track"
msgstr "Reemplazar pista no disponible"
msgid "No available version found"
msgstr "No se encontró ninguna versión disponible"
msgid "Track replaced"
msgstr "Pista reemplazada"
msgid "Could not replace the track"
msgstr "No se pudo reemplazar la pista"
msgid "The replacement was added at the end of the playlist"
msgstr "El reemplazo se agregó al final de la lista de reproducción"
msgid "{} track skipped — no longer available"
msgid_plural "{} tracks skipped — no longer available"
msgstr[0] "Se omitió {} pista — ya no está disponible"
msgstr[1] "Se omitieron {} pistas — ya no están disponibles"
msgid "No longer on Qobuz — playing your downloaded copy"
msgstr "Ya no está en Qobuz — reproduciendo tu copia descargada"
msgid "Exact match"
msgstr "Coincidencia exacta"
msgid "Low confidence"
msgstr "Coincidencia dudosa"
msgid "Replace"
msgstr "Reemplazar"
msgid "Replacing..."
msgstr "Reemplazando..."
msgid "The replacement takes the unavailable track's place in the playlist."
msgstr "El reemplazo ocupa el lugar de la pista no disponible en la lista."
@@ -5195,3 +5195,60 @@ msgstr "Sélectionnez un artiste pour voir ses albums"
msgid "{} / {} artists"
msgstr "{} / {} artistes"
# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17
# §8-6). "Unavailable" and "This track is no longer available" already
# exist in this file and are REUSED by the render and the reactive skip toast
# — nothing below twins them.
#
# The skipped-tracks toast is a plural PAIR, per the convention already set by
# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build
# drops N rows at once, and a singular-only string reads wrong in every locale
# with plural rules. Russian carries all three forms.
#
# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled
# state (contract §5.3/F5): the track left the catalogue but the user owns a
# downloaded copy, so it still plays. It cannot fold into "This track is no
# longer available" — that sentence would be a lie on a row playing right now.
#
# Em dash is U+2014 with spaces on both sides, as everywhere else in this file.
msgid "Find available version"
msgstr "Trouver une version disponible"
msgid "Replace unavailable track"
msgstr "Remplacer le titre indisponible"
msgid "No available version found"
msgstr "Aucune version disponible trouvée"
msgid "Track replaced"
msgstr "Titre remplacé"
msgid "Could not replace the track"
msgstr "Échec du remplacement du titre"
msgid "The replacement was added at the end of the playlist"
msgstr "Le remplacement a été ajouté à la fin de la playlist"
msgid "{} track skipped — no longer available"
msgid_plural "{} tracks skipped — no longer available"
msgstr[0] "{} titre ignoré — plus disponible"
msgstr[1] "{} titres ignorés — plus disponibles"
msgid "No longer on Qobuz — playing your downloaded copy"
msgstr "Plus disponible sur Qobuz — lecture de votre copie téléchargée"
msgid "Exact match"
msgstr "Correspondance exacte"
msgid "Low confidence"
msgstr "Faible confiance"
msgid "Replace"
msgstr "Remplacer"
msgid "Replacing..."
msgstr "Remplacement…"
msgid "The replacement takes the unavailable track's place in the playlist."
msgstr "Le remplacement prend la place du titre indisponible dans la playlist."
@@ -5180,3 +5180,60 @@ msgstr "アーティストを選択してアルバムを表示します"
msgid "{} / {} artists"
msgstr "{} / {} 組のアーティスト"
# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17
# §8-6). "Unavailable" and "This track is no longer available" already
# exist in this file and are REUSED by the render and the reactive skip toast
# — nothing below twins them.
#
# The skipped-tracks toast is a plural PAIR, per the convention already set by
# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build
# drops N rows at once, and a singular-only string reads wrong in every locale
# with plural rules. Russian carries all three forms.
#
# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled
# state (contract §5.3/F5): the track left the catalogue but the user owns a
# downloaded copy, so it still plays. It cannot fold into "This track is no
# longer available" — that sentence would be a lie on a row playing right now.
#
# Em dash is U+2014 with spaces on both sides, as everywhere else in this file.
# nplurals=1 — one form, like every other plural entry in this file.
msgid "Find available version"
msgstr "利用可能なバージョンを探す"
msgid "Replace unavailable track"
msgstr "利用できないトラックを置き換え"
msgid "No available version found"
msgstr "利用可能なバージョンが見つかりませんでした"
msgid "Track replaced"
msgstr "トラックを置き換えました"
msgid "Could not replace the track"
msgstr "トラックの置き換えに失敗しました"
msgid "The replacement was added at the end of the playlist"
msgstr "代わりのトラックはプレイリストの最後に追加されました"
msgid "{} track skipped — no longer available"
msgid_plural "{} tracks skipped — no longer available"
msgstr[0] "{}トラックをスキップしました — 利用できなくなりました"
msgid "No longer on Qobuz — playing your downloaded copy"
msgstr "Qobuz にはもうありません — ダウンロード済みのコピーを再生します"
msgid "Exact match"
msgstr "完全一致"
msgid "Low confidence"
msgstr "確度が低い"
msgid "Replace"
msgstr "置き換える"
msgid "Replacing..."
msgstr "置き換え中…"
msgid "The replacement takes the unavailable track's place in the playlist."
msgstr "置き換えた曲は、再生できない曲の位置に入ります。"
@@ -5185,3 +5185,60 @@ msgstr "Selecteer een artiest om diens albums te zien"
msgid "{} / {} artists"
msgstr "{} / {} artiesten"
# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17
# §8-6). "Unavailable" and "This track is no longer available" already
# exist in this file and are REUSED by the render and the reactive skip toast
# — nothing below twins them.
#
# The skipped-tracks toast is a plural PAIR, per the convention already set by
# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build
# drops N rows at once, and a singular-only string reads wrong in every locale
# with plural rules. Russian carries all three forms.
#
# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled
# state (contract §5.3/F5): the track left the catalogue but the user owns a
# downloaded copy, so it still plays. It cannot fold into "This track is no
# longer available" — that sentence would be a lie on a row playing right now.
#
# Em dash is U+2014 with spaces on both sides, as everywhere else in this file.
msgid "Find available version"
msgstr "Beschikbare versie zoeken"
msgid "Replace unavailable track"
msgstr "Niet-beschikbaar nummer vervangen"
msgid "No available version found"
msgstr "Geen beschikbare versie gevonden"
msgid "Track replaced"
msgstr "Nummer vervangen"
msgid "Could not replace the track"
msgstr "Vervangen van nummer mislukt"
msgid "The replacement was added at the end of the playlist"
msgstr "De vervanging is aan het einde van de afspeellijst toegevoegd"
msgid "{} track skipped — no longer available"
msgid_plural "{} tracks skipped — no longer available"
msgstr[0] "{} nummer overgeslagen — niet meer beschikbaar"
msgstr[1] "{} nummers overgeslagen — niet meer beschikbaar"
msgid "No longer on Qobuz — playing your downloaded copy"
msgstr "Niet meer op Qobuz — je gedownloade kopie wordt afgespeeld"
msgid "Exact match"
msgstr "Exacte overeenkomst"
msgid "Low confidence"
msgstr "Lage zekerheid"
msgid "Replace"
msgstr "Vervangen"
msgid "Replacing..."
msgstr "Vervangen…"
msgid "The replacement takes the unavailable track's place in the playlist."
msgstr "De vervanging neemt de plaats van het niet-beschikbare nummer in de afspeellijst in."
@@ -5195,3 +5195,60 @@ msgstr "Selecione um artista para ver os seus álbuns"
msgid "{} / {} artists"
msgstr "{} / {} artistas"
# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17
# §8-6). "Unavailable" and "This track is no longer available" already
# exist in this file and are REUSED by the render and the reactive skip toast
# — nothing below twins them.
#
# The skipped-tracks toast is a plural PAIR, per the convention already set by
# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build
# drops N rows at once, and a singular-only string reads wrong in every locale
# with plural rules. Russian carries all three forms.
#
# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled
# state (contract §5.3/F5): the track left the catalogue but the user owns a
# downloaded copy, so it still plays. It cannot fold into "This track is no
# longer available" — that sentence would be a lie on a row playing right now.
#
# Em dash is U+2014 with spaces on both sides, as everywhere else in this file.
msgid "Find available version"
msgstr "Procurar versão disponível"
msgid "Replace unavailable track"
msgstr "Substituir faixa indisponível"
msgid "No available version found"
msgstr "Não foi encontrada nenhuma versão disponível"
msgid "Track replaced"
msgstr "Faixa substituída"
msgid "Could not replace the track"
msgstr "Falha ao substituir a faixa"
msgid "The replacement was added at the end of the playlist"
msgstr "A substituição foi adicionada ao fim da playlist"
msgid "{} track skipped — no longer available"
msgid_plural "{} tracks skipped — no longer available"
msgstr[0] "{} faixa ignorada — já não está disponível"
msgstr[1] "{} faixas ignoradas — já não estão disponíveis"
msgid "No longer on Qobuz — playing your downloaded copy"
msgstr "Já não está no Qobuz — a reproduzir a sua cópia transferida"
msgid "Exact match"
msgstr "Correspondência exata"
msgid "Low confidence"
msgstr "Baixa confiança"
msgid "Replace"
msgstr "Substituir"
msgid "Replacing..."
msgstr "Substituindo..."
msgid "The replacement takes the unavailable track's place in the playlist."
msgstr "A substituição ocupa o lugar da faixa indisponível na playlist."
@@ -5212,3 +5212,62 @@ msgstr "Выберите исполнителя, чтобы увидеть ег
msgid "{} / {} artists"
msgstr "{} / {} исполнителей"
# --- Unavailable tracks + replacement search (Qt port, contract 2026-08-17
# §8-6). "Unavailable" and "This track is no longer available" already
# exist in this file and are REUSED by the render and the reactive skip toast
# — nothing below twins them.
#
# The skipped-tracks toast is a plural PAIR, per the convention already set by
# "Scan complete ({} file skipped)" and "{} duplicate skipped": the queue build
# drops N rows at once, and a singular-only string reads wrong in every locale
# with plural rules. Russian carries all three forms.
#
# "No longer on Qobuz — playing your downloaded copy" is the cached-but-pulled
# state (contract §5.3/F5): the track left the catalogue but the user owns a
# downloaded copy, so it still plays. It cannot fold into "This track is no
# longer available" — that sentence would be a lie on a row playing right now.
#
# Em dash is U+2014 with spaces on both sides, as everywhere else in this file.
# nplurals=3 — all three forms, like every other plural entry in this file.
msgid "Find available version"
msgstr "Найти доступную версию"
msgid "Replace unavailable track"
msgstr "Заменить недоступный трек"
msgid "No available version found"
msgstr "Доступная версия не найдена"
msgid "Track replaced"
msgstr "Трек заменён"
msgid "Could not replace the track"
msgstr "Не удалось заменить трек"
msgid "The replacement was added at the end of the playlist"
msgstr "Замена добавлена в конец плейлиста"
msgid "{} track skipped — no longer available"
msgid_plural "{} tracks skipped — no longer available"
msgstr[0] "Пропущен {} трек — он больше недоступен"
msgstr[1] "Пропущено {} трека — они больше недоступны"
msgstr[2] "Пропущено {} треков — они больше недоступны"
msgid "No longer on Qobuz — playing your downloaded copy"
msgstr "Больше нет в Qobuz — воспроизводится скачанная копия"
msgid "Exact match"
msgstr "Точное совпадение"
msgid "Low confidence"
msgstr "Низкая точность"
msgid "Replace"
msgstr "Заменить"
msgid "Replacing..."
msgstr "Замена…"
msgid "The replacement takes the unavailable track's place in the playlist."
msgstr "Замена займёт место недоступного трека в плейлисте."