mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): add dedicated audiobook playback API
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user