feat(api,web): modélisation série + volume et parcours de série

- Table series + colonnes seriesId/volumeNumber/volumeLabel sur les livres,
  extraction souple des numéros (T03, 003, etc.) via extract-series-volume
- Contrôleur et page de série, badge volume sur les cartes de livre,
  styles associés (fond/clarté série, ajustements globaux)
- Statuts de scan/enrichissement et provenance des métadonnées en base

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-24 09:11:10 +02:00
parent 34159e5e9b
commit 93bb40a8cd
24 changed files with 1481 additions and 76 deletions

View File

@ -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");
});
});

View 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();
}