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);