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
|
// 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
|
// stale-recipe manifest is kept as a floor and only replaced by a better
|
||||||
// lookup, so nobody goes blank while the new one resolves.
|
// 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
|
// 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
|
// 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
|
// 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.
|
// Pond, Weezer, Daft Punk...) went to 3. Match the cover-resolution horizon.
|
||||||
const ARTIST_ART_MANIFEST_TTL: std::time::Duration =
|
const ARTIST_ART_MANIFEST_TTL: std::time::Duration =
|
||||||
std::time::Duration::from_secs(30 * 24 * 60 * 60);
|
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;
|
const ARTIST_ART_MANIFEST_MAX_BYTES: u64 = 64 * 1024;
|
||||||
// Identity is part of the key because two Deezer artists can share the same
|
// Identity is part of the key because two Deezer artists can share the same
|
||||||
// display name (and, occasionally, the same release title/reference artwork).
|
// 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;
|
let _ = tokio::fs::remove_file(path).await;
|
||||||
return None;
|
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()
|
.modified()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|modified| modified.elapsed().ok())
|
.and_then(|modified| modified.elapsed().ok());
|
||||||
.is_some_and(|age| age < ARTIST_ART_MANIFEST_TTL);
|
|
||||||
let bytes = tokio::fs::read(&path).await.ok()?;
|
let bytes = tokio::fs::read(&path).await.ok()?;
|
||||||
let Ok(manifest) = serde_json::from_slice::<CachedArtistArtManifest>(&bytes) else {
|
let Ok(manifest) = serde_json::from_slice::<CachedArtistArtManifest>(&bytes) else {
|
||||||
let _ = tokio::fs::remove_file(path).await;
|
let _ = tokio::fs::remove_file(path).await;
|
||||||
@@ -1020,6 +1055,12 @@ async fn read_artist_art_manifest(
|
|||||||
{
|
{
|
||||||
return None;
|
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;
|
let current = fresh && manifest.recipe == ARTIST_ART_MANIFEST_RECIPE;
|
||||||
Some(StoredArtistArt { art, current })
|
Some(StoredArtistArt { art, current })
|
||||||
}
|
}
|
||||||
@@ -3527,6 +3568,23 @@ pub async fn play_stream(
|
|||||||
if size > 0 && md.len() == size {
|
if size > 0 && md.len() == size {
|
||||||
playback::touch(&cache);
|
playback::touch(&cache);
|
||||||
crate::diagnostics::clear_stream(&app);
|
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 app2 = app.clone();
|
||||||
let cache2 = cache.clone();
|
let cache2 = cache.clone();
|
||||||
crate::loudness::on_track_started(
|
crate::loudness::on_track_started(
|
||||||
|
|||||||
Reference in new issue
Block a user