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

@ -3,10 +3,11 @@ import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { BooksController } from "./books.controller.js";
import { BooksService } from "./books.service.js";
import { SeriesController } from "./series.controller.js";
@Module({
imports: [AuthModule, DatabaseModule],
controllers: [BooksController],
controllers: [BooksController, SeriesController],
providers: [BooksService],
exports: [BooksService]
})

View File

@ -0,0 +1,180 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries, series } from "../database/schema.js";
import { BooksService } from "./books.service.js";
const previousDatabasePath = process.env.DATABASE_PATH;
const previousStorageDir = process.env.STORAGE_DIR;
const tempDirs: string[] = [];
afterEach(() => {
process.env.DATABASE_PATH = previousDatabasePath;
process.env.STORAGE_DIR = previousStorageDir;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("BooksService", () => {
it.runIf(canLoadBetterSqlite())("exposes metadata status and parsed provenance on book API rows", () => {
const database = createDatabase();
const service = new BooksService(database);
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const daredevil = database.db
.insert(series)
.values({
title: "Daredevil",
normalizedTitle: "daredevil",
description: "Collection Daredevil",
publisher: "Marvel",
createdAt: now,
updatedAt: now
})
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
seriesId: daredevil.id,
title: "Daredevil",
author: "Roy Thomas",
description: "Daredevil affronte une nouvelle menace.",
isbn: "9782809476255",
isbn13: "9782809476255",
identifiersJson: null,
localMetadataJson: null,
language: "fre",
publisher: "Panini comics",
publishedDate: "0101-01-01T00:00:00+00:00",
volumeNumber: 1,
volumeLabel: "001",
format: "cbz",
filePath: "/library/Daredevil.cbz",
coverPath: "/storage/covers/daredevil.jpg",
metadataStatus: "enriched",
metadataProvenanceJson: JSON.stringify({ title: "local", author: "bnf", coverPath: "openlibrary" }),
scanStatus: "succeeded",
enrichmentStatus: "succeeded",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
expect(service.get(book.id)).toMatchObject({
publishedDate: null,
seriesId: daredevil.id,
volumeNumber: 1,
volumeLabel: "001",
series: { id: daredevil.id, title: "Daredevil", normalizedTitle: "daredevil" },
metadataStatus: "enriched",
metadataProvenance: { title: "local", author: "bnf", coverPath: "openlibrary" }
});
expect(service.list({ limit: 50, offset: 0 })[0]).toMatchObject({
publishedDate: null,
seriesId: daredevil.id,
volumeNumber: 1,
volumeLabel: "001",
series: { id: daredevil.id, title: "Daredevil", normalizedTitle: "daredevil" },
metadataStatus: "enriched",
metadataProvenance: { title: "local", author: "bnf", coverPath: "openlibrary" }
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("lists a series with distinct books sharing the same volume", () => {
const database = createDatabase();
const service = new BooksService(database);
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const soloLeveling = database.db
.insert(series)
.values({
title: "Solo Leveling",
normalizedTitle: "solo leveling",
description: null,
publisher: null,
createdAt: now,
updatedAt: now
})
.returning()
.get();
for (const filePath of ["/library/Solo Leveling T03.cbz", "/library/Solo Leveling 003.cbz"]) {
database.db
.insert(books)
.values({
libraryId: library.id,
seriesId: soloLeveling.id,
title: filePath.includes("T03") ? "Solo Leveling T03" : "Solo Leveling 003",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: null,
volumeNumber: 3,
volumeLabel: filePath.includes("T03") ? "T03" : "003",
format: "cbz",
filePath,
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: JSON.stringify({ title: "local" }),
scanStatus: "succeeded",
enrichmentStatus: "idle",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.run();
}
const result = service.getSeries(soloLeveling.id);
expect(result).toMatchObject({ id: soloLeveling.id, title: "Solo Leveling", normalizedTitle: "solo leveling" });
expect(result.books).toHaveLength(2);
expect(result.books.map((book) => [book.title, book.volumeNumber])).toEqual([
["Solo Leveling 003", 3],
["Solo Leveling T03", 3]
]);
database.onModuleDestroy();
});
});
function createDatabase(): DatabaseService {
const dir = mkdtempSync(join(tmpdir(), "readabook-books-service-"));
tempDirs.push(dir);
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
process.env.STORAGE_DIR = join(dir, "storage");
return new DatabaseService();
}
function canLoadBetterSqlite(): boolean {
try {
const database = createDatabase();
database.onModuleDestroy();
return true;
} catch {
return false;
}
}

View File

@ -6,7 +6,8 @@ import { BookQueryDto } from "@readabook/shared";
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
import { DatabaseService } from "../database/database.service.js";
import { books } from "../database/schema.js";
import { books, series } from "../database/schema.js";
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
@Injectable()
export class BooksService {
@ -26,7 +27,8 @@ export class BooksService {
.orderBy(books.title)
.limit(query.limit)
.offset(query.offset)
.all();
.all()
.map((book) => this.mapBookSelect(book));
}
search(q: string, limit = 50, offset = 0) {
@ -42,19 +44,32 @@ export class BooksService {
`
)
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
return (rows as Array<Record<string, unknown>>).map(mapBookRow);
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
}
get(id: number) {
const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
if (!book) {
throw new NotFoundException("Book not found");
}
return book;
return this.mapBookSelect(this.getRecord(id));
}
listSeries() {
return this.database.db.select().from(series).orderBy(series.title).all();
}
getSeries(id: number) {
const row = this.database.db.select().from(series).where(eq(series.id, id)).get();
if (!row) throw new NotFoundException("Series not found");
const seriesBooks = this.database.db
.select()
.from(books)
.where(eq(books.seriesId, id))
.orderBy(books.volumeNumber, books.title)
.all()
.map((book) => this.mapBookSelect(book));
return { ...row, books: seriesBooks };
}
streamFile(id: number, range?: string) {
const book = this.get(id);
const book = this.getRecord(id);
if (!existsSync(book.filePath)) {
throw new NotFoundException("Book file not found on disk");
}
@ -72,7 +87,7 @@ export class BooksService {
}
streamCover(id: number) {
const book = this.get(id);
const book = this.getRecord(id);
if (!book.coverPath || !existsSync(book.coverPath)) {
throw new NotFoundException("Cover not found");
}
@ -80,7 +95,7 @@ export class BooksService {
}
async listComicPages(id: number) {
const book = this.get(id);
const book = this.getRecord(id);
this.assertComicArchiveBook(book);
const pages = book.format === "cbr" ? await listCbrImageEntries(book.filePath) : listCbzImageEntries(book.filePath);
return {
@ -91,7 +106,7 @@ export class BooksService {
}
async readComicPage(id: number, page: number) {
const book = this.get(id);
const book = this.getRecord(id);
this.assertComicArchiveBook(book);
try {
const result =
@ -108,6 +123,14 @@ export class BooksService {
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
}
private getRecord(id: number): typeof books.$inferSelect {
const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
if (!book) {
throw new NotFoundException("Book not found");
}
return book;
}
private assertComicArchiveBook(book: typeof books.$inferSelect): void {
if (book.format !== "cbz" && book.format !== "cbr") {
throw new BadRequestException("Book is not a comic archive");
@ -116,6 +139,51 @@ export class BooksService {
throw new NotFoundException("Book file not found on disk");
}
}
private mapBookRow(row: Record<string, unknown>) {
const seriesId = nullable(row.series_id);
return {
id: Number(row.id),
libraryId: Number(row.library_id),
seriesId: seriesId ? Number(seriesId) : null,
title: String(row.title),
author: nullable(row.author),
description: nullable(row.description),
isbn: nullable(row.isbn),
isbn13: nullable(row.isbn13),
language: nullable(row.language),
publisher: nullable(row.publisher),
publishedDate: normalizePublishedDate(nullable(row.published_date)),
volumeNumber: row.volume_number === null || row.volume_number === undefined ? null : Number(row.volume_number),
volumeLabel: nullable(row.volume_label),
format: row.format,
filePath: String(row.file_path),
coverPath: nullable(row.cover_path),
metadataStatus: metadataStatusValue(row.metadata_status),
metadataProvenance: parseObject(row.metadata_provenance_json),
series: seriesId ? this.getSeriesRecord(Number(seriesId)) : null,
scanStatus: statusValue(row.scan_status),
enrichmentStatus: statusValue(row.enrichment_status),
fileSize: Number(row.file_size),
fileMtime: String(row.file_mtime),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
};
}
private mapBookSelect(row: typeof books.$inferSelect) {
const { metadataProvenanceJson: _metadataProvenanceJson, ...book } = row;
return {
...book,
publishedDate: normalizePublishedDate(row.publishedDate),
metadataProvenance: parseObject(row.metadataProvenanceJson),
series: row.seriesId ? this.getSeriesRecord(row.seriesId) : null
};
}
private getSeriesRecord(id: number) {
return this.database.db.select().from(series).where(eq(series.id, id)).get() ?? null;
}
}
function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } {
@ -159,28 +227,25 @@ function lookupMime(entryName: string): string {
return "image/jpeg";
}
function mapBookRow(row: Record<string, unknown>) {
return {
id: Number(row.id),
libraryId: Number(row.library_id),
title: String(row.title),
author: nullable(row.author),
description: nullable(row.description),
isbn: nullable(row.isbn),
isbn13: nullable(row.isbn13),
language: nullable(row.language),
publisher: nullable(row.publisher),
publishedDate: nullable(row.published_date),
format: row.format,
filePath: String(row.file_path),
coverPath: nullable(row.cover_path),
fileSize: Number(row.file_size),
fileMtime: String(row.file_mtime),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at)
};
}
function nullable(value: unknown): string | null {
return value === null || value === undefined ? null : String(value);
}
function statusValue(value: unknown): "idle" | "running" | "succeeded" | "failed" {
return value === "running" || value === "succeeded" || value === "failed" ? value : "idle";
}
function metadataStatusValue(value: unknown): "enriched" | "partial" | "none" {
return value === "enriched" || value === "partial" ? value : "none";
}
function parseObject(value: unknown): Record<string, string> {
if (typeof value !== "string") return {};
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
} catch {
return {};
}
}

View File

@ -0,0 +1,19 @@
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard.js";
import { BooksService } from "./books.service.js";
@Controller("series")
@UseGuards(AuthGuard)
export class SeriesController {
constructor(private readonly books: BooksService) {}
@Get()
list() {
return this.books.listSeries();
}
@Get(":id")
get(@Param("id") id: string) {
return this.books.getSeries(Number(id));
}
}