fix(qt): custom album covers propagate to every surface
The per-builder sweep was whack-a-mole — the report caught empty cards in Home > Recently Played Albums and the NPB, and mosaics still showing the Qobuz art. Overrides are keyed by album id but most surfaces carry only the art URL, so the link is now the artwork HASH: every size variant of one Qobuz cover shares its filename stem. Setting a cover records hash -> path in a Qt-side store (custom_cover_keys.json — same reason as the playlist store: the Slint file round-trips known keys only), the album page backfills it for covers set before the map existed, and artwork_qt's cached_path / cached_raw_path answer the override for any URL whose hash has one — cards, NPB, MPRIS, queue, collage tiles, no per-surface wiring. download_missing skips overridden URLs. km
This commit is contained in:
5 files changed
+125
-5
No files matched your search
@@ -1376,7 +1376,7 @@ Rectangle {
|
||||
onClicked: {
|
||||
coverMenu.close()
|
||||
var a = modelData.action
|
||||
if (a === "add") QbzAlbum.coverAddCustom(header.id)
|
||||
if (a === "add") QbzAlbum.coverAddCustom(header.id, header.artUrl)
|
||||
else if (a === "remove") QbzAlbum.coverRemoveCustom(header.id)
|
||||
else if (a === "view") coverLightbox.openWith(root.bestCoverSource())
|
||||
else if (a === "browser") QbzShell.openExternalUrl(header.artUrl)
|
||||
|
||||
@@ -74,7 +74,7 @@ pub mod qbz_album_bridge {
|
||||
/// Cover right-click menu (cover_artwork_qt.rs): pick / clear the
|
||||
/// custom cover override, save the artwork to disk.
|
||||
#[qinvokable]
|
||||
fn cover_add_custom(self: Pin<&mut QbzAlbum>, album_id: QString);
|
||||
fn cover_add_custom(self: Pin<&mut QbzAlbum>, album_id: QString, artwork_url: QString);
|
||||
#[qinvokable]
|
||||
fn cover_remove_custom(self: Pin<&mut QbzAlbum>, album_id: QString);
|
||||
#[qinvokable]
|
||||
@@ -164,8 +164,8 @@ impl qbz_album_bridge::QbzAlbum {
|
||||
pub fn download_booklet(self: Pin<&mut Self>) {
|
||||
crate::album_qt::download_booklet();
|
||||
}
|
||||
pub fn cover_add_custom(self: Pin<&mut Self>, album_id: QString) {
|
||||
crate::cover_artwork_qt::add_custom_cover(album_id.to_string());
|
||||
pub fn cover_add_custom(self: Pin<&mut Self>, album_id: QString, artwork_url: QString) {
|
||||
crate::cover_artwork_qt::add_custom_cover(album_id.to_string(), artwork_url.to_string());
|
||||
}
|
||||
pub fn cover_remove_custom(self: Pin<&mut Self>, album_id: QString) {
|
||||
crate::cover_artwork_qt::remove_custom_cover(album_id.to_string());
|
||||
|
||||
@@ -623,6 +623,13 @@ pub async fn load_album(runtime: &Arc<AppRuntime<LoggingAdapter>>, album_id: &st
|
||||
// every build so the header swaps image source and the cover menu flips
|
||||
// its Add/Change/Remove rows.
|
||||
let custom_cover = crate::cover_artwork_qt::album_cover(&album.id).unwrap_or_default();
|
||||
// Backfill the hash -> override link for covers set before the
|
||||
// propagation map existed (custom_cover_keys.json).
|
||||
if !custom_cover.is_empty() {
|
||||
if let Some(url) = album.image.best() {
|
||||
crate::cover_artwork_qt::note_override_key(url, &custom_cover);
|
||||
}
|
||||
}
|
||||
|
||||
let header = AlbumHeader {
|
||||
show_external_links: links.is_some(),
|
||||
|
||||
@@ -261,6 +261,13 @@ fn disk_path(key: &str, fetch: &str) -> Option<PathBuf> {
|
||||
/// be resolved without the network, or at all). SYNCHRONOUS — no download,
|
||||
/// and on a repeat call no SQLite either.
|
||||
pub fn cached_path(url: &str) -> String {
|
||||
// A custom cover registered for this artwork's hash wins over every
|
||||
// cached/remote variant (cover_artwork_qt::override_for_url), so cards,
|
||||
// the NPB, the queue and mosaics all render the user's art.
|
||||
let custom = crate::cover_artwork_qt::override_for_url(url);
|
||||
if !custom.is_empty() {
|
||||
return file_url(&custom);
|
||||
}
|
||||
match classify(url) {
|
||||
ArtUrl::Empty | ArtUrl::PlexUnconfigured => String::new(),
|
||||
// Already on disk: this is the whole fix for local covers. Stat it so
|
||||
@@ -293,6 +300,12 @@ pub fn cached_path(url: &str) -> String {
|
||||
/// Local classes deliberately return `None` — they were never in the disk
|
||||
/// cache to begin with, and their caller already has the path.
|
||||
pub fn cached_raw_path(url: &str) -> Option<PathBuf> {
|
||||
// The custom-cover override answers here too (MPRIS art and the other
|
||||
// raw-path consumers must not show the Qobuz art the UI replaced).
|
||||
let custom = crate::cover_artwork_qt::override_for_url(url);
|
||||
if !custom.is_empty() {
|
||||
return Some(PathBuf::from(custom));
|
||||
}
|
||||
match classify(url) {
|
||||
ArtUrl::Http(fetch) | ArtUrl::Plex(fetch) => disk_path(url, &fetch),
|
||||
ArtUrl::Empty | ArtUrl::PlexUnconfigured | ArtUrl::LocalFile(_) => None,
|
||||
@@ -331,6 +344,10 @@ pub async fn download_missing(urls: Vec<String>) {
|
||||
let mut jobs: Vec<(String, String)> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
for url in urls {
|
||||
// Overridden art never needs a fetch — cached_path answers it.
|
||||
if !crate::cover_artwork_qt::override_for_url(&url).is_empty() {
|
||||
continue;
|
||||
}
|
||||
match classify(&url) {
|
||||
ArtUrl::Http(fetch) | ArtUrl::Plex(fetch) => {
|
||||
if seen.insert(fetch.clone()) {
|
||||
|
||||
@@ -94,7 +94,7 @@ fn remove_album_cover(album_id: &str) {
|
||||
/// "Add cover" / "Change cover": native picker, persist the picked path,
|
||||
/// then re-open the album so the header repaints from the override.
|
||||
/// Cancel = no-op, no toast (the picker's own affordance).
|
||||
pub fn add_custom_cover(album_id: String) {
|
||||
pub fn add_custom_cover(album_id: String, artwork_url: String) {
|
||||
if album_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -110,6 +110,9 @@ pub fn add_custom_cover(album_id: String) {
|
||||
// Blocking IO, but tiny (read + write of a small JSON map); the spawn
|
||||
// keeps it off the Qt thread, same as the reference's handler.
|
||||
let id = album_id.clone();
|
||||
// The hash -> override link is what lets the rest of the app (cards,
|
||||
// NPB, queue, mosaics) render this art for this album.
|
||||
note_override_key(&artwork_url, &path);
|
||||
if tokio::task::spawn_blocking(move || {
|
||||
set_album_cover(&id, &path);
|
||||
})
|
||||
@@ -295,3 +298,96 @@ pub fn remove_custom_playlist_cover(playlist_id: String) {
|
||||
crate::open_playlist(playlist_id);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Propagation layer: artwork-hash -> override (the "everywhere" half)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Album overrides are keyed by album id, but most of the app never sees the
|
||||
// id — cards, the NPB, the queue and mosaics carry only the art URL. The
|
||||
// bridge between the two is the Qobuz cover hash: every size variant of one
|
||||
// artwork shares the stem (`.../covers/tr/9l/<hash>_<size>.jpg`), so mapping
|
||||
// hash -> override path lets the ARTWORK layer answer with the override no
|
||||
// matter which surface asked or which size it wanted.
|
||||
//
|
||||
// The map lives in a Qt-side file (custom_cover_keys.json) for the same
|
||||
// reason the playlist store does: the Slint store round-trips only known
|
||||
// keys and would drop an extension on its next write.
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct KeyStore {
|
||||
#[serde(default)]
|
||||
keys: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn key_store_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("custom_cover_keys.json"))
|
||||
}
|
||||
|
||||
fn load_key_store() -> KeyStore {
|
||||
let Some(path) = key_store_path() else {
|
||||
return KeyStore::default();
|
||||
};
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => KeyStore::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_key_store(store: &KeyStore) {
|
||||
let Some(path) = key_store_path() else {
|
||||
return;
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_vec_pretty(store) {
|
||||
let _ = std::fs::write(&path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// The artwork hash of a Qobuz cover URL: the filename stem before the size
|
||||
/// suffix (`<hash>_600.jpg` -> `<hash>`). None for non-cover URLs.
|
||||
fn art_key(url: &str) -> Option<String> {
|
||||
let stem = url
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.and_then(|f| f.rsplit_once('.').map(|(s, _)| s).or(Some(f)))
|
||||
.unwrap_or("");
|
||||
if stem.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (hash, _size) = stem.rsplit_once('_')?;
|
||||
if hash.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(hash.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Record `hash -> override path` for the album whose current art URL is
|
||||
/// `artwork_url`. Called when a cover is set and on album-page load as a
|
||||
/// backfill for covers set before this map existed.
|
||||
pub fn note_override_key(artwork_url: &str, override_path: &str) {
|
||||
let Some(key) = art_key(artwork_url) else {
|
||||
return;
|
||||
};
|
||||
let mut store = load_key_store();
|
||||
if store.keys.get(&key).map(|s| s.as_str()) == Some(override_path) {
|
||||
return;
|
||||
}
|
||||
store.keys.insert(key, override_path.to_string());
|
||||
write_key_store(&store);
|
||||
}
|
||||
|
||||
/// The override path for any art URL whose hash has one ("" when none or the
|
||||
/// file is gone). `artwork_qt::cached_path` consults this BEFORE the network
|
||||
/// cache, so every surface renders the custom art.
|
||||
pub fn override_for_url(url: &str) -> String {
|
||||
let Some(key) = art_key(url) else {
|
||||
return String::new();
|
||||
};
|
||||
match load_key_store().keys.get(&key) {
|
||||
Some(p) if std::path::Path::new(p).is_file() => p.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user