- Table series + colonnes seriesId/volumeNumber/volumeLabel sur les livres, extraction souple des numéros (T03, 003, etc.) via extract-series-volume - Contrôleur et page de série, badge volume sur les cartes de livre, styles associés (fond/clarté série, ajustements globaux) - Statuts de scan/enrichissement et provenance des métadonnées en base Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
244 lines
8.9 KiB
TypeScript
244 lines
8.9 KiB
TypeScript
import Database from "better-sqlite3";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { DatabaseService } from "./database.service.js";
|
|
import { books, libraries, series } from "./schema.js";
|
|
|
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
|
const previousStorageDir = process.env.STORAGE_DIR;
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
process.env.DATABASE_PATH = previousDatabasePath;
|
|
process.env.STORAGE_DIR = previousStorageDir;
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("database migrations", () => {
|
|
it.runIf(canLoadBetterSqlite())("adds metadata columns to an existing comic-capable books table before creating dependent indexes", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "readabook-migration-"));
|
|
tempDirs.push(dir);
|
|
const databasePath = join(dir, "readabook.sqlite");
|
|
const storageDir = join(dir, "storage");
|
|
|
|
const legacy = new Database(databasePath);
|
|
const now = new Date().toISOString();
|
|
legacy.exec(`
|
|
CREATE TABLE users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT NOT NULL UNIQUE,
|
|
name TEXT,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE libraries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
path TEXT NOT NULL,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE books (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
|
title TEXT NOT NULL,
|
|
author TEXT,
|
|
description TEXT,
|
|
isbn TEXT,
|
|
language TEXT,
|
|
publisher TEXT,
|
|
published_date TEXT,
|
|
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
|
file_path TEXT NOT NULL UNIQUE,
|
|
cover_path TEXT,
|
|
file_size INTEGER NOT NULL,
|
|
file_mtime TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE progress (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
|
locator TEXT NOT NULL,
|
|
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE(user_id, book_id)
|
|
);
|
|
|
|
CREATE TABLE jobs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
type TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed')),
|
|
detail TEXT,
|
|
error TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
INSERT INTO libraries (id, name, path, enabled, created_at, updated_at)
|
|
VALUES (1, 'Corpus', '/library', 1, '${now}', '${now}');
|
|
INSERT INTO books (
|
|
library_id, title, author, description, isbn, language, publisher, published_date,
|
|
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
|
)
|
|
VALUES
|
|
(1, 'Solo Leveling T03', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Solo Leveling T03.cbz', NULL, 42, '${now}', '${now}', '${now}'),
|
|
(1, 'Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz', NULL, 42, '${now}', '${now}', '${now}'),
|
|
(1, 'Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz', NULL, 42, '${now}', '${now}', '${now}');
|
|
`);
|
|
legacy.close();
|
|
|
|
process.env.DATABASE_PATH = databasePath;
|
|
process.env.STORAGE_DIR = storageDir;
|
|
|
|
const database = new DatabaseService();
|
|
const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
|
|
const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>;
|
|
const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>;
|
|
const seriesRows = database.sqlite
|
|
.prepare(
|
|
`
|
|
SELECT books.title, books.volume_number, books.volume_label, series.title AS series_title, series.normalized_title
|
|
FROM books
|
|
JOIN series ON series.id = books.series_id
|
|
ORDER BY books.title
|
|
`
|
|
)
|
|
.all() as Array<{
|
|
title: string;
|
|
volume_number: number | null;
|
|
volume_label: string | null;
|
|
series_title: string;
|
|
normalized_title: string;
|
|
}>;
|
|
const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as
|
|
| { id: number; isbn_priority_enabled: number }
|
|
| undefined;
|
|
|
|
expect(bookColumns.map((column) => column.name)).toContain("isbn13");
|
|
expect(bookColumns.map((column) => column.name)).toContain("identifiers_json");
|
|
expect(bookColumns.map((column) => column.name)).toContain("scan_status");
|
|
expect(bookColumns.map((column) => column.name)).toContain("enrichment_status");
|
|
expect(bookColumns.map((column) => column.name)).toContain("series_id");
|
|
expect(bookColumns.map((column) => column.name)).toContain("volume_number");
|
|
expect(bookColumns.map((column) => column.name)).toContain("volume_label");
|
|
expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx");
|
|
expect(bookIndexes.map((index) => index.name)).toContain("books_series_idx");
|
|
expect(seriesRows).toEqual([
|
|
{
|
|
title: "Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+",
|
|
volume_number: 1,
|
|
volume_label: "T01",
|
|
series_title: "Dragon Ball SD",
|
|
normalized_title: "dragon ball sd"
|
|
},
|
|
{
|
|
title: "Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz",
|
|
volume_number: 1,
|
|
volume_label: "T01",
|
|
series_title: "Eyeshield 21",
|
|
normalized_title: "eyeshield 21"
|
|
},
|
|
{
|
|
title: "Solo Leveling T03",
|
|
volume_number: 3,
|
|
volume_label: "T03",
|
|
series_title: "Solo Leveling",
|
|
normalized_title: "solo leveling"
|
|
}
|
|
]);
|
|
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]);
|
|
expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 });
|
|
|
|
database.onModuleDestroy();
|
|
});
|
|
|
|
it.runIf(canLoadBetterSqlite())("backfills missing Daredevil volume numbers when series already exists", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "readabook-series-backfill-"));
|
|
tempDirs.push(dir);
|
|
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
|
|
process.env.STORAGE_DIR = join(dir, "storage");
|
|
|
|
const first = new DatabaseService();
|
|
const now = first.now();
|
|
const library = first.db
|
|
.insert(libraries)
|
|
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
|
.returning()
|
|
.get();
|
|
const daredevil = first.db
|
|
.insert(series)
|
|
.values({
|
|
title: "Daredevil",
|
|
normalizedTitle: "daredevil",
|
|
description: null,
|
|
publisher: null,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
})
|
|
.returning()
|
|
.get();
|
|
first.db
|
|
.insert(books)
|
|
.values({
|
|
libraryId: library.id,
|
|
seriesId: daredevil.id,
|
|
title: "Daredevil",
|
|
author: null,
|
|
description: null,
|
|
isbn: null,
|
|
isbn13: null,
|
|
identifiersJson: null,
|
|
localMetadataJson: null,
|
|
language: null,
|
|
publisher: null,
|
|
publishedDate: null,
|
|
volumeNumber: null,
|
|
volumeLabel: null,
|
|
format: "cbz",
|
|
filePath: "/library/Daredevil - 001[Sebmov].cbz",
|
|
coverPath: null,
|
|
metadataStatus: "none",
|
|
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
|
scanStatus: "succeeded",
|
|
enrichmentStatus: "idle",
|
|
fileSize: 42,
|
|
fileMtime: now,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
})
|
|
.run();
|
|
first.onModuleDestroy();
|
|
|
|
const second = new DatabaseService();
|
|
const row = second.sqlite.prepare("SELECT volume_number, volume_label FROM books WHERE file_path = ?").get(
|
|
"/library/Daredevil - 001[Sebmov].cbz"
|
|
) as { volume_number: number | null; volume_label: string | null };
|
|
|
|
expect(row).toEqual({ volume_number: 1, volume_label: "001" });
|
|
|
|
second.onModuleDestroy();
|
|
});
|
|
});
|
|
|
|
function canLoadBetterSqlite(): boolean {
|
|
try {
|
|
new Database(":memory:").close();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|