Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 86 additions & 4 deletions src/connectors/audiobookshelf.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string, number>();
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<TextEpub> {
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<string | null> {
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`
Expand All @@ -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,
});
}
Expand All @@ -222,7 +295,8 @@ async function push(
cred: Credential,
m: Match,
ev: OutboundEvent,
http: HttpTransport
http: HttpTransport,
ctx?: ConnectorContext
): Promise<PushResult> {
const c = parseCred(cred);
if (!c) return { ok: false, retryable: false, needsReauth: true, error: 'bad credential' };
Expand All @@ -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),
Expand Down
172 changes: 172 additions & 0 deletions test/audiobookshelf-ebook.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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': '<container><rootfiles><rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>',
'EPUB/package.opf': '<package><metadata/><manifest><item id="a" href="../Text/ch1.xhtml"/><item id="b" href="../Text/ch2.xhtml"/></manifest><guide/><spine><itemref idref="a"/><itemref idref="b"/></spine></package>',
'Text/ch1.xhtml': '<html><head/><body><p>First</p></body></html>',
'Text/ch2.xhtml': '<html xmlns="http://www.w3.org/1999/xhtml"><head/><extra/><body><h1>Title</h1><p>Pre😀<em>bold</em> tail&amp;end</p></body></html>',
});
}

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 }]);
});
});