feat(reader): add dedicated audiobook playback API

This commit is contained in:
neon
2026-09-11 05:33:12 +00:00
parent 7ecc52ee30
commit ceda7428b0
41 changed files with 19933 additions and 523 deletions
+2
View File
@@ -87,6 +87,7 @@ import { SharedReadingInsightsModule } from './modules/shared-reading-insights/s
import { BookDuplicatesModule } from './modules/book-duplicates/book-duplicates.module';
import { MaintenanceModule } from './modules/maintenance/maintenance.module';
import { BookMoveModule } from './modules/book-move/book-move.module';
import { AudiobookModule } from './modules/audiobook/audiobook.module';
@Module({
imports: [
@@ -119,6 +120,7 @@ import { BookMoveModule } from './modules/book-move/book-move.module';
LibraryModule,
PathModule,
BookModule,
AudiobookModule,
CatalogModule,
CoverModule,
CollectionModule,
@@ -0,0 +1,11 @@
ALTER TABLE "book_files" ADD COLUMN "public_id" uuid DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint
ALTER TABLE "audiobook_progress" ADD COLUMN "revision" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "audiobook_progress" ADD COLUMN "captured_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "audiobook_progress" ADD COLUMN "operation_id" uuid;--> statement-breakpoint
ALTER TABLE "audiobook_progress" ADD COLUMN "manifest_revision" varchar(64);--> statement-breakpoint
ALTER TABLE "bookmarks" ADD COLUMN "client_id" uuid DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint
ALTER TABLE "bookmarks" ADD COLUMN "note" text;--> statement-breakpoint
ALTER TABLE "bookmarks" ADD COLUMN "chapter_id" varchar(80);--> statement-breakpoint
CREATE UNIQUE INDEX "book_files_public_id_uidx" ON "book_files" USING btree ("public_id");--> statement-breakpoint
CREATE UNIQUE INDEX "bookmarks_user_book_client_id_uidx" ON "bookmarks" USING btree ("user_id","book_id","client_id");--> statement-breakpoint
ALTER TABLE "audiobook_progress" ADD CONSTRAINT "audiobook_progress_revision_positive_chk" CHECK ("audiobook_progress"."revision" >= 1);
File diff suppressed because it is too large Load Diff
@@ -617,6 +617,13 @@
"when": 1789055692244,
"tag": "0087_add_library_added_at_source",
"breakpoints": true
},
{
"idx": 88,
"version": "7",
"when": 1789098337223,
"tag": "0088_audiobook_contract_v2",
"breakpoints": true
}
]
}
+3
View File
@@ -12,6 +12,7 @@ import {
timestamp,
unique,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
@@ -68,6 +69,7 @@ export const bookFiles = pgTable(
'book_files',
{
id: serial('id').primaryKey(),
publicId: uuid('public_id').notNull().defaultRandom(),
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
@@ -93,6 +95,7 @@ export const bookFiles = pgTable(
.$onUpdateFn(() => new Date()),
},
(t) => [
uniqueIndex('book_files_public_id_uidx').on(t.publicId),
uniqueIndex('book_files_absolute_path_uidx').on(t.absolutePath),
index('book_files_book_id_idx').on(t.bookId),
index('book_files_library_folder_id_idx').on(t.libraryFolderId),
+25 -1
View File
@@ -1,5 +1,20 @@
import { sql } from 'drizzle-orm';
import { check, date, index, integer, jsonb, pgTable, primaryKey, real, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import {
check,
date,
index,
integer,
jsonb,
pgTable,
primaryKey,
real,
serial,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import type { ReadStatus, ReadStatusSource, ReadingAttemptOrigin, ReadingAttemptOutcome, ReadingSessionSource } from '@bookorbit/types';
import { bookFiles, books } from './books';
@@ -316,6 +331,10 @@ export const audiobookProgress = pgTable(
.notNull()
.references(() => bookFiles.id, { onDelete: 'cascade' }),
positionSeconds: real('position_seconds').notNull().default(0),
revision: integer('revision').notNull().default(1),
capturedAt: timestamp('captured_at', { withTimezone: true }).notNull().defaultNow(),
operationId: uuid('operation_id'),
manifestRevision: varchar('manifest_revision', { length: 64 }),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
@@ -328,6 +347,7 @@ export const audiobookProgress = pgTable(
index('audiobook_progress_current_file_id_idx').on(t.currentFileId),
check('audiobook_progress_percentage_range_chk', sql`${t.percentage} >= 0 and ${t.percentage} <= 100`),
check('audiobook_progress_position_seconds_nonnegative_chk', sql`${t.positionSeconds} >= 0`),
check('audiobook_progress_revision_positive_chk', sql`${t.revision} >= 1`),
],
);
@@ -340,6 +360,7 @@ export const bookmarks = pgTable(
'bookmarks',
{
id: serial('id').primaryKey(),
clientId: uuid('client_id').notNull().defaultRandom(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
@@ -349,6 +370,8 @@ export const bookmarks = pgTable(
// EPUB: CFI string pinpoints exact location. Null for audio bookmarks.
cfi: varchar('cfi', { length: 2000 }),
title: varchar('title', { length: 500 }).notNull(),
note: text('note'),
chapterId: varchar('chapter_id', { length: 80 }),
// Audio: absolute book position in seconds (sum of preceding file durations + offset).
positionSeconds: real('position_seconds'),
origin: varchar('origin', { length: 10 }).$type<BookmarkOrigin>().notNull().default('web'),
@@ -366,6 +389,7 @@ export const bookmarks = pgTable(
deletedAt: timestamp('deleted_at', { withTimezone: true }),
},
(t) => [
uniqueIndex('bookmarks_user_book_client_id_uidx').on(t.userId, t.bookId, t.clientId),
index('bookmarks_user_book_idx').on(t.userId, t.bookId),
index('bookmarks_book_id_idx').on(t.bookId),
index('bookmarks_deleted_at_idx')
@@ -0,0 +1,46 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateAudiobookBookmarkDto } from './dto/create-audiobook-bookmark.dto';
import { PutAudiobookPlaybackStateDto } from './dto/put-audiobook-playback-state.dto';
import { UpdateAudiobookBookmarkDto } from './dto/update-audiobook-bookmark.dto';
async function errorsFor<T extends object>(type: new () => T, value: Record<string, unknown>) {
return validate(plainToInstance(type, value), { whitelist: true, forbidNonWhitelisted: true });
}
describe('Audiobook DTO validation', () => {
it('accepts only the revision-controlled playback-state shape', async () => {
const valid = {
assetId: 'aud_11111111-1111-4111-8111-111111111111',
positionMs: 12_345,
capturedAt: '2026-02-01T00:00:00.000Z',
operationId: '22222222-2222-4222-8222-222222222222',
baseRevision: 3,
manifestRevision: 'a'.repeat(64),
};
expect(await errorsFor(PutAudiobookPlaybackStateDto, valid)).toHaveLength(0);
expect(await errorsFor(PutAudiobookPlaybackStateDto, { ...valid, positionMs: 12.5 })).not.toHaveLength(0);
expect(await errorsFor(PutAudiobookPlaybackStateDto, { ...valid, assetId: '11' })).not.toHaveLength(0);
expect(await errorsFor(PutAudiobookPlaybackStateDto, { ...valid, percentage: 50 })).not.toHaveLength(0);
});
it('validates dedicated audiobook bookmark create and update shapes', async () => {
const create = {
clientId: '33333333-3333-4333-8333-333333333333',
positionMs: 50_000,
chapterId: 'ch_1',
title: 'A useful moment',
note: 'Return here',
};
expect(await errorsFor(CreateAudiobookBookmarkDto, create)).toHaveLength(0);
expect(await errorsFor(CreateAudiobookBookmarkDto, { ...create, title: '' })).not.toHaveLength(0);
expect(await errorsFor(CreateAudiobookBookmarkDto, { ...create, positionSeconds: 50 })).not.toHaveLength(0);
expect(await errorsFor(UpdateAudiobookBookmarkDto, { title: 'Renamed', note: null })).toHaveLength(0);
expect(await errorsFor(UpdateAudiobookBookmarkDto, { title: '' })).not.toHaveLength(0);
});
});
@@ -0,0 +1,129 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Put,
Res,
} from '@nestjs/common';
import { createReadStream } from 'node:fs';
import { basename } from 'node:path';
import type { FastifyReply } from 'fastify';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { contentDispositionHeader } from '../../common/utils/content-disposition.utils';
import { AudiobookService } from './audiobook.service';
import { CreateAudiobookBookmarkDto } from './dto/create-audiobook-bookmark.dto';
import { PutAudiobookPlaybackStateDto } from './dto/put-audiobook-playback-state.dto';
import { UpdateAudiobookBookmarkDto } from './dto/update-audiobook-bookmark.dto';
const AUDIO_MIME_TYPES: Record<string, string> = {
m4b: 'audio/mp4',
m4a: 'audio/mp4',
mp3: 'audio/mpeg',
opus: 'audio/ogg; codecs=opus',
ogg: 'audio/ogg',
flac: 'audio/flac',
};
@Controller('audiobooks')
export class AudiobookController {
constructor(private readonly service: AudiobookService) {}
@Get(':bookId/manifest')
getManifest(@Param('bookId', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser) {
return this.service.getManifest(bookId, user);
}
@Get(':bookId/assets/:assetId/content')
async serveAsset(
@Param('bookId', ParseIntPipe) bookId: number,
@Param('assetId') assetId: string,
@CurrentUser() user: RequestUser,
@Headers('range') rangeHeader: string | undefined,
@Res() reply: FastifyReply,
) {
const asset = await this.service.getAsset(bookId, assetId, user);
const size = asset.sizeBytes;
const mimeType = AUDIO_MIME_TYPES[asset.format.toLowerCase()] ?? 'application/octet-stream';
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Disposition', contentDispositionHeader('inline', basename(asset.absolutePath), 'audiobook'));
reply.type(mimeType);
if (rangeHeader) {
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
if (match) {
const start = Number.parseInt(match[1]!, 10);
const end = match[2] ? Number.parseInt(match[2], 10) : size - 1;
if (start >= size || end < start || end >= size) {
reply.status(416).header('Content-Range', `bytes */${size}`).send();
return;
}
reply.status(206);
reply.header('Content-Range', `bytes ${start}-${end}/${size}`);
reply.header('Content-Length', end - start + 1);
reply.send(createReadStream(asset.absolutePath, { start, end }));
return;
}
}
reply.header('Content-Length', size);
reply.send(createReadStream(asset.absolutePath));
}
@Get(':bookId/playback-state')
getPlaybackState(@Param('bookId', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser) {
return this.service.getPlaybackState(bookId, user);
}
@Put(':bookId/playback-state')
putPlaybackState(@Param('bookId', ParseIntPipe) bookId: number, @Body() dto: PutAudiobookPlaybackStateDto, @CurrentUser() user: RequestUser) {
return this.service.putPlaybackState(bookId, dto, user);
}
@Delete(':bookId/playback-state')
@HttpCode(HttpStatus.NO_CONTENT)
deletePlaybackState(@Param('bookId', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser) {
return this.service.deletePlaybackState(bookId, user);
}
@Get(':bookId/bookmarks')
listBookmarks(@Param('bookId', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser) {
return this.service.listBookmarks(bookId, user);
}
@Post(':bookId/bookmarks')
createBookmark(@Param('bookId', ParseIntPipe) bookId: number, @Body() dto: CreateAudiobookBookmarkDto, @CurrentUser() user: RequestUser) {
return this.service.createBookmark(bookId, dto, user);
}
@Patch(':bookId/bookmarks/:bookmarkId')
updateBookmark(
@Param('bookId', ParseIntPipe) bookId: number,
@Param('bookmarkId', ParseUUIDPipe) bookmarkId: string,
@Body() dto: UpdateAudiobookBookmarkDto,
@CurrentUser() user: RequestUser,
) {
return this.service.updateBookmark(bookId, bookmarkId, dto, user);
}
@Delete(':bookId/bookmarks/:bookmarkId')
@HttpCode(HttpStatus.NO_CONTENT)
deleteBookmark(
@Param('bookId', ParseIntPipe) bookId: number,
@Param('bookmarkId', ParseUUIDPipe) bookmarkId: string,
@CurrentUser() user: RequestUser,
) {
return this.service.deleteBookmark(bookId, bookmarkId, user);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { AudiobookController } from './audiobook.controller';
import { AudiobookRepository } from './audiobook.repository';
import { AudiobookService } from './audiobook.service';
@Module({
imports: [BookModule],
controllers: [AudiobookController],
providers: [AudiobookRepository, AudiobookService],
})
export class AudiobookModule {}
@@ -0,0 +1,163 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, asc, eq, isNotNull, isNull, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { audiobookProgress, bookmarks, bookFiles } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
export interface PlaybackStateWrite {
currentFileId: number;
positionSeconds: number;
percentage: number;
capturedAt: Date;
operationId: string;
manifestRevision: string;
}
@Injectable()
export class AudiobookRepository {
constructor(@Inject(DB) private readonly db: Db) {}
findAudioFiles(bookId: number) {
return this.db
.select({
id: bookFiles.id,
publicId: bookFiles.publicId,
absolutePath: bookFiles.absolutePath,
format: bookFiles.format,
sizeBytes: bookFiles.sizeBytes,
durationSeconds: bookFiles.durationSeconds,
sortOrder: bookFiles.sortOrder,
mtime: bookFiles.mtime,
updatedAt: bookFiles.updatedAt,
})
.from(bookFiles)
.where(eq(bookFiles.bookId, bookId));
}
async findAsset(bookId: number, publicId: string) {
const [row] = await this.db
.select()
.from(bookFiles)
.where(and(eq(bookFiles.bookId, bookId), eq(bookFiles.publicId, publicId)))
.limit(1);
return row ?? null;
}
async findPlaybackState(userId: number, bookId: number) {
const [row] = await this.db
.select()
.from(audiobookProgress)
.where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.bookId, bookId)))
.limit(1);
return row ?? null;
}
async createPlaybackState(userId: number, bookId: number, values: PlaybackStateWrite) {
const [row] = await this.db
.insert(audiobookProgress)
.values({ userId, bookId, ...values, revision: 1 })
.onConflictDoNothing()
.returning();
return row ?? null;
}
async updatePlaybackState(userId: number, bookId: number, baseRevision: number, values: PlaybackStateWrite) {
const [row] = await this.db
.update(audiobookProgress)
.set({ ...values, revision: sql`${audiobookProgress.revision} + 1`, updatedAt: new Date() })
.where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.bookId, bookId), eq(audiobookProgress.revision, baseRevision)))
.returning();
return row ?? null;
}
async deletePlaybackState(userId: number, bookId: number) {
await this.db.delete(audiobookProgress).where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.bookId, bookId)));
}
findAudioBookmarks(userId: number, bookId: number) {
return this.db
.select()
.from(bookmarks)
.where(
and(
eq(bookmarks.userId, userId),
eq(bookmarks.bookId, bookId),
isNull(bookmarks.cfi),
isNotNull(bookmarks.positionSeconds),
isNull(bookmarks.deletedAt),
),
)
.orderBy(asc(bookmarks.positionSeconds), asc(bookmarks.id));
}
async findAudioBookmark(userId: number, bookId: number, clientId: string) {
const [row] = await this.db
.select()
.from(bookmarks)
.where(
and(
eq(bookmarks.userId, userId),
eq(bookmarks.bookId, bookId),
eq(bookmarks.clientId, clientId),
isNull(bookmarks.cfi),
isNotNull(bookmarks.positionSeconds),
isNull(bookmarks.deletedAt),
),
)
.limit(1);
return row ?? null;
}
async createAudioBookmark(
userId: number,
bookId: number,
values: { clientId: string; positionSeconds: number; chapterId: string | null; title: string; note: string | null },
) {
const [row] = await this.db
.insert(bookmarks)
.values({ userId, bookId, cfi: null, ...values })
.onConflictDoNothing()
.returning();
return row ?? (await this.findAudioBookmark(userId, bookId, values.clientId));
}
async updateAudioBookmark(userId: number, bookId: number, clientId: string, values: { title?: string; note?: string | null }) {
const [row] = await this.db
.update(bookmarks)
.set(values)
.where(
and(
eq(bookmarks.userId, userId),
eq(bookmarks.bookId, bookId),
eq(bookmarks.clientId, clientId),
isNull(bookmarks.cfi),
isNotNull(bookmarks.positionSeconds),
isNull(bookmarks.deletedAt),
),
)
.returning();
return row ?? null;
}
async deleteAudioBookmark(userId: number, bookId: number, clientId: string) {
const rows = await this.db
.update(bookmarks)
.set({ deletedAt: new Date() })
.where(
and(
eq(bookmarks.userId, userId),
eq(bookmarks.bookId, bookId),
eq(bookmarks.clientId, clientId),
isNull(bookmarks.cfi),
isNotNull(bookmarks.positionSeconds),
isNull(bookmarks.deletedAt),
),
)
.returning({ id: bookmarks.id });
return rows.length > 0;
}
}
@@ -0,0 +1,186 @@
import { BadRequestException, ConflictException, PreconditionFailedException } from '@nestjs/common';
import { AUDIOBOOK_MANIFEST_SCHEMA, AUDIOBOOK_MANIFEST_VERSION } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { AudiobookService } from './audiobook.service';
const FIRST_PUBLIC_ID = '11111111-1111-4111-8111-111111111111';
const SECOND_PUBLIC_ID = '22222222-2222-4222-8222-222222222222';
function makeUser(): RequestUser {
return {
id: 7,
username: 'listener',
name: 'Listener',
email: null,
active: true,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
isSuperuser: false,
permissions: [],
contentFilters: null,
};
}
function makeFixture() {
const files = [
{
id: 12,
publicId: SECOND_PUBLIC_ID,
absolutePath: '/library/Part 2.mp3',
format: 'mp3',
sizeBytes: 2_000,
durationSeconds: 20,
sortOrder: 2,
mtime: new Date('2026-01-02T00:00:00.000Z'),
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
},
{
id: 11,
publicId: FIRST_PUBLIC_ID,
absolutePath: '/library/Part 1.mp3',
format: 'mp3',
sizeBytes: 1_000,
durationSeconds: 10.125,
sortOrder: 1,
mtime: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
},
];
const repo = {
findAudioFiles: vi.fn().mockResolvedValue(files),
findAsset: vi.fn(),
findPlaybackState: vi.fn().mockResolvedValue(null),
createPlaybackState: vi.fn(),
updatePlaybackState: vi.fn(),
deletePlaybackState: vi.fn(),
findAudioBookmarks: vi.fn(),
createAudioBookmark: vi.fn(),
updateAudioBookmark: vi.fn(),
deleteAudioBookmark: vi.fn(),
};
const bookService = {
verifyBookAccess: vi.fn().mockResolvedValue(undefined),
getFileInfo: vi.fn(),
getDetail: vi.fn().mockResolvedValue({
id: 30,
libraryId: 4,
title: 'A Long Listen',
folderPath: '/library/A Long Listen',
authors: [{ name: 'Author' }],
coverSource: 'embedded',
audioMetadata: {
durationSeconds: 30.125,
narrators: [{ name: 'Narrator' }],
chapters: [
{ title: 'Opening', startMs: 0 },
{ title: 'Second Part', startMs: 10_125 },
],
},
}),
autoUpdateReadStatusForProgress: vi.fn().mockResolvedValue(undefined),
};
return {
repo,
bookService,
service: new AudiobookService(repo as never, bookService as never),
};
}
describe('AudiobookService', () => {
it('builds a versioned manifest with stable opaque assets and full chapter ranges', async () => {
const { service, bookService } = makeFixture();
const manifest = await service.getManifest(30, makeUser());
expect(bookService.verifyBookAccess).toHaveBeenCalledWith(30, expect.objectContaining({ id: 7 }));
expect(manifest.schema).toBe(AUDIOBOOK_MANIFEST_SCHEMA);
expect(manifest.schemaVersion).toBe(AUDIOBOOK_MANIFEST_VERSION);
expect(manifest.revision).toMatch(/^[0-9a-f]{64}$/);
expect(manifest.assets).toEqual([
expect.objectContaining({ assetId: `aud_${FIRST_PUBLIC_ID}`, sequence: 0, durationMs: 10_125 }),
expect.objectContaining({ assetId: `aud_${SECOND_PUBLIC_ID}`, sequence: 1, durationMs: 20_000 }),
]);
expect(manifest.chapters).toEqual([
expect.objectContaining({ sequence: 0, startMs: 0, endMs: 10_125, assetId: `aud_${FIRST_PUBLIC_ID}`, assetOffsetMs: 0 }),
expect.objectContaining({ sequence: 1, startMs: 10_125, endMs: 30_125, assetId: `aud_${SECOND_PUBLIC_ID}`, assetOffsetMs: 0 }),
]);
});
it('derives percentage and creates revision one from a matching manifest write', async () => {
const { service, repo, bookService } = makeFixture();
const manifest = await service.getManifest(30, makeUser());
repo.createPlaybackState.mockResolvedValue({
capturedAt: new Date('2026-02-01T00:00:00.000Z'),
revision: 1,
});
const saved = await service.putPlaybackState(
30,
{
assetId: manifest.assets[1]!.assetId,
positionMs: 5_000,
capturedAt: '2026-02-01T00:00:00.000Z',
operationId: '33333333-3333-4333-8333-333333333333',
baseRevision: 0,
manifestRevision: manifest.revision,
},
makeUser(),
);
expect(saved.percentage).toBeCloseTo((15_125 / 30_125) * 100);
expect(saved.revision).toBe(1);
expect(repo.createPlaybackState).toHaveBeenCalledWith(
7,
30,
expect.objectContaining({ currentFileId: 12, positionSeconds: 5, percentage: saved.percentage }),
);
expect(bookService.autoUpdateReadStatusForProgress).toHaveBeenCalledWith(7, { bookId: 30, libraryId: 4 }, saved.percentage, {});
});
it('rejects stale manifests, unknown assets, and revision conflicts', async () => {
const { service, repo } = makeFixture();
const manifest = await service.getManifest(30, makeUser());
const base = {
assetId: manifest.assets[0]!.assetId,
positionMs: 1_000,
capturedAt: '2026-02-01T00:00:00.000Z',
operationId: '44444444-4444-4444-8444-444444444444',
baseRevision: 0,
manifestRevision: manifest.revision,
};
await expect(service.putPlaybackState(30, { ...base, manifestRevision: '0'.repeat(64) }, makeUser())).rejects.toThrow(
PreconditionFailedException,
);
await expect(service.putPlaybackState(30, { ...base, assetId: 'aud_55555555-5555-4555-8555-555555555555' }, makeUser())).rejects.toThrow(
BadRequestException,
);
repo.createPlaybackState.mockResolvedValue(null);
await expect(service.putPlaybackState(30, base, makeUser())).rejects.toThrow(ConflictException);
});
it('resolves asset content through the access-checked file path', async () => {
const { service, repo, bookService } = makeFixture();
repo.findAsset.mockResolvedValue({ id: 11, format: 'mp3' });
bookService.getFileInfo.mockResolvedValue({ path: '/library/Part 1.mp3', size: 1_234, format: 'mp3' });
await expect(service.getAsset(30, `aud_${FIRST_PUBLIC_ID}`, makeUser())).resolves.toEqual({
absolutePath: '/library/Part 1.mp3',
sizeBytes: 1_234,
format: 'mp3',
});
expect(bookService.getFileInfo).toHaveBeenCalledWith(11, expect.objectContaining({ id: 7 }));
});
it('requires an actual field when updating an audiobook bookmark', async () => {
const { service, repo } = makeFixture();
await expect(service.updateBookmark(30, '66666666-6666-4666-8666-666666666666', {}, makeUser())).rejects.toThrow(BadRequestException);
expect(repo.updateAudioBookmark).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,289 @@
import { createHash } from 'node:crypto';
import { basename } from 'node:path';
import { BadRequestException, ConflictException, Injectable, NotFoundException, PreconditionFailedException } from '@nestjs/common';
import {
AUDIOBOOK_MANIFEST_SCHEMA,
AUDIOBOOK_MANIFEST_VERSION,
isAudioFormat,
type AudiobookBookmark,
type AudiobookManifest,
type AudiobookManifestAsset,
type AudiobookManifestChapter,
type AudiobookPlaybackState,
} from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { naturalCompare } from '../../common/utils/natural-sort.utils';
import type { BookmarkRow } from '../../db/schema';
import { BookService } from '../book/book.service';
import type { CreateAudiobookBookmarkDto } from './dto/create-audiobook-bookmark.dto';
import type { PutAudiobookPlaybackStateDto } from './dto/put-audiobook-playback-state.dto';
import type { UpdateAudiobookBookmarkDto } from './dto/update-audiobook-bookmark.dto';
import { AudiobookRepository } from './audiobook.repository';
type AudioFileRow = Awaited<ReturnType<AudiobookRepository['findAudioFiles']>>[number];
interface ManifestContext {
manifest: AudiobookManifest;
files: AudioFileRow[];
libraryId: number;
}
@Injectable()
export class AudiobookService {
constructor(
private readonly repo: AudiobookRepository,
private readonly bookService: BookService,
) {}
async getManifest(bookId: number, user: RequestUser): Promise<AudiobookManifest> {
return (await this.loadManifestContext(bookId, user)).manifest;
}
async getAsset(bookId: number, assetId: string, user: RequestUser) {
await this.bookService.verifyBookAccess(bookId, user);
const row = await this.repo.findAsset(bookId, this.publicIdFromAssetId(assetId));
if (!row || !row.format || !isAudioFormat(row.format)) throw new NotFoundException('Audiobook asset not found');
const file = await this.bookService.getFileInfo(row.id, user);
return {
absolutePath: file.path,
format: file.format,
sizeBytes: file.size,
};
}
async getPlaybackState(bookId: number, user: RequestUser): Promise<AudiobookPlaybackState | null> {
const context = await this.loadManifestContext(bookId, user);
const row = await this.repo.findPlaybackState(user.id, bookId);
if (!row) return null;
const file = context.files.find((candidate) => candidate.id === row.currentFileId);
if (!file) return null;
return {
assetId: this.assetId(file.publicId),
positionMs: Math.max(0, Math.round(row.positionSeconds * 1000)),
percentage: row.percentage,
completed: row.percentage >= 100,
capturedAt: row.capturedAt.toISOString(),
revision: row.revision,
manifestRevision: row.manifestRevision ?? context.manifest.revision,
};
}
async putPlaybackState(bookId: number, dto: PutAudiobookPlaybackStateDto, user: RequestUser): Promise<AudiobookPlaybackState> {
const context = await this.loadManifestContext(bookId, user);
if (dto.manifestRevision !== context.manifest.revision) {
throw new PreconditionFailedException('Audiobook manifest has changed');
}
const fileIndex = context.files.findIndex((file) => this.assetId(file.publicId) === dto.assetId);
if (fileIndex < 0) throw new BadRequestException('assetId does not belong to this audiobook');
const previous = await this.repo.findPlaybackState(user.id, bookId);
if (previous?.operationId === dto.operationId) {
return (await this.getPlaybackState(bookId, user))!;
}
const asset = context.manifest.assets[fileIndex]!;
if (asset.durationMs !== null && dto.positionMs > asset.durationMs) {
throw new BadRequestException('positionMs exceeds the asset duration');
}
const elapsedBefore = context.manifest.assets.slice(0, fileIndex).reduce((sum, item) => sum + (item.durationMs ?? 0), 0);
const absolutePositionMs = elapsedBefore + dto.positionMs;
const percentage =
context.manifest.totalDurationMs > 0 ? Math.max(0, Math.min(100, (absolutePositionMs / context.manifest.totalDurationMs) * 100)) : 0;
const values = {
currentFileId: context.files[fileIndex]!.id,
positionSeconds: dto.positionMs / 1000,
percentage,
capturedAt: new Date(dto.capturedAt),
operationId: dto.operationId,
manifestRevision: dto.manifestRevision,
};
const saved =
dto.baseRevision === 0
? await this.repo.createPlaybackState(user.id, bookId, values)
: await this.repo.updatePlaybackState(user.id, bookId, dto.baseRevision, values);
if (!saved) throw new ConflictException('Playback state revision conflict');
const strongRereadEvidence = previous !== null && previous.percentage - percentage >= 10;
await this.bookService.autoUpdateReadStatusForProgress(
user.id,
{ bookId, libraryId: context.libraryId },
percentage,
strongRereadEvidence ? { origin: 'bookorbit', strongRereadEvidence: true } : {},
);
return {
assetId: dto.assetId,
positionMs: dto.positionMs,
percentage,
completed: percentage >= 100,
capturedAt: saved.capturedAt.toISOString(),
revision: saved.revision,
manifestRevision: dto.manifestRevision,
};
}
async deletePlaybackState(bookId: number, user: RequestUser): Promise<void> {
await this.bookService.verifyBookAccess(bookId, user);
await this.repo.deletePlaybackState(user.id, bookId);
}
async listBookmarks(bookId: number, user: RequestUser): Promise<AudiobookBookmark[]> {
await this.bookService.verifyBookAccess(bookId, user);
const rows = await this.repo.findAudioBookmarks(user.id, bookId);
return rows.map((row) => this.mapBookmark(row));
}
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) {
throw new BadRequestException('positionMs exceeds the audiobook duration');
}
if (dto.chapterId && !context.manifest.chapters.some((chapter) => chapter.id === dto.chapterId)) {
throw new BadRequestException('chapterId does not belong to this audiobook manifest');
}
const row = await this.repo.createAudioBookmark(user.id, bookId, {
clientId: dto.clientId,
positionSeconds: dto.positionMs / 1000,
chapterId: dto.chapterId ?? null,
title: dto.title,
note: dto.note ?? null,
});
if (!row) throw new ConflictException('Audiobook bookmark already exists');
return this.mapBookmark(row);
}
async updateBookmark(bookId: number, bookmarkId: string, dto: UpdateAudiobookBookmarkDto, user: RequestUser): Promise<AudiobookBookmark> {
await this.bookService.verifyBookAccess(bookId, user);
if (dto.title === undefined && dto.note === undefined) {
throw new BadRequestException('At least one bookmark field is required');
}
const row = await this.repo.updateAudioBookmark(user.id, bookId, bookmarkId, dto);
if (!row) throw new NotFoundException('Audiobook bookmark not found');
return this.mapBookmark(row);
}
async deleteBookmark(bookId: number, bookmarkId: string, user: RequestUser): Promise<void> {
await this.bookService.verifyBookAccess(bookId, user);
if (!(await this.repo.deleteAudioBookmark(user.id, bookId, bookmarkId))) {
throw new NotFoundException('Audiobook bookmark not found');
}
}
private async loadManifestContext(bookId: number, user: RequestUser): Promise<ManifestContext> {
await this.bookService.verifyBookAccess(bookId, user);
const [detail, rows] = await Promise.all([this.bookService.getDetail(bookId, user), this.repo.findAudioFiles(bookId)]);
const files = rows
.filter((row) => row.format !== null && isAudioFormat(row.format))
.sort((left, right) => {
if (left.sortOrder !== null || right.sortOrder !== null) {
const byOrder = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
if (byOrder !== 0) return byOrder;
}
return naturalCompare(basename(left.absolutePath), basename(right.absolutePath));
});
if (files.length === 0) throw new NotFoundException('Book has no audiobook assets');
const revision = createHash('sha256')
.update(
JSON.stringify({
files: files.map((file) => [file.publicId, file.sizeBytes, file.durationSeconds, file.mtime?.toISOString(), file.updatedAt.toISOString()]),
chapters: detail.audioMetadata?.chapters ?? [],
}),
)
.digest('hex');
const assets = files.map<AudiobookManifestAsset>((file, sequence) => ({
assetId: this.assetId(file.publicId),
sequence,
format: file.format!,
durationMs: file.durationSeconds === null ? null : Math.round(file.durationSeconds * 1000),
sizeBytes: file.sizeBytes,
etag: createHash('sha256')
.update(`${file.publicId}:${file.sizeBytes ?? ''}:${file.mtime?.getTime() ?? ''}`)
.digest('hex'),
}));
const totalDurationMs =
detail.audioMetadata?.durationSeconds !== null && detail.audioMetadata?.durationSeconds !== undefined
? Math.round(detail.audioMetadata.durationSeconds * 1000)
: assets.reduce((sum, asset) => sum + (asset.durationMs ?? 0), 0);
const chapters = this.buildChapters(bookId, revision, detail.audioMetadata?.chapters ?? [], assets, totalDurationMs);
return {
libraryId: detail.libraryId,
files,
manifest: {
schema: AUDIOBOOK_MANIFEST_SCHEMA,
schemaVersion: AUDIOBOOK_MANIFEST_VERSION,
revision,
book: {
id: bookId,
title: detail.title ?? basename(detail.folderPath),
authors: detail.authors.map((author) => author.name),
narrators: detail.audioMetadata?.narrators.map((narrator) => narrator.name) ?? [],
hasCover: detail.coverSource !== null,
},
assets,
chapters,
totalDurationMs,
},
};
}
private buildChapters(
bookId: number,
revision: string,
chapters: { title: string; startMs: number }[],
assets: AudiobookManifestAsset[],
totalDurationMs: number,
): AudiobookManifestChapter[] {
return [...chapters]
.sort((left, right) => left.startMs - right.startMs)
.map((chapter, sequence, ordered) => {
const startMs = Math.max(0, Math.round(chapter.startMs));
const endMs = Math.max(startMs, Math.round(ordered[sequence + 1]?.startMs ?? totalDurationMs));
let elapsed = 0;
let assetIndex = 0;
for (let index = 0; index < assets.length; index += 1) {
const duration = assets[index]!.durationMs ?? 0;
if (index === assets.length - 1 || startMs < elapsed + duration) {
assetIndex = index;
break;
}
elapsed += duration;
}
return {
id: `ch_${createHash('sha256').update(`${bookId}:${revision}:${sequence}:${startMs}`).digest('hex').slice(0, 32)}`,
title: chapter.title,
assetId: assets[assetIndex]!.assetId,
sequence,
startMs,
endMs,
assetOffsetMs: Math.max(0, startMs - elapsed),
};
});
}
private mapBookmark(row: BookmarkRow): AudiobookBookmark {
return {
id: row.clientId,
bookId: row.bookId,
positionMs: Math.max(0, Math.round((row.positionSeconds ?? 0) * 1000)),
chapterId: row.chapterId,
title: row.title,
note: row.note,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private assetId(publicId: string): string {
return `aud_${publicId}`;
}
private publicIdFromAssetId(assetId: string): string {
const publicId = assetId.startsWith('aud_') ? assetId.slice(4) : '';
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(publicId)) {
throw new NotFoundException('Audiobook asset not found');
}
return publicId;
}
}
@@ -0,0 +1,25 @@
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateAudiobookBookmarkDto {
@IsUUID()
clientId!: string;
@IsInt()
@Min(0)
positionMs!: number;
@IsOptional()
@IsString()
@MaxLength(80)
chapterId?: string;
@IsString()
@IsNotEmpty()
@MaxLength(500)
title!: string;
@IsOptional()
@IsString()
@MaxLength(4000)
note?: string;
}
@@ -0,0 +1,25 @@
import { IsISO8601, IsInt, IsString, IsUUID, Matches, Min } from 'class-validator';
export class PutAudiobookPlaybackStateDto {
@IsString()
@Matches(/^aud_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
assetId!: string;
@IsInt()
@Min(0)
positionMs!: number;
@IsISO8601({ strict: true })
capturedAt!: string;
@IsUUID()
operationId!: string;
@IsInt()
@Min(0)
baseRevision!: number;
@IsString()
@Matches(/^[0-9a-f]{64}$/)
manifestRevision!: string;
}
@@ -0,0 +1,14 @@
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateAudiobookBookmarkDto {
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(500)
title?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
note?: string | null;
}
@@ -147,10 +147,8 @@ function makeController() {
resolveDownloadFilename: vi.fn(),
getProgress: vi.fn(),
getBookProgress: vi.fn(),
getAudioProgress: vi.fn(),
saveProgress: vi.fn(),
clearFileProgress: vi.fn(),
saveAudioProgress: vi.fn(),
updatePersonalNote: vi.fn(),
updateAddedAt: vi.fn(),
updateMetadata: vi.fn(),
@@ -788,11 +786,9 @@ describe('BookController', () => {
await controller.saveFileProgress(9, { percentage: 25 } as never, user);
await controller.clearFileProgress(9, user);
await controller.saveAudioProgress(11, { currentFileId: 2, positionSeconds: 90, percentage: 20 } as never, user);
expect(bookService.saveProgress).toHaveBeenCalledWith(user.id, 9, { percentage: 25 }, user);
expect(bookService.clearFileProgress).toHaveBeenCalledWith(user.id, 9, user);
expect(bookService.saveAudioProgress).toHaveBeenCalledWith(user.id, 11, { currentFileId: 2, positionSeconds: 90, percentage: 20 }, user);
});
it('delegates remaining book endpoints to service methods', async () => {
@@ -808,7 +804,6 @@ describe('BookController', () => {
bookService.refreshMetadata.mockResolvedValue({ id: 7 });
bookService.getMetadataFromFile.mockResolvedValue({ title: 'File Title' });
bookService.getKoboState.mockResolvedValue({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] });
bookService.getAudioProgress.mockResolvedValue({ positionSeconds: 15 });
bookService.getDetail.mockResolvedValue({ id: 7, title: 'Detail' });
await controller.updateMetadata(7, { title: 'New' } as never, user);
@@ -820,7 +815,6 @@ describe('BookController', () => {
await controller.getMetadataFromFile(7, user);
await controller.getKoboState(7, user);
await controller.setReadStatus(7, { status: 'reading' } as never, user);
await controller.getAudioProgress(7, user);
await controller.getDetail(7, user);
expect(bookService.updateMetadata).toHaveBeenCalledWith(7, { title: 'New' }, user, { postSaveMode: 'schedule' });
@@ -834,7 +828,6 @@ describe('BookController', () => {
expect(bookService.getMetadataFromFile).toHaveBeenCalledWith(7, user);
expect(bookService.getKoboState).toHaveBeenCalledWith(7, user);
expect(bookService.setReadStatus).toHaveBeenCalledWith(7, { status: 'reading' }, user);
expect(bookService.getAudioProgress).toHaveBeenCalledWith(user.id, 7, user);
expect(bookService.getDetail).toHaveBeenCalledWith(7, user);
});
+1 -12
View File
@@ -42,7 +42,6 @@ import { DeleteBooksDto } from './dto/delete-books.dto';
import { ExportBooksDto } from './dto/export-books.dto';
import { MetadataExportDto } from './dto/metadata-export.dto';
import { SaveProgressDto } from './dto/save-progress.dto';
import { UpsertAudioProgressDto } from './dto/upsert-audio-progress.dto';
import { UpdateBookMetadataAndLocksDto } from './dto/update-book-metadata-and-locks.dto';
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
import { UpdateBookAddedAtDto } from './dto/update-book-added-at.dto';
@@ -396,6 +395,7 @@ export class BookController {
@Res() reply: FastifyReply,
) {
const { path, size, format, originalFilename } = await this.bookService.getFileInfo(fileId, user);
if (resolveAudioMimeType(format)) throw new NotFoundException('File route not found');
const mimeType = resolveBookMimeType(format);
const filename = originalFilename;
@@ -496,17 +496,6 @@ export class BookController {
await this.bookService.deleteFile(fileId, user);
}
@Get(':id/audio-progress')
async getAudioProgress(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return (await this.bookService.getAudioProgress(user.id, id, user)) ?? null;
}
@Patch(':id/audio-progress')
@HttpCode(HttpStatus.NO_CONTENT)
async saveAudioProgress(@Param('id', ParseIntPipe) id: number, @Body() dto: UpsertAudioProgressDto, @CurrentUser() user: RequestUser) {
await this.bookService.saveAudioProgress(user.id, id, dto, user);
}
@Patch(':id/personal-note')
updatePersonalNote(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdatePersonalNoteDto, @CurrentUser() user: RequestUser) {
return this.bookService.updatePersonalNote(id, dto, user);
@@ -733,41 +733,25 @@ describe('BookRepository', () => {
await expect(repo.findPrimaryFile(2)).resolves.toBeNull();
});
it('writes deletion, metadata updates, and audio progress rows', async () => {
it('writes deletion and metadata updates', async () => {
const deleteWhere = vi.fn().mockResolvedValue(undefined);
const deleteBuilder = { where: deleteWhere };
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateBuilder = { set: vi.fn().mockReturnValue({ where: updateWhere }) };
const audioInsert = {
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ bookId: 10, percentage: 33 }]),
}),
}),
};
const audioProgressSelect = makeSelectChain('limit', [{ percentage: 22 }]);
const missingAudioProgressSelect = makeSelectChain('limit', []);
const db = {
delete: vi.fn().mockReturnValue(deleteBuilder),
update: vi.fn().mockReturnValue(updateBuilder),
insert: vi.fn().mockReturnValue(audioInsert),
select: vi.fn().mockReturnValueOnce(audioProgressSelect).mockReturnValueOnce(missingAudioProgressSelect),
};
const repo = new BookRepository(db as never);
await repo.deleteByIds([10, 11]);
await repo.updateMetadataFields(10, { title: 'Updated' });
await expect(repo.findAudioProgress(1, 10)).resolves.toEqual({ percentage: 22 });
await expect(repo.findAudioProgress(1, 11)).resolves.toBeNull();
await expect(repo.upsertAudioProgress(1, 10, 4, 120, 33)).resolves.toEqual({ bookId: 10, percentage: 33 });
expect(db.delete).toHaveBeenCalledTimes(1);
expect(deleteWhere).toHaveBeenCalledTimes(1);
expect(db.update).toHaveBeenCalledTimes(2);
expect(updateBuilder.set).toHaveBeenNthCalledWith(1, { title: 'Updated' });
expect(updateBuilder.set).toHaveBeenNthCalledWith(2, expect.objectContaining({ updatedAt: expect.any(Date) }));
expect(updateWhere).toHaveBeenCalledTimes(2);
expect(db.insert).toHaveBeenCalledTimes(1);
});
it('replaces all community rating rows: deletes old then inserts new', async () => {
@@ -2435,28 +2435,6 @@ export class BookRepository {
`);
}
async findAudioProgress(userId: number, bookId: number) {
const [row] = await this.db
.select()
.from(audiobookProgress)
.where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.bookId, bookId)))
.limit(1);
return row ?? null;
}
async upsertAudioProgress(userId: number, bookId: number, currentFileId: number, positionSeconds: number, percentage: number) {
const now = new Date();
const [row] = await this.db
.insert(audiobookProgress)
.values({ userId, bookId, currentFileId, positionSeconds, percentage, updatedAt: now })
.onConflictDoUpdate({
target: [audiobookProgress.userId, audiobookProgress.bookId],
set: { currentFileId, positionSeconds, percentage, updatedAt: now },
})
.returning();
return row;
}
private clampProgressPercentage(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
@@ -2334,31 +2334,6 @@ describe('BookService', () => {
});
});
// ── AUDIO PROGRESS ─────────────────────────────────────────────────────────
describe('getAudioProgress', () => {
it('returns latest audio progress from repo', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
const progressRow = { fileId: 5, positionSeconds: 1234, updatedAt: new Date().toISOString() };
bookRepo.findLibraryIdByBookId = vi.fn().mockResolvedValue(1);
bookRepo.findAudioProgress = vi.fn().mockResolvedValue(progressRow);
const result = await service.getAudioProgress(user.id, 10, user);
expect(result).toBe(progressRow);
expect(bookRepo.findAudioProgress).toHaveBeenCalledWith(user.id, 10);
});
it('throws NotFoundException when book does not exist', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
bookRepo.findLibraryIdByBookId = vi.fn().mockResolvedValue(null);
await expect(service.getAudioProgress(user.id, 99, user)).rejects.toThrow();
});
});
describe('getBookProgress', () => {
it('returns one row per file with defaults for missing progress', async () => {
const { service, bookRepo } = makeService();
@@ -2553,102 +2528,6 @@ describe('BookService', () => {
});
});
describe('saveAudioProgress', () => {
it('writes audio progress when current file belongs to the target book', async () => {
const { service, bookRepo, libraryService, userBookStatusService } = makeService();
const user = makeUser({ id: 21 });
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
bookRepo.findFileById.mockResolvedValue({
id: 7,
absolutePath: '/books/audiobook-1.mp3',
format: 'mp3',
bookId: 10,
libraryId: 1,
});
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 4, markAsFinishedPercentComplete: 90 });
await service.saveAudioProgress(
user.id,
10,
{
percentage: 33,
currentFileId: 7,
positionSeconds: 120,
},
user,
);
expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(user.id, 10, 7, 120, 33);
expect(libraryService.findOne).toHaveBeenCalledWith(1);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 10, 33, 4, 90);
});
it('throws BadRequestException when current file belongs to a different book', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 21 });
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
bookRepo.findFileById.mockResolvedValue({
id: 8,
absolutePath: '/books/audiobook-2.mp3',
format: 'mp3',
bookId: 99,
libraryId: 1,
});
libraryService.verifyUserAccess.mockResolvedValue(undefined);
await expect(
service.saveAudioProgress(
user.id,
10,
{
percentage: 40,
currentFileId: 8,
positionSeconds: 90,
},
user,
),
).rejects.toThrow(BadRequestException);
expect(bookRepo.upsertAudioProgress).not.toHaveBeenCalled();
});
it('propagates ForbiddenException when current file is in an inaccessible library', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 21 });
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
bookRepo.findFileById.mockResolvedValue({
id: 9,
absolutePath: '/books/secret.mp3',
format: 'mp3',
bookId: 10,
libraryId: 2,
});
libraryService.verifyUserAccess.mockImplementation((_userId: number, libraryId: number) => {
if (libraryId === 2) {
return Promise.reject(new ForbiddenException());
}
return Promise.resolve();
});
await expect(
service.saveAudioProgress(
user.id,
10,
{
percentage: 20,
currentFileId: 9,
positionSeconds: 44,
},
user,
),
).rejects.toThrow(ForbiddenException);
expect(bookRepo.upsertAudioProgress).not.toHaveBeenCalled();
});
});
describe('query, pagination, and read-state delegates', () => {
it('queryForLibrary verifies access and returns paged cards', async () => {
const { service, libraryService, queryBuilder, bookRepo } = makeService();
-25
View File
@@ -101,7 +101,6 @@ import type { BulkSelectionDto } from '../../common/dto/bulk-selection.dto';
import type { MetadataExportDto, MetadataExportFormat, MetadataExportViewType } from './dto/metadata-export.dto';
import type { MetadataExportColumnMode } from './dto/metadata-export-options.dto';
import { SaveProgressDto } from './dto/save-progress.dto';
import { UpsertAudioProgressDto } from './dto/upsert-audio-progress.dto';
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
import { UpdateBookAddedAtDto } from './dto/update-book-added-at.dto';
import { UpdatePersonalNoteDto } from './dto/update-personal-note.dto';
@@ -2036,30 +2035,6 @@ export class BookService {
}));
}
async getAudioProgress(userId: number, bookId: number, user: RequestUser) {
await this.verifyBookAccess(bookId, user);
return this.bookRepo.findAudioProgress(userId, bookId);
}
async saveAudioProgress(userId: number, bookId: number, dto: UpsertAudioProgressDto, user: RequestUser) {
const libraryId = await this.bookRepo.findLibraryIdByBookId(bookId);
if (libraryId === null) throw new NotFoundException(`Book ${bookId} not found`);
await this.libraryService.verifyUserAccess(userId, libraryId, this.isSuperuser(user));
const currentFile = await this.verifyFileAccess(dto.currentFileId, user);
if (currentFile.bookId !== bookId) {
throw new BadRequestException(`currentFileId ${dto.currentFileId} does not belong to book ${bookId}`);
}
const previous = await this.bookRepo.findAudioProgress(userId, bookId);
await this.bookRepo.upsertAudioProgress(userId, bookId, dto.currentFileId, dto.positionSeconds, dto.percentage);
const strongRereadEvidence = previous != null && previous.percentage - dto.percentage >= 10;
await this.autoUpdateReadStatusForProgress(
userId,
{ bookId, libraryId },
dto.percentage,
strongRereadEvidence ? { origin: 'bookorbit', strongRereadEvidence: true } : {},
);
}
async autoUpdateReadStatusForProgress(
userId: number,
file: ProgressStatusFileContext,
@@ -24,7 +24,6 @@ import { UpdateBookMetadataDto } from './update-book-metadata.dto';
import { UpdateBookAddedAtDto } from './update-book-added-at.dto';
import { UpdatePersonalNoteDto } from './update-personal-note.dto';
import { UpdateRatingDto } from './update-rating.dto';
import { UpsertAudioProgressDto } from './upsert-audio-progress.dto';
async function errorsFor<T extends object>(cls: new () => T, value: Record<string, unknown>) {
const dto = plainToInstance(cls, value);
@@ -330,14 +329,4 @@ describe('Book DTO validation', () => {
expect((await errorsFor(UpdatePersonalNoteDto, { note: null })).length).toBe(0);
expect((await errorsFor(UpdatePersonalNoteDto, { note: 'a'.repeat(10001) })).length).toBeGreaterThan(0);
});
it('validates audiobook progress bounds for percentage and position', async () => {
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 0, currentFileId: 1, positionSeconds: 0 })).length).toBe(0);
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 100, currentFileId: 2, positionSeconds: 120.5 })).length).toBe(0);
expect((await errorsFor(UpsertAudioProgressDto, { percentage: -1, currentFileId: 1, positionSeconds: 0 })).length).toBeGreaterThan(0);
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 101, currentFileId: 1, positionSeconds: 0 })).length).toBeGreaterThan(0);
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 10, currentFileId: 0, positionSeconds: 0 })).length).toBeGreaterThan(0);
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 10, currentFileId: 1, positionSeconds: -5 })).length).toBeGreaterThan(0);
});
});
@@ -1,16 +0,0 @@
import { IsInt, IsNumber, Max, Min } from 'class-validator';
export class UpsertAudioProgressDto {
@IsNumber()
@Min(0)
@Max(100)
percentage!: number;
@IsInt()
@Min(1)
currentFileId!: number;
@IsNumber()
@Min(0)
positionSeconds!: number;
}
@@ -7,27 +7,27 @@ import { CreateBookmarkDto } from './dto/create-bookmark.dto';
async function errorsFor(value: Record<string, unknown>) {
const dto = plainToInstance(CreateBookmarkDto, value);
return validate(dto);
return validate(dto, { whitelist: true, forbidNonWhitelisted: true });
}
describe('Bookmark DTO validation', () => {
it('requires at least one location field: cfi or positionSeconds', async () => {
it('requires a CFI location', async () => {
const errors = await errorsFor({ title: 'Chapter 1' });
expect(errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
constraints: expect.objectContaining({
bookmarkLocation: 'Either cfi or positionSeconds must be provided',
isString: 'cfi must be a string',
}),
}),
]),
);
});
it('accepts valid CFI bookmarks and valid audio-position bookmarks', async () => {
it('accepts valid CFI bookmarks and rejects the deleted audio shape', async () => {
expect((await errorsFor({ title: 'Chapter 1', cfi: 'epubcfi(/6/2)' })).length).toBe(0);
expect((await errorsFor({ title: '00:01:40', positionSeconds: 100 })).length).toBe(0);
expect((await errorsFor({ title: '00:01:40', positionSeconds: 100 })).length).toBeGreaterThan(0);
});
it('rejects empty title and empty CFI', async () => {
@@ -39,9 +39,4 @@ describe('Bookmark DTO validation', () => {
expect((await errorsFor({ title: 'x', cfi: 'a'.repeat(2001) })).length).toBeGreaterThan(0);
expect((await errorsFor({ title: 'a'.repeat(501), cfi: 'epubcfi(/6/2)' })).length).toBeGreaterThan(0);
});
it('enforces non-negative numeric positionSeconds', async () => {
expect((await errorsFor({ title: 'x', positionSeconds: -0.1 })).length).toBeGreaterThan(0);
expect((await errorsFor({ title: 'x', positionSeconds: 0 })).length).toBe(0);
});
});
@@ -24,7 +24,7 @@ export class BookmarkRepository {
return this.db
.select()
.from(bookmarks)
.where(and(eq(bookmarks.bookId, bookId), eq(bookmarks.userId, userId), isNull(bookmarks.deletedAt)))
.where(and(eq(bookmarks.bookId, bookId), eq(bookmarks.userId, userId), isNotNull(bookmarks.cfi), isNull(bookmarks.deletedAt)))
.orderBy(asc(bookmarks.createdAt), asc(bookmarks.id));
}
@@ -107,7 +107,15 @@ export class BookmarkRepository {
const result = await this.db
.update(bookmarks)
.set({ deletedAt: new Date() })
.where(and(eq(bookmarks.id, bookmarkId), eq(bookmarks.bookId, bookId), eq(bookmarks.userId, userId), isNull(bookmarks.deletedAt)))
.where(
and(
eq(bookmarks.id, bookmarkId),
eq(bookmarks.bookId, bookId),
eq(bookmarks.userId, userId),
isNotNull(bookmarks.cfi),
isNull(bookmarks.deletedAt),
),
)
.returning({ id: bookmarks.id });
return result.length > 0;
}
@@ -98,43 +98,30 @@ describe('BookmarkService', () => {
expect(result.id).toBe(existing.id);
});
it('returns an existing bookmark for duplicate audio position requests', async () => {
const { service, bookmarkRepo } = makeService();
const existing = makeBookmarkRow({ id: 11, cfi: null, positionSeconds: 93.5, title: '00:01:33' });
bookmarkRepo.findLiveByLocation.mockResolvedValue(existing);
const result = await service.createBookmark(5, makeUser(), { title: '00:01:33', positionSeconds: 93.5 });
expect(bookmarkRepo.findLiveByLocation).toHaveBeenCalledWith(1, 5, { cfi: null, positionSeconds: 93.5 });
expect(bookmarkRepo.create).not.toHaveBeenCalled();
expect(result.id).toBe(11);
expect(result.positionSeconds).toBe(93.5);
});
it('creates and maps a bookmark when no duplicate exists', async () => {
const { service, bookmarkRepo } = makeService();
const createdRow = makeBookmarkRow({ id: 12, cfi: null, positionSeconds: 42, title: '00:00:42' });
const createdRow = makeBookmarkRow({ id: 12, title: 'Chapter 2' });
bookmarkRepo.findLiveByLocation.mockResolvedValue(null);
bookmarkRepo.create.mockResolvedValue(createdRow);
const result = await service.createBookmark(5, makeUser(), { title: '00:00:42', positionSeconds: 42 });
const result = await service.createBookmark(5, makeUser(), { title: 'Chapter 2', cfi: 'epubcfi(/6/8)' });
expect(bookmarkRepo.create).toHaveBeenCalledWith(1, 5, { cfi: null, title: '00:00:42', positionSeconds: 42 });
expect(bookmarkRepo.create).toHaveBeenCalledWith(1, 5, { cfi: 'epubcfi(/6/8)', title: 'Chapter 2', positionSeconds: null });
expect(result).toBeInstanceOf(BookmarkResponseDto);
expect(result.id).toBe(12);
expect(result.cfi).toBeNull();
expect(result.positionSeconds).toBe(42);
expect(result.cfi).toBe('epubcfi(/6/4!/4/2/1:0)');
expect(result.positionSeconds).toBeNull();
});
it('re-reads and returns the existing bookmark when a concurrent duplicate insert is ignored', async () => {
const { service, bookmarkRepo } = makeService();
const existing = makeBookmarkRow({ id: 13, cfi: null, positionSeconds: 42, title: '00:00:42' });
const existing = makeBookmarkRow({ id: 13, title: 'Chapter 2' });
bookmarkRepo.findLiveByLocation.mockResolvedValueOnce(null).mockResolvedValueOnce(existing);
bookmarkRepo.create.mockResolvedValue(null);
const result = await service.createBookmark(5, makeUser(), { title: '00:00:42', positionSeconds: 42 });
const result = await service.createBookmark(5, makeUser(), { title: 'Chapter 2', cfi: 'epubcfi(/6/8)' });
expect(bookmarkRepo.findLiveByLocation).toHaveBeenNthCalledWith(2, 1, 5, { cfi: null, positionSeconds: 42 });
expect(bookmarkRepo.findLiveByLocation).toHaveBeenNthCalledWith(2, 1, 5, { cfi: 'epubcfi(/6/8)', positionSeconds: null });
expect(result.id).toBe(13);
});
@@ -56,9 +56,9 @@ export class BookmarkService {
private buildCreateData(dto: CreateBookmarkDto): Pick<NewBookmark, 'cfi' | 'title' | 'positionSeconds'> {
return {
cfi: dto.cfi ?? null,
cfi: dto.cfi,
title: dto.title,
positionSeconds: dto.positionSeconds ?? null,
positionSeconds: null,
};
}
@@ -1,57 +1,13 @@
import {
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
@ValidatorConstraint({ name: 'bookmarkLocation', async: false })
class BookmarkLocationConstraint implements ValidatorConstraintInterface {
validate(_: unknown, args: ValidationArguments): boolean {
const dto = args.object as CreateBookmarkDto;
return typeof dto.cfi === 'string' || typeof dto.positionSeconds === 'number';
}
defaultMessage(): string {
return 'Either cfi or positionSeconds must be provided';
}
}
function HasBookmarkLocation(options?: ValidationOptions) {
return function (constructor: new (...args: unknown[]) => unknown) {
registerDecorator({
name: 'bookmarkLocation',
target: constructor,
propertyName: '',
options,
constraints: [],
validator: BookmarkLocationConstraint,
});
};
}
@HasBookmarkLocation()
export class CreateBookmarkDto {
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(2000)
cfi?: string;
cfi!: string;
@IsString()
@IsNotEmpty()
@MaxLength(500)
title!: string;
@IsOptional()
@IsNumber()
@Min(0)
positionSeconds?: number;
}
@@ -1,6 +1,7 @@
import { randomUUID } from 'crypto';
import { and, eq } from 'drizzle-orm';
import type { AudiobookManifest, AudiobookPlaybackState } from '@bookorbit/types';
import * as schema from '../src/db/schema';
import {
@@ -20,17 +21,22 @@ describe('Dashboard continue-listening scroller (e2e)', { timeout: SCENARIO_TIME
let ctx!: AuthorizationMatrixE2EContext;
let reader!: TestUserSession;
let bookId!: number;
let audioFileId!: number;
let assetId!: string;
let manifestRevision!: string;
let playbackRevision = 0;
async function saveAudioProgress(percentage: number) {
return ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${bookId}/audio-progress`,
method: 'PUT',
url: `/api/v1/audiobooks/${bookId}/playback-state`,
headers: authHeader(reader.accessToken),
payload: {
percentage,
currentFileId: audioFileId,
positionSeconds: percentage * 100,
assetId,
positionMs: percentage * 100_000,
capturedAt: new Date().toISOString(),
operationId: randomUUID(),
baseRevision: playbackRevision,
manifestRevision,
},
});
}
@@ -100,8 +106,17 @@ describe('Dashboard continue-listening scroller (e2e)', { timeout: SCENARIO_TIME
])
.returning({ id: schema.bookFiles.id, format: schema.bookFiles.format });
const epubFileId = files.find((file) => file.format === 'epub')!.id;
audioFileId = files.find((file) => file.format === 'm4b')!.id;
await ctx.db.update(schema.books).set({ primaryFileId: epubFileId }).where(eq(schema.books.id, bookId));
const manifestResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/audiobooks/${bookId}/manifest`,
headers: authHeader(reader.accessToken),
});
expect(manifestResponse.statusCode).toBe(200);
const manifest = manifestResponse.json<AudiobookManifest>();
assetId = manifest.assets[0]!.assetId;
manifestRevision = manifest.revision;
});
afterAll(async () => {
@@ -111,13 +126,14 @@ describe('Dashboard continue-listening scroller (e2e)', { timeout: SCENARIO_TIME
it('removes a multi-format audiobook when progress crosses its configured finish threshold', async () => {
const started = await saveAudioProgress(50);
expect(started.statusCode).toBe(204);
expect(started.statusCode).toBe(200);
playbackRevision = started.json<AudiobookPlaybackState>().revision;
await expect(getReadStatus()).resolves.toBe('reading');
await expect(getContinueListeningIds()).resolves.toContain(bookId);
const finished = await saveAudioProgress(99);
expect(finished.statusCode).toBe(204);
expect(finished.statusCode).toBe(200);
await expect(getReadStatus()).resolves.toBe('read');
await expect(getContinueListeningIds()).resolves.not.toContain(bookId);
});
@@ -1,7 +1,7 @@
{
"totalRoutes": 457,
"totalRoutes": 464,
"byPermission": {
"(none)": 168,
"(none)": 175,
"Permission.ManageMetadataConfig": 34,
"Permission.EmailSend": 30,
"Permission.LibraryEditMetadata": 35,
@@ -264,11 +264,11 @@
"useGuards": []
},
{
"file": "server/src/modules/book/book.controller.ts",
"className": "BookController",
"methodName": "getAudioProgress",
"file": "server/src/modules/audiobook/audiobook.controller.ts",
"className": "AudiobookController",
"methodName": "getManifest",
"httpMethod": "GET",
"path": "/books/:id/audio-progress",
"path": "/audiobooks/:bookId/manifest",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
@@ -276,11 +276,95 @@
"useGuards": []
},
{
"file": "server/src/modules/book/book.controller.ts",
"className": "BookController",
"methodName": "saveAudioProgress",
"file": "server/src/modules/audiobook/audiobook.controller.ts",
"className": "AudiobookController",
"methodName": "serveAsset",
"httpMethod": "GET",
"path": "/audiobooks/:bookId/assets/:assetId/content",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/audiobook/audiobook.controller.ts",
"className": "AudiobookController",
"methodName": "getPlaybackState",
"httpMethod": "GET",
"path": "/audiobooks/:bookId/playback-state",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/audiobook/audiobook.controller.ts",
"className": "AudiobookController",
"methodName": "putPlaybackState",
"httpMethod": "PUT",
"path": "/audiobooks/:bookId/playback-state",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/audiobook/audiobook.controller.ts",
"className": "AudiobookController",
"methodName": "deletePlaybackState",
"httpMethod": "DELETE",
"path": "/audiobooks/:bookId/playback-state",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/audiobook/audiobook.controller.ts",
"className": "AudiobookController",
"methodName": "listBookmarks",
"httpMethod": "GET",
"path": "/audiobooks/:bookId/bookmarks",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/audiobook/audiobook.controller.ts",
"className": "AudiobookController",
"methodName": "createBookmark",
"httpMethod": "POST",
"path": "/audiobooks/:bookId/bookmarks",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/audiobook/audiobook.controller.ts",
"className": "AudiobookController",
"methodName": "updateBookmark",
"httpMethod": "PATCH",
"path": "/books/:id/audio-progress",
"path": "/audiobooks/:bookId/bookmarks/:bookmarkId",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/audiobook/audiobook.controller.ts",
"className": "AudiobookController",
"methodName": "deleteBookmark",
"httpMethod": "DELETE",
"path": "/audiobooks/:bookId/bookmarks/:bookmarkId",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
@@ -48,7 +48,6 @@
"GET /book-duplicates/scans/active",
"GET /books/:id|:bookId",
"GET /books/:id|:bookId/annotations",
"GET /books/:id|:bookId/audio-progress",
"GET /books/:id|:bookId/author-books",
"GET /books/:id|:bookId/bookmarks",
"GET /books/:id|:bookId/cover",
@@ -152,7 +151,6 @@
"PATCH /auth/magic-links/:id",
"PATCH /books/:id|:bookId/added-at",
"PATCH /books/:id|:bookId/annotations/:annotationId",
"PATCH /books/:id|:bookId/audio-progress",
"PATCH /books/:id|:bookId/metadata",
"PATCH /books/:id|:bookId/metadata-and-locks",
"PATCH /books/:id|:bookId/metadata-locks",
+99 -47
View File
@@ -3,7 +3,7 @@ import { mkdir, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import { and, eq, inArray } from 'drizzle-orm';
import { EPUB_READER_DEFAULTS } from '@bookorbit/types';
import { EPUB_READER_DEFAULTS, type AudiobookManifest } from '@bookorbit/types';
import * as schema from '../src/db/schema';
import { createEpubFixture, writeFixtureFile } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
@@ -85,6 +85,11 @@ describe('Reader state isolation (e2e)', { timeout: 120_000 }, () => {
sharedAudioB = await locateBookByAbsolutePath(ctx, sharedAudioBPath);
hiddenAudio = await locateBookByAbsolutePath(ctx, hiddenAudioPath);
await ctx.db
.update(schema.bookFiles)
.set({ durationSeconds: 1_000 })
.where(inArray(schema.bookFiles.id, [sharedAudioA.bookFileId, sharedAudioB.bookFileId, hiddenAudio.bookFileId]));
userA = await createUserAndLogin(ctx);
userB = await createUserAndLogin(ctx);
outsider = await createUserAndLogin(ctx);
@@ -346,8 +351,8 @@ describe('Reader state isolation (e2e)', { timeout: 120_000 }, () => {
});
});
describe('progress and audio-progress ownership', () => {
it('keeps file progress and audio progress isolated per user', async () => {
describe('progress and audiobook playback-state ownership', () => {
it('keeps file progress and audiobook playback state isolated per user', async () => {
const userASaveFileProgress = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/files/${sharedEpub.bookFileId}/progress`,
@@ -392,83 +397,124 @@ describe('Reader state isolation (e2e)', { timeout: 120_000 }, () => {
percentage: 75.5,
});
const manifestResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/audiobooks/${sharedAudioA.bookId}/manifest`,
headers: authHeader(userA.accessToken),
});
expect(manifestResponse.statusCode).toBe(200);
const manifest = manifestResponse.json<AudiobookManifest>();
const assetId = manifest.assets[0]!.assetId;
const userASaveAudioProgress = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${sharedAudioA.bookId}/audio-progress`,
method: 'PUT',
url: `/api/v1/audiobooks/${sharedAudioA.bookId}/playback-state`,
headers: authHeader(userA.accessToken),
payload: {
percentage: 22.5,
currentFileId: sharedAudioA.bookFileId,
positionSeconds: 135,
assetId,
positionMs: 225_000,
capturedAt: '2026-01-10T10:00:00.000Z',
operationId: randomUUID(),
baseRevision: 0,
manifestRevision: manifest.revision,
},
});
expect(userASaveAudioProgress.statusCode).toBe(204);
expect(userASaveAudioProgress.statusCode).toBe(200);
const userBSaveAudioProgress = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${sharedAudioA.bookId}/audio-progress`,
method: 'PUT',
url: `/api/v1/audiobooks/${sharedAudioA.bookId}/playback-state`,
headers: authHeader(userB.accessToken),
payload: {
percentage: 44.4,
currentFileId: sharedAudioA.bookFileId,
positionSeconds: 274,
assetId,
positionMs: 444_000,
capturedAt: '2026-01-10T10:01:00.000Z',
operationId: randomUUID(),
baseRevision: 0,
manifestRevision: manifest.revision,
},
});
expect(userBSaveAudioProgress.statusCode).toBe(204);
expect(userBSaveAudioProgress.statusCode).toBe(200);
const userAGetAudioProgress = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${sharedAudioA.bookId}/audio-progress`,
url: `/api/v1/audiobooks/${sharedAudioA.bookId}/playback-state`,
headers: authHeader(userA.accessToken),
});
expect(userAGetAudioProgress.statusCode).toBe(200);
expect(userAGetAudioProgress.json()).toMatchObject({
userId: userA.userId,
bookId: sharedAudioA.bookId,
currentFileId: sharedAudioA.bookFileId,
assetId,
percentage: 22.5,
positionSeconds: 135,
positionMs: 225_000,
revision: 1,
manifestRevision: manifest.revision,
});
const userBGetAudioProgress = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${sharedAudioA.bookId}/audio-progress`,
url: `/api/v1/audiobooks/${sharedAudioA.bookId}/playback-state`,
headers: authHeader(userB.accessToken),
});
expect(userBGetAudioProgress.statusCode).toBe(200);
expect(userBGetAudioProgress.json()).toMatchObject({
userId: userB.userId,
bookId: sharedAudioA.bookId,
currentFileId: sharedAudioA.bookFileId,
assetId,
percentage: 44.4,
positionSeconds: 274,
positionMs: 444_000,
revision: 1,
manifestRevision: manifest.revision,
});
});
it('rejects invalid currentFileId bindings for audio progress', async () => {
it('rejects asset IDs that do not belong to the audiobook', async () => {
const [manifestAResponse, manifestBResponse] = await Promise.all([
ctx.app.inject({
method: 'GET',
url: `/api/v1/audiobooks/${sharedAudioA.bookId}/manifest`,
headers: authHeader(userA.accessToken),
}),
ctx.app.inject({
method: 'GET',
url: `/api/v1/audiobooks/${sharedAudioB.bookId}/manifest`,
headers: authHeader(userA.accessToken),
}),
]);
expect(manifestAResponse.statusCode).toBe(200);
expect(manifestBResponse.statusCode).toBe(200);
const manifestA = manifestAResponse.json<AudiobookManifest>();
const manifestB = manifestBResponse.json<AudiobookManifest>();
const basePayload = {
positionMs: 33_000,
capturedAt: '2026-01-10T10:00:00.000Z',
operationId: randomUUID(),
baseRevision: 0,
manifestRevision: manifestA.revision,
};
const crossBookResponse = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${sharedAudioA.bookId}/audio-progress`,
method: 'PUT',
url: `/api/v1/audiobooks/${sharedAudioA.bookId}/playback-state`,
headers: authHeader(userA.accessToken),
payload: {
percentage: 50,
currentFileId: sharedAudioB.bookFileId,
positionSeconds: 33,
...basePayload,
assetId: manifestB.assets[0]!.assetId,
},
});
expect(crossBookResponse.statusCode).toBe(400);
const [hiddenFile] = await ctx.db
.select({ publicId: schema.bookFiles.publicId })
.from(schema.bookFiles)
.where(eq(schema.bookFiles.id, hiddenAudio.bookFileId));
const inaccessibleFileResponse = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${sharedAudioA.bookId}/audio-progress`,
method: 'PUT',
url: `/api/v1/audiobooks/${sharedAudioA.bookId}/playback-state`,
headers: authHeader(userA.accessToken),
payload: {
percentage: 50,
currentFileId: hiddenAudio.bookFileId,
positionSeconds: 33,
...basePayload,
operationId: randomUUID(),
assetId: `aud_${hiddenFile!.publicId}`,
},
});
expect(inaccessibleFileResponse.statusCode).toBe(403);
expect(inaccessibleFileResponse.statusCode).toBe(400);
});
});
@@ -591,13 +637,16 @@ describe('Reader state isolation (e2e)', { timeout: 120_000 }, () => {
expect(sessionResponse.statusCode).toBe(403);
const audioProgressResponse = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${sharedAudioA.bookId}/audio-progress`,
method: 'PUT',
url: `/api/v1/audiobooks/${sharedAudioA.bookId}/playback-state`,
headers: authHeader(outsider.accessToken),
payload: {
percentage: 55,
currentFileId: sharedAudioA.bookFileId,
positionSeconds: 50,
assetId: `aud_${randomUUID()}`,
positionMs: 50_000,
capturedAt: '2026-02-01T12:00:00.000Z',
operationId: randomUUID(),
baseRevision: 0,
manifestRevision: '0'.repeat(64),
},
});
expect(audioProgressResponse.statusCode).toBe(403);
@@ -652,13 +701,16 @@ describe('Reader state isolation (e2e)', { timeout: 120_000 }, () => {
expect(invalidProgress.statusCode).toBe(400);
const invalidAudioProgress = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${sharedAudioA.bookId}/audio-progress`,
method: 'PUT',
url: `/api/v1/audiobooks/${sharedAudioA.bookId}/playback-state`,
headers: authHeader(userA.accessToken),
payload: {
percentage: 15,
currentFileId: 0,
positionSeconds: 30,
assetId: 'invalid',
positionMs: -1,
capturedAt: 'not-a-date',
operationId: 'invalid',
baseRevision: -1,
manifestRevision: 'invalid',
},
});
expect(invalidAudioProgress.statusCode).toBe(400);