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) ────────────────────────────────────