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