fix(qt): claim the row before the Qobuz funnel, and before casting it
Two remaining stage-3 consumers. `play_resolved_offline_aware` is the QOBUZ entry — the tier walk, the offline cache, the quality request — and every MyQBZ and album play arm sent its anchor there regardless of who owned the row. A local, ephemeral or Plex first track resolved and then failed AT THE PLAYER with "No Qobuz client available", which `myqbz_play_qt`'s own header documented as open. It was also unsafe: the offline cache is keyed on the raw u64, and a local_tracks rowid or a Plex rating key shares that small-integer space with older Qobuz ids, so a collision played the wrong track rather than failing cleanly (#638 fix 3's sharp edge). The funnel now claims the row first and hands a non-Qobuz one to audible_qt, which fixes all six call sites without touching a signature. The claim is gated on the core's cursor already sitting on that track — true for every caller, since they all set the queue and then play its anchor — so a caller that plays some other id behaves exactly as before instead of guessing. `cast_qt::resolve_local_path` becomes a PlaybackTicket consumer (IC-10). It was an ephemeral test plus a bare db.get_track on a QueueTrack.id, which is only sometimes a library.db rowid: a Plex row carries a namespaced id and an offline row carries the Qobuz catalog id, so both missed and reported "Local file not found" — an answer naming the wrong problem. Now File/DsdFile is servable and everything else is a named refusal, which covers every remote source by construction rather than the one word somebody thought to list. The Plex arm stays an early refusal so it does not pay a network round trip to build a url it would throw away. Structural gate: no plex_resolve_track_media / plex_rating_key left in qbz-qt outside comments. Four source-word matches remain on purpose — local_queue_track STAMPS the word (producer side, not consumer), play_single_from_source looks up a ROW rather than playing it, cast's early refusal above, and batch_all_qconnect_castable, which asks "does this row have a real Qobuz catalog id?" — a different question from ownership, and one SourceId cannot answer since qobuz_download maps to LOCAL while being castable. 423/423. cc
This commit is contained in:
3 files changed
+105
-29
No files matched your search
@@ -562,15 +562,22 @@ impl CastService {
|
||||
// Resolve + register per source. The fetch happens OUTSIDE the lock.
|
||||
let info = match source {
|
||||
"local" | "ephemeral" => {
|
||||
let path = resolve_local_path(track.id)
|
||||
.ok_or_else(|| format!("Local file not found for track {}", track.id))?;
|
||||
let path = resolve_castable_path(track).await?;
|
||||
self.register_local(track.id, &path).await?
|
||||
}
|
||||
"qobuz" | "qobuz_download" => self.register_qobuz(track.id).await?,
|
||||
"plex" => {
|
||||
// TODO(cast-plex): needs the Plex bytes resolver
|
||||
// (baseUrl/token/ratingKey -> proxied bytes). The Slint
|
||||
// service carries the same TODO.
|
||||
// TODO(cast-plex): needs a proxy that re-serves the resolved
|
||||
// part bytes to the renderer. `PlaybackTicket::Stream` now
|
||||
// hands over the url that proxy would read, so what is missing
|
||||
// is the media-server arm, not the resolve. The Slint service
|
||||
// carries the same TODO.
|
||||
//
|
||||
// Kept as an EARLY refusal on purpose: `resolve_castable_path`
|
||||
// would refuse it too — structurally, for every remote source,
|
||||
// including ones that have no arm here — but only after paying
|
||||
// a network round trip to build a url it is about to throw
|
||||
// away.
|
||||
return Err("Plex casting is not yet supported".to_string());
|
||||
}
|
||||
other => return Err(format!("Unsupported cast source: {other}")),
|
||||
@@ -1641,15 +1648,44 @@ fn cap_index_for_key(cap_key: &str) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk path for a local/ephemeral track id (mirrors the local playback
|
||||
/// lookup in `local_playback.rs`).
|
||||
fn resolve_local_path(track_id: u64) -> Option<String> {
|
||||
if crate::local_ephemeral::is_ephemeral_id(track_id as i64) {
|
||||
crate::local_ephemeral::get_track(track_id as i64).map(|row| row.file_path)
|
||||
} else {
|
||||
crate::local_state::with_db(|db| db.get_track(track_id as i64))
|
||||
.flatten()
|
||||
.map(|row| row.file_path)
|
||||
/// The on-disk file a row can be SERVED from, or a named refusal.
|
||||
///
|
||||
/// IC-10. This was `resolve_local_path(track_id)`: an ephemeral-floor test and
|
||||
/// then a bare `db.get_track(id)`. Its two siblings in `local_playback` both
|
||||
/// grew guards this one never got — and the id it is handed is a
|
||||
/// `QueueTrack.id`, which is only sometimes a `library.db` rowid. A Plex row
|
||||
/// carries a NAMESPACED id (bit 40 set) and an offline row carries the *Qobuz*
|
||||
/// catalog id, so both looked up a rowid that is not theirs, missed, and
|
||||
/// reported "Local file not found" — an answer that names the wrong problem.
|
||||
///
|
||||
/// It is now a `PlaybackTicket` consumer like every other playback path, which
|
||||
/// makes the refusals structural rather than remembered:
|
||||
///
|
||||
/// - `File` / `DsdFile` — a real path; cast can serve it.
|
||||
/// - anything else — the row plays through the network, and casting it would
|
||||
/// need a proxy this service does not have. That is the `TODO(cast-plex)`
|
||||
/// arm's actual content, and it now applies to every remote source by
|
||||
/// construction instead of to the one word somebody thought to list.
|
||||
async fn resolve_castable_path(track: &QueueTrack) -> Result<String, String> {
|
||||
let ticket = qbz_source::registry()
|
||||
.playback(track)
|
||||
.await
|
||||
.map_err(|e| format!("Cannot resolve track {} for casting: {e}", track.id))?;
|
||||
match ticket {
|
||||
qbz_source::PlaybackTicket::File { path, .. }
|
||||
| qbz_source::PlaybackTicket::DsdFile { path, .. } => {
|
||||
Ok(path.to_string_lossy().into_owned())
|
||||
}
|
||||
other => Err(format!(
|
||||
"Track {} cannot be cast: its source serves it over the network, not as a file ({})",
|
||||
track.id,
|
||||
match other {
|
||||
qbz_source::PlaybackTicket::Stream { .. } => "streamed",
|
||||
qbz_source::PlaybackTicket::Bytes { .. } => "fetched bytes",
|
||||
qbz_source::PlaybackTicket::Catalog { .. } => "catalog",
|
||||
_ => "unsupported",
|
||||
}
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,22 +23,20 @@
|
||||
//! No source is branched on by hand here: `SourceRegistry::claim` is the one
|
||||
//! normalisation point, and the source of a row always comes from the ROW.
|
||||
//!
|
||||
//! STILL OPEN, and NOT this stage's job: every `Play` arm below starts the
|
||||
//! first track through `playback_qt::play_resolved_offline_aware`, whose
|
||||
//! network arm is the Qobuz-only entry (`qbz-core/src/core.rs` errors "No
|
||||
//! Qobuz client available"). A local or Plex first track therefore still
|
||||
//! fails AT THE PLAYER even though it now resolves. That router is design 02
|
||||
//! §9 stage 3 (`SourceRegistry::playback` → `PlaybackTicket`), which owns both
|
||||
//! copies of `play_audible` as well; nothing here should grow a source branch
|
||||
//! to anticipate it.
|
||||
//! CLOSED by stage 3, and it is why no `Play` arm below grew a source branch:
|
||||
//! every arm still starts its first track through
|
||||
//! `playback_qt::play_resolved_offline_aware`, but that funnel now CLAIMS the
|
||||
//! row before it does anything else and hands a non-Qobuz one to `audible_qt`.
|
||||
//! A local, ephemeral or Plex first track plays. It used to resolve here and
|
||||
//! then fail AT THE PLAYER with "No Qobuz client available" — an error about a
|
||||
//! service that has nothing to do with the track.
|
||||
//!
|
||||
//! One sharp edge that arrived with the funnel (2026-08-17, #638 fix 3): the
|
||||
//! funnel consults the OFFLINE cache before the network, keyed on the raw
|
||||
//! `u64`. A `local_tracks` rowid and a Plex rating_key live in the same small
|
||||
//! -integer space as older Qobuz ids, so a collision would play the wrong
|
||||
//! track instead of failing cleanly — the same id-space hazard that produced
|
||||
//! the `feed_track_to_queue` bug. Ephemeral ids cannot collide (they start at
|
||||
//! 2^48). The real fix is stage 3's source-aware routing, not a guard here.
|
||||
//! That also removed the sharp edge the funnel arrived with (2026-08-17, #638
|
||||
//! fix 3): it consulted the OFFLINE cache before the network, keyed on the raw
|
||||
//! `u64`, and a `local_tracks` rowid or a Plex rating_key lives in the same
|
||||
//! small-integer space as older Qobuz ids — a collision played THE WRONG TRACK
|
||||
//! instead of failing cleanly. A claimed non-Qobuz row never reaches that
|
||||
//! lookup now, so the id-space overlap is gone rather than guarded.
|
||||
//!
|
||||
//! Contract, all load-bearing and 1:1 with the reference:
|
||||
//! - "Resolve all" uses the collection's persisted `play_mode`; the hero
|
||||
|
||||
@@ -706,6 +706,48 @@ pub(crate) async fn play_resolved_offline_aware(
|
||||
track_id: u64,
|
||||
start_position_secs: u64,
|
||||
) -> Result<(), String> {
|
||||
// SOURCE FIRST (design 02 §9 stage 3). Everything below this block is the
|
||||
// QOBUZ entry: the tier walk, the offline cache, the quality request. A row
|
||||
// a different source owns has no business in it, and until now it went
|
||||
// there anyway — `myqbz_play_qt`'s own header called this out as the reason
|
||||
// a MyQBZ collection's local or Plex first track "still fails AT THE
|
||||
// PLAYER even though it now resolves".
|
||||
//
|
||||
// It was also unsafe, not merely wrong: the offline cache is keyed on the
|
||||
// raw `u64`, and a `local_tracks` rowid or a Plex rating key lives in the
|
||||
// same small-integer space as older Qobuz ids. A collision played THE
|
||||
// WRONG TRACK rather than failing cleanly (#638 fix 3's sharp edge).
|
||||
// Claiming the row first removes the id-space overlap: a claimed non-Qobuz
|
||||
// row never reaches the cache lookup at all.
|
||||
//
|
||||
// The cursor test is deliberate. Every caller sets the queue and then plays
|
||||
// its anchor, so the core is already sitting on this track; if it is NOT,
|
||||
// this function does exactly what it did before rather than guessing which
|
||||
// row was meant.
|
||||
if let Some(qt) = runtime
|
||||
.core()
|
||||
.current_track()
|
||||
.await
|
||||
.filter(|t| t.id == track_id)
|
||||
{
|
||||
let claimed = qbz_source::registry().claim(&qbz_source::RawRef::from_queue_track(&qt));
|
||||
if let Ok(item) = claimed {
|
||||
if item.source() != qbz_source::SourceId::QOBUZ {
|
||||
return match crate::audible_qt::play_queue_track(runtime, &qt).await {
|
||||
Ok(true) => Ok(()),
|
||||
// Its own source owned it and could not play it. Phrased so
|
||||
// `is_terminal_unavailable` routes it into the SAME bounded
|
||||
// skip walk a pulled Qobuz track takes — to the user the two
|
||||
// failures are the same one.
|
||||
Ok(false) => Err(format!(
|
||||
"local file no longer available (track {track_id})"
|
||||
)),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let quality = local_playback_quality().0;
|
||||
let off = crate::offline_qt::get().await;
|
||||
let sink = off.as_ref().map(|_| crate::offline_cache_qt::row_sink());
|
||||
|
||||
Reference in new issue
Block a user