diff --git a/web/src/app/library/import-album.tsx b/web/src/app/library/import-album.tsx index 6234722..6eaeb18 100644 --- a/web/src/app/library/import-album.tsx +++ b/web/src/app/library/import-album.tsx @@ -6,8 +6,55 @@ 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. */ +// A dropped item may be a whole folder; the plain dataTransfer.files doesn't recurse, so walk +// 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 { + return new Promise((res, rej) => reader.readEntries(res, rej)); +} + +async function walkEntry(entry: FileSystemEntry, out: File[]): Promise { + if (entry.isFile) { + const file = await new Promise((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 }) { const [open, setOpen] = useState(false); const [files, setFiles] = useState([]); @@ -31,8 +78,22 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) { setOpen(false); reset(); } - function addFiles(list: FileList | null) { - if (list) setFiles((prev) => [...prev, ...Array.from(list)]); + function addFiles(list: File[]) { + 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) { @@ -75,25 +136,21 @@ export function ImportAlbum({ onImported }: { onImported: () => void }) { setDrag(true); }} onDragLeave={() => setDrag(false)} - onDrop={(e) => { - e.preventDefault(); - setDrag(false); - addFiles(e.dataTransfer.files); - }} + onDrop={onDrop} 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"} + : "Drop an album folder (or audio files) here, or click to choose files"} addFiles(e.target.files)} + onChange={(e) => addFiles(Array.from(e.target.files ?? []))} />