Merge pull request #157 from frostplexx/dev

feat: animated artwork
This commit is contained in:
meoware.exe authored and GitHub committed 2026-10-11 19:47:01 +11:00
commit e3d71806b1
3 files changed
+36 -8

No files matched your search

+25 -6
View File
@@ -211,9 +211,10 @@ 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.
* the answer was matched by album (by name or via the track's ISRC, which
* the server resolves to the same 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;
}
@@ -222,6 +223,13 @@ export interface ArtworkLookup {
export const artworkTitleKey = (artist: string, title: string): string =>
`${artist}\u0000\u0000${title}`;
/**
* ISRC lookup: the server resolves the exact song by ISRC and answers
* from its album, immune to title/album name mismatches.
*/
const artworkIsrcUrl = (isrc: string): string =>
`https://ama.trainswift.net/api/v1/artwork/search?isrc=${encodeURIComponent(isrc)}`;
function artworkSearchUrl(
title: string,
artist: string,
@@ -234,8 +242,9 @@ 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).
// Only album-matched results (by name or ISRC) 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<string, AnimatedArtwork | null>();
const ARTWORK_CACHE_MAX = 200;
@@ -243,10 +252,12 @@ export async function fetchAnimatedArtwork(
title: string,
artist: string,
album?: string,
isrc?: string,
signal?: AbortSignal,
): Promise<ArtworkLookup> {
const albumName = album?.trim() || "";
const albumPresent = albumName !== "";
const isrcTrimmed = isrc?.trim() || "";
// 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
@@ -301,7 +312,15 @@ export async function fetchAnimatedArtwork(
};
try {
// Exact album first; on a miss retry once without the album (server-side disambiguation is imperfect)
// ISRC first: the exact match. A hit is resolved through the track's
// album, so it is album-scoped (cacheKey); a miss says nothing about
// the album (the ISRC may simply be unknown to the source), so fall
// through to the name flow without caching it.
if (isrcTrimmed) {
const byIsrc = await tryFetch(artworkIsrcUrl(isrcTrimmed));
if (byIsrc) return { artwork: remember(byIsrc), key: cacheKey };
}
// Fallback: exact album first; on a miss retry once without the album (server-side disambiguation is imperfect)
const withAlbum = await tryFetch(
artworkSearchUrl(title, artist, album),
);
+10 -2
View File
@@ -29,7 +29,8 @@ export class AnimatedArtworkLayer {
private artwork: { url: string; url_tall: string } | null = null;
/**
* Scope key of the displayed artwork (see fetchAnimatedArtwork): the album
* key only when the answer was matched by album, else the title key.
* key only when the answer was matched by album (name or ISRC), else the
* title key.
*/
private artworkKey: string | null = null;
private liveSrc: string | null = null;
@@ -121,6 +122,7 @@ export class AnimatedArtworkLayer {
title: string,
artist: string,
album: string | undefined,
isrc?: string,
): Promise<{ done: boolean; key: string }> {
// Already showing artwork that covers this track (album-matched, or the
// same track again) — keep playing, touch nothing.
@@ -143,7 +145,13 @@ export class AnimatedArtworkLayer {
if (!document.querySelector<HTMLElement>(ART_TILE_SELECTOR))
return { done: false, key };
const lookup = await fetchAnimatedArtwork(title, artist, album, signal);
const lookup = await fetchAnimatedArtwork(
title,
artist,
album,
isrc,
signal,
);
if (token !== this.loadToken) return { done: true, key: lookup.key };
if (!this.tileAlive()) return { done: false, key: lookup.key };
+1
View File
@@ -1152,6 +1152,7 @@ const updateAnimatedArtwork = async (
target.title,
target.artist,
target.album,
target.isrc,
);
// 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.