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:
@@ -1,6 +1,103 @@
|
||||
import { rm, rmdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, rename, rm, rmdir } from "node:fs/promises";
|
||||
import { dirname, resolve, sep } from "node:path";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { albumDir, yearFromPath } from "@/lib/library-path";
|
||||
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
||||
|
||||
function insideRoot(root: string, target: string): boolean {
|
||||
return target !== root && target.startsWith(root + sep);
|
||||
}
|
||||
|
||||
// PATCH /api/library/[id] — fix an owned album's metadata: rename its folder to the canonical
|
||||
// Artist/Album (Year) scheme on disk, update the LibraryItem, and re-resolve the MusicBrainz
|
||||
// release-group (for cover art) when the artist/album changed. Does NOT rewrite the audio
|
||||
// files' embedded tags — that's a worker (mutagen) job; Lyra reads from the folder + DB.
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const { artist, album, year } = (body ?? {}) as { artist?: unknown; album?: unknown; year?: unknown };
|
||||
const strOrUndef = (v: unknown) => (v === undefined ? undefined : typeof v === "string" ? v.trim() : null);
|
||||
const nextArtist = strOrUndef(artist);
|
||||
const nextAlbum = strOrUndef(album);
|
||||
const nextYear = strOrUndef(year); // "" clears the year; a 4-digit string sets it
|
||||
if (nextArtist === null || nextAlbum === null || nextYear === null) {
|
||||
return Response.json({ error: "artist, album, year must be strings" }, { status: 400 });
|
||||
}
|
||||
if (nextArtist === undefined && nextAlbum === undefined && nextYear === undefined) {
|
||||
return Response.json({ error: "nothing to update" }, { status: 400 });
|
||||
}
|
||||
if (nextArtist === "" || nextAlbum === "") {
|
||||
return Response.json({ error: "artist and album cannot be empty" }, { status: 400 });
|
||||
}
|
||||
if (nextYear && !/^\d{4}$/.test(nextYear)) {
|
||||
return Response.json({ error: "year must be a 4-digit year or blank" }, { status: 400 });
|
||||
}
|
||||
|
||||
const item = await prisma.libraryItem.findUnique({ where: { id } });
|
||||
if (!item) return Response.json({ error: "not found" }, { status: 404 });
|
||||
|
||||
const root = resolve(process.env.LYRA_LIBRARY_ROOT ?? "/music");
|
||||
const oldPath = resolve(item.path);
|
||||
if (!insideRoot(root, oldPath)) {
|
||||
return Response.json({ error: "refusing to touch a path outside the library" }, { status: 400 });
|
||||
}
|
||||
|
||||
const finalArtist = nextArtist ?? item.artist;
|
||||
const finalAlbum = nextAlbum ?? item.album;
|
||||
// year isn't stored on LibraryItem; it lives only in the folder name. Keep the current
|
||||
// folder's year unless the caller passed one (empty string clears it).
|
||||
const finalYear = nextYear === undefined ? (yearFromPath(item.path) ?? undefined) : nextYear || undefined;
|
||||
const newPath = albumDir(root, finalArtist, finalAlbum, finalYear);
|
||||
|
||||
if (newPath !== oldPath) {
|
||||
if (!insideRoot(root, newPath)) {
|
||||
return Response.json({ error: "refusing to write outside the library" }, { status: 400 });
|
||||
}
|
||||
if (existsSync(newPath)) {
|
||||
return Response.json({ error: "a folder for that artist/album/year already exists" }, { status: 409 });
|
||||
}
|
||||
try {
|
||||
await mkdir(dirname(newPath), { recursive: true });
|
||||
await rename(oldPath, newPath);
|
||||
const oldParent = dirname(oldPath);
|
||||
if (oldParent !== root && oldParent !== dirname(newPath)) await rmdir(oldParent).catch(() => {});
|
||||
} catch {
|
||||
return Response.json({ error: "failed to rename the album folder" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve the release-group when the artist/album changed (year alone doesn't change it).
|
||||
// Best-effort: on an MB outage, keep the existing rgMbid rather than dropping the cover art.
|
||||
let rgMbid = item.rgMbid;
|
||||
if (nextArtist !== undefined || nextAlbum !== undefined) {
|
||||
try {
|
||||
rgMbid = (await searchReleaseGroup(finalArtist, finalAlbum))?.rgMbid ?? null;
|
||||
} catch {
|
||||
/* keep existing rgMbid */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await prisma.libraryItem.update({
|
||||
where: { id },
|
||||
data: { artist: finalArtist, album: finalAlbum, path: newPath, rgMbid },
|
||||
});
|
||||
return Response.json({ id: updated.id, artist: updated.artist, album: updated.album, path: updated.path, rgMbid: updated.rgMbid });
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||
// moved the folder already; the (artist, album) row collides with another library item
|
||||
return Response.json({ error: "another library item already has that artist/album" }, { status: 409 });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/library/[id] — remove an owned album: delete its folder on disk, drop the
|
||||
// LibraryItem, and stop monitoring the matching release so the monitor doesn't re-grab it.
|
||||
|
||||
Reference in New Issue
Block a user