fix(reader): accept audiobook positions in the last second of a track

The extractor stores durations as whole seconds, so a manifest asset can report a duration up
to half a second shorter than the audio. The playback-state PUT and the audiobook bookmark
create refused any position past that rounded value with a 400, which rejected the tail of every
such track and left clients retrying a write that could never land.

Allow one second of slack at both checks and clamp the stored position to the duration. This
only relaxes validation: every position accepted before is still accepted and stored unchanged.
This commit is contained in:
neon
2026-09-28 22:27:45 +00:00
parent fd480b7f2b
commit 35d5a8c2d6
2 changed files with 54 additions and 6 deletions
@@ -274,6 +274,48 @@ describe('AudiobookService', () => {
await expect(service.putPlaybackState(30, base, makeUser())).rejects.toThrow(ConflictException);
});
it('accepts a position in the rounded-off tail of a track and stores it clamped to the duration', async () => {
const { service, repo } = makeFixture();
const manifest = await service.getManifest(30, makeUser());
repo.createPlaybackState.mockResolvedValue({ capturedAt: new Date('2026-02-01T00:00:00.000Z'), revision: 1 });
const dto = {
assetId: manifest.assets[1]!.assetId,
positionMs: 20_300,
capturedAt: '2026-02-01T00:00:00.000Z',
operationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
baseRevision: 0,
manifestRevision: manifest.revision,
};
const saved = await service.putPlaybackState(30, dto, makeUser());
expect(manifest.assets[1]!.durationMs).toBe(20_000);
expect(saved).toMatchObject({ positionMs: 20_000, percentage: 100 });
expect(repo.createPlaybackState).toHaveBeenCalledWith(7, 30, expect.objectContaining({ currentFileId: 12, positionSeconds: 20 }));
await expect(service.putPlaybackState(30, { ...dto, positionMs: 21_001 }, makeUser())).rejects.toThrow(BadRequestException);
});
it('accepts a bookmark just past the rounded audiobook duration and stores it clamped', async () => {
const { service, repo } = makeFixture();
repo.createAudioBookmark.mockResolvedValue({
clientId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
bookId: 30,
positionSeconds: 30.125,
chapterId: null,
title: 'End',
note: null,
createdAt: new Date('2026-02-01T00:00:00.000Z'),
updatedAt: new Date('2026-02-01T00:00:00.000Z'),
});
const dto = { clientId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', positionMs: 30_600, title: 'End' };
const bookmark = await service.createBookmark(30, dto, makeUser());
expect(bookmark.positionMs).toBe(30_125);
expect(repo.createAudioBookmark).toHaveBeenCalledWith(7, 30, expect.objectContaining({ positionSeconds: 30.125 }));
await expect(service.createBookmark(30, { ...dto, positionMs: 31_126 }, makeUser())).rejects.toThrow(BadRequestException);
});
it('resolves asset content through the access-checked file path', async () => {
const { service, repo, bookService } = makeFixture();
repo.findAsset.mockResolvedValue({ id: 11, format: 'mp3' });
@@ -24,6 +24,10 @@ import { AudiobookRepository } from './audiobook.repository';
type AudioFileRow = Awaited<ReturnType<AudiobookRepository['findAudioFiles']>>[number];
// Durations are stored as whole seconds, so a real position can sit up to half a second past the
// rounded duration. Accept a little beyond it and clamp, rather than refusing the tail of a track.
const DURATION_SLACK_MS = 1_000;
interface ManifestContext {
manifest: AudiobookManifest;
files: AudioFileRow[];
@@ -94,18 +98,19 @@ export class AudiobookService {
}
const asset = context.manifest.assets[fileIndex]!;
if (asset.durationMs !== null && dto.positionMs > asset.durationMs) {
if (asset.durationMs !== null && dto.positionMs > asset.durationMs + DURATION_SLACK_MS) {
throw new BadRequestException('positionMs exceeds the asset duration');
}
const positionMs = asset.durationMs === null ? dto.positionMs : Math.min(dto.positionMs, asset.durationMs);
const elapsedBefore = context.manifest.assets.slice(0, fileIndex).reduce((sum, item) => sum + (item.durationMs ?? 0), 0);
const absolutePositionMs = elapsedBefore + dto.positionMs;
const absolutePositionMs = elapsedBefore + positionMs;
const percentage =
context.hasCompleteDurations && context.manifest.totalDurationMs > 0
? Math.max(0, Math.min(100, (absolutePositionMs / context.manifest.totalDurationMs) * 100))
: (previous?.percentage ?? 0);
const values = {
currentFileId: context.files[fileIndex]!.id,
positionSeconds: dto.positionMs / 1000,
positionSeconds: positionMs / 1000,
percentage,
capturedAt: new Date(dto.capturedAt),
operationId: dto.operationId,
@@ -134,7 +139,7 @@ export class AudiobookService {
);
return {
assetId: dto.assetId,
positionMs: dto.positionMs,
positionMs,
percentage,
completed: percentage >= 100,
capturedAt: saved.capturedAt.toISOString(),
@@ -156,7 +161,8 @@ export class AudiobookService {
async createBookmark(bookId: number, dto: CreateAudiobookBookmarkDto, user: RequestUser): Promise<AudiobookBookmark> {
const context = await this.loadManifestContext(bookId, user);
if (context.manifest.totalDurationMs > 0 && dto.positionMs > context.manifest.totalDurationMs) {
const totalDurationMs = context.manifest.totalDurationMs;
if (totalDurationMs > 0 && dto.positionMs > totalDurationMs + DURATION_SLACK_MS) {
throw new BadRequestException('positionMs exceeds the audiobook duration');
}
if (dto.chapterId && !context.manifest.chapters.some((chapter) => chapter.id === dto.chapterId)) {
@@ -164,7 +170,7 @@ export class AudiobookService {
}
const row = await this.repo.createAudioBookmark(user.id, bookId, {
clientId: dto.clientId,
positionSeconds: dto.positionMs / 1000,
positionSeconds: (totalDurationMs > 0 ? Math.min(dto.positionMs, totalDurationMs) : dto.positionMs) / 1000,
chapterId: dto.chapterId ?? null,
title: dto.title,
note: dto.note ?? null,