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:
1 parent
9dc3eaf939
commit
d460352973
1 file changed
+62
-4
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user