Remplace les 3 workers par fichier par un pool global de morceaux en vol, réparti équitablement entre les fichiers fragmentés actifs : 1 fichier utilise tout le pool, N fichiers en reçoivent une part chacun (ceil(taille / N)), la capacité libérée par un fichier terminé est redistribuée. Un fichier en cours d'init compte dans la part pour ne pas être affamé ; les morceaux déjà en vol ne sont jamais interrompus. La taille du pool est réglable via `chunkPoolSize` (config.json, défaut 30, borné à 256) et transmise au navigateur par un nouveau `GET /config`. Corps des morceaux repassé en streaming (writeChunkAt sur un flux + garde `maxBytes`) : mémoire ~constante même à pool élevé, plus de bufferisation de 16 Mo par requête. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
879 lines
28 KiB
HTML
879 lines
28 KiB
HTML
<!doctype html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>mediaDrop</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light dark;
|
||
--bg: #f6f7f9;
|
||
--card: #ffffff;
|
||
--border: #d7dbe0;
|
||
--text: #1c1f23;
|
||
--muted: #6b7280;
|
||
--accent: #2563eb;
|
||
--ok: #15803d;
|
||
--err: #b91c1c;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #14171a;
|
||
--card: #1d2125;
|
||
--border: #33393f;
|
||
--text: #e8eaed;
|
||
--muted: #9aa3ad;
|
||
--accent: #60a5fa;
|
||
--ok: #4ade80;
|
||
--err: #f87171;
|
||
}
|
||
}
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
body {
|
||
margin: 0;
|
||
padding: 2rem 1rem;
|
||
font:
|
||
15px/1.5 system-ui,
|
||
-apple-system,
|
||
'Segoe UI',
|
||
Roboto,
|
||
sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
}
|
||
main {
|
||
max-width: 640px;
|
||
margin: 0 auto;
|
||
display: grid;
|
||
gap: 1.25rem;
|
||
}
|
||
h1 {
|
||
margin: 0;
|
||
font-size: 1.4rem;
|
||
}
|
||
p.sub {
|
||
margin: 0.25rem 0 0;
|
||
color: var(--muted);
|
||
}
|
||
.card {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 1rem;
|
||
}
|
||
.authrow {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.btn {
|
||
display: inline-block;
|
||
font: inherit;
|
||
padding: 0.5rem 0.9rem;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--accent);
|
||
background: var(--accent);
|
||
color: #fff;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
.btn.secondary {
|
||
background: transparent;
|
||
color: var(--accent);
|
||
}
|
||
form.inline {
|
||
margin: 0;
|
||
}
|
||
label {
|
||
display: block;
|
||
font-weight: 600;
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
select {
|
||
width: 100%;
|
||
padding: 0.5rem 0.6rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font: inherit;
|
||
}
|
||
#dropzone {
|
||
border: 2px dashed var(--border);
|
||
border-radius: 10px;
|
||
padding: 2.5rem 1rem;
|
||
text-align: center;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 0.15s,
|
||
background 0.15s,
|
||
opacity 0.15s;
|
||
}
|
||
#dropzone.hover {
|
||
border-color: var(--accent);
|
||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||
color: var(--text);
|
||
}
|
||
#dropzone.disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
#list {
|
||
display: grid;
|
||
gap: 0.6rem;
|
||
}
|
||
.item {
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 0.6rem 0.75rem;
|
||
display: grid;
|
||
gap: 0.4rem;
|
||
}
|
||
.item .row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
font-size: 0.9rem;
|
||
}
|
||
.item .name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.item .detail {
|
||
color: var(--muted);
|
||
white-space: nowrap;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.cancel {
|
||
flex: none;
|
||
font: inherit;
|
||
line-height: 1;
|
||
padding: 0.15rem 0.4rem;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
background: transparent;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
}
|
||
.cancel:hover {
|
||
color: var(--err);
|
||
border-color: var(--err);
|
||
}
|
||
#queueRow {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
}
|
||
.bar {
|
||
height: 6px;
|
||
border-radius: 999px;
|
||
background: var(--border);
|
||
overflow: hidden;
|
||
}
|
||
.bar > span {
|
||
display: block;
|
||
height: 100%;
|
||
width: 0;
|
||
background: var(--accent);
|
||
transition: width 0.15s;
|
||
}
|
||
.status.ok {
|
||
color: var(--ok);
|
||
}
|
||
.status.err {
|
||
color: var(--err);
|
||
}
|
||
.muted {
|
||
color: var(--muted);
|
||
}
|
||
#banner {
|
||
color: var(--err);
|
||
font-size: 0.9rem;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<header>
|
||
<h1>mediaDrop</h1>
|
||
<p class="sub">Connectez-vous, puis glissez des fichiers médias pour les déposer.</p>
|
||
</header>
|
||
|
||
<div class="card">
|
||
<div class="authrow">
|
||
<span id="authState" class="muted">Chargement…</span>
|
||
<span>
|
||
<a id="loginLink" class="btn" href="/auth/login" hidden>Se connecter</a>
|
||
<form id="logoutForm" class="inline" method="post" action="/auth/logout" hidden>
|
||
<button class="btn secondary" type="submit">Se déconnecter</button>
|
||
</form>
|
||
</span>
|
||
</div>
|
||
<div id="banner" hidden></div>
|
||
</div>
|
||
|
||
<div id="targetCard" class="card" hidden>
|
||
<label for="targetSelect">Dossier de dépôt</label>
|
||
<select id="targetSelect"></select>
|
||
</div>
|
||
|
||
<div id="dropzone" class="card disabled">
|
||
<strong>Déposez fichiers ou dossiers ici</strong><br />
|
||
<span class="muted">ou cliquez pour parcourir</span>
|
||
<input id="picker" type="file" multiple hidden />
|
||
</div>
|
||
|
||
<div id="dirRow" class="card" hidden>
|
||
<button id="dirBtn" type="button" class="btn secondary">Parcourir un dossier…</button>
|
||
<span class="muted"> — ou glissez un dossier dans la zone ci-dessus</span>
|
||
<input id="dirPicker" type="file" webkitdirectory multiple hidden />
|
||
</div>
|
||
|
||
<div id="queueRow" class="card" hidden>
|
||
<span id="queueInfo" class="muted"></span>
|
||
<button id="queueCancel" type="button" class="btn secondary">Tout annuler</button>
|
||
</div>
|
||
|
||
<div id="list"></div>
|
||
</main>
|
||
|
||
<script>
|
||
const authState = document.getElementById('authState');
|
||
const banner = document.getElementById('banner');
|
||
const loginLink = document.getElementById('loginLink');
|
||
const logoutForm = document.getElementById('logoutForm');
|
||
const targetCard = document.getElementById('targetCard');
|
||
const targetSelect = document.getElementById('targetSelect');
|
||
const dropzone = document.getElementById('dropzone');
|
||
const picker = document.getElementById('picker');
|
||
const dirRow = document.getElementById('dirRow');
|
||
const dirBtn = document.getElementById('dirBtn');
|
||
const dirPicker = document.getElementById('dirPicker');
|
||
const queueRow = document.getElementById('queueRow');
|
||
const queueInfo = document.getElementById('queueInfo');
|
||
const queueCancel = document.getElementById('queueCancel');
|
||
const list = document.getElementById('list');
|
||
|
||
const MAX_PARALLEL = 3; // dépôts directs (petits fichiers) simultanés
|
||
const MAX_CHUNKED_JOBS = 32; // fichiers fragmentés actifs à la fois (< 100 côté serveur)
|
||
const CHUNK_THRESHOLD = 32 * 1024 * 1024; // au-delà : upload fragmenté et reprenable
|
||
let CHUNK_POOL = 30; // budget global de morceaux en vol ; remplacé par GET /config
|
||
const CHUNK_RETRIES = 3;
|
||
|
||
let authenticated = false;
|
||
let currentTarget = null;
|
||
let currentTargetAllowsDirs = false;
|
||
|
||
function showBanner(message) {
|
||
banner.textContent = message;
|
||
banner.hidden = false;
|
||
}
|
||
|
||
const loginParam = new URLSearchParams(location.search).get('login');
|
||
if (loginParam === 'error') showBanner('La connexion a échoué. Réessayez.');
|
||
else if (loginParam === 'expired') showBanner('La session de connexion a expiré. Réessayez.');
|
||
if (loginParam) history.replaceState({}, '', location.pathname);
|
||
|
||
function render(me) {
|
||
authenticated = Boolean(me && me.authenticated);
|
||
loginLink.hidden = authenticated;
|
||
logoutForm.hidden = !authenticated;
|
||
dropzone.classList.toggle('disabled', !authenticated);
|
||
authState.textContent = authenticated ? 'Connecté : ' + me.name : 'Non connecté';
|
||
if (authenticated) {
|
||
loadTargets();
|
||
} else {
|
||
targetCard.hidden = true;
|
||
dirRow.hidden = true;
|
||
}
|
||
}
|
||
|
||
let targets = [];
|
||
|
||
function applyTargetSelection() {
|
||
const target = targets.find((t) => t.name === targetSelect.value) || targets[0] || null;
|
||
currentTarget = target ? target.name : null;
|
||
currentTargetAllowsDirs = Boolean(target && target.allowDirectories);
|
||
dirRow.hidden = !authenticated || !currentTargetAllowsDirs;
|
||
}
|
||
|
||
function loadTargets() {
|
||
fetch('/targets', { headers: { accept: 'application/json' } })
|
||
.then((r) => (r.ok ? r.json() : []))
|
||
.then((data) => {
|
||
targets = Array.isArray(data) ? data : [];
|
||
targetSelect.replaceChildren();
|
||
targets.forEach((target) => {
|
||
const option = document.createElement('option');
|
||
option.value = target.name;
|
||
option.textContent = target.label;
|
||
targetSelect.appendChild(option);
|
||
});
|
||
targetCard.hidden = targets.length < 2;
|
||
applyTargetSelection();
|
||
})
|
||
.catch(() => {
|
||
targets = [];
|
||
currentTarget = null;
|
||
currentTargetAllowsDirs = false;
|
||
targetCard.hidden = true;
|
||
dirRow.hidden = true;
|
||
});
|
||
}
|
||
|
||
targetSelect.addEventListener('change', applyTargetSelection);
|
||
|
||
fetch('/auth/me', { headers: { accept: 'application/json' } })
|
||
.then((r) => r.json())
|
||
.then(render)
|
||
.catch(() => render({ authenticated: false }));
|
||
|
||
function formatSize(bytes) {
|
||
const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
|
||
let value = bytes;
|
||
let unit = 0;
|
||
while (value >= 1024 && unit < units.length - 1) {
|
||
value /= 1024;
|
||
unit += 1;
|
||
}
|
||
return value.toFixed(value < 10 && unit > 0 ? 1 : 0) + ' ' + units[unit];
|
||
}
|
||
|
||
function formatEta(seconds) {
|
||
if (!isFinite(seconds) || seconds < 0) return '';
|
||
if (seconds < 90) return '~' + Math.round(seconds) + ' s';
|
||
if (seconds < 5400) return '~' + Math.round(seconds / 60) + ' min';
|
||
return '~' + (seconds / 3600).toFixed(1) + ' h';
|
||
}
|
||
|
||
function addItem(file, label) {
|
||
const item = document.createElement('div');
|
||
item.className = 'item';
|
||
item.innerHTML =
|
||
'<div class="row">' +
|
||
'<span class="name"></span>' +
|
||
'<span class="detail"><span class="total"></span> ' +
|
||
'<button type="button" class="cancel" hidden>Annuler</button></span>' +
|
||
'</div>' +
|
||
'<div class="bar"><span></span></div>' +
|
||
'<div class="row"><span class="status muted">En attente…</span>' +
|
||
'<span class="detail progress"></span></div>';
|
||
item.querySelector('.name').textContent = label || file.name;
|
||
item.querySelector('.total').textContent = formatSize(file.size);
|
||
list.prepend(item);
|
||
return {
|
||
bar: item.querySelector('.bar > span'),
|
||
status: item.querySelector('.status'),
|
||
progress: item.querySelector('.progress'),
|
||
cancelBtn: item.querySelector('.cancel'),
|
||
};
|
||
}
|
||
|
||
function setStatus(ui, text, kind) {
|
||
ui.status.textContent = text;
|
||
ui.status.className = 'status ' + (kind || 'muted');
|
||
}
|
||
|
||
const queue = [];
|
||
const active = new Set();
|
||
|
||
function updateQueueRow() {
|
||
const running = active.size;
|
||
const waiting = queue.length;
|
||
if (running + waiting === 0) {
|
||
queueRow.hidden = true;
|
||
return;
|
||
}
|
||
queueRow.hidden = false;
|
||
queueInfo.textContent =
|
||
running + ' en cours' + (waiting ? ' · ' + waiting + ' en attente' : '');
|
||
}
|
||
|
||
function pump() {
|
||
let simple = 0;
|
||
let chunked = 0;
|
||
for (const j of active) {
|
||
if (j.chunked) chunked += 1;
|
||
else simple += 1;
|
||
}
|
||
const kept = [];
|
||
for (const job of queue) {
|
||
if (job.chunked ? chunked < MAX_CHUNKED_JOBS : simple < MAX_PARALLEL) {
|
||
if (job.chunked) chunked += 1;
|
||
else simple += 1;
|
||
startUpload(job);
|
||
} else {
|
||
kept.push(job);
|
||
}
|
||
}
|
||
queue.length = 0;
|
||
for (const job of kept) queue.push(job);
|
||
updateQueueRow();
|
||
}
|
||
|
||
function enqueue(file, relativePath) {
|
||
const ui = addItem(file, relativePath);
|
||
const job = { file, relativePath, ui, chunked: file.size > CHUNK_THRESHOLD };
|
||
queue.push(job);
|
||
ui.cancelBtn.hidden = false;
|
||
ui.cancelBtn.onclick = () => {
|
||
const i = queue.indexOf(job);
|
||
if (i < 0) return;
|
||
queue.splice(i, 1);
|
||
ui.cancelBtn.hidden = true;
|
||
setStatus(ui, 'Annulé', 'err');
|
||
updateQueueRow();
|
||
};
|
||
updateQueueRow();
|
||
}
|
||
|
||
// Pool global de morceaux en vol, réparti équitablement entre les fichiers
|
||
// fragmentés actifs. Chaque fichier est plafonné à sa part équitable
|
||
// (ceil(CHUNK_POOL / nb de fichiers actifs)) : 1 fichier => tout le pool,
|
||
// N fichiers => une part chacun. Un fichier encore en cours d'initialisation
|
||
// (`priming`) compte dans la part pour lui réserver un slot ; à égalité de
|
||
// morceaux en vol, le fichier le moins servi passe en premier.
|
||
const chunkPool = {
|
||
free: CHUNK_POOL,
|
||
entries: [],
|
||
add(entry) {
|
||
this.entries.push(entry);
|
||
this.tick();
|
||
},
|
||
remove(entry) {
|
||
const i = this.entries.indexOf(entry);
|
||
if (i >= 0) this.entries.splice(i, 1);
|
||
this.tick();
|
||
},
|
||
tick() {
|
||
while (this.free > 0) {
|
||
const contenders = this.entries.filter(
|
||
(e) => !e.stopped && (e.pending.length > 0 || e.priming),
|
||
);
|
||
if (contenders.length === 0) return;
|
||
const share = Math.max(1, Math.ceil(CHUNK_POOL / contenders.length));
|
||
let best = null;
|
||
for (const e of this.entries) {
|
||
if (e.stopped || e.pending.length === 0 || e.inflight >= share) continue;
|
||
if (!best || e.inflight < best.inflight) best = e;
|
||
}
|
||
if (!best) return;
|
||
const target = best;
|
||
const index = target.pending.shift();
|
||
target.inflight += 1;
|
||
this.free -= 1;
|
||
target.send(index).finally(() => {
|
||
target.inflight -= 1;
|
||
this.free += 1;
|
||
this.tick();
|
||
});
|
||
}
|
||
},
|
||
};
|
||
|
||
// Taille du pool réglée côté serveur (config.json -> GET /config). Ajuste
|
||
// `free` du même delta : sûr même si des morceaux sont déjà en vol.
|
||
fetch('/config', { headers: { accept: 'application/json' } })
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((cfg) => {
|
||
const n = cfg && Number(cfg.chunkPoolSize);
|
||
if (Number.isInteger(n) && n > 0 && n !== CHUNK_POOL) {
|
||
chunkPool.free += n - CHUNK_POOL;
|
||
CHUNK_POOL = n;
|
||
chunkPool.tick();
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
|
||
function makeReporter(ui, total) {
|
||
let started = 0;
|
||
let lastAt = 0;
|
||
let lastLoaded = 0;
|
||
let speed = 0;
|
||
return function report(loaded) {
|
||
const now = performance.now();
|
||
if (!started) {
|
||
started = now;
|
||
lastAt = now;
|
||
}
|
||
const dt = (now - lastAt) / 1000;
|
||
if (dt >= 0.25) {
|
||
const inst = (loaded - lastLoaded) / dt;
|
||
speed = speed ? speed * 0.7 + inst * 0.3 : inst;
|
||
lastAt = now;
|
||
lastLoaded = loaded;
|
||
}
|
||
const pct = total ? Math.floor((loaded / total) * 100) : 0;
|
||
ui.bar.style.width = pct + '%';
|
||
const eta = speed > 0 && total ? formatEta((total - loaded) / speed) : '';
|
||
ui.progress.textContent =
|
||
formatSize(loaded) +
|
||
' / ' +
|
||
formatSize(total) +
|
||
' · ' +
|
||
pct +
|
||
' %' +
|
||
(speed > 0 ? ' · ' + formatSize(speed) + '/s' : '') +
|
||
(eta ? ' · ' + eta : '');
|
||
};
|
||
}
|
||
|
||
function uploadQuery(relativePath) {
|
||
const p = new URLSearchParams();
|
||
if (currentTarget) p.set('target', currentTarget);
|
||
if (relativePath) p.set('path', relativePath);
|
||
const s = p.toString();
|
||
return s ? '?' + s : '';
|
||
}
|
||
|
||
function finish(job) {
|
||
active.delete(job);
|
||
job.ui.cancelBtn.hidden = true;
|
||
pump();
|
||
}
|
||
|
||
function applyResult(job, status, body) {
|
||
const ui = job.ui;
|
||
if (status >= 200 && status < 300) {
|
||
ui.bar.style.width = '100%';
|
||
ui.progress.textContent = formatSize(job.file.size);
|
||
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
|
||
} else if (status === 401) {
|
||
render({ authenticated: false });
|
||
showBanner('Session expirée — reconnectez-vous.');
|
||
setStatus(ui, 'Non authentifié', 'err');
|
||
} else {
|
||
setStatus(ui, 'Erreur ' + status + ' : ' + (body.message || 'échec'), 'err');
|
||
}
|
||
}
|
||
|
||
function startUpload(job) {
|
||
active.add(job);
|
||
job.ui.cancelBtn.hidden = false;
|
||
job.ui.cancelBtn.onclick = () => job.cancel && job.cancel();
|
||
setStatus(job.ui, 'Envoi…', 'muted');
|
||
if (job.file.size > CHUNK_THRESHOLD) runChunked(job);
|
||
else runSimple(job);
|
||
}
|
||
|
||
function runSimple(job) {
|
||
const { file, relativePath, ui } = job;
|
||
const report = makeReporter(ui, file.size);
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
const xhr = new XMLHttpRequest();
|
||
job.cancel = () => xhr.abort();
|
||
xhr.open('POST', '/upload' + uploadQuery(relativePath));
|
||
xhr.upload.addEventListener('progress', (e) => {
|
||
if (e.lengthComputable) report(e.loaded);
|
||
});
|
||
xhr.addEventListener('load', () => {
|
||
finish(job);
|
||
let body = {};
|
||
try {
|
||
body = JSON.parse(xhr.responseText);
|
||
} catch {
|
||
body = {};
|
||
}
|
||
applyResult(job, xhr.status, body);
|
||
});
|
||
xhr.addEventListener('error', () => {
|
||
finish(job);
|
||
setStatus(ui, 'Erreur réseau', 'err');
|
||
});
|
||
xhr.addEventListener('abort', () => {
|
||
finish(job);
|
||
ui.progress.textContent = '';
|
||
setStatus(ui, 'Annulé', 'err');
|
||
});
|
||
xhr.send(form);
|
||
}
|
||
|
||
async function runChunked(job) {
|
||
const { file, relativePath, ui } = job;
|
||
const report = makeReporter(ui, file.size);
|
||
const inflight = new Set(); // XHR en vol (pour l'annulation)
|
||
let aborted = false;
|
||
let sessionId = null;
|
||
|
||
let resolveAll = null;
|
||
const finished = new Promise((r) => {
|
||
resolveAll = r;
|
||
});
|
||
let settled = false;
|
||
const finalize = (outcome) => {
|
||
if (!settled) {
|
||
settled = true;
|
||
resolveAll(outcome);
|
||
}
|
||
};
|
||
|
||
const dropSession = () => {
|
||
if (sessionId) fetch('/upload/' + sessionId, { method: 'DELETE' }).catch(() => {});
|
||
};
|
||
|
||
// Inscrit dans le pool dès maintenant, avant l'init réseau : le
|
||
// planificateur compte ce fichier dans la part équitable (`priming`) et
|
||
// lui réserve un slot même si son init n'a pas encore répondu.
|
||
const entry = { inflight: 0, stopped: false, priming: true, pending: [], send: null };
|
||
chunkPool.add(entry);
|
||
|
||
job.cancel = () => {
|
||
aborted = true;
|
||
entry.stopped = true;
|
||
entry.pending.length = 0;
|
||
for (const x of inflight) x.abort();
|
||
chunkPool.remove(entry);
|
||
dropSession();
|
||
finish(job);
|
||
ui.progress.textContent = '';
|
||
setStatus(ui, 'Annulé', 'err');
|
||
finalize('abort');
|
||
};
|
||
|
||
let meta;
|
||
try {
|
||
const res = await fetch('/upload/init' + uploadQuery(relativePath), {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: file.name,
|
||
size: file.size,
|
||
type: file.type || 'application/octet-stream',
|
||
}),
|
||
});
|
||
meta = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
entry.stopped = true;
|
||
chunkPool.remove(entry);
|
||
finish(job);
|
||
applyResult(job, res.status, meta);
|
||
return;
|
||
}
|
||
} catch {
|
||
entry.stopped = true;
|
||
chunkPool.remove(entry);
|
||
finish(job);
|
||
setStatus(ui, 'Erreur réseau (init)', 'err');
|
||
return;
|
||
}
|
||
if (aborted) return;
|
||
sessionId = meta.id;
|
||
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;
|
||
|
||
function retry(index, status, releasePermit) {
|
||
if (aborted || entry.stopped) {
|
||
releasePermit();
|
||
return;
|
||
}
|
||
attempts[index] += 1;
|
||
if (attempts[index] > CHUNK_RETRIES) {
|
||
entry.stopped = true;
|
||
entry.pending.length = 0;
|
||
setStatus(ui, 'Erreur morceau ' + index + ' (' + (status || 'réseau') + ')', 'err');
|
||
finalize('fail');
|
||
releasePermit();
|
||
return;
|
||
}
|
||
setTimeout(() => {
|
||
if (!aborted && !entry.stopped) {
|
||
entry.pending.unshift(index);
|
||
chunkPool.tick();
|
||
}
|
||
}, 400 * attempts[index]);
|
||
releasePermit(); // libère le permis ; le morceau est re-planifié après le backoff
|
||
}
|
||
|
||
entry.pending = Array.from({ length: chunkCount }, (_, i) => i);
|
||
entry.priming = false;
|
||
entry.send = (index) =>
|
||
new Promise((releasePermit) => {
|
||
const start = index * chunkSize;
|
||
const end = Math.min(start + chunkSize, file.size);
|
||
const xhr = new XMLHttpRequest();
|
||
inflight.add(xhr);
|
||
xhr.open('PUT', '/upload/' + sessionId + '/' + index);
|
||
xhr.setRequestHeader('content-type', 'application/octet-stream');
|
||
xhr.upload.addEventListener('progress', (e) => {
|
||
if (e.lengthComputable) {
|
||
loadedPer[index] = e.loaded;
|
||
emit();
|
||
}
|
||
});
|
||
xhr.addEventListener('load', () => {
|
||
inflight.delete(xhr);
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
loadedPer[index] = end - start;
|
||
emit();
|
||
doneCount += 1;
|
||
if (doneCount === chunkCount) finalize('ok');
|
||
releasePermit();
|
||
} else if (xhr.status === 401) {
|
||
entry.stopped = true;
|
||
entry.pending.length = 0;
|
||
applyResult(job, 401, {});
|
||
finalize('fail');
|
||
releasePermit();
|
||
} else {
|
||
retry(index, xhr.status, releasePermit);
|
||
}
|
||
});
|
||
xhr.addEventListener('error', () => {
|
||
inflight.delete(xhr);
|
||
retry(index, 0, releasePermit);
|
||
});
|
||
xhr.addEventListener('abort', () => {
|
||
inflight.delete(xhr);
|
||
releasePermit();
|
||
});
|
||
xhr.send(file.slice(start, end));
|
||
});
|
||
|
||
chunkPool.tick();
|
||
const outcome = await finished;
|
||
chunkPool.remove(entry);
|
||
|
||
if (outcome === 'abort') return;
|
||
if (outcome === 'fail') {
|
||
for (const x of inflight) x.abort();
|
||
finish(job);
|
||
dropSession();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/upload/' + sessionId + '/complete', { method: 'POST' });
|
||
const body = await res.json().catch(() => ({}));
|
||
finish(job);
|
||
applyResult(job, res.status, body);
|
||
} catch {
|
||
finish(job);
|
||
setStatus(ui, 'Erreur réseau (finalisation)', 'err');
|
||
}
|
||
}
|
||
|
||
function cancelAll() {
|
||
while (queue.length) {
|
||
const job = queue.shift();
|
||
job.ui.cancelBtn.hidden = true;
|
||
setStatus(job.ui, 'Annulé', 'err');
|
||
}
|
||
for (const job of Array.from(active)) if (job.cancel) job.cancel();
|
||
updateQueueRow();
|
||
}
|
||
|
||
queueCancel.addEventListener('click', cancelAll);
|
||
|
||
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
|
||
function handleBatch(items) {
|
||
if (!authenticated) {
|
||
showBanner('Connectez-vous avant de déposer des fichiers.');
|
||
return;
|
||
}
|
||
let batch = items;
|
||
if (!currentTargetAllowsDirs && batch.some((entry) => entry.path)) {
|
||
showBanner('La cible sélectionnée n’accepte pas le dépôt de dossiers.');
|
||
batch = batch.filter((entry) => !entry.path);
|
||
} else {
|
||
banner.hidden = true;
|
||
}
|
||
for (const entry of batch) enqueue(entry.file, entry.path);
|
||
pump();
|
||
}
|
||
|
||
function fromFileList(fileList, useRelativePath) {
|
||
return Array.from(fileList, (file) => ({
|
||
file,
|
||
path: useRelativePath ? file.webkitRelativePath || '' : '',
|
||
}));
|
||
}
|
||
|
||
function readAllEntries(reader) {
|
||
return new Promise((resolve, reject) => {
|
||
const acc = [];
|
||
const step = () =>
|
||
reader.readEntries((batch) => {
|
||
if (batch.length === 0) resolve(acc);
|
||
else {
|
||
acc.push(...batch);
|
||
step();
|
||
}
|
||
}, reject);
|
||
step();
|
||
});
|
||
}
|
||
|
||
async function walkEntry(entry, prefix, out) {
|
||
if (entry.isFile) {
|
||
const file = await new Promise((res, rej) => entry.file(res, rej));
|
||
out.push({ file, path: prefix ? prefix + entry.name : '' });
|
||
} else if (entry.isDirectory) {
|
||
const children = await readAllEntries(entry.createReader());
|
||
for (const child of children) await walkEntry(child, prefix + entry.name + '/', out);
|
||
}
|
||
}
|
||
|
||
async function collectDropItems(dataTransfer) {
|
||
const entries = [];
|
||
for (const item of Array.from(dataTransfer.items || [])) {
|
||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||
if (entry) entries.push(entry);
|
||
}
|
||
if (entries.length === 0) {
|
||
return fromFileList(dataTransfer.files || [], false);
|
||
}
|
||
const out = [];
|
||
for (const entry of entries) await walkEntry(entry, '', out);
|
||
return out;
|
||
}
|
||
|
||
dropzone.addEventListener('click', () => {
|
||
if (authenticated) picker.click();
|
||
});
|
||
picker.addEventListener('change', () => {
|
||
handleBatch(fromFileList(picker.files, false));
|
||
picker.value = '';
|
||
});
|
||
dirBtn.addEventListener('click', () => dirPicker.click());
|
||
dirPicker.addEventListener('change', () => {
|
||
handleBatch(fromFileList(dirPicker.files, true));
|
||
dirPicker.value = '';
|
||
});
|
||
|
||
['dragenter', 'dragover'].forEach((type) => {
|
||
dropzone.addEventListener(type, (event) => {
|
||
event.preventDefault();
|
||
if (authenticated) dropzone.classList.add('hover');
|
||
});
|
||
});
|
||
['dragleave', 'drop'].forEach((type) => {
|
||
dropzone.addEventListener(type, (event) => {
|
||
event.preventDefault();
|
||
if (type === 'dragleave' && dropzone.contains(event.relatedTarget)) return;
|
||
dropzone.classList.remove('hover');
|
||
});
|
||
});
|
||
dropzone.addEventListener('drop', (event) => {
|
||
if (!authenticated || !event.dataTransfer) return;
|
||
collectDropItems(event.dataTransfer)
|
||
.then((items) => {
|
||
if (items.length) handleBatch(items);
|
||
})
|
||
.catch(() => showBanner('Impossible de lire le contenu déposé.'));
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|