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:
@ -2,6 +2,7 @@ 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()
|
||||
@ -49,9 +50,20 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
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,
|
||||
@ -62,9 +74,15 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
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,
|
||||
@ -93,7 +111,7 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_source_config (
|
||||
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf')),
|
||||
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,
|
||||
@ -143,8 +161,11 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
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();
|
||||
@ -170,6 +191,7 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
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,
|
||||
@ -180,9 +202,15 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
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,
|
||||
@ -190,11 +218,13 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
);
|
||||
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
|
||||
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,
|
||||
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||
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;
|
||||
@ -238,8 +268,95 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
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 {
|
||||
@ -278,6 +395,31 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
@ -322,6 +464,8 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user