From 360c01573837e70a94f92900cffe8e53890d5698 Mon Sep 17 00:00:00 2001 From: jvanantwerp4507 Date: Sun, 11 Oct 2026 03:27:31 -0600 Subject: [PATCH] audiobookshelf: send ebookLocation so ABS's reader resumes in place ABS's built-in EPUB reader resumes only from mediaProgress.ebookLocation (an epubcfi), so after a sync "Read" always opened at page one even though the progress percentage was correct. Push: resolve the device's KOReader XPath against the item's EPUB (GET /api/items/:id/ebook) with the existing epubPosition helper and send ebookLocation + ebookProgress alongside progress/currentTime. The EPUB map is stored per book via withEpubMap, items with no EPUB are remembered for an hour so audio-only books don't trigger a lookup on every push, and any failure still lands the percentage. Pull: for ebook-only items (no audio duration), read ebookProgress instead of progress and map ebookLocation back to an exact XPath with epubXPath, so reading in ABS's web reader syncs to the device. Items with audio keep the listening position as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VSu9p45TgStCPnzV4mSRYc --- src/connectors/audiobookshelf.ts | 90 +++++++++++++++- test/audiobookshelf-ebook.test.ts | 172 ++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 test/audiobookshelf-ebook.test.ts 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 }]); + }); +});