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:
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Modal } from "../_ui/modal";
|
||||
import { toast } from "../_ui/toast";
|
||||
|
||||
const AUDIO_RE = /\.(flac|mp3|m4a|opus|ogg|aac|wav)$/i;
|
||||
|
||||
/** Manually add an album (ripped CD / local files): drag-drop the audio + fill in
|
||||
* artist/album/year, POST to /api/library/import. */
|
||||
export function ImportAlbum({ onImported }: { onImported: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [artist, setArtist] = useState("");
|
||||
const [album, setAlbum] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [drag, setDrag] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const audioCount = files.filter((f) => AUDIO_RE.test(f.name)).length;
|
||||
const ready = !!artist.trim() && !!album.trim() && audioCount > 0;
|
||||
|
||||
function reset() {
|
||||
setFiles([]);
|
||||
setArtist("");
|
||||
setAlbum("");
|
||||
setYear("");
|
||||
}
|
||||
function close() {
|
||||
setOpen(false);
|
||||
reset();
|
||||
}
|
||||
function addFiles(list: FileList | null) {
|
||||
if (list) setFiles((prev) => [...prev, ...Array.from(list)]);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("artist", artist.trim());
|
||||
fd.set("album", album.trim());
|
||||
if (year.trim()) fd.set("year", year.trim());
|
||||
for (const f of files) fd.append("files", f);
|
||||
const res = await fetch("/api/library/import", { method: "POST", body: fd }).catch(() => null);
|
||||
if (res?.ok) {
|
||||
const d = (await res.json()) as { tracks: number };
|
||||
toast(`Imported ${album.trim()} · ${d.tracks} tracks`);
|
||||
onImported();
|
||||
close();
|
||||
} else {
|
||||
const d = res ? ((await res.json().catch(() => ({}))) as { error?: string }) : {};
|
||||
toast(d.error || "Import failed");
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="btn accent" onClick={() => setOpen(true)}>
|
||||
Add album
|
||||
</button>
|
||||
{open ? (
|
||||
<Modal open onClose={close} title="Add an album">
|
||||
<form onSubmit={submit} className="import-form">
|
||||
<div
|
||||
className={`dropzone${drag ? " over" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDrag(true);
|
||||
}}
|
||||
onDragLeave={() => setDrag(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDrag(false);
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{audioCount > 0
|
||||
? `${audioCount} audio file${audioCount === 1 ? "" : "s"} selected${
|
||||
files.length > audioCount ? " (+ cover)" : ""
|
||||
}`
|
||||
: "Drop audio files here, or click to choose"}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*,.flac,.m4a,.opus,image/*"
|
||||
hidden
|
||||
onChange={(e) => addFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>Artist</span>
|
||||
<input aria-label="import artist" value={artist} onChange={(e) => setArtist(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Album</span>
|
||||
<input aria-label="import album" value={album} onChange={(e) => setAlbum(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Year (optional)</span>
|
||||
<input aria-label="import year" value={year} onChange={(e) => setYear(e.target.value)} placeholder="2020" />
|
||||
</label>
|
||||
</div>
|
||||
<p className="help">
|
||||
Files are written into the library as-is (add a cover.jpg by including an image). Run a{" "}
|
||||
<b>Scan</b> in Settings afterwards to fetch cover art and match MusicBrainz.
|
||||
</p>
|
||||
<div className="save-row">
|
||||
<button type="submit" className="btn accent" disabled={busy || !ready}>
|
||||
{busy ? "Importing…" : "Import"}
|
||||
</button>
|
||||
{files.length > 0 ? (
|
||||
<button type="button" className="btn ghost sm" onClick={reset}>
|
||||
Clear files
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user