From 537d8a32e4985b95c8efb3f811623b6b53d0ceb9 Mon Sep 17 00:00:00 2001 From: Lee Kelly Date: Fri, 13 Nov 2026 15:00:00 -0500 Subject: [PATCH] fix(library): new singles and albums with a dead cover link get their cover from Deezer --- .tests/library/album-artwork.test.ts | 57 ++++++++++++++++++++++++++-- backend/services/libraryArtwork.ts | 38 +++++++++++++++---- 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/.tests/library/album-artwork.test.ts b/.tests/library/album-artwork.test.ts index fad482d3c..0ba2a2bf3 100644 --- a/.tests/library/album-artwork.test.ts +++ b/.tests/library/album-artwork.test.ts @@ -1,12 +1,21 @@ import { requireFixture } from "../playlists/fixtures.ts"; import { listeningAddress } from "../helpers/backendTestHarness.ts"; import assert from "node:assert/strict"; -import { describe, test, beforeAll, afterAll, vi, type TestContext } from "vite-plus/test"; +import { + describe, + test, + beforeAll, + beforeEach, + afterAll, + vi, + type TestContext, +} from "vite-plus/test"; import fs, { access, mkdir, symlink, writeFile, truncate } from "node:fs/promises"; import path from "node:path"; import sharp from "sharp"; import { cleanupIsolatedState, setupIsolatedBackend } from "../helpers/backendTestHarness.ts"; import { listen } from "../helpers/honoApp.ts"; +import { http } from "../../lib/http.ts"; const [ isolatedState, @@ -53,6 +62,7 @@ const artist = libraryStore.upsertLibraryArtist({ dbOps.updateSettings({ onboardingComplete: true }); const realFetch = globalThis.fetch; +const realGet = http.get.bind(http); const server = await listen(app); const baseUrl = `http://127.0.0.1:${listeningAddress(server).port}`; let session: { token: string; expiresAt: number } | null = null; @@ -62,6 +72,16 @@ afterAll(async () => { await cleanupIsolatedState(isolatedState); }); +function stubDeezer(artistName?: string, albums: object[] = []) { + vi.spyOn(http, "get").mockImplementation(async (url, options) => { + if (!url.startsWith("https://api.deezer.com/")) return realGet(url, options); + const artists = artistName ? [{ id: 7, name: artistName }] : []; + const data = url.endsWith("/search/artist") ? artists : albums; + return { status: 200, statusText: "", headers: {}, data: { data } }; + }); +} +beforeEach(() => stubDeezer()); + type PublicLibraryAlbum = Parameters[0]["albums"][number]; const libraryAlbum = (overrides: Partial & { coverUrl?: string }) => ({ @@ -176,10 +196,12 @@ const isBlue = async (body: Buffer) => { return dominant.b > dominant.r; }; -function stubImageHost(t: TestContext, body: Buffer) { +function stubImageHost(t: TestContext, body: Buffer, missing: string[] = []) { const requests: string[] = []; vi.stubGlobal("fetch", async (input: string | URL | Request) => { - requests.push(String(input instanceof Request ? input.url : input)); + const url = String(input instanceof Request ? input.url : input); + requests.push(url); + if (missing.includes(url)) return new Response(null, { status: 404 }); return new Response(new Uint8Array(body), { headers: { "content-type": "image/png" } }); }); t.onTestFinished(() => { @@ -486,6 +508,35 @@ describe("with sign-in required", () => { assert.deepEqual(requests, ["https://images.example/provider-art.png"]); }); + test("an album MusicBrainz has no cover for, or whose cover link is gone, gets Deezer's", async (t) => { + const gone = "https://images.example/gone.png"; + const newSingle = "https://deezer.example/new-single.png"; + const deadLinkCover = "https://deezer.example/dead-link.png"; + const requests = stubImageHost(t, blue, [gone]); + stubDeezer("Deezer Artist", [ + { id: 1, title: "New Single", record_type: "single", cover_xl: newSingle }, + { id: 2, title: "Dead Link", record_type: "album", cover_xl: deadLinkCover }, + ]); + const deezerArtist = libraryStore.upsertLibraryArtist({ + identityKey: "deezer-artist", + name: "Deezer Artist", + }); + const releaseGroupMbid = "7d3e2f1a-5b4c-4d6e-8f9a-0b1c2d3e4f5a"; + dbOps.setImage(`rg:${releaseGroupMbid}`, gone); + const single = await albumFixture("New Single", [null], { artistId: deezerArtist.id }); + const deadLink = await albumFixture("Dead Link", [null], { + artistId: deezerArtist.id, + releaseGroupMbid, + }); + + for (const album of [single, deadLink]) { + const result = await requestCover(album.id); + assert.equal(result.status, 200, album.title); + assert.equal(await isBlue(result.body), true, album.title); + } + assert.deepEqual(requests, [newSingle, gone, deadLinkCover]); + }); + test("a changed album file replaces the cached art", async () => { const album = await albumFixture("changed-file", [image]); assert.equal(await isBlue((await requestCover(album.id)).body), false); diff --git a/backend/services/libraryArtwork.ts b/backend/services/libraryArtwork.ts index 1808f71fb..5847c4d3e 100644 --- a/backend/services/libraryArtwork.ts +++ b/backend/services/libraryArtwork.ts @@ -26,6 +26,7 @@ import { logger, safeLogDiagnostic } from "./logger.ts"; import getSharp from "./sharpConfig.ts"; import { readLibraryMetadata } from "./libraryMetadata.ts"; import { fetchReleaseGroupCoverUrl } from "./releaseGroupCoverService.ts"; +import { resolveDeezerAlbumForPreview } from "./apiClients/deezer.ts"; export type LibraryArtworkKind = "album" | "artist"; type Artwork = { buffer: Uint8Array; contentType: string }; @@ -283,6 +284,11 @@ async function findArtistPhoto(mbid: string, artistName: string): Promise { + const album = await resolveDeezerAlbumForPreview({ artistName, albumTitle }).catch(() => null); + return album?._coverUrl ? downloadArtwork(album._coverUrl) : { artwork: null }; +} + async function findAlbumCover(albumId: number): Promise { const fileArtwork = await readFileArtwork(albumId); if (fileArtwork) return { artwork: fileArtwork, fromFiles: true }; @@ -292,13 +298,31 @@ async function findAlbumCover(albumId: number): Promise { const fromMetadata = metadataUrl ? await downloadArtwork(metadataUrl) : null; if (fromMetadata?.artwork) return fromMetadata; const releaseGroupMbid = album.release_group_mbid || album.mbid; - if (!releaseGroupMbid) return { artwork: null, transient: fromMetadata?.transient }; - const cover = await fetchReleaseGroupCoverUrl(releaseGroupMbid, { - artistName: album.artist_name, - albumTitle: album.title, - }); - if (cover?.imageUrl) return downloadArtwork(cover.imageUrl); - return { artwork: null, transient: Boolean(fromMetadata?.transient || cover?.transientError) }; + const releaseGroup = releaseGroupMbid + ? await fetchReleaseGroupCoverUrl(releaseGroupMbid, { + artistName: album.artist_name, + albumTitle: album.title, + }) + : null; + const fromReleaseGroup = releaseGroup?.imageUrl + ? await downloadArtwork(releaseGroup.imageUrl) + : null; + if (fromReleaseGroup?.artwork) return fromReleaseGroup; + // A release-group lookup that finds no link has already asked Deezer. + const fromDeezer = + !releaseGroup || releaseGroup.imageUrl + ? await findDeezerCover(album.artist_name, album.title) + : null; + if (fromDeezer?.artwork) return fromDeezer; + return { + artwork: null, + transient: Boolean( + fromMetadata?.transient || + releaseGroup?.transientError || + fromReleaseGroup?.transient || + fromDeezer?.transient, + ), + }; } async function findAlbumArtwork(albumId: number): Promise {