import { existsSync } from "node:fs"; import { mkdir, rename, 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"; import { removeLibraryItem } from "@/lib/library-ops"; 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. // 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. export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const r = await removeLibraryItem(id); return r.ok ? Response.json({ ok: true }) : Response.json({ error: r.error }, { status: r.status }); }