backend: a cached track says so, and fallback hero art expires in a day

play_stream's fully-cached fast path plays the file and returns early -- no
StreamProgress, no download task, so the loop that emits soulseek:stream never
runs and not one event is ever emitted for that track. The frontend had
already set received=0/total=size when it asked to play, so a record that was
100% on disk sat pinned at "peer - 0%" for its whole length. It emits one
completed event now; done also lets the frontend take its finished-track path
(final waveform, pre-buffer the next).

The artist-art manifest TTL was a flat 30 days. Fanart.tv's CDN refused
connections on 2026-07-27, the chain correctly degraded to Bing -- and then
those Bing-tier heroes counted as fresh for a month, which is why low quality
art stayed put after the provider came back. The TTL is tier-aware now: art
whose banner came from fanart.tv keeps the 30 days, anything from below it is
re-checked daily. Tier is read back off the URL host because the manifest
never recorded which provider won, and banners only -- logos legitimately come
from TheAudioDB even when fanart covered the artist, and treating that as a
fallback would put every artist on a daily re-check.

Recipe bumped to v6 so every stored manifest re-resolves once against the
providers that are up today. Weezer went from Bing-tier art to 8 banners, all
assets.fanart.tv.
This commit is contained in:
KeelrLeek committed 2026-07-29 08:11:49 -05:00
1 parent 9dc3eaf939
commit d460352973
1 file changed
+62 -4
+62 -4
View File
@@ -766,7 +766,11 @@ const ARTIST_HERO_CACHE_MAX_ENTRY_BYTES: u64 = 41 * 1024 * 1024;
// holding a logo-less manifest this build would now fill. Safe to bump: a
// stale-recipe manifest is kept as a floor and only replaced by a better
// lookup, so nobody goes blank while the new one resolves.
const ARTIST_ART_MANIFEST_RECIPE: &str = "artist-art-tier-v5-mb-duplicate-identity";
// v6: manifests written while Fanart.tv's CDN was refusing connections
// (2026-07-27) hold Bing-tier heroes and would have kept them for a month under
// the flat TTL below. Bumping re-resolves every artist once against the
// providers that are up TODAY; the tier-aware TTL keeps it from happening again.
const ARTIST_ART_MANIFEST_RECIPE: &str = "artist-art-tier-v6-fallback-ttl";
// A resolved hero is expensive to find and does not go stale the way metadata
// does -- a press photo is still the artist's press photo a month later. The
// old 24h expiry meant every artist was re-discovered daily through a tier that
@@ -775,6 +779,35 @@ const ARTIST_ART_MANIFEST_RECIPE: &str = "artist-art-tier-v5-mb-duplicate-identi
// Pond, Weezer, Daft Punk...) went to 3. Match the cover-resolution horizon.
const ARTIST_ART_MANIFEST_TTL: std::time::Duration =
std::time::Duration::from_secs(30 * 24 * 60 * 60);
// ...but only for art that came from the TOP of the chain. Everything below it
// was a fallback taken because the better provider was unreachable at that
// moment -- `image_host_alive` deliberately reports a dead CDN as "returned
// nothing", which is what lets the chain degrade gracefully -- and a month is
// far too long to live with the consequences of one bad afternoon. Re-checked
// daily instead, so an artist stops wearing a Bing hero the day after Fanart.tv
// comes back.
const ARTIST_ART_FALLBACK_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
/// Did the top provider tier supply the banner this manifest will actually
/// show? The manifest never recorded which provider won, so the tier is read
/// back off the URL's host.
///
/// Banners only, deliberately: logos routinely and legitimately come from
/// TheAudioDB even when Fanart.tv covered the artist, and treating that as a
/// fallback would put every artist on a daily re-check. An artist Fanart.tv
/// simply does not cover is re-checked daily forever, which is the price of
/// noticing the day they add one.
fn manifest_from_top_tier(art: &metadata::CatalogArtistArt) -> bool {
art.banners.iter().any(|url| {
// Every stored URL is validated as `https://...` before it gets here,
// so the authority is simply what sits between the scheme and the
// first slash (minus any port).
url.strip_prefix("https://")
.and_then(|rest| rest.split('/').next())
.map(|authority| authority.split(':').next().unwrap_or(authority))
.is_some_and(|host| host == "fanart.tv" || host.ends_with(".fanart.tv"))
})
}
const ARTIST_ART_MANIFEST_MAX_BYTES: u64 = 64 * 1024;
// Identity is part of the key because two Deezer artists can share the same
// display name (and, occasionally, the same release title/reference artwork).
@@ -993,11 +1026,13 @@ async fn read_artist_art_manifest(
let _ = tokio::fs::remove_file(path).await;
return None;
}
let fresh = metadata
// Age is taken here but judged below: how long this manifest counts as
// fresh depends on which provider tier produced it, which is only knowable
// once it has been parsed.
let age = metadata
.modified()
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age < ARTIST_ART_MANIFEST_TTL);
.and_then(|modified| modified.elapsed().ok());
let bytes = tokio::fs::read(&path).await.ok()?;
let Ok(manifest) = serde_json::from_slice::<CachedArtistArtManifest>(&bytes) else {
let _ = tokio::fs::remove_file(path).await;
@@ -1020,6 +1055,12 @@ async fn read_artist_art_manifest(
{
return None;
}
let ttl = if manifest_from_top_tier(&art) {
ARTIST_ART_MANIFEST_TTL
} else {
ARTIST_ART_FALLBACK_TTL
};
let fresh = age.is_some_and(|age| age < ttl);
let current = fresh && manifest.recipe == ARTIST_ART_MANIFEST_RECIPE;
Some(StoredArtistArt { art, current })
}
@@ -3527,6 +3568,23 @@ pub async fn play_stream(
if size > 0 && md.len() == size {
playback::touch(&cache);
crate::diagnostics::clear_stream(&app);
// This track never becomes a transfer: there is no StreamProgress
// and no download task, so the update loop below that emits
// `soulseek:stream` never runs for it. Say it once, or the UI keeps
// the provisional counts it set when it asked to play and shows a
// download pinned at 0% — or, coming out of a gapless advance, an
// indeterminate "Streaming from <peer>" — on a file that is already
// complete. `done` also lets the frontend take its completed-track
// path (final waveform, pre-buffer the next).
let _ = app.emit(
"soulseek:stream",
serde_json::json!({
"received": size,
"total": size,
"done": true,
"failed": Option::<String>::None,
}),
);
let app2 = app.clone();
let cache2 = cache.clone();
crate::loudness::on_track_started(