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 { DatabaseModule } from "../database/database.module.js";
|
||||||
import { BooksController } from "./books.controller.js";
|
import { BooksController } from "./books.controller.js";
|
||||||
import { BooksService } from "./books.service.js";
|
import { BooksService } from "./books.service.js";
|
||||||
|
import { SeriesController } from "./series.controller.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, DatabaseModule],
|
imports: [AuthModule, DatabaseModule],
|
||||||
controllers: [BooksController],
|
controllers: [BooksController, SeriesController],
|
||||||
providers: [BooksService],
|
providers: [BooksService],
|
||||||
exports: [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 { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
|
||||||
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
|
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
|
||||||
import { DatabaseService } from "../database/database.service.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()
|
@Injectable()
|
||||||
export class BooksService {
|
export class BooksService {
|
||||||
@ -26,7 +27,8 @@ export class BooksService {
|
|||||||
.orderBy(books.title)
|
.orderBy(books.title)
|
||||||
.limit(query.limit)
|
.limit(query.limit)
|
||||||
.offset(query.offset)
|
.offset(query.offset)
|
||||||
.all();
|
.all()
|
||||||
|
.map((book) => this.mapBookSelect(book));
|
||||||
}
|
}
|
||||||
|
|
||||||
search(q: string, limit = 50, offset = 0) {
|
search(q: string, limit = 50, offset = 0) {
|
||||||
@ -42,19 +44,32 @@ export class BooksService {
|
|||||||
`
|
`
|
||||||
)
|
)
|
||||||
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
|
.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) {
|
get(id: number) {
|
||||||
const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
|
return this.mapBookSelect(this.getRecord(id));
|
||||||
if (!book) {
|
|
||||||
throw new NotFoundException("Book not found");
|
|
||||||
}
|
}
|
||||||
return book;
|
|
||||||
|
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) {
|
streamFile(id: number, range?: string) {
|
||||||
const book = this.get(id);
|
const book = this.getRecord(id);
|
||||||
if (!existsSync(book.filePath)) {
|
if (!existsSync(book.filePath)) {
|
||||||
throw new NotFoundException("Book file not found on disk");
|
throw new NotFoundException("Book file not found on disk");
|
||||||
}
|
}
|
||||||
@ -72,7 +87,7 @@ export class BooksService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
streamCover(id: number) {
|
streamCover(id: number) {
|
||||||
const book = this.get(id);
|
const book = this.getRecord(id);
|
||||||
if (!book.coverPath || !existsSync(book.coverPath)) {
|
if (!book.coverPath || !existsSync(book.coverPath)) {
|
||||||
throw new NotFoundException("Cover not found");
|
throw new NotFoundException("Cover not found");
|
||||||
}
|
}
|
||||||
@ -80,7 +95,7 @@ export class BooksService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listComicPages(id: number) {
|
async listComicPages(id: number) {
|
||||||
const book = this.get(id);
|
const book = this.getRecord(id);
|
||||||
this.assertComicArchiveBook(book);
|
this.assertComicArchiveBook(book);
|
||||||
const pages = book.format === "cbr" ? await listCbrImageEntries(book.filePath) : listCbzImageEntries(book.filePath);
|
const pages = book.format === "cbr" ? await listCbrImageEntries(book.filePath) : listCbzImageEntries(book.filePath);
|
||||||
return {
|
return {
|
||||||
@ -91,7 +106,7 @@ export class BooksService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async readComicPage(id: number, page: number) {
|
async readComicPage(id: number, page: number) {
|
||||||
const book = this.get(id);
|
const book = this.getRecord(id);
|
||||||
this.assertComicArchiveBook(book);
|
this.assertComicArchiveBook(book);
|
||||||
try {
|
try {
|
||||||
const result =
|
const result =
|
||||||
@ -108,6 +123,14 @@ export class BooksService {
|
|||||||
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
|
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 {
|
private assertComicArchiveBook(book: typeof books.$inferSelect): void {
|
||||||
if (book.format !== "cbz" && book.format !== "cbr") {
|
if (book.format !== "cbz" && book.format !== "cbr") {
|
||||||
throw new BadRequestException("Book is not a comic archive");
|
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");
|
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 } {
|
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";
|
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 {
|
function nullable(value: unknown): string | null {
|
||||||
return value === null || value === undefined ? null : String(value);
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { DatabaseService } from "./database.service.js";
|
import { DatabaseService } from "./database.service.js";
|
||||||
|
import { books, libraries, series } from "./schema.js";
|
||||||
|
|
||||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
const previousStorageDir = process.env.STORAGE_DIR;
|
const previousStorageDir = process.env.STORAGE_DIR;
|
||||||
@ -25,6 +26,7 @@ describe("database migrations", () => {
|
|||||||
const storageDir = join(dir, "storage");
|
const storageDir = join(dir, "storage");
|
||||||
|
|
||||||
const legacy = new Database(databasePath);
|
const legacy = new Database(databasePath);
|
||||||
|
const now = new Date().toISOString();
|
||||||
legacy.exec(`
|
legacy.exec(`
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@ -84,6 +86,17 @@ describe("database migrations", () => {
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
INSERT INTO libraries (id, name, path, enabled, created_at, updated_at)
|
||||||
|
VALUES (1, 'Corpus', '/library', 1, '${now}', '${now}');
|
||||||
|
INSERT INTO books (
|
||||||
|
library_id, title, author, description, isbn, language, publisher, published_date,
|
||||||
|
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(1, 'Solo Leveling T03', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Solo Leveling T03.cbz', NULL, 42, '${now}', '${now}', '${now}'),
|
||||||
|
(1, 'Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz', NULL, 42, '${now}', '${now}', '${now}'),
|
||||||
|
(1, 'Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz', NULL, 42, '${now}', '${now}', '${now}');
|
||||||
`);
|
`);
|
||||||
legacy.close();
|
legacy.close();
|
||||||
|
|
||||||
@ -94,18 +107,130 @@ describe("database migrations", () => {
|
|||||||
const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
|
const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
|
||||||
const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>;
|
const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>;
|
||||||
const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>;
|
const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>;
|
||||||
|
const seriesRows = database.sqlite
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT books.title, books.volume_number, books.volume_label, series.title AS series_title, series.normalized_title
|
||||||
|
FROM books
|
||||||
|
JOIN series ON series.id = books.series_id
|
||||||
|
ORDER BY books.title
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all() as Array<{
|
||||||
|
title: string;
|
||||||
|
volume_number: number | null;
|
||||||
|
volume_label: string | null;
|
||||||
|
series_title: string;
|
||||||
|
normalized_title: string;
|
||||||
|
}>;
|
||||||
const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as
|
const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as
|
||||||
| { id: number; isbn_priority_enabled: number }
|
| { id: number; isbn_priority_enabled: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
expect(bookColumns.map((column) => column.name)).toContain("isbn13");
|
expect(bookColumns.map((column) => column.name)).toContain("isbn13");
|
||||||
expect(bookColumns.map((column) => column.name)).toContain("identifiers_json");
|
expect(bookColumns.map((column) => column.name)).toContain("identifiers_json");
|
||||||
|
expect(bookColumns.map((column) => column.name)).toContain("scan_status");
|
||||||
|
expect(bookColumns.map((column) => column.name)).toContain("enrichment_status");
|
||||||
|
expect(bookColumns.map((column) => column.name)).toContain("series_id");
|
||||||
|
expect(bookColumns.map((column) => column.name)).toContain("volume_number");
|
||||||
|
expect(bookColumns.map((column) => column.name)).toContain("volume_label");
|
||||||
expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx");
|
expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx");
|
||||||
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf"]);
|
expect(bookIndexes.map((index) => index.name)).toContain("books_series_idx");
|
||||||
|
expect(seriesRows).toEqual([
|
||||||
|
{
|
||||||
|
title: "Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+",
|
||||||
|
volume_number: 1,
|
||||||
|
volume_label: "T01",
|
||||||
|
series_title: "Dragon Ball SD",
|
||||||
|
normalized_title: "dragon ball sd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz",
|
||||||
|
volume_number: 1,
|
||||||
|
volume_label: "T01",
|
||||||
|
series_title: "Eyeshield 21",
|
||||||
|
normalized_title: "eyeshield 21"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Solo Leveling T03",
|
||||||
|
volume_number: 3,
|
||||||
|
volume_label: "T03",
|
||||||
|
series_title: "Solo Leveling",
|
||||||
|
normalized_title: "solo leveling"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]);
|
||||||
expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 });
|
expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 });
|
||||||
|
|
||||||
database.onModuleDestroy();
|
database.onModuleDestroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.runIf(canLoadBetterSqlite())("backfills missing Daredevil volume numbers when series already exists", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "readabook-series-backfill-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
|
||||||
|
process.env.STORAGE_DIR = join(dir, "storage");
|
||||||
|
|
||||||
|
const first = new DatabaseService();
|
||||||
|
const now = first.now();
|
||||||
|
const library = first.db
|
||||||
|
.insert(libraries)
|
||||||
|
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
const daredevil = first.db
|
||||||
|
.insert(series)
|
||||||
|
.values({
|
||||||
|
title: "Daredevil",
|
||||||
|
normalizedTitle: "daredevil",
|
||||||
|
description: null,
|
||||||
|
publisher: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
first.db
|
||||||
|
.insert(books)
|
||||||
|
.values({
|
||||||
|
libraryId: library.id,
|
||||||
|
seriesId: daredevil.id,
|
||||||
|
title: "Daredevil",
|
||||||
|
author: null,
|
||||||
|
description: null,
|
||||||
|
isbn: null,
|
||||||
|
isbn13: null,
|
||||||
|
identifiersJson: null,
|
||||||
|
localMetadataJson: null,
|
||||||
|
language: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedDate: null,
|
||||||
|
volumeNumber: null,
|
||||||
|
volumeLabel: null,
|
||||||
|
format: "cbz",
|
||||||
|
filePath: "/library/Daredevil - 001[Sebmov].cbz",
|
||||||
|
coverPath: null,
|
||||||
|
metadataStatus: "none",
|
||||||
|
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
||||||
|
scanStatus: "succeeded",
|
||||||
|
enrichmentStatus: "idle",
|
||||||
|
fileSize: 42,
|
||||||
|
fileMtime: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
first.onModuleDestroy();
|
||||||
|
|
||||||
|
const second = new DatabaseService();
|
||||||
|
const row = second.sqlite.prepare("SELECT volume_number, volume_label FROM books WHERE file_path = ?").get(
|
||||||
|
"/library/Daredevil - 001[Sebmov].cbz"
|
||||||
|
) as { volume_number: number | null; volume_label: string | null };
|
||||||
|
|
||||||
|
expect(row).toEqual({ volume_number: 1, volume_label: "001" });
|
||||||
|
|
||||||
|
second.onModuleDestroy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function canLoadBetterSqlite(): boolean {
|
function canLoadBetterSqlite(): boolean {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Injectable, OnModuleDestroy } from "@nestjs/common";
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
|
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
import { AppConfig, loadConfig } from "../config/env.js";
|
import { AppConfig, loadConfig } from "../config/env.js";
|
||||||
|
import { extractSeriesVolume } from "../metadata/use-cases/extract-series-volume.js";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -49,9 +50,20 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
updated_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 (
|
CREATE TABLE IF NOT EXISTS books (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
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,
|
title TEXT NOT NULL,
|
||||||
author TEXT,
|
author TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@ -62,9 +74,15 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
language TEXT,
|
language TEXT,
|
||||||
publisher TEXT,
|
publisher TEXT,
|
||||||
published_date TEXT,
|
published_date TEXT,
|
||||||
|
volume_number INTEGER,
|
||||||
|
volume_label TEXT,
|
||||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
||||||
file_path TEXT NOT NULL UNIQUE,
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
cover_path TEXT,
|
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_size INTEGER NOT NULL,
|
||||||
file_mtime TEXT NOT NULL,
|
file_mtime TEXT NOT NULL,
|
||||||
created_at 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 (
|
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,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
priority INTEGER NOT NULL,
|
priority INTEGER NOT NULL,
|
||||||
api_key TEXT,
|
api_key TEXT,
|
||||||
@ -143,8 +161,11 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
END;
|
END;
|
||||||
`);
|
`);
|
||||||
this.ensureBooksSupportsComicArchives();
|
this.ensureBooksSupportsComicArchives();
|
||||||
|
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
|
||||||
this.ensureBooksMetadataColumns();
|
this.ensureBooksMetadataColumns();
|
||||||
|
this.ensureSeriesModel();
|
||||||
this.ensureReaderPreferencesTable();
|
this.ensureReaderPreferencesTable();
|
||||||
|
this.ensureMetadataSourceConfigSupportsComicProviders();
|
||||||
this.ensureMetadataSourceConfigColumns();
|
this.ensureMetadataSourceConfigColumns();
|
||||||
this.ensureAutomationSettingsColumns();
|
this.ensureAutomationSettingsColumns();
|
||||||
this.ensureMetadataDefaults();
|
this.ensureMetadataDefaults();
|
||||||
@ -170,6 +191,7 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
CREATE TABLE books (
|
CREATE TABLE books (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
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,
|
title TEXT NOT NULL,
|
||||||
author TEXT,
|
author TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@ -180,9 +202,15 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
language TEXT,
|
language TEXT,
|
||||||
publisher TEXT,
|
publisher TEXT,
|
||||||
published_date TEXT,
|
published_date TEXT,
|
||||||
|
volume_number INTEGER,
|
||||||
|
volume_label TEXT,
|
||||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
||||||
file_path TEXT NOT NULL UNIQUE,
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
cover_path TEXT,
|
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_size INTEGER NOT NULL,
|
||||||
file_mtime TEXT NOT NULL,
|
file_mtime TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
@ -190,11 +218,13 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
INSERT INTO books (
|
INSERT INTO books (
|
||||||
id, library_id, title, author, description, isbn, isbn13, identifiers_json, local_metadata_json, language, publisher, published_date,
|
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
|
SELECT
|
||||||
id, library_id, title, author, description, isbn, NULL, NULL, NULL, language, publisher, published_date,
|
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;
|
FROM books_legacy_format;
|
||||||
DROP TABLE books_legacy_format;
|
DROP TABLE books_legacy_format;
|
||||||
COMMIT;
|
COMMIT;
|
||||||
@ -238,8 +268,95 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
if (!names.has("local_metadata_json")) {
|
if (!names.has("local_metadata_json")) {
|
||||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN local_metadata_json TEXT");
|
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_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_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 {
|
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 {
|
private ensureAutomationSettingsColumns(): void {
|
||||||
const names = this.columnNames("automation_settings");
|
const names = this.columnNames("automation_settings");
|
||||||
const now = sqlString(this.now());
|
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("openlibrary", this.config.openLibraryEnabled ? 1 : 0, 1, now, now);
|
||||||
insertSource.run("googlebooks", 0, 2, now, now);
|
insertSource.run("googlebooks", 0, 2, now, now);
|
||||||
insertSource.run("bnf", 0, 3, 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
|
this.sqlite
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@ -23,6 +23,20 @@ export const libraries = sqliteTable("libraries", {
|
|||||||
updatedAt: text("updated_at").notNull()
|
updatedAt: text("updated_at").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const series = sqliteTable(
|
||||||
|
"series",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
normalizedTitle: text("normalized_title").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
publisher: text("publisher"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
updatedAt: text("updated_at").notNull()
|
||||||
|
},
|
||||||
|
(table) => ({ normalizedTitleIdx: uniqueIndex("series_normalized_title_unique").on(table.normalizedTitle) })
|
||||||
|
);
|
||||||
|
|
||||||
export const books = sqliteTable(
|
export const books = sqliteTable(
|
||||||
"books",
|
"books",
|
||||||
{
|
{
|
||||||
@ -30,6 +44,7 @@ export const books = sqliteTable(
|
|||||||
libraryId: integer("library_id")
|
libraryId: integer("library_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => libraries.id, { onDelete: "cascade" }),
|
.references(() => libraries.id, { onDelete: "cascade" }),
|
||||||
|
seriesId: integer("series_id").references(() => series.id, { onDelete: "set null" }),
|
||||||
title: text("title").notNull(),
|
title: text("title").notNull(),
|
||||||
author: text("author"),
|
author: text("author"),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
@ -40,9 +55,15 @@ export const books = sqliteTable(
|
|||||||
language: text("language"),
|
language: text("language"),
|
||||||
publisher: text("publisher"),
|
publisher: text("publisher"),
|
||||||
publishedDate: text("published_date"),
|
publishedDate: text("published_date"),
|
||||||
|
volumeNumber: integer("volume_number"),
|
||||||
|
volumeLabel: text("volume_label"),
|
||||||
format: text("format", { enum: ["epub", "pdf", "cbz", "cbr"] }).notNull(),
|
format: text("format", { enum: ["epub", "pdf", "cbz", "cbr"] }).notNull(),
|
||||||
filePath: text("file_path").notNull(),
|
filePath: text("file_path").notNull(),
|
||||||
coverPath: text("cover_path"),
|
coverPath: text("cover_path"),
|
||||||
|
metadataStatus: text("metadata_status", { enum: ["enriched", "partial", "none"] }).notNull().default("none"),
|
||||||
|
metadataProvenanceJson: text("metadata_provenance_json"),
|
||||||
|
scanStatus: text("scan_status", { enum: ["idle", "running", "succeeded", "failed"] }).notNull().default("idle"),
|
||||||
|
enrichmentStatus: text("enrichment_status", { enum: ["idle", "running", "succeeded", "failed"] }).notNull().default("idle"),
|
||||||
fileSize: integer("file_size").notNull(),
|
fileSize: integer("file_size").notNull(),
|
||||||
fileMtime: text("file_mtime").notNull(),
|
fileMtime: text("file_mtime").notNull(),
|
||||||
createdAt: text("created_at").notNull(),
|
createdAt: text("created_at").notNull(),
|
||||||
@ -98,7 +119,7 @@ export const jobs = sqliteTable("jobs", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadataSourceConfig = sqliteTable("metadata_source_config", {
|
export const metadataSourceConfig = sqliteTable("metadata_source_config", {
|
||||||
provider: text("provider", { enum: ["local", "openlibrary", "googlebooks", "bnf"] }).primaryKey(),
|
provider: text("provider", { enum: ["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"] }).primaryKey(),
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
apiKey: text("api_key"),
|
apiKey: text("api_key"),
|
||||||
|
|||||||
43
apps/api/src/metadata/extract-series-volume.test.ts
Normal file
43
apps/api/src/metadata/extract-series-volume.test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { extractSeriesVolume, normalizeSeriesTitle } from "./use-cases/extract-series-volume.js";
|
||||||
|
|
||||||
|
describe("extractSeriesVolume", () => {
|
||||||
|
it.each([
|
||||||
|
["Daredevil 001.cbz", "Daredevil", 1, "001"],
|
||||||
|
["Daredevil 002.cbz", "Daredevil", 2, "002"],
|
||||||
|
["Daredevil - 001[Sebmov].cbz", "Daredevil", 1, "001"],
|
||||||
|
["DareDevil - 007[Fennlhor].cbz", "DareDevil", 7, "007"],
|
||||||
|
["Solo Leveling T03.cbz", "Solo Leveling", 3, "T03"],
|
||||||
|
["Solo Leveling 003.cbz", "Solo Leveling", 3, "003"],
|
||||||
|
["Solo Leveling Tome 3.cbz", "Solo Leveling", 3, "Tome 3"],
|
||||||
|
["Solo Leveling Vol. 3.cbz", "Solo Leveling", 3, "Vol 3"],
|
||||||
|
["Daredevil Issue 6.cbz", "Daredevil", 6, "Issue 6"],
|
||||||
|
["Daredevil #6.cbz", "Daredevil", 6, "#6"],
|
||||||
|
["Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz", "Eyeshield 21", 1, "T01"],
|
||||||
|
["Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz", "Dragon Ball SD", 1, "T01"],
|
||||||
|
["Demon.Slayer.School.Days.T01.FRENCH.CBZ.eBook-ebdz.cbz", "Demon Slayer School Days", 1, "T01"]
|
||||||
|
])("extracts series and volume from %s", (fileName, seriesTitle, volumeNumber, volumeLabel) => {
|
||||||
|
expect(extractSeriesVolume(seriesTitle, `/books/${fileName}`)).toMatchObject({
|
||||||
|
seriesTitle,
|
||||||
|
normalizedSeriesTitle: normalizeSeriesTitle(seriesTitle),
|
||||||
|
volumeNumber,
|
||||||
|
volumeLabel
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps numeric title components that are not explicit volume markers", () => {
|
||||||
|
expect(extractSeriesVolume("Eyeshield 21")).toMatchObject({
|
||||||
|
seriesTitle: "Eyeshield 21",
|
||||||
|
normalizedSeriesTitle: "eyeshield 21",
|
||||||
|
volumeNumber: null,
|
||||||
|
volumeLabel: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fuzzy-merge distinct normalized series titles", () => {
|
||||||
|
expect(normalizeSeriesTitle("Dragon Ball SD")).toBe("dragon ball sd");
|
||||||
|
expect(normalizeSeriesTitle("Dragon Ball")).toBe("dragon ball");
|
||||||
|
expect(normalizeSeriesTitle("Lord of the Mysteries")).toBe("lord of the mysteries");
|
||||||
|
expect(normalizeSeriesTitle("The Lord of the Rings")).toBe("the lord of the rings");
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/api/src/metadata/use-cases/extract-series-volume.ts
Normal file
67
apps/api/src/metadata/use-cases/extract-series-volume.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { basename, extname } from "node:path";
|
||||||
|
|
||||||
|
export type SeriesVolume = {
|
||||||
|
seriesTitle: string;
|
||||||
|
normalizedSeriesTitle: string;
|
||||||
|
volumeNumber: number | null;
|
||||||
|
volumeLabel: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractSeriesVolume(title: string, filePath?: string | null): SeriesVolume {
|
||||||
|
const source = cleanSeriesSource(filePath ? basename(filePath, extname(filePath)) : title) || cleanSeriesSource(title) || title;
|
||||||
|
const explicit = source.match(/\b(?:T(?:ome)?|Vol(?:ume)?\.?|Issue|No\.?)\s*0*(\d{1,4})\b/i) ?? source.match(/#\s*0*(\d{1,4})\b/);
|
||||||
|
if (explicit?.[1]) {
|
||||||
|
return result(source.replace(explicit[0], " "), Number(explicit[1]), explicit[0].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const padded = source.match(/\b(0{1,3}\d{1,4})\b\s*$/);
|
||||||
|
if (padded?.[1]) {
|
||||||
|
return result(source.slice(0, padded.index).trim(), Number(padded[1]), padded[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result(source, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(seriesTitle: string, volumeNumber: number | null, volumeLabel: string | null): SeriesVolume {
|
||||||
|
const title = cleanSeriesTitle(seriesTitle);
|
||||||
|
return {
|
||||||
|
seriesTitle: title,
|
||||||
|
normalizedSeriesTitle: normalizeSeriesTitle(title),
|
||||||
|
volumeNumber: Number.isFinite(volumeNumber) && volumeNumber !== null ? volumeNumber : null,
|
||||||
|
volumeLabel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanSeriesSource(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\.[A-Za-z0-9]{2,5}$/g, " ")
|
||||||
|
.replace(/\[[^\]]*\]/g, " ")
|
||||||
|
.replace(/[._]+/g, " ")
|
||||||
|
.replace(/\b(FRENCH|TRUEFRENCH|MULTI|CBZ|CBR|EPUB|PDF|eBook|ebook|scan|digital|retail)\b/gi, " ")
|
||||||
|
.replace(/\b(e?bdz|Paprika\+?|emuleCenter(?:\.|\s+)net)\b/gi, " ")
|
||||||
|
.replace(/[+]+/g, " ")
|
||||||
|
.replace(/\s+-\s+/g, " ")
|
||||||
|
.replace(/\s*-\s*$/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanSeriesTitle(value: string): string {
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
.replace(/\([^)]*\)/g, " ")
|
||||||
|
.replace(/\bby\s+[A-Za-z0-9À-ÖØ-öø-ÿ.' -]{2,80}$/i, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim() || "Untitled Series"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSeriesTitle(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import { LoginPage } from "./pages/LoginPage";
|
|||||||
import { ProfilePage } from "./pages/ProfilePage";
|
import { ProfilePage } from "./pages/ProfilePage";
|
||||||
import { ReaderPage } from "./pages/ReaderPage";
|
import { ReaderPage } from "./pages/ReaderPage";
|
||||||
import { SearchPage } from "./pages/SearchPage";
|
import { SearchPage } from "./pages/SearchPage";
|
||||||
|
import { SeriesPage } from "./pages/SeriesPage";
|
||||||
import { SetupPage } from "./pages/SetupPage";
|
import { SetupPage } from "./pages/SetupPage";
|
||||||
import { navigate, parseRoute, type Route } from "./router";
|
import { navigate, parseRoute, type Route } from "./router";
|
||||||
|
|
||||||
@ -24,6 +25,8 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
|
|||||||
<HomePage />
|
<HomePage />
|
||||||
) : route.name === "library" ? (
|
) : route.name === "library" ? (
|
||||||
<LibraryPage libraryId={route.libraryId} />
|
<LibraryPage libraryId={route.libraryId} />
|
||||||
|
) : route.name === "catalogSeries" ? (
|
||||||
|
<SeriesPage seriesName={route.seriesName} />
|
||||||
) : route.name === "book" ? (
|
) : route.name === "book" ? (
|
||||||
<BookPage bookId={route.bookId} />
|
<BookPage bookId={route.bookId} />
|
||||||
) : route.name === "reader" ? (
|
) : route.name === "reader" ? (
|
||||||
|
|||||||
@ -3,6 +3,25 @@ import type { ContinueItem } from "./types";
|
|||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
type BookPipelineStatus = "idle" | "running" | "succeeded" | "failed";
|
||||||
|
type BookMetadataStatus = "enriched" | "partial" | "none";
|
||||||
|
type MockBookDto = Omit<BookDto, "metadataStatus" | "metadataProvenance" | "scanStatus" | "enrichmentStatus"> & {
|
||||||
|
metadataStatus?: BookMetadataStatus;
|
||||||
|
metadataProvenance?: Record<string, string>;
|
||||||
|
scanStatus?: BookPipelineStatus;
|
||||||
|
enrichmentStatus?: BookPipelineStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockBook(book: MockBookDto): BookDto {
|
||||||
|
return {
|
||||||
|
metadataStatus: "partial",
|
||||||
|
metadataProvenance: { local: "fixture" },
|
||||||
|
scanStatus: "idle",
|
||||||
|
enrichmentStatus: "idle",
|
||||||
|
...book
|
||||||
|
} as BookDto;
|
||||||
|
}
|
||||||
|
|
||||||
export const mockUser: UserDto = {
|
export const mockUser: UserDto = {
|
||||||
id: 1,
|
id: 1,
|
||||||
email: "admin@readabook.local",
|
email: "admin@readabook.local",
|
||||||
@ -17,7 +36,7 @@ export const mockLibraries: LibraryDto[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const mockBooks: BookDto[] = [
|
export const mockBooks: BookDto[] = [
|
||||||
{
|
mockBook({
|
||||||
id: 1,
|
id: 1,
|
||||||
libraryId: 1,
|
libraryId: 1,
|
||||||
title: "L'Herbier des machines",
|
title: "L'Herbier des machines",
|
||||||
@ -35,8 +54,8 @@ export const mockBooks: BookDto[] = [
|
|||||||
fileMtime: now,
|
fileMtime: now,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
},
|
}),
|
||||||
{
|
mockBook({
|
||||||
id: 2,
|
id: 2,
|
||||||
libraryId: 2,
|
libraryId: 2,
|
||||||
title: "Cartographie des songes",
|
title: "Cartographie des songes",
|
||||||
@ -54,8 +73,8 @@ export const mockBooks: BookDto[] = [
|
|||||||
fileMtime: now,
|
fileMtime: now,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
},
|
}),
|
||||||
{
|
mockBook({
|
||||||
id: 3,
|
id: 3,
|
||||||
libraryId: 2,
|
libraryId: 2,
|
||||||
title: "Les vitrines de verre",
|
title: "Les vitrines de verre",
|
||||||
@ -73,8 +92,8 @@ export const mockBooks: BookDto[] = [
|
|||||||
fileMtime: now,
|
fileMtime: now,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
},
|
}),
|
||||||
{
|
mockBook({
|
||||||
id: 4,
|
id: 4,
|
||||||
libraryId: 2,
|
libraryId: 2,
|
||||||
title: "Cabinet noir",
|
title: "Cabinet noir",
|
||||||
@ -92,7 +111,7 @@ export const mockBooks: BookDto[] = [
|
|||||||
fileMtime: now,
|
fileMtime: now,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
}
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
export const mockProgress: ProgressDto[] = [
|
export const mockProgress: ProgressDto[] = [
|
||||||
|
|||||||
@ -36,3 +36,17 @@ export type ReaderPreferencesDto = {
|
|||||||
mode: ReaderMode;
|
mode: ReaderMode;
|
||||||
fit?: ReaderFit;
|
fit?: ReaderFit;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function hasActiveCoverWork(jobs: JobDto[]) {
|
||||||
|
return jobs.some((job) => {
|
||||||
|
if (job.status !== "queued" && job.status !== "running") return false;
|
||||||
|
const type = job.type.toLowerCase();
|
||||||
|
return type.includes("scan") || type.includes("enrich") || type.includes("metadata") || type.includes("cover");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBookCoverUpdating(book: BookDto, fallbackActive = false) {
|
||||||
|
const statuses = book as BookDto & { scanStatus?: string; enrichmentStatus?: string };
|
||||||
|
if (statuses.scanStatus === "running" || statuses.enrichmentStatus === "running") return true;
|
||||||
|
return statuses.scanStatus === undefined && statuses.enrichmentStatus === undefined && fallbackActive;
|
||||||
|
}
|
||||||
|
|||||||
131
apps/web/src/book/metadata.test.ts
Normal file
131
apps/web/src/book/metadata.test.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { BookDto } from "@readabook/shared";
|
||||||
|
import {
|
||||||
|
bookCardVolumeLabel,
|
||||||
|
bookDisplayTitle,
|
||||||
|
bookMetadataSourceSummary,
|
||||||
|
bookMetadataStateLabel,
|
||||||
|
bookSeriesInfo,
|
||||||
|
bookSeriesLabel,
|
||||||
|
bookVolumeLabel,
|
||||||
|
displayPublishedDate,
|
||||||
|
jobDigestSummary
|
||||||
|
} from "./metadata";
|
||||||
|
|
||||||
|
type BookFixture = BookDto & {
|
||||||
|
metadataStatus: "enriched" | "partial" | "none";
|
||||||
|
metadataProvenance: Record<string, string>;
|
||||||
|
scanStatus: "idle" | "running" | "succeeded" | "failed";
|
||||||
|
enrichmentStatus: "idle" | "running" | "succeeded" | "failed";
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseBook: BookFixture = {
|
||||||
|
id: 1,
|
||||||
|
libraryId: 1,
|
||||||
|
title: "Livre test",
|
||||||
|
author: null,
|
||||||
|
description: null,
|
||||||
|
isbn: null,
|
||||||
|
isbn13: null,
|
||||||
|
language: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedDate: null,
|
||||||
|
format: "epub",
|
||||||
|
filePath: "/books/test.epub",
|
||||||
|
coverPath: null,
|
||||||
|
metadataStatus: "none",
|
||||||
|
metadataProvenance: {},
|
||||||
|
scanStatus: "idle",
|
||||||
|
enrichmentStatus: "idle",
|
||||||
|
fileSize: 1,
|
||||||
|
fileMtime: "2026-08-23T00:00:00.000Z",
|
||||||
|
createdAt: "2026-08-23T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-23T00:00:00.000Z"
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("book metadata presentation", () => {
|
||||||
|
it("labels externally enriched books", () => {
|
||||||
|
const book: BookFixture = { ...baseBook, metadataStatus: "enriched", enrichmentStatus: "succeeded" };
|
||||||
|
expect(bookMetadataStateLabel(book)).toBe("enrichi");
|
||||||
|
expect(bookMetadataSourceSummary(book)).toBe("source locale + enrichissement externe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels locally discovered metadata as partial", () => {
|
||||||
|
const book: BookFixture = { ...baseBook, metadataStatus: "partial", author: "Ada", publishedDate: "1998", scanStatus: "succeeded" };
|
||||||
|
expect(bookMetadataStateLabel(book)).toBe("partiel");
|
||||||
|
expect(bookMetadataSourceSummary(book)).toBe("source locale uniquement");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels books without exploitable metadata as missing", () => {
|
||||||
|
expect(bookMetadataStateLabel(baseBook)).toBe("non enrichi");
|
||||||
|
expect(bookMetadataSourceSummary(baseBook)).toBe("metadata indisponible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts optional series fields when the backend exposes them", () => {
|
||||||
|
const book = { ...baseBook, title: "Nom fichier", series: "Cycle", volumeNumber: 2 } as BookDto & { series: string; volumeNumber: number };
|
||||||
|
expect(bookSeriesLabel(book)).toBe("Cycle · Volume 2");
|
||||||
|
expect(bookDisplayTitle(book)).toBe("Cycle");
|
||||||
|
expect(bookVolumeLabel(book)).toBe("Volume 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses backend series objects and normalized backend volume labels", () => {
|
||||||
|
const book = {
|
||||||
|
...baseBook,
|
||||||
|
title: "Daredevil",
|
||||||
|
series: { id: 1, title: "Daredevil", normalizedTitle: "daredevil", description: null, publisher: null, createdAt: baseBook.createdAt, updatedAt: baseBook.updatedAt },
|
||||||
|
volumeNumber: 1,
|
||||||
|
volumeLabel: "001"
|
||||||
|
} as BookDto;
|
||||||
|
expect(bookDisplayTitle(book)).toBe("Daredevil");
|
||||||
|
expect(bookVolumeLabel(book)).toBe("#1");
|
||||||
|
expect(bookSeriesLabel(book)).toBe("Daredevil · #1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses compact and unambiguous volume labels on book cards", () => {
|
||||||
|
expect(bookCardVolumeLabel({ ...baseBook, title: "Solo Leveling T03" })).toBe("T. 3");
|
||||||
|
expect(bookCardVolumeLabel({ ...baseBook, title: "Archive Volume 12" })).toBe("T. 12");
|
||||||
|
expect(bookCardVolumeLabel({ ...baseBook, title: "Daredevil #6" })).toBe("#6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides book card volume labels when the number is absent or ambiguous", () => {
|
||||||
|
const ambiguousBook = { ...baseBook, title: "Nom fichier", series: "Cycle", volumeLabel: "Tome final" } as BookDto & { series: string; volumeLabel: string };
|
||||||
|
expect(bookCardVolumeLabel(ambiguousBook)).toBeNull();
|
||||||
|
expect(bookCardVolumeLabel({ ...baseBook, title: "Livre sans tome" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps admin job digest synthetic", () => {
|
||||||
|
expect(
|
||||||
|
jobDigestSummary({
|
||||||
|
id: 1,
|
||||||
|
type: "metadata-enrich",
|
||||||
|
status: "succeeded",
|
||||||
|
detail: null,
|
||||||
|
error: null,
|
||||||
|
createdAt: baseBook.createdAt,
|
||||||
|
updatedAt: baseBook.updatedAt
|
||||||
|
})
|
||||||
|
).toBe("enrichissement externe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides sentinel and absent publication dates", () => {
|
||||||
|
expect(displayPublishedDate("0101-01-01T00:00:00+00:00")).toBeNull();
|
||||||
|
expect(displayPublishedDate(null)).toBeNull();
|
||||||
|
expect(displayPublishedDate("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders only the credible publication year", () => {
|
||||||
|
expect(displayPublishedDate("2007")).toBe("2007");
|
||||||
|
expect(displayPublishedDate("2007-07-21T00:00:00+00:00")).toBe("2007");
|
||||||
|
expect(displayPublishedDate("first published in 1998")).toBe("1998");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes flexible series and volume suffixes from titles", () => {
|
||||||
|
expect(bookSeriesInfo({ ...baseBook, title: "Daredevil 001" })).toEqual({ title: "Daredevil", volumeLabel: "#1", volumeNumber: 1 });
|
||||||
|
expect(bookSeriesInfo({ ...baseBook, title: "Daredevil #6" })).toEqual({ title: "Daredevil", volumeLabel: "#6", volumeNumber: 6 });
|
||||||
|
expect(bookSeriesInfo({ ...baseBook, title: "Solo Leveling T03" })).toEqual({ title: "Solo Leveling", volumeLabel: "Tome 3", volumeNumber: 3 });
|
||||||
|
expect(bookSeriesInfo({ ...baseBook, title: "Eyeshield 21 T02" })).toEqual({ title: "Eyeshield 21", volumeLabel: "Tome 2", volumeNumber: 2 });
|
||||||
|
expect(bookSeriesInfo({ ...baseBook, title: "Archive Tome 3" })).toEqual({ title: "Archive", volumeLabel: "Tome 3", volumeNumber: 3 });
|
||||||
|
expect(bookSeriesInfo({ ...baseBook, title: "Archive Volume 3" })).toEqual({ title: "Archive", volumeLabel: "Volume 3", volumeNumber: 3 });
|
||||||
|
expect(bookSeriesInfo({ ...baseBook, title: "Archive Issue 6" })).toEqual({ title: "Archive", volumeLabel: "#6", volumeNumber: 6 });
|
||||||
|
});
|
||||||
|
});
|
||||||
171
apps/web/src/book/metadata.ts
Normal file
171
apps/web/src/book/metadata.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import type { BookDto, JobDto } from "@readabook/shared";
|
||||||
|
|
||||||
|
export type BookMetadataState = "enriched" | "partial" | "missing";
|
||||||
|
|
||||||
|
const earliestCrediblePublishedYear = 1450;
|
||||||
|
|
||||||
|
export type BookSeriesInfo = {
|
||||||
|
title: string;
|
||||||
|
volumeLabel: string | null;
|
||||||
|
volumeNumber: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExtendedBookDto = BookDto & {
|
||||||
|
scanStatus?: "idle" | "running" | "succeeded" | "failed";
|
||||||
|
enrichmentStatus?: "idle" | "running" | "succeeded" | "failed";
|
||||||
|
series?: string | { title?: string | null } | null;
|
||||||
|
seriesTitle?: string | null;
|
||||||
|
collection?: string | null;
|
||||||
|
volumeLabel?: string | null;
|
||||||
|
seriesIndex?: string | number | null;
|
||||||
|
seriesNumber?: string | number | null;
|
||||||
|
volume?: string | number | null;
|
||||||
|
volumeNumber?: string | number | null;
|
||||||
|
issue?: string | number | null;
|
||||||
|
issueNumber?: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasValue(value: unknown): value is string | number {
|
||||||
|
if (typeof value === "number") return Number.isFinite(value);
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookSeriesLabel(book: BookDto): string | null {
|
||||||
|
const series = bookSeriesInfo(book);
|
||||||
|
if (!series) return null;
|
||||||
|
return [series.title, series.volumeLabel].filter(Boolean).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericValue(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const match = value.trim().match(/\d+/);
|
||||||
|
if (!match) return null;
|
||||||
|
const parsed = Number(match[0]);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVolumeLabel(value: unknown, fallbackKind: "tome" | "volume" | "issue" = "volume"): { label: string; number: number | null } | null {
|
||||||
|
if (!hasValue(value)) return null;
|
||||||
|
const raw = String(value).trim();
|
||||||
|
const number = numericValue(raw);
|
||||||
|
if (!number) return null;
|
||||||
|
if (/^(t|tome)\s*0*\d+$/i.test(raw)) return { label: `Tome ${number}`, number };
|
||||||
|
if (/^(vol\.?|volume)\s*0*\d+$/i.test(raw)) return { label: `Volume ${number}`, number };
|
||||||
|
if (/^(#|issue)\s*0*\d+$/i.test(raw)) return { label: `#${number}`, number };
|
||||||
|
if (/^0\d{2,}$/.test(raw)) return { label: `#${number}`, number };
|
||||||
|
if (fallbackKind === "tome") return { label: `Tome ${number}`, number };
|
||||||
|
if (fallbackKind === "issue") return { label: `#${number}`, number };
|
||||||
|
return { label: `Volume ${number}`, number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleVolumeInfo(title: string): BookSeriesInfo | null {
|
||||||
|
const trimmed = title.trim();
|
||||||
|
const patterns: Array<{ pattern: RegExp; kind: "tome" | "volume" | "issue" }> = [
|
||||||
|
{ pattern: /^(.+?)\s+(T|Tome)\s*0*(\d+)$/i, kind: "tome" },
|
||||||
|
{ pattern: /^(.+?)\s+(Vol\.?|Volume)\s*0*(\d+)$/i, kind: "volume" },
|
||||||
|
{ pattern: /^(.+?)\s+(#|Issue)\s*0*(\d+)$/i, kind: "issue" },
|
||||||
|
{ pattern: /^(.+?)\s+0*(\d{3})$/i, kind: "issue" }
|
||||||
|
];
|
||||||
|
for (const { pattern, kind } of patterns) {
|
||||||
|
const match = trimmed.match(pattern);
|
||||||
|
if (!match) continue;
|
||||||
|
const titlePart = match[1]?.trim();
|
||||||
|
const number = Number(match[3] ?? match[2]);
|
||||||
|
if (!titlePart || !Number.isFinite(number)) continue;
|
||||||
|
const normalized = normalizeVolumeLabel(number, kind);
|
||||||
|
if (!normalized) continue;
|
||||||
|
return { title: titlePart, volumeLabel: normalized.label, volumeNumber: normalized.number };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookSeriesInfo(book: BookDto): BookSeriesInfo | null {
|
||||||
|
const extended = book as ExtendedBookDto;
|
||||||
|
const seriesObjectTitle =
|
||||||
|
extended.series && typeof extended.series === "object" && hasValue(extended.series.title) ? extended.series.title : null;
|
||||||
|
const series = [seriesObjectTitle, extended.series, extended.seriesTitle, extended.collection].find(hasValue);
|
||||||
|
if (series) {
|
||||||
|
const explicitLabel = normalizeVolumeLabel(extended.volumeLabel);
|
||||||
|
const issue = normalizeVolumeLabel([extended.issueNumber, extended.issue, extended.seriesNumber].find(hasValue), "issue");
|
||||||
|
const volume = normalizeVolumeLabel([extended.volumeNumber, extended.volume, extended.seriesIndex].find(hasValue), "volume");
|
||||||
|
const position = explicitLabel ?? issue ?? volume;
|
||||||
|
return {
|
||||||
|
title: String(series),
|
||||||
|
volumeLabel: position?.label ?? null,
|
||||||
|
volumeNumber: position?.number ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return titleVolumeInfo(book.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookDisplayTitle(book: BookDto): string {
|
||||||
|
return bookSeriesInfo(book)?.title ?? book.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookVolumeLabel(book: BookDto): string | null {
|
||||||
|
return bookSeriesInfo(book)?.volumeLabel ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookCardVolumeLabel(book: BookDto): string | null {
|
||||||
|
const series = bookSeriesInfo(book);
|
||||||
|
if (!series?.volumeNumber) return null;
|
||||||
|
if (!series.volumeLabel) return null;
|
||||||
|
if (series.volumeLabel.startsWith("#")) return series.volumeLabel;
|
||||||
|
return `T. ${series.volumeNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayPublishedDate(value?: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const yearMatch = trimmed.match(/\b(\d{4})\b/);
|
||||||
|
if (!yearMatch) return null;
|
||||||
|
const year = Number(yearMatch[1]);
|
||||||
|
const nextYear = new Date().getFullYear() + 1;
|
||||||
|
if (!Number.isInteger(year) || year < earliestCrediblePublishedYear || year > nextYear) return null;
|
||||||
|
return String(year);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usefulMetadataCount(book: BookDto): number {
|
||||||
|
return [
|
||||||
|
book.author,
|
||||||
|
displayPublishedDate(book.publishedDate),
|
||||||
|
book.publisher,
|
||||||
|
book.description,
|
||||||
|
book.isbn13,
|
||||||
|
book.isbn,
|
||||||
|
book.coverPath,
|
||||||
|
bookSeriesLabel(book)
|
||||||
|
].filter(hasValue).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookMetadataState(book: BookDto): BookMetadataState {
|
||||||
|
const statuses = book as ExtendedBookDto;
|
||||||
|
if (statuses.enrichmentStatus === "succeeded") return "enriched";
|
||||||
|
if (usefulMetadataCount(book) > 0 || statuses.scanStatus === "succeeded") return "partial";
|
||||||
|
return "missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookMetadataStateLabel(book: BookDto): string {
|
||||||
|
const state = bookMetadataState(book);
|
||||||
|
if (state === "enriched") return "enrichi";
|
||||||
|
if (state === "partial") return "partiel";
|
||||||
|
return "non enrichi";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookMetadataSourceSummary(book: BookDto): string {
|
||||||
|
const statuses = book as ExtendedBookDto;
|
||||||
|
if (statuses.enrichmentStatus === "running" || statuses.scanStatus === "running") return "mise a jour en cours";
|
||||||
|
if (statuses.enrichmentStatus === "succeeded") return "source locale + enrichissement externe";
|
||||||
|
if (usefulMetadataCount(book) > 0 || statuses.scanStatus === "succeeded") return "source locale uniquement";
|
||||||
|
return "metadata indisponible";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobDigestSummary(job: JobDto): string {
|
||||||
|
const detail = job.detail?.trim();
|
||||||
|
if (detail) return detail;
|
||||||
|
if (job.type.toLowerCase().includes("enrich")) return "enrichissement externe";
|
||||||
|
if (job.type.toLowerCase().includes("scan")) return "source locale";
|
||||||
|
return "travail catalogue";
|
||||||
|
}
|
||||||
@ -1,22 +1,35 @@
|
|||||||
import { BookOpen, Eye } from "lucide-react";
|
import { BookOpen, Eye } from "lucide-react";
|
||||||
import type { BookDto } from "@readabook/shared";
|
import type { BookDto } from "@readabook/shared";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { bookCardVolumeLabel, bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, displayPublishedDate } from "../book/metadata";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { FormatPill } from "./ui";
|
import { FormatPill } from "./ui";
|
||||||
|
|
||||||
export function BookCard({ book, compact = false }: { book: BookDto; compact?: boolean }) {
|
export function BookCard({ book, compact = false, coverLoading = false }: { book: BookDto; compact?: boolean; coverLoading?: boolean }) {
|
||||||
|
const metadataState = bookMetadataState(book);
|
||||||
|
const publishedDate = displayPublishedDate(book.publishedDate);
|
||||||
|
const volumeLabel = bookCardVolumeLabel(book);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`book-card ${compact ? "book-card-compact" : ""}`}>
|
<article className={`book-card ${compact ? "book-card-compact" : ""}`}>
|
||||||
<button className="cover-button" onClick={() => navigate(`/book/${book.id}`)} aria-label={`Ouvrir ${book.title}`}>
|
<button className="cover-button" onClick={() => navigate(`/book/${book.id}`)} aria-label={`Ouvrir ${book.title}`}>
|
||||||
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={34} />}
|
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={34} />}
|
||||||
|
{coverLoading && <span className="cover-loading" aria-label="Jaquette en cours de mise à jour" />}
|
||||||
</button>
|
</button>
|
||||||
<div className="book-card-body">
|
<div className="book-card-body">
|
||||||
<div className="book-card-meta">
|
<div className="book-card-meta">
|
||||||
<FormatPill format={book.format} />
|
<FormatPill format={book.format} />
|
||||||
<span>{book.language ?? "langue inconnue"}</span>
|
{volumeLabel && <span className="volume-pill">{volumeLabel}</span>}
|
||||||
|
<span className="book-card-language">{book.language ?? "langue inconnue"}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3>{book.title}</h3>
|
<h3>{bookDisplayTitle(book)}</h3>
|
||||||
<p>{book.author ?? "Auteur inconnu"}</p>
|
<p>{book.author ?? "Auteur inconnu"}</p>
|
||||||
|
{(volumeLabel || publishedDate) && (
|
||||||
|
<p className="book-card-submeta">
|
||||||
|
{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
|
||||||
{!compact && <p className="book-card-description">{book.description ?? "Notice absente du catalogue."}</p>}
|
{!compact && <p className="book-card-description">{book.description ?? "Notice absente du catalogue."}</p>}
|
||||||
<div className="book-card-actions">
|
<div className="book-card-actions">
|
||||||
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
|
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
|||||||
import type { BookDto, ProgressDto } from "@readabook/shared";
|
import type { BookDto, ProgressDto } from "@readabook/shared";
|
||||||
import { api, getApiFallback } from "../api/client";
|
import { api, getApiFallback } from "../api/client";
|
||||||
import { cleanBookDescription } from "../book/description";
|
import { cleanBookDescription } from "../book/description";
|
||||||
|
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
|
||||||
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
|
|
||||||
@ -52,6 +53,18 @@ export function BookPage({ bookId }: { bookId: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const series = bookSeriesInfo(book);
|
||||||
|
const metadataState = bookMetadataState(book);
|
||||||
|
const publishedDate = displayPublishedDate(book.publishedDate);
|
||||||
|
const detailFacts = [
|
||||||
|
{ label: "Auteur", value: book.author },
|
||||||
|
{ label: "Date", value: publishedDate },
|
||||||
|
{ label: "Serie", value: series?.title },
|
||||||
|
{ label: "Position", value: series?.volumeLabel },
|
||||||
|
{ label: "Editeur", value: book.publisher },
|
||||||
|
{ label: "ISBN", value: book.isbn13 ?? book.isbn }
|
||||||
|
].filter((fact) => fact.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="book-detail">
|
<div className="book-detail">
|
||||||
<section className="book-portrait">
|
<section className="book-portrait">
|
||||||
@ -62,9 +75,18 @@ export function BookPage({ bookId }: { bookId: number }) {
|
|||||||
<div className="book-card-meta">
|
<div className="book-card-meta">
|
||||||
<FormatPill format={book.format} />
|
<FormatPill format={book.format} />
|
||||||
<span>{book.language ?? "langue inconnue"}</span>
|
<span>{book.language ?? "langue inconnue"}</span>
|
||||||
|
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h1>{book.title}</h1>
|
<h1>{bookDisplayTitle(book)}</h1>
|
||||||
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
|
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
|
||||||
|
<dl className="book-fact-list">
|
||||||
|
{detailFacts.map((fact) => (
|
||||||
|
<div key={fact.label}>
|
||||||
|
<dt>{fact.label}</dt>
|
||||||
|
<dd>{fact.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
<p className="book-description">{cleanBookDescription(book.description)}</p>
|
<p className="book-description">{cleanBookDescription(book.description)}</p>
|
||||||
{progress && <Meter value={progress.percent} />}
|
{progress && <Meter value={progress.percent} />}
|
||||||
<div className="book-card-actions">
|
<div className="book-card-actions">
|
||||||
@ -76,6 +98,12 @@ export function BookPage({ bookId }: { bookId: number }) {
|
|||||||
<LibraryBig size={18} />
|
<LibraryBig size={18} />
|
||||||
Rayon
|
Rayon
|
||||||
</button>
|
</button>
|
||||||
|
{series && (
|
||||||
|
<button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}>
|
||||||
|
<LibraryBig size={18} />
|
||||||
|
Serie
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<button className="ghost-button" onClick={() => void loadBook()}>
|
<button className="ghost-button" onClick={() => void loadBook()}>
|
||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LibraryBig, ScanLine } from "lucide-react";
|
import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { DashboardData } from "../api/types";
|
import { hasActiveCoverWork, isBookCoverUpdating, type DashboardData } from "../api/types";
|
||||||
|
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookVolumeLabel, displayPublishedDate } from "../book/metadata";
|
||||||
import { BookCard } from "../components/BookCard";
|
import { BookCard } from "../components/BookCard";
|
||||||
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
|
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
@ -27,6 +28,7 @@ export function HomePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!state) return <LoadingState />;
|
if (!state) return <LoadingState />;
|
||||||
|
const fallbackCoverLoading = hasActiveCoverWork(state.jobs);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
@ -49,13 +51,25 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
{state.continueReading.length ? (
|
{state.continueReading.length ? (
|
||||||
<div className="continue-grid">
|
<div className="continue-grid">
|
||||||
{state.continueReading.map((item) => (
|
{state.continueReading.map((item) => {
|
||||||
|
const metadataState = bookMetadataState(item.book);
|
||||||
|
const publishedDate = displayPublishedDate(item.book.publishedDate);
|
||||||
|
const volumeLabel = bookVolumeLabel(item.book);
|
||||||
|
return (
|
||||||
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
|
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
|
||||||
<strong>{item.book.title}</strong>
|
<span className="continue-cover" aria-hidden="true">
|
||||||
|
{item.book.coverPath ? <img src={api.bookCoverUrl(item.book.id)} alt="" /> : <BookOpen size={22} />}
|
||||||
|
</span>
|
||||||
|
<span className="continue-copy">
|
||||||
|
<strong>{bookDisplayTitle(item.book)}</strong>
|
||||||
<span>{item.book.author ?? "Auteur inconnu"}</span>
|
<span>{item.book.author ?? "Auteur inconnu"}</span>
|
||||||
|
{(volumeLabel || publishedDate) && <span>{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}</span>}
|
||||||
|
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(item.book)}</span>
|
||||||
<Meter value={item.progress.percent} />
|
<Meter value={item.progress.percent} />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
|
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
|
||||||
@ -79,7 +93,7 @@ export function HomePage() {
|
|||||||
|
|
||||||
<section className="book-grid span-3">
|
<section className="book-grid span-3">
|
||||||
{state.books.map((book) => (
|
{state.books.map((book) => (
|
||||||
<BookCard key={book.id} book={book} />
|
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,19 +1,22 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { BookDto, LibraryDto } from "@readabook/shared";
|
import type { BookDto, JobDto, LibraryDto } from "@readabook/shared";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
|
||||||
import { BookCard } from "../components/BookCard";
|
import { BookCard } from "../components/BookCard";
|
||||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||||
|
|
||||||
export function LibraryPage({ libraryId }: { libraryId: number }) {
|
export function LibraryPage({ libraryId }: { libraryId: number }) {
|
||||||
const [books, setBooks] = useState<BookDto[] | null>(null);
|
const [books, setBooks] = useState<BookDto[] | null>(null);
|
||||||
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
|
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
|
||||||
|
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
Promise.all([api.books({ libraryId }), api.libraries()]).then(([nextBooks, nextLibraries]) => {
|
Promise.all([api.books({ libraryId }), api.libraries(), api.jobs().catch(() => [])]).then(([nextBooks, nextLibraries, nextJobs]) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setBooks(nextBooks);
|
setBooks(nextBooks);
|
||||||
setLibraries(nextLibraries);
|
setLibraries(nextLibraries);
|
||||||
|
setJobs(nextJobs);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
@ -22,6 +25,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
|
|||||||
|
|
||||||
if (!books) return <LoadingState />;
|
if (!books) return <LoadingState />;
|
||||||
const library = libraries.find((item) => item.id === libraryId);
|
const library = libraries.find((item) => item.id === libraryId);
|
||||||
|
const fallbackCoverLoading = hasActiveCoverWork(jobs);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
@ -37,7 +41,7 @@ export function LibraryPage({ libraryId }: { libraryId: number }) {
|
|||||||
{books.length ? (
|
{books.length ? (
|
||||||
<section className="book-grid span-3">
|
<section className="book-grid span-3">
|
||||||
{books.map((book) => (
|
{books.map((book) => (
|
||||||
<BookCard key={book.id} book={book} />
|
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import type { BookDto } from "@readabook/shared";
|
import type { BookDto, JobDto } from "@readabook/shared";
|
||||||
import { api, getApiFallback } from "../api/client";
|
import { api, getApiFallback } from "../api/client";
|
||||||
|
import { hasActiveCoverWork, isBookCoverUpdating } from "../api/types";
|
||||||
import { BookCard } from "../components/BookCard";
|
import { BookCard } from "../components/BookCard";
|
||||||
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||||
|
|
||||||
export function SearchPage() {
|
export function SearchPage() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [books, setBooks] = useState<BookDto[]>([]);
|
const [books, setBooks] = useState<BookDto[]>([]);
|
||||||
|
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
@ -15,7 +17,9 @@ export function SearchPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
try {
|
try {
|
||||||
setBooks(nextQuery.trim() ? await api.search(nextQuery.trim()) : await api.books());
|
const [nextBooks, nextJobs] = await Promise.all([nextQuery.trim() ? api.search(nextQuery.trim()) : api.books(), api.jobs().catch(() => [])]);
|
||||||
|
setBooks(nextBooks);
|
||||||
|
setJobs(nextJobs);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
const fallback = getApiFallback<BookDto[]>(loadError);
|
const fallback = getApiFallback<BookDto[]>(loadError);
|
||||||
setBooks(fallback ?? []);
|
setBooks(fallback ?? []);
|
||||||
@ -25,6 +29,8 @@ export function SearchPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackCoverLoading = hasActiveCoverWork(jobs);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadBooks("");
|
void loadBooks("");
|
||||||
}, []);
|
}, []);
|
||||||
@ -58,7 +64,7 @@ export function SearchPage() {
|
|||||||
) : books.length ? (
|
) : books.length ? (
|
||||||
<section className="book-grid span-3">
|
<section className="book-grid span-3">
|
||||||
{books.map((book) => (
|
{books.map((book) => (
|
||||||
<BookCard key={book.id} book={book} />
|
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
53
apps/web/src/pages/SeriesPage.tsx
Normal file
53
apps/web/src/pages/SeriesPage.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { BookDto } from "@readabook/shared";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { bookSeriesInfo } from "../book/metadata";
|
||||||
|
import { BookCard } from "../components/BookCard";
|
||||||
|
import { EmptyState, LoadingState, Panel } from "../components/ui";
|
||||||
|
|
||||||
|
export function SeriesPage({ seriesName }: { seriesName: string }) {
|
||||||
|
const [books, setBooks] = useState<BookDto[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
api.books().then((nextBooks) => {
|
||||||
|
if (alive) setBooks(nextBooks);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seriesBooks = useMemo(() => {
|
||||||
|
const expected = seriesName.trim().toLocaleLowerCase();
|
||||||
|
return (books ?? [])
|
||||||
|
.filter((book) => bookSeriesInfo(book)?.title.trim().toLocaleLowerCase() === expected)
|
||||||
|
.sort((left, right) => (bookSeriesInfo(left)?.volumeNumber ?? Number.MAX_SAFE_INTEGER) - (bookSeriesInfo(right)?.volumeNumber ?? Number.MAX_SAFE_INTEGER));
|
||||||
|
}, [books, seriesName]);
|
||||||
|
|
||||||
|
if (!books) return <LoadingState />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-grid">
|
||||||
|
<Panel className="span-3">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h1>{seriesName}</h1>
|
||||||
|
<p>{seriesBooks.length} volumes reperes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
{seriesBooks.length ? (
|
||||||
|
<section className="book-grid span-3">
|
||||||
|
{seriesBooks.map((book) => (
|
||||||
|
<BookCard key={book.id} book={book} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<Panel className="span-3">
|
||||||
|
<EmptyState title="Serie introuvable" detail="Les volumes apparaitront ici quand le catalogue exposera leur serie." />
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/web/src/router.test.ts
Normal file
9
apps/web/src/router.test.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseRoute } from "./router";
|
||||||
|
|
||||||
|
describe("parseRoute", () => {
|
||||||
|
it("keeps the frontend series view away from the backend /series endpoint", () => {
|
||||||
|
expect(parseRoute("/series")).toEqual({ name: "home" });
|
||||||
|
expect(parseRoute("/catalog/series/Daredevil")).toEqual({ name: "catalogSeries", seriesName: "Daredevil" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,6 +3,7 @@ export type Route =
|
|||||||
| { name: "setup"; step: string }
|
| { name: "setup"; step: string }
|
||||||
| { name: "home" }
|
| { name: "home" }
|
||||||
| { name: "library"; libraryId: number }
|
| { name: "library"; libraryId: number }
|
||||||
|
| { name: "catalogSeries"; seriesName: string }
|
||||||
| { name: "book"; bookId: number }
|
| { name: "book"; bookId: number }
|
||||||
| { name: "reader"; bookId: number }
|
| { name: "reader"; bookId: number }
|
||||||
| { name: "search" }
|
| { name: "search" }
|
||||||
@ -14,6 +15,7 @@ export function parseRoute(pathname = window.location.pathname): Route {
|
|||||||
if (parts[0] === "login") return { name: "login" };
|
if (parts[0] === "login") return { name: "login" };
|
||||||
if (parts[0] === "setup") return { name: "setup", step: parts[1] ?? "admin" };
|
if (parts[0] === "setup") return { name: "setup", step: parts[1] ?? "admin" };
|
||||||
if (parts[0] === "library") return { name: "library", libraryId: Number(parts[1] ?? 0) };
|
if (parts[0] === "library") return { name: "library", libraryId: Number(parts[1] ?? 0) };
|
||||||
|
if (parts[0] === "catalog" && parts[1] === "series") return { name: "catalogSeries", seriesName: decodeURIComponent(parts[2] ?? "") };
|
||||||
if (parts[0] === "book") return { name: "book", bookId: Number(parts[1] ?? 0) };
|
if (parts[0] === "book") return { name: "book", bookId: Number(parts[1] ?? 0) };
|
||||||
if (parts[0] === "reader") return { name: "reader", bookId: Number(parts[1] ?? 0) };
|
if (parts[0] === "reader") return { name: "reader", bookId: Number(parts[1] ?? 0) };
|
||||||
if (parts[0] === "search") return { name: "search" };
|
if (parts[0] === "search") return { name: "search" };
|
||||||
|
|||||||
@ -173,6 +173,7 @@ h2 {
|
|||||||
|
|
||||||
.cover-button,
|
.cover-button,
|
||||||
.book-portrait {
|
.book-portrait {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -187,6 +188,19 @@ h2 {
|
|||||||
color: var(--brass);
|
color: var(--brass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cover-loading {
|
||||||
|
position: absolute;
|
||||||
|
right: 7px;
|
||||||
|
bottom: 7px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid rgba(247, 240, 223, 0.78);
|
||||||
|
border-top-color: var(--brass);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(23, 17, 13, 0.62);
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.cover-button img,
|
.cover-button img,
|
||||||
.book-portrait img {
|
.book-portrait img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -223,6 +237,10 @@ h2 {
|
|||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.book-card-submeta {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.book-card-meta,
|
.book-card-meta,
|
||||||
.book-card-actions,
|
.book-card-actions,
|
||||||
.section-heading,
|
.section-heading,
|
||||||
@ -232,6 +250,12 @@ h2 {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.book-card-meta {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.book-card-actions,
|
.book-card-actions,
|
||||||
.section-heading {
|
.section-heading {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -251,6 +275,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.format-pill {
|
.format-pill {
|
||||||
|
flex: 0 0 auto;
|
||||||
padding: 4px 7px;
|
padding: 4px 7px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #17110d;
|
color: #17110d;
|
||||||
@ -259,6 +284,27 @@ h2 {
|
|||||||
background: var(--brass);
|
background: var(--brass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.volume-pill {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 48px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border: 1px solid rgba(213, 168, 77, 0.56);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #f5dfaa;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(213, 168, 77, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card-language {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.format-pdf {
|
.format-pdf {
|
||||||
background: var(--lacquer);
|
background: var(--lacquer);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@ -274,6 +320,35 @@ h2 {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metadata-pill {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-enriched {
|
||||||
|
border-color: rgba(45, 111, 99, 0.72);
|
||||||
|
color: #d8fff5;
|
||||||
|
background: rgba(45, 111, 99, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-partial {
|
||||||
|
border-color: rgba(213, 168, 77, 0.68);
|
||||||
|
color: #f5dfaa;
|
||||||
|
background: rgba(213, 168, 77, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-missing {
|
||||||
|
border-color: rgba(169, 72, 52, 0.62);
|
||||||
|
color: #f2b8aa;
|
||||||
|
background: rgba(169, 72, 52, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.continue-grid,
|
.continue-grid,
|
||||||
.library-list,
|
.library-list,
|
||||||
.job-list {
|
.job-list {
|
||||||
@ -281,9 +356,15 @@ h2 {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-list {
|
||||||
|
max-height: 350px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.continue-tile,
|
.continue-tile,
|
||||||
.library-list button,
|
.library-list button,
|
||||||
.job-list div,
|
.job-list > div,
|
||||||
.library-table > div {
|
.library-table > div {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@ -300,6 +381,62 @@ h2 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-copy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-list time {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-copy small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-tile {
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-cover {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(213, 168, 77, 0.35);
|
||||||
|
border-radius: 5px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(213, 168, 77, 0.2), rgba(45, 111, 99, 0.22)),
|
||||||
|
var(--paper-soft);
|
||||||
|
color: var(--brass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-copy > span {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-copy .metadata-pill {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.library-copy {
|
.library-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
@ -523,7 +660,7 @@ select {
|
|||||||
|
|
||||||
.provider-row {
|
.provider-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(210px, 1fr) minmax(190px, 280px) auto;
|
grid-template-columns: minmax(210px, 1fr) minmax(240px, 360px) auto;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@ -541,6 +678,51 @@ select {
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.provider-config {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-state-line {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-state-line small {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-warning {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(213, 168, 77, 0.45);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #f5dfaa;
|
||||||
|
background: rgba(213, 168, 77, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-state-configured {
|
||||||
|
border-color: rgba(45, 111, 99, 0.72);
|
||||||
|
color: #d8fff5;
|
||||||
|
background: rgba(45, 111, 99, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-state-missing-config {
|
||||||
|
border-color: rgba(213, 168, 77, 0.68);
|
||||||
|
color: #f5dfaa;
|
||||||
|
background: rgba(213, 168, 77, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-state-limited,
|
||||||
|
.provider-state-error {
|
||||||
|
border-color: rgba(169, 72, 52, 0.62);
|
||||||
|
color: #f2b8aa;
|
||||||
|
background: rgba(169, 72, 52, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.provider-actions,
|
.provider-actions,
|
||||||
.save-bar,
|
.save-bar,
|
||||||
.save-bar div {
|
.save-bar div {
|
||||||
@ -635,6 +817,33 @@ select {
|
|||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.book-fact-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 6px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-fact-list > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-fact-list dt {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-fact-list dd {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.lead {
|
.lead {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
}
|
}
|
||||||
@ -656,6 +865,11 @@ select {
|
|||||||
background: #120e0b;
|
background: #120e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-page:fullscreen {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
.reader-topbar {
|
.reader-topbar {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
@ -687,6 +901,12 @@ select {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-toolbar .reader-mode-button {
|
||||||
|
gap: 7px;
|
||||||
|
padding-inline: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.reader-toolbar .active {
|
.reader-toolbar .active {
|
||||||
border-color: rgba(213, 168, 77, 0.72);
|
border-color: rgba(213, 168, 77, 0.72);
|
||||||
color: var(--brass);
|
color: var(--brass);
|
||||||
@ -730,6 +950,12 @@ select {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pdf-reader-vertical,
|
||||||
|
.cbz-reader-vertical {
|
||||||
|
place-items: start center;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.epub-host {
|
.epub-host {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: min(100%, 980px);
|
width: min(100%, 980px);
|
||||||
@ -746,18 +972,34 @@ select {
|
|||||||
color: #17110d;
|
color: #17110d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-reader canvas,
|
.pdf-page-frame,
|
||||||
.cbz-reader img {
|
.comic-page-frame {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 64px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-frame canvas {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: min(100%, 980px);
|
max-width: min(100%, 980px);
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: #f7f0df;
|
background: #f7f0df;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cbz-reader img {
|
.cbz-reader img {
|
||||||
width: auto;
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: min(100%, 980px);
|
||||||
|
max-height: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #f7f0df;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -767,17 +1009,28 @@ select {
|
|||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-mode-vertical .pdf-reader canvas,
|
.pdf-strip,
|
||||||
|
.comic-strip {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-mode-vertical .pdf-page-frame canvas,
|
||||||
.reader-mode-vertical .cbz-reader img {
|
.reader-mode-vertical .cbz-reader img {
|
||||||
width: min(100%, 980px);
|
width: min(100%, 980px);
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-mode-horizontal .pdf-reader canvas,
|
.reader-mode-horizontal .pdf-page-frame canvas,
|
||||||
.reader-mode-horizontal .cbz-reader img {
|
.reader-mode-horizontal .cbz-reader img {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -966,6 +1219,7 @@ select {
|
|||||||
|
|
||||||
.library-table > div,
|
.library-table > div,
|
||||||
.search-form,
|
.search-form,
|
||||||
|
.book-fact-list,
|
||||||
.automation-grid,
|
.automation-grid,
|
||||||
.provider-row,
|
.provider-row,
|
||||||
.provider-local,
|
.provider-local,
|
||||||
|
|||||||
@ -84,9 +84,21 @@ export const UpdateLibrarySchema = z.object({
|
|||||||
});
|
});
|
||||||
export type UpdateLibraryDto = z.infer<typeof UpdateLibrarySchema>;
|
export type UpdateLibraryDto = z.infer<typeof UpdateLibrarySchema>;
|
||||||
|
|
||||||
|
export const SeriesSchema = z.object({
|
||||||
|
id: z.number().int().positive(),
|
||||||
|
title: z.string(),
|
||||||
|
normalizedTitle: z.string(),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
publisher: z.string().nullable(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string()
|
||||||
|
});
|
||||||
|
export type SeriesDto = z.infer<typeof SeriesSchema>;
|
||||||
|
|
||||||
export const BookSchema = z.object({
|
export const BookSchema = z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
libraryId: z.number().int().positive(),
|
libraryId: z.number().int().positive(),
|
||||||
|
seriesId: z.number().int().positive().nullable().optional(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
author: z.string().nullable(),
|
author: z.string().nullable(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
@ -95,9 +107,16 @@ export const BookSchema = z.object({
|
|||||||
language: z.string().nullable(),
|
language: z.string().nullable(),
|
||||||
publisher: z.string().nullable(),
|
publisher: z.string().nullable(),
|
||||||
publishedDate: z.string().nullable(),
|
publishedDate: z.string().nullable(),
|
||||||
|
volumeNumber: z.number().int().nullable().optional(),
|
||||||
|
volumeLabel: z.string().nullable().optional(),
|
||||||
format: z.enum(["epub", "pdf", "cbz", "cbr"]),
|
format: z.enum(["epub", "pdf", "cbz", "cbr"]),
|
||||||
filePath: z.string(),
|
filePath: z.string(),
|
||||||
coverPath: z.string().nullable(),
|
coverPath: z.string().nullable(),
|
||||||
|
metadataStatus: z.enum(["enriched", "partial", "none"]).default("none"),
|
||||||
|
metadataProvenance: z.record(z.string(), z.string()).default({}),
|
||||||
|
series: SeriesSchema.nullable().optional(),
|
||||||
|
scanStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
|
||||||
|
enrichmentStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
|
||||||
fileSize: z.number().int().nonnegative(),
|
fileSize: z.number().int().nonnegative(),
|
||||||
fileMtime: z.string(),
|
fileMtime: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
@ -152,7 +171,7 @@ export const JobSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type JobDto = z.infer<typeof JobSchema>;
|
export type JobDto = z.infer<typeof JobSchema>;
|
||||||
|
|
||||||
export const MetadataProviderIdSchema = z.enum(["local", "openlibrary", "googlebooks", "bnf"]);
|
export const MetadataProviderIdSchema = z.enum(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]);
|
||||||
export type MetadataProviderId = z.infer<typeof MetadataProviderIdSchema>;
|
export type MetadataProviderId = z.infer<typeof MetadataProviderIdSchema>;
|
||||||
|
|
||||||
export const MetadataSourceConfigSchema = z.object({
|
export const MetadataSourceConfigSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user