feat(upload): resumable chunked upload protocol for large files — init(parts≤32MB)/status/complete endpoints with fingerprint-derived deterministic uploadId (same file resumes, no restart), sessions in BOOKS_DIR/.uploads with 24h sweep, frontend uploadBook auto-switches >16MB to 8MB parts (retry x3 in place, skip received); oversize single POST now reports honest 413 too_large instead of "multipart field 'file' required" (was MaxBytesReader abort mislabeled); UPLOAD_MAX_MB wired into both compose stacks (cap total, .env.example 2048), nginx body limit drops to 32m; testCfg limit 1MB, CJK/fullwidth-colon filename proven unaffected; e2e verified with 311MB real file (39 parts, sha1 match)
This commit is contained in:
@@ -113,6 +113,33 @@ export function formatPageUrl(fmt: string, n: number): string {
|
||||
return fmt.replace("%d", String(n));
|
||||
}
|
||||
|
||||
const SINGLE_MAX = 16 << 20;
|
||||
const CHUNK_SIZE = 8 << 20;
|
||||
const PART_RETRY = 3;
|
||||
|
||||
async function uploadChunked(id: number, file: File): Promise<{ accepted: boolean; path: string }> {
|
||||
const { uploadId } = await apiFetch<{ uploadId: string }>(`/libraries/${id}/upload/init`, {
|
||||
method: "POST",
|
||||
body: { name: file.name, size: file.size, chunkSize: CHUNK_SIZE },
|
||||
});
|
||||
const { received } = await apiFetch<{ received: number[] }>(`/uploads/${uploadId}`);
|
||||
const have = new Set(received);
|
||||
const n = Math.ceil(file.size / CHUNK_SIZE);
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (have.has(i)) continue;
|
||||
for (let tries = 0; ; tries++) {
|
||||
const res = await fetch(full(`/uploads/${uploadId}/parts/${i}`), {
|
||||
method: "PUT",
|
||||
headers: { ...authHeaders(), "Content-Type": "application/octet-stream" },
|
||||
body: file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size)),
|
||||
});
|
||||
if (res.ok) break;
|
||||
if (tries >= PART_RETRY) throw await toHttpError(res);
|
||||
}
|
||||
}
|
||||
return apiFetch<{ accepted: boolean; path: string }>(`/uploads/${uploadId}/complete`, { method: "POST" });
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) =>
|
||||
apiFetch<{ token: string }>("/auth/login", { method: "POST", body: { username, password } }),
|
||||
@@ -126,14 +153,15 @@ export const api = {
|
||||
listLibraries: () => apiFetch<Library[]>("/libraries"),
|
||||
createLibrary: (name: string) => apiFetch<Library>("/libraries", { method: "POST", body: { name } }),
|
||||
scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }),
|
||||
uploadBook: (id: number, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
return apiFetch<{ accepted: boolean; path: string }>(`/libraries/${id}/upload`, {
|
||||
method: "POST",
|
||||
form: fd,
|
||||
});
|
||||
},
|
||||
// ≤16MB 单 POST;更大走分片协议(init→parts→complete),失败分片原地重试,已传分片跳过
|
||||
uploadBook: (id: number, file: File): Promise<{ accepted: boolean; path: string }> =>
|
||||
file.size <= SINGLE_MAX
|
||||
? (() => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
return apiFetch<{ accepted: boolean; path: string }>(`/libraries/${id}/upload`, { method: "POST", form: fd });
|
||||
})()
|
||||
: uploadChunked(id, file),
|
||||
|
||||
listBooks: (params: { library?: number; q?: string } = {}) => {
|
||||
const sp = new URLSearchParams();
|
||||
|
||||
Reference in New Issue
Block a user