From f637ad4eeb6ad7ebc68fdc3992313d5602f8fa7f Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 18 Oct 2026 20:23:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(upload):=20reprise=20des=20morceaux=20en?= =?UTF-8?q?=20=C3=A9chec=20via=20=C2=AB=20R=C3=A9essayer=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute GET /upload/:id (morceaux reçus). Le client conserve la session après échec et ne renvoie que les morceaux manquants. Co-Authored-By: Claude Sonnet 5.5 --- public/upload.html | 47 +++++++++++++++++++++++++++++++----- src/routes/upload-chunked.ts | 18 ++++++++++++++ tests/upload-chunked.test.ts | 34 ++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/public/upload.html b/public/upload.html index 4fe6eca..5da99ad 100644 --- a/public/upload.html +++ b/public/upload.html @@ -750,8 +750,31 @@ chunkPool.tick(); }; + // Reprise après un échec : la session serveur est conservée dans `job.sessionId`, + // on récupère les morceaux déjà reçus pour ne renvoyer que les manquants. let meta; - for (;;) { + let alreadyReceived = []; + if (job.sessionId) { + try { + const status = await fetch('/upload/' + job.sessionId); + if (aborted) return; + if (status.ok) { + meta = await status.json(); + alreadyReceived = Array.isArray(meta.received) ? meta.received : []; + } else { + job.sessionId = null; // session expirée côté serveur : on repart de zéro + } + } catch { + if (aborted) return; + entry.stopped = true; + chunkPool.remove(entry); + finish(job); + fail(job, 'Erreur réseau (reprise)'); + return; + } + } + const resumed = Boolean(meta); + while (!resumed) { let res; try { res = await fetch('/upload/init' + uploadQuery(job), { @@ -794,12 +817,18 @@ } if (job.state === 'running') setStatus(ui, 'Envoi…', 'muted'); sessionId = meta.id; + job.sessionId = sessionId; const chunkSize = meta.chunkSize; const chunkCount = meta.chunkCount; const loadedPer = new Array(chunkCount).fill(0); const attempts = new Array(chunkCount).fill(0); const emit = () => report(loadedPer.reduce((a, b) => a + b, 0)); - let doneCount = 0; + const skip = new Set(alreadyReceived); + for (const i of skip) { + loadedPer[i] = Math.min(chunkSize, file.size - i * chunkSize); + } + let doneCount = skip.size; + if (doneCount > 0) emit(); function retry(index, status, releasePermit) { if (aborted || entry.stopped) { @@ -810,10 +839,15 @@ if (attempts[index] > CHUNK_RETRIES) { entry.stopped = true; entry.pending.length = 0; + if (status === 404) job.sessionId = null; // plus rien à reprendre côté serveur failureMessage = status === 404 ? 'Session expirée côté serveur, réessayez' - : 'Erreur morceau ' + index + ' (' + (status || 'réseau') + ')'; + : 'Erreur morceau ' + + index + + ' (' + + (status || 'réseau') + + ') — « Réessayer » reprend ici'; finalize('fail'); releasePermit(); return; @@ -827,7 +861,7 @@ releasePermit(); // libère le permis ; le morceau est re-planifié après le backoff } - entry.pending = Array.from({ length: chunkCount }, (_, i) => i); + entry.pending = Array.from({ length: chunkCount }, (_, i) => i).filter((i) => !skip.has(i)); entry.priming = false; entry.send = (index) => new Promise((releasePermit) => { @@ -878,15 +912,16 @@ xhr.send(file.slice(start, end)); }); + if (doneCount === chunkCount) finalize('ok'); // reprise : tout était déjà reçu chunkPool.tick(); const outcome = await finished; chunkPool.remove(entry); if (outcome === 'abort') return; if (outcome === 'fail') { + // La session serveur est conservée : « Réessayer » reprend aux morceaux manquants. for (const x of inflight) x.abort(); finish(job); - dropSession(sessionId); if (failureMessage) fail(job, failureMessage); return; } @@ -895,10 +930,10 @@ const res = await fetch('/upload/' + sessionId + '/complete', { method: 'POST' }); const body = await res.json().catch(() => ({})); finish(job); + if (res.ok || res.status === 404) job.sessionId = null; applyResult(job, res.status, body); } catch { finish(job); - dropSession(sessionId); fail(job, 'Erreur réseau (finalisation)'); } } diff --git a/src/routes/upload-chunked.ts b/src/routes/upload-chunked.ts index 466eb04..dacda4a 100644 --- a/src/routes/upload-chunked.ts +++ b/src/routes/upload-chunked.ts @@ -42,6 +42,11 @@ interface InitResponse { chunkCount: number; } +interface ResumeState extends InitResponse { + /** Index des morceaux déjà reçus par le serveur. */ + received: number[]; +} + interface ChunkAck { received: number; total: number; @@ -75,6 +80,7 @@ function clampChunkSize(value: unknown): number { /** * Upload fragmenté et reprenable : * - `POST /upload/init` crée une session, renvoie `{ id, chunkSize, chunkCount }` + * - `GET /upload/:id` état de la session (morceaux reçus) pour reprendre * - `PUT /upload/:id/:index` envoie un morceau (corps brut octet-stream) * - `POST /upload/:id/complete` assemble et finalise, renvoie 201 + métadonnées * - `DELETE /upload/:id` abandonne (supprime le fichier partiel) @@ -140,6 +146,18 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done) }, ); + // Permet au client de reprendre un upload en échec là où il s'est arrêté. + app.get('/upload/:id', { preHandler: requireSession }, (request): ResumeState => { + const { id } = request.params as { id: string }; + const session = app.uploadSessions.use(id); + return { + id: session.id, + chunkSize: session.chunkSize, + chunkCount: session.chunkCount, + received: [...session.received].sort((a, b) => a - b), + }; + }); + app.put( '/upload/:id/:index', { preHandler: requireSession }, diff --git a/tests/upload-chunked.test.ts b/tests/upload-chunked.test.ts index 77c3daf..fe893fe 100644 --- a/tests/upload-chunked.test.ts +++ b/tests/upload-chunked.test.ts @@ -207,6 +207,40 @@ describe('upload fragmenté', () => { expect(files[0]).toMatch(/\.mediadrop-part$/); }); + it('GET /upload/:id liste les morceaux reçus et permet de reprendre', async () => { + const data = randomBytes(MIB + 10); + const { body } = await init({ + size: data.length, + chunkSize: MIB, + target: 'tree', + path: 'a/clip.mp4', + }); + expect(body.chunkCount).toBe(2); + expect(await putChunk(body.id!, 1, data.subarray(MIB))).toBe(200); + + const state = await fetch(`${baseUrl}/upload/${body.id!}`, { headers: { cookie } }); + expect(state.status).toBe(200); + expect(await state.json()).toEqual({ + id: body.id, + chunkSize: MIB, + chunkCount: 2, + received: [1], + }); + + expect(await putChunk(body.id!, 0, data.subarray(0, MIB))).toBe(200); + const done = await complete(body.id!); + expect(done.status).toBe(201); + expect(await readFile(join(dir, 'a', 'clip.mp4'))).toEqual(data); + }, 20_000); + + it('GET /upload/:id : 404 si inconnue, 401 sans session', async () => { + const unknown = await fetch(`${baseUrl}/upload/inexistante`, { headers: { cookie } }); + expect(unknown.status).toBe(404); + const { body } = await init({ size: MIB, chunkSize: MIB }); + const anonymous = await fetch(`${baseUrl}/upload/${body.id!}`); + expect(anonymous.status).toBe(401); + }); + it('404 sur une session inconnue', async () => { expect(await putChunk('inexistante', 0, Buffer.alloc(10))).toBe(404); expect((await complete('inexistante')).status).toBe(404);