diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index ce77296d9..49ec62680 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -373,10 +373,31 @@ const readAloudSync = ref(props.book.readAloudSync) const readAloudSyncSaving = ref(false) const readAloudSyncError = ref(null) const showReadAloudSync = computed(() => readAlongFile.value != null || hasAudioFile.value) -const readAloudSyncStatus = computed(() => t(`book.detail.details.readAloudSync.state.${readAloudSync.value.state}`)) +// Another EPUB beside the read-along file keeps its position in sync through the read-along's +// narration even when no audiobook can be matched, so only the audiobook half is unavailable. +const syncsEpubCopiesOnly = computed( + () => + readAloudSync.value.state === 'unavailable' && + readAloudSync.value.unavailableReason !== 'no_media_overlay_epub' && + props.book.files.filter((file) => file.format?.toLowerCase() === 'epub').length > 1, +) +const readAloudSyncStatus = computed(() => + syncsEpubCopiesOnly.value + ? t('book.detail.details.readAloudSync.state.epubCopiesOnly') + : t(`book.detail.details.readAloudSync.state.${readAloudSync.value.state}`), +) const readAloudSyncDescription = computed(() => { if (readAloudSync.value.state === 'enabled') return t('book.detail.details.readAloudSync.enabledDescription') if (readAloudSync.value.state === 'disabled') return t('book.detail.details.readAloudSync.disabledDescription') + if (syncsEpubCopiesOnly.value) return t('book.detail.details.readAloudSync.epubCopiesDescription') + return readAloudSyncUnavailableReason() +}) +/** Why the audiobook is left out, when there is one and it is not simply missing. */ +const readAloudSyncAudiobookNote = computed(() => + syncsEpubCopiesOnly.value && readAloudSync.value.unavailableReason !== 'no_audio_files' ? readAloudSyncUnavailableReason() : null, +) + +function readAloudSyncUnavailableReason(): string { const reason = readAloudSync.value.unavailableReason ?? 'missing_duration' if (reason === 'duration_mismatch') { return t('book.detail.details.readAloudSync.reason.durationMismatch', { @@ -385,7 +406,7 @@ const readAloudSyncDescription = computed(() => { }) } return t(`book.detail.details.readAloudSync.reason.${reason}`) -}) +} watch( () => props.book.readAloudSync, @@ -1782,6 +1803,9 @@ watch( {{ readAloudSyncStatus }}

{{ readAloudSyncDescription }}

+

+ {{ readAloudSyncAudiobookNote }} +

{{ readAloudSyncError }}

diff --git a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts index 909763d1d..223f90ea8 100644 --- a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts +++ b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts @@ -846,4 +846,62 @@ describe('DetailsTab cover surface', () => { expect(wrapper.get('[role="status"]').text()).toBe('Could not update read-aloud progress sync.') expect(wrapper.emitted('saved')).toBeUndefined() }) + + describe('read-aloud sync with a second EPUB beside the read-along file', () => { + const readAlongEpub = { ...makeBook().files[0]!, mediaOverlay: { available: true, durationSeconds: 3600 } } + const originalEpub = { + id: 103, + format: 'epub', + role: 'content', + sizeBytes: 1200, + absolutePath: '/books/Title.epub', + createdAt: '2026-01-01T00:00:00.000Z', + filename: 'Title.epub', + durationSeconds: null, + } + const unavailable = (unavailableReason: 'no_audio_files' | 'duration_mismatch', audioDurationSeconds: number | null) => ({ + mode: 'auto' as const, + state: 'unavailable' as const, + unavailableReason, + overlayFileId: 101, + audioDurationSeconds, + overlayDurationSeconds: 3600, + durationDifferenceSeconds: audioDurationSeconds === null ? null : audioDurationSeconds - 3600, + durationDifferenceRatio: audioDurationSeconds === null ? null : (audioDurationSeconds - 3600) / 3600, + koreaderDownloadAvailable: true, + }) + + it('reports the EPUB copies as in sync when no audiobook is imported', async () => { + const wrapper = mountDetails(makeBook({ files: [readAlongEpub, originalEpub], readAloudSync: unavailable('no_audio_files', null) })) + await flushPromises() + + const panel = wrapper.get('[data-test="read-aloud-sync"]').text() + expect(panel).toContain('EPUB copies only') + expect(panel).toContain('Web reader, Kobo, and KOReader positions stay in sync across the EPUB copies.') + expect(panel).not.toContain('Unavailable') + // A missing audiobook is already what the description asks for, so there is nothing to add. + expect(wrapper.find('[data-test="read-aloud-sync-audiobook-note"]').exists()).toBe(false) + }) + + it('keeps the reason an existing audiobook is left out beside the EPUB copies status', async () => { + const audiobook = { ...originalEpub, id: 104, format: 'mp3', absolutePath: '/books/audio.mp3', filename: 'audio.mp3', durationSeconds: 4000 } + const wrapper = mountDetails( + makeBook({ files: [readAlongEpub, originalEpub, audiobook], readAloudSync: unavailable('duration_mismatch', 4000) }), + ) + await flushPromises() + + expect(wrapper.get('[data-test="read-aloud-sync"]').text()).toContain('EPUB copies only') + expect(wrapper.get('[data-test="read-aloud-sync-audiobook-note"]').text()).toContain('audiobook 1h 6m, read-along EPUB 1h') + }) + + it('still reports a lone read-along EPUB without an audiobook as unavailable', async () => { + const wrapper = mountDetails(makeBook({ files: [readAlongEpub], readAloudSync: unavailable('no_audio_files', null) })) + await flushPromises() + + const panel = wrapper.get('[data-test="read-aloud-sync"]').text() + expect(panel).toContain('Unavailable') + expect(panel).toContain('Matching standalone audiobook files are required.') + expect(panel).not.toContain('EPUB copies only') + }) + }) }) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 2e27c98e6..496516c86 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -5254,10 +5254,12 @@ "state": { "enabled": "Enabled", "disabled": "Disabled", - "unavailable": "Unavailable" + "unavailable": "Unavailable", + "epubCopiesOnly": "EPUB copies only" }, "enabledDescription": "Audiobook, web reader, Kobo, and KOReader positions stay in sync.", "disabledDescription": "Each format keeps its own reading position.", + "epubCopiesDescription": "Web reader, Kobo, and KOReader positions stay in sync across the EPUB copies. Add matching audiobook files to sync the audiobook too.", "reason": { "no_media_overlay_epub": "A read-along EPUB with media overlays is required.", "no_audio_files": "Matching standalone audiobook files are required.", diff --git a/scripts/e2e/suite-registry.mjs b/scripts/e2e/suite-registry.mjs index 830e4da22..e0e1665eb 100644 --- a/scripts/e2e/suite-registry.mjs +++ b/scripts/e2e/suite-registry.mjs @@ -735,6 +735,29 @@ export const E2E_SUITES = Object.freeze({ ...SHARED_DB_AND_HELPER_PATHS, ], }, + "koreader-readaloud-progress": { + id: "koreader-readaloud-progress", + name: "KOReader Read-Along Sibling Progress", + timeout: 45, + lane: "full", + description: "KOReader and web reader positions carried between an EPUB and its Storyteller read-along copy", + vitestTarget: "test/koreader-readaloud-progress.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/koreader-readaloud-progress-e2e-junit.xml`, + prepareDedicatedDatabase: true, + useDedicatedDatabase: true, + changedPaths: [ + "server/src/modules/koreader/**", + "server/src/modules/book/audiobook-ebook-progress-sync.service.ts", + "server/src/modules/book/book.repository.ts", + "server/src/modules/book/book.service.ts", + "server/src/modules/position-converter/**", + "server/src/modules/reader/epub/**", + "server/src/common/utils/primary-file-selection.utils.ts", + "server/test/koreader-readaloud-progress.e2e-spec.ts", + "server/test/e2e/reader-state-isolation/**", + ...SHARED_DB_AND_HELPER_PATHS, + ], + }, "koreader-file-naming": { id: "koreader-file-naming", name: "KOReader File Naming", diff --git a/server/src/modules/book/audiobook-ebook-progress-sync.service.test.ts b/server/src/modules/book/audiobook-ebook-progress-sync.service.test.ts index 7b92ab4d0..851fb7e88 100644 --- a/server/src/modules/book/audiobook-ebook-progress-sync.service.test.ts +++ b/server/src/modules/book/audiobook-ebook-progress-sync.service.test.ts @@ -11,7 +11,7 @@ const mockBuildPlaylist = vi.mocked(buildEpubMediaOverlayPlaylistFromFile); const SOURCE_TIME = new Date('2026-09-19T12:00:00.000Z'); -function makeFiles(audioDuration = 100) { +function makeFiles(audioDuration: number | null = 100) { return { primaryFileId: 20, files: [ @@ -112,10 +112,16 @@ function makePlaylistWithZeroLengthClip() { }; } -function makeFixture(audioDuration = 100) { +/** A Storyteller read-along EPUB beside the original, with the audiobook never imported. */ +function makeFilesWithoutAudio() { + const files = makeFiles(); + return { ...files, primaryFileId: 30, files: files.files.filter((file) => file.format === 'epub') }; +} + +function makeFixture(audioDuration = 100, syncFiles: ReturnType = makeFiles(audioDuration)) { const bookRepo = { findReadAloudSyncMode: vi.fn().mockResolvedValue('auto'), - findAudioEbookProgressSyncFiles: vi.fn().mockResolvedValue(makeFiles(audioDuration)), + findAudioEbookProgressSyncFiles: vi.fn().mockResolvedValue(syncFiles), upsertSyncedEpubProgressIfNewer: vi.fn().mockResolvedValue(true), syncKoboReadingStateFromProgress: vi.fn().mockResolvedValue(undefined), upsertAudioProgress: vi.fn().mockResolvedValue({ revision: 2 }), @@ -156,13 +162,30 @@ describe('AudiobookEbookProgressSyncService', () => { ).resolves.toBe(true); expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledTimes(2); + // The plain EPUB has no narration to resume, so it gets the text position without a marker. expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ userId: 7, fileId: 20, percentage: 75, positionSeconds: 75, sourceUpdatedAt: SOURCE_TIME }), + expect.objectContaining({ + userId: 7, + fileId: 20, + percentage: 75, + positionSeconds: null, + mediaOverlayFragment: null, + mediaOverlaySectionIndex: null, + sourceUpdatedAt: SOURCE_TIME, + }), ); expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ userId: 7, fileId: 30, percentage: 75, positionSeconds: 75, sourceUpdatedAt: SOURCE_TIME }), + expect.objectContaining({ + userId: 7, + fileId: 30, + percentage: 75, + positionSeconds: 75, + mediaOverlayFragment: 'OPS/chapter.xhtml#second', + mediaOverlaySectionIndex: 0, + sourceUpdatedAt: SOURCE_TIME, + }), ); expect(bookRepo.syncKoboReadingStateFromProgress).toHaveBeenCalledWith(7, 20, 75, null, null, null, null); expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith( @@ -194,9 +217,10 @@ describe('AudiobookEbookProgressSyncService', () => { 1, expect.objectContaining({ fileId: 20, + cfi: 'epubcfi(20-after)', percentage: positionSeconds, - positionSeconds, - mediaOverlayFragment: 'OPS/chapter.xhtml#after', + positionSeconds: null, + mediaOverlayFragment: null, }), ); expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith( @@ -258,7 +282,7 @@ describe('AudiobookEbookProgressSyncService', () => { service.syncFromEbookProgress({ userId: 7, bookId: 5, - bookFileId: 20, + bookFileId: 30, percentage: 75, positionSeconds: 75, mediaOverlayFragment: 'OPS/chapter.xhtml#after', @@ -269,10 +293,55 @@ describe('AudiobookEbookProgressSyncService', () => { expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(7, 5, 10, 75, 75, SOURCE_TIME); expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith( - expect.objectContaining({ bookFileId: 30, sourceBookFileId: 30, fragment: 'after' }), + expect.objectContaining({ bookFileId: 20, sourceBookFileId: 30, fragment: 'after' }), ); expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith( - expect.objectContaining({ fileId: 30, positionSeconds: 75, mediaOverlayFragment: 'OPS/chapter.xhtml#after' }), + expect.objectContaining({ fileId: 20, cfi: 'epubcfi(20-after)', positionSeconds: null, mediaOverlayFragment: null }), + ); + }); + + it('follows the text position of a plain EPUB that sends back a narration marker an older sync left on it', async () => { + const { service, bookRepo, positionConverter } = makeFixture(); + + await expect( + service.syncFromEbookProgress({ + userId: 7, + bookId: 5, + bookFileId: 20, + percentage: 75, + cfi: 'epubcfi(/6/4)', + positionSeconds: 10, + mediaOverlayFragment: 'OPS/chapter.xhtml#first', + mediaOverlaySectionIndex: 0, + sourceUpdatedAt: SOURCE_TIME, + }), + ).resolves.toBe(true); + + // The stale marker names 'first' at 10 seconds; the page the reader is on resolves to 'second'. + expect(positionConverter.nearestFragmentForPosition).toHaveBeenCalledWith(expect.objectContaining({ bookFileId: 20, cfi: 'epubcfi(/6/4)' })); + expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(7, 5, 10, 50, 50, SOURCE_TIME); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith( + expect.objectContaining({ fileId: 30, positionSeconds: 50, mediaOverlayFragment: 'OPS/chapter.xhtml#second' }), + ); + }); + + it('writes a read-along position to the plain sibling without a narration marker', async () => { + const { service, bookRepo } = makeFixture(); + + await expect( + service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 30, percentage: 75, cfi: 'epubcfi(/6/4)', sourceUpdatedAt: SOURCE_TIME }), + ).resolves.toBe(true); + + expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(7, 5, 10, 50, 50, SOURCE_TIME); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledOnce(); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith( + expect.objectContaining({ + fileId: 20, + percentage: 50, + positionSeconds: null, + mediaOverlayFragment: null, + mediaOverlaySectionIndex: null, + }), ); }); @@ -351,3 +420,185 @@ describe('AudiobookEbookProgressSyncService', () => { expect(mockBuildPlaylist).not.toHaveBeenCalled(); }); }); + +describe('AudiobookEbookProgressSyncService sibling EPUBs without an audiobook', () => { + const KOREADER_XPOINTER = '/body/DocFragment[1]/body/p[3]/text().0'; + + beforeEach(() => { + vi.clearAllMocks(); + mockStat.mockResolvedValue({ mtimeMs: 1234 } as never); + mockBuildPlaylist.mockResolvedValue(makePlaylist()); + }); + + it('carries a device position on the original EPUB to the read-along copy', async () => { + const { service, bookRepo, positionConverter } = makeFixture(100, makeFilesWithoutAudio()); + + await expect( + service.syncFromEbookProgress({ + userId: 7, + bookId: 5, + bookFileId: 20, + percentage: 69, + cfi: 'epubcfi(/6/4)', + koreaderProgress: KOREADER_XPOINTER, + sourceUpdatedAt: SOURCE_TIME, + }), + ).resolves.toBe(true); + + expect(bookRepo.upsertAudioProgress).not.toHaveBeenCalled(); + expect(positionConverter.nearestFragmentForPosition).toHaveBeenCalledWith( + expect.objectContaining({ bookFileId: 20, sourceBookFileId: 30, cfi: 'epubcfi(/6/4)', xpointer: KOREADER_XPOINTER }), + ); + expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith({ bookFileId: 30, sourceBookFileId: 30, chapterIndex: 0, fragment: 'second' }); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledOnce(); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith({ + userId: 7, + fileId: 30, + cfi: 'epubcfi(30-second)', + koreaderProgress: '/body/30/second', + percentage: 69, + positionSeconds: 50, + mediaOverlayFragment: 'OPS/chapter.xhtml#second', + mediaOverlaySectionIndex: 0, + sourceUpdatedAt: SOURCE_TIME, + }); + }); + + it('carries a web position on the read-along copy to the original without a narration marker', async () => { + const { service, bookRepo, positionConverter } = makeFixture(100, makeFilesWithoutAudio()); + + await expect( + service.syncFromEbookProgress({ + userId: 7, + bookId: 5, + bookFileId: 30, + percentage: 67, + cfi: 'epubcfi(/6/4)', + koreaderProgress: KOREADER_XPOINTER, + sourceUpdatedAt: SOURCE_TIME, + }), + ).resolves.toBe(true); + + expect(positionConverter.nearestFragmentForPosition).toHaveBeenCalledWith(expect.objectContaining({ bookFileId: 30, sourceBookFileId: 30 })); + expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith({ bookFileId: 20, sourceBookFileId: 30, chapterIndex: 0, fragment: 'second' }); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledOnce(); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith({ + userId: 7, + fileId: 20, + cfi: 'epubcfi(20-second)', + koreaderProgress: '/body/20/second', + percentage: 67, + positionSeconds: null, + mediaOverlayFragment: null, + mediaOverlaySectionIndex: null, + sourceUpdatedAt: SOURCE_TIME, + }); + }); + + it('parses nothing for a read-along EPUB that has no sibling and no audiobook', async () => { + const readAlongOnly = makeFilesWithoutAudio(); + readAlongOnly.files = readAlongOnly.files.filter((file) => file.id === 30); + const { service, bookRepo, positionConverter } = makeFixture(100, readAlongOnly); + + await expect( + service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 30, percentage: 67, cfi: 'epubcfi(/6/4)', sourceUpdatedAt: SOURCE_TIME }), + ).resolves.toBe(false); + + expect(mockStat).not.toHaveBeenCalled(); + expect(mockBuildPlaylist).not.toHaveBeenCalled(); + expect(positionConverter.nearestFragmentForPosition).not.toHaveBeenCalled(); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled(); + }); + + it('parses nothing for a narration write that did not move the text position', async () => { + const { service, bookRepo, positionConverter } = makeFixture(100, makeFilesWithoutAudio()); + + await expect( + service.syncFromEbookProgress({ + userId: 7, + bookId: 5, + bookFileId: 30, + percentage: 32, + positionSeconds: 10, + mediaOverlayFragment: 'OPS/chapter.xhtml#first', + mediaOverlaySectionIndex: 0, + sourceUpdatedAt: SOURCE_TIME, + syncSiblingEpubs: false, + }), + ).resolves.toBe(false); + + expect(mockBuildPlaylist).not.toHaveBeenCalled(); + expect(positionConverter.nearestFragmentForPosition).not.toHaveBeenCalled(); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled(); + }); + + it('keeps every copy on its own position when read-aloud sync is disabled', async () => { + const { service, bookRepo } = makeFixture(100, makeFilesWithoutAudio()); + bookRepo.findReadAloudSyncMode.mockResolvedValue('disabled'); + + await expect( + service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 20, percentage: 69, koreaderProgress: KOREADER_XPOINTER }), + ).resolves.toBe(false); + + expect(bookRepo.findAudioEbookProgressSyncFiles).not.toHaveBeenCalled(); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled(); + }); + + it.each([ + ['its duration is too far from the narration', () => makeFixture(120)], + ['a file has no duration', () => makeFixture(100, makeFiles(null))], + ])('syncs the sibling EPUB by text when the audiobook cannot be mapped because %s', async (_reason, fixture) => { + const { service, bookRepo } = fixture(); + + await expect( + service.syncFromEbookProgress({ + userId: 7, + bookId: 5, + bookFileId: 20, + percentage: 69, + koreaderProgress: KOREADER_XPOINTER, + sourceUpdatedAt: SOURCE_TIME, + }), + ).resolves.toBe(true); + + expect(bookRepo.upsertAudioProgress).not.toHaveBeenCalled(); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledOnce(); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith( + expect.objectContaining({ fileId: 30, percentage: 69, positionSeconds: 50, mediaOverlayFragment: 'OPS/chapter.xhtml#second' }), + ); + }); + + it('writes nothing when the copies do not share the chapter text', async () => { + const { service, bookRepo, positionConverter } = makeFixture(100, makeFilesWithoutAudio()); + positionConverter.fragmentToPositions.mockResolvedValue({ status: 'failed', reason: 'chapter_text_mismatch' } as never); + + await expect( + service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 30, percentage: 67, cfi: 'epubcfi(/6/4)', sourceUpdatedAt: SOURCE_TIME }), + ).resolves.toBe(false); + + expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled(); + }); + + it('writes nothing when the position cannot be placed on the narration', async () => { + const { service, bookRepo, positionConverter } = makeFixture(100, makeFilesWithoutAudio()); + positionConverter.nearestFragmentForPosition.mockResolvedValue({ status: 'failed', reason: 'no_candidate_fragments' }); + + await expect( + service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 20, percentage: 69, koreaderProgress: KOREADER_XPOINTER }), + ).resolves.toBe(false); + + expect(positionConverter.fragmentToPositions).not.toHaveBeenCalled(); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled(); + }); + + it('reports no sync when the sibling already holds a newer position', async () => { + const { service, bookRepo } = makeFixture(100, makeFilesWithoutAudio()); + bookRepo.upsertSyncedEpubProgressIfNewer.mockResolvedValue(false); + + await expect( + service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 20, percentage: 69, koreaderProgress: KOREADER_XPOINTER }), + ).resolves.toBe(false); + + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledOnce(); + }); +}); diff --git a/server/src/modules/book/audiobook-ebook-progress-sync.service.ts b/server/src/modules/book/audiobook-ebook-progress-sync.service.ts index 1933d9b1f..b342eb652 100644 --- a/server/src/modules/book/audiobook-ebook-progress-sync.service.ts +++ b/server/src/modules/book/audiobook-ebook-progress-sync.service.ts @@ -34,16 +34,27 @@ type EpubProgressResolution = { cfi: string; koreaderProgress: string | null; percentage: number; - positionSeconds: number; - mediaOverlayFragment: string; - mediaOverlaySectionIndex: number; + positionSeconds: number | null; + mediaOverlayFragment: string | null; + mediaOverlaySectionIndex: number | null; }; -type AudioProgressResolution = { +/** An EPUB position placed on the read-along file's narration timeline. */ +type EbookOverlayPosition = { + ebookFile: SyncFile; + overlaySourceFile: SyncFile; + playlist: EpubMediaOverlayPlaylist; + overlayDurationSeconds: number; + overlaySeconds: number; + audioFiles: SyncFile[]; + audioTotalSeconds: number | null; + siblingEpubFiles: SyncFile[]; +}; + +type AudioPosition = { currentFileId: number; positionSeconds: number; percentage: number; - siblingTargets: EpubProgressResolution[]; }; @Injectable() @@ -129,22 +140,29 @@ export class AudiobookEbookProgressSyncService { try { if ((await this.bookRepo.findReadAloudSyncMode(params.userId, params.bookId)) === 'disabled') return false; - const resolution = await this.resolveAudioProgress(params); - if (!resolution) return false; + const syncSiblingEpubs = params.syncSiblingEpubs !== false; + const position = await this.resolveEbookOverlayPosition(params, syncSiblingEpubs); + if (!position) return false; const sourceUpdatedAt = params.sourceUpdatedAt ?? new Date(); - const saved = await this.bookRepo.upsertAudioProgress( - params.userId, - params.bookId, - resolution.currentFileId, - resolution.positionSeconds, - resolution.percentage, - sourceUpdatedAt, - ); - if (!saved) return false; + const audio = this.mapOverlayPositionToAudio(position); + if (audio) { + const saved = await this.bookRepo.upsertAudioProgress( + params.userId, + params.bookId, + audio.currentFileId, + audio.positionSeconds, + audio.percentage, + sourceUpdatedAt, + ); + if (!saved) return false; + } + if (!syncSiblingEpubs) return audio !== null; - for (const target of params.syncSiblingEpubs === false ? [] : resolution.siblingTargets) { - await this.bookRepo.upsertSyncedEpubProgressIfNewer({ + let siblingSynced = false; + const textPercentage = audio ? null : this.clampPercentage(params.percentage); + for (const target of await this.resolveSiblingEpubTargets(position, textPercentage)) { + const accepted = await this.bookRepo.upsertSyncedEpubProgressIfNewer({ userId: params.userId, fileId: target.targetFile.id, cfi: target.cfi, @@ -155,8 +173,9 @@ export class AudiobookEbookProgressSyncService { koreaderProgress: target.koreaderProgress, sourceUpdatedAt, }); + siblingSynced ||= accepted; } - return true; + return audio !== null || siblingSynced; } catch (error: unknown) { const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError'; const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error)); @@ -201,17 +220,11 @@ export class AudiobookEbookProgressSyncService { const targetFile = this.selectTargetFile(syncFiles.files, syncFiles.primaryFileId, overlaySourceFile); const epubFiles = syncFiles.files.filter((file) => file.format?.toLowerCase() === 'epub'); const orderedTargets = [targetFile, ...epubFiles.filter((file) => file.id !== targetFile.id)]; + const percentage = this.clampPercentage((overlaySeconds / playlist.durationSeconds) * 100); const targets: EpubProgressResolution[] = []; for (const candidate of orderedTargets) { const resolved = await this.resolveFragmentPositions(candidate, item, overlaySourceFile.id); - if (!resolved) continue; - targets.push({ - ...resolved, - percentage: this.clampPercentage((overlaySeconds / playlist.durationSeconds) * 100), - positionSeconds: overlaySeconds, - mediaOverlayFragment: this.itemFragment(item), - mediaOverlaySectionIndex: item.sectionIndex, - }); + if (resolved) targets.push(this.toEpubTarget(resolved, item, overlaySourceFile, overlaySeconds, percentage)); } if (targets.length === 0) return null; @@ -220,15 +233,23 @@ export class AudiobookEbookProgressSyncService { }; } - private async resolveAudioProgress(params: { - bookId: number; - bookFileId: number; - cfi?: string | null; - koreaderProgress?: string | null; - positionSeconds?: number | null; - mediaOverlayFragment?: string | null; - mediaOverlaySectionIndex?: number | null; - }): Promise { + /** + * Places an EPUB position on the read-along file's narration, which is what every other copy + * of the book is found through. Returns null before any file is parsed when there is nothing + * to carry the position to, which is every sync of a read-along EPUB that sits on its own. + */ + private async resolveEbookOverlayPosition( + params: { + bookId: number; + bookFileId: number; + cfi?: string | null; + koreaderProgress?: string | null; + positionSeconds?: number | null; + mediaOverlayFragment?: string | null; + mediaOverlaySectionIndex?: number | null; + }, + syncSiblingEpubs: boolean, + ): Promise { const syncFiles = await this.bookRepo.findAudioEbookProgressSyncFiles(params.bookId); if (!syncFiles) return null; @@ -240,7 +261,10 @@ export class AudiobookEbookProgressSyncService { const audioFiles = syncFiles.files.filter((file) => typeof file.format === 'string' && isAudioFormat(file.format)); const audioTotalSeconds = this.computeAudioTotalSeconds(audioFiles); - if (audioFiles.length === 0 || audioTotalSeconds === null) return null; + const siblingEpubFiles = syncSiblingEpubs + ? syncFiles.files.filter((file) => file.id !== ebookFile.id && file.format?.toLowerCase() === 'epub') + : []; + if (audioTotalSeconds === null && siblingEpubFiles.length === 0) return null; const playlist = await this.getPlaylist(overlaySourceFile, params.bookId); if (playlist.items.length === 0 || playlist.durationSeconds == null || playlist.durationSeconds <= 0) return null; @@ -248,34 +272,75 @@ export class AudiobookEbookProgressSyncService { const overlaySeconds = await this.resolveOverlaySecondsFromEbookPosition(ebookFile, overlaySourceFile.id, playlist, params); if (overlaySeconds === null) return null; - const audioSeconds = this.mapOverlaySecondsToAudioSeconds(overlaySeconds, playlist.durationSeconds, audioTotalSeconds); + return { + ebookFile, + overlaySourceFile, + playlist, + overlayDurationSeconds: playlist.durationSeconds, + overlaySeconds, + audioFiles, + audioTotalSeconds, + siblingEpubFiles, + }; + } + + private mapOverlayPositionToAudio(position: EbookOverlayPosition): AudioPosition | null { + if (position.audioTotalSeconds === null) return null; + + const audioSeconds = this.mapOverlaySecondsToAudioSeconds(position.overlaySeconds, position.overlayDurationSeconds, position.audioTotalSeconds); if (audioSeconds === null) return null; - const filePosition = this.audioFilePositionForSeconds(audioFiles, audioSeconds); + const filePosition = this.audioFilePositionForSeconds(position.audioFiles, audioSeconds); if (!filePosition) return null; - const item = this.findItemBySeconds(playlist, overlaySeconds); - const siblingTargets: EpubProgressResolution[] = []; - if (item?.textFragment) { - for (const candidate of syncFiles.files) { - if (candidate.id === ebookFile.id || candidate.format?.toLowerCase() !== 'epub') continue; - const resolved = await this.resolveFragmentPositions(candidate, item, overlaySourceFile.id); - if (!resolved) continue; - siblingTargets.push({ - ...resolved, - percentage: this.clampPercentage((overlaySeconds / playlist.durationSeconds) * 100), - positionSeconds: overlaySeconds, - mediaOverlayFragment: this.itemFragment(item), - mediaOverlaySectionIndex: item.sectionIndex, - }); - } - } - return { currentFileId: filePosition.file.id, positionSeconds: filePosition.positionSeconds, - percentage: this.clampPercentage((audioSeconds / audioTotalSeconds) * 100), - siblingTargets, + percentage: this.clampPercentage((audioSeconds / position.audioTotalSeconds) * 100), + }; + } + + /** + * Where the position lands in every other EPUB of the book: the nearest narrated sentence, + * located in each copy whose chapter text is identical. + * + * Behind an audiobook the copies follow its timeline percentage. Without one, `textPercentage` + * is the source's own figure and is used as is: narration time drifts from text wherever the + * pace changes or a section goes unnarrated, and KOReader decides by percentage whether a + * pulled position is ahead of its own. + */ + private async resolveSiblingEpubTargets(position: EbookOverlayPosition, textPercentage: number | null): Promise { + const item = this.findItemBySeconds(position.playlist, position.overlaySeconds); + if (!item?.textFragment) return []; + + const percentage = textPercentage ?? this.clampPercentage((position.overlaySeconds / position.overlayDurationSeconds) * 100); + const targets: EpubProgressResolution[] = []; + for (const candidate of position.siblingEpubFiles) { + const resolved = await this.resolveFragmentPositions(candidate, item, position.overlaySourceFile.id); + if (resolved) targets.push(this.toEpubTarget(resolved, item, position.overlaySourceFile, position.overlaySeconds, percentage)); + } + return targets; + } + + /** + * A copy's synced position. The narration marker goes only to the read-along file: it names one + * of that file's sentences, and the web reader opens a file at its stored marker before its cfi, + * so a copy without media overlays given one would open at the start of the chapter. + */ + private toEpubTarget( + resolved: { targetFile: SyncFile; cfi: string; koreaderProgress: string | null }, + item: EpubMediaOverlayPlaylistItem, + overlaySourceFile: SyncFile, + overlaySeconds: number, + percentage: number, + ): EpubProgressResolution { + const narration = resolved.targetFile.id === overlaySourceFile.id; + return { + ...resolved, + percentage, + positionSeconds: narration ? overlaySeconds : null, + mediaOverlayFragment: narration ? this.itemFragment(item) : null, + mediaOverlaySectionIndex: narration ? item.sectionIndex : null, }; } @@ -339,7 +404,11 @@ export class AudiobookEbookProgressSyncService { mediaOverlaySectionIndex?: number | null; }, ): Promise { + // A copy without media overlays has no narration of its own. A marker it sends back is one an + // earlier sync left on it, and its text position is the only one it can speak for. + const hasNarration = ebookFile.mediaOverlayAvailable === true; if ( + hasNarration && this.isNonNegativeFinite(params.positionSeconds) && (params.mediaOverlayFragment || params.mediaOverlaySectionIndex != null) && playlist.durationSeconds != null @@ -347,7 +416,7 @@ export class AudiobookEbookProgressSyncService { return Math.max(0, Math.min(playlist.durationSeconds, params.positionSeconds)); } - if (params.mediaOverlayFragment) { + if (hasNarration && params.mediaOverlayFragment) { const match = this.findItemStartByFragment(playlist, params.mediaOverlayFragment, params.mediaOverlaySectionIndex ?? null); if (match) return match.startSeconds; } diff --git a/server/src/modules/book/book.repository.test.ts b/server/src/modules/book/book.repository.test.ts index 755bde91b..2a1ea2829 100644 --- a/server/src/modules/book/book.repository.test.ts +++ b/server/src/modules/book/book.repository.test.ts @@ -751,6 +751,35 @@ describe('BookRepository', () => { expect(resetWhere).toHaveBeenCalledOnce(); }); + it('clears a stale narration marker when bridging to a copy without media overlays', async () => { + const returning = vi.fn().mockResolvedValue([{ fileId: 20 }]); + const onConflictDoUpdate = vi.fn().mockReturnValue({ returning }); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const db = { + insert: vi.fn().mockReturnValue({ values }), + delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), + }; + const repo = new BookRepository(db as never); + + await expect( + repo.upsertSyncedEpubProgressIfNewer({ + userId: 7, + fileId: 20, + cfi: 'epubcfi(/6/4)', + percentage: 42, + positionSeconds: null, + mediaOverlayFragment: null, + mediaOverlaySectionIndex: null, + koreaderProgress: '/body/DocFragment[2]/body/p[2]/text().0', + sourceUpdatedAt: new Date('2026-09-19T12:00:00.000Z'), + }), + ).resolves.toBe(true); + + const cleared = { positionSeconds: null, mediaOverlayFragment: null, mediaOverlaySectionIndex: null }; + expect(values).toHaveBeenCalledWith(expect.objectContaining(cleared)); + expect(onConflictDoUpdate).toHaveBeenCalledWith(expect.objectContaining({ set: expect.objectContaining(cleared) })); + }); + it('does not clear reset protection when a newer EPUB row rejects the bridge', async () => { const returning = vi.fn().mockResolvedValue([]); const db = { diff --git a/server/src/modules/book/book.repository.ts b/server/src/modules/book/book.repository.ts index 61c4d0980..bc9942bbf 100644 --- a/server/src/modules/book/book.repository.ts +++ b/server/src/modules/book/book.repository.ts @@ -2368,9 +2368,9 @@ export class BookRepository { fileId: number; cfi: string; percentage: number; - positionSeconds: number; - mediaOverlayFragment: string; - mediaOverlaySectionIndex: number; + positionSeconds: number | null; + mediaOverlayFragment: string | null; + mediaOverlaySectionIndex: number | null; koreaderProgress: string | null; sourceUpdatedAt: Date; }): Promise { diff --git a/server/src/modules/koreader/koreader.repository.ts b/server/src/modules/koreader/koreader.repository.ts index 9f7ab1b41..b951ee345 100644 --- a/server/src/modules/koreader/koreader.repository.ts +++ b/server/src/modules/koreader/koreader.repository.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { and, asc, desc, eq, inArray, notExists, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, inArray, notExists, or, sql } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { chunk } from '../../common/utils/batch.utils'; @@ -761,13 +761,22 @@ export class KoreaderRepository { } /** - * The file a book's KOReader progress hangs off, in the shape the shared-progress path needs. - * Resolved through the book's primary file, matching findBookFileIdByBookId: the book page - * reports one file's holds, so the release action has to act on that same file rather than - * whichever one a marker happens to be found on first. + * The file a book's KOReader progress hangs off, in the shape the shared-progress path needs: + * the copy this user's devices synced most recently, or the primary file when none has. A + * device syncs whichever copy it was given, and a Storyteller read-along EPUB becomes the + * primary while devices usually hold the original beside it. The book page reports one file's + * devices and holds, and the release action resolves through this same query, so it acts on + * the file the page showed rather than whichever one a marker happens to be found on first. */ - async findProgressBookFileByBookId(bookId: number, accessibleLibraryIds: number[] | null) { + async findProgressBookFileByBookId(bookId: number, userId: number, accessibleLibraryIds: number[] | null) { if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return null; + const lastDeviceSyncAt = sql`( + select max(${schema.koreaderDeviceProgress.updatedAt}) + from ${schema.koreaderDeviceProgress} + where ${schema.koreaderDeviceProgress.bookFileId} = ${schema.bookFiles.id} + and ${schema.koreaderDeviceProgress.userId} = ${userId} + and ${schema.koreaderDeviceProgress.orphaned} = false + )`; const [row] = await this.db .select({ id: schema.bookFiles.id, @@ -780,13 +789,14 @@ export class KoreaderRepository { .where( and( eq(schema.books.id, bookId), - eq(schema.books.primaryFileId, schema.bookFiles.id), + or(eq(schema.books.primaryFileId, schema.bookFiles.id), sql`${lastDeviceSyncAt} is not null`), // Scoped the same way every other entry point into this module is. A device progress // row outlives the library grant that created it, so ownership of the row is not // ownership of the book, and this path writes. accessibleLibraryIds === null ? undefined : inArray(schema.books.libraryId, accessibleLibraryIds), ), ) + .orderBy(sql`${lastDeviceSyncAt} desc nulls last`, asc(schema.bookFiles.id)) .limit(1); return row ?? null; } diff --git a/server/src/modules/koreader/koreader.service.test.ts b/server/src/modules/koreader/koreader.service.test.ts index 6782662e8..2d685eaa0 100644 --- a/server/src/modules/koreader/koreader.service.test.ts +++ b/server/src/modules/koreader/koreader.service.test.ts @@ -81,7 +81,6 @@ describe('KoreaderService', () => { setKoreaderUserDefaultPattern: ReturnType; setDeviceFileNamingPattern: ReturnType; clearDeviceFileNamingPattern: ReturnType; - findBookFileIdByBookId: ReturnType; getBookProgressForDashboard: ReturnType; getChapters: ReturnType; getLastFileWriteTime: ReturnType; @@ -161,7 +160,6 @@ describe('KoreaderService', () => { setKoreaderUserDefaultPattern: vi.fn().mockResolvedValue(undefined), setDeviceFileNamingPattern: vi.fn().mockResolvedValue(undefined), clearDeviceFileNamingPattern: vi.fn().mockResolvedValue(undefined), - findBookFileIdByBookId: vi.fn(), getBookProgressForDashboard: vi.fn(), getChapters: vi.fn(), getLastFileWriteTime: vi.fn(), @@ -908,7 +906,7 @@ describe('KoreaderService', () => { }); it('does not report a held device as KOReader-latest', async () => { - mockRepo.findBookFileIdByBookId.mockResolvedValue(10); + mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' }); mockRepo.getProgressReset.mockResolvedValue(resetAt); mockRepo.getBookProgressForDashboard.mockResolvedValue({ deviceProgress: [ @@ -928,7 +926,7 @@ describe('KoreaderService', () => { }); it('counts a converged device as canonical again even while the marker is still live', async () => { - mockRepo.findBookFileIdByBookId.mockResolvedValue(10); + mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' }); mockRepo.getProgressReset.mockResolvedValue(resetAt); mockRepo.getConvergedResetDeviceIds.mockResolvedValue(new Set(['device-1'])); mockRepo.getBookProgressForDashboard.mockResolvedValue({ @@ -982,7 +980,7 @@ describe('KoreaderService', () => { // A device progress row outlives the library grant that created it, so holding a row is // not proof of access to the book it points at. await expect(service.releaseResetHold(7, 20, 'device-1')).rejects.toThrow(NotFoundException); - expect(mockRepo.findProgressBookFileByBookId).toHaveBeenCalledWith(20, [3]); + expect(mockRepo.findProgressBookFileByBookId).toHaveBeenCalledWith(20, 7, [3]); }); }); @@ -1602,7 +1600,7 @@ describe('KoreaderService', () => { describe('getBookProgress', () => { it('returns full sync info with chapters when KOReader is the canonical source', async () => { const latestDeviceTime = new Date('2026-03-01T10:00:00.000Z'); - mockRepo.findBookFileIdByBookId.mockResolvedValue(31); + mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 31, bookId: 20, libraryId: 1, format: 'epub' }); mockRepo.getBookProgressForDashboard.mockResolvedValue({ deviceProgress: [ { @@ -1662,14 +1660,34 @@ describe('KoreaderService', () => { }); }); - it('returns null when there is no primary book file', async () => { - mockRepo.findBookFileIdByBookId.mockResolvedValue(null); + it('returns null when the book has no file to report on', async () => { + mockRepo.findProgressBookFileByBookId.mockResolvedValue(null); await expect(service.getBookProgress(7, 99)).resolves.toBeNull(); }); + it('reports the file the user devices sync, resolved within libraries the user can reach', async () => { + mockRepo.getAccessibleLibraryIds.mockResolvedValue([3]); + mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 44, bookId: 99, libraryId: 3, format: 'epub' }); + mockRepo.getBookProgressForDashboard.mockResolvedValue({ + deviceProgress: [ + { device: 'Kindle', deviceId: 'device-1', percentage: 0.69, chapterIndex: 6, updatedAt: new Date('2026-03-01T10:00:00.000Z') }, + ], + readingProgress: null, + }); + mockRepo.getChapters.mockResolvedValue([]); + mockRepo.getLastFileWriteTime.mockResolvedValue(null); + + const info = await service.getBookProgress(7, 99); + + // The same resolver the release action uses, so a hold shown here is one it can release. + expect(mockRepo.findProgressBookFileByBookId).toHaveBeenCalledWith(99, 7, [3]); + expect(mockRepo.getBookProgressForDashboard).toHaveBeenCalledWith(44, 7); + expect(info).toEqual(expect.objectContaining({ bookFileId: 44, canonicalSource: 'koreader', canonicalPercentage: 69 })); + }); + it('returns null when no progress data exists for the book', async () => { - mockRepo.findBookFileIdByBookId.mockResolvedValue(31); + mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 31, bookId: 20, libraryId: 1, format: 'epub' }); mockRepo.getBookProgressForDashboard.mockResolvedValue({ deviceProgress: [], readingProgress: null, @@ -1679,7 +1697,7 @@ describe('KoreaderService', () => { }); it('uses web reader as the canonical source when its progress is newer', async () => { - mockRepo.findBookFileIdByBookId.mockResolvedValue(31); + mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 31, bookId: 20, libraryId: 1, format: 'epub' }); mockRepo.getBookProgressForDashboard.mockResolvedValue({ deviceProgress: [ { @@ -1722,7 +1740,7 @@ describe('KoreaderService', () => { }); it('marks the file stale when any device synced before the last file write', async () => { - mockRepo.findBookFileIdByBookId.mockResolvedValue(31); + mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 31, bookId: 20, libraryId: 1, format: 'epub' }); mockRepo.getBookProgressForDashboard.mockResolvedValue({ deviceProgress: [ { @@ -1758,7 +1776,7 @@ describe('KoreaderService', () => { }); it('keeps the file fresh when every device synced after the last file write', async () => { - mockRepo.findBookFileIdByBookId.mockResolvedValue(31); + mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 31, bookId: 20, libraryId: 1, format: 'epub' }); mockRepo.getBookProgressForDashboard.mockResolvedValue({ deviceProgress: [ { diff --git a/server/src/modules/koreader/koreader.service.ts b/server/src/modules/koreader/koreader.service.ts index cb190f13b..f155f306a 100644 --- a/server/src/modules/koreader/koreader.service.ts +++ b/server/src/modules/koreader/koreader.service.ts @@ -726,8 +726,10 @@ export class KoreaderService { } async getBookProgress(userId: number, bookId: number): Promise { - const bookFileId = await this.repo.findBookFileIdByBookId(bookId); - if (!bookFileId) return null; + const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(userId); + const bookFile = await this.repo.findProgressBookFileByBookId(bookId, userId, accessibleLibraryIds); + if (!bookFile) return null; + const bookFileId = bookFile.id; const { deviceProgress, readingProgress } = await this.repo.getBookProgressForDashboard(bookFileId, userId); if (deviceProgress.length === 0 && !readingProgress) return null; @@ -792,7 +794,7 @@ export class KoreaderService { async releaseResetHold(userId: number, bookId: number, deviceId: string): Promise { const startedAt = Date.now(); const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(userId); - const bookFile = await this.repo.findProgressBookFileByBookId(bookId, accessibleLibraryIds); + const bookFile = await this.repo.findProgressBookFileByBookId(bookId, userId, accessibleLibraryIds); if (!bookFile) throw new NotFoundException(`No synced file found for book ${bookId}`); const held = await this.repo.getDeviceProgressForDevice(bookFile.id, userId, deviceId); diff --git a/server/test/koreader-readaloud-progress.e2e-spec.ts b/server/test/koreader-readaloud-progress.e2e-spec.ts new file mode 100644 index 000000000..b258756fb --- /dev/null +++ b/server/test/koreader-readaloud-progress.e2e-spec.ts @@ -0,0 +1,353 @@ +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); + }); +});