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
@@ -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';