feat: finish the upload, playback, and test gaps from the docs
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
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>
This commit is contained in:
1 parent
2b76060ea3
commit
066ba24834
61 files changed
+2818
-463
No files matched your search
@@ -17,15 +17,18 @@ export type Stage =
|
||||
| 'uploading'
|
||||
| 'done'
|
||||
| 'rejected'
|
||||
| 'error';
|
||||
| 'error'
|
||||
| 'resume';
|
||||
|
||||
export interface UploadItem {
|
||||
id: string;
|
||||
file: File;
|
||||
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, …
|
||||
@@ -44,6 +47,17 @@ export interface UploaderState {
|
||||
|
||||
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;
|
||||
|
||||
@@ -56,7 +70,9 @@ export class Uploader {
|
||||
private uploads = new Map<string, tus.Upload>();
|
||||
private refreshing: Promise<unknown> | null = null;
|
||||
|
||||
constructor(private readonly session: AuthSession) {}
|
||||
constructor(private readonly session: AuthSession) {
|
||||
this.restore();
|
||||
}
|
||||
|
||||
subscribe = (fn: () => void) => {
|
||||
this.listeners.add(fn);
|
||||
@@ -89,8 +105,8 @@ export class Uploader {
|
||||
continue;
|
||||
}
|
||||
// Тот же путь и размер уже в списке — не добавляем второй раз.
|
||||
if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue;
|
||||
fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 });
|
||||
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));
|
||||
@@ -101,6 +117,7 @@ export class Uploader {
|
||||
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();
|
||||
}
|
||||
|
||||
/** Очистить список (после завершения или чтобы начать заново). */
|
||||
@@ -108,6 +125,7 @@ export class Uploader {
|
||||
if (this.state.busy) return;
|
||||
this.hashQueue = [];
|
||||
this.set({ items: [], skipped: 0, batchId: null, error: null });
|
||||
this.persist();
|
||||
}
|
||||
|
||||
private hashWorker(): Worker {
|
||||
@@ -125,6 +143,7 @@ export class Uploader {
|
||||
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) });
|
||||
@@ -137,13 +156,15 @@ export class Uploader {
|
||||
}
|
||||
|
||||
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.file.size, 1) });
|
||||
this.patch(it.id, { progress: m.done / Math.max(it.size, 1) });
|
||||
return;
|
||||
}
|
||||
w.removeEventListener('message', onMessage);
|
||||
@@ -151,7 +172,7 @@ export class Uploader {
|
||||
else reject(new Error(m.message));
|
||||
};
|
||||
w.addEventListener('message', onMessage);
|
||||
w.postMessage({ id: it.id, file: it.file } satisfies HashRequest);
|
||||
w.postMessage({ id: it.id, file } satisfies HashRequest);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,7 +183,17 @@ export class Uploader {
|
||||
try {
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/uploads/precheck', {
|
||||
body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) },
|
||||
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) {
|
||||
@@ -192,9 +223,9 @@ export class Uploader {
|
||||
comment: comment.trim() || undefined,
|
||||
files: ready.map((it) => ({
|
||||
client_id: it.id,
|
||||
name: it.file.name,
|
||||
name: it.file?.name ?? it.relPath,
|
||||
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
|
||||
size: it.file.size,
|
||||
size: it.size,
|
||||
sha256: it.sha256!,
|
||||
})),
|
||||
},
|
||||
@@ -209,6 +240,7 @@ export class Uploader {
|
||||
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 });
|
||||
@@ -239,9 +271,10 @@ export class Uploader {
|
||||
|
||||
private upload(id: string, url: string): Promise<void> {
|
||||
const it = this.item(id);
|
||||
if (!it) return Promise.resolve();
|
||||
if (!it?.file) return Promise.resolve();
|
||||
const file = it.file;
|
||||
return new Promise((resolve) => {
|
||||
const up = new tus.Upload(it.file, {
|
||||
const up = new tus.Upload(file, {
|
||||
uploadUrl: url,
|
||||
chunkSize: CHUNK,
|
||||
retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
|
||||
@@ -264,6 +297,7 @@ export class Uploader {
|
||||
onSuccess: () => {
|
||||
this.uploads.delete(id);
|
||||
this.patch(id, { stage: 'done', progress: 1 });
|
||||
this.persist();
|
||||
resolve();
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -276,6 +310,100 @@ export class Uploader {
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user