ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m4s
ci / ts / ts (packages + web) (push) Successful in 1m23s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m51s
- Suggest tracks: drag-and-drop of files and folders with relative paths, streaming SHA-256 in a Web Worker (hash-wasm), duplicate precheck, batch reservation and tus-js-client upload (8 MB chunks, 4 in parallel, token refresh on 401, resume of failed files). - My uploads: statuses, rejection reasons, withdraw, quota meter. - Upload queue (admin): status tabs, possible-duplicate filter, batches, metadata and storage editing, bulk approve / reject with a reason, import retry, and the queue settings form. - Sidebar section for moderators and admins; ru / en strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
49 lines
2.4 KiB
TypeScript
49 lines
2.4 KiB
TypeScript
// Сбор файлов из 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);
|
|
}
|
|
|
|
/** Файлы из <input type="file"> (в том числе 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<PickedFile[]> {
|
|
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<File>((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<FileSystemEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
|
|
if (!batch.length) break;
|
|
for (const child of batch) await walk(child, path, out);
|
|
}
|
|
}
|