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