diff --git a/src/connectors/audiobookshelf.ts b/src/connectors/audiobookshelf.ts index d2a4b33..d01f5b9 100644 --- a/src/connectors/audiobookshelf.ts +++ b/src/connectors/audiobookshelf.ts @@ -1,6 +1,8 @@ +import { cachedEpub, epubPosition, epubXPath, withEpubMap, type TextEpub } from './bookfusion-epub.js'; import { decideMatch, extractTitleAuthor, type Candidate } from './matching.js'; import type { Connector, + ConnectorContext, Credential, DocumentMeta, ExternalBook, @@ -21,6 +23,11 @@ import type { * `currentTime / duration`, so we map ebook% -> currentTime = pct * duration. * This is approximate (text position != audio time) but lands you at roughly * the right spot. Verified against api.audiobookshelf.org + the ABS source. + * + * When the ABS item also has an EPUB, the device's KOReader XPath is resolved + * against that EPUB into an exact CFI and sent as `ebookLocation` (with + * `ebookProgress`). ABS's built-in reader resumes from `ebookLocation` only, + * so without it "Read" always opened at the start of the book. */ interface AbsCred extends Credential { @@ -188,6 +195,58 @@ async function resolveEdition(cred: Credential, externalId: string, http: HttpTr return d != null ? String(d) : null; } +/** Items recently found to have no downloadable EPUB (audio-only), so pushes skip the lookup. */ +const noEpub = new Map(); +const NO_EPUB_TTL = 60 * 60 * 1000; + +function noEpubKey(c: AbsCred, externalId: string): string { + return `${baseUrl(c.server)}\n${externalId}`; +} + +/** The item's primary EPUB from ABS (`GET /api/items/:id/ebook`), briefly cached (text entries only). */ +function absEpub(http: HttpTransport, c: AbsCred, externalId: string): Promise { + return cachedEpub(['audiobookshelf', baseUrl(c.server), c.token, externalId], async () => { + const res = await http(`${baseUrl(c.server)}/api/items/${encodeURIComponent(externalId)}/ebook`, { + method: 'GET', + headers: { authorization: `Bearer ${c.token}` }, + }); + if (res.status === 404 || res.status === 400) noEpub.set(noEpubKey(c, externalId), Date.now() + NO_EPUB_TTL); + if (res.status < 200 || res.status >= 300) throw new Error(`ABS ebook download failed (${res.status})`); + return res; + }); +} + +function knownNoEpub(c: AbsCred, externalId: string): boolean { + const key = noEpubKey(c, externalId); + const until = noEpub.get(key); + if (until === undefined) return false; + if (until > Date.now()) return true; + noEpub.delete(key); + return false; +} + +/** Test hook: forget which items were found to have no EPUB. */ +export function clearAbsNoEpubCache(): void { + noEpub.clear(); +} + +/** + * Resolve a KOReader XPath to an EPUB CFI in the ABS item's own EPUB. Null when + * the item has no EPUB or the position can't be placed - the push still lands + * the percentage, it just can't give ABS's reader an exact resume point. + */ +async function resolveCfi( + http: HttpTransport, c: AbsCred, m: Match, xpath: string, ctx?: ConnectorContext +): Promise { + if (knownNoEpub(c, m.externalId)) return null; + try { + const at = await withEpubMap(ctx, 'audiobookshelf', m.externalId, () => absEpub(http, c, m.externalId), (b) => epubPosition(b, xpath)); + return at.cfi; + } catch { + return null; + } +} + /** * Pull listening-position changes for bidirectional sync: read the user's media * progress and emit anything updated since the cursor. ABS derives `progress` @@ -203,15 +262,29 @@ async function pullChanges(cred: Credential, http: HttpTransport, sinceMs: numbe if (p?.episodeId) continue; // books only, not podcast episodes const lu = typeof p?.lastUpdate === 'number' ? p.lastUpdate : 0; if (lu <= sinceMs) continue; + if (p?.libraryItemId == null) continue; + const externalId = String(p.libraryItemId); + const hasAudio = typeof p?.duration === 'number' && p.duration > 0; let pct = typeof p?.progress === 'number' ? p.progress : 0; - if (!pct && typeof p?.currentTime === 'number' && typeof p?.duration === 'number' && p.duration > 0) { + if (!pct && hasAudio && typeof p?.currentTime === 'number') { pct = p.currentTime / p.duration; } - if (p?.libraryItemId == null) continue; + // Ebook-only items are read in ABS's own reader, which records its position + // as ebookProgress + an ebookLocation CFI rather than `progress`. Items with + // audio keep the listening position (that is the audiobook<->ebook sync). + let progress: string | undefined; + if (!hasAudio && typeof p?.ebookProgress === 'number') { + pct = p.ebookProgress; + const cfi = p?.ebookLocation; + if (typeof cfi === 'string' && cfi.startsWith('epubcfi(') && !knownNoEpub(c, externalId)) { + progress = await absEpub(http, c, externalId).then((b) => epubXPath(b, cfi)).catch(() => undefined); + } + } out.push({ - externalId: String(p.libraryItemId), + externalId, percentage: Math.max(0, Math.min(1, pct)), finished: !!p?.isFinished, + ...(progress ? { progress } : {}), updatedAtMs: lu, }); } @@ -222,7 +295,8 @@ async function push( cred: Credential, m: Match, ev: OutboundEvent, - http: HttpTransport + http: HttpTransport, + ctx?: ConnectorContext ): Promise { const c = parseCred(cred); if (!c) return { ok: false, retryable: false, needsReauth: true, error: 'bad credential' }; @@ -246,6 +320,14 @@ async function push( payload.currentTime = Math.max(0, Math.min(duration, pct * duration)); } + // The ebook side of the same progress record. ABS's reader shows ebookProgress + // and resumes from ebookLocation (a CFI); `progress` alone opens at page one. + payload.ebookProgress = finished ? 1 : pct; + if (!finished && ev.progress?.startsWith('/body')) { + const cfi = await resolveCfi(http, c, m, ev.progress, ctx); + if (cfi) payload.ebookLocation = cfi; + } + const res = await http(`${baseUrl(c.server)}/api/me/progress/${encodeURIComponent(m.externalId)}`, { method: 'PATCH', headers: authHeaders(c.token), diff --git a/test/audiobookshelf-ebook.test.ts b/test/audiobookshelf-ebook.test.ts new file mode 100644 index 0000000..f728de6 --- /dev/null +++ b/test/audiobookshelf-ebook.test.ts @@ -0,0 +1,172 @@ +import { crc32 } from 'node:zlib'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { clearBookFusionEpubCache, epubXPath } from '../src/connectors/bookfusion-epub.js'; +import { audiobookshelfConnector, clearAbsNoEpubCache } from '../src/connectors/audiobookshelf.js'; +import type { HttpTransport } from '../src/connectors/types.js'; + +// Stored-ZIP EPUB fixture (same shape as bookfusion-position.test.ts). +function archive(files: Record): Buffer { + const local: Buffer[] = [], directory: Buffer[] = []; + let offset = 0; + for (const [path, source] of Object.entries(files)) { + const name = Buffer.from(path), data = Buffer.from(source); + const header = Buffer.alloc(30), central = Buffer.alloc(46); + header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4); + header.writeUInt32LE(crc32(data), 14); + header.writeUInt32LE(data.length, 18); header.writeUInt32LE(data.length, 22); + header.writeUInt16LE(name.length, 26); + central.writeUInt32LE(0x02014b50); central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(name.length, 28); central.writeUInt32LE(offset, 42); + local.push(header, name, data); directory.push(central, name); + offset += header.length + name.length + data.length; + } + const end = Buffer.alloc(22), entries = Object.keys(files).length; + end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries, 8); end.writeUInt16LE(entries, 10); + end.writeUInt32LE(Buffer.concat(directory).length, 12); end.writeUInt32LE(offset, 16); + return Buffer.concat([...local, ...directory, end]); +} + +function epub(): Buffer { + return archive({ + 'META-INF/container.xml': '', + 'EPUB/package.opf': '', + 'Text/ch1.xhtml': '

First

', + 'Text/ch2.xhtml': '

Title

Pre😀bold tail&end

', + }); +} + +const XPATH = '/body/DocFragment[2]/body/p[1]/text()[2].5'; +const CFI = 'epubcfi(/8/4!/6/4/3:5)'; +const CRED = { server: 'abs.test', token: 'k' }; +const MATCH = { externalId: 'li_1', externalEdition: null, confidence: 1 }; + +/** Fake ABS: serves the EPUB (or 404s for audio-only) and records progress PATCHes. */ +function absServer(opts: { ebook?: Buffer | null; me?: unknown; itemDuration?: number } = {}) { + const calls: { url: string; method: string; body?: string }[] = []; + const http: HttpTransport = async (url, init) => { + calls.push({ url, method: init.method, body: init.body }); + const ok = (body: unknown) => ({ status: 200, text: async () => JSON.stringify(body), json: async () => body }); + if (url.endsWith('/api/items/li_1/ebook')) { + if (!opts.ebook) return { status: 404, text: async () => 'Not Found', json: async () => null }; + const bytes = opts.ebook; + return { + status: 200, text: async () => '', json: async () => null, + body: new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); } }), + }; + } + if (url.includes('/api/items/li_1')) return ok({ media: opts.itemDuration ? { duration: opts.itemDuration } : {} }); + if (url.includes('/api/me/progress/li_1')) return ok({}); + if (url.endsWith('/api/me')) return ok(opts.me ?? {}); + throw new Error(`Unexpected request: ${url}`); + }; + const patches = () => calls.filter((c) => c.method === 'PATCH').map((c) => JSON.parse(c.body!)); + const ebookFetches = () => calls.filter((c) => c.url.endsWith('/ebook')).length; + return { http, calls, patches, ebookFetches }; +} + +beforeEach(() => { + clearBookFusionEpubCache(); + clearAbsNoEpubCache(); +}); + +describe('audiobookshelf ebook position (push)', () => { + it('resolves the device XPath to a CFI in the ABS EPUB and sends ebookLocation', async () => { + const abs = absServer({ ebook: epub() }); + const r = await audiobookshelfConnector.push( + CRED, MATCH, { kind: 'progress', document: 'd', percentage: 0.16, progress: XPATH, timestamp: 1 }, abs.http + ); + expect(r.ok).toBe(true); + const [body] = abs.patches(); + expect(body).toMatchObject({ progress: 0.16, ebookProgress: 0.16, ebookLocation: CFI }); + expect(body.isFinished).toBeUndefined(); + // The EPUB request carries the ABS bearer token. + expect(abs.calls.find((c) => c.url.endsWith('/ebook'))?.url).toBe('https://abs.test/api/items/li_1/ebook'); + }); + + it('round-trips: the CFI sent to ABS maps back to the same XPath', async () => { + const abs = absServer({ ebook: epub() }); + await audiobookshelfConnector.push( + CRED, MATCH, { kind: 'progress', document: 'd', percentage: 0.5, progress: XPATH, timestamp: 1 }, abs.http + ); + expect(await epubXPath(epub(), abs.patches()[0].ebookLocation)).toBe(XPATH); + }); + + it('still pushes progress for audio-only items, and stops asking for an EPUB that is not there', async () => { + const abs = absServer({ ebook: null, itemDuration: 1000 }); + const ev = { kind: 'progress' as const, document: 'd', percentage: 0.5, progress: XPATH, timestamp: 1 }; + expect((await audiobookshelfConnector.push(CRED, MATCH, ev, abs.http)).ok).toBe(true); + expect((await audiobookshelfConnector.push(CRED, MATCH, ev, abs.http)).ok).toBe(true); + const [first] = abs.patches(); + expect(first).toMatchObject({ progress: 0.5, ebookProgress: 0.5, currentTime: 500, duration: 1000 }); + expect(first.ebookLocation).toBeUndefined(); + expect(abs.ebookFetches()).toBe(1); // second push skipped the known-missing EPUB + }); + + it('sends only ebookProgress when the device sent no XPath', async () => { + const abs = absServer({ ebook: epub() }); + await audiobookshelfConnector.push(CRED, MATCH, { kind: 'progress', document: 'd', percentage: 0.3, timestamp: 1 }, abs.http); + const [body] = abs.patches(); + expect(body).toMatchObject({ progress: 0.3, ebookProgress: 0.3 }); + expect(body.ebookLocation).toBeUndefined(); + expect(abs.ebookFetches()).toBe(0); + }); + + it('an XPath that does not resolve in this EPUB still saves the percentage', async () => { + const abs = absServer({ ebook: epub() }); + const r = await audiobookshelfConnector.push( + CRED, MATCH, { kind: 'progress', document: 'd', percentage: 0.4, progress: '/body/DocFragment[9]/body/p[1]', timestamp: 1 }, abs.http + ); + expect(r.ok).toBe(true); + const [body] = abs.patches(); + expect(body).toMatchObject({ progress: 0.4, ebookProgress: 0.4 }); + expect(body.ebookLocation).toBeUndefined(); + }); + + it('finishing marks the ebook finished without a resume location', async () => { + const abs = absServer({ ebook: epub() }); + await audiobookshelfConnector.push( + CRED, MATCH, { kind: 'finished', document: 'd', percentage: 1, progress: XPATH, timestamp: 1 }, abs.http + ); + const [body] = abs.patches(); + expect(body).toMatchObject({ progress: 1, ebookProgress: 1, isFinished: true }); + expect(body.ebookLocation).toBeUndefined(); + }); +}); + +describe('audiobookshelf ebook position (pull)', () => { + it('reads ABS reader progress for ebook-only items and maps its CFI to an exact XPath', async () => { + const abs = absServer({ + ebook: epub(), + me: { mediaProgress: [ + { libraryItemId: 'li_1', episodeId: null, duration: 0, progress: 0, ebookProgress: 0.42, ebookLocation: CFI, isFinished: false, lastUpdate: 2000 }, + ] }, + }); + const changes = await audiobookshelfConnector.pullChanges!(CRED, abs.http, 1000); + expect(changes).toEqual([{ externalId: 'li_1', percentage: 0.42, finished: false, progress: XPATH, updatedAtMs: 2000 }]); + }); + + it('keeps the listening position for items with audio', async () => { + const abs = absServer({ + ebook: epub(), + me: { mediaProgress: [ + { libraryItemId: 'li_1', episodeId: null, duration: 1000, currentTime: 600, progress: 0.6, ebookProgress: 0.1, ebookLocation: CFI, isFinished: false, lastUpdate: 2000 }, + ] }, + }); + const changes = await audiobookshelfConnector.pullChanges!(CRED, abs.http, 1000); + expect(changes).toEqual([{ externalId: 'li_1', percentage: 0.6, finished: false, updatedAtMs: 2000 }]); + expect(abs.ebookFetches()).toBe(0); + }); + + it('falls back to the percentage when the CFI cannot be placed', async () => { + const abs = absServer({ + ebook: epub(), + me: { mediaProgress: [ + { libraryItemId: 'li_1', episodeId: null, duration: 0, ebookProgress: 0.7, ebookLocation: 'epubcfi(/8/40!/6/2)', isFinished: false, lastUpdate: 2000 }, + ] }, + }); + const changes = await audiobookshelfConnector.pullChanges!(CRED, abs.http, 1000); + expect(changes).toEqual([{ externalId: 'li_1', percentage: 0.7, finished: false, updatedAtMs: 2000 }]); + }); +});