diff --git a/server/src/common/utils/series-index-format.utils.test.ts b/server/src/common/utils/series-index-format.utils.test.ts
index f1debe16d..5787c1b7b 100644
--- a/server/src/common/utils/series-index-format.utils.test.ts
+++ b/server/src/common/utils/series-index-format.utils.test.ts
@@ -29,6 +29,7 @@ describe('formatSeriesIndex', () => {
it('validates and parses exact labels without normalizing their digits', () => {
expect(['5.10', '01', '0.5'].every(isValidSeriesIndex)).toBe(true);
expect(parseSeriesIndex(' 5.10 ')).toBe('5.10');
+ expect(parseSeriesIndex('1.0')).toBe('1.0');
expect(parseSeriesIndex(5.1)).toBe('5.1');
expect(['', ' ', '-1', '1e2', '1.2.3', '123456789012345678901'].some(isValidSeriesIndex)).toBe(false);
});
diff --git a/server/src/main.ts b/server/src/main.ts
index 2d5ca3be3..65186ff59 100644
--- a/server/src/main.ts
+++ b/server/src/main.ts
@@ -107,7 +107,10 @@ async function bootstrap() {
if (!shouldServeSpaFallback(request.url)) {
return reply.status(404).send({ statusCode: 404, message: 'Not Found', path: request.url });
}
- return reply.sendFile('index.html');
+ // Nest's SWC type checker does not retain @fastify/static's reply augmentation
+ // through the adapter callback, even though the plugin decorates this reply at runtime.
+ const staticReply = reply as typeof reply & { sendFile(filename: string): typeof reply };
+ return staticReply.sendFile('index.html');
});
};
diff --git a/server/src/modules/metadata/lib/opf-parser.test.ts b/server/src/modules/metadata/lib/opf-parser.test.ts
index 9908d03d3..9feff0072 100644
--- a/server/src/modules/metadata/lib/opf-parser.test.ts
+++ b/server/src/modules/metadata/lib/opf-parser.test.ts
@@ -340,13 +340,28 @@ describe('parseOpf', () => {
expect(r.seriesIndex).toBe('1');
});
- it('parses fractional series index', () => {
+ it.each([
+ ['1.0', '1'],
+ ['2.0', '2'],
+ ['10.0', '10'],
+ ['0.0', '0'],
+ ['01.0', '01'],
+ ['1', '1'],
+ ['1.5', '1.5'],
+ ['5.10', '5.10'],
+ ['5.01', '5.01'],
+ ['1.00', '1.00'],
+ [' 1.0 ', '1'],
+ ['-1.0', null],
+ ['1e2', null],
+ ['1.0.0', null],
+ ])('maps Calibre series index %s to %s', (embedded, expected) => {
const xml = epub2Opf(`
-
+
`);
- const r = parseOpf(xml);
- expect(r.seriesIndex).toBe('5.10');
+
+ expect(parseOpf(xml).seriesIndex).toBe(expected);
});
it('parses EPUB3 belongs-to-collection series', () => {
@@ -360,6 +375,16 @@ describe('parseOpf', () => {
expect(r.seriesIndex).toBe('1');
});
+ it('preserves an exact EPUB3 group-position ending in .0', () => {
+ const xml = epub3Opf(`
+ Dune Chronicles
+ series
+ 1.0
+ `);
+
+ expect(parseOpf(xml).seriesIndex).toBe('1.0');
+ });
+
it('Calibre series takes precedence over EPUB3 belongs-to-collection', () => {
const xml = epub3Opf(`
diff --git a/server/src/modules/metadata/lib/opf-parser.ts b/server/src/modules/metadata/lib/opf-parser.ts
index ea8e46d24..3a11bd293 100644
--- a/server/src/modules/metadata/lib/opf-parser.ts
+++ b/server/src/modules/metadata/lib/opf-parser.ts
@@ -81,6 +81,15 @@ function parseNumber(raw: string | null): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
+function parseCalibreSeriesIndex(raw: string): string | null {
+ const parsed = parseSeriesIndex(raw);
+ if (parsed === null) return null;
+
+ // Calibre serializes whole-number series positions with a single `.0`; other
+ // fractional digits are exact user-facing labels and must remain untouched.
+ return /^\d+\.0$/.test(parsed) ? parsed.slice(0, -2) : parsed;
+}
+
function parseBookOrbitTags(raw: string | null): string[] {
if (!raw) return [];
try {
@@ -461,7 +470,7 @@ export function parseOpf(xml: string): ParsedOpf {
let seriesIndex: string | null = null;
const rawSeriesIdx = namedMeta('calibre:series_index');
if (rawSeriesIdx) {
- seriesIndex = parseSeriesIndex(rawSeriesIdx);
+ seriesIndex = parseCalibreSeriesIndex(rawSeriesIdx);
}
if (!seriesName) {