mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -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);
|
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 () => {
|
it('resolves asset content through the access-checked file path', async () => {
|
||||||
const { service, repo, bookService } = makeFixture();
|
const { service, repo, bookService } = makeFixture();
|
||||||
repo.findAsset.mockResolvedValue({ id: 11, format: 'mp3' });
|
repo.findAsset.mockResolvedValue({ id: 11, format: 'mp3' });
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { AudiobookRepository } from './audiobook.repository';
|
|||||||
|
|
||||||
type AudioFileRow = Awaited<ReturnType<AudiobookRepository['findAudioFiles']>>[number];
|
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 {
|
interface ManifestContext {
|
||||||
manifest: AudiobookManifest;
|
manifest: AudiobookManifest;
|
||||||
files: AudioFileRow[];
|
files: AudioFileRow[];
|
||||||
@@ -94,18 +98,19 @@ export class AudiobookService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const asset = context.manifest.assets[fileIndex]!;
|
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');
|
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 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 =
|
const percentage =
|
||||||
context.hasCompleteDurations && context.manifest.totalDurationMs > 0
|
context.hasCompleteDurations && context.manifest.totalDurationMs > 0
|
||||||
? Math.max(0, Math.min(100, (absolutePositionMs / context.manifest.totalDurationMs) * 100))
|
? Math.max(0, Math.min(100, (absolutePositionMs / context.manifest.totalDurationMs) * 100))
|
||||||
: (previous?.percentage ?? 0);
|
: (previous?.percentage ?? 0);
|
||||||
const values = {
|
const values = {
|
||||||
currentFileId: context.files[fileIndex]!.id,
|
currentFileId: context.files[fileIndex]!.id,
|
||||||
positionSeconds: dto.positionMs / 1000,
|
positionSeconds: positionMs / 1000,
|
||||||
percentage,
|
percentage,
|
||||||
capturedAt: new Date(dto.capturedAt),
|
capturedAt: new Date(dto.capturedAt),
|
||||||
operationId: dto.operationId,
|
operationId: dto.operationId,
|
||||||
@@ -134,7 +139,7 @@ export class AudiobookService {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
assetId: dto.assetId,
|
assetId: dto.assetId,
|
||||||
positionMs: dto.positionMs,
|
positionMs,
|
||||||
percentage,
|
percentage,
|
||||||
completed: percentage >= 100,
|
completed: percentage >= 100,
|
||||||
capturedAt: saved.capturedAt.toISOString(),
|
capturedAt: saved.capturedAt.toISOString(),
|
||||||
@@ -156,7 +161,8 @@ export class AudiobookService {
|
|||||||
|
|
||||||
async createBookmark(bookId: number, dto: CreateAudiobookBookmarkDto, user: RequestUser): Promise<AudiobookBookmark> {
|
async createBookmark(bookId: number, dto: CreateAudiobookBookmarkDto, user: RequestUser): Promise<AudiobookBookmark> {
|
||||||
const context = await this.loadManifestContext(bookId, user);
|
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');
|
throw new BadRequestException('positionMs exceeds the audiobook duration');
|
||||||
}
|
}
|
||||||
if (dto.chapterId && !context.manifest.chapters.some((chapter) => chapter.id === dto.chapterId)) {
|
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, {
|
const row = await this.repo.createAudioBookmark(user.id, bookId, {
|
||||||
clientId: dto.clientId,
|
clientId: dto.clientId,
|
||||||
positionSeconds: dto.positionMs / 1000,
|
positionSeconds: (totalDurationMs > 0 ? Math.min(dto.positionMs, totalDurationMs) : dto.positionMs) / 1000,
|
||||||
chapterId: dto.chapterId ?? null,
|
chapterId: dto.chapterId ?? null,
|
||||||
title: dto.title,
|
title: dto.title,
|
||||||
note: dto.note ?? null,
|
note: dto.note ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user