diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml index c2f5b1181..7e8a3a756 100644 --- a/crates/qbz-qt/qml/cards/AlbumCard.qml +++ b/crates/qbz-qt/qml/cards/AlbumCard.qml @@ -102,11 +102,12 @@ Rectangle { || 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 - // grid (LocalLibraryView.slint:1267). + // Hosts can also show catalog badges. Local Library provenance is always + // visible, even when a host's optional catalog-badge toggle is off. 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 // artist, and ONLY there (discover/AlbumCard.slint:508-513). Every // 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 // for the Plex mark. Rectangle { - visible: root.showSourceBadge && root.badgeSources.length > 0 + visible: (root.showSourceBadge || root.hasLocalSourceBadge) + && root.badgeSources.length > 0 x: parent.width - width - 6 y: parent.height - height - 6 width: sourceBadgeRow.implicitWidth diff --git a/crates/qbz-qt/qml/views/AlbumCollection.qml b/crates/qbz-qt/qml/views/AlbumCollection.qml index 984e167e6..4e5324417 100644 --- a/crates/qbz-qt/qml/views/AlbumCollection.qml +++ b/crates/qbz-qt/qml/views/AlbumCollection.qml @@ -382,6 +382,8 @@ Column { ? sg.reveal : 1.0 AlbumCard { albumId: gcell.modelData.id + source: gcell.modelData.source || "" + sources: gcell.modelData.sources || [] title: gcell.modelData.title artist: gcell.modelData.artist artistId: gcell.modelData.artistId @@ -557,6 +559,8 @@ Column { AlbumCard { id: mountedCard albumId: cell.cardData.id + source: cell.cardData.source || "" + sources: cell.cardData.sources || [] title: cell.cardData.title artist: cell.cardData.artist artistId: cell.cardData.artistId diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index cdb85ed91..cdf546f63 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -601,6 +601,8 @@ Rectangle { AlbumCard { albumId: modelData.id + source: modelData.source || "" + sources: modelData.sources || [] title: modelData.title artist: modelData.artist artistId: modelData.artistId @@ -882,6 +884,8 @@ Rectangle { readonly property bool localPinned: modelData.isLocalAlbum === true localMode: localPinned + source: modelData.source || "" + sources: modelData.sources || [] quickViewAffordance: true pinAffordance: true albumId: modelData.id @@ -1260,6 +1264,8 @@ Rectangle { id: albumCardComp AlbumCard { albumId: modelData.album.id + source: modelData.album.source || "" + sources: modelData.album.sources || [] title: modelData.album.title artist: modelData.album.artist artistId: modelData.album.artistId diff --git a/crates/qbz-qt/qml/views/SectionRail.qml b/crates/qbz-qt/qml/views/SectionRail.qml index 9612c665a..80639280f 100644 --- a/crates/qbz-qt/qml/views/SectionRail.qml +++ b/crates/qbz-qt/qml/views/SectionRail.qml @@ -57,6 +57,8 @@ Column { model: root.items delegate: AlbumCard { albumId: modelData.id + source: modelData.source || "" + sources: modelData.sources || [] title: modelData.title artist: modelData.artist artistId: modelData.artistId diff --git a/crates/qbz-qt/qml/views/library/FeedGridCell.qml b/crates/qbz-qt/qml/views/library/FeedGridCell.qml index 053192417..2ef57d366 100644 --- a/crates/qbz-qt/qml/views/library/FeedGridCell.qml +++ b/crates/qbz-qt/qml/views/library/FeedGridCell.qml @@ -110,12 +110,10 @@ Item { // Local/server rows have no portable catalog URL; retain their // resolved artwork snapshot for the per-machine Pinned rail. pinArtworkUrl: artworkUrl !== "" ? artworkUrl : artSource - // The source word is ALWAYS passed (the "Block this album" menu - // gate reads it); the toolbar's show-local toggle only decides - // whether the BADGE is drawn — FavoritesView.slint:1097 - // `show-source-badge: LibraryAllState.show-local`, the same gate - // the track card below already uses. + // Local source badges remain visible on every album. The toolbar + // may additionally show the catalog mark in the mixed feed. source: cell.item.source + sources: cell.item.sources || [] showSourceBadge: cell.view.showLocal } } diff --git a/crates/qbz-qt/src/home_qt.rs b/crates/qbz-qt/src/home_qt.rs index 56bebdc83..d10535ded 100644 --- a/crates/qbz-qt/src/home_qt.rs +++ b/crates/qbz-qt/src/home_qt.rs @@ -89,6 +89,8 @@ pub struct HomeCard { /// it; every other kind leaves it empty and it stays off the wire. #[serde(default, skip_serializing_if = "String::is_empty")] pub source: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, #[serde(rename = "isPinned", default)] pub is_pinned: bool, /// Heart state at BUILD time, from `fav_cache_qt` — the row's own kind @@ -385,6 +387,11 @@ fn pinned_cards() -> Vec { .parse::() .map(crate::playlist_qt::is_following) .unwrap_or(false), + source: if is_local_album { + pinned_local_source(&p.id) + } else { + String::new() + }, id: p.id, title: p.title, artist: p.subtitle.clone(), @@ -478,7 +485,7 @@ pub(crate) fn publish_pinned() { ); crate::spawn(async 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; if PINNED_PUBLISH_REVISION.load(Ordering::Acquire) != revision { @@ -499,8 +506,13 @@ pub(crate) fn publish_pinned() { }; let visible = cards .into_iter() - .filter(|card| { - !card.is_local_album || existing.as_ref().is_none_or(|ids| ids.contains(&card.id)) + .filter_map(|mut card| { + if card.is_local_album { + if let Some(sources) = existing.as_ref() { + card.sources = sources.get(&card.id)?.clone(); + } + } + Some(card) }) .collect(); publish_pinned_cards(visible, revision, true); diff --git a/crates/qbz-qt/src/library_qt.rs b/crates/qbz-qt/src/library_qt.rs index 8919daa7f..e0c620337 100644 --- a/crates/qbz-qt/src/library_qt.rs +++ b/crates/qbz-qt/src/library_qt.rs @@ -56,6 +56,8 @@ pub struct FeedItem { pub kind: String, // track | album | artist | playlist | label pub group: String, // favorites | following | purchases | local pub source: String, // qobuz | local | plex + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, pub id: String, pub title: String, pub subtitle: String, @@ -1299,11 +1301,13 @@ fn all_local_feed_blocking() -> Vec { "subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga" => "subsonic", _ => "local", }; + let sources = crate::local_rows::album_badge_sources(&a); out.push( FeedItem { kind: "album".into(), group: "local".into(), source: source.into(), + sources, subtitle: a.artist.clone(), artist: a.artist, image_url: a.artwork_path.unwrap_or_default(), diff --git a/crates/qbz-qt/src/local_albums.rs b/crates/qbz-qt/src/local_albums.rs index 1075d85aa..ec339c215 100644 --- a/crates/qbz-qt/src/local_albums.rs +++ b/crates/qbz-qt/src/local_albums.rs @@ -136,17 +136,34 @@ pub fn load_albums_blocking() -> Result, String> { pub fn existing_favorite_album_ids_blocking( candidates: Vec<(String, String)>, ) -> Result, 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>, String> { if candidates.is_empty() { - return Ok(HashSet::new()); + return Ok(HashMap::new()); } 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") { - if crate::local_plex::is_configured() && !crate::local_plex::album_tracks(id).is_empty() { - existing.insert(id.clone()); + if crate::local_plex::is_configured() { + 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() .filter(|(_, source)| matches!(source.as_str(), "jellyfin" | "subsonic")) { - if crate::media_servers_qt::album_tracks(id).is_some_and(|tracks| !tracks.is_empty()) { - existing.insert(id.clone()); + if let Some(tracks) = + 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 found = with_db(|db| { - let mut found = HashSet::new(); + let mut found = HashMap::new(); for id in &local_ids { let tracks = match mode { AlbumGroupMode::Metadata => { @@ -183,7 +207,12 @@ pub fn existing_favorite_album_ids_blocking( AlbumGroupMode::Folder => db.get_album_tracks(id)?, }; 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) diff --git a/crates/qbz-qt/src/local_rows.rs b/crates/qbz-qt/src/local_rows.rs index 8a3f5a8ad..e22a827ff 100644 --- a/crates/qbz-qt/src/local_rows.rs +++ b/crates/qbz-qt/src/local_rows.rs @@ -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>) -> Vec { + 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 { + 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 /// window needs in order to resolve it WITHOUT guessing. /// @@ -601,26 +628,7 @@ pub fn map_album_with_artists( .unwrap_or(0); let source = badge_source(Some(a.source.as_str())); let source_raw = badge_source_raw(Some(a.source.as_str())); - let mut sources = if a.sources.is_empty() { - 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::>() - }; - sources.dedup(); + let sources = album_badge_sources(&a); let favoriteable = album_favorite_source(&sources).is_some(); let is_favorite = favoriteable && crate::library_qt::is_local_favorite("album", &a.id); 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] fn portable_artwork_ref_preserves_source_and_scope() { let jellyfin = LocalTrack {