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:
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