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

+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)
}