feat(library): edit/fix album metadata

Correct an owned album's artist/album/year from the Library modal ("Edit
metadata"). PATCH /api/library/[id] renames the folder to the canonical
Artist/Album (Year) scheme on the /music mount, updates the LibraryItem,
and re-resolves the MusicBrainz release-group (cover art) when the
artist/album changed (best-effort — keeps the old rgMbid on an MB outage).

- New shared lib/library-path.ts (safeName, albumDir, yearFromPath); the
  manual-import route now uses it so edits and imports name folders
  identically.
- Library GET falls back to the folder's "(YYYY)" for the year when there's
  no matching release, so edited/manual years show in the grid.
- Does NOT rewrite embedded file tags (a worker/mutagen job) — Lyra reads
  from the folder + DB. Guards paths inside the library root; 409 on folder
  or (artist,album) collision.

web 172 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 21:12:02 +02:00
parent 855a0f15fe
commit f78c88df3f
7 changed files with 257 additions and 15 deletions
+2 -8
View File
@@ -8,6 +8,7 @@ import Busboy from "busboy";
import { prisma } from "@/lib/db";
import { isAuthed } from "@/lib/auth";
import { searchReleaseGroup } from "@/lib/musicbrainz";
import { safeName, albumDir } from "@/lib/library-path";
// 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
@@ -19,12 +20,6 @@ import { searchReleaseGroup } from "@/lib/musicbrainz";
const AUDIO_EXT = new Set([".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"]);
const LOSSLESS_EXT = new Set([".flac", ".wav"]);
const ILLEGAL = /[<>:"/\\|?*]/g;
function safeName(s: string): string {
const cleaned = s.replace(ILLEGAL, "_").trim().replace(/^\.+|\.+$/g, "").trim();
return cleaned || "Unknown";
}
export async function POST(request: Request) {
// This route is excluded from the auth middleware (to avoid the 10MB body cap), so it must
@@ -95,8 +90,7 @@ export async function POST(request: Request) {
return Response.json({ error: bad }, { status: 400 });
}
const albumFolder = year ? `${safeName(album)} (${year})` : safeName(album);
const finalDir = join(root, safeName(artist), albumFolder);
const finalDir = albumDir(root, artist, album, year || undefined);
if (existsSync(finalDir) || (await prisma.libraryItem.findFirst({ where: { artist, album } }))) {
await rm(staging, { recursive: true, force: true });
return Response.json({ error: "this album is already in the library" }, { status: 409 });