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
+23
View File
@@ -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;
}