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:
@@ -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();
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user