diff --git a/server/src/modules/metadata/lib/opf-parser.test.ts b/server/src/modules/metadata/lib/opf-parser.test.ts
index 2de541cc5..302151ad9 100644
--- a/server/src/modules/metadata/lib/opf-parser.test.ts
+++ b/server/src/modules/metadata/lib/opf-parser.test.ts
@@ -448,6 +448,200 @@ describe('parseOpf', () => {
expect(r.ranobedbId).toBeNull();
expect(r.itunesId).toBeNull();
});
+
+ describe('Calibre prefix:value identifiers (opf3 fallback)', () => {
+ it('parses every known provider prefix from bare prefix:value text', () => {
+ const xml = epub3Opf(`
+ amazon:B0G3YRNY6Y
+ goodreads:244564568
+ google:ABCD1234
+ openlibrary:OL99999999W
+ hardcover:test-book-slug
+ kobo:test-kobo-id
+ itunes:987654321
+ lubimyczytac:lub-99999
+ ranobedb:ranobe-999
+ `);
+ const r = parseOpf(xml);
+ expect(r.amazonId).toBe('B0G3YRNY6Y');
+ expect(r.goodreadsId).toBe('244564568');
+ expect(r.googleBooksId).toBe('ABCD1234');
+ expect(r.openLibraryId).toBe('OL99999999W');
+ expect(r.hardcoverId).toBe('test-book-slug');
+ expect(r.koboId).toBe('test-kobo-id');
+ expect(r.itunesId).toBe('987654321');
+ expect(r.lubimyczytacId).toBe('lub-99999');
+ expect(r.ranobedbId).toBe('ranobe-999');
+ });
+
+ it('maps both asin: and mobi-asin: to amazonId', () => {
+ expect(parseOpf(epub3Opf(`asin:B0G3YRNY6Y`)).amazonId).toBe('B0G3YRNY6Y');
+ expect(parseOpf(epub3Opf(`mobi-asin:B0ABCDEFGH`)).amazonId).toBe('B0ABCDEFGH');
+ });
+
+ it('lets opf:scheme win over prefix:value for the same provider', () => {
+ const xml = epub2Opf(`
+ amazon:PREFIX_ASIN
+ SCHEME_ASIN
+ `);
+ expect(parseOpf(xml).amazonId).toBe('SCHEME_ASIN');
+ });
+
+ it('lets urn: win over prefix:value for the same provider', () => {
+ const xml = epub2Opf(`
+ amazon:PREFIX_ASIN
+ urn:amazon:URN_ASIN
+ `);
+ expect(parseOpf(xml).amazonId).toBe('URN_ASIN');
+ });
+
+ it('uses prefix:value only as a fallback when scheme and urn are absent', () => {
+ const xml = epub3Opf(`amazon:ONLY_PREFIX`);
+ expect(parseOpf(xml).amazonId).toBe('ONLY_PREFIX');
+ });
+
+ it('ignores unknown prefixes', () => {
+ const xml = epub3Opf(`
+ uuid:123e4567-e89b-12d3-a456-426614174000
+ calibre:12345
+ doi:10.1000/xyz123
+ `);
+ const r = parseOpf(xml);
+ expect(r.amazonId).toBeNull();
+ expect(r.googleBooksId).toBeNull();
+ expect(r.goodreadsId).toBeNull();
+ expect(r.openLibraryId).toBeNull();
+ });
+
+ it('still parses isbn:VALUE as an ISBN, not a provider id', () => {
+ const r = parseOpf(epub3Opf(`isbn:9780441013593`));
+ expect(r.isbn13).toBe('9780441013593');
+ expect(r.amazonId).toBeNull();
+ expect(r.googleBooksId).toBeNull();
+ });
+
+ it('splits on the first colon only, keeping colons in the id', () => {
+ const r = parseOpf(epub3Opf(`hardcover:some:slug`));
+ expect(r.hardcoverId).toBe('some:slug');
+ });
+
+ it('matches the prefix case-insensitively while preserving the id case', () => {
+ const r = parseOpf(epub3Opf(`AMAZON:B0G3YRNY6Y`));
+ expect(r.amazonId).toBe('B0G3YRNY6Y');
+ });
+
+ it('ignores a known prefix with an empty value', () => {
+ const r = parseOpf(epub3Opf(`amazon:`));
+ expect(r.amazonId).toBeNull();
+ });
+
+ it('ignores a bare identifier with no colon', () => {
+ const r = parseOpf(epub3Opf(`SOMEBAREID`));
+ expect(r.amazonId).toBeNull();
+ expect(r.googleBooksId).toBeNull();
+ });
+
+ it('parses a full real-world Calibre 9.x opf3 identifier block', () => {
+ const xml = epub3Opf(`
+ urn:uuid:5f3c2b1a-0000-4444-8888-aaaabbbbcccc
+ amazon:B0G3YRNY6Y
+ goodreads:244564568
+ google:ABCD1234
+ `);
+ const r = parseOpf(xml);
+ expect(r.amazonId).toBe('B0G3YRNY6Y');
+ expect(r.goodreadsId).toBe('244564568');
+ expect(r.googleBooksId).toBe('ABCD1234');
+ });
+ });
+ });
+
+ describe('Calibre user_metadata (pageCount & subtitle)', () => {
+ it('parses page count from a #pagecount custom column', () => {
+ const xml = epub3Opf(`{"#pagecount":{"#value#":353}}`);
+ expect(parseOpf(xml).pageCount).toBe(353);
+ });
+
+ it('parses page count from a #page_count custom column', () => {
+ const xml = epub3Opf(`{"#page_count":{"#value#":400}}`);
+ expect(parseOpf(xml).pageCount).toBe(400);
+ });
+
+ it('parses a string numeric page count value', () => {
+ const xml = epub3Opf(`{"#page_count":{"#value#":"400"}}`);
+ expect(parseOpf(xml).pageCount).toBe(400);
+ });
+
+ it('parses subtitle from a #subtitle custom column', () => {
+ const xml = epub3Opf(`{"#subtitle":{"#value#":"A Subtitle"}}`);
+ expect(parseOpf(xml).subtitle).toBe('A Subtitle');
+ });
+
+ it('parses page count and subtitle together from one blob (issue sample)', () => {
+ const xml = epub3Opf(`{"#pagecount":{"#value#":353},"#subtitle":{"#value#":"TEST-SUBTITLE"}}`);
+ const r = parseOpf(xml);
+ expect(r.pageCount).toBe(353);
+ expect(r.subtitle).toBe('TEST-SUBTITLE');
+ });
+
+ it('prefers bookorbit:page_count over the Calibre user_metadata page count', () => {
+ const xml = epub3Opf(`
+ 100
+ {"#pagecount":{"#value#":353}}
+ `);
+ expect(parseOpf(xml).pageCount).toBe(100);
+ });
+
+ it('prefers the EPUB3 title-type subtitle over the Calibre user_metadata subtitle', () => {
+ const xml = epub3Opf(`
+ Main Title
+ Real Subtitle
+ subtitle
+ {"#subtitle":{"#value#":"Calibre Subtitle"}}
+ `);
+ expect(parseOpf(xml).subtitle).toBe('Real Subtitle');
+ });
+
+ it('ignores a malformed user_metadata JSON blob without throwing', () => {
+ const xml = epub3Opf(`{not valid json}`);
+ const r = parseOpf(xml);
+ expect(r.pageCount).toBeNull();
+ expect(r.subtitle).toBeNull();
+ });
+
+ it('ignores a zero or negative page count value', () => {
+ expect(parseOpf(epub3Opf(`{"#pagecount":{"#value#":0}}`)).pageCount).toBeNull();
+ expect(parseOpf(epub3Opf(`{"#pagecount":{"#value#":-5}}`)).pageCount).toBeNull();
+ });
+
+ it('ignores a non-string subtitle value', () => {
+ const xml = epub3Opf(`{"#subtitle":{"#value#":42}}`);
+ expect(parseOpf(xml).subtitle).toBeNull();
+ });
+
+ it('leaves fields null when the blob has only unrelated columns', () => {
+ const xml = epub3Opf(`{"#myrating":{"#value#":5}}`);
+ const r = parseOpf(xml);
+ expect(r.pageCount).toBeNull();
+ expect(r.subtitle).toBeNull();
+ });
+
+ it('ignores a blob that is a JSON array rather than an object', () => {
+ const r = parseOpf(epub3Opf(`[1,2,3]`));
+ expect(r.pageCount).toBeNull();
+ expect(r.subtitle).toBeNull();
+ });
+
+ it('ignores a column whose value is not an object with a #value# key', () => {
+ const r = parseOpf(epub3Opf(`{"#pagecount":353,"#subtitle":{}}`));
+ expect(r.pageCount).toBeNull();
+ expect(r.subtitle).toBeNull();
+ });
+
+ it('ignores a non-numeric page count value', () => {
+ const r = parseOpf(epub3Opf(`{"#pagecount":{"#value#":"lots"}}`));
+ expect(r.pageCount).toBeNull();
+ });
});
describe('graceful handling of bad input', () => {
diff --git a/server/src/modules/metadata/lib/opf-parser.ts b/server/src/modules/metadata/lib/opf-parser.ts
index 6fb40ff23..24df22a73 100644
--- a/server/src/modules/metadata/lib/opf-parser.ts
+++ b/server/src/modules/metadata/lib/opf-parser.ts
@@ -86,6 +86,55 @@ function parseBookOrbitTags(raw: string | null): string[] {
}
}
+type ProviderKey = 'google' | 'amazon' | 'goodreads' | 'hardcover' | 'openlibrary' | 'ranobedb' | 'kobo' | 'lubimyczytac' | 'itunes';
+
+// Calibre 9.x (opf3) writes provider identifiers as bare `prefix:value` text inside .
+// Only these known prefixes are recognized, as a lowest-priority fallback after opf:scheme and urn:.
+const PREFIX_TO_PROVIDER: Record = {
+ amazon: 'amazon',
+ asin: 'amazon',
+ 'mobi-asin': 'amazon',
+ goodreads: 'goodreads',
+ google: 'google',
+ openlibrary: 'openlibrary',
+ hardcover: 'hardcover',
+ kobo: 'kobo',
+ itunes: 'itunes',
+ lubimyczytac: 'lubimyczytac',
+ ranobedb: 'ranobedb',
+};
+
+// Calibre stores custom-column values in a `calibre:user_metadata` JSON blob keyed by column name,
+// each value carrying the actual value under `#value#`. Used only to fill page count / subtitle when null.
+function parseCalibreUserMetadata(raw: string | null): { pageCount: number | null; subtitle: string | null } {
+ const empty = { pageCount: null, subtitle: null };
+ if (!raw) return empty;
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return empty;
+ }
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return empty;
+
+ const cols = parsed as Record;
+ const columnValue = (key: string): unknown => {
+ const col = cols[key];
+ return typeof col === 'object' && col !== null && '#value#' in col ? (col as Record)['#value#'] : undefined;
+ };
+ const coercePageCount = (v: unknown): number | null => {
+ if (v === undefined || v === null) return null;
+ const n = Number(v);
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
+ };
+
+ const pageCount = coercePageCount(columnValue('#pagecount')) ?? coercePageCount(columnValue('#page_count'));
+ const subRaw = columnValue('#subtitle');
+ const subtitle = typeof subRaw === 'string' && subRaw.trim() ? subRaw.trim() : null;
+ return { pageCount, subtitle };
+}
+
function normalizeCreatorRole(role: string | null | undefined): string {
if (!role) return '';
const trimmed = role.trim().toLowerCase();
@@ -153,6 +202,8 @@ export function parseOpf(xml: string): ParsedOpf {
return null;
}
+ const calibreUser = parseCalibreUserMetadata(propertyMeta('calibre:user_metadata'));
+
// ── Titles ─────────────────────────────────────────────────────────────────
let title: string | null = null;
let subtitle: string | null = null;
@@ -172,6 +223,7 @@ export function parseOpf(xml: string): ParsedOpf {
title ??= getText(rawTitles[0]);
}
subtitle ??= namedMeta('bookorbit:subtitle');
+ subtitle ??= calibreUser.subtitle;
// ── Authors ────────────────────────────────────────────────────────────────
const authors: { name: string; sortName: string | null }[] = [];
@@ -222,6 +274,9 @@ export function parseOpf(xml: string): ParsedOpf {
let urnLubimyczytacId: string | null = null;
let urnItunesId: string | null = null;
+ // Calibre prefix:value identifiers — lowest priority, resolved after scheme and urn.
+ const prefixIds: Partial> = {};
+
for (const ident of toArray(metadata['identifier'])) {
const mo = (typeof ident === 'object' && ident !== null ? ident : {}) as Record;
const scheme = ((mo['@_opf:scheme'] ?? mo['@_scheme'] ?? '') as string).toLowerCase().trim();
@@ -256,25 +311,38 @@ export function parseOpf(xml: string): ParsedOpf {
if (value.startsWith('urn:kobo:')) urnKoboId ??= value.slice('urn:kobo:'.length) || null;
if (value.startsWith('urn:lubimyczytac:')) urnLubimyczytacId ??= value.slice('urn:lubimyczytac:'.length) || null;
if (value.startsWith('urn:itunes:')) urnItunesId ??= value.slice('urn:itunes:'.length) || null;
+
+ // Calibre prefix:value — only when there is no opf:scheme attribute and the value is not a urn:
+ if (scheme === '' && !value.startsWith('urn:')) {
+ const colon = value.indexOf(':');
+ if (colon > 0) {
+ const provider = PREFIX_TO_PROVIDER[value.slice(0, colon).toLowerCase()];
+ if (provider) {
+ const id = value.slice(colon + 1).trim();
+ if (id) prefixIds[provider] ??= id;
+ }
+ }
+ }
}
- // opf:scheme wins over urn: when both are present
- const googleBooksId = schemeGoogleBooksId ?? urnGoogleBooksId;
- const goodreadsId = schemeGoodreadsId ?? urnGoodreadsId;
- const amazonId = schemeAmazonId ?? urnAmazonId;
- const hardcoverId = schemeHardcoverId ?? urnHardcoverId;
- const openLibraryId = schemeOpenLibraryId ?? urnOpenLibraryId;
- const ranobedbId = schemeRanobedbId ?? urnRanobedbId;
- const koboId = schemeKoboId ?? urnKoboId;
- const lubimyczytacId = schemeLubimyczytacId ?? urnLubimyczytacId;
- const itunesId = schemeItunesId ?? urnItunesId;
+ // Priority: opf:scheme → urn: → Calibre prefix:value
+ const googleBooksId = schemeGoogleBooksId ?? urnGoogleBooksId ?? prefixIds.google ?? null;
+ const goodreadsId = schemeGoodreadsId ?? urnGoodreadsId ?? prefixIds.goodreads ?? null;
+ const amazonId = schemeAmazonId ?? urnAmazonId ?? prefixIds.amazon ?? null;
+ const hardcoverId = schemeHardcoverId ?? urnHardcoverId ?? prefixIds.hardcover ?? null;
+ const openLibraryId = schemeOpenLibraryId ?? urnOpenLibraryId ?? prefixIds.openlibrary ?? null;
+ const ranobedbId = schemeRanobedbId ?? urnRanobedbId ?? prefixIds.ranobedb ?? null;
+ const koboId = schemeKoboId ?? urnKoboId ?? prefixIds.kobo ?? null;
+ const lubimyczytacId = schemeLubimyczytacId ?? urnLubimyczytacId ?? prefixIds.lubimyczytac ?? null;
+ const itunesId = schemeItunesId ?? urnItunesId ?? prefixIds.itunes ?? null;
isbn10 ??= propertyMeta('bookorbit:isbn10') ?? namedMeta('bookorbit:isbn10');
// ── Genres and tags ────────────────────────────────────────────────────────
const genres = toArray(metadata['subject']).map(getText).filter(Boolean);
const tags = parseBookOrbitTags(propertyMeta('bookorbit:tags') ?? namedMeta('bookorbit:tags'));
- const pageCount = parseNumber(propertyMeta('bookorbit:page_count') ?? namedMeta('bookorbit:page_count'));
+ let pageCount = parseNumber(propertyMeta('bookorbit:page_count') ?? namedMeta('bookorbit:page_count'));
+ pageCount ??= calibreUser.pageCount;
const rating = parseNumber(propertyMeta('bookorbit:rating') ?? namedMeta('bookorbit:rating'));
// ── Series (Calibre EPUB2, then EPUB3) ────────────────────────────────────