diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c3fa121..7de60d5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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::(&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 " — 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::::None, + }), + ); let app2 = app.clone(); let cache2 = cache.clone(); crate::loudness::on_track_started(