library: the page lit by its own records, and portraits that prove out

The expander reserves once on the page root instead of standing min-heights
on the grid; shelf heads carry artist portraits keyed by path; every record
tints its own card, expander and glow from its cover; the artwork opens the
shared viewer; the banner wash reads the playing record and actually paints
(isolation, the artist page's own fix); descriptions animate on both pages
via partAnim. Portrait resolution rejects placeholder rows and never stores
a silhouette (pick_artist_portrait, 3 tests); repair re-resolves the missing.
This commit is contained in:
KeelrLeek committed 2026-08-06 17:21:23 -05:00
1 parent 741c4ae6dc
commit 185c29fc33
5 files changed
+1068 -49

No files matched your search

+15
View File
@@ -288,6 +288,21 @@ impl Library {
Ok(())
}
/// One row per distinct album artist, with whatever portrait path is
/// stored against it. The caller decides what counts as missing — the
/// library has no view of the filesystem, and a path that is present in the
/// database but gone from disk is exactly the case worth repairing.
pub fn artist_portraits(&self) -> Result<Vec<(String, Option<String>)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT artist, MAX(artist_art_path) FROM albums GROUP BY artist COLLATE NOCASE",
)?;
let rows = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
pub fn set_artist_art(&self, artist: &str, art_path: &str) -> Result<usize> {
let conn = self.conn.lock().unwrap();
Ok(conn.execute(
+91 -5
View File
@@ -8843,18 +8843,25 @@ impl MetadataClient {
/// Best-effort artist portrait from Deezer's public catalog. The image is
/// cached locally by the app and is only used for the library UI.
pub async fn fetch_artist_art(&self, artist: &str) -> Option<Vec<u8>> {
// `limit=1` + `/data/0` took whatever Deezer happened to rank first,
// with no name check and no prominence check. For a shared name that is
// routinely a dead duplicate row: "GUM" resolved to the 90-fan entry
// whose portrait is the empty-md5 placeholder, so the library shelf drew
// a grey silhouette while the real 3707-fan GUM has a photo.
//
// Ask for a handful instead and break the tie the way the rest of the
// crate does — exact name first, then followers (see
// `deezer_exact_name_state`) — and never accept the placeholder.
let url = format!(
"https://api.deezer.com/search/artist?q={}&limit=1",
"https://api.deezer.com/search/artist?q={}&limit=10",
urlencode(artist)
);
// Library imports call this once per artist, so it shares the same
// cache, single-flight and request-budget gate as every other Deezer
// read instead of spending an unpaced request beside them.
let body = self.deezer(&url).await?;
let image = body
.pointer("/data/0/picture_xl")
.and_then(|v| v.as_str())?;
self.get_image(image).await
let image = pick_artist_portrait(&body, artist)?.to_string();
self.get_image(&image).await
}
/// Fetch an image and return it as a data URL. Catalog covers live on
@@ -11515,6 +11522,13 @@ fn deezer_search_allows_bing(
/// (`crazy_p`, `Pond` x6) that no search engine ranks for. Requiring a decisive
/// margin keeps the real collisions — the several genuinely followed Ponds —
/// on the strict catalogue-anchored path.
/// Deezer's "this artist has no photo" portrait is the md5 of the EMPTY STRING.
/// The frontend carries the same constant for the sidebar's portrait fallback
/// (`DEEZER_EMPTY_PICTURE_HASH` in src/lib/state.svelte.js) — there the hash is
/// only reachable through the CDN's redirect, but a search row's `picture_*`
/// often carries it literally, which is enough to reject the row outright.
const DEEZER_EMPTY_PICTURE_HASH: &str = "d41d8cd98f00b204e9800998ecf8427e";
const BING_NAME_OWNER_FAN_RATIO: u64 = 4;
/// Floor so two obscure rows (5 fans against 1) cannot manufacture a ratio.
const BING_NAME_OWNER_MIN_FANS: u64 = 25;
@@ -11623,6 +11637,38 @@ fn deezer_quota_error(body: &serde_json::Value) -> bool {
.is_some_and(|message| message.to_lowercase().contains("quota"))
}
/// Choose which row of a Deezer artist search owns the portrait for `artist`.
///
/// Exact name outranks any follower count — a bigger artist whose name merely
/// CONTAINS the query is the wrong answer, not a better one — and rows carrying
/// the empty-md5 placeholder are dropped entirely. Returning `None` (every
/// candidate placeholder-only) is deliberate: the shelf head's fallback icon is
/// honest, where a stored grey silhouette reads as a broken fetch.
fn pick_artist_portrait<'a>(body: &'a serde_json::Value, artist: &str) -> Option<&'a str> {
let results = body.get("data").and_then(|data| data.as_array())?;
let wanted = normalize(artist);
let mut ranked: Vec<(bool, u64, &str)> = results
.iter()
.filter_map(|row| {
let name = row.get("name").and_then(|name| name.as_str())?;
let picture = row
.get("picture_xl")
.or_else(|| row.get("picture_big"))
.and_then(|picture| picture.as_str())?;
if picture.contains(DEEZER_EMPTY_PICTURE_HASH) {
return None;
}
let fans = row
.get("nb_fan")
.and_then(|fans| fans.as_u64())
.unwrap_or_default();
Some((normalize(name) == wanted, fans, picture))
})
.collect();
ranked.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
ranked.first().map(|(_, _, picture)| *picture)
}
fn deezer_exact_name_state(
body: &serde_json::Value,
requested_name: &str,
@@ -15024,6 +15070,46 @@ mod tests {
));
}
#[test]
fn artist_portrait_skips_the_placeholder_duplicate() {
// The real GUM search, reduced: the first row Deezer returned had the
// empty-md5 portrait, which `limit=1` stored as the library's shelf art.
let gum = json!({ "data": [
{ "name": "GUM", "nb_fan": 90,
"picture_xl": "https://cdn-images.dzcdn.net/images/artist/d41d8cd98f00b204e9800998ecf8427e/1000x1000.jpg" },
{ "name": "GUM", "nb_fan": 3707,
"picture_xl": "https://cdn-images.dzcdn.net/images/artist/0728a4530cd8ee40f1212845423/1000x1000.jpg" },
{ "name": "Gumball", "nb_fan": 900_000,
"picture_xl": "https://cdn-images.dzcdn.net/images/artist/aaaa/1000x1000.jpg" },
]});
assert_eq!(
pick_artist_portrait(&gum, "GUM"),
Some("https://cdn-images.dzcdn.net/images/artist/0728a4530cd8ee40f1212845423/1000x1000.jpg"),
);
}
#[test]
fn artist_portrait_prefers_the_exact_name_over_a_bigger_namesake() {
// A far more popular artist whose name merely contains the query must
// never win — that is how a shelf ends up wearing the wrong face.
let body = json!({ "data": [
{ "name": "Weezer Rascal", "nb_fan": 5_000_000, "picture_xl": "https://x/rascal.jpg" },
{ "name": "Weezer", "nb_fan": 725_694, "picture_xl": "https://x/weezer.jpg" },
]});
assert_eq!(pick_artist_portrait(&body, "Weezer"), Some("https://x/weezer.jpg"));
}
#[test]
fn artist_portrait_is_none_when_every_candidate_is_a_placeholder() {
// Nothing beats the fallback icon here; a stored silhouette would read
// as a failed fetch forever.
let body = json!({ "data": [
{ "name": "Nobody", "nb_fan": 3,
"picture_xl": "https://cdn-images.dzcdn.net/images/artist/d41d8cd98f00b204e9800998ecf8427e/1000x1000.jpg" },
]});
assert_eq!(pick_artist_portrait(&body, "Nobody"), None);
}
#[test]
fn bing_fallback_requires_an_unambiguous_exact_name() {
let pond = json!({ "data": [