feat(web): drop a whole album folder into manual import

Manual import only accepted individual audio files — dragging a folder yielded
nothing (dataTransfer.files doesn't recurse). Now the drop zone walks the entry
tree via webkitGetAsEntry, so you can drag a whole album folder (the natural
shape of a ripped CD) and it collects the audio + cover inside. When a single
folder is dropped, artist/album/year are prefilled by parsing its name
("Artist - Album (Year)"), only filling fields you haven't typed into. Clicking
still opens the multi-file picker. No API change — same files reach the route.

web 161 tests, tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 14:15:39 +02:00
parent 93ba9c7e16
commit 0a2a5cbdc4
+68 -11
View File
@@ -6,8 +6,55 @@ import { toast } from "../_ui/toast";
const AUDIO_RE = /\.(flac|mp3|m4a|opus|ogg|aac|wav)$/i; 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 // A dropped item may be a whole folder; the plain dataTransfer.files doesn't recurse, so walk
* artist/album/year, POST to /api/library/import. */ // the entry tree with webkitGetAsEntry to collect files inside a dropped album folder.
function readEntries(reader: {
readEntries: (cb: (e: FileSystemEntry[]) => void, err: (e: unknown) => void) => void;
}): Promise<FileSystemEntry[]> {
return new Promise((res, rej) => reader.readEntries(res, rej));
}
async function walkEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
if (entry.isFile) {
const file = await new Promise<File>((res, rej) => (entry as FileSystemFileEntry).file(res, rej));
out.push(file);
} else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
for (let batch = await readEntries(reader); batch.length; batch = await readEntries(reader)) {
for (const e of batch) await walkEntry(e, out);
}
}
}
/** Returns the dropped files (recursing into a dropped folder) + the top-level folder name if
* a single folder was dropped (used to prefill artist/album). */
async function collectDropped(dt: DataTransfer): Promise<{ files: File[]; folder: string | null }> {
const entries = Array.from(dt.items)
.map((it) => it.webkitGetAsEntry?.())
.filter((e): e is FileSystemEntry => !!e);
if (entries.length === 0) return { files: Array.from(dt.files), folder: null };
const out: File[] = [];
for (const e of entries) await walkEntry(e, out);
const dirs = entries.filter((e) => e.isDirectory);
return { files: out, folder: dirs.length === 1 && entries.length === 1 ? dirs[0].name : null };
}
/** Parse "Artist - Album (Year)" / "Album (Year)" from a dropped folder name. */
function parseFolder(name: string): { artist?: string; album: string; year?: string } {
let s = name.trim();
let year: string | undefined;
const ym = s.match(/[([]?(\d{4})[)\]]?\s*$/);
if (ym) {
year = ym[1];
s = s.slice(0, ym.index).trim().replace(/[-–—([]\s*$/, "").trim();
}
const dash = s.split(/\s+[-–—]\s+/);
if (dash.length >= 2) return { artist: dash[0].trim(), album: dash.slice(1).join(" - ").trim(), year };
return { album: s, year };
}
/** Manually add an album (ripped CD / local files): drag-drop the folder (or audio files) +
* fill in artist/album/year, POST to /api/library/import. */
export function ImportAlbum({ onImported }: { onImported: () => void }) { export function ImportAlbum({ onImported }: { onImported: () => void }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
@@ -31,8 +78,22 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) {
setOpen(false); setOpen(false);
reset(); reset();
} }
function addFiles(list: FileList | null) { function addFiles(list: File[]) {
if (list) setFiles((prev) => [...prev, ...Array.from(list)]); if (list.length) setFiles((prev) => [...prev, ...list]);
}
async function onDrop(e: React.DragEvent) {
e.preventDefault();
setDrag(false);
const { files: dropped, folder } = await collectDropped(e.dataTransfer);
addFiles(dropped);
// prefill from a dropped album folder's name (only fields the user hasn't typed into)
if (folder) {
const p = parseFolder(folder);
setAlbum((a) => a || p.album);
if (p.artist) setArtist((a) => a || p.artist!);
if (p.year) setYear((y) => y || p.year!);
}
} }
async function submit(e: React.FormEvent) { async function submit(e: React.FormEvent) {
@@ -75,25 +136,21 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) {
setDrag(true); setDrag(true);
}} }}
onDragLeave={() => setDrag(false)} onDragLeave={() => setDrag(false)}
onDrop={(e) => { onDrop={onDrop}
e.preventDefault();
setDrag(false);
addFiles(e.dataTransfer.files);
}}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
> >
{audioCount > 0 {audioCount > 0
? `${audioCount} audio file${audioCount === 1 ? "" : "s"} selected${ ? `${audioCount} audio file${audioCount === 1 ? "" : "s"} selected${
files.length > audioCount ? " (+ cover)" : "" files.length > audioCount ? " (+ cover)" : ""
}` }`
: "Drop audio files here, or click to choose"} : "Drop an album folder (or audio files) here, or click to choose files"}
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
multiple multiple
accept="audio/*,.flac,.m4a,.opus,image/*" accept="audio/*,.flac,.m4a,.opus,image/*"
hidden hidden
onChange={(e) => addFiles(e.target.files)} onChange={(e) => addFiles(Array.from(e.target.files ?? []))}
/> />
</div> </div>
<div className="field-grid"> <div className="field-grid">