refactor(qt): the Local Library art index carries an ArtRef, not a string

Design 02 §9 stage 4, the Local Library half. `local_state.art_index`
becomes `HashMap<String, ArtRef>`: the row mappers register what the
row's OWN source says its artwork token means, at the last moment that
provenance still exists, and the windowed pipeline reads that answer
instead of re-deriving it.

`artwork_qt::classify` is gone from every caller outside artwork_qt
itself. It recognised a Plex thumb only because `local_plex::
is_thumb_path` was bolted onto it, so a token from any future source
that starts with `/` — a Jellyfin `/Items/<id>/Images/Primary` — matched
its filesystem-path arm and the pipeline opened it from disk: blank
cover, no error, nothing in the log. That is bug 3.

Producers wired: both row mappers, the folder tree (two sites), the
ephemeral pane, the album-detail header and the Artists rail.

The Artists rail needed real work rather than a cast. Its portrait comes
from one of three chain tiers (custom image, Plex portrait, borrowed
album cover) while `MergedArtist.source` describes the artist's LIBRARY
provenance and is legitimately "mixed" — which is not a source a token
can be interpreted by. `AlbumCredit` now carries its album's source word
so a borrowed cover keeps that cover's provenance, and `MergedArtist`
reports `image_source` beside `source`.

`fetch_plex_misses` carries `(artKey, fetch url, cache key)` instead of
collapsing the last two. They are not the same string — a Plex url is
re-tokenized every pass and a Qobuz one carries a size query, while the
key stays put — and collapsing them re-downloads covers already on disk.
`artwork_qt` grows `cached_path_for` / `download_pairs`, the
classification-free entries, sharing one download loop with
`download_missing` rather than a second copy of the pool and the
error-page guard.

Still on the old path and NOT in this commit: the Home / queue /
now-playing surfaces, where the string arrives as `HomeCard.art_url` or
`QueueTrack.artwork_url`. Those need IC-7 (HomeCard has no source field)
and the eight hardcoded `"source": "qobuz"` QML payloads first.

423/423.

cc
This commit is contained in:
vicrodh committed 2026-08-20 01:44:31 -06:00
1 parent ffb35085c9
commit 68235a4012
10 files changed
+284 -83

No files matched your search

+53
View File
@@ -308,6 +308,49 @@ pub fn cached_path(url: &str) -> String {
}
}
/// [`cached_path`] for a cover whose `(cache_key, fetch url)` pair is ALREADY
/// resolved — i.e. one that arrived as a `qbz_source::ArtRef::Fetch`.
///
/// The difference is only that it does not call [`classify`], because it does
/// not have to: the source that owns the row already said what its token means
/// (design 02 §9 stage 4). `classify` remains for the surfaces that still hand
/// this module a bare string.
///
/// The `cache_key` keeps doing both jobs it did before — the custom-cover
/// override is looked up under it, and it is the memo key `disk_path` stores
/// beside the fetch url — so a cover cached by the old code path is still a
/// hit.
pub fn cached_path_for(cache_key: &str, fetch: &str) -> String {
let custom = crate::cover_artwork_qt::override_for_url(cache_key);
if !custom.is_empty() {
return file_url(&custom);
}
disk_path(cache_key, fetch)
.map(|p| file_url(&p.to_string_lossy()))
.unwrap_or_default()
}
/// [`download_missing`] for pairs that need no classification: each job is
/// `(cache_key, fetch url)`, deduped by the FETCH url so two rows sharing a
/// cover download it once.
pub async fn download_pairs(pairs: Vec<(String, String)>) {
let mut jobs: Vec<(String, String)> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for (key, fetch) in pairs {
// Overridden art never needs a fetch — `cached_path_for` answers it.
if !crate::cover_artwork_qt::override_for_url(&key).is_empty() {
continue;
}
if fetch.is_empty() {
continue;
}
if seen.insert(fetch.clone()) {
jobs.push((key, fetch));
}
}
fetch_jobs(jobs).await;
}
/// The cached file for a FETCHABLE cover (http/https, and Plex thumbs once
/// tokenized) as a RAW filesystem path — `None` when it is not on disk.
///
@@ -384,6 +427,16 @@ pub async fn download_missing(urls: Vec<String>) {
ArtUrl::Empty => {}
}
}
fetch_jobs(jobs).await;
}
/// The shared download loop: `(cache_key, fetch url)` jobs, already deduped.
///
/// Split out of [`download_missing`] so [`download_pairs`] — the
/// classification-free entry the `ArtRef::Fetch` pipeline uses — reuses the
/// bounded pool, the error-page guard and the cache store rather than growing
/// a second copy of them.
async fn fetch_jobs(jobs: Vec<(String, String)>) {
if jobs.is_empty() {
return;
}
+3 -1
View File
@@ -184,7 +184,9 @@ pub fn version_doc(id: &str, index: usize) -> Option<AlbumDetailDoc> {
.iter()
.find_map(|t| t.artwork_path.as_ref().filter(|p| !p.is_empty()))
{
art.entry(row.art_key.clone()).or_insert_with(|| p.clone());
let source = tracks.first().and_then(|t| t.source.as_deref());
art.entry(row.art_key.clone())
.or_insert_with(|| crate::local_rows::art_ref(source, p));
}
tracks.iter().map(|t| map_track(t, art)).collect::<Vec<TrackRow>>()
});
+5 -1
View File
@@ -174,6 +174,7 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
let credits: Vec<AlbumCredit<'_>> = albums
.iter()
.map(|a| AlbumCredit {
source: a.source.as_str(),
id: a.id.as_str(),
artist: a.artist.as_str(),
all_artists: a.all_artists.as_str(),
@@ -195,7 +196,10 @@ pub fn load_artists_blocking() -> Result<Vec<ArtistRow>, String> {
.map(|m| {
let key = artist_key(&m.name);
if !m.image_path.is_empty() {
art.insert(key.clone(), m.image_path);
art.insert(
key.clone(),
crate::local_rows::art_ref(Some(&m.image_source), &m.image_path),
);
}
ArtistRow {
art_key: key,
+44 -9
View File
@@ -102,6 +102,11 @@ pub struct AlbumCredit<'a> {
pub all_artists: &'a str,
/// On-disk cover / Plex thumb path ("" when the album has none).
pub artwork_path: &'a str,
/// The album's own source word, so a portrait borrowed from its cover
/// keeps that cover's provenance. Without it the artwork index would have
/// to guess what `artwork_path` is from its characters, which is exactly
/// the sniffing design 02 §9 stage 4 removes.
pub source: &'a str,
}
/// Does this album credit the artist whose normalized name is `nsel` — as
@@ -175,7 +180,16 @@ pub struct MergedArtist {
/// Portrait SOURCE (custom/cached url or path, Plex thumb, album cover);
/// "" when nothing is known and the row falls back to the placeholder.
pub image_path: String,
/// "local" | "plex" | "mixed".
/// WHICH source [`MergedArtist::image_path`] came from — never "mixed".
///
/// Distinct from [`MergedArtist::source`] on purpose: that one describes
/// the artist's LIBRARY provenance and is legitimately "mixed" when a name
/// has both local and Plex rows. A portrait, however, comes from exactly
/// ONE of the three chain tiers, and the artwork index needs to know which
/// — "mixed" is not a source a token can be interpreted by.
pub image_source: String,
/// "local" | "plex" | "mixed" — the artist's LIBRARY provenance, which the
/// Artists rail renders as a hint.
pub source: String,
}
@@ -205,13 +219,15 @@ pub fn merge_artists(
.iter()
.map(|(k, v)| (normalize_artist(k), v.clone()))
.collect();
let mut album_thumbs: HashMap<String, String> = HashMap::new();
// (path, the album's OWN source word) — the tier is Plex-gated but the
// album it borrows from can still be a local one, so the word travels.
let mut album_thumbs: HashMap<String, (String, String)> = HashMap::new();
if album_thumb_fallback {
for al in albums {
if !al.artwork_path.is_empty() {
album_thumbs
.entry(normalize_artist(al.artist))
.or_insert_with(|| al.artwork_path.to_string());
.or_insert_with(|| (al.artwork_path.to_string(), al.source.to_string()));
}
}
}
@@ -270,17 +286,25 @@ pub fn merge_artists(
};
(canon.name.clone(), ac, total_tracks)
};
let image_path = norm_imgs
.get(&n)
.or_else(|| plex_portraits.get(&n))
.or_else(|| album_thumbs.get(&n))
.cloned()
.unwrap_or_default();
// The three tiers, each carrying WHERE it came from. Order is the
// reference's: custom/cached image -> Plex portrait -> album cover.
let (image_path, image_source) = if let Some(p) = norm_imgs.get(&n) {
// Custom portraits are written by this app: a local path or a
// cached url, never a server-relative thumb.
(p.clone(), "local")
} else if let Some(p) = plex_portraits.get(&n) {
(p.clone(), "plex")
} else if let Some((p, src)) = album_thumbs.get(&n) {
(p.clone(), src.as_str())
} else {
(String::new(), "local")
};
out.push(MergedArtist {
name: canonical,
album_count,
track_count,
image_path,
image_source: image_source.to_string(),
source: source.to_string(),
});
}
@@ -295,7 +319,18 @@ mod tests {
use super::*;
fn credit<'a>(id: &'a str, artist: &'a str, all: &'a str, art: &'a str) -> AlbumCredit<'a> {
credit_from("local", id, artist, all, art)
}
fn credit_from<'a>(
source: &'a str,
id: &'a str,
artist: &'a str,
all: &'a str,
art: &'a str,
) -> AlbumCredit<'a> {
AlbumCredit {
source,
id,
artist,
all_artists: all,
+83 -43
View File
@@ -1,21 +1,33 @@
//! Windowed, id-keyed artwork for the Local Library (the perf contract:
//! a 16K-track library must never decode a cover it does not show).
//!
//! Split out of `local_library_qt.rs` (phase-24 modularization) and made
//! source-aware. The routing decision is NOT made here — `artwork_qt::classify`
//! owns the one url taxonomy the whole app shares (see its module docs), so a
//! cover resolves identically in the grid, the queue and the now-playing bar:
//! Split out of `local_library_qt.rs` (phase-24 modularization).
//!
//! - a LOCAL cover path resolves to the `qbz-library` THUMBNAIL (generated
//! once, cached on disk) — never the full-size original, which is what
//! keeps a 16K library from decoding 3000px jpegs into cards;
//! - a PLEX thumb (`/library/...` / `/photo/...`) resolves to the tokenized
//! server-side transcode URL and is served through the SAME shared image
//! cache the Home/queue covers use (`artwork_qt`), at the SAME transcode
//! size (`artwork_qt::PLEX_THUMB_PX`), so a Plex cover is downloaded once
//! for the whole process and then read from disk like any other;
//! - an http(s) cover (an offline-download row that kept its CDN url) goes
//! down the same disk-cache path as a Plex thumb.
//! The routing decision is not made here, and since design 02 §9 stage 4 it is
//! not made by sniffing a string either: the art index holds a
//! `qbz_source::ArtRef` that the row's OWN source produced at mapping time
//! (`local_rows::art_ref`), and this file obeys it:
//!
//! - [`ArtRef::File`] — an on-disk cover, resolved to the `qbz-library`
//! THUMBNAIL (generated once, cached on disk) rather than the full-size
//! original, which is what keeps a 16K library from decoding 3000px jpegs
//! into cards;
//! - [`ArtRef::Fetch`] — a remote cover (a tokenized Plex transcode url, an
//! offline-download row's CDN url) served through the SAME shared image
//! cache the Home/queue covers use (`artwork_qt`), so it is downloaded once
//! for the whole process and then read from disk like any other. Its
//! `cache_key` is what the memo keys on, never the fetch url, which is
//! rebuilt every pass;
//! - [`ArtRef::Unavailable`] — the art exists but the server is not connected.
//! Kept distinct from `None` so the miss is logged for what it is rather
//! than looking like a dead download.
//!
//! `artwork_qt::classify` used to stand where that match is. It knew about
//! Plex only because `local_plex::is_thumb_path` was bolted onto it, so any
//! future source whose token starts with `/` — a Jellyfin
//! `/Items/<id>/Images/Primary`, say — would have been read as a filesystem
//! path and opened from disk: blank cover, no error, nothing in the log. That
//! is bug 3, and it is why the taxonomy now belongs to the sources.
//!
//! Every arm ends at a `file://` path — QML `Image` decodes it natively and
//! asynchronously, and no token ever reaches QML.
@@ -58,7 +70,9 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, LazyLock, Mutex, RwLock};
use crate::artwork_qt::{self, ArtUrl};
use qbz_source::ArtRef;
use crate::artwork_qt;
use crate::local_state::state;
/// Memo ceiling, mirroring `artwork_qt::MEMO_CAP`: an accelerator, cleared
@@ -93,10 +107,16 @@ fn gen_shard(src: &str) -> usize {
pub struct ArtworkWindow {
/// (artKey, "file://…") — already on disk, ready to emit immediately.
pub hits: Vec<(String, String)>,
/// (artKey, fetchable http url) — not on disk yet; the bridge downloads
/// these off the Qt thread and re-resolves them to `file://`. Named for
/// its dominant case; an http cover on a Plex-less row lands here too.
pub plex_misses: Vec<(String, String)>,
/// (artKey, fetchable http url, STABLE cache key) — not on disk yet; the
/// bridge downloads these off the Qt thread and re-resolves them to
/// `file://`. Named for its dominant case; an http cover on a Plex-less
/// row lands here too.
///
/// The cache key rides ALONGSIDE the url because they are not the same
/// string: a Plex url is re-tokenized every pass and a Qobuz one carries a
/// size query, while the key stays put. Collapsing them re-downloads a
/// cover that is already on disk (`ArtRef::Fetch`, design 02 §3.4).
pub plex_misses: Vec<(String, String, String)>,
/// (artKey, local cover path) — needs a decode+downscale, in the caller's
/// display order. Fed to [`stream_cold`], which emits each one as it
/// lands instead of batching them.
@@ -109,7 +129,7 @@ pub struct ArtworkWindow {
/// Keys with no cover are dropped, so the QML map only ever grows with real
/// hits.
pub fn resolve_window_blocking(keys: Vec<String>) -> ArtworkWindow {
let sources: Vec<(String, String)> = state(|s| {
let sources: Vec<(String, ArtRef)> = state(|s| {
keys.iter()
.filter_map(|k| s.art_index.get(k).map(|p| (k.clone(), p.clone())))
.collect()
@@ -118,32 +138,46 @@ pub fn resolve_window_blocking(keys: Vec<String>) -> ArtworkWindow {
let mut plex_misses = Vec::new();
let mut cold = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for (key, src) in sources {
for (key, art) in sources {
if !seen.insert(key.clone()) {
continue;
}
match artwork_qt::classify(&src) {
// --- Plex / remote arm: disk cache, else a background fetch ----
ArtUrl::Plex(_) | ArtUrl::Http(_) => {
let cached = artwork_qt::cached_path(&src);
// The taxonomy is READ, not re-derived. `artwork_qt::classify` used to
// stand here and sniff a `String` whose provenance had been thrown
// away three layers up; the row's own source answered this question at
// mapping time (`local_rows::art_ref`), and every arm below is that
// answer being obeyed.
match art {
// --- Remote arm: disk cache, else a background fetch -----------
ArtRef::Fetch { url, cache_key } => {
// The memo is keyed on the STABLE key, never the fetch url: a
// Plex url is re-tokenized every pass and a Qobuz one carries
// a size query, so keying on it would miss a cover already
// sitting in the cache (artwork_qt.rs:231-238).
let cached = artwork_qt::cached_path_for(&cache_key, &url);
if cached.is_empty() {
// Hand the bridge the RAW source: `download_missing`
// re-classifies it, and `cached_path` memoizes under the
// raw url, so the re-resolve below is a RAM read.
plex_misses.push((key, src));
plex_misses.push((key, url, cache_key));
} else {
hits.push((key, cached));
}
}
// No server / token: nothing to fetch, leave the card blank.
ArtUrl::PlexUnconfigured | ArtUrl::Empty => {}
// Nothing to show, and nothing to wait for.
ArtRef::None => {}
// The art EXISTS but the server is not connected. Distinct from
// `None` so the miss is logged for what it is instead of looking
// like a dead download — `ArtUrl::PlexUnconfigured`'s whole reason
// for existing, kept.
ArtRef::Unavailable(_) => {}
// --- Local arm: thumbnail the on-disk cover -------------------
ArtUrl::LocalFile(path) => match cached_thumbnail(&path) {
Some(thumb) => hits.push((key, artwork_qt::file_url(&thumb.to_string_lossy()))),
// Not on disk (or gone): decode it in phase 2. `cold` keeps
// the caller's order, which is display order.
None => cold.push((key, path)),
},
ArtRef::File(path) => {
let path = path.to_string_lossy().into_owned();
match cached_thumbnail(&path) {
Some(thumb) => hits.push((key, artwork_qt::file_url(&thumb.to_string_lossy()))),
// Not on disk (or gone): decode it in phase 2. `cold`
// keeps the caller's order, which is display order.
None => cold.push((key, path)),
}
}
}
}
ArtworkWindow {
@@ -255,17 +289,23 @@ fn memoize(src: &str, resolved: &Path) {
/// Download the misses into the shared image cache and return the resolved
/// `(key, file://…)` pairs. Async (network) — the bridge awaits it on the
/// tokio runtime, never on the Qt thread.
pub async fn fetch_plex_misses(misses: Vec<(String, String)>) -> Vec<(String, String)> {
pub async fn fetch_plex_misses(misses: Vec<(String, String, String)>) -> Vec<(String, String)> {
if misses.is_empty() {
return Vec::new();
}
let urls: Vec<String> = misses.iter().map(|(_, u)| u.clone()).collect();
artwork_qt::download_missing(urls).await;
// `download_pairs` / `cached_path_for` instead of the classifying pair:
// the source already resolved both halves, so nothing here has to guess
// what the string is (design 02 §9 stage 4).
let jobs: Vec<(String, String)> = misses
.iter()
.map(|(_, url, key)| (key.clone(), url.clone()))
.collect();
artwork_qt::download_pairs(jobs).await;
misses
.into_iter()
.filter_map(|(key, url)| {
let path = artwork_qt::cached_path(&url);
(!path.is_empty()).then_some((key, path))
.filter_map(|(art_key, url, cache_key)| {
let path = artwork_qt::cached_path_for(&cache_key, &url);
(!path.is_empty()).then_some((art_key, path))
})
.collect()
}
+4 -1
View File
@@ -184,7 +184,10 @@ fn build_doc(name: &str, path: &str, tracks: &[LocalTrack]) -> EphemeralDoc {
// key of an indexed album that happens to group the same.
let art_key = album_key(&format!("eph:{key}"));
if let Some(p) = first.artwork_path.as_ref().filter(|p| !p.is_empty()) {
art.insert(art_key.clone(), p.clone());
art.insert(
art_key.clone(),
crate::local_rows::art_ref(first.source.as_deref(), p),
);
}
let rows: Vec<TrackRow> = group.iter().map(|t| map_track(*t, art)).collect();
EphemeralAlbumBlock {
+46 -6
View File
@@ -23,6 +23,7 @@
use std::collections::HashMap;
use qbz_library::{AudioFormat, LocalAlbum, LocalTrack};
use qbz_source::{ArtRef, ArtSize};
use serde::Serialize;
// ---------------------------------------------------------------------------
@@ -274,15 +275,54 @@ pub fn badge_source_raw(raw: Option<&str>) -> String {
}
}
/// The [`ArtRef`] for a row's raw artwork token, decided by the row's OWN
/// source (design 02 §9 stage 4).
///
/// The art index used to hold the token as a bare `String`, and
/// `artwork_qt::classify` sniffed it back apart later — by which point the row
/// was gone and all that was left was the shape of the characters. That is bug
/// 3, and its concrete failure is not hypothetical: `classify` knows a Plex
/// thumb only because `local_plex::is_thumb_path` was bolted onto it, so a
/// Jellyfin token (`/Items/<id>/Images/Primary`) matches its `starts_with('/')`
/// arm and the pipeline tries to open it as a file on disk — blank cover, no
/// error, nothing in the log.
///
/// Registering the answer HERE is the fix: this is the last moment the row's
/// provenance exists. `SourceId::from_word` is the one vocabulary table
/// (§3.1), so all six source words land on the right source, and an unknown
/// word gets `None` rather than being guessed into Qobuz.
///
/// Cheap by contract — `Source::artwork_token` may not touch the network, a DB
/// or the filesystem — so this stays a per-row call on a page load.
pub fn art_ref(source_word: Option<&str>, token: &str) -> ArtRef {
if token.trim().is_empty() {
return ArtRef::None;
}
// The grid's size class. `ArtSize::Card` is `artwork_qt::PLEX_THUMB_PX`
// (256), which is deliberately ONE app-wide Plex transcode size so a cover
// is downloaded once — do not introduce a second size here.
match qbz_source::SourceId::from_word(source_word.unwrap_or("")) {
Some(id) => qbz_source::registry().artwork_token(id, token, ArtSize::Card),
// No word at all is the LOCAL case in practice (`local_tracks.source`
// is empty for a plain scanned file, §3.1's vocabulary table), and it
// is what `classify` did with those rows too.
None => qbz_source::registry().artwork_token(
qbz_source::SourceId::LOCAL,
token,
ArtSize::Card,
),
}
}
// ---------------------------------------------------------------------------
// Mappers (they also register the row's artwork SOURCE in the art index —
// a local cover path, or a raw Plex `/library/...` thumb path)
// Mappers (they also register the row's artwork REFERENCE in the art index —
// see `art_ref`: the row's own source decides what its token means)
// ---------------------------------------------------------------------------
pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, String>) -> AlbumRow {
pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, ArtRef>) -> AlbumRow {
let key = album_key(&a.id);
if let Some(p) = a.artwork_path.as_ref().filter(|p| !p.is_empty()) {
art.insert(key.clone(), p.clone());
art.insert(key.clone(), art_ref(Some(a.source.as_str()), p));
}
let folder_count = a
.source_folders
@@ -308,10 +348,10 @@ pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, String>) -> AlbumRow {
}
}
pub fn map_track(t: &LocalTrack, art: &mut HashMap<String, String>) -> TrackRow {
pub fn map_track(t: &LocalTrack, art: &mut HashMap<String, ArtRef>) -> TrackRow {
let key = track_key(t.id);
if let Some(p) = t.artwork_path.as_ref().filter(|p| !p.is_empty()) {
art.insert(key.clone(), p.clone());
art.insert(key.clone(), art_ref(t.source.as_deref(), p));
}
TrackRow {
id: t.id.to_string(),
+11 -5
View File
@@ -12,6 +12,7 @@ use std::sync::Mutex;
use qbz_app::user_data::UserDataPaths;
use qbz_library::{LibraryDatabase, LibraryError, LocalTrack};
use qbz_source::ArtRef;
use crate::local_rows::{AlbumRow, ArtistRow, LocalCounts, TrackRow, TreeNode};
@@ -96,10 +97,15 @@ pub struct LocalState {
/// The FULL flattened tree (visible derivation applies the rail search).
pub tree: Vec<TreeNode>,
pub tree_search: String,
/// artKey -> the artwork SOURCE: an on-disk cover path, or a raw Plex
/// `/library/...` thumb path. The artwork window turns these into 256px
/// thumbnails (local) or tokenized, disk-cached fetches (Plex).
pub art_index: HashMap<String, String>,
/// artKey -> what the row's OWN source said its artwork token means
/// (design 02 §9 stage 4). The artwork window turns these into 256px
/// thumbnails (`ArtRef::File`) or disk-cached fetches (`ArtRef::Fetch`).
///
/// It used to hold the raw token as a `String`, and `artwork_qt::classify`
/// sniffed it back apart at window time — by which point the row was gone
/// and only the shape of the characters was left. That is bug 3;
/// `local_rows::art_ref` resolves it while provenance still exists.
pub art_index: HashMap<String, ArtRef>,
/// Album identity ("folder" | "metadata") — persisted (locallibrary_ui).
pub album_mode: String,
pub counts: LocalCounts,
@@ -133,7 +139,7 @@ pub fn state<R>(f: impl FnOnce(&mut LocalState) -> R) -> R {
/// Take the art index out, run `f` with it, put it back — the shape every
/// loader uses so mapping can register covers without a second lock.
pub fn with_art<R>(f: impl FnOnce(&mut HashMap<String, String>) -> R) -> R {
pub fn with_art<R>(f: impl FnOnce(&mut HashMap<String, ArtRef>) -> R) -> R {
let mut art = state(|s| std::mem::take(&mut s.art_index));
let out = f(&mut art);
state(|s| s.art_index = art);
+10 -4
View File
@@ -18,7 +18,9 @@ use std::sync::Mutex;
use qbz_library::{FolderTreeEntry, LibraryError, LocalTrack};
use serde::Serialize;
use crate::local_rows::{basename, folder_key, map_track, FolderDetail, SubfolderRow, TrackRow, TreeNode};
use qbz_source::ArtRef;
use crate::local_rows::{art_ref, basename, folder_key, map_track, FolderDetail, SubfolderRow, TrackRow, TreeNode};
use crate::local_state::{state, with_art, with_db};
/// `list_folder_children` + the reference's on-disk cover fallback for the
@@ -61,7 +63,7 @@ fn folder_children_with_covers(path: &str) -> Vec<FolderTreeEntry> {
fn entry_to_node(
e: &FolderTreeEntry,
depth: i32,
art: &mut std::collections::HashMap<String, String>,
art: &mut std::collections::HashMap<String, ArtRef>,
) -> TreeNode {
match e {
FolderTreeEntry::Folder {
@@ -72,7 +74,9 @@ fn entry_to_node(
} => {
let key = folder_key(path);
if let Some(a) = artwork.as_ref().filter(|a| !a.is_empty()) {
art.insert(key.clone(), a.clone());
// The folder tree is `library.db`: every cover here is a
// local file.
art.insert(key.clone(), art_ref(Some("local"), a));
}
TreeNode {
path: path.clone(),
@@ -406,7 +410,9 @@ pub fn load_folder_detail_blocking(path: &str) -> FolderDetail {
} => {
let key = folder_key(path);
if let Some(a) = artwork.as_ref().filter(|a| !a.is_empty()) {
art.insert(key.clone(), a.clone());
// The folder tree is `library.db`: every cover here is a
// local file.
art.insert(key.clone(), art_ref(Some("local"), a));
}
Some(SubfolderRow {
path: path.clone(),
+25 -13
View File
@@ -96,16 +96,28 @@ pub(crate) fn local_album_artist(t: &qbz_library::LocalTrack) -> String {
/// SHARED `attach_urls` + `download_missing` pipeline exactly like the Qobuz
/// rows and shares its cache entry with the Local Library grid;
/// - nothing usable yields two empty strings.
pub(crate) fn local_art_split(path: Option<&str>) -> (String, String) {
match path.filter(|p| !p.is_empty()) {
Some(p) => match crate::artwork_qt::classify(p) {
crate::artwork_qt::ArtUrl::LocalFile(_) => {
(String::new(), crate::artwork_qt::file_url(p))
}
crate::artwork_qt::ArtUrl::Empty => (String::new(), String::new()),
_ => (p.to_string(), String::new()),
},
None => (String::new(), String::new()),
pub(crate) fn local_art_split(source: Option<&str>, path: Option<&str>) -> (String, String) {
let Some(p) = path.filter(|p| !p.is_empty()) else {
return (String::new(), String::new());
};
// The ROW's source decides, not the shape of the string (design 02 §9
// stage 4). `classify` stood here and would read a Jellyfin token as a
// filesystem path purely because it starts with `/`.
match crate::local_rows::art_ref(source, p) {
qbz_source::ArtRef::File(f) => (
String::new(),
crate::artwork_qt::file_url(&f.to_string_lossy()),
),
// The RAW token, not the resolved url: `attach_urls` /
// `download_missing` re-resolve it downstream and memoize under it, so
// this row shares its cache entry with the Local Library grid. That
// sharing is the whole reason this split exists.
qbz_source::ArtRef::Fetch { .. } => (p.to_string(), String::new()),
// Nothing usable, or a server that is not connected — two empty
// strings either way, which is what leaves the placeholder up.
qbz_source::ArtRef::None | qbz_source::ArtRef::Unavailable(_) => {
(String::new(), String::new())
}
}
}
@@ -165,7 +177,7 @@ pub(crate) fn derive_local_album_rows(
if out.len() >= cap {
continue; // keep counting for an honest has_more
}
let (art_url, art_path) = local_art_split(t.artwork_path.as_deref());
let (art_url, art_path) = local_art_split(t.source.as_deref(), t.artwork_path.as_deref());
out.push(CortRow {
kind: "album".into(),
id: key,
@@ -216,7 +228,7 @@ pub(crate) fn derive_local_artist_rows(
if out.len() >= cap {
continue;
}
let (art_url, art_path) = local_art_split(t.artwork_path.as_deref());
let (art_url, art_path) = local_art_split(t.source.as_deref(), t.artwork_path.as_deref());
out.push(CortRow {
kind: "artist".into(),
id: String::new(),
@@ -239,7 +251,7 @@ pub(crate) fn derive_local_artist_rows(
/// media action. `id` is the library row id; the router resolves the concrete
/// `LocalTrack` back from the per-query snapshot, NOT from this id.
pub(crate) fn map_local_track_to_cort_row(t: &qbz_library::LocalTrack) -> CortRow {
let (art_url, art_path) = local_art_split(t.artwork_path.as_deref());
let (art_url, art_path) = local_art_split(t.source.as_deref(), t.artwork_path.as_deref());
// "artist · album" when both exist, else whichever does (U+00B7).
let subtitle = match (t.artist.is_empty(), t.album.is_empty()) {
(false, false) => format!("{} · {}", t.artist, t.album),