feat(api,web): modélisation série + volume et parcours de série
- Table series + colonnes seriesId/volumeNumber/volumeLabel sur les livres, extraction souple des numéros (T03, 003, etc.) via extract-series-volume - Contrôleur et page de série, badge volume sur les cartes de livre, styles associés (fond/clarté série, ajustements globaux) - Statuts de scan/enrichissement et provenance des métadonnées en base Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -3,10 +3,11 @@ import { AuthModule } from "../auth/auth.module.js";
|
||||
import { DatabaseModule } from "../database/database.module.js";
|
||||
import { BooksController } from "./books.controller.js";
|
||||
import { BooksService } from "./books.service.js";
|
||||
import { SeriesController } from "./series.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, DatabaseModule],
|
||||
controllers: [BooksController],
|
||||
controllers: [BooksController, SeriesController],
|
||||
providers: [BooksService],
|
||||
exports: [BooksService]
|
||||
})
|
||||
|
||||
180
apps/api/src/books/books.service.test.ts
Normal file
180
apps/api/src/books/books.service.test.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { books, libraries, series } from "../database/schema.js";
|
||||
import { BooksService } from "./books.service.js";
|
||||
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const previousStorageDir = process.env.STORAGE_DIR;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
process.env.STORAGE_DIR = previousStorageDir;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("BooksService", () => {
|
||||
it.runIf(canLoadBetterSqlite())("exposes metadata status and parsed provenance on book API rows", () => {
|
||||
const database = createDatabase();
|
||||
const service = new BooksService(database);
|
||||
const now = database.now();
|
||||
const library = database.db
|
||||
.insert(libraries)
|
||||
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.get();
|
||||
const daredevil = database.db
|
||||
.insert(series)
|
||||
.values({
|
||||
title: "Daredevil",
|
||||
normalizedTitle: "daredevil",
|
||||
description: "Collection Daredevil",
|
||||
publisher: "Marvel",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
const book = database.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
seriesId: daredevil.id,
|
||||
title: "Daredevil",
|
||||
author: "Roy Thomas",
|
||||
description: "Daredevil affronte une nouvelle menace.",
|
||||
isbn: "9782809476255",
|
||||
isbn13: "9782809476255",
|
||||
identifiersJson: null,
|
||||
localMetadataJson: null,
|
||||
language: "fre",
|
||||
publisher: "Panini comics",
|
||||
publishedDate: "0101-01-01T00:00:00+00:00",
|
||||
volumeNumber: 1,
|
||||
volumeLabel: "001",
|
||||
format: "cbz",
|
||||
filePath: "/library/Daredevil.cbz",
|
||||
coverPath: "/storage/covers/daredevil.jpg",
|
||||
metadataStatus: "enriched",
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local", author: "bnf", coverPath: "openlibrary" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "succeeded",
|
||||
fileSize: 42,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
expect(service.get(book.id)).toMatchObject({
|
||||
publishedDate: null,
|
||||
seriesId: daredevil.id,
|
||||
volumeNumber: 1,
|
||||
volumeLabel: "001",
|
||||
series: { id: daredevil.id, title: "Daredevil", normalizedTitle: "daredevil" },
|
||||
metadataStatus: "enriched",
|
||||
metadataProvenance: { title: "local", author: "bnf", coverPath: "openlibrary" }
|
||||
});
|
||||
expect(service.list({ limit: 50, offset: 0 })[0]).toMatchObject({
|
||||
publishedDate: null,
|
||||
seriesId: daredevil.id,
|
||||
volumeNumber: 1,
|
||||
volumeLabel: "001",
|
||||
series: { id: daredevil.id, title: "Daredevil", normalizedTitle: "daredevil" },
|
||||
metadataStatus: "enriched",
|
||||
metadataProvenance: { title: "local", author: "bnf", coverPath: "openlibrary" }
|
||||
});
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("lists a series with distinct books sharing the same volume", () => {
|
||||
const database = createDatabase();
|
||||
const service = new BooksService(database);
|
||||
const now = database.now();
|
||||
const library = database.db
|
||||
.insert(libraries)
|
||||
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.get();
|
||||
const soloLeveling = database.db
|
||||
.insert(series)
|
||||
.values({
|
||||
title: "Solo Leveling",
|
||||
normalizedTitle: "solo leveling",
|
||||
description: null,
|
||||
publisher: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
for (const filePath of ["/library/Solo Leveling T03.cbz", "/library/Solo Leveling 003.cbz"]) {
|
||||
database.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
seriesId: soloLeveling.id,
|
||||
title: filePath.includes("T03") ? "Solo Leveling T03" : "Solo Leveling 003",
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
identifiersJson: null,
|
||||
localMetadataJson: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
volumeNumber: 3,
|
||||
volumeLabel: filePath.includes("T03") ? "T03" : "003",
|
||||
format: "cbz",
|
||||
filePath,
|
||||
coverPath: null,
|
||||
metadataStatus: "none",
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "idle",
|
||||
fileSize: 42,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
const result = service.getSeries(soloLeveling.id);
|
||||
|
||||
expect(result).toMatchObject({ id: soloLeveling.id, title: "Solo Leveling", normalizedTitle: "solo leveling" });
|
||||
expect(result.books).toHaveLength(2);
|
||||
expect(result.books.map((book) => [book.title, book.volumeNumber])).toEqual([
|
||||
["Solo Leveling 003", 3],
|
||||
["Solo Leveling T03", 3]
|
||||
]);
|
||||
|
||||
database.onModuleDestroy();
|
||||
});
|
||||
});
|
||||
|
||||
function createDatabase(): DatabaseService {
|
||||
const dir = mkdtempSync(join(tmpdir(), "readabook-books-service-"));
|
||||
tempDirs.push(dir);
|
||||
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
|
||||
process.env.STORAGE_DIR = join(dir, "storage");
|
||||
return new DatabaseService();
|
||||
}
|
||||
|
||||
function canLoadBetterSqlite(): boolean {
|
||||
try {
|
||||
const database = createDatabase();
|
||||
database.onModuleDestroy();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,8 @@ import { BookQueryDto } from "@readabook/shared";
|
||||
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
|
||||
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { books } from "../database/schema.js";
|
||||
import { books, series } from "../database/schema.js";
|
||||
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
|
||||
|
||||
@Injectable()
|
||||
export class BooksService {
|
||||
@ -26,7 +27,8 @@ export class BooksService {
|
||||
.orderBy(books.title)
|
||||
.limit(query.limit)
|
||||
.offset(query.offset)
|
||||
.all();
|
||||
.all()
|
||||
.map((book) => this.mapBookSelect(book));
|
||||
}
|
||||
|
||||
search(q: string, limit = 50, offset = 0) {
|
||||
@ -42,19 +44,32 @@ export class BooksService {
|
||||
`
|
||||
)
|
||||
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
|
||||
return (rows as Array<Record<string, unknown>>).map(mapBookRow);
|
||||
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
|
||||
}
|
||||
|
||||
get(id: number) {
|
||||
const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
|
||||
if (!book) {
|
||||
throw new NotFoundException("Book not found");
|
||||
}
|
||||
return book;
|
||||
return this.mapBookSelect(this.getRecord(id));
|
||||
}
|
||||
|
||||
listSeries() {
|
||||
return this.database.db.select().from(series).orderBy(series.title).all();
|
||||
}
|
||||
|
||||
getSeries(id: number) {
|
||||
const row = this.database.db.select().from(series).where(eq(series.id, id)).get();
|
||||
if (!row) throw new NotFoundException("Series not found");
|
||||
const seriesBooks = this.database.db
|
||||
.select()
|
||||
.from(books)
|
||||
.where(eq(books.seriesId, id))
|
||||
.orderBy(books.volumeNumber, books.title)
|
||||
.all()
|
||||
.map((book) => this.mapBookSelect(book));
|
||||
return { ...row, books: seriesBooks };
|
||||
}
|
||||
|
||||
streamFile(id: number, range?: string) {
|
||||
const book = this.get(id);
|
||||
const book = this.getRecord(id);
|
||||
if (!existsSync(book.filePath)) {
|
||||
throw new NotFoundException("Book file not found on disk");
|
||||
}
|
||||
@ -72,7 +87,7 @@ export class BooksService {
|
||||
}
|
||||
|
||||
streamCover(id: number) {
|
||||
const book = this.get(id);
|
||||
const book = this.getRecord(id);
|
||||
if (!book.coverPath || !existsSync(book.coverPath)) {
|
||||
throw new NotFoundException("Cover not found");
|
||||
}
|
||||
@ -80,7 +95,7 @@ export class BooksService {
|
||||
}
|
||||
|
||||
async listComicPages(id: number) {
|
||||
const book = this.get(id);
|
||||
const book = this.getRecord(id);
|
||||
this.assertComicArchiveBook(book);
|
||||
const pages = book.format === "cbr" ? await listCbrImageEntries(book.filePath) : listCbzImageEntries(book.filePath);
|
||||
return {
|
||||
@ -91,7 +106,7 @@ export class BooksService {
|
||||
}
|
||||
|
||||
async readComicPage(id: number, page: number) {
|
||||
const book = this.get(id);
|
||||
const book = this.getRecord(id);
|
||||
this.assertComicArchiveBook(book);
|
||||
try {
|
||||
const result =
|
||||
@ -108,6 +123,14 @@ export class BooksService {
|
||||
return this.database.db.select({ count: sql<number>`count(*)` }).from(books).get()?.count ?? 0;
|
||||
}
|
||||
|
||||
private getRecord(id: number): typeof books.$inferSelect {
|
||||
const book = this.database.db.select().from(books).where(eq(books.id, id)).get();
|
||||
if (!book) {
|
||||
throw new NotFoundException("Book not found");
|
||||
}
|
||||
return book;
|
||||
}
|
||||
|
||||
private assertComicArchiveBook(book: typeof books.$inferSelect): void {
|
||||
if (book.format !== "cbz" && book.format !== "cbr") {
|
||||
throw new BadRequestException("Book is not a comic archive");
|
||||
@ -116,6 +139,51 @@ export class BooksService {
|
||||
throw new NotFoundException("Book file not found on disk");
|
||||
}
|
||||
}
|
||||
|
||||
private mapBookRow(row: Record<string, unknown>) {
|
||||
const seriesId = nullable(row.series_id);
|
||||
return {
|
||||
id: Number(row.id),
|
||||
libraryId: Number(row.library_id),
|
||||
seriesId: seriesId ? Number(seriesId) : null,
|
||||
title: String(row.title),
|
||||
author: nullable(row.author),
|
||||
description: nullable(row.description),
|
||||
isbn: nullable(row.isbn),
|
||||
isbn13: nullable(row.isbn13),
|
||||
language: nullable(row.language),
|
||||
publisher: nullable(row.publisher),
|
||||
publishedDate: normalizePublishedDate(nullable(row.published_date)),
|
||||
volumeNumber: row.volume_number === null || row.volume_number === undefined ? null : Number(row.volume_number),
|
||||
volumeLabel: nullable(row.volume_label),
|
||||
format: row.format,
|
||||
filePath: String(row.file_path),
|
||||
coverPath: nullable(row.cover_path),
|
||||
metadataStatus: metadataStatusValue(row.metadata_status),
|
||||
metadataProvenance: parseObject(row.metadata_provenance_json),
|
||||
series: seriesId ? this.getSeriesRecord(Number(seriesId)) : null,
|
||||
scanStatus: statusValue(row.scan_status),
|
||||
enrichmentStatus: statusValue(row.enrichment_status),
|
||||
fileSize: Number(row.file_size),
|
||||
fileMtime: String(row.file_mtime),
|
||||
createdAt: String(row.created_at),
|
||||
updatedAt: String(row.updated_at)
|
||||
};
|
||||
}
|
||||
|
||||
private mapBookSelect(row: typeof books.$inferSelect) {
|
||||
const { metadataProvenanceJson: _metadataProvenanceJson, ...book } = row;
|
||||
return {
|
||||
...book,
|
||||
publishedDate: normalizePublishedDate(row.publishedDate),
|
||||
metadataProvenance: parseObject(row.metadataProvenanceJson),
|
||||
series: row.seriesId ? this.getSeriesRecord(row.seriesId) : null
|
||||
};
|
||||
}
|
||||
|
||||
private getSeriesRecord(id: number) {
|
||||
return this.database.db.select().from(series).where(eq(series.id, id)).get() ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } {
|
||||
@ -159,28 +227,25 @@ function lookupMime(entryName: string): string {
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
function mapBookRow(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
libraryId: Number(row.library_id),
|
||||
title: String(row.title),
|
||||
author: nullable(row.author),
|
||||
description: nullable(row.description),
|
||||
isbn: nullable(row.isbn),
|
||||
isbn13: nullable(row.isbn13),
|
||||
language: nullable(row.language),
|
||||
publisher: nullable(row.publisher),
|
||||
publishedDate: nullable(row.published_date),
|
||||
format: row.format,
|
||||
filePath: String(row.file_path),
|
||||
coverPath: nullable(row.cover_path),
|
||||
fileSize: Number(row.file_size),
|
||||
fileMtime: String(row.file_mtime),
|
||||
createdAt: String(row.created_at),
|
||||
updatedAt: String(row.updated_at)
|
||||
};
|
||||
}
|
||||
|
||||
function nullable(value: unknown): string | null {
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
}
|
||||
|
||||
function statusValue(value: unknown): "idle" | "running" | "succeeded" | "failed" {
|
||||
return value === "running" || value === "succeeded" || value === "failed" ? value : "idle";
|
||||
}
|
||||
|
||||
function metadataStatusValue(value: unknown): "enriched" | "partial" | "none" {
|
||||
return value === "enriched" || value === "partial" ? value : "none";
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): Record<string, string> {
|
||||
if (typeof value !== "string") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
return Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/api/src/books/series.controller.ts
Normal file
19
apps/api/src/books/series.controller.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/auth.guard.js";
|
||||
import { BooksService } from "./books.service.js";
|
||||
|
||||
@Controller("series")
|
||||
@UseGuards(AuthGuard)
|
||||
export class SeriesController {
|
||||
constructor(private readonly books: BooksService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.books.listSeries();
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
get(@Param("id") id: string) {
|
||||
return this.books.getSeries(Number(id));
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DatabaseService } from "./database.service.js";
|
||||
import { books, libraries, series } from "./schema.js";
|
||||
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const previousStorageDir = process.env.STORAGE_DIR;
|
||||
@ -25,6 +26,7 @@ describe("database migrations", () => {
|
||||
const storageDir = join(dir, "storage");
|
||||
|
||||
const legacy = new Database(databasePath);
|
||||
const now = new Date().toISOString();
|
||||
legacy.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@ -84,6 +86,17 @@ describe("database migrations", () => {
|
||||
created_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();
|
||||
|
||||
@ -94,18 +107,130 @@ describe("database migrations", () => {
|
||||
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 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
|
||||
| { id: number; isbn_priority_enabled: number }
|
||||
| undefined;
|
||||
|
||||
expect(bookColumns.map((column) => column.name)).toContain("isbn13");
|
||||
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(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 });
|
||||
|
||||
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 {
|
||||
|
||||
@ -2,6 +2,7 @@ import { Injectable, OnModuleDestroy } from "@nestjs/common";
|
||||
import Database from "better-sqlite3";
|
||||
import { BetterSQLite3Database, drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { AppConfig, loadConfig } from "../config/env.js";
|
||||
import { extractSeriesVolume } from "../metadata/use-cases/extract-series-volume.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
@Injectable()
|
||||
@ -49,9 +50,20 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
normalized_title TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
publisher TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
series_id INTEGER REFERENCES series(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
description TEXT,
|
||||
@ -62,9 +74,15 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
language TEXT,
|
||||
publisher TEXT,
|
||||
published_date TEXT,
|
||||
volume_number INTEGER,
|
||||
volume_label TEXT,
|
||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
cover_path TEXT,
|
||||
metadata_status TEXT NOT NULL DEFAULT 'none' CHECK (metadata_status IN ('enriched','partial','none')),
|
||||
metadata_provenance_json TEXT,
|
||||
scan_status TEXT NOT NULL DEFAULT 'idle' CHECK (scan_status IN ('idle','running','succeeded','failed')),
|
||||
enrichment_status TEXT NOT NULL DEFAULT 'idle' CHECK (enrichment_status IN ('idle','running','succeeded','failed')),
|
||||
file_size INTEGER NOT NULL,
|
||||
file_mtime TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
@ -93,7 +111,7 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_source_config (
|
||||
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf')),
|
||||
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf','mangadex','comicvine')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
priority INTEGER NOT NULL,
|
||||
api_key TEXT,
|
||||
@ -143,8 +161,11 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
END;
|
||||
`);
|
||||
this.ensureBooksSupportsComicArchives();
|
||||
this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')");
|
||||
this.ensureBooksMetadataColumns();
|
||||
this.ensureSeriesModel();
|
||||
this.ensureReaderPreferencesTable();
|
||||
this.ensureMetadataSourceConfigSupportsComicProviders();
|
||||
this.ensureMetadataSourceConfigColumns();
|
||||
this.ensureAutomationSettingsColumns();
|
||||
this.ensureMetadataDefaults();
|
||||
@ -170,6 +191,7 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
series_id INTEGER REFERENCES series(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
description TEXT,
|
||||
@ -180,9 +202,15 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
language TEXT,
|
||||
publisher TEXT,
|
||||
published_date TEXT,
|
||||
volume_number INTEGER,
|
||||
volume_label TEXT,
|
||||
format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')),
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
cover_path TEXT,
|
||||
metadata_status TEXT NOT NULL DEFAULT 'none' CHECK (metadata_status IN ('enriched','partial','none')),
|
||||
metadata_provenance_json TEXT,
|
||||
scan_status TEXT NOT NULL DEFAULT 'idle' CHECK (scan_status IN ('idle','running','succeeded','failed')),
|
||||
enrichment_status TEXT NOT NULL DEFAULT 'idle' CHECK (enrichment_status IN ('idle','running','succeeded','failed')),
|
||||
file_size INTEGER NOT NULL,
|
||||
file_mtime TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
@ -190,11 +218,13 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
);
|
||||
INSERT INTO books (
|
||||
id, library_id, title, author, description, isbn, isbn13, identifiers_json, local_metadata_json, language, publisher, published_date,
|
||||
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||
volume_number, volume_label, format, file_path, cover_path, metadata_status, metadata_provenance_json, scan_status, enrichment_status, file_size, file_mtime, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, library_id, title, author, description, isbn, NULL, NULL, NULL, language, publisher, published_date,
|
||||
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
|
||||
NULL, NULL,
|
||||
format, file_path, cover_path, CASE WHEN cover_path IS NOT NULL OR author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL THEN 'partial' ELSE 'none' END, NULL,
|
||||
'idle', 'idle', file_size, file_mtime, created_at, updated_at
|
||||
FROM books_legacy_format;
|
||||
DROP TABLE books_legacy_format;
|
||||
COMMIT;
|
||||
@ -238,8 +268,95 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
if (!names.has("local_metadata_json")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN local_metadata_json TEXT");
|
||||
}
|
||||
if (!names.has("metadata_status")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN metadata_status TEXT NOT NULL DEFAULT 'none'");
|
||||
this.sqlite.exec(`
|
||||
UPDATE books
|
||||
SET metadata_status = CASE
|
||||
WHEN cover_path IS NOT NULL AND (author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL) THEN 'enriched'
|
||||
WHEN cover_path IS NOT NULL OR author IS NOT NULL OR description IS NOT NULL OR isbn IS NOT NULL THEN 'partial'
|
||||
ELSE 'none'
|
||||
END
|
||||
`);
|
||||
}
|
||||
if (!names.has("metadata_provenance_json")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN metadata_provenance_json TEXT");
|
||||
}
|
||||
if (!names.has("scan_status")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN scan_status TEXT NOT NULL DEFAULT 'idle'");
|
||||
}
|
||||
if (!names.has("enrichment_status")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN enrichment_status TEXT NOT NULL DEFAULT 'idle'");
|
||||
}
|
||||
if (!names.has("series_id")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN series_id INTEGER REFERENCES series(id) ON DELETE SET NULL");
|
||||
}
|
||||
if (!names.has("volume_number")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN volume_number INTEGER");
|
||||
}
|
||||
if (!names.has("volume_label")) {
|
||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN volume_label TEXT");
|
||||
}
|
||||
this.sqlite.exec(`
|
||||
UPDATE books
|
||||
SET published_date = NULL
|
||||
WHERE published_date IS NOT NULL
|
||||
AND (
|
||||
trim(published_date) = '0000'
|
||||
OR substr(trim(published_date), 1, 10) IN ('0001-01-01', '0101-01-01', '1970-01-01')
|
||||
OR CAST(substr(trim(published_date), 1, 4) AS INTEGER) < 1500
|
||||
OR CAST(substr(trim(published_date), 1, 4) AS INTEGER) > 2027
|
||||
)
|
||||
`);
|
||||
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)");
|
||||
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_local_metadata_idx ON books(local_metadata_json)");
|
||||
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_series_idx ON books(series_id)");
|
||||
}
|
||||
|
||||
private ensureSeriesModel(): void {
|
||||
this.sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
normalized_title TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
publisher TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS series_normalized_title_unique ON series(normalized_title);
|
||||
CREATE INDEX IF NOT EXISTS books_series_idx ON books(series_id);
|
||||
`);
|
||||
this.backfillSeries();
|
||||
}
|
||||
|
||||
private backfillSeries(): void {
|
||||
const rows = this.sqlite.prepare("SELECT id, title, file_path, series_id FROM books WHERE series_id IS NULL OR volume_number IS NULL").all() as Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
file_path: string;
|
||||
series_id: number | null;
|
||||
}>;
|
||||
if (!rows.length) return;
|
||||
const now = this.now();
|
||||
const insertSeries = this.sqlite.prepare(`
|
||||
INSERT INTO series (title, normalized_title, description, publisher, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, ?, ?)
|
||||
ON CONFLICT(normalized_title) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at
|
||||
RETURNING id
|
||||
`);
|
||||
const updateBook = this.sqlite.prepare("UPDATE books SET series_id = ?, volume_number = ?, volume_label = ? WHERE id = ?");
|
||||
const transaction = this.sqlite.transaction(() => {
|
||||
for (const row of rows) {
|
||||
const parsed = extractSeriesVolume(row.title, row.file_path);
|
||||
if (row.series_id !== null && parsed.volumeNumber === null) continue;
|
||||
const seriesId =
|
||||
row.series_id ??
|
||||
(insertSeries.get(parsed.seriesTitle, parsed.normalizedSeriesTitle, now, now) as { id: number }).id;
|
||||
updateBook.run(seriesId, parsed.volumeNumber, parsed.volumeLabel, row.id);
|
||||
}
|
||||
});
|
||||
transaction();
|
||||
}
|
||||
|
||||
private ensureReaderPreferencesTable(): void {
|
||||
@ -278,6 +395,31 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureMetadataSourceConfigSupportsComicProviders(): void {
|
||||
const table = this.sqlite
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'metadata_source_config'")
|
||||
.get() as { sql?: string } | undefined;
|
||||
if (!table?.sql || (table.sql.includes("'mangadex'") && table.sql.includes("'comicvine'"))) return;
|
||||
|
||||
this.sqlite.exec(`
|
||||
BEGIN;
|
||||
ALTER TABLE metadata_source_config RENAME TO metadata_source_config_legacy_provider;
|
||||
CREATE TABLE metadata_source_config (
|
||||
provider TEXT PRIMARY KEY CHECK (provider IN ('local','openlibrary','googlebooks','bnf','mangadex','comicvine')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
priority INTEGER NOT NULL,
|
||||
api_key TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO metadata_source_config (provider, enabled, priority, api_key, created_at, updated_at)
|
||||
SELECT provider, enabled, priority, api_key, created_at, updated_at
|
||||
FROM metadata_source_config_legacy_provider;
|
||||
DROP TABLE metadata_source_config_legacy_provider;
|
||||
COMMIT;
|
||||
`);
|
||||
}
|
||||
|
||||
private ensureAutomationSettingsColumns(): void {
|
||||
const names = this.columnNames("automation_settings");
|
||||
const now = sqlString(this.now());
|
||||
@ -322,6 +464,8 @@ export class DatabaseService implements OnModuleDestroy {
|
||||
insertSource.run("openlibrary", this.config.openLibraryEnabled ? 1 : 0, 1, now, now);
|
||||
insertSource.run("googlebooks", 0, 2, now, now);
|
||||
insertSource.run("bnf", 0, 3, now, now);
|
||||
insertSource.run("mangadex", 1, 4, now, now);
|
||||
insertSource.run("comicvine", 0, 5, now, now);
|
||||
|
||||
this.sqlite
|
||||
.prepare(
|
||||
|
||||
@ -23,6 +23,20 @@ export const libraries = sqliteTable("libraries", {
|
||||
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(
|
||||
"books",
|
||||
{
|
||||
@ -30,6 +44,7 @@ export const books = sqliteTable(
|
||||
libraryId: integer("library_id")
|
||||
.notNull()
|
||||
.references(() => libraries.id, { onDelete: "cascade" }),
|
||||
seriesId: integer("series_id").references(() => series.id, { onDelete: "set null" }),
|
||||
title: text("title").notNull(),
|
||||
author: text("author"),
|
||||
description: text("description"),
|
||||
@ -40,9 +55,15 @@ export const books = sqliteTable(
|
||||
language: text("language"),
|
||||
publisher: text("publisher"),
|
||||
publishedDate: text("published_date"),
|
||||
volumeNumber: integer("volume_number"),
|
||||
volumeLabel: text("volume_label"),
|
||||
format: text("format", { enum: ["epub", "pdf", "cbz", "cbr"] }).notNull(),
|
||||
filePath: text("file_path").notNull(),
|
||||
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(),
|
||||
fileMtime: text("file_mtime").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
@ -98,7 +119,7 @@ export const jobs = sqliteTable("jobs", {
|
||||
});
|
||||
|
||||
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),
|
||||
priority: integer("priority").notNull(),
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user