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:
25 files changed
+1666
-161
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user