Compare commits
2 commits
1cb0da05e2
...
4637d1e0b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 4637d1e0b7 | |||
| f637ad4eeb |
3 changed files with 93 additions and 6 deletions
|
|
@ -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)');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue