fix(qt): offline-cache rows, Jellyfin playback, and media art on the bar

Three defects the owner found by using it, all mine, all from the same
root: a source word that stops at Plex.

1. AN OFFLINE-CACHE ROW PLAYED NOTHING.

     audible: file not available at …/audio/tracks-cmaf/426056576

   `SourceId::from_word` folds `qobuz_download` into LOCAL because the row
   IS a file — but for a CMAF-cached download that "file" is the offline
   cache's own container and only `qbz-core`'s offline tier can read it.
   IC-12's first pass declared those rows ours and handed them to the
   local audible step. They now stay on the Qobuz funnel, which is where
   they were before stage 3 and where their `QueueTrack.id` — the real
   catalog id — is meant to be resolved. The symptom was the bar, the
   queue and MPRIS all changing while the previous track kept playing.

2. A JELLYFIN ROW CHANGED THE BAR AND PLAYED NOTHING.

   `local_queue_track` folded everything that was not Plex or a download
   into "local", so the row entered the queue wearing a source word that
   disagreed with its namespaced id and carrying no server id at all.
   Both media words are now arms of their own, and the hint carries the
   SERVER's item id — which `claim` prefers, because it survives a cache
   rebuild that renumbers rowids.

3. MEDIA ART NEVER REACHED THE BAR, THE QUEUE OR MPRIS.

   Those surfaces resolve an `artwork_url` string with no row in hand, so
   `classify` has only the SHAPE to go on — and a Jellyfin image tag or a
   Subsonic coverArt id has no shape worth reading. `local_queue_track`
   now stamps the token SOURCE-TAGGED (`jellyfin:<albumId>/<tag>`) and
   `classify` grows one arm that DELEGATES it to
   `registry().artwork_token` instead of guessing. One arm, not 111 call
   sites, and it shares its cache entry with the grid because it is the
   same call.

Verified in the running window: a Jellyfin album streams and plays —
`[JELLYFIN/STREAMING] 331 MB @ 12 MB/s`, decoder up, 44.1 kHz to the DAC.

429/429.

cc
This commit is contained in:
vicrodh committed 2026-08-20 12:57:36 -06:00
1 parent a3aff5f1b3
commit d5ce8bcaeb
6 files changed
+252 -8

No files matched your search

@@ -67,8 +67,21 @@ Item {
readonly property int cellH: 266 readonly property int cellH: 266
readonly property int headerH: 34 readonly property int headerH: 34
readonly property int listRowH: 56 readonly property int listRowH: 56
/// Columns, or 0 while the width is still UNKNOWN.
///
/// Zero is not a degenerate case to be clamped away — it is the honest
/// answer, and clamping it to 1 is what put a full-length ONE-COLUMN grid
/// on screen at every mount. `Component.onCompleted` builds synchronously
/// (deliberately, so the grid is not empty for a frame), and at that moment
/// `width` is still 0, so `Math.max(1, …)` said "one column" and the whole
/// album list was chunked one-per-row. The real width arrives a moment
/// later, `onColsChanged` fires, and the entire model is thrown away and
/// rebuilt — the reflow the owner sees, and it is visible for exactly as
/// long as chunking every album twice takes.
///
/// A list view really is one column, so that arm keeps its 1.
readonly property int cols: viewMode === "grid" readonly property int cols: viewMode === "grid"
? Math.max(1, Math.floor(width / cellW)) : 1 ? (width > 0 ? Math.max(1, Math.floor(width / cellW)) : 0) : 1
/// Stand-in for a grid slot with no album — the last row of a group is /// Stand-in for a grid slot with no album — the last row of a group is
/// rarely full, and since the cell count is now CONSTANT (that is what /// rarely full, and since the cell count is now CONSTANT (that is what
@@ -106,6 +119,11 @@ Item {
property int rebuildCount: 0 property int rebuildCount: 0
function rebuild() { function rebuild() {
// No width yet: build NOTHING rather than something guaranteed wrong.
// One empty frame beats a full-length one-column grid that has to be
// discarded — and in the common case the width IS known here, so this
// never fires and the synchronous first build is unchanged.
if (cols <= 0) return
var _t0 = Date.now() var _t0 = Date.now()
var out = [] var out = []
var flatOut = [] var flatOut = []
@@ -144,6 +162,7 @@ Item {
root.rebuildCount += 1 root.rebuildCount += 1
console.info(colTiming, "[coltiming] rebuild #" + root.rebuildCount console.info(colTiming, "[coltiming] rebuild #" + root.rebuildCount
+ " surface=" + root.surface + " grouped=" + root.grouped + " surface=" + root.surface + " grouped=" + root.grouped
+ " cols=" + per
+ " albums=" + flatOut.length + " entries=" + out.length + " albums=" + flatOut.length + " entries=" + out.length
+ " in " + (Date.now() - _t0) + "ms") + " in " + (Date.now() - _t0) + "ms")
report() report()
+59
View File
@@ -165,6 +165,40 @@ pub fn classify(url: &str) -> ArtUrl {
if let Some(path) = url.strip_prefix("file://") { if let Some(path) = url.strip_prefix("file://") {
return ArtUrl::LocalFile(path.to_string()); return ArtUrl::LocalFile(path.to_string());
} }
// A SOURCE-TAGGED token (`jellyfin:<albumId>/<tag>`,
// `subsonic:al-<id>_<hash>`), stamped by `local_queue_track` for a media
// server row.
//
// This is the ONE arm that does not guess. Every other arm above reads the
// SHAPE of the string, which works for a filesystem path and a CDN url and
// is exactly what fails for an opaque token: a Jellyfin image tag looks
// like nothing in particular, and a Subsonic coverArt id looks like a
// relative path. So the tag names the owner and the owner is asked —
// `registry().artwork_token` is the same call the Local Library grid makes,
// so a cover fetched for a queue row and the same cover in the grid share
// one cache entry.
//
// It is here rather than at the 111 call sites of `cached_path` for the
// same reason `classify` exists at all: one taxonomy, asked from
// everywhere. The difference is that this arm delegates instead of
// sniffing.
if let Some((word, token)) = url.split_once(':') {
if let Some(id) = qbz_source::SourceId::from_word(word) {
if matches!(id, qbz_source::SourceId::JELLYFIN | qbz_source::SourceId::SUBSONIC) {
return match qbz_source::registry()
.artwork_token(id, token, qbz_source::ArtSize::Card)
{
qbz_source::ArtRef::Fetch { url, .. } => ArtUrl::Http(url),
// The server is not connected. Reuse the Plex arm's answer:
// "the art exists, it cannot be resolved right now", so the
// miss is logged for what it is instead of looking like a
// dead download.
qbz_source::ArtRef::Unavailable(_) => ArtUrl::PlexUnconfigured,
_ => ArtUrl::Empty,
};
}
}
}
// A Plex thumb is server-relative; it needs base url + token to exist. // A Plex thumb is server-relative; it needs base url + token to exist.
// `local_plex::thumb_url` is the SAME builder the Local Library grid // `local_plex::thumb_url` is the SAME builder the Local Library grid
// uses, so both surfaces produce identical cache keys. // uses, so both surfaces produce identical cache keys.
@@ -843,6 +877,31 @@ mod tests {
#[test] #[test]
fn relative_and_empty_urls_are_empty() { fn relative_and_empty_urls_are_empty() {
// A SOURCE-TAGGED media token is DELEGATED, never sniffed. Untagged, a
// Jellyfin `<albumId>/<tag>` and a Subsonic `al-x_y` are both shapes
// this function would have called `Empty` or a filesystem path — which
// is why the now-playing bar, the queue and MPRIS showed no cover for a
// media-server track even though the grid did.
//
// Disconnected answers `PlexUnconfigured` rather than `Empty` for the
// same reason the Plex arm does: the art EXISTS, it cannot be resolved
// right now, and the two must stay distinguishable. Nothing is
// connected in a unit test, so that is what this asserts — the
// CONNECTED shape is covered where the credentials live
// (`JellyfinSource::artwork_token`).
assert_eq!(classify("jellyfin:alb-1/deadbeef"), ArtUrl::PlexUnconfigured);
assert_eq!(classify("subsonic:al-abc_59fec8ff"), ArtUrl::PlexUnconfigured);
// A brand spelling folds the same way the source words do.
assert_eq!(classify("navidrome:al-abc"), ArtUrl::PlexUnconfigured);
// A Subsonic track cover id contains its own colon; only the FIRST
// separator may be consumed.
assert_eq!(classify("subsonic:dc-abc:1_0"), ArtUrl::PlexUnconfigured);
// And the arm must not swallow anything that merely contains a colon.
assert_eq!(
classify("https://static.qobuz.com/images/covers/a.jpg"),
ArtUrl::Http("https://static.qobuz.com/images/covers/a.jpg".into())
);
assert_eq!(classify("plex:whatever"), ArtUrl::Empty);
assert_eq!(classify(""), ArtUrl::Empty); assert_eq!(classify(""), ArtUrl::Empty);
assert_eq!(classify(" "), ArtUrl::Empty); assert_eq!(classify(" "), ArtUrl::Empty);
assert_eq!(classify("covers/a.jpg"), ArtUrl::Empty); assert_eq!(classify("covers/a.jpg"), ArtUrl::Empty);
+8
View File
@@ -352,6 +352,14 @@ pub fn fetch_album_tracks_blocking(id: &str) -> Vec<LocalTrack> {
if id.starts_with("plex:") { if id.starts_with("plex:") {
return crate::local_plex::album_tracks(id); return crate::local_plex::album_tracks(id);
} }
// A media-server key (`jellyfin:<albumId>` / `subsonic:<albumId>`). WITHOUT
// this arm the key fell through to `library.db`, where it matches nothing:
// the album page opened EMPTY, and in metadata mode it paid two fruitless
// full queries first — which is why leaving a Jellyfin album was slower
// than leaving a local one.
if let Some(rows) = crate::media_servers_qt::album_tracks(id) {
return rows;
}
let mode = group_mode(); let mode = group_mode();
with_db(|db| match mode { with_db(|db| match mode {
AlbumGroupMode::Metadata => { AlbumGroupMode::Metadata => {
+44 -5
View File
@@ -144,16 +144,33 @@ pub fn local_queue_track(t: &LocalTrack) -> QueueTrack {
let src = match t.source.as_deref() { let src = match t.source.as_deref() {
Some("qobuz_download") => "qobuz_download", Some("qobuz_download") => "qobuz_download",
Some("plex") => "plex", Some("plex") => "plex",
// The media servers. Folding these into "local" — which the `_` arm
// did — put a Jellyfin row into the queue wearing the wrong source
// word AND with no server id, so it changed the now-playing bar and
// then played nothing: `claim` had a namespaced id and a word that
// disagreed with it.
Some("jellyfin") => "jellyfin",
Some("subsonic") | Some("navidrome") | Some("gonic") | Some("airsonic")
| Some("astiga") => "subsonic",
_ => "local", _ => "local",
}; };
let is_offline = src == "qobuz_download"; let is_offline = src == "qobuz_download";
let is_plex = src == "plex"; let is_plex = src == "plex";
let is_media = src == "jellyfin" || src == "subsonic";
// A Plex row carries a RAW server-relative thumb path; it must stay raw // A Plex row carries a RAW server-relative thumb path; it must stay raw
// so the now-playing bar / queue resolve it from current creds. // so the now-playing bar / queue resolve it from current creds.
// `file://`-prefixing it poisons it into a local-read miss. // `file://`-prefixing it poisons it into a local-read miss.
let artwork_url = t.artwork_path.as_ref().map(|p| { let artwork_url = t.artwork_path.as_ref().map(|p| {
if is_plex || p.starts_with("file://") { if is_plex || p.starts_with("file://") {
p.clone() p.clone()
} else if is_media {
// SOURCE-TAGGED, because a media server's token is meaningless on
// its own: a Jellyfin image tag and a Subsonic coverArt id are
// both opaque, and the now-playing bar / queue / MPRIS resolve an
// `artwork_url` through the shared taxonomy with no row in hand.
// The tag is what lets that taxonomy hand it back to the source
// that issued it instead of guessing (`artwork_qt::classify`).
format!("{src}:{p}")
} else { } else {
format!("file://{p}") format!("file://{p}")
} }
@@ -186,6 +203,9 @@ pub fn local_queue_track(t: &LocalTrack) -> QueueTrack {
album_id: Some(if is_plex { album_id: Some(if is_plex {
crate::local_plex::album_key_for(&t.artist, &t.album) crate::local_plex::album_key_for(&t.artist, &t.album)
} else { } else {
// For a media row this is already the PREFIXED key
// (`jellyfin:<albumId>`) that `cached_to_local_track` stamped, so
// "go to album" from the queue opens the same page the card does.
t.album_group_key.clone() t.album_group_key.clone()
}), }),
artist_id: None, artist_id: None,
@@ -203,8 +223,12 @@ pub fn local_queue_track(t: &LocalTrack) -> QueueTrack {
parental_warning: false, parental_warning: false,
// Plex: the string rating_key the resolve needs (the numeric queue id // Plex: the string rating_key the resolve needs (the numeric queue id
// is a namespaced form). Offline: the local row id. // is a namespaced form). Offline: the local row id.
source_item_id_hint: if is_plex || is_offline { source_item_id_hint: if is_plex || is_offline || is_media {
Some(if is_plex { Some(if is_plex || is_media {
// Plex: the raw rating key. Media servers: the SERVER's own
// item id, which `cached_to_local_track` parks in `file_path`
// (a remote track has no path on this machine). `claim` prefers
// it over the numeric id because it survives a cache rebuild.
t.file_path.clone() t.file_path.clone()
} else { } else {
t.id.to_string() t.id.to_string()
@@ -309,9 +333,24 @@ pub async fn play_current_if_local(runtime: &Runtime, track_id: u64) -> LocalPla
// //
// `claim` refuses to guess, so a row nobody owns stays `NotLocal` and the // `claim` refuses to guess, so a row nobody owns stays `NotLocal` and the
// Qobuz path runs exactly as before. // Qobuz path runs exactly as before.
let claimed = qbz_source::registry().claim(&qbz_source::RawRef::from_queue_track(&qt)); let raw = qbz_source::RawRef::from_queue_track(&qt);
let ours = match claimed { // An OFFLINE row is NOT ours, and this exclusion is the correction to
Ok(item) => item.source() != qbz_source::SourceId::QOBUZ, // IC-12's first pass. `SourceId::from_word` folds `qobuz_download` /
// `qobuz_purchase` / `offline` into LOCAL because the row IS a file — but
// for a CMAF-cached download that "file" is the offline cache's own
// container, and only `qbz-core`'s offline tier can read it. Handing it to
// the local audible step produced exactly one log line and silence:
//
// audible: file not available at …/audio/tracks-cmaf/426056576
//
// Its `QueueTrack.id` is the real Qobuz catalog id precisely so the funnel
// can resolve it, which is what happened before stage 3 and what has to
// keep happening.
let ours = match qbz_source::registry().claim(&raw) {
Ok(item) => {
item.source() != qbz_source::SourceId::QOBUZ
&& raw.badge != qbz_source::SourceBadge::Offline
}
Err(_) => false, Err(_) => false,
}; };
if !ours { if !ours {
+113
View File
@@ -319,6 +319,119 @@ pub fn purge_cache(kind: MediaServerKind) {
} }
} }
// ---------------------------------------------------------------------------
// The `LocalTrack` shape — what every Local Library surface consumes
// ---------------------------------------------------------------------------
/// Map a cached remote row into the `LocalTrack` shape the Local Library's
/// album page, per-disc menu, bulk bar and row cache all read.
///
/// The Plex sibling is `local_plex::map_cached_to_local_track`. Two fields are
/// decided differently and both matter:
///
/// - `file_path` carries the SERVER'S OWN id, not a path. A remote track has no
/// file on this machine, and this is the slot `local_queue_track` stamps into
/// `source_item_id_hint` — which is exactly what `claim` prefers, because a
/// server id survives a cache rebuild that renumbers rowids. Nothing ever
/// opens it as a path; `LocalSource` never sees these rows.
/// - `album_group_key` is the PREFIXED key (`jellyfin:<albumId>`), matching the
/// one the grid's SQL union publishes, so "go to album" from a track row
/// lands on the same page the card opens.
pub fn cached_to_local_track(t: qbz_media_cache::CachedTrack) -> qbz_library::LocalTrack {
qbz_library::LocalTrack {
id: t.id,
file_path: t.item_id,
title: t.title,
artist: t.artist.clone(),
album_artist: (!t.album_artist.is_empty()).then_some(t.album_artist),
album: t.album.clone(),
album_group_key: format!("{}:{}", t.source, t.album_id),
album_group_title: t.album,
track_number: t.track_number,
disc_number: t.disc_number,
duration_secs: t.duration_ms / 1000,
format: parse_audio_format(&t.container, t.codec.as_deref()),
bit_depth: t.bit_depth,
sample_rate: t.sample_rate_hz.unwrap_or(0) as f64,
year: t.year,
artwork_path: t.artwork_token,
source: Some(t.source),
..Default::default()
}
}
/// Container/codec name -> the enum the quality badge derives from.
///
/// Both are consulted because the two protocols disagree about which one is
/// populated: Jellyfin reports a `Container` and often a codec, Subsonic
/// reports a `suffix` and a MIME type.
fn parse_audio_format(container: &str, codec: Option<&str>) -> qbz_library::AudioFormat {
use qbz_library::AudioFormat as F;
let probe = |s: &str| -> Option<F> {
let s = s.to_ascii_lowercase();
if s.contains("flac") {
Some(F::Flac)
} else if s.contains("mp3") || s.contains("mpeg") {
Some(F::Mp3)
} else if s.contains("alac") || s.contains("m4a") || s.contains("mp4") {
Some(F::Alac)
} else if s.contains("wav") {
Some(F::Wav)
} else if s.contains("aiff") {
Some(F::Aiff)
} else {
None
}
};
probe(container)
.or_else(|| codec.and_then(probe))
.unwrap_or(F::Flac)
}
/// The tracks of one remote album, by its PREFIXED grid key
/// (`jellyfin:<albumId>` / `subsonic:<albumId>`), in disc/track order.
///
/// `None` when the key is not a remote one, so the caller falls through to
/// `library.db` exactly as before.
pub fn album_tracks(prefixed_key: &str) -> Option<Vec<qbz_library::LocalTrack>> {
let (word, album_id) = prefixed_key.split_once(':')?;
let source = qbz_media_cache::RemoteSource::from_word(word)?;
let handle = match source {
qbz_media_cache::RemoteSource::Jellyfin => qbz_source::registry().jellyfin().cache(),
qbz_media_cache::RemoteSource::Subsonic => qbz_source::registry().subsonic().cache(),
};
let rows = handle
.with(|c| qbz_media_cache::album_tracks(c, source, album_id).unwrap_or_default())
.unwrap_or_default();
Some(rows.into_iter().map(cached_to_local_track).collect())
}
/// Substring search across one remote source, in the `LocalTrack` shape.
pub fn search_tracks(query: &str, limit: Option<u32>) -> Vec<qbz_library::LocalTrack> {
let mut out = Vec::new();
for kind in MediaServerKind::ALL {
if !get(kind).is_configured(kind) {
continue;
}
let (source, handle) = match kind {
MediaServerKind::Jellyfin => (
qbz_media_cache::RemoteSource::Jellyfin,
qbz_source::registry().jellyfin().cache(),
),
MediaServerKind::Subsonic => (
qbz_media_cache::RemoteSource::Subsonic,
qbz_source::registry().subsonic().cache(),
),
};
if let Some(rows) = handle
.with(|c| qbz_media_cache::search(c, source, query, limit).unwrap_or_default())
{
out.extend(rows.into_iter().map(cached_to_local_track));
}
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+8 -2
View File
@@ -730,8 +730,14 @@ pub(crate) async fn play_resolved_offline_aware(
.await .await
.filter(|t| t.id == track_id) .filter(|t| t.id == track_id)
{ {
let claimed = qbz_source::registry().claim(&qbz_source::RawRef::from_queue_track(&qt)); let raw = qbz_source::RawRef::from_queue_track(&qt);
if let Ok(item) = claimed { // OFFLINE rows stay on THIS path on purpose — see
// `local_playback::play_current_if_local` for the log line that proves
// why. The offline tier below is the only thing that can read a
// CMAF-cached download, and the row carries the Qobuz catalog id so it
// can.
let is_offline = raw.badge == qbz_source::SourceBadge::Offline;
if let (false, Ok(item)) = (is_offline, qbz_source::registry().claim(&raw)) {
if item.source() != qbz_source::SourceId::QOBUZ { if item.source() != qbz_source::SourceId::QOBUZ {
return match crate::audible_qt::play_queue_track(runtime, &qt).await { return match crate::audible_qt::play_queue_track(runtime, &qt).await {
Ok(true) => Ok(()), Ok(true) => Ok(()),