// Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5). export interface PickedFile { file: File; relPath: string; // «Кино/1988 - Группа крови/01 - Группа крови.flac» или просто имя файла } /** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */ export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv']; export function isAudio(name: string): boolean { const ext = name.split('.').pop()?.toLowerCase() ?? ''; return AUDIO_EXTENSIONS.includes(ext); } /** Файлы из (в том числе webkitdirectory — тогда путь есть в webkitRelativePath). */ export function fromInput(list: FileList | null): PickedFile[] { return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name })); } /** Файлы из перетаскивания: папки обходятся рекурсивно (FileSystemEntry API). */ export async function fromDrop(dt: DataTransfer): Promise { const entries = [...dt.items] .filter((it) => it.kind === 'file') .map((it) => it.webkitGetAsEntry?.()) .filter((e): e is FileSystemEntry => !!e); if (!entries.length) return fromInput(dt.files); const out: PickedFile[] = []; for (const e of entries) await walk(e, '', out); return out; } async function walk(entry: FileSystemEntry, prefix: string, out: PickedFile[]) { const path = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isFile) { const file = await new Promise((resolve, reject) => (entry as FileSystemFileEntry).file(resolve, reject)); out.push({ file, relPath: path }); return; } if (!entry.isDirectory || entry.name.startsWith('.')) return; const reader = (entry as FileSystemDirectoryEntry).createReader(); // readEntries отдаёт порциями (~100), пока не вернёт пустой массив. for (;;) { const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); if (!batch.length) break; for (const child of batch) await walk(child, path, out); } }