mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user