artist art: a provider's API being up is no promise its CDN is
On 2026-07-28 `webservice.fanart.tv` answered every request normally while `assets.fanart.tv` refused TCP on both :443 and :80. The two are different machines, and the hero chain only ever asked the first one anything. Every gate in that chain tests whether a provider RETURNED URLS, never whether those URLs load. So Fanart.tv handed back its usual eight banners and four logos, `banners.is_empty()` was false, and the two fallbacks that exist for exactly this situation -- TheAudioDB, then Bing -- were both skipped. The page then fell back to a blown-up square portrait. The tell that this was a provider outage rather than a regression in the hero code is that it was artist-shaped: every artist Fanart.tv covers lost its hero, and the ones it does not cover (Tame Impala) were untouched. Worth recording that browsing fanart.tv proves nothing here -- that is a third host, on Cloudflare, and it was up throughout. The check is a one-byte ranged GET, so it is proof the host serves image bytes without pulling a 4K background to find out. Any HTTP answer counts as alive: a 404 on one image is not an outage, so only a transport failure or a 5xx marks a host dead. Cached per host for 5 minutes, which is long enough that a dead CDN costs one probe rather than one per artist, and short enough that the hero comes back within minutes of the host recovering -- no restart, no cache clearing. The probe races the page, hence the 3s ceiling. It has to be applied in three places, and the third is the one that is easy to miss: - Discovery, after Fanart and again after TheAudioDB, so an unreachable CDN reads downstream as "this provider had nothing" -- precisely the state the fallback chain already knows how to handle. - The persisted manifest, because a manifest outlives the CDN behind it. A stored answer short-circuits discovery entirely, so replaying one produces a hero that never loads AND no path to the fallback that would have worked. - The "keep previous banners as a floor" clause. Restoring unreachable banners would undo the whole point of falling through: discovery correctly dropped a dead CDN, and the floor would put it straight back. Note for the next time this looks like a renderer bug: a 1x1 hero canvas is a SYMPTOM, not the cause. It means no image ever decoded, so the GPU renderer never sized itself.
This commit is contained in:
1 parent
0cc01328a4
commit
6d6968e9b1
2 files changed
+131
-10
No files matched your search
@@ -205,6 +205,15 @@ fn stage_trace(stage: &str, started: Instant, detail: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a provider image host keeps its last liveness verdict.
|
||||||
|
///
|
||||||
|
/// Long enough that a dead CDN costs one probe rather than one per artist,
|
||||||
|
/// short enough that the hero comes back on its own within minutes of the host
|
||||||
|
/// recovering — no restart, no cache clearing.
|
||||||
|
const IMAGE_HOST_PROBE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||||
|
/// A liveness probe races the page, so it must never become the page's latency.
|
||||||
|
const IMAGE_HOST_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
/// Cached catalog responses expire after this long.
|
/// Cached catalog responses expire after this long.
|
||||||
const CATALOG_TTL: Duration = Duration::from_secs(15 * 60);
|
const CATALOG_TTL: Duration = Duration::from_secs(15 * 60);
|
||||||
/// Deezer publishes a budget of 50 requests per 5 seconds per IP and answers an
|
/// Deezer publishes a budget of 50 requests per 5 seconds per IP and answers an
|
||||||
@@ -1572,6 +1581,12 @@ impl MetadataClient {
|
|||||||
};
|
};
|
||||||
let (mut tadb, (linked_mbid, (mut banners, mut logos))) =
|
let (mut tadb, (linked_mbid, (mut banners, mut logos))) =
|
||||||
tokio::join!(self.audiodb_artist(artist), linked_fanart);
|
tokio::join!(self.audiodb_artist(artist), linked_fanart);
|
||||||
|
// Fanart.tv's API host and its asset host are different machines, and
|
||||||
|
// a live API is no promise of a live CDN. Verify before these URLs win,
|
||||||
|
// or a dead CDN satisfies every `is_empty()` guard below and silently
|
||||||
|
// locks out both TheAudioDB and Bing. See `image_host_alive`.
|
||||||
|
self.drop_if_host_is_down(&mut banners).await;
|
||||||
|
self.drop_if_host_is_down(&mut logos).await;
|
||||||
// A same-name TheAudioDB record may be a completely different act.
|
// A same-name TheAudioDB record may be a completely different act.
|
||||||
// Keep it only when one of its provider ids independently closes the
|
// Keep it only when one of its provider ids independently closes the
|
||||||
// loop back to the requested Deezer identity. In particular, never use
|
// loop back to the requested Deezer identity. In particular, never use
|
||||||
@@ -1611,6 +1626,13 @@ impl MetadataClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same check for whatever TheAudioDB just supplied. Cached per host, so
|
||||||
|
// when Fanart was the dead one this costs nothing, and when TheAudioDB
|
||||||
|
// is the dead one it leaves `banners` empty for the Bing fallback in
|
||||||
|
// `resolve_art` rather than handing the page more unloadable URLs.
|
||||||
|
self.drop_if_host_is_down(&mut banners).await;
|
||||||
|
self.drop_if_host_is_down(&mut logos).await;
|
||||||
|
|
||||||
ArtistArt { banners, logos }
|
ArtistArt { banners, logos }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2857,6 +2879,85 @@ impl MetadataClient {
|
|||||||
select_audiodb_artist(&body, artist).cloned()
|
select_audiodb_artist(&body, artist).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is a provider's IMAGE host actually serving bytes right now?
|
||||||
|
///
|
||||||
|
/// A provider's API and its CDN are different machines, and the API being
|
||||||
|
/// up is no promise the images are. Measured 2026-07-28: Fanart.tv's
|
||||||
|
/// `webservice.fanart.tv` answered normally while `assets.fanart.tv`
|
||||||
|
/// refused every TCP connection. Because the whole hero chain gates on
|
||||||
|
/// "did the provider return URLs" and never on "do those URLs load", every
|
||||||
|
/// artist Fanart.tv covers got eight dead banners and four dead logos —
|
||||||
|
/// and the `banners.is_empty()` guards that pick TheAudioDB and then Bing
|
||||||
|
/// were both satisfied, so neither fallback ever ran. Pages fell back to a
|
||||||
|
/// blown-up square artist portrait; only artists Fanart.tv did NOT cover
|
||||||
|
/// (Tame Impala) still had a working hero.
|
||||||
|
///
|
||||||
|
/// One probe per host per [`IMAGE_HOST_PROBE_TTL`] turns "this provider's
|
||||||
|
/// CDN is down" into "this provider returned nothing", which is precisely
|
||||||
|
/// the state the existing fallback chain already knows how to handle.
|
||||||
|
pub async fn image_host_alive(&self, url: &str) -> bool {
|
||||||
|
static HEALTH: std::sync::OnceLock<Mutex<HashMap<String, (bool, Instant)>>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
let Some(host) = reqwest::Url::parse(url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|parsed| parsed.host_str().map(String::from))
|
||||||
|
else {
|
||||||
|
// Not a URL this can reason about; never block art on that.
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let health = HEALTH.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
if let Some((alive, checked)) = health.lock().await.get(&host).copied() {
|
||||||
|
if checked.elapsed() < IMAGE_HOST_PROBE_TTL {
|
||||||
|
return alive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A one-byte ranged GET: proof the host serves image bytes without
|
||||||
|
// pulling a 4K background to find out. Any HTTP answer means the host
|
||||||
|
// is up — a 404 for one image is not an outage — so only a transport
|
||||||
|
// failure or a server error counts as dead.
|
||||||
|
let alive = tokio::time::timeout(
|
||||||
|
IMAGE_HOST_PROBE_TIMEOUT,
|
||||||
|
self.http
|
||||||
|
.get(url)
|
||||||
|
.header(reqwest::header::RANGE, "bytes=0-0")
|
||||||
|
.send(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|sent| sent.ok())
|
||||||
|
.is_some_and(|resp| !resp.status().is_server_error());
|
||||||
|
if !alive {
|
||||||
|
log::warn!("image host {host} is not serving images; falling through to next provider");
|
||||||
|
}
|
||||||
|
health.lock().await.insert(host, (alive, Instant::now()));
|
||||||
|
alive
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can this already-chosen artwork actually be shown right now?
|
||||||
|
///
|
||||||
|
/// A persisted manifest is a list of URLs, and the CDN behind them can go
|
||||||
|
/// down long after it was written. Serving one then produces a hero that
|
||||||
|
/// never loads AND no path to the fallback that would have worked, because
|
||||||
|
/// the stored answer short-circuits discovery entirely. A manifest with no
|
||||||
|
/// banners is not a liveness question, and a custom data: URL has no host.
|
||||||
|
pub async fn art_is_servable(&self, art: &CatalogArtistArt) -> bool {
|
||||||
|
let Some(url) = art.banners.first().or(art.banner.as_ref()) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
self.image_host_alive(url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a candidate list whose host cannot currently serve it, so an
|
||||||
|
/// unreachable CDN reads downstream as "this provider had nothing".
|
||||||
|
async fn drop_if_host_is_down(&self, urls: &mut Vec<String>) {
|
||||||
|
let Some(probe) = urls.first().cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !self.image_host_alive(&probe).await {
|
||||||
|
urls.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fanart.tv's most-liked 1920×1080 backgrounds and HD ClearLOGO candidates
|
/// Fanart.tv's most-liked 1920×1080 backgrounds and HD ClearLOGO candidates
|
||||||
/// for a MusicBrainz id — one call, both best-effort. Returns
|
/// for a MusicBrainz id — one call, both best-effort. Returns
|
||||||
/// `(banners, logos)`; either list is empty when the artist has none.
|
/// `(banners, logos)`; either list is empty when the artist has none.
|
||||||
|
|||||||
+30
-10
@@ -1443,8 +1443,19 @@ pub async fn catalog_artist_art(
|
|||||||
// address the already-known hero immediately instead of rediscovering Bing.
|
// address the already-known hero immediately instead of rediscovering Bing.
|
||||||
let stored = read_artist_art_manifest(&app, artist_id).await;
|
let stored = read_artist_art_manifest(&app, artist_id).await;
|
||||||
let mut art = match stored {
|
let mut art = match stored {
|
||||||
// Fresh, current recipe: serve it, no network.
|
// Fresh, current recipe, and the host is still serving the images:
|
||||||
Some(stored) if stored.current => stored.art,
|
// serve it, no network. The liveness check matters because a manifest
|
||||||
|
// outlives the CDN behind it -- measured 2026-07-28, Fanart.tv's API
|
||||||
|
// stayed up while `assets.fanart.tv` refused every connection, so every
|
||||||
|
// stored manifest naming it replayed a hero that could never load and
|
||||||
|
// short-circuited the discovery that would have found a working one.
|
||||||
|
// One probe per host per 5 minutes, so the hero returns on its own once
|
||||||
|
// the CDN does.
|
||||||
|
Some(stored)
|
||||||
|
if stored.current && state.metadata.art_is_servable(&stored.art).await =>
|
||||||
|
{
|
||||||
|
stored.art
|
||||||
|
}
|
||||||
// Either nothing on disk, or something stale/older-recipe. Try to do
|
// Either nothing on disk, or something stale/older-recipe. Try to do
|
||||||
// better, but keep what we have as a floor: the discovery tier fails
|
// better, but keep what we have as a floor: the discovery tier fails
|
||||||
// per-artist-name (Bing collapses rare names onto popular entities and
|
// per-artist-name (Bing collapses rare names onto popular entities and
|
||||||
@@ -1452,14 +1463,23 @@ pub async fn catalog_artist_art(
|
|||||||
// a normal event, not a reason to show an artist with no hero.
|
// a normal event, not a reason to show an artist with no hero.
|
||||||
other => {
|
other => {
|
||||||
let resolved = state.metadata.catalog_artist_art(artist_id).await;
|
let resolved = state.metadata.catalog_artist_art(artist_id).await;
|
||||||
// Only banners we already had count as a floor. Note that
|
// Only banners we already had AND can still show count as a floor.
|
||||||
// `catalog_artist_art` returns Some as soon as the Deezer *profile*
|
// Restoring unreachable ones would undo the whole point of falling
|
||||||
// resolves, so "found nothing" arrives as Some with an empty banner
|
// through: discovery correctly dropped a dead CDN, and the floor
|
||||||
// list -- an emptiness check, not a None check, is what detects a
|
// would put it straight back. Note that `catalog_artist_art`
|
||||||
// failed hero discovery.
|
// returns Some as soon as the Deezer *profile* resolves, so "found
|
||||||
let previous = other
|
// nothing" arrives as Some with an empty banner list -- an
|
||||||
.map(|stored| stored.art)
|
// emptiness check, not a None check, is what detects a failed hero
|
||||||
.filter(|art| !art.banners.is_empty());
|
// discovery.
|
||||||
|
let previous = match other.map(|stored| stored.art) {
|
||||||
|
Some(art)
|
||||||
|
if !art.banners.is_empty()
|
||||||
|
&& state.metadata.art_is_servable(&art).await =>
|
||||||
|
{
|
||||||
|
Some(art)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
match (resolved, previous) {
|
match (resolved, previous) {
|
||||||
(Some(mut resolved), Some(previous)) if resolved.banners.is_empty() => {
|
(Some(mut resolved), Some(previous)) if resolved.banners.is_empty() => {
|
||||||
log::info!(
|
log::info!(
|
||||||
|
|||||||
Reference in new issue
Block a user