import { randomUUID } from 'crypto'; import { rm } from 'fs/promises'; import { eq } from 'drizzle-orm'; import * as schema from '../src/db/schema'; import { createZipArchiveFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder'; import { authHeader, closeReaderStateIsolationE2EContext, createLibraryWithFolder, createReaderStateIsolationE2EContext, locateBookByAbsolutePath, triggerAndWaitForLibraryScan, type CreatedLibrary, type ReaderStateIsolationE2EContext, } from './e2e/reader-state-isolation/reader-state-isolation-harness'; const KOREADER_USERNAME = `readaloud-device-${randomUUID().slice(0, 8)}`; const KOREADER_PASSWORD = 'ReadaloudProgressPass123'; const DEVICE_ID = 'readaloud-kindle'; const CHAPTER_COUNT = 10; /** Chapter 7 of the original, where the device reports it has read to. */ const KOREADER_XPOINTER = '/body/DocFragment[7]/body/p[1]/text().0'; const ORIGINAL_CHAPTER_7_CFI = 'epubcfi(/6/14!/4/2/1:0)'; /** Chapter 7 of the read-along copy, inside the sentence span Storyteller wraps the text in. */ const READ_ALONG_CHAPTER_7_CFI = 'epubcfi(/6/14!/4/2/2/1:0)'; const READ_ALONG_CHAPTER_7_SENTENCE = 'OPS/chapter-7.xhtml#c7-s1'; type FileProgress = { percentage: number; cfi: string | null; koreaderProgress: string | null; mediaOverlayFragment: string | null }; /** * Issue 1508: a Storyteller read-along EPUB beside the original, with no audiobook imported. * KOReader holds the original, the web reader opens the read-along copy because it becomes the * primary file, and progress is stored per file, so each side has to reach the other's copy. */ describe('KOReader progress with a Storyteller read-along sibling (e2e)', { timeout: 180_000 }, () => { let ctx!: ReaderStateIsolationE2EContext; let library!: CreatedLibrary; function deviceHeaders(): Record { return { 'x-auth-user': KOREADER_USERNAME, 'x-auth-key': KOREADER_PASSWORD }; } function epubEntries(title: string, uid: string, readAlong: boolean) { const chapterIds = Array.from({ length: CHAPTER_COUNT }, (_, index) => index + 1); const manifest = chapterIds .map((n) => { const chapter = ` `; if (!readAlong) return chapter; return [ chapter, ` `, ` `, ].join('\n'); }) .join('\n'); const spine = chapterIds.map((n) => ` `).join('\n'); const entries: Array<{ path: string; content: string | Buffer; store?: boolean }> = [ { path: 'mimetype', content: 'application/epub+zip', store: true }, { path: 'META-INF/container.xml', content: ` `, }, { path: 'OPS/content.opf', content: ` ${uid} ${title} en 2026-01-01T00:00:00Z ${manifest} ${spine} `, }, ]; for (const n of chapterIds) { const sentence = `Chapter ${n} of ${title} has one sentence of prose in it.`; const body = readAlong ? `

${sentence}

` : `

${sentence}

`; entries.push({ path: `OPS/chapter-${n}.xhtml`, content: `Chapter ${n}${body}`, }); if (!readAlong) continue; entries.push({ path: `OPS/chapter-${n}.smil`, content: ` `, }); entries.push({ path: `OPS/audio/chapter-${n}.mp3`, content: Buffer.alloc(2048, n) }); } return entries; } /** Lays out Author/Series/Title/Title.epub, and the Storyteller copy beside it when asked. */ async function writeBook(folder: string, options: { readAlong: boolean }) { const title = `Read Along ${randomUUID().slice(0, 8)}`; const uid = `urn:uuid:${randomUUID()}`; const originalPath = await createZipArchiveFixture(library.folderPath, `${folder}/Title.epub`, epubEntries(title, uid, false)); const readAlongPath = options.readAlong ? await createZipArchiveFixture(library.folderPath, `${folder}/Title (readaloud).epub`, epubEntries(title, uid, true)) : null; return { title, uid, originalPath, readAlongPath }; } /** Scans a book whose original and read-along copy share a folder, and checks they became one book. */ async function scanReadAlongBook(folder: string) { const { originalPath, readAlongPath } = await writeBook(folder, { readAlong: true }); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const original = await locateBookByAbsolutePath(ctx, originalPath); const readAlong = await locateBookByAbsolutePath(ctx, readAlongPath!); expect(readAlong.bookId).toBe(original.bookId); expect(await webReaderFileId(original.bookId)).toBe(readAlong.bookFileId); return { original, readAlong, readAlongPath: readAlongPath!, originalHash: await fileHashFor(original.bookFileId) }; } async function fileHashFor(bookFileId: number): Promise { const [row] = await ctx.db.select({ fileHash: schema.bookFiles.fileHash }).from(schema.bookFiles).where(eq(schema.bookFiles.id, bookFileId)); expect(row?.fileHash).toBeTruthy(); return row!.fileHash!; } /** The file the web client opens: `role === 'primary'`, as the library card and detail page pick it. */ async function webReaderFileId(bookId: number): Promise { const response = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}`, headers: authHeader(ctx.adminToken) }); expect(response.statusCode).toBe(200); const detail = response.json() as { files: Array<{ id: number; role: string; format: string | null }> }; const readable = detail.files.filter((file) => file.format === 'epub'); return (readable.find((file) => file.role === 'primary') ?? readable[0]!).id; } async function saveFromWebReader(bookFileId: number, percentage: number, cfi: string) { const response = await ctx.app.inject({ method: 'POST', url: `/api/v1/books/files/${bookFileId}/progress`, headers: authHeader(ctx.adminToken), payload: { percentage, cfi, source: 'text' }, }); expect([200, 201, 204]).toContain(response.statusCode); } /** What the web reader loads when it opens this file. */ async function fileProgress(bookFileId: number): Promise { const response = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/files/${bookFileId}/progress`, headers: authHeader(ctx.adminToken), }); expect(response.statusCode).toBe(200); return response.json() as FileProgress; } /** What the book page's KOReader panel shows. */ async function bookPanel(bookId: number) { const response = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/books/${bookId}/progress`, headers: authHeader(ctx.adminToken) }); expect(response.statusCode).toBe(200); return response.json() as { bookFileId: number; canonicalPercentage: number; devices: Array<{ deviceId: string; percentage: number }>; heldByReset: Array<{ deviceId: string; percentage: number }>; }; } async function resetFileProgress(bookFileId: number) { const response = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/books/files/${bookFileId}/progress`, headers: authHeader(ctx.adminToken), }); expect([200, 204]).toContain(response.statusCode); } async function pushFromKoreader(hash: string, percentage: number) { const response = await ctx.app.inject({ method: 'PUT', url: '/api/v1/koreader/syncs/progress', headers: deviceHeaders(), payload: { document: hash, percentage, progress: KOREADER_XPOINTER, device: 'Kindle', device_id: DEVICE_ID }, }); expect(response.statusCode).toBe(200); } async function pullFromKoreader(hash: string): Promise<{ percentage?: number; progress?: string; device?: string }> { const response = await ctx.app.inject({ method: 'GET', url: `/api/v1/koreader/syncs/progress/${hash}`, headers: deviceHeaders() }); expect(response.statusCode).toBe(200); return response.json() as { percentage?: number; progress?: string; device?: string }; } beforeAll(async () => { ctx = await createReaderStateIsolationE2EContext(); library = await createLibraryWithFolder(ctx, { mode: 'book_per_folder', name: `koreader-read-along-${randomUUID()}` }); const credentials = await ctx.app.inject({ method: 'POST', url: '/api/v1/koreader/credentials', headers: authHeader(ctx.adminToken), payload: { username: KOREADER_USERNAME, password: KOREADER_PASSWORD }, }); expect([200, 201]).toContain(credentials.statusCode); }, 180_000); afterAll(async () => { if (ctx) await closeReaderStateIsolationE2EContext(ctx); }); it('control: without a read-along copy the web reader resumes from the KOReader push', async () => { const { originalPath } = await writeBook('Author A/Series A/Control', { readAlong: false }); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const original = await locateBookByAbsolutePath(ctx, originalPath); const opened = await webReaderFileId(original.bookId); expect(opened).toBe(original.bookFileId); await saveFromWebReader(opened, 67, ORIGINAL_CHAPTER_7_CFI); await pushFromKoreader(await fileHashFor(original.bookFileId), 0.69); expect((await fileProgress(opened)).percentage).toBeCloseTo(69, 1); }); it('KOReader -> web: the web reader resumes from a push on the original once a read-along copy is added', async () => { const folder = 'Author B/Series B/Added Later'; const { title, uid, originalPath } = await writeBook(folder, { readAlong: false }); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const original = await locateBookByAbsolutePath(ctx, originalPath); const originalHash = await fileHashFor(original.bookFileId); expect(await webReaderFileId(original.bookId)).toBe(original.bookFileId); // Storyteller finishes and drops "Title (readaloud).epub" beside the original. const readAlongPath = await createZipArchiveFixture(library.folderPath, `${folder}/Title (readaloud).epub`, epubEntries(title, uid, true)); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const readAlong = await locateBookByAbsolutePath(ctx, readAlongPath); expect(readAlong.bookId).toBe(original.bookId); const opened = await webReaderFileId(original.bookId); expect(opened).toBe(readAlong.bookFileId); await saveFromWebReader(opened, 67, READ_ALONG_CHAPTER_7_CFI); await pushFromKoreader(originalHash, 0.69); const resumed = await fileProgress(opened); expect(resumed.percentage).toBeCloseTo(69, 1); expect(resumed.cfi).toMatch(/^epubcfi\(\/6\/14!/); // The web reader opens a read-along file at its narration marker when one is stored, so the // marker has to have moved with the text or the reader would land on a stale sentence. expect(resumed.mediaOverlayFragment).toBe(READ_ALONG_CHAPTER_7_SENTENCE); }); it('web -> KOReader: a device holding the original is served the position read on the read-along copy', async () => { const { original, readAlong, originalHash } = await scanReadAlongBook('Author C/Series C/Reverse'); await saveFromWebReader(readAlong.bookFileId, 67, READ_ALONG_CHAPTER_7_CFI); const pulled = await pullFromKoreader(originalHash); expect(pulled.percentage).toBeCloseTo(0.67, 3); expect(pulled.progress).toMatch(/^\/body\/DocFragment\[7\]\//); // The original has no narration of its own, so it must not carry the read-along copy's // sentence id, which the web reader would otherwise try to open it at. expect((await fileProgress(original.bookFileId)).mediaOverlayFragment).toBeNull(); }); it('round trip: the web reader re-saving the synced position does not drag the device backwards', async () => { const { readAlong, originalHash } = await scanReadAlongBook('Author D/Series D/Round Trip'); await pushFromKoreader(originalHash, 0.69); const synced = await fileProgress(readAlong.bookFileId); expect(synced.percentage).toBeCloseTo(69, 1); // Opening the book saves the position it opened at. await saveFromWebReader(readAlong.bookFileId, 69.2, synced.cfi!); const pulled = await pullFromKoreader(originalHash); expect(pulled.percentage).toBeGreaterThanOrEqual(0.69); expect(pulled.progress).toMatch(/^\/body\/DocFragment\[7\]\//); }); it('the book page reports the device that syncs the original, not the read-along primary', async () => { const { original, originalHash } = await scanReadAlongBook('Author G/Series G/Panel'); await pushFromKoreader(originalHash, 0.69); const panel = await bookPanel(original.bookId); expect(panel.bookFileId).toBe(original.bookFileId); expect(panel.canonicalPercentage).toBeCloseTo(69, 1); expect(panel.devices).toEqual([expect.objectContaining({ deviceId: DEVICE_ID, percentage: 69 })]); }); it('a device on the original held behind a reset is shown and can be released from the book page', async () => { const { original, originalHash } = await scanReadAlongBook('Author H/Series H/Release'); await pushFromKoreader(originalHash, 0.42); await resetFileProgress(original.bookFileId); // The device never saw the reset and replays its old position. await pushFromKoreader(originalHash, 0.42); expect((await bookPanel(original.bookId)).heldByReset).toEqual([expect.objectContaining({ deviceId: DEVICE_ID, percentage: 42 })]); const released = await ctx.app.inject({ method: 'POST', url: `/api/v1/koreader/books/${original.bookId}/reset-hold/release`, headers: authHeader(ctx.adminToken), payload: { deviceId: DEVICE_ID }, }); expect([200, 201]).toContain(released.statusCode); expect((await bookPanel(original.bookId)).heldByReset).toEqual([]); expect((await pullFromKoreader(originalHash)).percentage).toBeCloseTo(0.42, 3); }); it('keeps each copy on its own position when read-aloud sync is disabled', async () => { const { original, readAlong, originalHash } = await scanReadAlongBook('Author E/Series E/Disabled'); const disabled = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/books/${original.bookId}/read-aloud-sync`, headers: authHeader(ctx.adminToken), payload: { mode: 'disabled' }, }); expect(disabled.statusCode).toBe(200); await saveFromWebReader(readAlong.bookFileId, 67, READ_ALONG_CHAPTER_7_CFI); await pushFromKoreader(originalHash, 0.69); expect((await fileProgress(readAlong.bookFileId)).percentage).toBeCloseTo(67, 1); expect((await fileProgress(original.bookFileId)).percentage).toBeCloseTo(69, 1); }); it('workaround from the report: deleting the read-along copy resumes from the original', async () => { const { original, readAlongPath, originalHash } = await scanReadAlongBook('Author F/Series F/Workaround'); await saveFromWebReader(await webReaderFileId(original.bookId), 67, READ_ALONG_CHAPTER_7_CFI); await pushFromKoreader(originalHash, 0.69); await rm(readAlongPath); await triggerAndWaitForLibraryScan(ctx, library.libraryId); const opened = await webReaderFileId(original.bookId); expect(opened).toBe(original.bookFileId); expect((await fileProgress(opened)).percentage).toBeCloseTo(69, 1); }); });