mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(cover): artist artwork from fanart.tv (off by default) (#1137)
* feat(cover): add artist_artwork_lookup table + accessors
Image-scraper P0 (design-review §12): additive library-SQLite migration 013
plus get/upsert/clear-per-server accessors for the external artist-artwork
lookup (fanart.tv). Render never reads it; the on-demand cover ensure path
and the mbid_ambiguous 24h negative cache use it. server_id = serverIndexKey.
* feat(cover): add fanart.tv + getArtistInfo2 provider layer
Image-scraper P0 (§7/§19/§23): new cover_cache/external.rs — Rust-side
getArtistInfo2 tag-MBID resolution plus fanart.tv v3/music URL + first
artistbackground fetch (BYOK client_key sent in addition to the project
api_key per fanart.tv ToS, §22). Extract a shared build_subsonic_url helper
in fetch.rs (cover URL behaviour unchanged). Add a dedicated low-concurrency
fanart_http_sem so external HTTP never starves Navidrome (§26). URL builders
unit-tested; wired into ensure_inner next.
* feat(cover): wire fanart.tv external branch into ensure_inner
Image-scraper P0 (§16): on-demand artist `fanart` ensures try fanart.tv
before the Navidrome fallback. MBID resolved Rust-side via getArtistInfo2
(§23, tag MBID); on a miss it falls through WITHOUT a .fetch-failed marker so
Navidrome stays the display fallback (§28). External tiers are written as
{tier}-fanart.webp in the same entity dir (same cacheKind, §16) — 2000 + 512
(matryoshka §17); peek prefers them for the fanart surface. Dedicated
low-concurrency fanart lane (§26); .miss-fanart ~30min negative marker.
Additive IPC args (externalArtworkEnabled, surfaceKind), off by default and
gated by PSYSONIC_FANART_KEY — inert until a render surface opts in (P1).
Quality gate (§11), name->MusicBrainz (§19), and lookup-table writes are P1.
* feat(cover): fanart-first peek for the fanart surface
Image-scraper P0: for an artist `fanart` ensure, the early peek serves only
the external {tier}-fanart.webp tiers; if none exist yet it returns None so
ensure runs the external branch (fetch fanart) instead of short-circuiting on
a cached Navidrome tier. Realises "fanart prioritised" (§18) for the opt-in
surface; Navidrome stays the fallback inside the branch's miss path.
* chore(cover): dev-only artist-fanart spike helper
DEV-only window.psyFanartSpike(name) — resolves an artist by name and fires
the real cover_cache_ensure with externalArtworkEnabled+surfaceKind=fanart to
verify the P0 pipeline against a live server (with PSYSONIC_FANART_KEY set).
Not wired in production.
* feat(cover): §11 quality gate for the fanart surface
Before an external fanart fetch, check whether a Navidrome tier already on
disk is an HQ ~16:9 image (width >= 1280, aspect 1.6-2.0) and skip the fetch
if so — square artist portraits never satisfy it, so the common case still
fetches. Reads tier dimensions only (no full decode). Rust consts per the
design review. Unit-tested predicate.
* feat(cover): persist fanart resolution in artist_artwork_lookup (§12)
Wire the lookup table as both the MBID resolution cache and the negative
cache: a cached MBID skips the getArtistInfo2 round-trip; no_mbid/mbid_ambiguous
back off 24h and miss 30min from updated_at before re-querying. Writes
hit/miss/no_mbid with mbid/mbid_source/provider; transient network errors are
not cached. All store reads/writes run off the async executor via
spawn_blocking and no-op before login. Store reached via app.try_state::<LibraryRuntime>().
* feat(cover): compile-time fanart key fallback + album/name IPC args
A runtime PSYSONIC_FANART_KEY still wins (dev), else the key baked in at build
time via option_env! (release). Add additive artistName/albumTitle ensure args
as context for the §19 name->MusicBrainz fallback (inert until the render
passes them). Library backfill passes None.
* feat(settings): add External Artwork Scraper toggle under Integrations
Master toggle (themeStore, off by default per §20) in a new Integrations
subsection, alongside the other opt-in third-party categories. Contacts
fanart.tv only when enabled. i18n across all 9 locales.
* feat(cover): wire fanart background into the fullscreen player (§28)
New useArtistFanart hook resolves a fanart.tv 16:9 background via a dedicated
cover_cache_ensure (surfaceKind=fanart) — it bypasses the shared peek/disk-src
cache (the {tier}-fanart.webp surface is keyed differently) and reuses
coverDiskUrl for the asset URL. Fullscreen background priority is now
fanart -> Navidrome artist image (cover pipeline) -> album cover; the live
useFsArtistPortrait probe is deleted (§28). Additive ensure opts
(surfaceKind/artistName/albumTitle); externalArtworkEnabled is derived in
ensureArgsFromRef from the master toggle and restricted to the artist fanart
surface, so plain cover ensures are unaffected.
* feat(cover): generalize external surface to fanart + banner (§13)
surfaceKind='banner' fetches the fanart.tv musicbanner array -> {tier}-banner.webp
in the same entity dir; fanart stays the 16:9 artistbackground. The ensure
branch, peek, lookup rows (per-surface surface_kind), miss marker
(.miss-<surface>) and tier suffix are all surface-parameterised. The §11
quality gate stays fanart-only (the banner strip has its own aspect). Unit
test for the surface->fanart JSON key map.
* feat(artist): fanart banner on the artist-detail header (§13, Option B)
The artist-detail header gets an album-detail-style background layer: fanart.tv
banner (musicbanner) -> the 16:9 fanart background cropped to the strip ->
empty. Both via a shared useArtistExternalImage hook (useArtistBanner /
useArtistFanart); each fetches on demand and shares the Rust cache, so the
header and the fullscreen player warm each other's images. The header is its
own stacking context (isolation) so a z-index:-1 banner clips behind the avatar
+ meta with no content wrapper; the album-style framing (padding/radius/clip)
is applied only when a banner is shown, so the off-by-default case stays
pixel-identical.
* refactor(artist): reuse album-detail header structure for the fanart banner
The artist-detail header now uses the same album-detail-* container classes as
AlbumHeader (header/bg/overlay/content/hero) with the fanart banner as the
background; the Back button moves inside the header. A surgical
`artist-detail-bleed` cancels the artist page's .content-body padding so the
banner is full-bleed to the container edges, matching the album header exactly
instead of the earlier inset card. Reverts the experimental artist-specific bg
CSS.
* feat(cover): drop artist_artwork_lookup rows on clear-cover-cache (§12/B.4)
cover_cache_clear_server already removed the server's whole cover dir (so the
{tier}-fanart.webp / -banner.webp tiers + .miss-* markers go with it); also
clear the artist_artwork_lookup rows for that server (off-thread) so no stale
resolution state lingers. Automatic toggle-off purge deferred — turning the
toggle off already hides external artwork (render is gated), and explicit
cache-clear now cleans external state too.
* feat(cover): name->MusicBrainz album-confirmed MBID resolution (§19)
When getArtistInfo2 has no tag MBID and the ensure carries the artist name +
an album in context (fullscreen), one MusicBrainz release-search query resolves
the artist MBID: the primary artist across score>=90 releases wins, conflicting
ids -> mbid_ambiguous (24h backoff), none -> no_mbid. Sends the required
User-Agent; a single-permit musicbrainz_sem + >=1s spacing holds us under MB's
rate limit. mbid_source=musicbrainz persisted. Banner surface (no album
context) correctly skips this. Pure classify/escape helpers unit-tested.
* fix(cover): enable banner surface in ensureArgsFromRef
externalEnsureFields only set externalArtworkEnabled for surfaceKind 'fanart',
so the 'banner' surface never fired — the artist-detail header always fell back
to the fanart image instead of the fanart.tv musicbanner. Both external artist
surfaces (fanart/banner) now enable the external branch.
* feat(settings): optional BYOK personal fanart key field
Add an optional personal fanart.tv API key field to the External Artwork
Scraper block (shown when the toggle is on): a masked input, a saved/in-use
status line, and the simple note that it is sent in addition to the app key.
Persisted in themeStore and plumbed through cover_cache_ensure
(externalArtworkByok); Rust prefers the settings key, falling back to the
PSYSONIC_FANART_CLIENT_KEY dev env. i18n x9.
* fix(cover): resolve artist-page fanart image collision on navigation
The artist-detail header keyed its fanart/banner hooks on the route `id`,
which flips immediately on navigation while `artist`/`albums` refetch a beat
later. The mismatched ensure wrote the previous artist's image under the new
artist's id (e.g. Sepultura's image under Lordi's id).
- key on the loaded `artist.id`, not the route `id`, so id/name/album always
describe the same artist
- pick the §19 album context from an album that actually belongs to this
artist (`albums.find(a => a.artistId === artist.id)`), so a stale album can't
run a mismatched name→MusicBrainz query or cache a wrong `no_mbid`
- reset `src` on every input change in `useArtistExternalImage` so a previous
artist's image never lingers while the new one resolves
* fix(cover): strip trailing album qualifier before MusicBrainz lookup
Library titles like "Show No Mercy (2004 Remastered)" or "Album [Deluxe
Edition]" failed the §19 MusicBrainz release query, blocking name-confirmed
MBID resolution. `normalize_album_for_mb` strips a single trailing
parenthetical/bracketed qualifier; leading qualifiers (e.g. "(What's the
Story) Morning Glory?") are left intact. Unit-tested.
* fix(cover): don't cache no_mbid when album context is unavailable
The banner ensure could fire before the artist's albums loaded, with no album
in context. The old code cached `no_mbid` there and the 24h backoff then
blocked the later ensure that arrived *with* album context. Could-not-attempt
is not tried-and-failed: the no-album branch now returns without persisting.
* fix(cover): don't emit tier-ready for external fanart/banner surfaces
`try_external_fanart` emitted `cover:tier-ready` with the `{tier}-{surface}.webp`
path. That event is keyed by the canonical cover key (cacheKind/cacheEntityId/
tier, no surface), so the frontend `useCoverArtBridge` listener seeded the
Navidrome artist cover's disk-src cache with the external image — leaking
fanart/banner into the plain artist cover (avatar, fullscreen "navidrome-artist"
fallback) even with the scraper toggled off.
Remove the emit: the fanart/banner hooks read the path from the
`cover_cache_ensure` return value, so no event is needed. (No disk-level
overwrite — the suffixed files are never matched by `tier_exists`; this was
frontend disk-src-cache cross-contamination.)
* fix(cover): wait for the final external background before showing it, with fade-in
The fullscreen player and artist-detail header flashed several backgrounds in
sequence while the fanart resolved (upscaled album cover → Navidrome artist
image → fanart), and the artist header could show the fanart first and then
swap to the banner.
- the album cover is no longer a background source — it only feeds the
foreground thumbnail
- the external-artwork hooks return `{ src, pending }` so callers can tell
"still resolving" (hold back) from "resolved, no image" (fall back now)
- fullscreen background: scraper on → fanart, empty while it resolves, Navidrome
artist image only on a confirmed miss; scraper off → Navidrome artist image
- artist header: the banner is preferred — nothing shows while it resolves
(no fanart flash), fanart is the fallback only once the banner misses
- both backgrounds preload the chosen image and fade it in (`onLoad` plus a
`ref` `complete` check so an already-cached image, whose load event can fire
before React attaches the handler, still appears). The header fade is a
scoped inline opacity so the shared `album-detail-bg` class is untouched.
* ci(release): pass PSYSONIC_FANART_KEY into the macOS + Linux builds
* refactor(cover): extract external-artwork ensure into its own module
Pure code move: the on-demand fanart/banner fetch, the quality gate, the
surface-aware peek and the lookup-table cache move from cover_cache/mod.rs
into cover_cache/external_ensure.rs. Behaviour unchanged; mod.rs 1877 -> 1488.
* chore(cover): remove dev-only fanart spike helper
The real render wiring now exercises the external ensure branch, so the
dev-only window.psyFanartSpike helper is redundant.
* feat(cover): purge external artwork on opt-out (B3)
New cover_cache_purge_external command: when the External Artwork toggle is
turned off, drop every fetched {tier}-{provider}.webp, .miss-{provider}
marker and artist_artwork_lookup row across all configured servers, leaving
the canonical Navidrome covers intact. Opting out now removes the
third-party-sourced data instead of just hiding it (design-review §9/§12/B.4).
* docs: changelog, credits and what's new for artist fanart (PR #1137)
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
//! External artist-artwork providers (image-scraper P0 spike).
|
||||
//!
|
||||
//! - Subsonic `getArtistInfo2` → the artist's tag MusicBrainz id (§19 step 2;
|
||||
//! MBID resolution stays Rust-side per §23).
|
||||
//! - fanart.tv `v3/music/<mbid>` → the first `artistbackground` URL.
|
||||
//!
|
||||
//! Mirrors the token auth of `fetch.rs`. The chosen background image's bytes
|
||||
//! are downloaded by the ensure flow via `fetch::fetch_cover_bytes` (a generic
|
||||
//! retrying GET). All network use is gated by the caller (feature flag +
|
||||
//! reachability + the dedicated low-concurrency fanart semaphore).
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use super::fetch::build_subsonic_url;
|
||||
|
||||
const FANART_API_BASE: &str = "https://webservice.fanart.tv/v3/music";
|
||||
const MUSICBRAINZ_BASE: &str = "https://musicbrainz.org/ws/2";
|
||||
/// MusicBrainz requires a meaningful, contactable User-Agent (their ToS).
|
||||
const MUSICBRAINZ_USER_AGENT: &str = concat!(
|
||||
"Psysonic/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" ( https://github.com/Psychotoxical/psysonic )"
|
||||
);
|
||||
|
||||
/// Subsonic `getArtistInfo2.view` (JSON) URL for an artist id.
|
||||
pub fn build_artist_info2_url(
|
||||
rest_base: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
artist_id: &str,
|
||||
) -> String {
|
||||
build_subsonic_url(
|
||||
rest_base,
|
||||
"getArtistInfo2",
|
||||
username,
|
||||
password,
|
||||
&[("id", artist_id), ("f", "json")],
|
||||
)
|
||||
}
|
||||
|
||||
/// fanart.tv music endpoint URL for an MBID. The BYOK personal `client_key` is
|
||||
/// sent **in addition to** the project `api_key` when non-empty (fanart.tv ToS,
|
||||
/// §22) — never a replacement.
|
||||
pub fn build_fanart_url(mbid: &str, api_key: &str, client_key: Option<&str>) -> String {
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
serializer.append_pair("api_key", api_key);
|
||||
if let Some(ck) = client_key {
|
||||
if !ck.is_empty() {
|
||||
serializer.append_pair("client_key", ck);
|
||||
}
|
||||
}
|
||||
format!("{FANART_API_BASE}/{mbid}?{}", serializer.finish())
|
||||
}
|
||||
|
||||
/// GET `getArtistInfo2` and extract `artistInfo2.musicBrainzId` (tag MBID).
|
||||
/// `Ok(None)` when the artist carries no MBID tag.
|
||||
pub async fn fetch_artist_tag_mbid(
|
||||
client: &Client,
|
||||
rest_base: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
artist_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let url = build_artist_info2_url(rest_base, username, password, artist_id);
|
||||
let body = http_get_text(client, &url).await?;
|
||||
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
let mbid = v
|
||||
.get("subsonic-response")
|
||||
.and_then(|r| r.get("artistInfo2"))
|
||||
.and_then(|a| a.get("musicBrainzId"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
Ok(mbid)
|
||||
}
|
||||
|
||||
/// Map a render surface to its fanart.tv JSON array key. `fanart` (the 16:9
|
||||
/// fullscreen background) → `artistbackground`; `banner` (the wide artist-detail
|
||||
/// header strip) → `musicbanner`.
|
||||
pub fn fanart_json_key(surface: &str) -> &'static str {
|
||||
match surface {
|
||||
"banner" => "musicbanner",
|
||||
_ => "artistbackground",
|
||||
}
|
||||
}
|
||||
|
||||
/// GET the fanart.tv music JSON for an MBID and return the first image URL for
|
||||
/// the requested `surface` (the API returns each kind most-liked first).
|
||||
/// `Ok(None)` when the artist has no image of that kind (404 or empty array).
|
||||
pub async fn fetch_fanart_image_url(
|
||||
client: &Client,
|
||||
mbid: &str,
|
||||
api_key: &str,
|
||||
client_key: Option<&str>,
|
||||
surface: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let url = build_fanart_url(mbid, api_key, client_key);
|
||||
let Some(body) = http_get_text_opt(client, &url).await? else {
|
||||
return Ok(None); // 404 → artist has no fanart at all
|
||||
};
|
||||
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
let img = v
|
||||
.get(fanart_json_key(surface))
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|o| o.get("url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Outcome of a name→MusicBrainz artist-MBID resolution (§19).
|
||||
pub enum MbResolution {
|
||||
/// A single, confident artist MBID (one artist across high-score releases).
|
||||
Found(String),
|
||||
/// Multiple candidate artists — never guess; the caller backs off 24h.
|
||||
Ambiguous,
|
||||
/// No matching release at all.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Escape the Lucene special characters that would break a MusicBrainz query.
|
||||
fn mb_escape(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
/// Strip a single trailing parenthetical / bracketed qualifier (e.g.
|
||||
/// "(2004 Remastered)", "[Deluxe Edition]") so a decorated library album title
|
||||
/// still matches the canonical MusicBrainz release. Leaves leading qualifiers
|
||||
/// (e.g. "(What's the Story) Morning Glory?") untouched.
|
||||
fn normalize_album_for_mb(title: &str) -> String {
|
||||
let t = title.trim();
|
||||
let stripped = if t.ends_with(')') {
|
||||
t.rfind(" (").map(|i| &t[..i]).unwrap_or(t)
|
||||
} else if t.ends_with(']') {
|
||||
t.rfind(" [").map(|i| &t[..i]).unwrap_or(t)
|
||||
} else {
|
||||
t
|
||||
};
|
||||
stripped.trim().to_string()
|
||||
}
|
||||
|
||||
/// Resolve an artist MBID by name, confirmed by an album release (§19). One
|
||||
/// query to the MusicBrainz release search; the primary artist across the
|
||||
/// high-confidence releases wins, conflicting ids → `Ambiguous`. Sends the
|
||||
/// required User-Agent. The caller enforces the ≤1 req/s rate limit.
|
||||
pub async fn resolve_mbid_via_musicbrainz(
|
||||
client: &Client,
|
||||
artist_name: &str,
|
||||
album_title: &str,
|
||||
) -> Result<MbResolution, String> {
|
||||
let album = normalize_album_for_mb(album_title);
|
||||
let query = format!(
|
||||
"artist:\"{}\" AND release:\"{}\"",
|
||||
mb_escape(artist_name),
|
||||
mb_escape(&album)
|
||||
);
|
||||
// Scope the (non-Send) serializer so it is dropped before the await below.
|
||||
let url = {
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
serializer.append_pair("query", &query);
|
||||
serializer.append_pair("fmt", "json");
|
||||
serializer.append_pair("limit", "8");
|
||||
format!("{MUSICBRAINZ_BASE}/release/?{}", serializer.finish())
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header(reqwest::header::USER_AGENT, MUSICBRAINZ_USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
Ok(classify_mb_releases(&v))
|
||||
}
|
||||
|
||||
/// Pure classification of a MusicBrainz release-search response: the primary
|
||||
/// artist id of each release scoring ≥ 90. One distinct id → `Found`, several →
|
||||
/// `Ambiguous`, none → `None`.
|
||||
fn classify_mb_releases(v: &serde_json::Value) -> MbResolution {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
if let Some(releases) = v.get("releases").and_then(|r| r.as_array()) {
|
||||
for rel in releases {
|
||||
let score = rel.get("score").and_then(|s| s.as_i64()).unwrap_or(0);
|
||||
if score < 90 {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = rel
|
||||
.get("artist-credit")
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|c| c.get("artist"))
|
||||
.and_then(|a| a.get("id"))
|
||||
.and_then(|i| i.as_str())
|
||||
{
|
||||
ids.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
match ids.len() {
|
||||
0 => MbResolution::None,
|
||||
1 => MbResolution::Found(ids.into_iter().next().unwrap_or_default()),
|
||||
_ => MbResolution::Ambiguous,
|
||||
}
|
||||
}
|
||||
|
||||
/// Single GET → response text; any non-2xx is an error.
|
||||
async fn http_get_text(client: &Client, url: &str) -> Result<String, String> {
|
||||
let resp = client.get(url).send().await.map_err(|e| e.to_string())?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {status}"));
|
||||
}
|
||||
resp.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Single GET → `Some(text)` on success, `None` on 404, error otherwise.
|
||||
async fn http_get_text_opt(client: &Client, url: &str) -> Result<Option<String>, String> {
|
||||
let resp = client.get(url).send().await.map_err(|e| e.to_string())?;
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {status}"));
|
||||
}
|
||||
resp.text().await.map(Some).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artist_info2_url_is_json_and_token_authed() {
|
||||
let u = build_artist_info2_url("http://nav.local:4533", "u", "p", "ar-1");
|
||||
assert!(u.starts_with("http://nav.local:4533/rest/getArtistInfo2.view?"));
|
||||
assert!(u.contains("id=ar-1"));
|
||||
assert!(u.contains("f=json"));
|
||||
assert!(u.contains("&t=") && u.contains("&s="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanart_url_adds_client_key_only_when_present() {
|
||||
assert_eq!(
|
||||
build_fanart_url("mbid-123", "PROJ", None),
|
||||
"https://webservice.fanart.tv/v3/music/mbid-123?api_key=PROJ"
|
||||
);
|
||||
let byok = build_fanart_url("mbid-123", "PROJ", Some("PERS"));
|
||||
assert!(byok.contains("api_key=PROJ") && byok.contains("client_key=PERS"));
|
||||
// empty BYOK is ignored — project key only
|
||||
assert!(!build_fanart_url("mbid-123", "PROJ", Some("")).contains("client_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_first_artistbackground_url() {
|
||||
let json = r#"{"artistbackground":[{"id":"1","url":"https://a/bg1.jpg","likes":"9"},{"url":"https://a/bg2.jpg"}]}"#;
|
||||
let v: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let bg = v
|
||||
.get("artistbackground")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|o| o.get("url"))
|
||||
.and_then(|u| u.as_str());
|
||||
assert_eq!(bg, Some("https://a/bg1.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_key_maps_surface() {
|
||||
assert_eq!(fanart_json_key("fanart"), "artistbackground");
|
||||
assert_eq!(fanart_json_key("banner"), "musicbanner");
|
||||
assert_eq!(fanart_json_key("anything-else"), "artistbackground");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_album_strips_trailing_qualifier_only() {
|
||||
assert_eq!(normalize_album_for_mb("Show No Mercy (2004 Remastered)"), "Show No Mercy");
|
||||
assert_eq!(normalize_album_for_mb("Album [Deluxe Edition]"), "Album");
|
||||
assert_eq!(normalize_album_for_mb("Reign in Blood"), "Reign in Blood");
|
||||
// leading qualifier left intact (does not end with a close bracket)
|
||||
assert_eq!(
|
||||
normalize_album_for_mb("(What's the Story) Morning Glory?"),
|
||||
"(What's the Story) Morning Glory?"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mb_escape_handles_quotes_and_backslashes() {
|
||||
assert_eq!(mb_escape("AC/DC"), "AC/DC");
|
||||
assert_eq!(mb_escape(r#"a"b"#), r#"a\"b"#);
|
||||
assert_eq!(mb_escape(r"a\b"), r"a\\b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_mb_picks_single_high_score_artist() {
|
||||
let v: serde_json::Value = serde_json::from_str(
|
||||
r#"{"releases":[
|
||||
{"score":100,"artist-credit":[{"artist":{"id":"mbid-A"}}]},
|
||||
{"score":95,"artist-credit":[{"artist":{"id":"mbid-A"}}]},
|
||||
{"score":40,"artist-credit":[{"artist":{"id":"mbid-Z"}}]}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(classify_mb_releases(&v), MbResolution::Found(id) if id == "mbid-A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_mb_ambiguous_and_none() {
|
||||
let two: serde_json::Value = serde_json::from_str(
|
||||
r#"{"releases":[
|
||||
{"score":100,"artist-credit":[{"artist":{"id":"mbid-A"}}]},
|
||||
{"score":92,"artist-credit":[{"artist":{"id":"mbid-B"}}]}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(classify_mb_releases(&two), MbResolution::Ambiguous));
|
||||
|
||||
let low: serde_json::Value =
|
||||
serde_json::from_str(r#"{"releases":[{"score":50,"artist-credit":[{"artist":{"id":"x"}}]}]}"#)
|
||||
.unwrap();
|
||||
assert!(matches!(classify_mb_releases(&low), MbResolution::None));
|
||||
|
||||
let empty: serde_json::Value = serde_json::from_str(r#"{"releases":[]}"#).unwrap();
|
||||
assert!(matches!(classify_mb_releases(&empty), MbResolution::None));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user