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
+69 -4
View File
@@ -40,10 +40,46 @@ export function LibraryClient() {
const [sort, setSort] = useState<SortKey>("added");
const [open, setOpen] = useState<Album | null>(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={<span className="chip done">In library · {open.format}</span>}
actions={
confirmDelete ? (
editing ? (
<div className="edit-meta">
<label className="field">
<span>Artist</span>
<input aria-label="edit artist" value={edit.artist} onChange={(e) => setEdit((s) => ({ ...s, artist: e.target.value }))} />
</label>
<label className="field">
<span>Album</span>
<input aria-label="edit album" value={edit.album} onChange={(e) => setEdit((s) => ({ ...s, album: e.target.value }))} />
</label>
<label className="field">
<span>Year</span>
<input aria-label="edit year" placeholder="YYYY" value={edit.year} onChange={(e) => setEdit((s) => ({ ...s, year: e.target.value }))} />
</label>
<div className="save-row">
<button className="btn sm accent" onClick={saveEdit} disabled={saving}>
{saving ? "Saving…" : "Save"}
</button>
<button className="btn sm ghost" onClick={() => setEditing(false)} disabled={saving}>
Cancel
</button>
</div>
<p className="muted-note">Renames the folder on disk and re-resolves cover art. Embedded file tags are left as-is.</p>
</div>
) : confirmDelete ? (
<>
<span className="rmeta">Delete this album and its files from disk?</span>
<button className="btn sm accent" onClick={remove}>
@@ -184,9 +244,14 @@ export function LibraryClient() {
</button>
</>
) : (
<button className="btn sm ghost" onClick={() => setConfirmDelete(true)}>
Delete album
</button>
<>
<button className="btn sm ghost" onClick={startEdit}>
Edit metadata
</button>
<button className="btn sm ghost" onClick={() => setConfirmDelete(true)}>
Delete album
</button>
</>
)
}
/>