add chunked upload to bypass 100MB proxy body limit
Resumable /api/uploads flow (init -> append -> complete -> abort) so a large recording uploads as many small slices (<= ASR_CHUNK_SIZE_MB, default 50MB) instead of one request a size-capping proxy (Cloudflare's 100MB) would reject. The web console auto-routes files larger than one slice through the chunked path with cross-slice progress and transient-error retry; smaller files keep the single-shot upload. - store: in-memory upload sessions + .part reassembly (offset writes, out-of-order/idempotent), reuse upload id as task id, orphan sweep on start, idle-session GC - fix limitedReader rejecting bodies exactly at the cap (broke full chunks) - fix drag-drop double-firing when a drop lands on the dropzone (its drop handler now stopPropagation so the window listener doesn't re-upload) - tests: store + HTTP flow, plus a 120MB reassembly test (ASR_TEST_LARGE)
This commit is contained in:
+114
-17
@@ -311,6 +311,26 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="api-section">
|
||||
<h3>分片上传 <span class="tag">绕过代理体积上限(如 Cloudflare 100MB)</span></h3>
|
||||
<table class="api-tbl">
|
||||
<tr><th>方法</th><th>路径</th><th>说明</th></tr>
|
||||
<tr><td class="m post">POST</td><td><code>/api/uploads</code></td><td>开启上传会话(JSON:<code>{filename,size}</code>)→ <code>{upload_id, chunk_size, total_chunks}</code></td></tr>
|
||||
<tr><td class="m post">POST</td><td><code>/api/uploads/{id}?index=N</code></td><td>上传第 N 片(0 起,原始字节,≤ chunk_size)。乱序/重发幂等</td></tr>
|
||||
<tr><td class="m post">POST</td><td><code>/api/uploads/{id}/complete</code></td><td>收齐后合并并入队 → 202 + 任务(等同 <code>/api/tasks</code>)</td></tr>
|
||||
<tr><td class="m del">DELETE</td><td><code>/api/uploads/{id}</code></td><td>放弃会话并删除分片 → 204</td></tr>
|
||||
</table>
|
||||
<pre class="code"><span class="c"># 大文件分片上传(每片 50MB,默认)</span>
|
||||
curl -H "X-Api-Key: $PW" -H "Content-Type: application/json" \
|
||||
-d '{"filename":"big.mp4","size":123456789}' http://localhost:8080/api/uploads
|
||||
# → {"upload_id":"ab12cd34","chunk_size":52428800,"total_chunks":3}
|
||||
for i in 0 1 2; do
|
||||
curl -H "X-Api-Key: $PW" --data-binary @"part_$i.bin" \
|
||||
"http://localhost:8080/api/uploads/ab12cd34?index=$i"
|
||||
done
|
||||
curl -H "X-Api-Key: $PW" -X POST http://localhost:8080/api/uploads/ab12cd34/complete</pre>
|
||||
</div>
|
||||
|
||||
<div class="api-section">
|
||||
<h3>OpenAI 兼容 <span class="tag">同步</span></h3>
|
||||
<table class="api-tbl">
|
||||
@@ -368,7 +388,7 @@ const fmtTime = iso=>{ if(!iso) return "—"; const d=new Date(iso); return isNa
|
||||
const fmtDur = s=> s==null?"—": (s<60?s.toFixed(1)+"s" : Math.floor(s/60)+"m"+String(Math.round(s%60)).padStart(2,"0")+"s");
|
||||
const escapeHtml = s=>(s||"").replace(/[&<>]/g,c=>({"&":"&","<":"<",">":">"}[c]));
|
||||
|
||||
const state = { pw: localStorage.getItem("asr_pw")||"", tasks:[], sel:null, busy:false, authing:false, uploading:false, maxUpload:0 };
|
||||
const state = { pw: localStorage.getItem("asr_pw")||"", tasks:[], sel:null, busy:false, authing:false, uploading:false, maxUpload:0, chunkSize:0 };
|
||||
|
||||
// --- password modal (shown only when verification is needed) ---
|
||||
const authModal=$("#authModal"), authPw=$("#authPw"), authMsg=$("#authMsg"), authOk=$("#authOk"), authCancel=$("#authCancel");
|
||||
@@ -480,6 +500,7 @@ async function loadHealth(){
|
||||
try{
|
||||
const h = await api("/healthz");
|
||||
state.maxUpload = h.max_upload_bytes || 0;
|
||||
state.chunkSize = h.chunk_size || 0;
|
||||
$("#htext").textContent = h.models?"模型就绪":"模型加载中";
|
||||
$("#hdot").className = "dot "+(h.models?"ok":"down");
|
||||
}catch(e){ $("#htext").textContent="服务不可达"; $("#hdot").className="dot down"; }
|
||||
@@ -606,9 +627,12 @@ function renderDropzone(c){
|
||||
const dz=el("div",{class:"dropzone"});
|
||||
dz.innerHTML=`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 16V4M5 11l7-7 7 7M4 20h16"/></svg><h3>拖拽音频或视频到这里,或点击上传</h3><p>音频直接转写;视频会自动提取音轨。约 1~3 秒出结果。</p><div class="ext">音频 .wav/.mp3/.flac/.ogg/.m4a/.aac/.opus · 视频 .mp4/.mkv/.mov/.avi/.ts…</div>`;
|
||||
dz.onclick=()=>$("#file").click();
|
||||
dz.addEventListener("dragover",e=>{e.preventDefault();dz.classList.add("drag")});
|
||||
dz.addEventListener("dragover",e=>{e.preventDefault();e.stopPropagation();dz.classList.add("drag")});
|
||||
dz.addEventListener("dragleave",()=>dz.classList.remove("drag"));
|
||||
dz.addEventListener("drop",e=>{e.preventDefault();dz.classList.remove("drag"); if(e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);});
|
||||
// stopPropagation: the global window 'drop' listener also uploads, so
|
||||
// without this a drop landing on the dropzone would bubble up and upload
|
||||
// the same file twice. The dropzone owns drops on itself.
|
||||
dz.addEventListener("drop",e=>{e.preventDefault();e.stopPropagation();dz.classList.remove("drag"); if(e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);});
|
||||
w.appendChild(dz); c.appendChild(w);
|
||||
}
|
||||
|
||||
@@ -627,21 +651,12 @@ async function uploadFiles(files){
|
||||
item.querySelector(".upload-name").textContent=f.name;
|
||||
list.appendChild(item);
|
||||
const bar=item.querySelector(".upload-bar>div"), pct=item.querySelector(".upload-pct"), st=item.querySelector(".upload-status");
|
||||
const fd=new FormData(); fd.append("file",f);
|
||||
// Large files go through the chunked path so each request stays under
|
||||
// size-capping proxies' body limits (e.g. Cloudflare's 100MB). Files no
|
||||
// bigger than one chunk use the simple single-shot multipart upload.
|
||||
const useChunked = state.chunkSize && f.size>state.chunkSize;
|
||||
try{
|
||||
const t=await new Promise((res,rej)=>{
|
||||
const x=new XMLHttpRequest();
|
||||
x.open("POST","/api/tasks");
|
||||
x.setRequestHeader("X-Api-Key",state.pw);
|
||||
x.upload.onprogress=e=>{ if(e.lengthComputable){ const p=Math.round(e.loaded/e.total*100); bar.style.width=p+"%"; pct.textContent=p+"%"; } };
|
||||
x.onload=()=>{
|
||||
if(x.status>=200&&x.status<300){ try{res(JSON.parse(x.responseText));}catch(e){res(null);} }
|
||||
else if(x.status===401){ state.pw=""; localStorage.removeItem("asr_pw"); openAuthModal("密码不正确或服务已重启,请重新输入。",true); rej(new Error("unauthorized")); }
|
||||
else { let m="HTTP "+x.status; try{m=(JSON.parse(x.responseText).error)||m;}catch(e){} rej(new Error(m)); }
|
||||
};
|
||||
x.onerror=()=>rej(new Error("网络错误,上传失败"));
|
||||
x.send(fd);
|
||||
});
|
||||
const t = useChunked ? await uploadChunked(f,bar,pct,st) : await uploadSingle(f,bar,pct,st);
|
||||
bar.style.width="100%"; pct.textContent="100%";
|
||||
st.textContent="已入队,转写排队中…"; item.classList.add("done");
|
||||
toast("已加入队列: "+(t.original_name||t.id),"ok");
|
||||
@@ -651,6 +666,88 @@ async function uploadFiles(files){
|
||||
// Let the user see the finished state briefly, then refresh the queue.
|
||||
setTimeout(()=>{ state.uploading=false; loadTasks(); }, 600);
|
||||
}
|
||||
|
||||
// uploadSingle sends a whole file as one multipart request (POST /api/tasks)
|
||||
// with XHR so the progress bar reflects bytes sent.
|
||||
function uploadSingle(f,bar,pct,st){
|
||||
return new Promise((res,rej)=>{
|
||||
const fd=new FormData(); fd.append("file",f);
|
||||
const x=new XMLHttpRequest();
|
||||
x.open("POST","/api/tasks");
|
||||
x.setRequestHeader("X-Api-Key",state.pw);
|
||||
x.upload.onprogress=e=>{ if(e.lengthComputable){ const p=Math.round(e.loaded/e.total*100); bar.style.width=p+"%"; pct.textContent=p+"%"; } };
|
||||
x.onload=()=>{
|
||||
if(x.status>=200&&x.status<300){ try{res(JSON.parse(x.responseText));}catch(e){res(null);} }
|
||||
else if(x.status===401){ state.pw=""; localStorage.removeItem("asr_pw"); openAuthModal("密码不正确或服务已重启,请重新输入。",true); rej(new Error("unauthorized")); }
|
||||
else { let m="HTTP "+x.status; try{m=(JSON.parse(x.responseText).error)||m;}catch(e){} rej(new Error(m)); }
|
||||
};
|
||||
x.onerror=()=>rej(new Error("网络错误,上传失败"));
|
||||
x.send(fd);
|
||||
});
|
||||
}
|
||||
|
||||
// uploadChunked sends a file as ceil(size/chunkSize) slices to the resumable
|
||||
// upload API (POST /api/uploads -> /api/uploads/{id}?index=N -> /complete),
|
||||
// tracking overall progress across slices. A failing slice is retried a few
|
||||
// times on transient errors; on give-up the partial session is aborted.
|
||||
async function uploadChunked(f,bar,pct,st){
|
||||
let uid=null; // declared outside try so the catch can reference it safely
|
||||
try{
|
||||
const init = await api("/api/uploads", {
|
||||
method:"POST",
|
||||
headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({filename:f.name, size:f.size})
|
||||
});
|
||||
uid=init.upload_id;
|
||||
const cs=+init.chunk_size, total=+init.total_chunks;
|
||||
const setPct=bytes=>{ const p=Math.min(100,Math.round(bytes/f.size*100)); bar.style.width=p+"%"; pct.textContent=p+"%"; };
|
||||
let uploaded=0;
|
||||
for(let i=0;i<total;i++){
|
||||
const start=i*cs, end=Math.min(start+cs,f.size);
|
||||
const blob=f.slice(start,end);
|
||||
let done=false, lastErr;
|
||||
for(let attempt=0; attempt<3 && !done; attempt++){
|
||||
try{
|
||||
await new Promise((res,rej)=>{
|
||||
const x=new XMLHttpRequest();
|
||||
x.open("POST", `/api/uploads/${uid}?index=${i}`);
|
||||
x.setRequestHeader("X-Api-Key",state.pw);
|
||||
x.upload.onprogress=e=>{ if(e.lengthComputable) setPct(uploaded+e.loaded); };
|
||||
x.onload=()=>{
|
||||
if(x.status>=200&&x.status<300){ try{res(JSON.parse(x.responseText));}catch(e){res(null);} return; }
|
||||
const m=(()=>{ let s="HTTP "+x.status; try{s=(JSON.parse(x.responseText).error)||s;}catch(e){} return s; })();
|
||||
const e=new Error(m); e.status=x.status; rej(e);
|
||||
};
|
||||
x.onerror=()=>{ const e=new Error("网络错误"); e.status=0; rej(e); };
|
||||
x.send(blob);
|
||||
});
|
||||
done=true;
|
||||
}catch(e){
|
||||
lastErr=e;
|
||||
// Auth failure: surface immediately (the modal reopens); don't retry.
|
||||
if(e.status===401){ state.pw=""; localStorage.removeItem("asr_pw"); openAuthModal("密码不正确或服务已重启,请重新输入。",true); throw e; }
|
||||
// 4xx (bad request / session gone): retrying won't help; give up.
|
||||
if(e.status>=400 && e.status<500 && e.status!==0) throw e;
|
||||
// Transient (network/5xx): back off and retry unless last attempt.
|
||||
if(attempt<2){ await new Promise(r=>setTimeout(r, 800*(attempt+1))); }
|
||||
}
|
||||
}
|
||||
if(!done) throw lastErr || new Error("上传分片失败");
|
||||
uploaded += (end-start);
|
||||
setPct(uploaded);
|
||||
}
|
||||
const task = await api(`/api/uploads/${uid}/complete`, {method:"POST"});
|
||||
return task;
|
||||
}catch(e){
|
||||
// Best-effort cleanup of the partial session — but only if one was
|
||||
// actually created (init may have failed) and not on an auth failure
|
||||
// (the password is already cleared and a retry would just 401 again).
|
||||
if(uid && e.status!==401 && state.pw){
|
||||
try{ await fetch(`/api/uploads/${uid}`,{method:"DELETE",headers:{"X-Api-Key":state.pw}}); }catch(_){}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
$("#file").addEventListener("change",e=>{ if(e.target.files.length) uploadFiles(e.target.files); e.target.value=""; });
|
||||
$("#uploadBtn").addEventListener("click",()=>$("#file").click());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user