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:
@ -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]
|
||||
})
|
||||
|
||||
180
apps/api/src/books/books.service.test.ts
Normal file
180
apps/api/src/books/books.service.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/api/src/books/series.controller.ts
Normal file
19
apps/api/src/books/series.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user