diff --git a/server/src/artwork.ts b/server/src/artwork.ts new file mode 100644 index 0000000..e0cd156 --- /dev/null +++ b/server/src/artwork.ts @@ -0,0 +1,41 @@ +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import path from 'node:path' + +const EXT_BY_TYPE: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', +} + +/** + * Downloads `url` into `artworkDir` keyed by its sha256 hash. Returns the + * stored file name (e.g. `abc123….jpg`) or null when the url is empty or the + * download fails — artwork caching is best-effort and must never break adds. + */ +export async function cacheArtwork( + artworkDir: string, + url: string, + fetchImpl: typeof fetch = fetch +): Promise { + if (!url) return null + const key = createHash('sha256').update(url).digest('hex') + mkdirSync(artworkDir, { recursive: true }) + for (const ext of Object.values(EXT_BY_TYPE)) { + if (existsSync(path.join(artworkDir, key + ext))) return key + ext + } + let res: Response + try { + res = await fetchImpl(url) + } catch { + return null + } + if (!res.ok) return null + const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '' + const ext = EXT_BY_TYPE[type] ?? '.jpg' + const buf = Buffer.from(await res.arrayBuffer()) + await writeFile(path.join(artworkDir, key + ext), buf) + return key + ext +} diff --git a/server/test/artwork.test.ts b/server/test/artwork.test.ts new file mode 100644 index 0000000..ada14f5 --- /dev/null +++ b/server/test/artwork.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { cacheArtwork } from '../src/artwork.js' + +function tempDir(): string { + return mkdtempSync(path.join(tmpdir(), 'rs-artwork-')) +} + +function imageFetch(calls: { count: number }): typeof fetch { + return (async () => { + calls.count++ + return new Response(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]), { + status: 200, + headers: { 'content-type': 'image/jpeg' }, + }) + }) as typeof fetch +} + +describe('cacheArtwork', () => { + it('downloads and stores by url hash with content-type extension', async () => { + const dir = tempDir() + try { + const calls = { count: 0 } + const file = await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', imageFetch(calls)) + expect(file).toMatch(/^[0-9a-f]{64}\.jpg$/) + expect(existsSync(path.join(dir, file as string))).toBe(true) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('second call for same url does not refetch', async () => { + const dir = tempDir() + try { + const calls = { count: 0 } + const fetcher = imageFetch(calls) + await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', fetcher) + const again = await cacheArtwork(dir, 'https://img.discogs.com/a.jpg', fetcher) + expect(calls.count).toBe(1) + expect(again).not.toBeNull() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('returns null for empty url and fetch failures without throwing', async () => { + const dir = tempDir() + try { + expect(await cacheArtwork(dir, '', imageFetch({ count: 0 }))).toBeNull() + const failing = (async () => { + throw new TypeError('fetch failed') + }) as unknown as typeof fetch + expect(await cacheArtwork(dir, 'https://x/y.jpg', failing)).toBeNull() + const notFound = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch + expect(await cacheArtwork(dir, 'https://x/y.jpg', notFound)).toBeNull() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('stores non-jpeg types with correct extension', async () => { + const dir = tempDir() + try { + const fetcher = (async () => + new Response(new Uint8Array([0x89, 0x50]), { + status: 200, + headers: { 'content-type': 'image/png' }, + })) as typeof fetch + const file = await cacheArtwork(dir, 'https://img.discogs.com/a.png', fetcher) + expect(file).toMatch(/\.png$/) + expect(readFileSync(path.join(dir, file as string)).length).toBeGreaterThan(0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +})