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; 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 { 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, "''")}'`; }