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:
vicrodh committed 2026-08-20 01:28:26 -06:00
1 parent a55e426330
commit 22033672a1
3 files changed
+105 -29

No files matched your search

+50 -14
View File
@@ -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",
}
)),
}
}
+13 -15
View File
@@ -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
+42
View File
@@ -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());