1
0

feat: best-effort artwork disk cache

This commit is contained in:
2026-08-29 16:38:28 +02:00
parent 9b52039fde
commit 0faa74ae27
2 changed files with 119 additions and 0 deletions

41
server/src/artwork.ts Normal file
View File

@@ -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<string, string> = {
'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<string | null> {
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
}

View File

@@ -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 })
}
})
})