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:
@@ -11,7 +11,7 @@ const mockBuildPlaylist = vi.mocked(buildEpubMediaOverlayPlaylistFromFile);
|
||||
|
||||
const SOURCE_TIME = new Date('2026-09-19T12:00:00.000Z');
|
||||
|
||||
function makeFiles(audioDuration = 100) {
|
||||
function makeFiles(audioDuration: number | null = 100) {
|
||||
return {
|
||||
primaryFileId: 20,
|
||||
files: [
|
||||
@@ -112,10 +112,16 @@ function makePlaylistWithZeroLengthClip() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeFixture(audioDuration = 100) {
|
||||
/** A Storyteller read-along EPUB beside the original, with the audiobook never imported. */
|
||||
function makeFilesWithoutAudio() {
|
||||
const files = makeFiles();
|
||||
return { ...files, primaryFileId: 30, files: files.files.filter((file) => file.format === 'epub') };
|
||||
}
|
||||
|
||||
function makeFixture(audioDuration = 100, syncFiles: ReturnType<typeof makeFiles> = makeFiles(audioDuration)) {
|
||||
const bookRepo = {
|
||||
findReadAloudSyncMode: vi.fn().mockResolvedValue('auto'),
|
||||
findAudioEbookProgressSyncFiles: vi.fn().mockResolvedValue(makeFiles(audioDuration)),
|
||||
findAudioEbookProgressSyncFiles: vi.fn().mockResolvedValue(syncFiles),
|
||||
upsertSyncedEpubProgressIfNewer: vi.fn().mockResolvedValue(true),
|
||||
syncKoboReadingStateFromProgress: vi.fn().mockResolvedValue(undefined),
|
||||
upsertAudioProgress: vi.fn().mockResolvedValue({ revision: 2 }),
|
||||
@@ -156,13 +162,30 @@ describe('AudiobookEbookProgressSyncService', () => {
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledTimes(2);
|
||||
// The plain EPUB has no narration to resume, so it gets the text position without a marker.
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ userId: 7, fileId: 20, percentage: 75, positionSeconds: 75, sourceUpdatedAt: SOURCE_TIME }),
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
fileId: 20,
|
||||
percentage: 75,
|
||||
positionSeconds: null,
|
||||
mediaOverlayFragment: null,
|
||||
mediaOverlaySectionIndex: null,
|
||||
sourceUpdatedAt: SOURCE_TIME,
|
||||
}),
|
||||
);
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ userId: 7, fileId: 30, percentage: 75, positionSeconds: 75, sourceUpdatedAt: SOURCE_TIME }),
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
fileId: 30,
|
||||
percentage: 75,
|
||||
positionSeconds: 75,
|
||||
mediaOverlayFragment: 'OPS/chapter.xhtml#second',
|
||||
mediaOverlaySectionIndex: 0,
|
||||
sourceUpdatedAt: SOURCE_TIME,
|
||||
}),
|
||||
);
|
||||
expect(bookRepo.syncKoboReadingStateFromProgress).toHaveBeenCalledWith(7, 20, 75, null, null, null, null);
|
||||
expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith(
|
||||
@@ -194,9 +217,10 @@ describe('AudiobookEbookProgressSyncService', () => {
|
||||
1,
|
||||
expect.objectContaining({
|
||||
fileId: 20,
|
||||
cfi: 'epubcfi(20-after)',
|
||||
percentage: positionSeconds,
|
||||
positionSeconds,
|
||||
mediaOverlayFragment: 'OPS/chapter.xhtml#after',
|
||||
positionSeconds: null,
|
||||
mediaOverlayFragment: null,
|
||||
}),
|
||||
);
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith(
|
||||
@@ -258,7 +282,7 @@ describe('AudiobookEbookProgressSyncService', () => {
|
||||
service.syncFromEbookProgress({
|
||||
userId: 7,
|
||||
bookId: 5,
|
||||
bookFileId: 20,
|
||||
bookFileId: 30,
|
||||
percentage: 75,
|
||||
positionSeconds: 75,
|
||||
mediaOverlayFragment: 'OPS/chapter.xhtml#after',
|
||||
@@ -269,10 +293,55 @@ describe('AudiobookEbookProgressSyncService', () => {
|
||||
|
||||
expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(7, 5, 10, 75, 75, SOURCE_TIME);
|
||||
expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bookFileId: 30, sourceBookFileId: 30, fragment: 'after' }),
|
||||
expect.objectContaining({ bookFileId: 20, sourceBookFileId: 30, fragment: 'after' }),
|
||||
);
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileId: 30, positionSeconds: 75, mediaOverlayFragment: 'OPS/chapter.xhtml#after' }),
|
||||
expect.objectContaining({ fileId: 20, cfi: 'epubcfi(20-after)', positionSeconds: null, mediaOverlayFragment: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it('follows the text position of a plain EPUB that sends back a narration marker an older sync left on it', async () => {
|
||||
const { service, bookRepo, positionConverter } = makeFixture();
|
||||
|
||||
await expect(
|
||||
service.syncFromEbookProgress({
|
||||
userId: 7,
|
||||
bookId: 5,
|
||||
bookFileId: 20,
|
||||
percentage: 75,
|
||||
cfi: 'epubcfi(/6/4)',
|
||||
positionSeconds: 10,
|
||||
mediaOverlayFragment: 'OPS/chapter.xhtml#first',
|
||||
mediaOverlaySectionIndex: 0,
|
||||
sourceUpdatedAt: SOURCE_TIME,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
// The stale marker names 'first' at 10 seconds; the page the reader is on resolves to 'second'.
|
||||
expect(positionConverter.nearestFragmentForPosition).toHaveBeenCalledWith(expect.objectContaining({ bookFileId: 20, cfi: 'epubcfi(/6/4)' }));
|
||||
expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(7, 5, 10, 50, 50, SOURCE_TIME);
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileId: 30, positionSeconds: 50, mediaOverlayFragment: 'OPS/chapter.xhtml#second' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes a read-along position to the plain sibling without a narration marker', async () => {
|
||||
const { service, bookRepo } = makeFixture();
|
||||
|
||||
await expect(
|
||||
service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 30, percentage: 75, cfi: 'epubcfi(/6/4)', sourceUpdatedAt: SOURCE_TIME }),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(7, 5, 10, 50, 50, SOURCE_TIME);
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledOnce();
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileId: 20,
|
||||
percentage: 50,
|
||||
positionSeconds: null,
|
||||
mediaOverlayFragment: null,
|
||||
mediaOverlaySectionIndex: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -351,3 +420,185 @@ describe('AudiobookEbookProgressSyncService', () => {
|
||||
expect(mockBuildPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AudiobookEbookProgressSyncService sibling EPUBs without an audiobook', () => {
|
||||
const KOREADER_XPOINTER = '/body/DocFragment[1]/body/p[3]/text().0';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1234 } as never);
|
||||
mockBuildPlaylist.mockResolvedValue(makePlaylist());
|
||||
});
|
||||
|
||||
it('carries a device position on the original EPUB to the read-along copy', async () => {
|
||||
const { service, bookRepo, positionConverter } = makeFixture(100, makeFilesWithoutAudio());
|
||||
|
||||
await expect(
|
||||
service.syncFromEbookProgress({
|
||||
userId: 7,
|
||||
bookId: 5,
|
||||
bookFileId: 20,
|
||||
percentage: 69,
|
||||
cfi: 'epubcfi(/6/4)',
|
||||
koreaderProgress: KOREADER_XPOINTER,
|
||||
sourceUpdatedAt: SOURCE_TIME,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(bookRepo.upsertAudioProgress).not.toHaveBeenCalled();
|
||||
expect(positionConverter.nearestFragmentForPosition).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bookFileId: 20, sourceBookFileId: 30, cfi: 'epubcfi(/6/4)', xpointer: KOREADER_XPOINTER }),
|
||||
);
|
||||
expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith({ bookFileId: 30, sourceBookFileId: 30, chapterIndex: 0, fragment: 'second' });
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledOnce();
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith({
|
||||
userId: 7,
|
||||
fileId: 30,
|
||||
cfi: 'epubcfi(30-second)',
|
||||
koreaderProgress: '/body/30/second',
|
||||
percentage: 69,
|
||||
positionSeconds: 50,
|
||||
mediaOverlayFragment: 'OPS/chapter.xhtml#second',
|
||||
mediaOverlaySectionIndex: 0,
|
||||
sourceUpdatedAt: SOURCE_TIME,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries a web position on the read-along copy to the original without a narration marker', async () => {
|
||||
const { service, bookRepo, positionConverter } = makeFixture(100, makeFilesWithoutAudio());
|
||||
|
||||
await expect(
|
||||
service.syncFromEbookProgress({
|
||||
userId: 7,
|
||||
bookId: 5,
|
||||
bookFileId: 30,
|
||||
percentage: 67,
|
||||
cfi: 'epubcfi(/6/4)',
|
||||
koreaderProgress: KOREADER_XPOINTER,
|
||||
sourceUpdatedAt: SOURCE_TIME,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(positionConverter.nearestFragmentForPosition).toHaveBeenCalledWith(expect.objectContaining({ bookFileId: 30, sourceBookFileId: 30 }));
|
||||
expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith({ bookFileId: 20, sourceBookFileId: 30, chapterIndex: 0, fragment: 'second' });
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledOnce();
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith({
|
||||
userId: 7,
|
||||
fileId: 20,
|
||||
cfi: 'epubcfi(20-second)',
|
||||
koreaderProgress: '/body/20/second',
|
||||
percentage: 67,
|
||||
positionSeconds: null,
|
||||
mediaOverlayFragment: null,
|
||||
mediaOverlaySectionIndex: null,
|
||||
sourceUpdatedAt: SOURCE_TIME,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses nothing for a read-along EPUB that has no sibling and no audiobook', async () => {
|
||||
const readAlongOnly = makeFilesWithoutAudio();
|
||||
readAlongOnly.files = readAlongOnly.files.filter((file) => file.id === 30);
|
||||
const { service, bookRepo, positionConverter } = makeFixture(100, readAlongOnly);
|
||||
|
||||
await expect(
|
||||
service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 30, percentage: 67, cfi: 'epubcfi(/6/4)', sourceUpdatedAt: SOURCE_TIME }),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(mockStat).not.toHaveBeenCalled();
|
||||
expect(mockBuildPlaylist).not.toHaveBeenCalled();
|
||||
expect(positionConverter.nearestFragmentForPosition).not.toHaveBeenCalled();
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('parses nothing for a narration write that did not move the text position', async () => {
|
||||
const { service, bookRepo, positionConverter } = makeFixture(100, makeFilesWithoutAudio());
|
||||
|
||||
await expect(
|
||||
service.syncFromEbookProgress({
|
||||
userId: 7,
|
||||
bookId: 5,
|
||||
bookFileId: 30,
|
||||
percentage: 32,
|
||||
positionSeconds: 10,
|
||||
mediaOverlayFragment: 'OPS/chapter.xhtml#first',
|
||||
mediaOverlaySectionIndex: 0,
|
||||
sourceUpdatedAt: SOURCE_TIME,
|
||||
syncSiblingEpubs: false,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(mockBuildPlaylist).not.toHaveBeenCalled();
|
||||
expect(positionConverter.nearestFragmentForPosition).not.toHaveBeenCalled();
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps every copy on its own position when read-aloud sync is disabled', async () => {
|
||||
const { service, bookRepo } = makeFixture(100, makeFilesWithoutAudio());
|
||||
bookRepo.findReadAloudSyncMode.mockResolvedValue('disabled');
|
||||
|
||||
await expect(
|
||||
service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 20, percentage: 69, koreaderProgress: KOREADER_XPOINTER }),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(bookRepo.findAudioEbookProgressSyncFiles).not.toHaveBeenCalled();
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['its duration is too far from the narration', () => makeFixture(120)],
|
||||
['a file has no duration', () => makeFixture(100, makeFiles(null))],
|
||||
])('syncs the sibling EPUB by text when the audiobook cannot be mapped because %s', async (_reason, fixture) => {
|
||||
const { service, bookRepo } = fixture();
|
||||
|
||||
await expect(
|
||||
service.syncFromEbookProgress({
|
||||
userId: 7,
|
||||
bookId: 5,
|
||||
bookFileId: 20,
|
||||
percentage: 69,
|
||||
koreaderProgress: KOREADER_XPOINTER,
|
||||
sourceUpdatedAt: SOURCE_TIME,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(bookRepo.upsertAudioProgress).not.toHaveBeenCalled();
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledOnce();
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileId: 30, percentage: 69, positionSeconds: 50, mediaOverlayFragment: 'OPS/chapter.xhtml#second' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes nothing when the copies do not share the chapter text', async () => {
|
||||
const { service, bookRepo, positionConverter } = makeFixture(100, makeFilesWithoutAudio());
|
||||
positionConverter.fragmentToPositions.mockResolvedValue({ status: 'failed', reason: 'chapter_text_mismatch' } as never);
|
||||
|
||||
await expect(
|
||||
service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 30, percentage: 67, cfi: 'epubcfi(/6/4)', sourceUpdatedAt: SOURCE_TIME }),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes nothing when the position cannot be placed on the narration', async () => {
|
||||
const { service, bookRepo, positionConverter } = makeFixture(100, makeFilesWithoutAudio());
|
||||
positionConverter.nearestFragmentForPosition.mockResolvedValue({ status: 'failed', reason: 'no_candidate_fragments' });
|
||||
|
||||
await expect(
|
||||
service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 20, percentage: 69, koreaderProgress: KOREADER_XPOINTER }),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(positionConverter.fragmentToPositions).not.toHaveBeenCalled();
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports no sync when the sibling already holds a newer position', async () => {
|
||||
const { service, bookRepo } = makeFixture(100, makeFilesWithoutAudio());
|
||||
bookRepo.upsertSyncedEpubProgressIfNewer.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
service.syncFromEbookProgress({ userId: 7, bookId: 5, bookFileId: 20, percentage: 69, koreaderProgress: KOREADER_XPOINTER }),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,16 +34,27 @@ type EpubProgressResolution = {
|
||||
cfi: string;
|
||||
koreaderProgress: string | null;
|
||||
percentage: number;
|
||||
positionSeconds: number;
|
||||
mediaOverlayFragment: string;
|
||||
mediaOverlaySectionIndex: number;
|
||||
positionSeconds: number | null;
|
||||
mediaOverlayFragment: string | null;
|
||||
mediaOverlaySectionIndex: number | null;
|
||||
};
|
||||
|
||||
type AudioProgressResolution = {
|
||||
/** An EPUB position placed on the read-along file's narration timeline. */
|
||||
type EbookOverlayPosition = {
|
||||
ebookFile: SyncFile;
|
||||
overlaySourceFile: SyncFile;
|
||||
playlist: EpubMediaOverlayPlaylist;
|
||||
overlayDurationSeconds: number;
|
||||
overlaySeconds: number;
|
||||
audioFiles: SyncFile[];
|
||||
audioTotalSeconds: number | null;
|
||||
siblingEpubFiles: SyncFile[];
|
||||
};
|
||||
|
||||
type AudioPosition = {
|
||||
currentFileId: number;
|
||||
positionSeconds: number;
|
||||
percentage: number;
|
||||
siblingTargets: EpubProgressResolution[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -129,22 +140,29 @@ export class AudiobookEbookProgressSyncService {
|
||||
try {
|
||||
if ((await this.bookRepo.findReadAloudSyncMode(params.userId, params.bookId)) === 'disabled') return false;
|
||||
|
||||
const resolution = await this.resolveAudioProgress(params);
|
||||
if (!resolution) return false;
|
||||
const syncSiblingEpubs = params.syncSiblingEpubs !== false;
|
||||
const position = await this.resolveEbookOverlayPosition(params, syncSiblingEpubs);
|
||||
if (!position) return false;
|
||||
|
||||
const sourceUpdatedAt = params.sourceUpdatedAt ?? new Date();
|
||||
const saved = await this.bookRepo.upsertAudioProgress(
|
||||
params.userId,
|
||||
params.bookId,
|
||||
resolution.currentFileId,
|
||||
resolution.positionSeconds,
|
||||
resolution.percentage,
|
||||
sourceUpdatedAt,
|
||||
);
|
||||
if (!saved) return false;
|
||||
const audio = this.mapOverlayPositionToAudio(position);
|
||||
if (audio) {
|
||||
const saved = await this.bookRepo.upsertAudioProgress(
|
||||
params.userId,
|
||||
params.bookId,
|
||||
audio.currentFileId,
|
||||
audio.positionSeconds,
|
||||
audio.percentage,
|
||||
sourceUpdatedAt,
|
||||
);
|
||||
if (!saved) return false;
|
||||
}
|
||||
if (!syncSiblingEpubs) return audio !== null;
|
||||
|
||||
for (const target of params.syncSiblingEpubs === false ? [] : resolution.siblingTargets) {
|
||||
await this.bookRepo.upsertSyncedEpubProgressIfNewer({
|
||||
let siblingSynced = false;
|
||||
const textPercentage = audio ? null : this.clampPercentage(params.percentage);
|
||||
for (const target of await this.resolveSiblingEpubTargets(position, textPercentage)) {
|
||||
const accepted = await this.bookRepo.upsertSyncedEpubProgressIfNewer({
|
||||
userId: params.userId,
|
||||
fileId: target.targetFile.id,
|
||||
cfi: target.cfi,
|
||||
@@ -155,8 +173,9 @@ export class AudiobookEbookProgressSyncService {
|
||||
koreaderProgress: target.koreaderProgress,
|
||||
sourceUpdatedAt,
|
||||
});
|
||||
siblingSynced ||= accepted;
|
||||
}
|
||||
return true;
|
||||
return audio !== null || siblingSynced;
|
||||
} catch (error: unknown) {
|
||||
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
|
||||
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
|
||||
@@ -201,17 +220,11 @@ export class AudiobookEbookProgressSyncService {
|
||||
const targetFile = this.selectTargetFile(syncFiles.files, syncFiles.primaryFileId, overlaySourceFile);
|
||||
const epubFiles = syncFiles.files.filter((file) => file.format?.toLowerCase() === 'epub');
|
||||
const orderedTargets = [targetFile, ...epubFiles.filter((file) => file.id !== targetFile.id)];
|
||||
const percentage = this.clampPercentage((overlaySeconds / playlist.durationSeconds) * 100);
|
||||
const targets: EpubProgressResolution[] = [];
|
||||
for (const candidate of orderedTargets) {
|
||||
const resolved = await this.resolveFragmentPositions(candidate, item, overlaySourceFile.id);
|
||||
if (!resolved) continue;
|
||||
targets.push({
|
||||
...resolved,
|
||||
percentage: this.clampPercentage((overlaySeconds / playlist.durationSeconds) * 100),
|
||||
positionSeconds: overlaySeconds,
|
||||
mediaOverlayFragment: this.itemFragment(item),
|
||||
mediaOverlaySectionIndex: item.sectionIndex,
|
||||
});
|
||||
if (resolved) targets.push(this.toEpubTarget(resolved, item, overlaySourceFile, overlaySeconds, percentage));
|
||||
}
|
||||
if (targets.length === 0) return null;
|
||||
|
||||
@@ -220,15 +233,23 @@ export class AudiobookEbookProgressSyncService {
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveAudioProgress(params: {
|
||||
bookId: number;
|
||||
bookFileId: number;
|
||||
cfi?: string | null;
|
||||
koreaderProgress?: string | null;
|
||||
positionSeconds?: number | null;
|
||||
mediaOverlayFragment?: string | null;
|
||||
mediaOverlaySectionIndex?: number | null;
|
||||
}): Promise<AudioProgressResolution | null> {
|
||||
/**
|
||||
* Places an EPUB position on the read-along file's narration, which is what every other copy
|
||||
* of the book is found through. Returns null before any file is parsed when there is nothing
|
||||
* to carry the position to, which is every sync of a read-along EPUB that sits on its own.
|
||||
*/
|
||||
private async resolveEbookOverlayPosition(
|
||||
params: {
|
||||
bookId: number;
|
||||
bookFileId: number;
|
||||
cfi?: string | null;
|
||||
koreaderProgress?: string | null;
|
||||
positionSeconds?: number | null;
|
||||
mediaOverlayFragment?: string | null;
|
||||
mediaOverlaySectionIndex?: number | null;
|
||||
},
|
||||
syncSiblingEpubs: boolean,
|
||||
): Promise<EbookOverlayPosition | null> {
|
||||
const syncFiles = await this.bookRepo.findAudioEbookProgressSyncFiles(params.bookId);
|
||||
if (!syncFiles) return null;
|
||||
|
||||
@@ -240,7 +261,10 @@ export class AudiobookEbookProgressSyncService {
|
||||
|
||||
const audioFiles = syncFiles.files.filter((file) => typeof file.format === 'string' && isAudioFormat(file.format));
|
||||
const audioTotalSeconds = this.computeAudioTotalSeconds(audioFiles);
|
||||
if (audioFiles.length === 0 || audioTotalSeconds === null) return null;
|
||||
const siblingEpubFiles = syncSiblingEpubs
|
||||
? syncFiles.files.filter((file) => file.id !== ebookFile.id && file.format?.toLowerCase() === 'epub')
|
||||
: [];
|
||||
if (audioTotalSeconds === null && siblingEpubFiles.length === 0) return null;
|
||||
|
||||
const playlist = await this.getPlaylist(overlaySourceFile, params.bookId);
|
||||
if (playlist.items.length === 0 || playlist.durationSeconds == null || playlist.durationSeconds <= 0) return null;
|
||||
@@ -248,34 +272,75 @@ export class AudiobookEbookProgressSyncService {
|
||||
const overlaySeconds = await this.resolveOverlaySecondsFromEbookPosition(ebookFile, overlaySourceFile.id, playlist, params);
|
||||
if (overlaySeconds === null) return null;
|
||||
|
||||
const audioSeconds = this.mapOverlaySecondsToAudioSeconds(overlaySeconds, playlist.durationSeconds, audioTotalSeconds);
|
||||
return {
|
||||
ebookFile,
|
||||
overlaySourceFile,
|
||||
playlist,
|
||||
overlayDurationSeconds: playlist.durationSeconds,
|
||||
overlaySeconds,
|
||||
audioFiles,
|
||||
audioTotalSeconds,
|
||||
siblingEpubFiles,
|
||||
};
|
||||
}
|
||||
|
||||
private mapOverlayPositionToAudio(position: EbookOverlayPosition): AudioPosition | null {
|
||||
if (position.audioTotalSeconds === null) return null;
|
||||
|
||||
const audioSeconds = this.mapOverlaySecondsToAudioSeconds(position.overlaySeconds, position.overlayDurationSeconds, position.audioTotalSeconds);
|
||||
if (audioSeconds === null) return null;
|
||||
|
||||
const filePosition = this.audioFilePositionForSeconds(audioFiles, audioSeconds);
|
||||
const filePosition = this.audioFilePositionForSeconds(position.audioFiles, audioSeconds);
|
||||
if (!filePosition) return null;
|
||||
|
||||
const item = this.findItemBySeconds(playlist, overlaySeconds);
|
||||
const siblingTargets: EpubProgressResolution[] = [];
|
||||
if (item?.textFragment) {
|
||||
for (const candidate of syncFiles.files) {
|
||||
if (candidate.id === ebookFile.id || candidate.format?.toLowerCase() !== 'epub') continue;
|
||||
const resolved = await this.resolveFragmentPositions(candidate, item, overlaySourceFile.id);
|
||||
if (!resolved) continue;
|
||||
siblingTargets.push({
|
||||
...resolved,
|
||||
percentage: this.clampPercentage((overlaySeconds / playlist.durationSeconds) * 100),
|
||||
positionSeconds: overlaySeconds,
|
||||
mediaOverlayFragment: this.itemFragment(item),
|
||||
mediaOverlaySectionIndex: item.sectionIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentFileId: filePosition.file.id,
|
||||
positionSeconds: filePosition.positionSeconds,
|
||||
percentage: this.clampPercentage((audioSeconds / audioTotalSeconds) * 100),
|
||||
siblingTargets,
|
||||
percentage: this.clampPercentage((audioSeconds / position.audioTotalSeconds) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the position lands in every other EPUB of the book: the nearest narrated sentence,
|
||||
* located in each copy whose chapter text is identical.
|
||||
*
|
||||
* Behind an audiobook the copies follow its timeline percentage. Without one, `textPercentage`
|
||||
* is the source's own figure and is used as is: narration time drifts from text wherever the
|
||||
* pace changes or a section goes unnarrated, and KOReader decides by percentage whether a
|
||||
* pulled position is ahead of its own.
|
||||
*/
|
||||
private async resolveSiblingEpubTargets(position: EbookOverlayPosition, textPercentage: number | null): Promise<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;
|
||||
},
|
||||
): 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 (
|
||||
hasNarration &&
|
||||
this.isNonNegativeFinite(params.positionSeconds) &&
|
||||
(params.mediaOverlayFragment || params.mediaOverlaySectionIndex != null) &&
|
||||
playlist.durationSeconds != null
|
||||
@@ -347,7 +416,7 @@ export class AudiobookEbookProgressSyncService {
|
||||
return Math.max(0, Math.min(playlist.durationSeconds, params.positionSeconds));
|
||||
}
|
||||
|
||||
if (params.mediaOverlayFragment) {
|
||||
if (hasNarration && params.mediaOverlayFragment) {
|
||||
const match = this.findItemStartByFragment(playlist, params.mediaOverlayFragment, params.mediaOverlaySectionIndex ?? null);
|
||||
if (match) return match.startSeconds;
|
||||
}
|
||||
|
||||
@@ -751,6 +751,35 @@ describe('BookRepository', () => {
|
||||
expect(resetWhere).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clears a stale narration marker when bridging to a copy without media overlays', async () => {
|
||||
const returning = vi.fn().mockResolvedValue([{ fileId: 20 }]);
|
||||
const onConflictDoUpdate = vi.fn().mockReturnValue({ returning });
|
||||
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
|
||||
const db = {
|
||||
insert: vi.fn().mockReturnValue({ values }),
|
||||
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
||||
};
|
||||
const repo = new BookRepository(db as never);
|
||||
|
||||
await expect(
|
||||
repo.upsertSyncedEpubProgressIfNewer({
|
||||
userId: 7,
|
||||
fileId: 20,
|
||||
cfi: 'epubcfi(/6/4)',
|
||||
percentage: 42,
|
||||
positionSeconds: null,
|
||||
mediaOverlayFragment: null,
|
||||
mediaOverlaySectionIndex: null,
|
||||
koreaderProgress: '/body/DocFragment[2]/body/p[2]/text().0',
|
||||
sourceUpdatedAt: new Date('2026-09-19T12:00:00.000Z'),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const cleared = { positionSeconds: null, mediaOverlayFragment: null, mediaOverlaySectionIndex: null };
|
||||
expect(values).toHaveBeenCalledWith(expect.objectContaining(cleared));
|
||||
expect(onConflictDoUpdate).toHaveBeenCalledWith(expect.objectContaining({ set: expect.objectContaining(cleared) }));
|
||||
});
|
||||
|
||||
it('does not clear reset protection when a newer EPUB row rejects the bridge', async () => {
|
||||
const returning = vi.fn().mockResolvedValue([]);
|
||||
const db = {
|
||||
|
||||
@@ -2368,9 +2368,9 @@ export class BookRepository {
|
||||
fileId: number;
|
||||
cfi: string;
|
||||
percentage: number;
|
||||
positionSeconds: number;
|
||||
mediaOverlayFragment: string;
|
||||
mediaOverlaySectionIndex: number;
|
||||
positionSeconds: number | null;
|
||||
mediaOverlayFragment: string | null;
|
||||
mediaOverlaySectionIndex: number | null;
|
||||
koreaderProgress: string | null;
|
||||
sourceUpdatedAt: Date;
|
||||
}): Promise<boolean> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, asc, desc, eq, inArray, notExists, sql } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, inArray, notExists, or, sql } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { chunk } from '../../common/utils/batch.utils';
|
||||
@@ -761,13 +761,22 @@ export class KoreaderRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* The file a book's KOReader progress hangs off, in the shape the shared-progress path needs.
|
||||
* Resolved through the book's primary file, matching findBookFileIdByBookId: the book page
|
||||
* reports one file's holds, so the release action has to act on that same file rather than
|
||||
* whichever one a marker happens to be found on first.
|
||||
* The file a book's KOReader progress hangs off, in the shape the shared-progress path needs:
|
||||
* the copy this user's devices synced most recently, or the primary file when none has. A
|
||||
* device syncs whichever copy it was given, and a Storyteller read-along EPUB becomes the
|
||||
* primary while devices usually hold the original beside it. The book page reports one file's
|
||||
* devices and holds, and the release action resolves through this same query, so it acts on
|
||||
* the file the page showed rather than whichever one a marker happens to be found on first.
|
||||
*/
|
||||
async findProgressBookFileByBookId(bookId: number, accessibleLibraryIds: number[] | null) {
|
||||
async findProgressBookFileByBookId(bookId: number, userId: number, accessibleLibraryIds: number[] | null) {
|
||||
if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return null;
|
||||
const lastDeviceSyncAt = sql`(
|
||||
select max(${schema.koreaderDeviceProgress.updatedAt})
|
||||
from ${schema.koreaderDeviceProgress}
|
||||
where ${schema.koreaderDeviceProgress.bookFileId} = ${schema.bookFiles.id}
|
||||
and ${schema.koreaderDeviceProgress.userId} = ${userId}
|
||||
and ${schema.koreaderDeviceProgress.orphaned} = false
|
||||
)`;
|
||||
const [row] = await this.db
|
||||
.select({
|
||||
id: schema.bookFiles.id,
|
||||
@@ -780,13 +789,14 @@ export class KoreaderRepository {
|
||||
.where(
|
||||
and(
|
||||
eq(schema.books.id, bookId),
|
||||
eq(schema.books.primaryFileId, schema.bookFiles.id),
|
||||
or(eq(schema.books.primaryFileId, schema.bookFiles.id), sql`${lastDeviceSyncAt} is not null`),
|
||||
// Scoped the same way every other entry point into this module is. A device progress
|
||||
// row outlives the library grant that created it, so ownership of the row is not
|
||||
// ownership of the book, and this path writes.
|
||||
accessibleLibraryIds === null ? undefined : inArray(schema.books.libraryId, accessibleLibraryIds),
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${lastDeviceSyncAt} desc nulls last`, asc(schema.bookFiles.id))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ describe('KoreaderService', () => {
|
||||
setKoreaderUserDefaultPattern: ReturnType<typeof vi.fn>;
|
||||
setDeviceFileNamingPattern: ReturnType<typeof vi.fn>;
|
||||
clearDeviceFileNamingPattern: ReturnType<typeof vi.fn>;
|
||||
findBookFileIdByBookId: ReturnType<typeof vi.fn>;
|
||||
getBookProgressForDashboard: ReturnType<typeof vi.fn>;
|
||||
getChapters: ReturnType<typeof vi.fn>;
|
||||
getLastFileWriteTime: ReturnType<typeof vi.fn>;
|
||||
@@ -161,7 +160,6 @@ describe('KoreaderService', () => {
|
||||
setKoreaderUserDefaultPattern: vi.fn().mockResolvedValue(undefined),
|
||||
setDeviceFileNamingPattern: vi.fn().mockResolvedValue(undefined),
|
||||
clearDeviceFileNamingPattern: vi.fn().mockResolvedValue(undefined),
|
||||
findBookFileIdByBookId: vi.fn(),
|
||||
getBookProgressForDashboard: vi.fn(),
|
||||
getChapters: vi.fn(),
|
||||
getLastFileWriteTime: vi.fn(),
|
||||
@@ -908,7 +906,7 @@ describe('KoreaderService', () => {
|
||||
});
|
||||
|
||||
it('does not report a held device as KOReader-latest', async () => {
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(10);
|
||||
mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
|
||||
mockRepo.getProgressReset.mockResolvedValue(resetAt);
|
||||
mockRepo.getBookProgressForDashboard.mockResolvedValue({
|
||||
deviceProgress: [
|
||||
@@ -928,7 +926,7 @@ describe('KoreaderService', () => {
|
||||
});
|
||||
|
||||
it('counts a converged device as canonical again even while the marker is still live', async () => {
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(10);
|
||||
mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1, format: 'epub' });
|
||||
mockRepo.getProgressReset.mockResolvedValue(resetAt);
|
||||
mockRepo.getConvergedResetDeviceIds.mockResolvedValue(new Set(['device-1']));
|
||||
mockRepo.getBookProgressForDashboard.mockResolvedValue({
|
||||
@@ -982,7 +980,7 @@ describe('KoreaderService', () => {
|
||||
// A device progress row outlives the library grant that created it, so holding a row is
|
||||
// not proof of access to the book it points at.
|
||||
await expect(service.releaseResetHold(7, 20, 'device-1')).rejects.toThrow(NotFoundException);
|
||||
expect(mockRepo.findProgressBookFileByBookId).toHaveBeenCalledWith(20, [3]);
|
||||
expect(mockRepo.findProgressBookFileByBookId).toHaveBeenCalledWith(20, 7, [3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1602,7 +1600,7 @@ describe('KoreaderService', () => {
|
||||
describe('getBookProgress', () => {
|
||||
it('returns full sync info with chapters when KOReader is the canonical source', async () => {
|
||||
const latestDeviceTime = new Date('2026-03-01T10:00:00.000Z');
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(31);
|
||||
mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 31, bookId: 20, libraryId: 1, format: 'epub' });
|
||||
mockRepo.getBookProgressForDashboard.mockResolvedValue({
|
||||
deviceProgress: [
|
||||
{
|
||||
@@ -1662,14 +1660,34 @@ describe('KoreaderService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when there is no primary book file', async () => {
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(null);
|
||||
it('returns null when the book has no file to report on', async () => {
|
||||
mockRepo.findProgressBookFileByBookId.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getBookProgress(7, 99)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('reports the file the user devices sync, resolved within libraries the user can reach', async () => {
|
||||
mockRepo.getAccessibleLibraryIds.mockResolvedValue([3]);
|
||||
mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 44, bookId: 99, libraryId: 3, format: 'epub' });
|
||||
mockRepo.getBookProgressForDashboard.mockResolvedValue({
|
||||
deviceProgress: [
|
||||
{ device: 'Kindle', deviceId: 'device-1', percentage: 0.69, chapterIndex: 6, updatedAt: new Date('2026-03-01T10:00:00.000Z') },
|
||||
],
|
||||
readingProgress: null,
|
||||
});
|
||||
mockRepo.getChapters.mockResolvedValue([]);
|
||||
mockRepo.getLastFileWriteTime.mockResolvedValue(null);
|
||||
|
||||
const info = await service.getBookProgress(7, 99);
|
||||
|
||||
// The same resolver the release action uses, so a hold shown here is one it can release.
|
||||
expect(mockRepo.findProgressBookFileByBookId).toHaveBeenCalledWith(99, 7, [3]);
|
||||
expect(mockRepo.getBookProgressForDashboard).toHaveBeenCalledWith(44, 7);
|
||||
expect(info).toEqual(expect.objectContaining({ bookFileId: 44, canonicalSource: 'koreader', canonicalPercentage: 69 }));
|
||||
});
|
||||
|
||||
it('returns null when no progress data exists for the book', async () => {
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(31);
|
||||
mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 31, bookId: 20, libraryId: 1, format: 'epub' });
|
||||
mockRepo.getBookProgressForDashboard.mockResolvedValue({
|
||||
deviceProgress: [],
|
||||
readingProgress: null,
|
||||
@@ -1679,7 +1697,7 @@ describe('KoreaderService', () => {
|
||||
});
|
||||
|
||||
it('uses web reader as the canonical source when its progress is newer', async () => {
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(31);
|
||||
mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 31, bookId: 20, libraryId: 1, format: 'epub' });
|
||||
mockRepo.getBookProgressForDashboard.mockResolvedValue({
|
||||
deviceProgress: [
|
||||
{
|
||||
@@ -1722,7 +1740,7 @@ describe('KoreaderService', () => {
|
||||
});
|
||||
|
||||
it('marks the file stale when any device synced before the last file write', async () => {
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(31);
|
||||
mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 31, bookId: 20, libraryId: 1, format: 'epub' });
|
||||
mockRepo.getBookProgressForDashboard.mockResolvedValue({
|
||||
deviceProgress: [
|
||||
{
|
||||
@@ -1758,7 +1776,7 @@ describe('KoreaderService', () => {
|
||||
});
|
||||
|
||||
it('keeps the file fresh when every device synced after the last file write', async () => {
|
||||
mockRepo.findBookFileIdByBookId.mockResolvedValue(31);
|
||||
mockRepo.findProgressBookFileByBookId.mockResolvedValue({ id: 31, bookId: 20, libraryId: 1, format: 'epub' });
|
||||
mockRepo.getBookProgressForDashboard.mockResolvedValue({
|
||||
deviceProgress: [
|
||||
{
|
||||
|
||||
@@ -726,8 +726,10 @@ export class KoreaderService {
|
||||
}
|
||||
|
||||
async getBookProgress(userId: number, bookId: number): Promise<KoreaderBookSyncInfo | null> {
|
||||
const bookFileId = await this.repo.findBookFileIdByBookId(bookId);
|
||||
if (!bookFileId) return null;
|
||||
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(userId);
|
||||
const bookFile = await this.repo.findProgressBookFileByBookId(bookId, userId, accessibleLibraryIds);
|
||||
if (!bookFile) return null;
|
||||
const bookFileId = bookFile.id;
|
||||
|
||||
const { deviceProgress, readingProgress } = await this.repo.getBookProgressForDashboard(bookFileId, userId);
|
||||
if (deviceProgress.length === 0 && !readingProgress) return null;
|
||||
@@ -792,7 +794,7 @@ export class KoreaderService {
|
||||
async releaseResetHold(userId: number, bookId: number, deviceId: string): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const accessibleLibraryIds = await this.repo.getAccessibleLibraryIds(userId);
|
||||
const bookFile = await this.repo.findProgressBookFileByBookId(bookId, accessibleLibraryIds);
|
||||
const bookFile = await this.repo.findProgressBookFileByBookId(bookId, userId, accessibleLibraryIds);
|
||||
if (!bookFile) throw new NotFoundException(`No synced file found for book ${bookId}`);
|
||||
|
||||
const held = await this.repo.getDeviceProgressForDevice(bookFile.id, userId, deviceId);
|
||||
|
||||
@@ -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