fix(reader): sync progress between an EPUB and its read-along copy

Carry positions between sibling EPUBs even without an audiobook, and keep
narration markers on the read-along file only. Point the KOReader book panel
and hold release at the copy devices sync, and label EPUB-only sync in the
read-aloud panel.

Fixes #1508
This commit is contained in:
neon
2026-09-23 12:30:01 +00:00
parent 94eca198c3
commit 3c032e0e3c
12 changed files with 936 additions and 97 deletions
@@ -373,10 +373,31 @@ const readAloudSync = ref<ReadAloudProgressSync>(props.book.readAloudSync)
const readAloudSyncSaving = ref(false) const readAloudSyncSaving = ref(false)
const readAloudSyncError = ref<string | null>(null) const readAloudSyncError = ref<string | null>(null)
const showReadAloudSync = computed(() => readAlongFile.value != null || hasAudioFile.value) 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(() => { const readAloudSyncDescription = computed(() => {
if (readAloudSync.value.state === 'enabled') return t('book.detail.details.readAloudSync.enabledDescription') 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 (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' const reason = readAloudSync.value.unavailableReason ?? 'missing_duration'
if (reason === 'duration_mismatch') { if (reason === 'duration_mismatch') {
return t('book.detail.details.readAloudSync.reason.durationMismatch', { return t('book.detail.details.readAloudSync.reason.durationMismatch', {
@@ -385,7 +406,7 @@ const readAloudSyncDescription = computed(() => {
}) })
} }
return t(`book.detail.details.readAloudSync.reason.${reason}`) return t(`book.detail.details.readAloudSync.reason.${reason}`)
}) }
watch( watch(
() => props.book.readAloudSync, () => props.book.readAloudSync,
@@ -1782,6 +1803,9 @@ watch(
<span class="text-[11px] text-muted-foreground">{{ readAloudSyncStatus }}</span> <span class="text-[11px] text-muted-foreground">{{ readAloudSyncStatus }}</span>
</div> </div>
<p class="mt-0.5 text-xs text-muted-foreground">{{ readAloudSyncDescription }}</p> <p class="mt-0.5 text-xs text-muted-foreground">{{ readAloudSyncDescription }}</p>
<p v-if="readAloudSyncAudiobookNote" data-test="read-aloud-sync-audiobook-note" class="mt-0.5 text-xs text-muted-foreground">
{{ readAloudSyncAudiobookNote }}
</p>
<p v-if="readAloudSyncError" class="mt-1 text-xs text-destructive" role="status" aria-live="polite"> <p v-if="readAloudSyncError" class="mt-1 text-xs text-destructive" role="status" aria-live="polite">
{{ readAloudSyncError }} {{ readAloudSyncError }}
</p> </p>
@@ -846,4 +846,62 @@ describe('DetailsTab cover surface', () => {
expect(wrapper.get('[role="status"]').text()).toBe('Could not update read-aloud progress sync.') expect(wrapper.get('[role="status"]').text()).toBe('Could not update read-aloud progress sync.')
expect(wrapper.emitted('saved')).toBeUndefined() 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')
})
})
}) })
+3 -1
View File
@@ -5254,10 +5254,12 @@
"state": { "state": {
"enabled": "Enabled", "enabled": "Enabled",
"disabled": "Disabled", "disabled": "Disabled",
"unavailable": "Unavailable" "unavailable": "Unavailable",
"epubCopiesOnly": "EPUB copies only"
}, },
"enabledDescription": "Audiobook, web reader, Kobo, and KOReader positions stay in sync.", "enabledDescription": "Audiobook, web reader, Kobo, and KOReader positions stay in sync.",
"disabledDescription": "Each format keeps its own reading position.", "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": { "reason": {
"no_media_overlay_epub": "A read-along EPUB with media overlays is required.", "no_media_overlay_epub": "A read-along EPUB with media overlays is required.",
"no_audio_files": "Matching standalone audiobook files are required.", "no_audio_files": "Matching standalone audiobook files are required.",
+23
View File
@@ -735,6 +735,29 @@ export const E2E_SUITES = Object.freeze({
...SHARED_DB_AND_HELPER_PATHS, ...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": { "koreader-file-naming": {
id: "koreader-file-naming", id: "koreader-file-naming",
name: "KOReader File Naming", name: "KOReader File Naming",
@@ -11,7 +11,7 @@ const mockBuildPlaylist = vi.mocked(buildEpubMediaOverlayPlaylistFromFile);
const SOURCE_TIME = new Date('2026-09-19T12:00:00.000Z'); const SOURCE_TIME = new Date('2026-09-19T12:00:00.000Z');
function makeFiles(audioDuration = 100) { function makeFiles(audioDuration: number | null = 100) {
return { return {
primaryFileId: 20, primaryFileId: 20,
files: [ 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<typeof makeFiles> = makeFiles(audioDuration)) {
const bookRepo = { const bookRepo = {
findReadAloudSyncMode: vi.fn().mockResolvedValue('auto'), findReadAloudSyncMode: vi.fn().mockResolvedValue('auto'),
findAudioEbookProgressSyncFiles: vi.fn().mockResolvedValue(makeFiles(audioDuration)), findAudioEbookProgressSyncFiles: vi.fn().mockResolvedValue(syncFiles),
upsertSyncedEpubProgressIfNewer: vi.fn().mockResolvedValue(true), upsertSyncedEpubProgressIfNewer: vi.fn().mockResolvedValue(true),
syncKoboReadingStateFromProgress: vi.fn().mockResolvedValue(undefined), syncKoboReadingStateFromProgress: vi.fn().mockResolvedValue(undefined),
upsertAudioProgress: vi.fn().mockResolvedValue({ revision: 2 }), upsertAudioProgress: vi.fn().mockResolvedValue({ revision: 2 }),
@@ -156,13 +162,30 @@ describe('AudiobookEbookProgressSyncService', () => {
).resolves.toBe(true); ).resolves.toBe(true);
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledTimes(2); 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( expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith(
1, 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( expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith(
2, 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(bookRepo.syncKoboReadingStateFromProgress).toHaveBeenCalledWith(7, 20, 75, null, null, null, null);
expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith( expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith(
@@ -194,9 +217,10 @@ describe('AudiobookEbookProgressSyncService', () => {
1, 1,
expect.objectContaining({ expect.objectContaining({
fileId: 20, fileId: 20,
cfi: 'epubcfi(20-after)',
percentage: positionSeconds, percentage: positionSeconds,
positionSeconds, positionSeconds: null,
mediaOverlayFragment: 'OPS/chapter.xhtml#after', mediaOverlayFragment: null,
}), }),
); );
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith( expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith(
@@ -258,7 +282,7 @@ describe('AudiobookEbookProgressSyncService', () => {
service.syncFromEbookProgress({ service.syncFromEbookProgress({
userId: 7, userId: 7,
bookId: 5, bookId: 5,
bookFileId: 20, bookFileId: 30,
percentage: 75, percentage: 75,
positionSeconds: 75, positionSeconds: 75,
mediaOverlayFragment: 'OPS/chapter.xhtml#after', mediaOverlayFragment: 'OPS/chapter.xhtml#after',
@@ -269,10 +293,55 @@ describe('AudiobookEbookProgressSyncService', () => {
expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(7, 5, 10, 75, 75, SOURCE_TIME); expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(7, 5, 10, 75, 75, SOURCE_TIME);
expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith( 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(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(); 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();
});
});
@@ -34,16 +34,27 @@ type EpubProgressResolution = {
cfi: string; cfi: string;
koreaderProgress: string | null; koreaderProgress: string | null;
percentage: number; percentage: number;
positionSeconds: number; positionSeconds: number | null;
mediaOverlayFragment: string; mediaOverlayFragment: string | null;
mediaOverlaySectionIndex: number; 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; currentFileId: number;
positionSeconds: number; positionSeconds: number;
percentage: number; percentage: number;
siblingTargets: EpubProgressResolution[];
}; };
@Injectable() @Injectable()
@@ -129,22 +140,29 @@ export class AudiobookEbookProgressSyncService {
try { try {
if ((await this.bookRepo.findReadAloudSyncMode(params.userId, params.bookId)) === 'disabled') return false; if ((await this.bookRepo.findReadAloudSyncMode(params.userId, params.bookId)) === 'disabled') return false;
const resolution = await this.resolveAudioProgress(params); const syncSiblingEpubs = params.syncSiblingEpubs !== false;
if (!resolution) return false; const position = await this.resolveEbookOverlayPosition(params, syncSiblingEpubs);
if (!position) return false;
const sourceUpdatedAt = params.sourceUpdatedAt ?? new Date(); const sourceUpdatedAt = params.sourceUpdatedAt ?? new Date();
const saved = await this.bookRepo.upsertAudioProgress( const audio = this.mapOverlayPositionToAudio(position);
params.userId, if (audio) {
params.bookId, const saved = await this.bookRepo.upsertAudioProgress(
resolution.currentFileId, params.userId,
resolution.positionSeconds, params.bookId,
resolution.percentage, audio.currentFileId,
sourceUpdatedAt, audio.positionSeconds,
); audio.percentage,
if (!saved) return false; sourceUpdatedAt,
);
if (!saved) return false;
}
if (!syncSiblingEpubs) return audio !== null;
for (const target of params.syncSiblingEpubs === false ? [] : resolution.siblingTargets) { let siblingSynced = false;
await this.bookRepo.upsertSyncedEpubProgressIfNewer({ 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, userId: params.userId,
fileId: target.targetFile.id, fileId: target.targetFile.id,
cfi: target.cfi, cfi: target.cfi,
@@ -155,8 +173,9 @@ export class AudiobookEbookProgressSyncService {
koreaderProgress: target.koreaderProgress, koreaderProgress: target.koreaderProgress,
sourceUpdatedAt, sourceUpdatedAt,
}); });
siblingSynced ||= accepted;
} }
return true; return audio !== null || siblingSynced;
} catch (error: unknown) { } catch (error: unknown) {
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError'; const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error)); 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 targetFile = this.selectTargetFile(syncFiles.files, syncFiles.primaryFileId, overlaySourceFile);
const epubFiles = syncFiles.files.filter((file) => file.format?.toLowerCase() === 'epub'); const epubFiles = syncFiles.files.filter((file) => file.format?.toLowerCase() === 'epub');
const orderedTargets = [targetFile, ...epubFiles.filter((file) => file.id !== targetFile.id)]; const orderedTargets = [targetFile, ...epubFiles.filter((file) => file.id !== targetFile.id)];
const percentage = this.clampPercentage((overlaySeconds / playlist.durationSeconds) * 100);
const targets: EpubProgressResolution[] = []; const targets: EpubProgressResolution[] = [];
for (const candidate of orderedTargets) { for (const candidate of orderedTargets) {
const resolved = await this.resolveFragmentPositions(candidate, item, overlaySourceFile.id); const resolved = await this.resolveFragmentPositions(candidate, item, overlaySourceFile.id);
if (!resolved) continue; if (resolved) targets.push(this.toEpubTarget(resolved, item, overlaySourceFile, overlaySeconds, percentage));
targets.push({
...resolved,
percentage: this.clampPercentage((overlaySeconds / playlist.durationSeconds) * 100),
positionSeconds: overlaySeconds,
mediaOverlayFragment: this.itemFragment(item),
mediaOverlaySectionIndex: item.sectionIndex,
});
} }
if (targets.length === 0) return null; if (targets.length === 0) return null;
@@ -220,15 +233,23 @@ export class AudiobookEbookProgressSyncService {
}; };
} }
private async resolveAudioProgress(params: { /**
bookId: number; * Places an EPUB position on the read-along file's narration, which is what every other copy
bookFileId: number; * of the book is found through. Returns null before any file is parsed when there is nothing
cfi?: string | null; * to carry the position to, which is every sync of a read-along EPUB that sits on its own.
koreaderProgress?: string | null; */
positionSeconds?: number | null; private async resolveEbookOverlayPosition(
mediaOverlayFragment?: string | null; params: {
mediaOverlaySectionIndex?: number | null; bookId: number;
}): Promise<AudioProgressResolution | null> { bookFileId: number;
cfi?: string | null;
koreaderProgress?: string | null;
positionSeconds?: number | null;
mediaOverlayFragment?: string | null;
mediaOverlaySectionIndex?: number | null;
},
syncSiblingEpubs: boolean,
): Promise<EbookOverlayPosition | null> {
const syncFiles = await this.bookRepo.findAudioEbookProgressSyncFiles(params.bookId); const syncFiles = await this.bookRepo.findAudioEbookProgressSyncFiles(params.bookId);
if (!syncFiles) return null; 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 audioFiles = syncFiles.files.filter((file) => typeof file.format === 'string' && isAudioFormat(file.format));
const audioTotalSeconds = this.computeAudioTotalSeconds(audioFiles); 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); const playlist = await this.getPlaylist(overlaySourceFile, params.bookId);
if (playlist.items.length === 0 || playlist.durationSeconds == null || playlist.durationSeconds <= 0) return null; 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); const overlaySeconds = await this.resolveOverlaySecondsFromEbookPosition(ebookFile, overlaySourceFile.id, playlist, params);
if (overlaySeconds === null) return null; 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; if (audioSeconds === null) return null;
const filePosition = this.audioFilePositionForSeconds(audioFiles, audioSeconds); const filePosition = this.audioFilePositionForSeconds(position.audioFiles, audioSeconds);
if (!filePosition) return null; 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 { return {
currentFileId: filePosition.file.id, currentFileId: filePosition.file.id,
positionSeconds: filePosition.positionSeconds, positionSeconds: filePosition.positionSeconds,
percentage: this.clampPercentage((audioSeconds / audioTotalSeconds) * 100), percentage: this.clampPercentage((audioSeconds / position.audioTotalSeconds) * 100),
siblingTargets, };
}
/**
* 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<EpubProgressResolution[]> {
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; mediaOverlaySectionIndex?: number | null;
}, },
): Promise<number | null> { ): Promise<number | null> {
// 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 ( if (
hasNarration &&
this.isNonNegativeFinite(params.positionSeconds) && this.isNonNegativeFinite(params.positionSeconds) &&
(params.mediaOverlayFragment || params.mediaOverlaySectionIndex != null) && (params.mediaOverlayFragment || params.mediaOverlaySectionIndex != null) &&
playlist.durationSeconds != null playlist.durationSeconds != null
@@ -347,7 +416,7 @@ export class AudiobookEbookProgressSyncService {
return Math.max(0, Math.min(playlist.durationSeconds, params.positionSeconds)); 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); const match = this.findItemStartByFragment(playlist, params.mediaOverlayFragment, params.mediaOverlaySectionIndex ?? null);
if (match) return match.startSeconds; if (match) return match.startSeconds;
} }
@@ -751,6 +751,35 @@ describe('BookRepository', () => {
expect(resetWhere).toHaveBeenCalledOnce(); 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 () => { it('does not clear reset protection when a newer EPUB row rejects the bridge', async () => {
const returning = vi.fn().mockResolvedValue([]); const returning = vi.fn().mockResolvedValue([]);
const db = { const db = {
+3 -3
View File
@@ -2368,9 +2368,9 @@ export class BookRepository {
fileId: number; fileId: number;
cfi: string; cfi: string;
percentage: number; percentage: number;
positionSeconds: number; positionSeconds: number | null;
mediaOverlayFragment: string; mediaOverlayFragment: string | null;
mediaOverlaySectionIndex: number; mediaOverlaySectionIndex: number | null;
koreaderProgress: string | null; koreaderProgress: string | null;
sourceUpdatedAt: Date; sourceUpdatedAt: Date;
}): Promise<boolean> { }): Promise<boolean> {
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; 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 type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { chunk } from '../../common/utils/batch.utils'; 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. * 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 * the copy this user's devices synced most recently, or the primary file when none has. A
* reports one file's holds, so the release action has to act on that same file rather than * device syncs whichever copy it was given, and a Storyteller read-along EPUB becomes the
* whichever one a marker happens to be found on first. * 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; 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 const [row] = await this.db
.select({ .select({
id: schema.bookFiles.id, id: schema.bookFiles.id,
@@ -780,13 +789,14 @@ export class KoreaderRepository {
.where( .where(
and( and(
eq(schema.books.id, bookId), 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 // 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 // row outlives the library grant that created it, so ownership of the row is not
// ownership of the book, and this path writes. // ownership of the book, and this path writes.
accessibleLibraryIds === null ? undefined : inArray(schema.books.libraryId, accessibleLibraryIds), accessibleLibraryIds === null ? undefined : inArray(schema.books.libraryId, accessibleLibraryIds),
), ),
) )
.orderBy(sql`${lastDeviceSyncAt} desc nulls last`, asc(schema.bookFiles.id))
.limit(1); .limit(1);
return row ?? null; return row ?? null;
} }
@@ -81,7 +81,6 @@ describe('KoreaderService', () => {
setKoreaderUserDefaultPattern: ReturnType<typeof vi.fn>; setKoreaderUserDefaultPattern: ReturnType<typeof vi.fn>;
setDeviceFileNamingPattern: ReturnType<typeof vi.fn>; setDeviceFileNamingPattern: ReturnType<typeof vi.fn>;
clearDeviceFileNamingPattern: ReturnType<typeof vi.fn>; clearDeviceFileNamingPattern: ReturnType<typeof vi.fn>;
findBookFileIdByBookId: ReturnType<typeof vi.fn>;
getBookProgressForDashboard: ReturnType<typeof vi.fn>; getBookProgressForDashboard: ReturnType<typeof vi.fn>;
getChapters: ReturnType<typeof vi.fn>; getChapters: ReturnType<typeof vi.fn>;
getLastFileWriteTime: ReturnType<typeof vi.fn>; getLastFileWriteTime: ReturnType<typeof vi.fn>;
@@ -161,7 +160,6 @@ describe('KoreaderService', () => {
setKoreaderUserDefaultPattern: vi.fn().mockResolvedValue(undefined), setKoreaderUserDefaultPattern: vi.fn().mockResolvedValue(undefined),
setDeviceFileNamingPattern: vi.fn().mockResolvedValue(undefined), setDeviceFileNamingPattern: vi.fn().mockResolvedValue(undefined),
clearDeviceFileNamingPattern: vi.fn().mockResolvedValue(undefined), clearDeviceFileNamingPattern: vi.fn().mockResolvedValue(undefined),
findBookFileIdByBookId: vi.fn(),
getBookProgressForDashboard: vi.fn(), getBookProgressForDashboard: vi.fn(),
getChapters: vi.fn(), getChapters: vi.fn(),
getLastFileWriteTime: vi.fn(), getLastFileWriteTime: vi.fn(),
@@ -908,7 +906,7 @@ describe('KoreaderService', () => {
}); });
it('does not report a held device as KOReader-latest', async () => { 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.getProgressReset.mockResolvedValue(resetAt);
mockRepo.getBookProgressForDashboard.mockResolvedValue({ mockRepo.getBookProgressForDashboard.mockResolvedValue({
deviceProgress: [ deviceProgress: [
@@ -928,7 +926,7 @@ describe('KoreaderService', () => {
}); });
it('counts a converged device as canonical again even while the marker is still live', async () => { 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.getProgressReset.mockResolvedValue(resetAt);
mockRepo.getConvergedResetDeviceIds.mockResolvedValue(new Set(['device-1'])); mockRepo.getConvergedResetDeviceIds.mockResolvedValue(new Set(['device-1']));
mockRepo.getBookProgressForDashboard.mockResolvedValue({ 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 // 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. // not proof of access to the book it points at.
await expect(service.releaseResetHold(7, 20, 'device-1')).rejects.toThrow(NotFoundException); 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', () => { describe('getBookProgress', () => {
it('returns full sync info with chapters when KOReader is the canonical source', async () => { it('returns full sync info with chapters when KOReader is the canonical source', async () => {
const latestDeviceTime = new Date('2026-03-01T10:00:00.000Z'); 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({ mockRepo.getBookProgressForDashboard.mockResolvedValue({
deviceProgress: [ deviceProgress: [
{ {
@@ -1662,14 +1660,34 @@ describe('KoreaderService', () => {
}); });
}); });
it('returns null when there is no primary book file', async () => { it('returns null when the book has no file to report on', async () => {
mockRepo.findBookFileIdByBookId.mockResolvedValue(null); mockRepo.findProgressBookFileByBookId.mockResolvedValue(null);
await expect(service.getBookProgress(7, 99)).resolves.toBeNull(); 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 () => { 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({ mockRepo.getBookProgressForDashboard.mockResolvedValue({
deviceProgress: [], deviceProgress: [],
readingProgress: null, readingProgress: null,
@@ -1679,7 +1697,7 @@ describe('KoreaderService', () => {
}); });
it('uses web reader as the canonical source when its progress is newer', async () => { 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({ mockRepo.getBookProgressForDashboard.mockResolvedValue({
deviceProgress: [ deviceProgress: [
{ {
@@ -1722,7 +1740,7 @@ describe('KoreaderService', () => {
}); });
it('marks the file stale when any device synced before the last file write', async () => { 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({ mockRepo.getBookProgressForDashboard.mockResolvedValue({
deviceProgress: [ deviceProgress: [
{ {
@@ -1758,7 +1776,7 @@ describe('KoreaderService', () => {
}); });
it('keeps the file fresh when every device synced after the last file write', async () => { 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({ mockRepo.getBookProgressForDashboard.mockResolvedValue({
deviceProgress: [ deviceProgress: [
{ {
@@ -726,8 +726,10 @@ export class KoreaderService {
} }
async getBookProgress(userId: number, bookId: number): Promise<KoreaderBookSyncInfo | null> { async getBookProgress(userId: number, bookId: number): Promise<KoreaderBookSyncInfo | null> {
const bookFileId = await this.repo.findBookFileIdByBookId(bookId); const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(userId);
if (!bookFileId) return null; 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); const { deviceProgress, readingProgress } = await this.repo.getBookProgressForDashboard(bookFileId, userId);
if (deviceProgress.length === 0 && !readingProgress) return null; if (deviceProgress.length === 0 && !readingProgress) return null;
@@ -792,7 +794,7 @@ export class KoreaderService {
async releaseResetHold(userId: number, bookId: number, deviceId: string): Promise<void> { async releaseResetHold(userId: number, bookId: number, deviceId: string): Promise<void> {
const startedAt = Date.now(); const startedAt = Date.now();
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(userId); 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}`); if (!bookFile) throw new NotFoundException(`No synced file found for book ${bookId}`);
const held = await this.repo.getDeviceProgressForDevice(bookFile.id, userId, deviceId); const held = await this.repo.getDeviceProgressForDevice(bookFile.id, userId, deviceId);
@@ -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<string, string> {
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 = ` <item id="chapter-${n}" href="chapter-${n}.xhtml" media-type="application/xhtml+xml"${readAlong ? ` media-overlay="smil-${n}"` : ''} />`;
if (!readAlong) return chapter;
return [
chapter,
` <item id="smil-${n}" href="chapter-${n}.smil" media-type="application/smil+xml" />`,
` <item id="audio-${n}" href="audio/chapter-${n}.mp3" media-type="audio/mpeg" />`,
].join('\n');
})
.join('\n');
const spine = chapterIds.map((n) => ` <itemref idref="chapter-${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: `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml" /></rootfiles>
</container>`,
},
{
path: 'OPS/content.opf',
content: `<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">${uid}</dc:identifier>
<dc:title>${title}</dc:title>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
${manifest}
</manifest>
<spine>
${spine}
</spine>
</package>`,
},
];
for (const n of chapterIds) {
const sentence = `Chapter ${n} of ${title} has one sentence of prose in it.`;
const body = readAlong ? `<p><span id="c${n}-s1">${sentence}</span></p>` : `<p>${sentence}</p>`;
entries.push({
path: `OPS/chapter-${n}.xhtml`,
content: `<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter ${n}</title></head><body>${body}</body></html>`,
});
if (!readAlong) continue;
entries.push({
path: `OPS/chapter-${n}.smil`,
content: `<?xml version="1.0" encoding="UTF-8"?>
<smil xmlns="http://www.w3.org/ns/SMIL" xmlns:epub="http://www.idpf.org/2007/ops" version="3.0">
<body><seq id="seq-${n}" epub:textref="chapter-${n}.xhtml">
<par id="par-${n}"><text src="chapter-${n}.xhtml#c${n}-s1" /><audio src="audio/chapter-${n}.mp3" clipBegin="0s" clipEnd="60s" /></par>
</seq></body>
</smil>`,
});
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<string> {
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<number> {
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<FileProgress> {
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);
});
});