feat(api,web): modélisation série + volume et parcours de série
- 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>
This commit is contained in:
@ -4,6 +4,7 @@ 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;
|
||||
@ -25,6 +26,7 @@ describe("database migrations", () => {
|
||||
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,
|
||||
@ -84,6 +86,17 @@ describe("database migrations", () => {
|
||||
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();
|
||||
|
||||
@ -94,18 +107,130 @@ describe("database migrations", () => {
|
||||
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(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf"]);
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user