ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites. Co-authored-by: Cursor <cursoragent@cursor.com>
416 lines
15 KiB
TypeScript
416 lines
15 KiB
TypeScript
import { unwrap, type AuthSession } from '@pipu/core';
|
|
import * as tus from 'tus-js-client';
|
|
|
|
import { isAudio, type PickedFile } from './files';
|
|
import type { HashRequest, HashResponse } from './hash.worker';
|
|
|
|
// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей
|
|
// (ничего не резервирует) → POST /uploads (резерв квоты) → tus, до 4 файлов параллельно, куски по 8 МБ.
|
|
// Синглтон модуля: загрузка продолжается при переходах по приложению, но не переживает перезагрузку страницы.
|
|
|
|
export type Stage =
|
|
| 'hashing'
|
|
| 'checking'
|
|
| 'ready'
|
|
| 'duplicate'
|
|
| 'too_large'
|
|
| 'uploading'
|
|
| 'done'
|
|
| 'rejected'
|
|
| 'error'
|
|
| 'resume';
|
|
|
|
export interface UploadItem {
|
|
id: string;
|
|
file: File | null;
|
|
size: number;
|
|
relPath: string;
|
|
stage: Stage;
|
|
progress: number; // 0..1 текущего этапа (хэш или загрузка)
|
|
sha256?: string;
|
|
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
|
|
trackId?: string | null;
|
|
possibleDuplicate?: boolean;
|
|
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
|
|
message?: string;
|
|
uploadFileId?: string;
|
|
tusUrl?: string;
|
|
}
|
|
|
|
export interface UploaderState {
|
|
items: UploadItem[];
|
|
skipped: number; // не аудио (обложки, .cue, …)
|
|
busy: boolean; // идёт загрузка
|
|
batchId: string | null;
|
|
error: unknown;
|
|
}
|
|
|
|
const PARALLEL = 4;
|
|
const CHUNK = 8 << 20;
|
|
const PENDING_KEY = 'pipu.uploads.pending';
|
|
|
|
interface PendingUpload {
|
|
id: string;
|
|
name: string;
|
|
size: number;
|
|
relPath: string;
|
|
sha256: string;
|
|
tusUrl: string;
|
|
uploadFileId: string;
|
|
}
|
|
|
|
let seq = 0;
|
|
|
|
export class Uploader {
|
|
private state: UploaderState = { items: [], skipped: 0, busy: false, batchId: null, error: null };
|
|
private listeners = new Set<() => void>();
|
|
private worker: Worker | null = null;
|
|
private hashQueue: string[] = [];
|
|
private hashing = false;
|
|
private uploads = new Map<string, tus.Upload>();
|
|
private refreshing: Promise<unknown> | null = null;
|
|
|
|
constructor(private readonly session: AuthSession) {
|
|
this.restore();
|
|
}
|
|
|
|
subscribe = (fn: () => void) => {
|
|
this.listeners.add(fn);
|
|
return () => this.listeners.delete(fn);
|
|
};
|
|
|
|
getState = () => this.state;
|
|
|
|
private set(patch: Partial<UploaderState>) {
|
|
this.state = { ...this.state, ...patch };
|
|
for (const fn of this.listeners) fn();
|
|
}
|
|
|
|
private patch(id: string, p: Partial<UploadItem>) {
|
|
this.set({ items: this.state.items.map((it) => (it.id === id ? { ...it, ...p } : it)) });
|
|
}
|
|
|
|
private item(id: string) {
|
|
return this.state.items.find((it) => it.id === id);
|
|
}
|
|
|
|
/** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */
|
|
add(files: PickedFile[]) {
|
|
if (this.state.busy) return;
|
|
const fresh: UploadItem[] = [];
|
|
let skipped = 0;
|
|
for (const f of files) {
|
|
if (!isAudio(f.file.name)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
// Тот же путь и размер уже в списке — не добавляем второй раз.
|
|
if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue;
|
|
fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 });
|
|
}
|
|
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
|
|
this.hashQueue.push(...fresh.map((it) => it.id));
|
|
void this.hashNext();
|
|
}
|
|
|
|
remove(id: string) {
|
|
if (this.state.busy) return;
|
|
this.hashQueue = this.hashQueue.filter((x) => x !== id);
|
|
this.set({ items: this.state.items.filter((it) => it.id !== id) });
|
|
this.persist();
|
|
}
|
|
|
|
/** Очистить список (после завершения или чтобы начать заново). */
|
|
clear() {
|
|
if (this.state.busy) return;
|
|
this.hashQueue = [];
|
|
this.set({ items: [], skipped: 0, batchId: null, error: null });
|
|
this.persist();
|
|
}
|
|
|
|
private hashWorker(): Worker {
|
|
this.worker ??= new Worker(new URL('./hash.worker.ts', import.meta.url), { type: 'module' });
|
|
return this.worker;
|
|
}
|
|
|
|
private async hashNext() {
|
|
if (this.hashing) return;
|
|
this.hashing = true;
|
|
try {
|
|
while (this.hashQueue.length) {
|
|
const id = this.hashQueue.shift()!;
|
|
const it = this.item(id);
|
|
if (!it) continue;
|
|
try {
|
|
const sha256 = await this.hash(it);
|
|
await this.readTags(it);
|
|
this.patch(id, { sha256, stage: 'checking', progress: 1 });
|
|
} catch (err) {
|
|
this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
}
|
|
}
|
|
await this.precheck();
|
|
} finally {
|
|
this.hashing = false;
|
|
}
|
|
}
|
|
|
|
private hash(it: UploadItem): Promise<string> {
|
|
const file = it.file;
|
|
if (!file) return Promise.reject(new Error('no file'));
|
|
const w = this.hashWorker();
|
|
return new Promise((resolve, reject) => {
|
|
const onMessage = (e: MessageEvent<HashResponse>) => {
|
|
const m = e.data;
|
|
if (m.id !== it.id) return;
|
|
if (m.type === 'progress') {
|
|
this.patch(it.id, { progress: m.done / Math.max(it.size, 1) });
|
|
return;
|
|
}
|
|
w.removeEventListener('message', onMessage);
|
|
if (m.type === 'done') resolve(m.sha256);
|
|
else reject(new Error(m.message));
|
|
};
|
|
w.addEventListener('message', onMessage);
|
|
w.postMessage({ id: it.id, file } satisfies HashRequest);
|
|
});
|
|
}
|
|
|
|
/** Предпроверка всех посчитанных файлов: дубли отсеиваются до загрузки. */
|
|
private async precheck() {
|
|
const pending = this.state.items.filter((it) => it.stage === 'checking' && it.sha256);
|
|
if (!pending.length) return;
|
|
try {
|
|
const res = unwrap(
|
|
await this.session.client.POST('/api/v1/uploads/precheck', {
|
|
body: {
|
|
files: pending.map((it) => ({
|
|
client_id: it.id,
|
|
size: it.size,
|
|
sha256: it.sha256!,
|
|
artist: it.tags?.artist,
|
|
title: it.tags?.title,
|
|
album: it.tags?.album,
|
|
duration_ms: it.tags?.duration_ms,
|
|
})),
|
|
},
|
|
}),
|
|
);
|
|
for (const r of res.files) {
|
|
const stage: Stage = r.result === 'accepted' ? 'ready' : r.result;
|
|
this.patch(r.client_id, { stage, trackId: r.track_id, possibleDuplicate: r.possible_duplicate });
|
|
}
|
|
} catch (err) {
|
|
// Предпроверка — подсказка: при сбое файлы всё равно можно отправить, сервер проверит дубли сам.
|
|
for (const it of pending) this.patch(it.id, { stage: 'ready' });
|
|
this.set({ error: err });
|
|
}
|
|
}
|
|
|
|
get ready(): UploadItem[] {
|
|
return this.state.items.filter((it) => it.stage === 'ready');
|
|
}
|
|
|
|
/** Создать пачку (резерв квоты) и загрузить принятые файлы. */
|
|
async start(comment: string) {
|
|
const ready = this.ready;
|
|
if (this.state.busy || !ready.length) return;
|
|
this.set({ busy: true, error: null });
|
|
try {
|
|
const res = unwrap(
|
|
await this.session.client.POST('/api/v1/uploads', {
|
|
body: {
|
|
comment: comment.trim() || undefined,
|
|
files: ready.map((it) => ({
|
|
client_id: it.id,
|
|
name: it.file?.name ?? it.relPath,
|
|
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
|
|
size: it.size,
|
|
sha256: it.sha256!,
|
|
})),
|
|
},
|
|
}),
|
|
);
|
|
this.set({ batchId: res.batch_id });
|
|
const queue: { id: string; url: string }[] = [];
|
|
for (const f of res.files) {
|
|
if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id });
|
|
else if (f.tus_url && f.upload_file_id) {
|
|
this.patch(f.client_id, { uploadFileId: f.upload_file_id, tusUrl: f.tus_url, stage: 'uploading', progress: 0 });
|
|
queue.push({ id: f.client_id, url: f.tus_url });
|
|
}
|
|
}
|
|
this.persist();
|
|
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
|
|
} catch (err) {
|
|
this.set({ error: err });
|
|
} finally {
|
|
this.set({ busy: false });
|
|
}
|
|
}
|
|
|
|
/** Повторить оборвавшиеся загрузки: место уже зарезервировано, tus продолжит с принятого смещения. */
|
|
async retryFailed() {
|
|
const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl);
|
|
if (this.state.busy || !failed.length) return;
|
|
this.set({ busy: true, error: null });
|
|
try {
|
|
const queue = failed.map((it) => {
|
|
this.patch(it.id, { stage: 'uploading', message: undefined });
|
|
return { id: it.id, url: it.tusUrl! };
|
|
});
|
|
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
|
|
} finally {
|
|
this.set({ busy: false });
|
|
}
|
|
}
|
|
|
|
private async drain(queue: { id: string; url: string }[]) {
|
|
for (let next = queue.shift(); next; next = queue.shift()) await this.upload(next.id, next.url);
|
|
}
|
|
|
|
private upload(id: string, url: string): Promise<void> {
|
|
const it = this.item(id);
|
|
if (!it?.file) return Promise.resolve();
|
|
const file = it.file;
|
|
return new Promise((resolve) => {
|
|
const up = new tus.Upload(file, {
|
|
uploadUrl: url,
|
|
chunkSize: CHUNK,
|
|
retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
|
|
storeFingerprintForResuming: false,
|
|
onBeforeRequest: async (req) => {
|
|
await this.refreshing;
|
|
const token = this.session.accessToken;
|
|
if (token) req.setHeader('Authorization', `Bearer ${token}`);
|
|
},
|
|
onShouldRetry: (err) => {
|
|
const status = (err as tus.DetailedError).originalResponse?.getStatus();
|
|
if (status === 401) {
|
|
this.refreshing = this.session.refresh();
|
|
return true;
|
|
}
|
|
// Сеть, перегрузка, рассинхрон смещения — повторяем (tus-js-client спросит HEAD и продолжит).
|
|
return status === undefined || status === 0 || status >= 500 || status === 409 || status === 423;
|
|
},
|
|
onProgress: (sent, total) => this.patch(id, { progress: sent / Math.max(total, 1) }),
|
|
onSuccess: () => {
|
|
this.uploads.delete(id);
|
|
this.patch(id, { stage: 'done', progress: 1 });
|
|
this.persist();
|
|
resolve();
|
|
},
|
|
onError: (err) => {
|
|
this.uploads.delete(id);
|
|
this.patch(id, { stage: 'error', message: err.message });
|
|
resolve();
|
|
},
|
|
});
|
|
this.uploads.set(id, up);
|
|
up.start();
|
|
});
|
|
}
|
|
|
|
private async readTags(it: UploadItem) {
|
|
if (!it.file) return;
|
|
try {
|
|
const { parseBlob } = await import('music-metadata');
|
|
const meta = await parseBlob(it.file);
|
|
const duration = meta.format.duration;
|
|
this.patch(it.id, {
|
|
tags: {
|
|
artist: meta.common.artist,
|
|
title: meta.common.title,
|
|
album: meta.common.album,
|
|
duration_ms: duration ? Math.round(duration * 1000) : undefined,
|
|
},
|
|
});
|
|
} catch {
|
|
// Теги — подсказка для предпроверки; без них сервер прочитает их сам.
|
|
}
|
|
}
|
|
|
|
/** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */
|
|
private persist() {
|
|
const pending: PendingUpload[] = this.state.items
|
|
.filter((it): it is UploadItem & { sha256: string; tusUrl: string; uploadFileId: string } =>
|
|
Boolean(it.tusUrl && it.sha256 && it.uploadFileId && it.stage !== 'done'),
|
|
)
|
|
.map((it) => ({
|
|
id: it.id,
|
|
name: it.file?.name ?? it.relPath,
|
|
size: it.size,
|
|
relPath: it.relPath,
|
|
sha256: it.sha256,
|
|
tusUrl: it.tusUrl,
|
|
uploadFileId: it.uploadFileId,
|
|
}));
|
|
try {
|
|
if (pending.length) localStorage.setItem(PENDING_KEY, JSON.stringify(pending));
|
|
else localStorage.removeItem(PENDING_KEY);
|
|
} catch {
|
|
// Приватный режим — докачка после перезагрузки недоступна.
|
|
}
|
|
}
|
|
|
|
private restore() {
|
|
try {
|
|
const raw = localStorage.getItem(PENDING_KEY);
|
|
if (!raw) return;
|
|
const rows = JSON.parse(raw) as PendingUpload[];
|
|
if (!Array.isArray(rows) || !rows.length) return;
|
|
this.state = {
|
|
...this.state,
|
|
items: rows.map((r) => ({
|
|
id: r.id,
|
|
file: null,
|
|
size: r.size,
|
|
relPath: r.relPath,
|
|
stage: 'resume' as const,
|
|
progress: 0,
|
|
sha256: r.sha256,
|
|
tusUrl: r.tusUrl,
|
|
uploadFileId: r.uploadFileId,
|
|
})),
|
|
};
|
|
} catch {
|
|
localStorage.removeItem(PENDING_KEY);
|
|
}
|
|
}
|
|
|
|
/** Приложить заново те же файлы и продолжить tus с принятого смещения. */
|
|
async continueWith(files: PickedFile[]) {
|
|
const waiting = this.state.items.filter((it) => it.stage === 'resume' && it.tusUrl);
|
|
if (!waiting.length) return;
|
|
const queue: { id: string; url: string }[] = [];
|
|
for (const picked of files) {
|
|
const it = waiting.find((w) => w.size === picked.file.size && (w.relPath === picked.relPath || w.relPath.endsWith(picked.file.name)));
|
|
if (!it) continue;
|
|
this.patch(it.id, { file: picked.file, stage: 'hashing', progress: 0 });
|
|
const sha = await this.hash(this.item(it.id)!);
|
|
if (sha !== it.sha256) {
|
|
this.patch(it.id, { stage: 'error', message: 'sha256' });
|
|
continue;
|
|
}
|
|
this.patch(it.id, { stage: 'uploading', progress: 0 });
|
|
queue.push({ id: it.id, url: it.tusUrl! });
|
|
}
|
|
if (!queue.length) return;
|
|
this.set({ busy: true });
|
|
try {
|
|
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
|
|
} finally {
|
|
this.set({ busy: false });
|
|
this.persist();
|
|
}
|
|
}
|
|
}
|
|
|
|
let instance: { session: AuthSession; uploader: Uploader } | null = null;
|
|
|
|
/** Загрузчик текущей сессии (новый при смене пользователя). */
|
|
export function uploaderFor(session: AuthSession): Uploader {
|
|
if (instance?.session !== session) instance = { session, uploader: new Uploader(session) };
|
|
return instance.uploader;
|
|
}
|