Recherche de fiches par nom peu fiable : sans ISBN, les providers étaient interrogés avec des titres bruts peu discriminants. - extraction de hints locaux (titre/auteur/année/isbn du fichier et du nom de fichier) persistés dans books.local_metadata_json - nouveau use-case ScoreMetadataMatch : tri des résultats par score de correspondance avant sélection du meilleur candidat - providers (google-books, open-library, bnf, local) durcis et nourris par les hints ; colonne + index local_metadata_json avec migrations idempotentes (création et rebuild legacy) - web : nettoyage de la description de la fiche livre (cleanBookDescription, white-space pre-line) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
349 lines
14 KiB
TypeScript
349 lines
14 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 * 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 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,
|
|
isbn13 TEXT,
|
|
identifiers_json TEXT,
|
|
local_metadata_json 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 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')),
|
|
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.ensureBooksMetadataColumns();
|
|
this.ensureReaderPreferencesTable();
|
|
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,
|
|
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,
|
|
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
|
|
);
|
|
INSERT INTO books (
|
|
id, library_id, title, author, description, isbn, isbn13, identifiers_json, local_metadata_json, language, publisher, published_date,
|
|
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
|
)
|
|
SELECT
|
|
id, library_id, title, author, description, isbn, NULL, NULL, NULL, language, publisher, published_date,
|
|
format, file_path, cover_path, 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");
|
|
}
|
|
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)");
|
|
}
|
|
|
|
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 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);
|
|
|
|
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, "''")}'`;
|
|
}
|