fix(ui): keep local album source badges visible across surfaces

Carry the physical source list through Discover pins and Library feeds, including logical albums with multiple origins. Reuse the Local Library badge vocabulary and shared AlbumCard rendering so local origins remain visible in Discover, Library and MyQBZ.

Validated with 738 Qt tests and the six QML audits.

cx
This commit is contained in:
vicrodh committed 2026-09-06 06:37:38 -06:00
1 parent df8e1c89c1
commit 9bcc5043f3
9 files changed
+130 -42

No files matched your search

+7 -5
View File
@@ -102,11 +102,12 @@ Rectangle {
|| root.source === "subsonic" || root.source === "navidrome" || root.source === "subsonic" || root.source === "navidrome"
|| root.source === "gonic" || root.source === "airsonic" || root.source === "gonic" || root.source === "airsonic"
|| root.source === "astiga" || root.source === "astiga"
// discover/AlbumCard.slint:79 `show-source-badge` — default OFF; the two // Hosts can also show catalog badges. Local Library provenance is always
// hosts that turn it on are the Library ALL grid (gated on the toolbar's // visible, even when a host's optional catalog-badge toggle is off.
// show-local toggle, FavoritesView.slint:1097) and the Local Library
// grid (LocalLibraryView.slint:1267).
property bool showSourceBadge: false property bool showSourceBadge: false
readonly property bool hasLocalSourceBadge: root.badgeSources.some(function (source) {
return source !== "" && source !== "qobuz"
})
// Local play count — rendered as the muted "{} plays" line under the // Local play count — rendered as the muted "{} plays" line under the
// artist, and ONLY there (discover/AlbumCard.slint:508-513). Every // artist, and ONLY there (discover/AlbumCard.slint:508-513). Every
// surface but Most Played Albums publishes 0, which is why that page's // surface but Most Played Albums publishes 0, which is why that page's
@@ -844,7 +845,8 @@ Rectangle {
// `hard-drive` for Plex — a blue hard drive where the design calls // `hard-drive` for Plex — a blue hard drive where the design calls
// for the Plex mark. // for the Plex mark.
Rectangle { Rectangle {
visible: root.showSourceBadge && root.badgeSources.length > 0 visible: (root.showSourceBadge || root.hasLocalSourceBadge)
&& root.badgeSources.length > 0
x: parent.width - width - 6 x: parent.width - width - 6
y: parent.height - height - 6 y: parent.height - height - 6
width: sourceBadgeRow.implicitWidth width: sourceBadgeRow.implicitWidth
@@ -382,6 +382,8 @@ Column {
? sg.reveal : 1.0 ? sg.reveal : 1.0
AlbumCard { AlbumCard {
albumId: gcell.modelData.id albumId: gcell.modelData.id
source: gcell.modelData.source || ""
sources: gcell.modelData.sources || []
title: gcell.modelData.title title: gcell.modelData.title
artist: gcell.modelData.artist artist: gcell.modelData.artist
artistId: gcell.modelData.artistId artistId: gcell.modelData.artistId
@@ -557,6 +559,8 @@ Column {
AlbumCard { AlbumCard {
id: mountedCard id: mountedCard
albumId: cell.cardData.id albumId: cell.cardData.id
source: cell.cardData.source || ""
sources: cell.cardData.sources || []
title: cell.cardData.title title: cell.cardData.title
artist: cell.cardData.artist artist: cell.cardData.artist
artistId: cell.cardData.artistId artistId: cell.cardData.artistId
+6
View File
@@ -601,6 +601,8 @@ Rectangle {
AlbumCard { AlbumCard {
albumId: modelData.id albumId: modelData.id
source: modelData.source || ""
sources: modelData.sources || []
title: modelData.title title: modelData.title
artist: modelData.artist artist: modelData.artist
artistId: modelData.artistId artistId: modelData.artistId
@@ -882,6 +884,8 @@ Rectangle {
readonly property bool localPinned: readonly property bool localPinned:
modelData.isLocalAlbum === true modelData.isLocalAlbum === true
localMode: localPinned localMode: localPinned
source: modelData.source || ""
sources: modelData.sources || []
quickViewAffordance: true quickViewAffordance: true
pinAffordance: true pinAffordance: true
albumId: modelData.id albumId: modelData.id
@@ -1260,6 +1264,8 @@ Rectangle {
id: albumCardComp id: albumCardComp
AlbumCard { AlbumCard {
albumId: modelData.album.id albumId: modelData.album.id
source: modelData.album.source || ""
sources: modelData.album.sources || []
title: modelData.album.title title: modelData.album.title
artist: modelData.album.artist artist: modelData.album.artist
artistId: modelData.album.artistId artistId: modelData.album.artistId
+2
View File
@@ -57,6 +57,8 @@ Column {
model: root.items model: root.items
delegate: AlbumCard { delegate: AlbumCard {
albumId: modelData.id albumId: modelData.id
source: modelData.source || ""
sources: modelData.sources || []
title: modelData.title title: modelData.title
artist: modelData.artist artist: modelData.artist
artistId: modelData.artistId artistId: modelData.artistId
@@ -110,12 +110,10 @@ Item {
// Local/server rows have no portable catalog URL; retain their // Local/server rows have no portable catalog URL; retain their
// resolved artwork snapshot for the per-machine Pinned rail. // resolved artwork snapshot for the per-machine Pinned rail.
pinArtworkUrl: artworkUrl !== "" ? artworkUrl : artSource pinArtworkUrl: artworkUrl !== "" ? artworkUrl : artSource
// The source word is ALWAYS passed (the "Block this album" menu // Local source badges remain visible on every album. The toolbar
// gate reads it); the toolbar's show-local toggle only decides // may additionally show the catalog mark in the mixed feed.
// whether the BADGE is drawn — FavoritesView.slint:1097
// `show-source-badge: LibraryAllState.show-local`, the same gate
// the track card below already uses.
source: cell.item.source source: cell.item.source
sources: cell.item.sources || []
showSourceBadge: cell.view.showLocal showSourceBadge: cell.view.showLocal
} }
} }
+15 -3
View File
@@ -89,6 +89,8 @@ pub struct HomeCard {
/// it; every other kind leaves it empty and it stays off the wire. /// it; every other kind leaves it empty and it stays off the wire.
#[serde(default, skip_serializing_if = "String::is_empty")] #[serde(default, skip_serializing_if = "String::is_empty")]
pub source: String, pub source: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<String>,
#[serde(rename = "isPinned", default)] #[serde(rename = "isPinned", default)]
pub is_pinned: bool, pub is_pinned: bool,
/// Heart state at BUILD time, from `fav_cache_qt` — the row's own kind /// Heart state at BUILD time, from `fav_cache_qt` — the row's own kind
@@ -385,6 +387,11 @@ fn pinned_cards() -> Vec<HomeCard> {
.parse::<u64>() .parse::<u64>()
.map(crate::playlist_qt::is_following) .map(crate::playlist_qt::is_following)
.unwrap_or(false), .unwrap_or(false),
source: if is_local_album {
pinned_local_source(&p.id)
} else {
String::new()
},
id: p.id, id: p.id,
title: p.title, title: p.title,
artist: p.subtitle.clone(), artist: p.subtitle.clone(),
@@ -478,7 +485,7 @@ pub(crate) fn publish_pinned() {
); );
crate::spawn(async move { crate::spawn(async move {
let checked = tokio::task::spawn_blocking(move || { let checked = tokio::task::spawn_blocking(move || {
crate::local_albums::existing_favorite_album_ids_blocking(local_candidates) crate::local_albums::existing_favorite_album_sources_blocking(local_candidates)
}) })
.await; .await;
if PINNED_PUBLISH_REVISION.load(Ordering::Acquire) != revision { if PINNED_PUBLISH_REVISION.load(Ordering::Acquire) != revision {
@@ -499,8 +506,13 @@ pub(crate) fn publish_pinned() {
}; };
let visible = cards let visible = cards
.into_iter() .into_iter()
.filter(|card| { .filter_map(|mut card| {
!card.is_local_album || existing.as_ref().is_none_or(|ids| ids.contains(&card.id)) if card.is_local_album {
if let Some(sources) = existing.as_ref() {
card.sources = sources.get(&card.id)?.clone();
}
}
Some(card)
}) })
.collect(); .collect();
publish_pinned_cards(visible, revision, true); publish_pinned_cards(visible, revision, true);
+4
View File
@@ -56,6 +56,8 @@ pub struct FeedItem {
pub kind: String, // track | album | artist | playlist | label pub kind: String, // track | album | artist | playlist | label
pub group: String, // favorites | following | purchases | local pub group: String, // favorites | following | purchases | local
pub source: String, // qobuz | local | plex pub source: String, // qobuz | local | plex
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<String>,
pub id: String, pub id: String,
pub title: String, pub title: String,
pub subtitle: String, pub subtitle: String,
@@ -1299,11 +1301,13 @@ fn all_local_feed_blocking() -> Vec<FeedItem> {
"subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga" => "subsonic", "subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga" => "subsonic",
_ => "local", _ => "local",
}; };
let sources = crate::local_rows::album_badge_sources(&a);
out.push( out.push(
FeedItem { FeedItem {
kind: "album".into(), kind: "album".into(),
group: "local".into(), group: "local".into(),
source: source.into(), source: source.into(),
sources,
subtitle: a.artist.clone(), subtitle: a.artist.clone(),
artist: a.artist, artist: a.artist,
image_url: a.artwork_path.unwrap_or_default(), image_url: a.artwork_path.unwrap_or_default(),
+38 -9
View File
@@ -136,17 +136,34 @@ pub fn load_albums_blocking() -> Result<Vec<AlbumRow>, String> {
pub fn existing_favorite_album_ids_blocking( pub fn existing_favorite_album_ids_blocking(
candidates: Vec<(String, String)>, candidates: Vec<(String, String)>,
) -> Result<HashSet<String>, String> { ) -> Result<HashSet<String>, String> {
existing_favorite_album_sources_blocking(candidates).map(|rows| rows.into_keys().collect())
}
/// Preserve the badge sources from the rows already read by the availability
/// check. A logical album key cannot identify its physical sources by itself.
pub(crate) fn existing_favorite_album_sources_blocking(
candidates: Vec<(String, String)>,
) -> Result<HashMap<String, Vec<String>>, String> {
if candidates.is_empty() { if candidates.is_empty() {
return Ok(HashSet::new()); return Ok(HashMap::new());
} }
if candidates.iter().any(|(id, _)| id.starts_with("logical:")) { if candidates.iter().any(|(id, _)| id.starts_with("logical:")) {
return load_albums_blocking().map(|rows| rows.into_iter().map(|row| row.id).collect()); return load_albums_blocking()
.map(|rows| rows.into_iter().map(|row| (row.id, row.sources)).collect());
} }
let mut existing = HashSet::new(); let mut existing = HashMap::new();
for (id, _) in candidates.iter().filter(|(_, source)| source == "plex") { for (id, _) in candidates.iter().filter(|(_, source)| source == "plex") {
if crate::local_plex::is_configured() && !crate::local_plex::album_tracks(id).is_empty() { if crate::local_plex::is_configured() {
existing.insert(id.clone()); let tracks = crate::local_plex::album_tracks(id);
if !tracks.is_empty() {
existing.insert(
id.clone(),
crate::local_rows::source_badges(
tracks.iter().map(|track| track.source.as_deref()),
),
);
}
} }
} }
@@ -154,8 +171,15 @@ pub fn existing_favorite_album_ids_blocking(
.iter() .iter()
.filter(|(_, source)| matches!(source.as_str(), "jellyfin" | "subsonic")) .filter(|(_, source)| matches!(source.as_str(), "jellyfin" | "subsonic"))
{ {
if crate::media_servers_qt::album_tracks(id).is_some_and(|tracks| !tracks.is_empty()) { if let Some(tracks) =
existing.insert(id.clone()); crate::media_servers_qt::album_tracks(id).filter(|tracks| !tracks.is_empty())
{
existing.insert(
id.clone(),
crate::local_rows::source_badges(
tracks.iter().map(|track| track.source.as_deref()),
),
);
} }
} }
@@ -169,7 +193,7 @@ pub fn existing_favorite_album_ids_blocking(
} }
let mode = group_mode(); let mode = group_mode();
let found = with_db(|db| { let found = with_db(|db| {
let mut found = HashSet::new(); let mut found = HashMap::new();
for id in &local_ids { for id in &local_ids {
let tracks = match mode { let tracks = match mode {
AlbumGroupMode::Metadata => { AlbumGroupMode::Metadata => {
@@ -183,7 +207,12 @@ pub fn existing_favorite_album_ids_blocking(
AlbumGroupMode::Folder => db.get_album_tracks(id)?, AlbumGroupMode::Folder => db.get_album_tracks(id)?,
}; };
if !tracks.is_empty() { if !tracks.is_empty() {
found.insert(id.clone()); found.insert(
id.clone(),
crate::local_rows::source_badges(
tracks.iter().map(|track| track.source.as_deref()),
),
);
} }
} }
Ok(found) Ok(found)
+51 -20
View File
@@ -538,6 +538,33 @@ pub fn badge_source_raw(raw: Option<&str>) -> String {
} }
} }
/// The same badge vocabulary on Local Library cards, mixed feeds and pins.
/// Routing keeps its own source discriminator; these words are presentation.
pub(crate) fn source_badges<'a>(sources: impl IntoIterator<Item = Option<&'a str>>) -> Vec<String> {
let mut badges = Vec::new();
for source in sources {
let raw = badge_source_raw(source);
let badge = if raw.is_empty() {
badge_source(source)
} else {
raw
};
if !badges.contains(&badge) {
badges.push(badge);
}
}
badges
}
pub(crate) fn album_badge_sources(album: &LocalAlbum) -> Vec<String> {
let sources = if album.sources.is_empty() {
std::slice::from_ref(&album.source)
} else {
album.sources.as_slice()
};
source_badges(sources.iter().map(|word| Some(word.as_str())))
}
/// `(owning source, raw token)` for a row's artwork — the pair the artwork /// `(owning source, raw token)` for a row's artwork — the pair the artwork
/// window needs in order to resolve it WITHOUT guessing. /// window needs in order to resolve it WITHOUT guessing.
/// ///
@@ -601,26 +628,7 @@ pub fn map_album_with_artists(
.unwrap_or(0); .unwrap_or(0);
let source = badge_source(Some(a.source.as_str())); let source = badge_source(Some(a.source.as_str()));
let source_raw = badge_source_raw(Some(a.source.as_str())); let source_raw = badge_source_raw(Some(a.source.as_str()));
let mut sources = if a.sources.is_empty() { let sources = album_badge_sources(&a);
vec![if source_raw.is_empty() {
source.clone()
} else {
source_raw.clone()
}]
} else {
a.sources
.iter()
.map(|value| {
let raw = badge_source_raw(Some(value));
if raw.is_empty() {
badge_source(Some(value))
} else {
raw
}
})
.collect::<Vec<_>>()
};
sources.dedup();
let favoriteable = album_favorite_source(&sources).is_some(); let favoriteable = album_favorite_source(&sources).is_some();
let is_favorite = favoriteable && crate::library_qt::is_local_favorite("album", &a.id); let is_favorite = favoriteable && crate::library_qt::is_local_favorite("album", &a.id);
AlbumRow { AlbumRow {
@@ -789,6 +797,29 @@ mod tests {
); );
} }
#[test]
fn album_badges_keep_physical_origins_without_changing_routing_words() {
assert_eq!(
super::source_badges([None, Some("user"), Some("local")]),
["local"]
);
assert_eq!(
super::source_badges([
Some("qobuz_download"),
Some("qobuz_purchase"),
Some("plex"),
Some("jellyfin"),
Some("navidrome"),
Some("gonic")
]),
["offline", "qobuz_purchase", "plex", "jellyfin", "subsonic"]
);
// The menu/filter discriminator stays folded; only badge data keeps
// the purchase identity. The card must not confuse a downloaded copy
// with a catalog purchase that has never been downloaded.
assert_eq!(super::badge_source(Some("qobuz_purchase")), "offline");
}
#[test] #[test]
fn portable_artwork_ref_preserves_source_and_scope() { fn portable_artwork_ref_preserves_source_and_scope() {
let jellyfin = LocalTrack { let jellyfin = LocalTrack {