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