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:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user