feat(metadata): support calibre 9.x opf3 metadata formats

Calibre 9.x (opf3) writes provider identifiers as bare `prefix:value` text
inside `<dc:identifier>` instead of using `opf:scheme` attributes. It also
stores custom-column values like page count and subtitle in a
`calibre:user_metadata` JSON blob. This adds parsing for these fallback
formats to correctly extract metadata from Calibre-processed EPUBs.

Closes #341
This commit is contained in:
neon
2026-06-15 04:26:27 +00:00
parent 1b8454f557
commit d13fc929e3
2 changed files with 273 additions and 11 deletions
@@ -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(`
<dc:identifier>amazon:B0G3YRNY6Y</dc:identifier>
<dc:identifier>goodreads:244564568</dc:identifier>
<dc:identifier>google:ABCD1234</dc:identifier>
<dc:identifier>openlibrary:OL99999999W</dc:identifier>
<dc:identifier>hardcover:test-book-slug</dc:identifier>
<dc:identifier>kobo:test-kobo-id</dc:identifier>
<dc:identifier>itunes:987654321</dc:identifier>
<dc:identifier>lubimyczytac:lub-99999</dc:identifier>
<dc:identifier>ranobedb:ranobe-999</dc:identifier>
`);
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(`<dc:identifier>asin:B0G3YRNY6Y</dc:identifier>`)).amazonId).toBe('B0G3YRNY6Y');
expect(parseOpf(epub3Opf(`<dc:identifier>mobi-asin:B0ABCDEFGH</dc:identifier>`)).amazonId).toBe('B0ABCDEFGH');
});
it('lets opf:scheme win over prefix:value for the same provider', () => {
const xml = epub2Opf(`
<dc:identifier>amazon:PREFIX_ASIN</dc:identifier>
<dc:identifier opf:scheme="AMAZON">SCHEME_ASIN</dc:identifier>
`);
expect(parseOpf(xml).amazonId).toBe('SCHEME_ASIN');
});
it('lets urn: win over prefix:value for the same provider', () => {
const xml = epub2Opf(`
<dc:identifier>amazon:PREFIX_ASIN</dc:identifier>
<dc:identifier>urn:amazon:URN_ASIN</dc:identifier>
`);
expect(parseOpf(xml).amazonId).toBe('URN_ASIN');
});
it('uses prefix:value only as a fallback when scheme and urn are absent', () => {
const xml = epub3Opf(`<dc:identifier>amazon:ONLY_PREFIX</dc:identifier>`);
expect(parseOpf(xml).amazonId).toBe('ONLY_PREFIX');
});
it('ignores unknown prefixes', () => {
const xml = epub3Opf(`
<dc:identifier>uuid:123e4567-e89b-12d3-a456-426614174000</dc:identifier>
<dc:identifier>calibre:12345</dc:identifier>
<dc:identifier>doi:10.1000/xyz123</dc:identifier>
`);
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(`<dc:identifier>isbn:9780441013593</dc:identifier>`));
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(`<dc:identifier>hardcover:some:slug</dc:identifier>`));
expect(r.hardcoverId).toBe('some:slug');
});
it('matches the prefix case-insensitively while preserving the id case', () => {
const r = parseOpf(epub3Opf(`<dc:identifier>AMAZON:B0G3YRNY6Y</dc:identifier>`));
expect(r.amazonId).toBe('B0G3YRNY6Y');
});
it('ignores a known prefix with an empty value', () => {
const r = parseOpf(epub3Opf(`<dc:identifier>amazon:</dc:identifier>`));
expect(r.amazonId).toBeNull();
});
it('ignores a bare identifier with no colon', () => {
const r = parseOpf(epub3Opf(`<dc:identifier>SOMEBAREID</dc:identifier>`));
expect(r.amazonId).toBeNull();
expect(r.googleBooksId).toBeNull();
});
it('parses a full real-world Calibre 9.x opf3 identifier block', () => {
const xml = epub3Opf(`
<dc:identifier>urn:uuid:5f3c2b1a-0000-4444-8888-aaaabbbbcccc</dc:identifier>
<dc:identifier>amazon:B0G3YRNY6Y</dc:identifier>
<dc:identifier>goodreads:244564568</dc:identifier>
<dc:identifier>google:ABCD1234</dc:identifier>
`);
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(`<meta property="calibre:user_metadata">{"#pagecount":{"#value#":353}}</meta>`);
expect(parseOpf(xml).pageCount).toBe(353);
});
it('parses page count from a #page_count custom column', () => {
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#page_count":{"#value#":400}}</meta>`);
expect(parseOpf(xml).pageCount).toBe(400);
});
it('parses a string numeric page count value', () => {
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#page_count":{"#value#":"400"}}</meta>`);
expect(parseOpf(xml).pageCount).toBe(400);
});
it('parses subtitle from a #subtitle custom column', () => {
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#subtitle":{"#value#":"A Subtitle"}}</meta>`);
expect(parseOpf(xml).subtitle).toBe('A Subtitle');
});
it('parses page count and subtitle together from one blob (issue sample)', () => {
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#pagecount":{"#value#":353},"#subtitle":{"#value#":"TEST-SUBTITLE"}}</meta>`);
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(`
<meta property="bookorbit:page_count">100</meta>
<meta property="calibre:user_metadata">{"#pagecount":{"#value#":353}}</meta>
`);
expect(parseOpf(xml).pageCount).toBe(100);
});
it('prefers the EPUB3 title-type subtitle over the Calibre user_metadata subtitle', () => {
const xml = epub3Opf(`
<dc:title id="t1">Main Title</dc:title>
<dc:title id="t2">Real Subtitle</dc:title>
<meta refines="#t2" property="title-type">subtitle</meta>
<meta property="calibre:user_metadata">{"#subtitle":{"#value#":"Calibre Subtitle"}}</meta>
`);
expect(parseOpf(xml).subtitle).toBe('Real Subtitle');
});
it('ignores a malformed user_metadata JSON blob without throwing', () => {
const xml = epub3Opf(`<meta property="calibre:user_metadata">{not valid json}</meta>`);
const r = parseOpf(xml);
expect(r.pageCount).toBeNull();
expect(r.subtitle).toBeNull();
});
it('ignores a zero or negative page count value', () => {
expect(parseOpf(epub3Opf(`<meta property="calibre:user_metadata">{"#pagecount":{"#value#":0}}</meta>`)).pageCount).toBeNull();
expect(parseOpf(epub3Opf(`<meta property="calibre:user_metadata">{"#pagecount":{"#value#":-5}}</meta>`)).pageCount).toBeNull();
});
it('ignores a non-string subtitle value', () => {
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#subtitle":{"#value#":42}}</meta>`);
expect(parseOpf(xml).subtitle).toBeNull();
});
it('leaves fields null when the blob has only unrelated columns', () => {
const xml = epub3Opf(`<meta property="calibre:user_metadata">{"#myrating":{"#value#":5}}</meta>`);
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(`<meta property="calibre:user_metadata">[1,2,3]</meta>`));
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(`<meta property="calibre:user_metadata">{"#pagecount":353,"#subtitle":{}}</meta>`));
expect(r.pageCount).toBeNull();
expect(r.subtitle).toBeNull();
});
it('ignores a non-numeric page count value', () => {
const r = parseOpf(epub3Opf(`<meta property="calibre:user_metadata">{"#pagecount":{"#value#":"lots"}}</meta>`));
expect(r.pageCount).toBeNull();
});
});
describe('graceful handling of bad input', () => {
+79 -11
View File
@@ -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 <dc:identifier>.
// Only these known prefixes are recognized, as a lowest-priority fallback after opf:scheme and urn:.
const PREFIX_TO_PROVIDER: Record<string, ProviderKey> = {
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<string, unknown>;
const columnValue = (key: string): unknown => {
const col = cols[key];
return typeof col === 'object' && col !== null && '#value#' in col ? (col as Record<string, unknown>)['#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<Record<ProviderKey, string>> = {};
for (const ident of toArray(metadata['identifier'])) {
const mo = (typeof ident === 'object' && ident !== null ? ident : {}) as Record<string, unknown>;
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) ────────────────────────────────────