Files
ReadaBook/apps/api/src/database/database.service.ts
Git Agent 93bb40a8cd 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>
2026-08-24 09:11:10 +02:00

493 lines
20 KiB
TypeScript

import { Injectable, OnModuleDestroy } from "@nestjs/common";
import Database from "better-sqlite3";
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
import { AppConfig, loadConfig } from "../config/env.js";
import { extractSeriesVolume } from "../metadata/use-cases/extract-series-volume.js";
import * as schema from "./schema.js";
@Injectable()
export class DatabaseService implements OnModuleDestroy {
readonly config: AppConfig;
readonly sqlite: Database.Database;
readonly db: BetterSQLite3Database<typeof schema>;
constructor() {
this.config = loadConfig();
this.sqlite = new Database(this.config.databasePath);
this.sqlite.pragma("journal_mode = WAL");
this.sqlite.pragma("foreign_keys = ON");
this.sqlite.pragma("busy_timeout = 5000");
this.db = drizzle(this.sqlite, { schema });
this.migrate();
}
onModuleDestroy(): void {
this.sqlite.close();
}
now(): string {
return new Date().toISOString();
}
private migrate(): void {
this.sqlite.exec(`
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS series (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
normalized_title TEXT NOT NULL UNIQUE,
description TEXT,
publisher TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
series_id INTEGER REFERENCES series(id) ON DELETE SET NULL,
title TEXT NOT NULL,
author TEXT,
description TEXT,
isbn TEXT,
isbn13 TEXT,
identifiers_json TEXT,
local_metadata_json TEXT,
language TEXT,
publisher TEXT,
published_date TEXT,
volume_number INTEGER,
volume_label TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
metadata_status TEXT NOT NULL DEFAULT 'none' CHECK (metadata_status IN ('enriched','partial','none')),
metadata_provenance_json TEXT,
scan_status TEXT NOT NULL DEFAULT 'idle' CHECK (scan_status IN ('idle','running','succeeded','failed')),
enrichment_status TEXT NOT NULL DEFAULT 'idle' CHECK (enrichment_status IN ('idle','running','succeeded','failed')),
file_size INTEGER NOT NULL,
file_mtime TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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
);
CREATE TABLE IF NOT EXISTS metadata_source_config (
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf','mangadex','comicvine')),
enabled INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL,
api_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS automation_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
watch_libraries INTEGER NOT NULL DEFAULT 0,
auto_enrich_new_books INTEGER NOT NULL DEFAULT 1,
isbn_priority_enabled INTEGER NOT NULL DEFAULT 1,
scan_schedule_json TEXT NOT NULL,
enrich_schedule_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS book_fts USING fts5(
title,
author,
description,
isbn,
content='books',
content_rowid='id'
);
CREATE INDEX IF NOT EXISTS books_library_idx ON books(library_id);
CREATE INDEX IF NOT EXISTS books_title_idx ON books(title);
CREATE INDEX IF NOT EXISTS jobs_status_idx ON jobs(status);
CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
`);
this.ensureBooksSupportsComicArchives();
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
this.ensureBooksMetadataColumns();
this.ensureSeriesModel();
this.ensureReaderPreferencesTable();
this.ensureMetadataSourceConfigSupportsComicProviders();
this.ensureMetadataSourceConfigColumns();
this.ensureAutomationSettingsColumns();
this.ensureMetadataDefaults();
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
}
private ensureBooksSupportsComicArchives(): void {
const table = this.sqlite
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'books'")
.get() as { sql?: string } | undefined;
if (!table?.sql || (table.sql.includes("'cbz'") && table.sql.includes("'cbr'"))) return;
this.sqlite.exec(`
PRAGMA foreign_keys = OFF;
PRAGMA legacy_alter_table = ON;
DROP TRIGGER IF EXISTS books_ai;
DROP TRIGGER IF EXISTS books_ad;
DROP TRIGGER IF EXISTS books_au;
BEGIN;
ALTER TABLE books RENAME TO books_legacy_format;
CREATE TABLE books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
series_id INTEGER REFERENCES series(id) ON DELETE SET NULL,
title TEXT NOT NULL,
author TEXT,
description TEXT,
isbn TEXT,
isbn13 TEXT,
identifiers_json TEXT,
local_metadata_json TEXT,
language TEXT,
publisher TEXT,
published_date TEXT,
volume_number INTEGER,
volume_label TEXT,
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
metadata_status TEXT NOT NULL DEFAULT 'none' CHECK (metadata_status IN ('enriched','partial','none')),
metadata_provenance_json TEXT,
scan_status TEXT NOT NULL DEFAULT 'idle' CHECK (scan_status IN ('idle','running','succeeded','failed')),
enrichment_status TEXT NOT NULL DEFAULT 'idle' CHECK (enrichment_status IN ('idle','running','succeeded','failed')),
file_size INTEGER NOT NULL,
file_mtime TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO books (
id, library_id, title, author, description, isbn, isbn13, identifiers_json, local_metadata_json, language, publisher, published_date,
volume_number, volume_label, format, file_path, cover_path, metadata_status, metadata_provenance_json, scan_status, enrichment_status, file_size, file_mtime, created_at, updated_at
)
SELECT
id, library_id, title, author, description, isbn, NULL, NULL, NULL, language, publisher, published_date,
NULL, NULL,
format, file_path, cover_path, CASE WHEN cover_path IS NOT NULL OR author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL THEN 'partial' ELSE 'none' END, NULL,
'idle', 'idle', file_size, file_mtime, created_at, updated_at
FROM books_legacy_format;
DROP TABLE books_legacy_format;
COMMIT;
PRAGMA legacy_alter_table = OFF;
PRAGMA foreign_keys = ON;
CREATE UNIQUE INDEX IF NOT EXISTS books_file_path_unique ON books(file_path);
CREATE INDEX IF NOT EXISTS books_library_idx ON books(library_id);
CREATE INDEX IF NOT EXISTS books_title_idx ON books(title);
CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13);
CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
END;
CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN
INSERT INTO book_fts(book_fts, rowid, title, author, description, isbn)
VALUES('delete', old.id, old.title, old.author, old.description, old.isbn);
INSERT INTO book_fts(rowid, title, author, description, isbn)
VALUES (new.id, new.title, new.author, new.description, new.isbn);
END;
`);
}
private ensureBooksMetadataColumns(): void {
const columns = this.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
const names = new Set(columns.map((column) => column.name));
if (!names.has("isbn13")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN isbn13 TEXT");
}
if (!names.has("identifiers_json")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN identifiers_json TEXT");
}
if (!names.has("local_metadata_json")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN local_metadata_json TEXT");
}
if (!names.has("metadata_status")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN metadata_status TEXT NOT NULL DEFAULT 'none'");
this.sqlite.exec(`
UPDATE books
SET metadata_status = CASE
WHEN cover_path IS NOT NULL AND (author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL) THEN 'enriched'
WHEN cover_path IS NOT NULL OR author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL THEN 'partial'
ELSE 'none'
END
`);
}
if (!names.has("metadata_provenance_json")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN metadata_provenance_json TEXT");
}
if (!names.has("scan_status")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN scan_status TEXT NOT NULL DEFAULT 'idle'");
}
if (!names.has("enrichment_status")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN enrichment_status TEXT NOT NULL DEFAULT 'idle'");
}
if (!names.has("series_id")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN series_id INTEGER REFERENCES series(id) ON DELETE SET NULL");
}
if (!names.has("volume_number")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN volume_number INTEGER");
}
if (!names.has("volume_label")) {
this.sqlite.exec("ALTER TABLE books ADD COLUMN volume_label TEXT");
}
this.sqlite.exec(`
UPDATE books
SET published_date = NULL
WHERE published_date IS NOT NULL
AND (
trim(published_date) = '0000'
OR substr(trim(published_date), 1, 10) IN ('0001-01-01', '0101-01-01', '1970-01-01')
OR CAST(substr(trim(published_date), 1, 4) AS INTEGER) < 1500
OR CAST(substr(trim(published_date), 1, 4) AS INTEGER) > 2027
)
`);
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)");
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_local_metadata_idx ON books(local_metadata_json)");
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_series_idx ON books(series_id)");
}
private ensureSeriesModel(): void {
this.sqlite.exec(`
CREATE TABLE IF NOT EXISTS series (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
normalized_title TEXT NOT NULL UNIQUE,
description TEXT,
publisher TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS series_normalized_title_unique ON series(normalized_title);
CREATE INDEX IF NOT EXISTS books_series_idx ON books(series_id);
`);
this.backfillSeries();
}
private backfillSeries(): void {
const rows = this.sqlite.prepare("SELECT id, title, file_path, series_id FROM books WHERE series_id IS NULL OR volume_number IS NULL").all() as Array<{
id: number;
title: string;
file_path: string;
series_id: number | null;
}>;
if (!rows.length) return;
const now = this.now();
const insertSeries = this.sqlite.prepare(`
INSERT INTO series (title, normalized_title, description, publisher, created_at, updated_at)
VALUES (?, ?, NULL, NULL, ?, ?)
ON CONFLICT(normalized_title) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at
RETURNING id
`);
const updateBook = this.sqlite.prepare("UPDATE books SET series_id = ?, volume_number = ?, volume_label = ? WHERE id = ?");
const transaction = this.sqlite.transaction(() => {
for (const row of rows) {
const parsed = extractSeriesVolume(row.title, row.file_path);
if (row.series_id !== null && parsed.volumeNumber === null) continue;
const seriesId =
row.series_id ??
(insertSeries.get(parsed.seriesTitle, parsed.normalizedSeriesTitle, now, now) as { id: number }).id;
updateBook.run(seriesId, parsed.volumeNumber, parsed.volumeLabel, row.id);
}
});
transaction();
}
private ensureReaderPreferencesTable(): void {
this.sqlite.exec(`
CREATE TABLE IF NOT EXISTS reader_preferences (
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,
mode TEXT NOT NULL CHECK (mode IN ('paged','scrolled','horizontal','vertical')),
fit TEXT CHECK (fit IN ('page','width','height','auto')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, book_id)
);
CREATE INDEX IF NOT EXISTS reader_preferences_user_idx ON reader_preferences(user_id);
`);
}
private ensureMetadataSourceConfigColumns(): void {
const names = this.columnNames("metadata_source_config");
const now = sqlString(this.now());
if (!names.has("enabled")) {
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1");
}
if (!names.has("priority")) {
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN priority INTEGER NOT NULL DEFAULT 0");
}
if (!names.has("api_key")) {
this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN api_key TEXT");
}
if (!names.has("created_at")) {
this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`);
}
if (!names.has("updated_at")) {
this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`);
}
}
private ensureMetadataSourceConfigSupportsComicProviders(): void {
const table = this.sqlite
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'metadata_source_config'")
.get() as { sql?: string } | undefined;
if (!table?.sql || (table.sql.includes("'mangadex'") && table.sql.includes("'comicvine'"))) return;
this.sqlite.exec(`
BEGIN;
ALTER TABLE metadata_source_config RENAME TO metadata_source_config_legacy_provider;
CREATE TABLE metadata_source_config (
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf','mangadex','comicvine')),
enabled INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL,
api_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO metadata_source_config (provider, enabled, priority, api_key, created_at, updated_at)
SELECT provider, enabled, priority, api_key, created_at, updated_at
FROM metadata_source_config_legacy_provider;
DROP TABLE metadata_source_config_legacy_provider;
COMMIT;
`);
}
private ensureAutomationSettingsColumns(): void {
const names = this.columnNames("automation_settings");
const now = sqlString(this.now());
const disabledScan = sqlString(JSON.stringify({ frequency: "disabled", time: "03:00", dayOfWeek: 1 }));
const disabledEnrich = sqlString(JSON.stringify({ frequency: "disabled", time: "04:00", dayOfWeek: 1 }));
if (!names.has("watch_libraries")) {
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN watch_libraries INTEGER NOT NULL DEFAULT 0");
}
if (!names.has("auto_enrich_new_books")) {
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN auto_enrich_new_books INTEGER NOT NULL DEFAULT 1");
}
if (!names.has("isbn_priority_enabled")) {
this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN isbn_priority_enabled INTEGER NOT NULL DEFAULT 1");
}
if (!names.has("scan_schedule_json")) {
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN scan_schedule_json TEXT NOT NULL DEFAULT ${disabledScan}`);
}
if (!names.has("enrich_schedule_json")) {
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN enrich_schedule_json TEXT NOT NULL DEFAULT ${disabledEnrich}`);
}
if (!names.has("created_at")) {
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`);
}
if (!names.has("updated_at")) {
this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`);
}
}
private columnNames(table: string): Set<string> {
const columns = this.sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
return new Set(columns.map((column) => column.name));
}
private ensureMetadataDefaults(): void {
const now = this.now();
const insertSource = this.sqlite.prepare(`
INSERT INTO metadata_source_config (provider, enabled, priority, api_key, created_at, updated_at)
VALUES (?, ?, ?, NULL, ?, ?)
ON CONFLICT(provider) DO NOTHING
`);
insertSource.run("local", 1, 0, now, now);
insertSource.run("openlibrary", this.config.openLibraryEnabled ? 1 : 0, 1, now, now);
insertSource.run("googlebooks", 0, 2, now, now);
insertSource.run("bnf", 0, 3, now, now);
insertSource.run("mangadex", 1, 4, now, now);
insertSource.run("comicvine", 0, 5, now, now);
this.sqlite
.prepare(
`
INSERT INTO automation_settings (
id, watch_libraries, auto_enrich_new_books, isbn_priority_enabled,
scan_schedule_json, enrich_schedule_json, created_at, updated_at
)
VALUES (1, 0, 1, 1, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
`
)
.run(
JSON.stringify({ frequency: "disabled", time: "03:00", dayOfWeek: 1 }),
JSON.stringify({ frequency: "disabled", time: "04:00", dayOfWeek: 1 }),
now,
now
);
}
}
function sqlString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}