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

+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.