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
+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);