fix(qt): treat every server source as non-catalog, not just Plex

Three places still spelled "not a Qobuz row" as "local or plex", which
made a Jellyfin or Subsonic row fall into the catalog class:

- Library ALL feed stamped the album source from the ID PREFIX
  (`plex:`), so every media-server album came out as "local" and drew
  the local hard-drive badge. Reads the row's own `source` word now —
  byte-identical for local and Plex, correct for the rest. The same
  failure `local_rows::badge_source` had.
- TrackRow's `localSourceRow` gated "Add to playlist" and the offline
  download entry. A Jellyfin row counted as a catalog row, so the menu
  offered to send a namespaced synthetic id to the Qobuz playlist API,
  which adds an UNRELATED track. An allowlist, not `!== "qobuz"`:
  "offline" is an offline-CACHED Qobuz row whose id IS a catalog id.
- AlbumCard hid "Block this album" on local/plex only, so a server album
  offered to blacklist an id the Qobuz catalog never had.

`feed_track_to_queue` is taught the words too. Media-server TRACKS do
not ride that feed today (only their albums do), so that one is the
guard learning the vocabulary before it is needed rather than a live
fix — its existing test, which pins that a local id must never resolve
as the Qobuz track carrying the same number, now covers all of them.

cc
This commit is contained in:
vicrodh committed 2026-08-20 14:12:53 -06:00
1 parent 13e938727c
commit 8b4e0e92b8
3 files changed
+77 -12

No files matched your search

+17 -4
View File
@@ -55,8 +55,9 @@ Rectangle {
property string artworkUrl: ""
property bool isFavorite: false
property bool isPinned: false
// The row's SOURCE word — "local" | "offline" | "plex" (the Local Library
// badge triple, src/local_rows.rs `badge_source`) or "qobuz" (the Library
// The row's SOURCE word — "local" | "offline" | "plex" | "jellyfin" |
// a Subsonic brand spelling (the Local Library badge set,
// src/local_rows.rs `badge_source`) or "qobuz" (the Library
// ALL feed, library_qt.rs `FeedItem.source`). "" = a catalog surface that
// publishes no source at all.
//
@@ -65,6 +66,15 @@ Rectangle {
// (AlbumCard.slint:434 keeps the entry off local/plex rows). Hosts that
// want the badge hidden set `showSourceBadge`, never `source: ""`.
property string source: ""
/// Source words that mean "this album does not live in the Qobuz catalog".
/// Same fold as `SourceIcon.isSubsonic`: a row stamped with a Subsonic
/// BRAND is still a server row, so it must not be offered a catalog-only
/// action.
readonly property bool serverAlbum: root.source === "local"
|| root.source === "plex" || root.source === "jellyfin"
|| root.source === "subsonic" || root.source === "navidrome"
|| root.source === "gonic" || root.source === "airsonic"
|| root.source === "astiga"
// discover/AlbumCard.slint:79 `show-source-badge` — default OFF; the two
// hosts that turn it on are the Library ALL grid (gated on the toolbar's
// show-local toggle, FavoritesView.slint:1097) and the Local Library
@@ -256,8 +266,11 @@ Rectangle {
if (root.catalogAffordances) {
m.push({ "label": root.isFavorite ? t("Remove from Library", r) : t("Add to Library", r),
"icon": root.isFavorite ? "heart-filled" : "heart", "action": "favorite" })
// .slint gates this on a non-local/plex source as well.
if (root.hasBlacklistSeam && root.source !== "local" && root.source !== "plex")
// .slint gates this on a non-local/plex source as well — and the
// rule is "not a Qobuz catalog album", so every server source is
// in the same class as Plex. Blocking a Jellyfin album would write
// a blacklist entry against an id the Qobuz catalog never had.
if (root.hasBlacklistSeam && !root.serverAlbum)
m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" })
}
// Host tail (see `extraMenuEntries`). `concat` so the host's array is
+15 -5
View File
@@ -228,9 +228,10 @@ Rectangle {
// unmistakable (playlist_picker_qt.rs's four-case matrix):
//
// catalogRow `item.id` is a Qobuz catalog id -> openForTrack.
// localSourceRow the row carries its own source word and it says
// local/plex -> `item.id` is a library.db row id or
// a Plex synthetic id. Sending it to the Qobuz arm
// localSourceRow the row carries its own source word and it names
// a NON-CATALOG origin -> `item.id` is a library.db
// row id or a namespaced synthetic id (Plex,
// Jellyfin, Subsonic). Sending it to the Qobuz arm
// adds an UNRELATED track, so the entry is only
// offered when the HOST takes the routing
// (`routePlaylistAddExternally`, below), and is
@@ -241,8 +242,17 @@ Rectangle {
//
// Ephemeral rows never reach this decision: their surface sets
// `showMenu: false`, so the whole menu is absent (LocalEphemeralPane).
readonly property bool localSourceRow:
root.item.source === "local" || root.item.source === "plex"
// An ALLOWLIST of non-catalog words, not `!== "qobuz"`: "offline" is an
// offline-CACHED Qobuz row whose id IS a catalog id, so inverting the test
// would strip Add-to-playlist from the rows that legitimately have it.
// The Subsonic brand spellings fold here for the same reason they fold in
// `SourceIcon.isSubsonic` — a row stamped "navidrome" is not a Qobuz row.
readonly property bool localSourceRow: {
var s = root.item.source
return s === "local" || s === "plex" || s === "jellyfin"
|| s === "subsonic" || s === "navidrome" || s === "gonic"
|| s === "airsonic" || s === "astiga"
}
/// Hand "Add to playlist" to the HOST (the `routeGoToExternally` idiom):
/// the host answers `playlistAddRequested()` with the LOCAL-mode call its
/// surface can make. The local playlist detail is the one that needs it —
+45 -3
View File
@@ -897,7 +897,24 @@ fn all_local_feed_blocking() -> Vec<FeedItem> {
.unwrap_or_default();
let n = albums.len();
for (i, a) in albums.into_iter().enumerate() {
let source = if a.id.starts_with("plex:") { "plex" } else { "local" };
// The row's own `source` word, NOT the id prefix. Both answer "plex"
// for a Plex album (group_key is `plex:<hash>` and the CTE stamps
// 'plex'), so this is byte-identical for the two sources that existed
// — but a media server's group_key is `jellyfin:<id>` / `navidrome:<id>`,
// and the prefix test folded every one of them to "local". That is the
// same failure `local_rows::badge_source` had: the ALL feed drew the
// local hard-drive on albums that live on a server.
//
// "user" and "qobuz_download" keep answering "local" exactly as before;
// this feed's `source` vocabulary is consumed by `feed_track_to_queue`
// and by the QML badge, and widening it to "offline" here would be a
// separate change with its own consumers to check.
let source = match a.source.as_str() {
"plex" => "plex",
"jellyfin" => "jellyfin",
"subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga" => "subsonic",
_ => "local",
};
out.push(
FeedItem {
kind: "album".into(),
@@ -1608,6 +1625,22 @@ pub(crate) fn apply_pin_change(kind: &str, id: &str, pinned: bool) {
// under the pointer — identical on the Tracks tab, and in the All feed it
// queues the mixed feed's track rows in the order shown instead of nothing.
/// Every source word whose row id is NOT a Qobuz catalog id.
///
/// The list is an ALLOWLIST rather than `!= "qobuz"` because the fall-through
/// below parses the id as a u64 and resolves it against the Qobuz catalog: a
/// word this function has not been taught silently becomes "somebody else's
/// track", which is exactly the failure the test below pins for local/plex.
/// Media-server tracks do not ride this feed today (only their ALBUMS do), so
/// this is the guard being taught the words BEFORE they arrive, not a fix for
/// a live bug.
fn is_local_source(source: &str) -> bool {
matches!(
source,
"local" | "plex" | "jellyfin" | "subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga"
)
}
/// `playback_qt::feed_queue_track` for a feed row already in hand.
///
/// Mirrors that function field-for-field (it looks the same row up by id and
@@ -1632,7 +1665,7 @@ pub(crate) fn apply_pin_change(kind: &str, id: &str, pinned: bool) {
/// Slint builds — its `FAV_CURRENT` is Qobuz-only — and it is strictly better
/// than resolving it as somebody else's track.
pub(crate) fn feed_track_to_queue(item: &FeedItem) -> Option<QueueTrack> {
if item.source == "local" || item.source == "plex" {
if is_local_source(&item.source) {
let row_id = item.id.parse::<i64>().ok()?;
let raw = LOCAL_RAW.lock().ok()?.get(&row_id).cloned();
return match raw {
@@ -1813,7 +1846,16 @@ mod tests {
/// `QueueTrack` with `source: "qobuz"` and this id.
#[test]
fn a_local_row_is_never_resolved_as_a_qobuz_track() {
for source in ["local", "plex"] {
for source in [
"local",
"plex",
"jellyfin",
"subsonic",
"navidrome",
"gonic",
"airsonic",
"astiga",
] {
let mut item = track("42", "Ghost in the Machine");
item.source = source.into();
assert!(