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:
1 parent
741c4ae6dc
commit
185c29fc33
5 files changed
+1068
-49
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user