"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; // 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); } } } /** 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([]); 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(null); const folderRef = useRef(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: File[]) { if (list.length) setFiles((prev) => [...prev, ...list]); } function prefillFolder(folder: string | null) { if (!folder) return; 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 onDrop(e: React.DragEvent) { e.preventDefault(); setDrag(false); // Capture the entries SYNCHRONOUSLY — the DataTransfer is emptied once this handler yields. const dt = e.dataTransfer; const entries = Array.from(dt.items || []) .map((it) => it.webkitGetAsEntry?.()) .filter((x): x is FileSystemEntry => !!x); const plain = Array.from(dt.files || []); try { if (entries.length) { const out: File[] = []; for (const en of entries) await walkEntry(en, out); const dirs = entries.filter((en) => en.isDirectory); addFiles(out); prefillFolder(dirs.length === 1 && entries.length === 1 ? dirs[0].name : null); if (out.length === 0) toast("That folder had no files Lyra could read"); } else if (plain.length) { addFiles(plain); } else { toast("Couldn't read the drop — try the “choose a folder” link"); } } catch (err) { console.error("import drop failed:", err); toast("Couldn't read the dropped folder — try the “choose a folder” link"); } } // Reliable fallback: a folder picker (webkitdirectory). Files carry webkitRelativePath, whose // first segment is the folder name — use it to prefill. function onPickFolder(e: React.ChangeEvent) { const picked = Array.from(e.target.files ?? []); addFiles(picked); const rel = picked[0]?.webkitRelativePath ?? ""; prefillFolder(rel.includes("/") ? rel.split("/")[0] : null); e.target.value = ""; } 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 ( <> {open ? (
{ e.preventDefault(); setDrag(true); }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={onDrop} onClick={() => inputRef.current?.click()} > {audioCount > 0 ? `${audioCount} audio file${audioCount === 1 ? "" : "s"} selected${ files.length > audioCount ? " (+ cover)" : "" }` : "Drop an album folder (or files) here"} addFiles(Array.from(e.target.files ?? []))} /> {/* @ts-expect-error webkitdirectory is a non-standard attribute for folder selection */}

{" · "}

Files are written into the library as-is (add a cover.jpg by including an image). Run a{" "} Scan in Settings afterwards to fetch cover art and match MusicBrainz.

{files.length > 0 ? ( ) : null}
) : null} ); }