From d76ed2ce553f7a7dc870c0c56b1a6c6103ae8dcc Mon Sep 17 00:00:00 2001 From: Daniel <62436912+frostplexx@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:00:41 +0200 Subject: [PATCH] fix: animated artwork album cache poisoned by title-only fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only cache artwork results that were matched by album. Previously the first track looked up in an album decided the cached answer for every other track: the title-only fallback (and its miss) was stored under the album key, so later tracks in the album inherited it even when a title-specific lookup would have found artwork. fetchAnimatedArtwork now returns a scope key alongside the result — the album key only when album-matched, else the title key. The dedupe skips in artwork.ts and index.ts compare against that effective scope, so skipping within an album stays free whenever the album matched. --- plugins/radiant-lyrics-luna/src/api.ts | 50 ++++++++++++++------ plugins/radiant-lyrics-luna/src/artwork.ts | 54 +++++++++++++--------- plugins/radiant-lyrics-luna/src/index.ts | 22 +++++++-- 3 files changed, 87 insertions(+), 39 deletions(-) diff --git a/plugins/radiant-lyrics-luna/src/api.ts b/plugins/radiant-lyrics-luna/src/api.ts index f2ae935..657f5ca 100644 --- a/plugins/radiant-lyrics-luna/src/api.ts +++ b/plugins/radiant-lyrics-luna/src/api.ts @@ -207,6 +207,21 @@ export interface AnimatedArtwork { url_tall: string; } +export interface ArtworkLookup { + artwork: AnimatedArtwork | null; + /** + * Scope the result is valid for, as a dedupe key: the album key only when + * the answer was matched by album. A title-only fallback (or a miss) is + * specific to this track, so its key scopes to the title — the album's + * other tracks must not inherit it. + */ + key: string; +} + +/** Title-scoped artwork key: a result only valid for this exact track. */ +export const artworkTitleKey = (artist: string, title: string): string => + `${artist}\u0000\u0000${title}`; + function artworkSearchUrl( title: string, artist: string, @@ -219,6 +234,8 @@ function artworkSearchUrl( } // Cached including misses; most tracks have none and the answer never changes. +// Only album-matched results go under the album key — a title-only fallback +// must not stand in for the album's other tracks (see ArtworkLookup). const animatedArtworkCache = new Map(); const ARTWORK_CACHE_MAX = 200; @@ -227,14 +244,16 @@ export async function fetchAnimatedArtwork( artist: string, album?: string, signal?: AbortSignal, -): Promise { - // Keyed by album, matching artworkKey() in index.ts. - const cacheKey = - album && album.trim() !== "" - ? `${artist}\u0000${album.trim()}` - : `${artist}\u0000\u0000${title}`; +): Promise { + const albumName = album?.trim() || ""; + const albumPresent = albumName !== ""; + // Album key, matching artworkKey() in index.ts; without an album the + // answer is only ever matched by title, so key by title directly. + const cacheKey = albumPresent + ? `${artist}\u0000${albumName}` + : artworkTitleKey(artist, title); if (animatedArtworkCache.has(cacheKey)) { - return animatedArtworkCache.get(cacheKey) ?? null; + return { artwork: animatedArtworkCache.get(cacheKey) ?? null, key: cacheKey }; } const controller = new AbortController(); @@ -286,14 +305,17 @@ export async function fetchAnimatedArtwork( const withAlbum = await tryFetch( artworkSearchUrl(title, artist, album), ); - if (withAlbum) return remember(withAlbum); - if (album && album.trim() !== "") { - const withoutAlbum = await tryFetch( - artworkSearchUrl(title, artist), - ); - if (withoutAlbum) return remember(withoutAlbum); + // Album-matched: cacheable, and its scope is the whole album. + if (withAlbum) return { artwork: remember(withAlbum), key: cacheKey }; + if (albumPresent) { + // Album lookup missed: the title-only answer — hit or miss — is + // matched by title, so never cache it under the album key (that + // would poison the album's other tracks) and scope it to the title. + const artwork = await tryFetch(artworkSearchUrl(title, artist)); + return { artwork, key: artworkTitleKey(artist, title) }; } - return remember(null); + // No album: the title key is already track-specific. + return { artwork: remember(null), key: cacheKey }; } finally { clearTimeout(timeout); signal?.removeEventListener("abort", onAbort); diff --git a/plugins/radiant-lyrics-luna/src/artwork.ts b/plugins/radiant-lyrics-luna/src/artwork.ts index 8d777a9..4d46912 100644 --- a/plugins/radiant-lyrics-luna/src/artwork.ts +++ b/plugins/radiant-lyrics-luna/src/artwork.ts @@ -6,7 +6,7 @@ import { Tracer } from "@luna/core"; // Light build: no alt-audio/subtitles/EME needed here. Types in hls-light.d.ts. import Hls from "hls.js/light"; -import { fetchAnimatedArtwork } from "./api"; +import { artworkTitleKey, fetchAnimatedArtwork } from "./api"; import { settings } from "./Settings"; const { trace } = Tracer("[Radiant Lyrics]"); @@ -27,7 +27,10 @@ export class AnimatedArtworkLayer { private resizeObs: ResizeObserver | null = null; private resizeRaf = 0; private artwork: { url: string; url_tall: string } | null = null; - /** See artworkKey() in index.ts. */ + /** + * Scope key of the displayed artwork (see fetchAnimatedArtwork): the album + * key only when the answer was matched by album, else the title key. + */ private artworkKey: string | null = null; private liveSrc: string | null = null; private loadToken = 0; @@ -109,52 +112,61 @@ export class AnimatedArtworkLayer { /** * Mount the artwork for `key`, leaving any playing video alone until a - * replacement resolves. False means no tile was on screen, so retry later. + * replacement resolves. `done: false` means no tile was on screen, so + * retry later. Returns the scope `key` the result is valid under (see + * fetchAnimatedArtwork), which may be narrower than the request key. */ async load( key: string, title: string, artist: string, album: string | undefined, - ): Promise { - // Already showing this album's artwork — keep playing, touch nothing. - if (key === this.artworkKey && this.artwork && this.video?.isConnected) { + ): Promise<{ done: boolean; key: string }> { + // Already showing artwork that covers this track (album-matched, or the + // same track again) — keep playing, touch nothing. + if ( + (key === this.artworkKey || + artworkTitleKey(artist, title) === this.artworkKey) && + this.artwork && + this.video?.isConnected + ) { this.ensurePlaying(); - return true; + return { done: true, key: this.artworkKey }; } const token = ++this.loadToken; this.fetchAbort?.abort(); this.fetchAbort = new AbortController(); const signal = this.fetchAbort.signal; - if (!settings.animatedArtwork) return true; + if (!settings.animatedArtwork) return { done: true, key }; // Only check a tile exists; host after the fetch so the old video keeps playing. - if (!document.querySelector(ART_TILE_SELECTOR)) return false; + if (!document.querySelector(ART_TILE_SELECTOR)) + return { done: false, key }; - const artwork = await fetchAnimatedArtwork(title, artist, album, signal); - if (token !== this.loadToken) return true; - if (!this.tileAlive()) return false; + const lookup = await fetchAnimatedArtwork(title, artist, album, signal); + if (token !== this.loadToken) return { done: true, key: lookup.key }; + if (!this.tileAlive()) return { done: false, key: lookup.key }; - if (!artwork) { + if (!lookup.artwork) { trace.log(`Animated Artwork: no animated art for "${title}"`); this.detach(false); // After detach(), which clears the key too. - this.artworkKey = key; - return true; + this.artworkKey = lookup.key; + return { done: true, key: lookup.key }; } // Same stream already on screen: adopt the key, leave the video be. const unchanged = - this.artwork?.url === artwork.url && - this.artwork?.url_tall === artwork.url_tall && + this.artwork?.url === lookup.artwork.url && + this.artwork?.url_tall === lookup.artwork.url_tall && this.video?.isConnected === true; - this.artwork = artwork; - this.artworkKey = key; + this.artwork = lookup.artwork; + this.artworkKey = lookup.key; if (unchanged) { this.ensurePlaying(); - return true; + return { done: true, key: lookup.key }; } this.mountVideo(false); - return true; + return { done: true, key: lookup.key }; } /** Re-bind to the artwork tile after the Now Playing view (re)mounted. */ diff --git a/plugins/radiant-lyrics-luna/src/index.ts b/plugins/radiant-lyrics-luna/src/index.ts index aa33d8d..83d199b 100644 --- a/plugins/radiant-lyrics-luna/src/index.ts +++ b/plugins/radiant-lyrics-luna/src/index.ts @@ -15,6 +15,7 @@ import { type LyricsApiResponse, type WordLine, type WordTiming, + artworkTitleKey, fetchLyrics as fetchLyricsApi, flushLyrics as flushLyricsApi, romanizeLyrics as romanizeLyricsApi, @@ -1048,7 +1049,12 @@ let animatedArtworkLayer: AnimatedArtworkLayer | null = null; let lastArtworkKey: string | null = null; let lastArtworkTile: Element | null = null; -/** Animated art is an album asset, so key by album and skips within one are free. */ +/** + * Request key for an animated-artwork lookup: album-scoped when an album is + * present (skips within one are free). A result may still be scoped tighter — + * see fetchAnimatedArtwork() — so skipping compares lastArtworkKey, the + * effective scope, not this request key. + */ const artworkKey = (t: TrackInfo): string => t.album && t.album.trim() !== "" ? `${t.artist}\u0000${t.album.trim()}` @@ -1072,20 +1078,28 @@ const updateAnimatedArtwork = async ( // Re-check after the await; another caller may have claimed this already. if (!settings.animatedArtwork || !animatedArtworkLayer) return; const key = artworkKey(target); - if (key === lastArtworkKey) { + // Free skip: the shown artwork's scope covers this track — it was matched + // by album (album key), or it is the same track again (title key). + if ( + key === lastArtworkKey || + artworkTitleKey(target.artist, target.title) === lastArtworkKey + ) { // Same artwork, but the view may have remounted underneath us. if (rehost) animatedArtworkLayer.reattach(); return; } lastArtworkKey = key; - const done = await animatedArtworkLayer.load( + const res = await animatedArtworkLayer.load( key, target.title, target.artist, target.album, ); + // Adopt the effective scope — a title-matched result (or a miss) must not + // stand in for the album's other tracks — unless a newer trigger claimed. + if (lastArtworkKey === key) lastArtworkKey = res.key; // No tile on screen yet; clear the key so the tile observer retries. - if (!done && lastArtworkKey === key) lastArtworkKey = null; + if (!res.done && lastArtworkKey === res.key) lastArtworkKey = null; }; (window as any).updateAnimatedArtwork = updateAnimatedArtwork;