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:
Psychotoxical
2026-06-20 21:04:21 +02:00
committed by GitHub
parent 23f8008248
commit b950d4704b
33 changed files with 1563 additions and 73 deletions
@@ -309,6 +309,12 @@ async fn ensure_one(
password: session.password,
library_bulk: true,
library_server_id: Some(session.library_server_id),
// Library backfill never touches external providers (§15).
external_artwork_enabled: false,
surface_kind: None,
artist_name: None,
album_title: None,
external_artwork_byok: None,
};
let _ = CoverCacheState::ensure_inner(&st, &app, &args, Some(http_sem)).await;
}
+16
View File
@@ -13,6 +13,22 @@ pub fn tier_path(dir: &Path, tier: u32) -> PathBuf {
dir.join(format!("{tier}.webp"))
}
/// External-provider tier file in the SAME entity dir, differentiated by a
/// filename suffix only (image-scraper §14/§16): `{tier}-{provider}.webp`
/// (e.g. `2000-fanart.webp`). The `coverStorageKey`/`cacheKind` is unchanged.
pub fn provider_tier_path(dir: &Path, tier: u32, provider: &str) -> PathBuf {
dir.join(format!("{tier}-{provider}.webp"))
}
pub fn provider_tier_exists(dir: &Path, tier: u32, provider: &str) -> Option<PathBuf> {
let p = provider_tier_path(dir, tier, provider);
if p.is_file() {
Some(p)
} else {
None
}
}
#[allow(dead_code)]
pub fn meta_path(dir: &Path) -> PathBuf {
dir.join("meta.json")
+332
View File
@@ -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));
}
}
@@ -0,0 +1,408 @@
//! External artist-artwork ensure path (image-scraper). Split out of `mod.rs`
//! to keep the cover-cache orchestrator navigable: the on-demand fanart/banner
//! fetch (fanart.tv via MBID resolution), the §11 quality gate, the
//! surface-aware peek, and the §12 lookup-table cache. Everything here is gated
//! by `ensure_inner` (feature flag, `!library_bulk`, artist kind) — see the call
//! site in `mod.rs`. Pure code move; behaviour unchanged.
use super::encode::write_webp_tier;
use super::{decode_image_bytes, disk, external, fetch, peek_fallback_tiers, peek_tier_path};
use super::CoverCacheEnsureArgs;
use psysonic_library::LibraryRuntime;
use reqwest::Client;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Manager};
use tokio::sync::Semaphore;
/// Spike negative-cache marker — "this artist has no fanart", mirrors the
/// §11 quality gate for the fanart (16:9) surface: an existing on-disk image
/// pre-empts an external fetch only when it is wide enough AND roughly 16:9.
/// Square Navidrome artist portraits never satisfy it.
const FANART_MIN_WIDTH: u32 = 1280;
const FANART_ASPECT_MIN: f32 = 1.6;
const FANART_ASPECT_MAX: f32 = 2.0;
/// The external-artwork surfaces fanart.tv serves for an artist. Returns the
/// surface name — also the on-disk file suffix (`{tier}-{surface}.webp`) and the
/// lookup `surface_kind` — when the requested surface is external, else `None`.
pub(super) fn external_surface(surface_kind: Option<&str>) -> Option<&str> {
match surface_kind {
Some("fanart") => Some("fanart"),
Some("banner") => Some("banner"),
_ => None,
}
}
/// Like [`peek_tier_path`] but, for an external surface (`fanart`/`banner`),
/// serves only the matching `{tier}-{surface}.webp` tiers. If none exist yet it
/// returns None so ensure runs the external branch (fetch; Navidrome is the
/// fallback inside that branch's miss path) instead of short-circuiting on a
/// cached Navidrome tier (§18, "external prioritised").
pub(super) fn peek_cover_path(dir: &Path, want: u32, args: &CoverCacheEnsureArgs) -> Option<PathBuf> {
if let Some(surface) = external_surface(args.surface_kind.as_deref()) {
if let Some(p) = disk::provider_tier_exists(dir, want, surface) {
return Some(p);
}
for &tier in peek_fallback_tiers(want) {
if let Some(p) = disk::provider_tier_exists(dir, tier, surface) {
return Some(p);
}
}
return None;
}
peek_tier_path(dir, want)
}
fn marker_recent(path: &Path, max_age: Duration) -> bool {
std::fs::metadata(path)
.and_then(|m| m.modified())
.map(|t| t.elapsed().map(|e| e < max_age).unwrap_or(true))
.unwrap_or(false)
}
fn write_marker(path: &Path) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, b"1");
}
/// §11: do these pixel dimensions satisfy the fanart (16:9) surface?
fn dims_satisfy_fanart(w: u32, h: u32) -> bool {
if w < FANART_MIN_WIDTH || h == 0 {
return false;
}
let aspect = w as f32 / h as f32;
(FANART_ASPECT_MIN..=FANART_ASPECT_MAX).contains(&aspect)
}
/// §11 quality gate: true when a Navidrome tier already on disk is an HQ ~16:9
/// image (so the external fetch can be skipped). Reads dimensions only — no
/// full decode. Square artist portraits fail and external proceeds.
fn navidrome_tier_is_hq_fanart(dir: &Path) -> bool {
for &tier in &[2000u32, 800, 512, 256, 128] {
let p = disk::tier_path(dir, tier);
if p.is_file() {
if let Ok((w, h)) = image::image_dimensions(&p) {
if dims_satisfy_fanart(w, h) {
return true;
}
}
}
}
false
}
fn now_unix_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// Read the cached `artist_artwork_lookup` row for a surface off the async
/// executor (§12).
async fn read_artist_lookup(
store: &Option<Arc<psysonic_library::store::LibraryStore>>,
server_id: &str,
artist_id: &str,
surface: &str,
) -> Option<psysonic_library::artist_artwork::ArtistArtworkRow> {
let store = store.clone()?;
let (server_id, artist_id, surface) =
(server_id.to_string(), artist_id.to_string(), surface.to_string());
tauri::async_runtime::spawn_blocking(move || {
psysonic_library::artist_artwork::get_artist_artwork(&store, &server_id, &artist_id, &surface)
.ok()
.flatten()
})
.await
.ok()
.flatten()
}
/// Upsert an `artist_artwork_lookup` row off the async executor (§12). No-op
/// when the library store is absent (e.g. before login).
#[allow(clippy::too_many_arguments)]
async fn persist_artist_lookup(
store: &Option<Arc<psysonic_library::store::LibraryStore>>,
server_id: &str,
artist_id: &str,
surface: &str,
status: &str,
mbid: Option<&str>,
mbid_source: Option<&str>,
provider: Option<&str>,
now: i64,
) {
let Some(store) = store.clone() else {
return;
};
let (server_id, artist_id, surface, status) = (
server_id.to_string(),
artist_id.to_string(),
surface.to_string(),
status.to_string(),
);
let (mbid, mbid_source, provider) = (
mbid.map(String::from),
mbid_source.map(String::from),
provider.map(String::from),
);
let _ = tauri::async_runtime::spawn_blocking(move || {
psysonic_library::artist_artwork::upsert_artist_artwork(
&store,
&server_id,
&artist_id,
&surface,
mbid.as_deref(),
mbid_source.as_deref(),
&status,
provider.as_deref(),
now,
)
})
.await;
}
/// Try to satisfy an external artist `surface` (`fanart` 16:9 background or
/// `banner` strip) from fanart.tv. Writes `{2000,512}-{surface}.webp` into the
/// entity dir and returns the requested-tier path on success. `None` = "no
/// image, fall through to Navidrome" — never writes a `.fetch-failed` marker
/// (§28).
///
/// MBID resolution stays Rust-side (§23): the tag MBID via `getArtistInfo2`,
/// else a name→MusicBrainz album-confirmed lookup (§19), cached per surface in
/// `artist_artwork_lookup` (§12). The §11 quality gate runs first for the
/// `fanart` surface only.
#[allow(clippy::too_many_arguments)]
pub(super) async fn try_external_fanart(
app: &AppHandle,
args: &CoverCacheEnsureArgs,
dir: &Path,
client: &Client,
fanart_sem: &Arc<Semaphore>,
musicbrainz_sem: &Arc<Semaphore>,
requested: u32,
surface: &str,
) -> Option<PathBuf> {
// Behind the project key: a runtime env var (dev convenience) wins, else the
// key baked in at build time via `option_env!` (release builds). No secret
// lands in the repo. The BYOK personal key is optional (§22).
let api_key = std::env::var("PSYSONIC_FANART_KEY")
.ok()
.or_else(|| option_env!("PSYSONIC_FANART_KEY").map(str::to_string))
.filter(|k| !k.is_empty())?;
// BYOK personal key (§22): the settings field wins, else the dev env var.
let byok = args
.external_artwork_byok
.as_deref()
.map(str::trim)
.filter(|k| !k.is_empty())
.map(str::to_string)
.or_else(|| std::env::var("PSYSONIC_FANART_CLIENT_KEY").ok())
.filter(|k| !k.is_empty());
// §11 quality gate applies to the 16:9 `fanart` surface only — if Navidrome
// already serves an HQ ~16:9 image, skip the external fetch. The `banner`
// strip has its own aspect and is never pre-empted by a Navidrome tier.
if surface == "fanart" && navidrome_tier_is_hq_fanart(dir) {
return None;
}
// §12: the lookup table is both the MBID resolution cache and the negative
// cache. Absent before login → all reads/writes become no-ops.
let store: Option<Arc<psysonic_library::store::LibraryStore>> =
app.try_state::<LibraryRuntime>().map(|rt| rt.store.clone());
let server_id = &args.server_index_key;
let artist_id = &args.cache_entity_id;
let now = now_unix_ms();
let cached = read_artist_lookup(&store, server_id, artist_id, surface).await;
if let Some(row) = &cached {
// Back off: no/ambiguous MBID for 24h; a confirmed "no fanart" miss for
// 30 min (also held by the `.miss-fanart` marker).
let within = |window: Duration| now - row.updated_at < window.as_millis() as i64;
match row.status.as_str() {
"no_mbid" | "mbid_ambiguous" if within(Duration::from_secs(24 * 60 * 60)) => {
return None;
}
"miss" if within(Duration::from_secs(30 * 60)) => return None,
_ => {}
}
}
let miss_marker = dir.join(format!(".miss-{surface}"));
if marker_recent(&miss_marker, Duration::from_secs(30 * 60)) {
return None;
}
let _permit = fanart_sem.clone().acquire_owned().await.ok()?;
// §23: resolve the tag MBID Rust-side via getArtistInfo2 — unless the cache
// already carries one (skip the Navidrome round-trip).
let (mbid, mbid_source) = match cached.as_ref().and_then(|r| r.mbid.clone()) {
Some(m) => (m, cached.as_ref().and_then(|r| r.mbid_source.clone())),
None => match external::fetch_artist_tag_mbid(
client,
&args.rest_base_url,
&args.username,
&args.password,
&args.cache_entity_id,
)
.await
{
Ok(Some(m)) => (m, Some("tag".to_string())),
Ok(None) => {
// No tag MBID. §19: try a name→MusicBrainz album-confirmed lookup
// when both the artist name and an album are in context.
match (args.artist_name.as_deref(), args.album_title.as_deref()) {
(Some(name), Some(album))
if !name.trim().is_empty() && !album.trim().is_empty() =>
{
// ≤1 req/s: hold the single MB permit across the request
// plus a ≥1s spacing so concurrent ensures can't burst MB.
let _mb = musicbrainz_sem.clone().acquire_owned().await.ok()?;
let resolved =
external::resolve_mbid_via_musicbrainz(client, name, album).await;
tokio::time::sleep(Duration::from_millis(1100)).await;
drop(_mb);
match resolved {
Ok(external::MbResolution::Found(m)) => {
(m, Some("musicbrainz".to_string()))
}
Ok(external::MbResolution::Ambiguous) => {
persist_artist_lookup(
&store, server_id, artist_id, surface, "mbid_ambiguous", None,
None, None, now,
)
.await;
return None;
}
Ok(external::MbResolution::None) => {
persist_artist_lookup(
&store, server_id, artist_id, surface, "no_mbid", None, None,
None, now,
)
.await;
return None;
}
Err(e) => {
eprintln!("[fanart] musicbrainz failed: {e}"); // transient
return None;
}
}
}
_ => {
// No album context → we could not even *attempt* name→MB.
// Do NOT cache `no_mbid`: a later ensure that arrives with
// album context (e.g. once the artist's album list loads)
// would otherwise be blocked by the 24h backoff.
return None;
}
}
}
Err(e) => {
eprintln!("[fanart] getArtistInfo2 failed: {e}"); // transient — don't cache
return None;
}
},
};
let img_url = match external::fetch_fanart_image_url(
client,
&mbid,
&api_key,
byok.as_deref(),
surface,
)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
write_marker(&miss_marker); // artist has no image of this kind
persist_artist_lookup(
&store,
server_id,
artist_id,
surface,
"miss",
Some(&mbid),
mbid_source.as_deref(),
None,
now,
)
.await;
return None;
}
Err(e) => {
eprintln!("[fanart] lookup failed: {e}"); // transient — don't cache
return None;
}
};
let bytes = match fetch::fetch_cover_bytes(client, &img_url).await {
Ok(b) => b,
Err(e) => {
eprintln!("[fanart] download failed: {e}"); // transient — don't cache
return None;
}
};
// Decode + write {2000,512}-{surface}.webp (matryoshka §17).
let dir_owned = dir.to_path_buf();
let surface_owned = surface.to_string();
let encoded = tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let img = decode_image_bytes(&bytes)?;
std::fs::create_dir_all(&dir_owned).map_err(|e| e.to_string())?;
for tier in [2000u32, 512u32] {
write_webp_tier(&img, tier, &disk::provider_tier_path(&dir_owned, tier, &surface_owned))?;
}
Ok(())
})
.await;
if !matches!(encoded, Ok(Ok(()))) {
eprintln!("[fanart] encode failed: {encoded:?}");
return None;
}
persist_artist_lookup(
&store,
server_id,
artist_id,
surface,
"hit",
Some(&mbid),
mbid_source.as_deref(),
Some("fanart"),
now,
)
.await;
// NOTE: do NOT emit `cover:tier-ready` here. That event is keyed by the
// canonical cover key (cacheKind/cacheEntityId/tier, no surface), so emitting
// it with the `{tier}-{surface}.webp` path would seed the frontend disk-src
// cache for the *Navidrome* artist cover with the external image — leaking
// fanart/banner into the plain artist cover (avatar, FS "navidrome-artist"
// fallback) even with the scraper off. The external hooks read the path from
// this function's return value, so no event is needed.
Some(disk::provider_tier_path(dir, requested, surface))
}
#[cfg(test)]
mod fanart_gate_tests {
use super::dims_satisfy_fanart;
#[test]
fn gate_accepts_wide_16_9_and_rejects_square_or_small() {
assert!(dims_satisfy_fanart(2000, 1125)); // 16:9, wide
assert!(dims_satisfy_fanart(1280, 800)); // aspect 1.6 boundary
assert!(dims_satisfy_fanart(1280, 640)); // aspect 2.0 boundary
assert!(!dims_satisfy_fanart(2000, 2000)); // square portrait
assert!(!dims_satisfy_fanart(1000, 560)); // width < 1280
assert!(!dims_satisfy_fanart(1280, 600)); // aspect 2.13 > 2.0
assert!(!dims_satisfy_fanart(1280, 0)); // div-by-zero guard
}
}
+29 -8
View File
@@ -19,12 +19,15 @@ fn random_salt() -> String {
format!("{nanos:x}")
}
pub fn build_cover_art_url(
/// Build a token-authed Subsonic REST URL `{rest_base}/rest/{endpoint}.view`
/// with the standard `u/t/s/v/c` auth params plus the given `extra` query
/// pairs. Shared by all Subsonic GETs (cover art, `getArtistInfo2`, …).
pub(crate) fn build_subsonic_url(
rest_base: &str,
endpoint: &str,
username: &str,
password: &str,
cover_art_id: &str,
size: u32,
extra: &[(&str, &str)],
) -> String {
let base = rest_base.trim_end_matches('/');
let api_base = if base.ends_with("/rest") {
@@ -34,25 +37,43 @@ pub fn build_cover_art_url(
};
let salt = random_salt();
let token = format!("{:x}", md5::compute(format!("{password}{salt}")));
let endpoint = format!("{api_base}/getCoverArt.view");
let endpoint_url = format!("{api_base}/{endpoint}.view");
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair("id", cover_art_id);
serializer.append_pair("size", &size.to_string());
for (k, v) in extra {
serializer.append_pair(k, v);
}
serializer.append_pair("u", username);
serializer.append_pair("t", &token);
serializer.append_pair("s", &salt);
serializer.append_pair("v", "1.16.1");
serializer.append_pair("c", SUBSONIC_CLIENT);
let query = serializer.finish();
match Url::parse(&endpoint) {
match Url::parse(&endpoint_url) {
Ok(mut url) => {
url.set_query(Some(&query));
url.to_string()
}
Err(_) => format!("{endpoint}?{query}"),
Err(_) => format!("{endpoint_url}?{query}"),
}
}
pub fn build_cover_art_url(
rest_base: &str,
username: &str,
password: &str,
cover_art_id: &str,
size: u32,
) -> String {
let size_s = size.to_string();
build_subsonic_url(
rest_base,
"getCoverArt",
username,
password,
&[("id", cover_art_id), ("size", &size_s)],
)
}
/// Outcome of a single fetch attempt: transient errors are worth retrying,
/// permanent ones (a real 4xx like 404 — the cover simply does not exist) are
/// not, so we never hammer the server for genuinely-missing art.
+169 -2
View File
@@ -3,6 +3,8 @@
mod backfill_worker;
mod disk;
mod encode;
mod external;
mod external_ensure;
mod fetch;
use disk::{cover_dir, tier_exists, tier_path, DERIVE_TIERS};
@@ -133,6 +135,27 @@ pub struct CoverCacheEnsureArgs {
/// with the album/artist name. On-demand UI ensures leave it `None`.
#[serde(default)]
pub library_server_id: Option<String>,
/// Image-scraper spike (§16 P0): when true, an artist `fanart` ensure may
/// fetch a fanart.tv background into `{tier}-fanart.webp`. Inert unless the
/// fanart project key is present (`PSYSONIC_FANART_KEY`). Off by default.
#[serde(default)]
pub external_artwork_enabled: bool,
/// Surface intent for external artwork — `fanart` for the 16:9 artist
/// background. `None` on plain cover ensures.
#[serde(default)]
pub surface_kind: Option<String>,
/// Artist display name — context for the §19 name→MusicBrainz fallback when
/// the artist carries no tag MBID. `None` skips that fallback.
#[serde(default)]
pub artist_name: Option<String>,
/// Album title currently in context (fullscreen playback) — disambiguates
/// the name→MusicBrainz query (§19).
#[serde(default)]
pub album_title: Option<String>,
/// Optional BYOK personal fanart.tv key from settings — sent in addition to
/// the project key (§22). Falls back to the `PSYSONIC_FANART_CLIENT_KEY` env.
#[serde(default)]
pub external_artwork_byok: Option<String>,
}
fn cover_dir_for_args(root: &Path, args: &CoverCacheEnsureArgs) -> PathBuf {
@@ -148,6 +171,9 @@ const COVER_CPU_UI_CONCURRENCY: usize = 2;
const COVER_CPU_BACKFILL_CONCURRENCY: usize = 2;
/// Upper bound for the runtime encode-pool knob (matches the worker cap).
const COVER_CPU_BACKFILL_MAX: usize = 16;
/// External providers (fanart.tv) get their own low-concurrency HTTP lane so
/// they can never starve Navidrome cover / getArtistInfo2 fetches (§26).
const FANART_HTTP_CONCURRENCY: usize = 4;
pub struct CoverCacheState {
pub root: PathBuf,
@@ -158,6 +184,13 @@ pub struct CoverCacheState {
pub http_sem: Arc<Semaphore>,
pub cover_cpu_ui_sem: Arc<Semaphore>,
pub cover_cpu_backfill_sem: Arc<Semaphore>,
/// External-provider (fanart.tv) HTTP lane — separate from `http_sem` so
/// external fetches never starve Navidrome cover / getArtistInfo2 (§26).
pub fanart_http_sem: Arc<Semaphore>,
/// MusicBrainz name→MBID lane — a single permit, so the §19 resolver runs
/// strictly serially and the caller's ≥1s spacing keeps us under MB's rate
/// limit (their ToS).
pub musicbrainz_sem: Arc<Semaphore>,
/// Live permit count of `cover_cpu_backfill_sem` (the semaphore itself only
/// exposes *available* permits, not the configured ceiling).
cover_cpu_backfill_max: AtomicUsize,
@@ -180,6 +213,8 @@ impl CoverCacheState {
http_sem: Arc::new(Semaphore::new(COVER_HTTP_CONCURRENCY)),
cover_cpu_ui_sem: Arc::new(Semaphore::new(COVER_CPU_UI_CONCURRENCY)),
cover_cpu_backfill_sem: Arc::new(Semaphore::new(COVER_CPU_BACKFILL_CONCURRENCY)),
fanart_http_sem: Arc::new(Semaphore::new(FANART_HTTP_CONCURRENCY)),
musicbrainz_sem: Arc::new(Semaphore::new(1)),
cover_cpu_backfill_max: AtomicUsize::new(COVER_CPU_BACKFILL_CONCURRENCY),
})
}
@@ -229,7 +264,7 @@ impl CoverCacheState {
) -> Result<CoverCacheEnsureResult, String> {
let this = state.lock().await;
let dir = cover_dir_for_args(&this.root, args);
if let Some(path) = peek_tier_path(&dir, args.tier) {
if let Some(path) = external_ensure::peek_cover_path(&dir, args.tier, args) {
return Ok(CoverCacheEnsureResult {
hit: true,
path: path.to_string_lossy().into_owned(),
@@ -254,6 +289,8 @@ impl CoverCacheState {
let root = this.root.clone();
let http_sem = http_sem_override.unwrap_or_else(|| this.http_sem.clone());
let cover_cpu_sem = this.cpu_sem_for(args.library_bulk);
let fanart_sem = this.fanart_http_sem.clone();
let musicbrainz_sem = this.musicbrainz_sem.clone();
drop(this);
if cover_fetch_recently_failed(&dir) {
@@ -264,6 +301,33 @@ impl CoverCacheState {
});
}
// For an external artist surface (`fanart` 16:9 background or `banner`
// strip), try fanart.tv before the Navidrome fallback. On any miss it
// falls through WITHOUT writing a `.fetch-failed` marker, so Navidrome
// stays the display fallback (§28).
if args.external_artwork_enabled && !args.library_bulk && args.cache_kind == "artist" {
if let Some(surface) = external_ensure::external_surface(args.surface_kind.as_deref()) {
if let Some(path) = external_ensure::try_external_fanart(
app,
args,
&dir,
&client,
&fanart_sem,
&musicbrainz_sem,
args.tier,
surface,
)
.await
{
return Ok(CoverCacheEnsureResult {
hit: true,
path: path.to_string_lossy().into_owned(),
tier: args.tier,
});
}
}
}
let requested = args.tier;
let quiet = args.library_bulk;
let tiers_now: Vec<u32> = if args.library_bulk {
@@ -824,6 +888,7 @@ fn peek_tier_path(dir: &Path, want: u32) -> Option<PathBuf> {
None
}
#[tauri::command]
pub async fn cover_cache_ensure(
app: AppHandle,
@@ -923,6 +988,17 @@ pub async fn cover_cache_clear_server(
}
invalidate_dir_usage_cache(&server_index_key);
drop(guard);
// §12/B.4: the on-disk external tiers (`{tier}-fanart.webp` / `-banner.webp`)
// + `.miss-*` markers went with the dir removal above; also drop the
// `artist_artwork_lookup` rows for this server so no resolution state lingers.
if let Some(rt) = app.try_state::<LibraryRuntime>() {
let store = rt.store.clone();
let key = server_index_key.clone();
let _ = tauri::async_runtime::spawn_blocking(move || {
psysonic_library::artist_artwork::clear_artist_artwork_for_server(&store, &key)
})
.await;
}
// Clearing drops files the cheap idle-gate signature can't see, so re-arm
// the backfill worker — otherwise the next sync-idle would skip the rescan.
if let Some(worker) = app.try_state::<Arc<CoverBackfillWorker>>() {
@@ -935,6 +1011,65 @@ pub async fn cover_cache_clear_server(
Ok(())
}
/// Delete only external-provider artifacts under a server's cover dir — the
/// `{tier}-{provider}.webp` tiers and `.miss-{provider}` markers — leaving the
/// canonical Navidrome `{tier}.webp` and `.fetch-failed` untouched (Navidrome
/// tiers have no `-` in the stem; their marker is `.fetch-failed`, not
/// `.miss-*`). FS-only so it is testable against a real `tempdir`. Returns the
/// number of files removed.
fn purge_external_files(server_dir: &Path) -> usize {
fn is_external(name: &str) -> bool {
(name.ends_with(".webp") && name.contains('-')) || name.starts_with(".miss-")
}
fn walk(dir: &Path, count: &mut usize) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
walk(&p, count);
} else if p.file_name().and_then(|n| n.to_str()).is_some_and(is_external)
&& std::fs::remove_file(&p).is_ok()
{
*count += 1;
}
}
}
let mut count = 0;
walk(server_dir, &mut count);
count
}
/// Opt-out purge (§9, §12, Appendix B.4): drop every external artwork artifact
/// for a server — `{tier}-{provider}.webp`, `.miss-{provider}`, and the
/// `artist_artwork_lookup` rows — while leaving the canonical Navidrome covers
/// intact. Fired when the user turns the External Artwork toggle off. Unlike
/// `cover_cache_clear_server`, Navidrome tiers survive.
#[tauri::command]
pub async fn cover_cache_purge_external(
app: AppHandle,
server_index_key: String,
) -> Result<(), String> {
let st = state(&app)?;
let guard = st.lock().await;
let path = cover_server_dir(&guard.root, &server_index_key);
if path.is_dir() {
purge_external_files(&path);
}
invalidate_dir_usage_cache(&server_index_key);
drop(guard);
if let Some(rt) = app.try_state::<LibraryRuntime>() {
let store = rt.store.clone();
let key = server_index_key.clone();
let _ = tauri::async_runtime::spawn_blocking(move || {
psysonic_library::artist_artwork::clear_artist_artwork_for_server(&store, &key)
})
.await;
}
Ok(())
}
/// Rename a server's cover-cache bucket on disk after the user edits the
/// primary URL (and the derived index key changes). Used by the URL-change
/// remigration pipeline (dual-server-address spec §8.3) so cached covers
@@ -1214,7 +1349,10 @@ mod tests {
use super::decode_image_bytes;
use super::disk::{cover_dir, tier_path};
use super::{count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, rename_bucket_inner};
use super::{
count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, purge_external_files,
rename_bucket_inner,
};
use psysonic_core::cover_cache_layout::CANONICAL_PROGRESS_TIER;
use std::fs;
use std::path::PathBuf;
@@ -1409,4 +1547,33 @@ mod tests {
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn purge_external_removes_only_external_artifacts() {
let root = fresh_tmpdir("purge-external");
let entity = root.join("artist").join("ar-1");
fs::create_dir_all(&entity).unwrap();
// Navidrome canonical — must survive.
fs::write(entity.join("2000.webp"), b"n").unwrap();
fs::write(entity.join("512.webp"), b"n").unwrap();
fs::write(entity.join(".fetch-failed"), b"1").unwrap();
// External — must go.
fs::write(entity.join("2000-fanart.webp"), b"f").unwrap();
fs::write(entity.join("512-fanart.webp"), b"f").unwrap();
fs::write(entity.join("2000-banner.webp"), b"b").unwrap();
fs::write(entity.join(".miss-fanart"), b"1").unwrap();
fs::write(entity.join(".miss-banner"), b"1").unwrap();
assert_eq!(purge_external_files(&root), 5);
assert!(entity.join("2000.webp").exists());
assert!(entity.join("512.webp").exists());
assert!(entity.join(".fetch-failed").exists());
assert!(!entity.join("2000-fanart.webp").exists());
assert!(!entity.join("512-fanart.webp").exists());
assert!(!entity.join("2000-banner.webp").exists());
assert!(!entity.join(".miss-fanart").exists());
assert!(!entity.join(".miss-banner").exists());
let _ = fs::remove_dir_all(&root);
}
}