Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0147975b14 | |||
| d52517ef39 |
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LibraryItem" ADD COLUMN "rgMbid" TEXT;
|
||||
@@ -82,6 +82,9 @@ model LibraryItem {
|
||||
format String
|
||||
qualityClass Int
|
||||
importedAt DateTime @default(now())
|
||||
// MusicBrainz release-group MBID when known (resolved at manual import) — gives the album
|
||||
// cover art without depending on a matching MonitoredRelease row.
|
||||
rgMbid String?
|
||||
// The album's on-disk audio filenames ("## Title.ext"), captured at import + scan, so the
|
||||
// Library modal can show the REAL tracks on disk (revealing incomplete/edition mismatches)
|
||||
// instead of MusicBrainz's canonical listing. Empty for items imported before this existed
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/** Album art from the Cover Art Archive (keyed by release-group MBID), loaded straight from
|
||||
* their CDN by the browser — no backend, no MB API rate limit. Falls back to a placeholder
|
||||
* when there's no MBID or no art. */
|
||||
/** Album art keyed by release-group MBID, served through /api/cover (which caches it on disk +
|
||||
* sends a long Cache-Control, so it isn't re-fetched from the Cover Art Archive on every
|
||||
* Library / Recently-Pressed render). Falls back to a placeholder when there's no MBID or art. */
|
||||
export function CoverArt({
|
||||
rgMbid,
|
||||
alt,
|
||||
@@ -29,7 +29,7 @@ export function CoverArt({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className={cls}
|
||||
src={`https://coverartarchive.org/release-group/${rgMbid}/front-${size}`}
|
||||
src={`/api/cover/${rgMbid}?size=${size}`}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { GET } from "./route";
|
||||
|
||||
let cache: string;
|
||||
const original = process.env.LYRA_COVER_CACHE;
|
||||
const RG = "735bcee6-6be6-3957-af15-d21ba2bd355b";
|
||||
|
||||
beforeEach(() => {
|
||||
cache = mkdtempSync(join(tmpdir(), "lyra-cov-"));
|
||||
process.env.LYRA_COVER_CACHE = cache;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
if (original === undefined) delete process.env.LYRA_COVER_CACHE;
|
||||
else process.env.LYRA_COVER_CACHE = original;
|
||||
rmSync(cache, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const req = (rg: string) => new Request(`http://x/api/cover/${rg}?size=250`);
|
||||
const params = (rg: string) => ({ params: Promise.resolve({ rgMbid: rg }) });
|
||||
|
||||
describe("GET /api/cover/[rgMbid]", () => {
|
||||
it("400s a non-uuid", async () => {
|
||||
expect((await GET(req("not-a-uuid"), params("not-a-uuid"))).status).toBe(400);
|
||||
});
|
||||
|
||||
it("fetches from CAA once, then serves from the disk cache", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const r1 = await GET(req(RG), params(RG));
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.headers.get("cache-control")).toContain("max-age");
|
||||
expect(existsSync(join(cache, `${RG}-250.jpg`))).toBe(true);
|
||||
|
||||
const r2 = await GET(req(RG), params(RG));
|
||||
expect(r2.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // second served from cache
|
||||
});
|
||||
|
||||
it("caches a miss (404) so it isn't re-fetched", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 404 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
expect((await GET(req(RG), params(RG))).status).toBe(404);
|
||||
expect((await GET(req(RG), params(RG))).status).toBe(404);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // negative cache hit on the second
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Cover art proxy + cache. The browser used to hit the Cover Art Archive CDN directly on every
|
||||
// Library / Recently-Pressed render; now it hits this, which caches the image on disk and serves
|
||||
// it with a long Cache-Control so the browser caches it too. A negative marker caps re-fetching
|
||||
// covers that don't exist.
|
||||
const CACHEABLE = "public, max-age=2592000"; // 30 days
|
||||
const NEG_CACHE = "public, max-age=86400"; // re-check a missing cover daily
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ rgMbid: string }> }) {
|
||||
const { rgMbid } = await params;
|
||||
if (!UUID.test(rgMbid)) return new Response(null, { status: 400 });
|
||||
const size = new URL(request.url).searchParams.get("size") === "500" ? "500" : "250";
|
||||
const CACHE = process.env.LYRA_COVER_CACHE ?? "/tmp/lyra-covers";
|
||||
const key = `${rgMbid}-${size}`;
|
||||
const file = join(CACHE, `${key}.jpg`);
|
||||
const miss = join(CACHE, `${key}.miss`);
|
||||
|
||||
try {
|
||||
if (existsSync(file)) {
|
||||
return new Response(await readFile(file), {
|
||||
headers: { "Content-Type": "image/jpeg", "Cache-Control": CACHEABLE },
|
||||
});
|
||||
}
|
||||
if (existsSync(miss)) {
|
||||
return new Response(null, { status: 404, headers: { "Cache-Control": NEG_CACHE } });
|
||||
}
|
||||
const res = await fetch(`https://coverartarchive.org/release-group/${rgMbid}/front-${size}`, {
|
||||
headers: { "User-Agent": "Lyra/0.1 ( https://git.jger.nl/Jonathan/Lyra )" },
|
||||
});
|
||||
await mkdir(CACHE, { recursive: true });
|
||||
if (!res.ok) {
|
||||
await writeFile(miss, "").catch(() => {});
|
||||
return new Response(null, { status: 404, headers: { "Cache-Control": NEG_CACHE } });
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await writeFile(file, buf).catch(() => {});
|
||||
return new Response(buf, { headers: { "Content-Type": "image/jpeg", "Cache-Control": CACHEABLE } });
|
||||
} catch {
|
||||
return new Response(null, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn(async () => null) }));
|
||||
import { mkdtempSync, existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto";
|
||||
import Busboy from "busboy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isAuthed } from "@/lib/auth";
|
||||
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
||||
|
||||
// Manually import an album (a ripped CD / files already on the PC): upload the audio (+ an
|
||||
// optional cover) with artist/album/year, and Lyra writes it into the library. The web
|
||||
@@ -104,6 +105,15 @@ export async function POST(request: Request) {
|
||||
await mkdir(dirname(finalDir), { recursive: true });
|
||||
await rename(staging, finalDir);
|
||||
|
||||
// Resolve the release group so the album gets cover art immediately (best-effort — a
|
||||
// no-match just leaves rgMbid null; a later Scan can still match it). Uses the shared MB cache.
|
||||
let rgMbid: string | null = null;
|
||||
try {
|
||||
rgMbid = (await searchReleaseGroup(artist, album))?.rgMbid ?? null;
|
||||
} catch {
|
||||
/* MB unavailable — carry on without cover art */
|
||||
}
|
||||
|
||||
const firstExt = extname(audio[0]).toLowerCase();
|
||||
await prisma.libraryItem.create({
|
||||
data: {
|
||||
@@ -115,6 +125,7 @@ export async function POST(request: Request) {
|
||||
// rough class from the extension (2 = CD-lossless, 1 = lossy); a later scan re-probes and
|
||||
// can upgrade a hi-res file to class 3.
|
||||
qualityClass: LOSSLESS_EXT.has(firstExt) ? 2 : 1,
|
||||
rgMbid,
|
||||
trackNames: audio,
|
||||
},
|
||||
});
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user