Compare commits
2 Commits
36a34181c9
...
0147975b14
| 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
|
format String
|
||||||
qualityClass Int
|
qualityClass Int
|
||||||
importedAt DateTime @default(now())
|
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
|
// 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)
|
// 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
|
// instead of MusicBrainz's canonical listing. Empty for items imported before this existed
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
/** Album art from the Cover Art Archive (keyed by release-group MBID), loaded straight from
|
/** Album art keyed by release-group MBID, served through /api/cover (which caches it on disk +
|
||||||
* their CDN by the browser — no backend, no MB API rate limit. Falls back to a placeholder
|
* sends a long Cache-Control, so it isn't re-fetched from the Cover Art Archive on every
|
||||||
* when there's no MBID or no art. */
|
* Library / Recently-Pressed render). Falls back to a placeholder when there's no MBID or art. */
|
||||||
export function CoverArt({
|
export function CoverArt({
|
||||||
rgMbid,
|
rgMbid,
|
||||||
alt,
|
alt,
|
||||||
@@ -29,7 +29,7 @@ export function CoverArt({
|
|||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
className={cls}
|
className={cls}
|
||||||
src={`https://coverartarchive.org/release-group/${rgMbid}/front-${size}`}
|
src={`/api/cover/${rgMbid}?size=${size}`}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={() => setFailed(true)}
|
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 { mkdtempSync, existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import Busboy from "busboy";
|
import Busboy from "busboy";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { isAuthed } from "@/lib/auth";
|
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
|
// 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
|
// 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 mkdir(dirname(finalDir), { recursive: true });
|
||||||
await rename(staging, finalDir);
|
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();
|
const firstExt = extname(audio[0]).toLowerCase();
|
||||||
await prisma.libraryItem.create({
|
await prisma.libraryItem.create({
|
||||||
data: {
|
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
|
// 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.
|
// can upgrade a hi-res file to class 3.
|
||||||
qualityClass: LOSSLESS_EXT.has(firstExt) ? 2 : 1,
|
qualityClass: LOSSLESS_EXT.has(firstExt) ? 2 : 1,
|
||||||
|
rgMbid,
|
||||||
trackNames: audio,
|
trackNames: audio,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user