Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions .tests/library/album-artwork.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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<typeof buildPublicLibrary>[0]["albums"][number];

const libraryAlbum = (overrides: Partial<PublicLibraryAlbum> & { coverUrl?: string }) => ({
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 31 additions & 7 deletions backend/services/libraryArtwork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -283,6 +284,11 @@ async function findArtistPhoto(mbid: string, artistName: string): Promise<Artwor
return { artwork: null, transient: Boolean(photo?.transient || image.transientError) };
}

async function findDeezerCover(artistName: string, albumTitle: string): Promise<ArtworkLookup> {
const album = await resolveDeezerAlbumForPreview({ artistName, albumTitle }).catch(() => null);
return album?._coverUrl ? downloadArtwork(album._coverUrl) : { artwork: null };
}

async function findAlbumCover(albumId: number): Promise<ArtworkLookup> {
const fileArtwork = await readFileArtwork(albumId);
if (fileArtwork) return { artwork: fileArtwork, fromFiles: true };
Expand All @@ -292,13 +298,31 @@ async function findAlbumCover(albumId: number): Promise<ArtworkLookup> {
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<ArtworkLookup> {
Expand Down
Loading