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:
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user