fix(reader): skip zero-length overlay clips

Keep zero-width SMIL entries from capturing every later timeline position.

Fixes #1492
This commit is contained in:
neon
2026-09-22 15:34:39 +00:00
parent 09ea8bdd5f
commit c25a756ed7
3 changed files with 112 additions and 2 deletions
@@ -87,6 +87,31 @@ function makePlaylist() {
};
}
function makePlaylistWithZeroLengthClip() {
const playlist = makePlaylist();
return {
...playlist,
items: [
{ ...playlist.items[0]!, textFragment: 'before', clipEndSeconds: 40, durationSeconds: 40 },
{
...playlist.items[1]!,
textFragment: 'zero',
clipBeginSeconds: 40,
clipEndSeconds: 40,
durationSeconds: 0,
},
{
...playlist.items[1]!,
index: 2,
textFragment: 'after',
clipBeginSeconds: 40,
clipEndSeconds: 100,
durationSeconds: 60,
},
],
};
}
function makeFixture(audioDuration = 100) {
const bookRepo = {
findReadAloudSyncMode: vi.fn().mockResolvedValue('auto'),
@@ -145,6 +170,46 @@ describe('AudiobookEbookProgressSyncService', () => {
);
});
it.each([40, 75, 100])('skips a zero-length media-overlay clip when mapping %s seconds', async (positionSeconds) => {
const { service, bookRepo, positionConverter } = makeFixture();
mockBuildPlaylist.mockResolvedValueOnce(makePlaylistWithZeroLengthClip());
await expect(
service.syncFromAudioProgress({
userId: 7,
bookId: 5,
currentFileId: 10,
positionSeconds,
percentage: positionSeconds,
syncKobo: false,
sourceUpdatedAt: SOURCE_TIME,
}),
).resolves.toBe(true);
expect(positionConverter.fragmentToPositions).toHaveBeenCalledTimes(2);
expect(positionConverter.fragmentToPositions).toHaveBeenNthCalledWith(1, expect.objectContaining({ fragment: 'after' }));
expect(positionConverter.fragmentToPositions).toHaveBeenNthCalledWith(2, expect.objectContaining({ fragment: 'after' }));
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledTimes(2);
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
fileId: 20,
percentage: positionSeconds,
positionSeconds,
mediaOverlayFragment: 'OPS/chapter.xhtml#after',
}),
);
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
fileId: 30,
percentage: positionSeconds,
positionSeconds,
mediaOverlayFragment: 'OPS/chapter.xhtml#after',
}),
);
});
it('does not replace or forward a newer EPUB position', async () => {
const { service, bookRepo } = makeFixture();
bookRepo.upsertSyncedEpubProgressIfNewer.mockResolvedValue(false);
@@ -185,6 +250,32 @@ describe('AudiobookEbookProgressSyncService', () => {
expect(positionConverter.nearestFragmentForPosition).toHaveBeenCalledWith(expect.objectContaining({ bookFileId: 20, sourceBookFileId: 30 }));
});
it('skips a zero-length clip when propagating an EPUB narration position to a sibling', async () => {
const { service, bookRepo, positionConverter } = makeFixture();
mockBuildPlaylist.mockResolvedValueOnce(makePlaylistWithZeroLengthClip());
await expect(
service.syncFromEbookProgress({
userId: 7,
bookId: 5,
bookFileId: 20,
percentage: 75,
positionSeconds: 75,
mediaOverlayFragment: 'OPS/chapter.xhtml#after',
mediaOverlaySectionIndex: 0,
sourceUpdatedAt: SOURCE_TIME,
}),
).resolves.toBe(true);
expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(7, 5, 10, 75, 75, SOURCE_TIME);
expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith(
expect.objectContaining({ bookFileId: 30, sourceBookFileId: 30, fragment: 'after' }),
);
expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith(
expect.objectContaining({ fileId: 30, positionSeconds: 75, mediaOverlayFragment: 'OPS/chapter.xhtml#after' }),
);
});
it('stops sibling propagation when a newer audiobook state rejects the source write', async () => {
const { service, bookRepo } = makeFixture();
bookRepo.upsertAudioProgress.mockResolvedValue(undefined);
@@ -379,6 +379,7 @@ export class AudiobookEbookProgressSyncService {
for (const item of playlist.items) {
const duration = item.durationSeconds;
const start = elapsed;
if (typeof duration === 'number' && Number.isFinite(duration) && duration <= 0) continue;
const end = this.isPositiveFinite(duration) ? start + duration : null;
if (positionSeconds <= start) return item;
@@ -158,13 +158,13 @@ const SMIL_XML = `
</smil>
`;
function makeMediaOverlayArchive() {
function makeMediaOverlayArchive(smil = SMIL_XML) {
return makeArchive([
{ path: 'META-INF/container.xml', content: CONTAINER_XML },
{ path: 'OPS/content.opf', content: OPF_MEDIA_OVERLAY_XML },
{ path: 'OPS/nav.xhtml', content: NAV_XHTML },
{ path: 'OPS/text/ch1.xhtml', content: '<h1>ch1</h1>' },
{ path: 'OPS/smil/ch1.smil', content: SMIL_XML },
{ path: 'OPS/smil/ch1.smil', content: smil },
{ path: 'OPS/audio/ch1.mp3', content: Buffer.from('0123456789') },
]);
}
@@ -331,6 +331,24 @@ describe('EpubService', () => {
]);
});
it('preserves a zero-length SMIL clip as a known zero-width playlist item', async () => {
const smil = SMIL_XML.replace('clipBegin="3s" clipEnd="4500ms"', 'clipBegin="3s" clipEnd="3s"');
mockOpenFile.mockResolvedValueOnce(makeMediaOverlayArchive(smil) as any).mockResolvedValueOnce(makeMediaOverlayArchive(smil) as any);
const playlist = await service.getMediaOverlayPlaylist(99, undefined, user);
expect(playlist.durationSeconds).toBe(1.5);
expect(playlist.sections).toEqual([expect.objectContaining({ durationSeconds: 1.5 })]);
expect(playlist.items[1]).toEqual(
expect.objectContaining({
textFragment: 's2',
clipBeginSeconds: 3,
clipEndSeconds: 3,
durationSeconds: 0,
}),
);
});
it('streams only playlist audio resources and supports byte ranges', async () => {
mockOpenFile
.mockResolvedValueOnce(makeMediaOverlayArchive() as any)