diff --git a/web/src/app/api/library/[id]/route.test.ts b/web/src/app/api/library/[id]/route.test.ts index 0847fe1..4e22903 100644 --- a/web/src/app/api/library/[id]/route.test.ts +++ b/web/src/app/api/library/[id]/route.test.ts @@ -1,9 +1,13 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { prisma } from "@/lib/db"; -import { DELETE } from "./route"; +import { DELETE, PATCH } from "./route"; + +vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn() })); +import { searchReleaseGroup } from "@/lib/musicbrainz"; +const mockSearch = vi.mocked(searchReleaseGroup); let root: string; const original = process.env.LYRA_LIBRARY_ROOT; @@ -59,3 +63,61 @@ describe("DELETE /api/library/[id]", () => { expect(await prisma.libraryItem.findUnique({ where: { id: item.id } })).not.toBeNull(); }); }); + +function patchReq(body: unknown) { + return new Request("http://x", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); +} + +describe("PATCH /api/library/[id]", () => { + beforeEach(() => mockSearch.mockReset()); + + it("renames the folder, updates the record, and re-resolves rgMbid on an artist/album change", async () => { + const dir = join(root, "Old Artist", "Old Album (2019)"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "01 Track.flac"), "audio"); + const item = await prisma.libraryItem.create({ + data: { artist: "Old Artist", album: "Old Album", path: dir, source: "scan", format: "FLAC", qualityClass: 3, rgMbid: "old-rg" }, + }); + mockSearch.mockResolvedValue({ rgMbid: "new-rg", title: "New Album", artistMbid: "x", artistName: "New Artist" } as never); + + const res = await PATCH(patchReq({ artist: "New Artist", album: "New Album", year: "2020" }), req(item.id)); + expect(res.status).toBe(200); + const moved = join(root, "New Artist", "New Album (2020)"); + expect(existsSync(moved)).toBe(true); + expect(existsSync(dir)).toBe(false); + const row = (await prisma.libraryItem.findUnique({ where: { id: item.id } }))!; + expect(row).toMatchObject({ artist: "New Artist", album: "New Album", path: moved, rgMbid: "new-rg" }); + }); + + it("changing only the year renames without calling MusicBrainz", async () => { + const dir = join(root, "A", "B (2000)"); + mkdirSync(dir, { recursive: true }); + const item = await prisma.libraryItem.create({ + data: { artist: "A", album: "B", path: dir, source: "scan", format: "FLAC", qualityClass: 2, rgMbid: "keep" }, + }); + const res = await PATCH(patchReq({ year: "2001" }), req(item.id)); + expect(res.status).toBe(200); + expect(mockSearch).not.toHaveBeenCalled(); + expect(existsSync(join(root, "A", "B (2001)"))).toBe(true); + expect((await prisma.libraryItem.findUnique({ where: { id: item.id } }))!.rgMbid).toBe("keep"); + }); + + it("409s when the target folder already exists", async () => { + mkdirSync(join(root, "A", "B (2000)"), { recursive: true }); + mkdirSync(join(root, "A", "C (2000)"), { recursive: true }); + const item = await prisma.libraryItem.create({ + data: { artist: "A", album: "B", path: join(root, "A", "B (2000)"), source: "scan", format: "FLAC", qualityClass: 2 }, + }); + const res = await PATCH(patchReq({ album: "C" }), req(item.id)); + expect(res.status).toBe(409); + }); + + it("validates input and 404s an unknown id", async () => { + const item = await prisma.libraryItem.create({ + data: { artist: "A", album: "B", path: join(root, "A", "B"), source: "scan", format: "FLAC", qualityClass: 1 }, + }); + expect((await PATCH(patchReq({ year: "20" }), req(item.id))).status).toBe(400); // bad year + expect((await PATCH(patchReq({ artist: "" }), req(item.id))).status).toBe(400); // empty artist + expect((await PATCH(patchReq({ album: "Z" }), req("nope"))).status).toBe(404); + }); +}); diff --git a/web/src/app/api/library/[id]/route.ts b/web/src/app/api/library/[id]/route.ts index ffa85b7..e87f781 100644 --- a/web/src/app/api/library/[id]/route.ts +++ b/web/src/app/api/library/[id]/route.ts @@ -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. diff --git a/web/src/app/api/library/import/route.ts b/web/src/app/api/library/import/route.ts index 55df0c4..8f9cccc 100644 --- a/web/src/app/api/library/import/route.ts +++ b/web/src/app/api/library/import/route.ts @@ -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 }); diff --git a/web/src/app/api/library/route.ts b/web/src/app/api/library/route.ts index fc8be4b..cc70ef6 100644 Binary files a/web/src/app/api/library/route.ts and b/web/src/app/api/library/route.ts differ diff --git a/web/src/app/design.css b/web/src/app/design.css index 0f3b8ca..eace3ff 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -284,6 +284,7 @@ nav.contents .sep { flex: 1; } font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); } .save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; } +.edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; } .settings-section { margin-bottom: 30px; } .help { font-size: 0.8rem; color: var(--graphite); line-height: 1.5; margin: 4px 0 14px; max-width: 62ch; } .help b { color: var(--ink); font-weight: 600; } diff --git a/web/src/app/library/library-client.tsx b/web/src/app/library/library-client.tsx index 191f9e7..5aaf098 100644 --- a/web/src/app/library/library-client.tsx +++ b/web/src/app/library/library-client.tsx @@ -40,10 +40,46 @@ export function LibraryClient() { const [sort, setSort] = useState("added"); const [open, setOpen] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); + const [editing, setEditing] = useState(false); + const [edit, setEdit] = useState({ artist: "", album: "", year: "" }); + const [saving, setSaving] = useState(false); function show(a: Album | null) { setOpen(a); setConfirmDelete(false); + setEditing(false); + } + + function startEdit() { + if (!open) return; + setEdit({ artist: open.artist, album: open.album, year: open.year ?? "" }); + setEditing(true); + } + + async function saveEdit() { + if (!open) return; + if (!edit.artist.trim() || !edit.album.trim()) { + toast("Artist and album are required"); + return; + } + setSaving(true); + try { + const res = await fetch(`/api/library/${open.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ artist: edit.artist.trim(), album: edit.album.trim(), year: edit.year.trim() }), + }).catch(() => null); + if (res?.ok) { + toast(`Updated ${edit.album.trim()}`); + setEditing(false); + show(null); + refresh(); + } else { + toast((res && (await res.json().catch(() => null))?.error) || "Couldn't update album"); + } + } finally { + setSaving(false); + } } async function remove() { @@ -173,7 +209,31 @@ export function LibraryClient() { album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType, artistMbid: open.artistMbid, trackNames: open.trackNames }} status={In library · {open.format}} actions={ - confirmDelete ? ( + editing ? ( +
+ + + +
+ + +
+

Renames the folder on disk and re-resolves cover art. Embedded file tags are left as-is.

+
+ ) : confirmDelete ? ( <> Delete this album and its files from disk? ) : ( - + <> + + + ) } /> diff --git a/web/src/lib/library-path.ts b/web/src/lib/library-path.ts new file mode 100644 index 0000000..adfd6d5 --- /dev/null +++ b/web/src/lib/library-path.ts @@ -0,0 +1,23 @@ +import { join, resolve } from "node:path"; + +const ILLEGAL = /[<>:"/\\|?*]/g; +const YEAR_DIR = /\((\d{4})\)\s*$/; + +/** Filesystem-safe path segment — mirrors the worker's import naming so manual imports and + * edits produce the same on-disk layout the scanner expects (Artist/Album (Year)/…). */ +export function safeName(s: string): string { + const cleaned = s.replace(ILLEGAL, "_").trim().replace(/^\.+|\.+$/g, "").trim(); + return cleaned || "Unknown"; +} + +/** Absolute album folder for `{root}/{Artist}/{Album (Year)}` (year optional). */ +export function albumDir(root: string, artist: string, album: string, year?: string): string { + const folder = year ? `${safeName(album)} (${year})` : safeName(album); + return join(resolve(root), safeName(artist), folder); +} + +/** Pull a trailing "(YYYY)" out of an album folder path, if present. */ +export function yearFromPath(path: string): string | null { + const m = YEAR_DIR.exec(path); + return m ? m[1] : null; +}