feat: import a Calibre library

Reads metadata.db and copies the books into a library — from a zip uploaded on
the library settings page, or from a path with `litestar calibre-import`. The
source is never touched, and re-running only picks up what is new.

Also names the formats mimetypes does not know: a Calibre library is full of
MOBI and AZW3, and a null content type used to fail the book endpoint.
This commit is contained in:
2026-08-17 13:38:44 -04:00
parent 85367daf7e
commit 55e00ba960
34 changed files with 4723 additions and 50 deletions
+5 -2
View File
@@ -167,8 +167,11 @@ Observed in the current tree — don't mistake these for intentional patterns to
`svelte/no-navigation-without-resolve` on plain `href`s, plus ~59 files Prettier would rewrite
(mostly vendored shadcn components). Check the files you touched, not the whole tree.
- `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
import of that type is commented out at line 4. It also buffers whole responses with
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed.
import of that type is commented out at line 4. It also buffers whole **responses** with
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed. **Requests**
are streamed — POST and PATCH pass `request.body` through with `duplex: 'half'` (see `bodyOf`),
because a zipped Calibre library upload cannot be held in this process. The response side is
still buffered; see `TODO.md`.
- `src/app.d.ts``App.Locals["user"]` is typed from `lucide-svelte`'s `User` _icon_ component
rather than the `User` interface in `$lib/server/auth`.
- No CSP, which foliate's README asks for because EPUBs can carry scripts. See `TODO.md` for why it
@@ -0,0 +1,51 @@
import { command, getRequestEvent, query } from '$app/server';
import { error } from '@sveltejs/kit';
import { stringCoerce } from '$lib/schema/common';
import type { CalibreImport } from '$lib/schema/library';
/** The backend's own message for a failed response, rather than its JSON envelope. */
function detailOf(body: string): string {
try {
const parsed = JSON.parse(body);
return typeof parsed?.detail === 'string' ? parsed.detail : body;
} catch {
return body;
}
}
/**
* Where an import has got to.
*
* A `query` rather than a `command` so it can be refreshed, but it is polled on a timer
* rather than cached — the answer changes on its own.
*
* Starting an import is deliberately **not** here: the archive goes straight to the
* backend through the proxy, so it never passes through this process. See the import
* screen's `upload`.
*/
export const getCalibreImport = query(stringCoerce, async (jobId): Promise<CalibreImport> => {
const { locals } = getRequestEvent();
const response = await locals.api.get(`/libraries/imports/${jobId}`);
if (!response.ok) error(response.status, detailOf(await response.text()));
return await response.json();
});
/**
* Ask an import to stop after the book it is on.
*
* Not an abort: a book abandoned mid-copy would leave files on disk with no row
* describing them. Whatever it has imported stays imported.
*/
export const cancelCalibreImport = command(stringCoerce, async (jobId): Promise<CalibreImport> => {
const { locals } = getRequestEvent();
const response = await locals.api.delete(`/libraries/imports/${jobId}`);
if (!response.ok) error(response.status, detailOf(await response.text()));
return await response.json();
});
+1
View File
@@ -2,6 +2,7 @@ export * from './auth.remote';
export * from './author.remote';
export * from './book.remote';
export * from './bookshelf.remote';
export * from './calibre-import.remote';
export * from './library.remote';
export * from './publisher.remote';
export * from './tag.remote';
+3
View File
@@ -19,3 +19,6 @@ export const libraryCreateSchema = z.object({
export type LibraryQuerySchema = typeof libraryQuerySchema;
export type LibraryCreateSchema = typeof libraryCreateSchema;
export type CalibreImport = components['schemas']['CalibreImportRead'];
export type ImportFailure = components['schemas']['ImportFailureRead'];
+181 -1
View File
@@ -230,6 +230,24 @@ export interface paths {
patch?: never;
trace?: never;
};
"/libraries/imports/{job_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** GetImport */
get: operations["LibrariesImportsJobIdGetImport"];
put?: never;
post?: never;
/** CancelImport */
delete: operations["LibrariesImportsJobIdCancelImport"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/libraries": {
parameters: {
query?: never;
@@ -248,6 +266,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/libraries/{library_id}/imports/calibre/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** UploadCalibreImport */
post: operations["LibrariesLibraryIdImportsCalibreUploadUploadCalibreImport"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/access/me": {
parameters: {
query?: never;
@@ -791,6 +826,30 @@ export interface components {
skipped: components["schemas"]["DuplicateFileRead"][];
possible_duplicates?: components["schemas"]["PossibleDuplicateRead"][];
};
/** CalibreArchiveUpload */
CalibreArchiveUpload: {
/** Format: binary */
archive: string;
/** @default false */
allow_duplicates: boolean;
};
/** CalibreImportRead */
CalibreImportRead: {
id: string;
library_id: number;
source: string;
state: string;
total: number;
processed: number;
created: number;
skipped: number;
failed: number;
current_title?: string | null;
failures?: components["schemas"]["ImportFailureRead"][];
/** @default 0 */
possible_duplicates: number;
error?: string | null;
};
/** DuplicateBookGroupRead */
DuplicateBookGroupRead: {
books: components["schemas"]["DuplicateBookRead"][];
@@ -831,9 +890,15 @@ export interface components {
path: string;
hash: string;
size: number;
content_type: string;
content_type?: string | null;
readonly filename: string;
};
/** ImportFailureRead */
ImportFailureRead: {
calibre_id: number;
title: string;
reason: string;
};
/** KosyncDeviceCreate */
KosyncDeviceCreate: {
name: string;
@@ -1686,6 +1751,80 @@ export interface operations {
};
};
};
LibrariesImportsJobIdGetImport: {
parameters: {
query?: never;
header?: never;
path: {
job_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Request fulfilled, document follows */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalibreImportRead"];
};
};
/** @description Bad request syntax or unsupported method */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
status_code: number;
detail: string;
extra?: null | {
[key: string]: unknown;
} | unknown[];
};
};
};
};
};
LibrariesImportsJobIdCancelImport: {
parameters: {
query?: never;
header?: never;
path: {
job_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Request fulfilled, document follows */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalibreImportRead"];
};
};
/** @description Bad request syntax or unsupported method */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
status_code: number;
detail: string;
extra?: null | {
[key: string]: unknown;
} | unknown[];
};
};
};
};
};
LibrariesListLibraries: {
parameters: {
query?: {
@@ -1772,6 +1911,47 @@ export interface operations {
};
};
};
LibrariesLibraryIdImportsCalibreUploadUploadCalibreImport: {
parameters: {
query?: never;
header?: never;
path: {
library_id: number;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["CalibreArchiveUpload"];
};
};
responses: {
/** @description Request accepted, processing continues off-line */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalibreImportRead"];
};
};
/** @description Bad request syntax or unsupported method */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
status_code: number;
detail: string;
extra?: null | {
[key: string]: unknown;
} | unknown[];
};
};
};
};
};
AccessMeGetUserInfo: {
parameters: {
query?: never;
+17 -1
View File
@@ -81,7 +81,10 @@ function isbnDigits(value: string) {
}
function identifierKey(name: string) {
return name.trim().toLowerCase().replace(/[\s_]+/g, '-');
return name
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-');
}
export function describeIdentifier(name: string, value: string) {
@@ -111,4 +114,17 @@ export function getFileType(filename: string) {
return extension.toUpperCase();
}
/**
* The formats there is a reader for. Everything else is download-only.
*
* A library imported from elsewhere carries MOBI, AZW3 and CBZ files, which are
* legitimate to store and to download but have nowhere to open — the reader routes
* are `read/epub` and `read/pdf` and there is no third one.
*/
const READABLE_FILE_TYPES = ['EPUB', 'PDF'];
export function isReadable(filename: string) {
return READABLE_FILE_TYPES.includes(getFileType(filename));
}
export const pluck = (array: [], key: string) => array.map((obj) => obj?.[key]);
@@ -18,7 +18,13 @@
PlusIcon,
Trash2
} from '@lucide/svelte';
import { describeIdentifier, formatFileSize, getFileType, sortIdentifiers } from '$lib/utils.js';
import {
describeIdentifier,
formatFileSize,
getFileType,
isReadable,
sortIdentifiers
} from '$lib/utils.js';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte.js';
import { getLibraryState } from '$lib/state/library.svelte.js';
@@ -52,6 +58,11 @@
book.progress?.percentage ? Math.round(book.progress.percentage * 100) : 0
);
// Only the files there is a reader for. A book can be stored in a format Chitai
// cannot open — a Calibre library is full of MOBI and AZW3 — and offering to read
// one opened a window that did nothing at all.
const readableFiles = $derived(book.files.filter((file) => isReadable(file.filename)));
// The primary action states what it will actually do.
const readLabel = $derived(
book.progress?.completed
@@ -86,6 +97,10 @@
'inline-flex items-center gap-2 rounded-lg bg-primary-foreground px-4 py-2 text-sm font-semibold text-primary tabular-nums transition-colors hover:bg-primary-foreground/90';
const bandGhost =
'inline-flex items-center gap-2 rounded-lg border border-primary-foreground/35 px-4 py-2 text-sm font-medium transition-colors hover:bg-primary-foreground/10';
// With nothing to read, downloading is the only thing left to do — so it takes the
// primary slot rather than leaving the band with no filled button on it.
const bandDownload = $derived(readableFiles.length ? bandGhost : bandPrimary);
</script>
<div class="flex h-full flex-col overflow-y-auto">
@@ -132,9 +147,13 @@
</p>
{/if}
<!-- Primary actions live on the band, not in a side rail -->
<!--
Primary actions live on the band, not in a side rail. Read and Download
are counted separately: everything can be downloaded, only EPUB and PDF
can be opened, so a book can have two files and one way to read it.
-->
<div class="mt-5 flex flex-wrap items-center gap-2">
{#if book.files.length > 1}
{#if readableFiles.length > 1}
<DropdownMenu.Root>
<DropdownMenu.Trigger class={bandPrimary}>
<BookOpenText class="size-4" />
@@ -144,7 +163,7 @@
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
<DropdownMenu.Separator />
{#each book.files as file (file.id)}
{#each readableFiles as file (file.id)}
<DropdownMenu.Item onclick={() => openBookInReader(file)}>
{getFileType(file.filename)}
</DropdownMenu.Item>
@@ -152,9 +171,20 @@
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else if readableFiles.length === 1}
<button
type="button"
class={bandPrimary}
onclick={() => openBookInReader(readableFiles[0])}
>
<BookOpenText class="size-4" />
{readLabel}
</button>
{/if}
{#if book.files.length > 1}
<DropdownMenu.Root>
<DropdownMenu.Trigger class={bandGhost}>
<DropdownMenu.Trigger class={bandDownload}>
<Download class="size-4" />
Download
</DropdownMenu.Trigger>
@@ -181,16 +211,7 @@
{:else if book.files.length === 1}
<button
type="button"
class={bandPrimary}
onclick={() => openBookInReader(book.files[0])}
>
<BookOpenText class="size-4" />
{readLabel}
</button>
<button
type="button"
class={bandGhost}
class={bandDownload}
onclick={() =>
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
>
@@ -491,7 +512,7 @@
>
<Table.Cell class="text-right">
<div class="flex justify-end gap-1">
{#if getFileType(file.filename) === 'EPUB' || getFileType(file.filename) === 'PDF'}
{#if isReadable(file.filename)}
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
@@ -13,14 +13,14 @@
libraryState.libraries.find((lib) => String(lib.id) === page.params.libraryId)
);
// Duplicates is the only section today. The strip exists so General and a
// danger zone have an obvious place to land; neither is built yet, and an
// empty tab is worse than no tab.
// The strip exists so General and a danger zone have an obvious place to land;
// neither is built yet, and an empty tab is worse than no tab.
//
// Active state is matched on route id, not pathname, for the reason spelled
// out in settings/+layout.svelte.
const sections = [
{ title: 'Duplicates', routeId: '/(root)/settings/libraries/[libraryId]/duplicates' }
{ title: 'Duplicates', routeId: '/(root)/settings/libraries/[libraryId]/duplicates' },
{ title: 'Import', routeId: '/(root)/settings/libraries/[libraryId]/import' }
] as const;
</script>
@@ -0,0 +1,367 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
import { BookOpen, Loader2, TriangleAlert, Upload } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Progress } from '$lib/components/ui/progress/index.js';
import { cancelCalibreImport, getCalibreImport } from '$lib/api/calibre-import.remote';
import { formatFileSize } from '$lib/utils';
import type { CalibreImport } from '$lib/schema/library';
const libraryId = $derived(page.params.libraryId!);
let archive = $state<File | null>(null);
let allowDuplicates = $state(false);
let job = $state<CalibreImport | null>(null);
let busy = $state(false);
let problem = $state<string | null>(null);
/** How much of the archive has reached the server, 01, while it is going up. */
let uploaded = $state<number | null>(null);
/** How often a running import is asked where it has got to. */
const POLL_MS = 1000;
let poll: ReturnType<typeof setInterval> | undefined;
const running = $derived(job?.state === 'running');
const percent = $derived(
job && job.total > 0 ? Math.round((job.processed / job.total) * 100) : 0
);
function stopPolling() {
clearInterval(poll);
poll = undefined;
}
onDestroy(stopPolling);
function pick(event: Event) {
const input = event.currentTarget as HTMLInputElement;
archive = input.files?.[0] ?? null;
problem = null;
}
/**
* The backend's own message for a failed proxied response.
*
* The proxy wraps the upstream body in SvelteKit's error envelope, so the useful
* `detail` is one or two layers down.
*/
function detailOf(body: string): string {
try {
const parsed = JSON.parse(body);
if (typeof parsed?.detail === 'string') return parsed.detail;
if (typeof parsed?.message === 'string') return detailOf(parsed.message);
} catch {
// Not JSON — the raw text is the best there is.
}
return body;
}
/**
* Follow a running import until it lands.
*
* Polled rather than pushed: the job lives on the server, so this survives a reload
* and does not depend on the tab that started it staying open.
*/
function follow(jobId: string) {
stopPolling();
poll = setInterval(async () => {
try {
job = await getCalibreImport(jobId);
} catch (error) {
problem = error instanceof Error ? error.message : 'Lost track of the import';
stopPolling();
return;
}
if (job.state !== 'running') {
stopPolling();
announce(job);
}
}, POLL_MS);
}
function announce(finished: CalibreImport) {
const created = `${finished.created} book${finished.created === 1 ? '' : 's'} imported`;
if (finished.state === 'failed') toast.error(finished.error ?? 'The import failed');
else if (finished.state === 'cancelled') toast.info(`Import stopped — ${created}`);
else if (finished.failed > 0) toast.warning(`${created}, ${finished.failed} failed`);
else toast.success(created);
}
/**
* Send the archive and start the import it becomes.
*
* `XMLHttpRequest` rather than `fetch` for the one thing fetch cannot do: report how
* much of the body has gone up. On a large archive that is the only progress there is
* for minutes at a time.
*
* It goes through the proxy so the browser streams straight to the backend — a remote
* function would put the whole archive through the SvelteKit process first.
*/
function send(file: File): Promise<CalibreImport> {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open('POST', `/api/libraries/${libraryId}/imports/calibre/upload`);
request.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) uploaded = event.loaded / event.total;
});
request.addEventListener('load', () => {
if (request.status >= 200 && request.status < 300) {
resolve(JSON.parse(request.responseText));
} else {
reject(new Error(detailOf(request.responseText)));
}
});
request.addEventListener('error', () => reject(new Error('The upload failed')));
request.addEventListener('abort', () => reject(new Error('The upload was stopped')));
const body = new FormData();
body.append('archive', file);
body.append('allow_duplicates', String(allowDuplicates));
request.send(body);
});
}
async function start() {
if (!archive) return;
busy = true;
problem = null;
uploaded = 0;
try {
job = await send(archive);
follow(job.id);
} catch (error) {
problem = error instanceof Error ? error.message : 'Could not upload the archive';
} finally {
busy = false;
uploaded = null;
}
}
async function stop() {
if (!job) return;
try {
job = await cancelCalibreImport(job.id);
} catch (error) {
problem = error instanceof Error ? error.message : 'Could not stop the import';
}
}
function reset() {
stopPolling();
job = null;
problem = null;
archive = null;
uploaded = null;
}
</script>
<div class="flex flex-col gap-4">
<Card.Root>
<Card.Header>
<Card.Title>Import from Calibre</Card.Title>
<Card.Description>
Zip your Calibre library folder and upload it here. Nothing is taken from the original — the
books are copied in, and uploading the same library again only picks up what is new.
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<Field.Field>
<Field.Label for="archive">Zipped Calibre library</Field.Label>
<!--
The native file button inherits the input's own text styling, which leaves
"Browse…" looking like the first half of the sentence "Browse… No file
selected." The `file:` variants target ::file-selector-button, so it can be
made to read as a button without replacing the input with a custom one.
Styled here rather than in `ui/input`, which is generated.
-->
<Input
id="archive"
type="file"
accept=".zip,application/zip"
onchange={pick}
disabled={running || busy}
class="py-1.5 file:mr-3 file:cursor-pointer file:rounded-sm file:border file:border-input file:bg-secondary file:px-2 file:py-0.5 file:text-secondary-foreground file:hover:bg-secondary/80"
/>
<Field.Description>
The zip has to contain <code class="font-mono text-xs">metadata.db</code> — zip the whole Calibre
folder rather than just the books.
</Field.Description>
</Field.Field>
{#if archive}
<p class="text-sm text-muted-foreground">
{archive.name} · {formatFileSize(archive.size)}
</p>
{/if}
<div class="flex items-center gap-2">
<Checkbox id="allow-duplicates" bind:checked={allowDuplicates} disabled={running || busy} />
<label for="allow-duplicates" class="text-sm">
Import books this library already holds
</label>
</div>
{#if uploaded !== null}
<div class="flex flex-col gap-1">
<Progress value={Math.round(uploaded * 100)} />
<p class="text-xs text-muted-foreground">
Uploading · {Math.round(uploaded * 100)}%
</p>
</div>
{/if}
{#if problem}
<p class="flex items-center gap-2 text-sm text-destructive">
<TriangleAlert class="size-4 shrink-0" />
{problem}
</p>
{/if}
</Card.Content>
<Card.Footer class="gap-2">
{#if running}
<Button variant="outline" onclick={stop}>Stop after this book</Button>
{:else if job}
<Button onclick={reset}>Import another</Button>
{:else}
<!--
No preview step: the catalogue cannot be read until the archive is on the
server, and by then it has been carried across anyway. Unpacking it is what
refuses an archive that is not a Calibre library.
-->
<Button onclick={start} disabled={busy || !archive}>
{#if busy}
<Loader2 class="size-4 animate-spin" />
{:else}
<Upload class="size-4" />
{/if}
Upload and import
</Button>
{/if}
</Card.Footer>
</Card.Root>
{#if job}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
{#if running}<Loader2 class="size-4 animate-spin" />{/if}
{running ? 'Importing' : job.state === 'failed' ? 'Import failed' : 'Import finished'}
</Card.Title>
<Card.Description>
{#if running && job.current_title}
{job.processed} of {job.total} · {job.current_title}
{:else if job.state === 'cancelled'}
Stopped after {job.processed} of {job.total}. Everything imported is complete.
{:else if job.state === 'failed'}
{job.error ?? 'The import stopped before it finished.'}
{:else}
{job.processed} of {job.total} books considered.
{/if}
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<Progress value={percent} />
<div class="grid grid-cols-3 gap-3 text-sm">
<div>
<p class="font-mono text-lg tabular-nums">{job.created}</p>
<p class="text-muted-foreground">imported</p>
</div>
<div>
<p class="font-mono text-lg tabular-nums">{job.skipped}</p>
<p class="text-muted-foreground">already here</p>
</div>
<div>
<p class="font-mono text-lg tabular-nums">{job.failed}</p>
<p class="text-muted-foreground">failed</p>
</div>
</div>
{#if job.created > 0}
<a
href={resolve('/(root)/(library)/library/[libraryId]/view', { libraryId })}
class="inline-flex w-fit items-center gap-2 text-sm hover:underline"
>
<BookOpen class="size-4" />
See the books
</a>
{/if}
<!--
An import into a library that already holds books is the likeliest way to
end up with two records for one book, and that screen already handles them.
-->
{#if job.possible_duplicates > 0}
<div class="rounded-lg border p-3 text-sm">
<p>
{job.possible_duplicates} imported book{job.possible_duplicates === 1 ? '' : 's'}
look{job.possible_duplicates === 1 ? 's' : ''} like something this library already had.
They were imported all the same — a metadata match is a guess.
</p>
<a
href={resolve('/(root)/settings/libraries/[libraryId]/duplicates', { libraryId })}
class="mt-2 inline-block font-medium hover:underline"
>
Review duplicates
</a>
</div>
{/if}
{#if job.failures?.length}
<div class="flex flex-col gap-2">
<p class="text-sm font-medium">Books that could not be imported</p>
<div class="overflow-x-auto rounded-lg border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[80px]">Calibre</Table.Head>
<Table.Head>Title</Table.Head>
<Table.Head>Reason</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each job.failures as failure (failure.calibre_id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">#{failure.calibre_id}</Table.Cell>
<Table.Cell>{failure.title}</Table.Cell>
<Table.Cell class="font-mono text-xs">{failure.reason}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>
+20 -8
View File
@@ -30,6 +30,20 @@ async function handleResponse(response: Response) {
}
}
/**
* The body to forward, as fetch init options.
*
* Passed through as a stream rather than read into memory first: this process should
* never hold a whole upload, which for a zipped Calibre library could be many
* gigabytes. A request with no body contributes nothing, since `duplex` without a body
* is rejected.
*/
function bodyOf(request: Request) {
if (!request.body) return {};
return { body: request.body, duplex: 'half' } as RequestInit;
}
// Shared function to prepare the request with authentication
function prepareRequest(locals: App.Locals, request: Request) {
const token = locals.authToken || 'server-default-token';
@@ -76,13 +90,14 @@ export const POST: RequestHandler = async ({ params, locals, fetch, request, url
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
const headers = prepareRequest(locals, request);
// Get the request body
const body = await request.arrayBuffer();
const response = await fetch(backendUrl, {
method: 'POST',
headers,
body
// Streamed, not buffered. `arrayBuffer()` held the whole upload in this
// process before forwarding a byte of it, which is survivable for one book
// and not for a zipped Calibre library. `duplex: 'half'` is required by the
// fetch spec whenever the body is a stream.
...bodyOf(request)
});
return handleResponse(response);
@@ -100,13 +115,10 @@ export const PATCH: RequestHandler = async ({ params, locals, fetch, request, ur
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
const headers = prepareRequest(locals, request);
// Get the request body
const body = await request.arrayBuffer();
const response = await fetch(backendUrl, {
method: 'PATCH',
headers,
body
...bodyOf(request)
});
return handleResponse(response);