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": [
+47
View File
@@ -280,9 +280,56 @@ async fn scan_downloads(app: &tauri::AppHandle) {
process_file(app, &path, None).await;
}
}
repair_artist_portraits(app).await;
let _ = app.emit("soulseek:library-changed", ());
}
/// Artist portraits are written ONCE, by `process_file` at import. This scan
/// skips every file it already knows, so a portrait that later goes missing
/// from disk — a cleared art directory, a half-copied profile — leaves the
/// database pointing at a file that will never come back, and the shelf head
/// falls back to its icon forever.
///
/// Repair exactly that: an artist whose stored path is NULL or no longer on
/// disk. Untouched portraits cost nothing, and an affected artist costs one
/// paced Deezer read (shared budget, same as every other read here).
///
/// Deliberately NOT a re-resolve of portraits that DO exist. That would repair
/// the other half of this problem — entries stored before `fetch_artist_art`
/// learned to rank candidates and reject Deezer's empty-md5 placeholder — but
/// it is a request per artist on every scan, and it would overwrite a portrait
/// the user is already looking at. Dropping the `needs` condition below is all
/// it would take, if that is ever wanted.
async fn repair_artist_portraits(app: &tauri::AppHandle) {
let state = app.state::<LibraryState>();
let stored = {
let lib = state.library.clone();
match tauri::async_runtime::spawn_blocking(move || lib.artist_portraits()).await {
Ok(Ok(rows)) => rows,
_ => return,
}
};
for (artist, path) in stored {
let needs = match path.as_deref() {
None => true,
Some(p) => !Path::new(p).exists(),
};
if !needs {
continue;
}
if let Some(fresh) = fetch_artist_art_cached(&state, &artist).await {
let lib = state.library.clone();
let name = artist.clone();
let updated =
tauri::async_runtime::spawn_blocking(move || lib.set_artist_art(&name, &fresh))
.await;
if let Ok(Ok(n)) = updated {
log::info!("artist portrait repaired for {artist} ({n} albums)");
}
}
}
}
fn collect_audio_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
if depth > 6 {
return;
+37 -4
View File
@@ -3816,6 +3816,39 @@
};
}
/** Fade+lift ONE element from its own mount, with the same WAAPI call
* `staggerIn` makes.
*
* The description needs this because `in:partIn` above never actually ran
* for it, and that was measured on this page: opening a Weezer record and
* sampling `.rail-lede` every frame gave opacity 1 on all 1389 frames cold
* and all 711 warm, with no transform at any point.
*
* Both of the other mechanisms miss it by construction. `staggerIn` is an
* action on .xp-panel and queries for its parts when that element is
* created, but the description block is created microseconds later — same
* flush — so the query finds nothing, which is why .rail-desc was left out
* of SWAP_PARTS in the first place. And Svelte plays an `in:` only for an
* element added by a later UPDATE; landing in the same flush is not one, so
* the intro is skipped too. An action always runs when its own element is
* created, whichever flush that is, so it covers every arrival time. */
function partAnim(node, { delay = 0 } = {}) {
if (reducedMotion()) return;
const rest = getComputedStyle(node).opacity || "1";
node.animate(
[
{ opacity: 0, transform: "translateY(7px)" },
{ opacity: rest, transform: "none" },
],
{
duration: SWAP_MS * 0.7,
delay: delay * (SWAP_MS / 360),
easing: SWAP_EASE,
fill: "backwards",
},
);
}
/** Hold the stage at an eased height that TRACKS the panel, rather than
* animating once to a height measured up front. The panel is at its
* smallest when the swap begins — no description, no track rows — so a
@@ -5269,19 +5302,19 @@
keeps its 4-line clamp and
its mask fade. -->
{#if railDesc.lede}
<p class="rail-lede" in:partIn={{ delay: 55 }}>{railDesc.lede}</p>
<p class="rail-lede" use:partAnim={{ delay: 55 }}>{railDesc.lede}</p>
{/if}
{#if railDesc.rest}
<p class="rail-desc" class:clamped={!descExpanded} in:partIn={{ delay: 60 }}>{railDesc.rest}</p>
<p class="rail-desc" class:clamped={!descExpanded} use:partAnim={{ delay: 60 }}>{railDesc.rest}</p>
<button
class="rail-more"
class:open={descExpanded}
in:partIn={{ delay: 85 }}
use:partAnim={{ delay: 85 }}
onclick={() => (descExpanded = !descExpanded)}
>{descExpanded ? "Show less" : "Show more"} <Icon name="chevron-down" size={13} /></button>
{/if}
{#if openInfo.genre || openInfo.label}
<div class="rail-pills" in:partIn={{ delay: 100 }}>
<div class="rail-pills" use:partAnim={{ delay: 100 }}>
{#if openInfo.genre}<span class="gpill accent">{openInfo.genre}</span>{/if}
{#if openInfo.label}<span class="gpill">{openInfo.label}</span>{/if}
</div>
+878 -40
View File
File diff suppressed because it is too large. Load diff