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:
Git Agent
2026-08-24 09:11:10 +02:00
parent 34159e5e9b
commit 93bb40a8cd
24 changed files with 1481 additions and 76 deletions

View File

@ -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(