feat(web): Library Management — manual album import (drag-and-drop)

Second Library Management feature. An "Add album" button on the Library page
opens a modal: drag-drop (or pick) the audio files (+ optional cover image),
enter artist/album/year, and Lyra writes the album into the library.

- POST /api/library/import (auth-gated, multipart): sanitizes the folder name
  like the worker's safe_name, assembles into a sibling ".importing" temp dir
  then renames into place (never a half-written album), writes a cover.jpg from
  any image, and records a source="manual" LibraryItem with the on-disk
  trackNames + a rough quality class from the extension (a later Scan re-probes /
  MB-resolves for cover art + hi-res detection). Guards: 400 (missing
  artist/album or no audio), 409 (folder exists / already in library).
- Library page: ImportAlbum drop-zone modal wired to refresh the grid.

web 161 tests (write-to-disk + trackNames + guards + filename sanitization).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Also: DELETE now prunes an emptied artist folder (best-effort).
This commit is contained in:
Jonathan
2026-07-14 14:00:35 +02:00
parent 0d3fe8d907
commit 93ba9c7e16
6 changed files with 299 additions and 3 deletions
+6 -1
View File
@@ -6,6 +6,7 @@ import { SectionHeader } from "../_ui/section-header";
import { CoverArt } from "../_ui/cover-art";
import { AlbumModal } from "../_ui/album-modal";
import { toast } from "../_ui/toast";
import { ImportAlbum } from "./import-album";
type Album = {
id: string;
@@ -44,10 +45,13 @@ export function LibraryClient() {
}
}
useEffect(() => {
function refresh() {
fetch("/api/library")
.then((r) => r.json())
.then((d) => setAlbums(d.albums ?? []));
}
useEffect(() => {
refresh();
}, []);
const shown = useMemo(() => {
@@ -61,6 +65,7 @@ export function LibraryClient() {
<PageHead title="Library" eyebrow="The pressings · everything on disk" />
<form className="request-form" onSubmit={(e) => e.preventDefault()}>
<ImportAlbum onImported={refresh} />
<label className="field">
<span>Filter</span>
<input aria-label="filter library" placeholder="Artist or album…" value={q} onChange={(e) => setQ(e.target.value)} />