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:
@@ -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 { mkdtempSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { prisma } from "@/lib/db";
|
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;
|
let root: string;
|
||||||
const original = process.env.LYRA_LIBRARY_ROOT;
|
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();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { dirname, resolve, sep } from "node:path";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
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
|
// 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.
|
// LibraryItem, and stop monitoring the matching release so the monitor doesn't re-grab it.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Busboy from "busboy";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { isAuthed } from "@/lib/auth";
|
import { isAuthed } from "@/lib/auth";
|
||||||
import { searchReleaseGroup } from "@/lib/musicbrainz";
|
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
|
// 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
|
// 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 AUDIO_EXT = new Set([".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"]);
|
||||||
const LOSSLESS_EXT = new Set([".flac", ".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) {
|
export async function POST(request: Request) {
|
||||||
// This route is excluded from the auth middleware (to avoid the 10MB body cap), so it must
|
// 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 });
|
return Response.json({ error: bad }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const albumFolder = year ? `${safeName(album)} (${year})` : safeName(album);
|
const finalDir = albumDir(root, artist, album, year || undefined);
|
||||||
const finalDir = join(root, safeName(artist), albumFolder);
|
|
||||||
if (existsSync(finalDir) || (await prisma.libraryItem.findFirst({ where: { artist, album } }))) {
|
if (existsSync(finalDir) || (await prisma.libraryItem.findFirst({ where: { artist, album } }))) {
|
||||||
await rm(staging, { recursive: true, force: true });
|
await rm(staging, { recursive: true, force: true });
|
||||||
return Response.json({ error: "this album is already in the library" }, { status: 409 });
|
return Response.json({ error: "this album is already in the library" }, { status: 409 });
|
||||||
|
|||||||
Binary file not shown.
@@ -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);
|
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; }
|
.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; }
|
.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 { 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; }
|
.help b { color: var(--ink); font-weight: 600; }
|
||||||
|
|||||||
@@ -40,10 +40,46 @@ export function LibraryClient() {
|
|||||||
const [sort, setSort] = useState<SortKey>("added");
|
const [sort, setSort] = useState<SortKey>("added");
|
||||||
const [open, setOpen] = useState<Album | null>(null);
|
const [open, setOpen] = useState<Album | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
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) {
|
function show(a: Album | null) {
|
||||||
setOpen(a);
|
setOpen(a);
|
||||||
setConfirmDelete(false);
|
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() {
|
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 }}
|
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>}
|
status={<span className="chip done">In library · {open.format}</span>}
|
||||||
actions={
|
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>
|
<span className="rmeta">Delete this album and its files from disk?</span>
|
||||||
<button className="btn sm accent" onClick={remove}>
|
<button className="btn sm accent" onClick={remove}>
|
||||||
@@ -184,9 +244,14 @@ export function LibraryClient() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="btn sm ghost" onClick={startEdit}>
|
||||||
|
Edit metadata
|
||||||
|
</button>
|
||||||
<button className="btn sm ghost" onClick={() => setConfirmDelete(true)}>
|
<button className="btn sm ghost" onClick={() => setConfirmDelete(true)}>
|
||||||
Delete album
|
Delete album
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user