fix: download every selected book, not just the last
ci / backend (push) Failing after 3m3s
ci / frontend (push) Successful in 2m2s

The frontend built book_ids with `set` rather than `append`, so the archive
only ever held one book. The proxy also sent an empty Content-Length, which
is not valid HTTP/1.1, and buffered the whole zip before forwarding a byte
of it.

Entries now carry the file's own name, suffixed when two books claim the
same one, and the books are resolved before the stream starts so a miss is
a 404 instead of a truncated archive under a 200.
This commit is contained in:
2026-09-14 11:04:47 -04:00
parent 412a73cedf
commit ead43b2562
6 changed files with 230 additions and 58 deletions
@@ -134,8 +134,11 @@ export class BookOperationsState {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local temporary, not reactive state
const downloadUrl = new URL(`/api/books/download`, page.url.origin);
downloadUrl.searchParams.set('library_id', this.libraryId);
// `append`, not `set` — `set` replaces the existing value, so every book but
// the last was dropped from the query string and the archive came back holding
// one book however many were selected.
bookIds.forEach((id) => {
downloadUrl.searchParams.set('book_ids', id.toString());
downloadUrl.searchParams.append('book_ids', id.toString());
});
this.download(downloadUrl, filename || 'download.zip');
+19 -8
View File
@@ -16,16 +16,27 @@ async function handleResponse(response: Response) {
const data = await response.json();
return json(data);
} else {
// Handle file or other non-JSON response
const responseBody = await response.arrayBuffer();
// Only headers the backend actually sent are forwarded. Defaulting a missing
// one to '' put `Content-Length: ` on the wire — SvelteKit's `setResponse`
// hands every header to `res.setHeader` as-is, empty or not — and an empty
// Content-Length is not valid HTTP/1.1, so strict clients reject the whole
// response. The multi-book ZIP is streamed and carries no Content-Length,
// which made it the one download that always tripped over this.
const headers = new Headers({ 'Content-Type': contentType });
for (const name of ['Content-Disposition', 'Content-Length']) {
const value = response.headers.get(name);
if (value) headers.set(name, value);
}
return new Response(responseBody, {
// The body is passed through as a stream, matching what `bodyOf` does for
// requests. `arrayBuffer()` held the whole response here first, so nothing
// reached the browser until the last byte had been read and this process
// carried a copy of it — survivable for one book, not for a ZIP of a whole
// shelf, which only starts arriving once the backend has finished
// compressing all of it.
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': contentType,
'Content-Disposition': response.headers.get('Content-Disposition') || '',
'Content-Length': response.headers.get('Content-Length') || ''
}
headers
});
}
}