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:
KeelrLeek committed 2026-07-28 09:39:14 -05:00
1 parent 0cc01328a4
commit 6d6968e9b1
2 files changed
+131 -10

No files matched your search

+30 -10
View File
@@ -1443,8 +1443,19 @@ pub async fn catalog_artist_art(
// address the already-known hero immediately instead of rediscovering Bing.
let stored = read_artist_art_manifest(&app, artist_id).await;
let mut art = match stored {
// Fresh, current recipe: serve it, no network.
Some(stored) if stored.current => stored.art,
// Fresh, current recipe, and the host is still serving the images:
// 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
// better, but keep what we have as a floor: the discovery tier fails
// 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.
other => {
let resolved = state.metadata.catalog_artist_art(artist_id).await;
// Only banners we already had count as a floor. Note that
// `catalog_artist_art` returns Some as soon as the Deezer *profile*
// resolves, so "found nothing" arrives as Some with an empty banner
// list -- an emptiness check, not a None check, is what detects a
// failed hero discovery.
let previous = other
.map(|stored| stored.art)
.filter(|art| !art.banners.is_empty());
// Only banners we already had AND can still show count as a floor.
// Restoring unreachable ones would undo the whole point of falling
// through: discovery correctly dropped a dead CDN, and the floor
// would put it straight back. Note that `catalog_artist_art`
// returns Some as soon as the Deezer *profile* resolves, so "found
// nothing" arrives as Some with an empty banner list -- an
// emptiness check, not a None check, is what detects a failed hero
// 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) {
(Some(mut resolved), Some(previous)) if resolved.banners.is_empty() => {
log::info!(