diff --git a/crates/qbz-qt/src/cast_qt.rs b/crates/qbz-qt/src/cast_qt.rs index eaecc7af6..45bd5157f 100644 --- a/crates/qbz-qt/src/cast_qt.rs +++ b/crates/qbz-qt/src/cast_qt.rs @@ -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 { - 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 { + 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", + } + )), } } diff --git a/crates/qbz-qt/src/myqbz_play_qt.rs b/crates/qbz-qt/src/myqbz_play_qt.rs index 577ec5419..0d8cfb785 100644 --- a/crates/qbz-qt/src/myqbz_play_qt.rs +++ b/crates/qbz-qt/src/myqbz_play_qt.rs @@ -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 diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index e7471532d..8dae6a4a5 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -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());