feat(books): add an audiobook chapter editor

Add a Chapters tab to edit chapter timing and titles, look chapters up on Audible or in the ebook,
snap starts to pauses, keep a version history and write chapters into M4B, M4A and MP3 files. Add a
chapter health tool with a bulk Audible lookup queue. Editing needs Editor access to the book's
library. Chapter edits keep released iOS playback working by accepting a stale manifest revision
while the track layout is unchanged.

Ref #1120
This commit is contained in:
neon
2026-10-22 03:25:48 +00:00
parent 5ba5352e3e
commit d21c68d13c
180 changed files with 48279 additions and 299 deletions
@@ -0,0 +1,407 @@
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import {
BOOK_CHAPTERS_ERROR_CODES,
type AudiobookManifest,
type AudiobookPlaybackState,
type BookChaptersState,
type BookChapterVersion,
type BookChapterVersionSummary,
type BookDetail,
} from '@bookorbit/types';
import { computeAudiobookManifestRevision, orderAudiobookTracks, resolveStoredAudiobookChapters } from '../src/common/utils/audiobook-chapters.utils';
import { MigrationImportRepository } from '../src/modules/migration/executor/migration-import.repository';
import * as schema from '../src/db/schema';
import {
authHeader,
closeAuthorizationMatrixE2EContext,
createAuthorizationMatrixE2EContext,
createLibraryWithFolder,
createUserAndLogin,
grantLibraryAccess,
type AuthorizationMatrixE2EContext,
type CreatedLibrary,
type TestUserSession,
} from './e2e/authorization-matrix/authorization-matrix-harness';
const SEEDED_CHAPTERS = [
{ title: 'Part 1', startMs: 0 },
{ title: 'Part 2', startMs: 60_000 },
];
// Released iOS treats a 412 from playback-state as permanent: a download or a Watch transfer keeps
// sending the manifest revision it was made with. These cases pin which changes it survives.
describe('Audiobook chapter changes and manifest revisions (e2e)', { timeout: 60_000 }, () => {
let ctx!: AuthorizationMatrixE2EContext;
let library!: CreatedLibrary;
let listener!: TestUserSession;
let bookId!: number;
let secondFileId!: number;
async function getManifest(targetBookId = bookId): Promise<AudiobookManifest> {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/audiobooks/${targetBookId}/manifest`,
headers: authHeader(listener.accessToken),
});
expect(response.statusCode).toBe(200);
return response.json<AudiobookManifest>();
}
async function putPlayback(manifest: AudiobookManifest, manifestRevision: string, targetBookId = bookId) {
const current = await ctx.app.inject({
method: 'GET',
url: `/api/v1/audiobooks/${targetBookId}/playback-state`,
headers: authHeader(listener.accessToken),
});
const baseRevision = current.statusCode === 200 && current.body ? (current.json<AudiobookPlaybackState | null>()?.revision ?? 0) : 0;
return ctx.app.inject({
method: 'PUT',
url: `/api/v1/audiobooks/${targetBookId}/playback-state`,
headers: authHeader(listener.accessToken),
payload: {
assetId: manifest.assets[0]!.assetId,
positionMs: 5_000,
capturedAt: new Date().toISOString(),
operationId: randomUUID(),
baseRevision,
manifestRevision,
},
});
}
async function patchChapters(chapters: Array<{ title: string; startMs: number }>) {
return ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${bookId}/metadata`,
headers: authHeader(ctx.adminToken),
payload: { audioMetadata: { chapters } },
});
}
/** Two tracks of 60s and 120s whose stored chapters came from the files. */
async function seedAudiobook(title: string): Promise<{ bookId: number; fileIds: number[]; filePaths: string[] }> {
const slug = `chapter-revision-${randomUUID()}`;
const [book] = await ctx.db
.insert(schema.books)
.values({
libraryId: library.libraryId,
libraryFolderId: library.libraryFolderId,
folderPath: `${library.folderPath}/${slug}`,
status: 'present',
})
.returning({ id: schema.books.id });
const seededBookId = book!.id;
await ctx.db.insert(schema.bookMetadata).values({ bookId: seededBookId, title, chapters: SEEDED_CHAPTERS, chaptersSource: 'embedded' });
const filePaths = [1, 2].map((part) => `${library.folderPath}/${slug}/Part ${part}.mp3`);
const files = await ctx.db
.insert(schema.bookFiles)
.values(
[1, 2].map((part) => ({
bookId: seededBookId,
libraryFolderId: library.libraryFolderId,
absolutePath: filePaths[part - 1]!,
relPath: `${slug}/Part ${part}.mp3`,
ino: BigInt(part),
sizeBytes: 1_000 * part,
format: 'mp3',
role: 'content',
sortOrder: part,
durationSeconds: 60 * part,
})),
)
.returning({ id: schema.bookFiles.id });
await ctx.db.update(schema.books).set({ primaryFileId: files[0]!.id }).where(eq(schema.books.id, seededBookId));
return { bookId: seededBookId, fileIds: files.map((file) => file.id), filePaths };
}
beforeAll(async () => {
ctx = await createAuthorizationMatrixE2EContext();
library = await createLibraryWithFolder(ctx, { name: `chapter-revision-${randomUUID()}` });
listener = await createUserAndLogin(ctx);
await grantLibraryAccess(ctx, listener.userId, library.libraryId);
const seeded = await seedAudiobook('Chapter Revision Regression');
bookId = seeded.bookId;
secondFileId = seeded.fileIds[1]!;
});
afterAll(async () => {
await closeAuthorizationMatrixE2EContext(ctx);
});
it('accepts exactly one concurrent editor save and records only accepted history', async () => {
const seeded = await seedAudiobook('Concurrent edits');
const loaded = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${seeded.bookId}/chapters`, headers: authHeader(ctx.adminToken) });
const revision = loaded.json<BookChaptersState>().revision;
const responses = await Promise.all(
['Edit A', 'Edit B'].map((title) =>
ctx.app.inject({
method: 'PUT',
url: `/api/v1/books/${seeded.bookId}/chapters`,
headers: authHeader(ctx.adminToken),
payload: { chapters: [{ title, startMs: 0 }], baseRevision: revision },
}),
),
);
expect(responses.map((response) => response.statusCode).sort()).toEqual([200, 409]);
const versions = await ctx.db.select().from(schema.bookChapterVersions).where(eq(schema.bookChapterVersions.bookId, seeded.bookId));
expect(versions).toHaveLength(2);
expect(versions.some((version) => version.source === 'embedded')).toBe(true);
});
it('invalidates editor drafts when track order changes', async () => {
const seeded = await seedAudiobook('Reordered audio');
const load = () => ctx.app.inject({ method: 'GET', url: `/api/v1/books/${seeded.bookId}/chapters`, headers: authHeader(ctx.adminToken) });
const first = (await load()).json<BookChaptersState>();
await ctx.db.update(schema.bookFiles).set({ sortOrder: 5 }).where(eq(schema.bookFiles.id, seeded.fileIds[0]));
expect((await load()).json<BookChaptersState>().revision).not.toBe(first.revision);
const save = await ctx.app.inject({
method: 'PUT',
url: `/api/v1/books/${seeded.bookId}/chapters`,
headers: authHeader(ctx.adminToken),
payload: { chapters: SEEDED_CHAPTERS, baseRevision: first.revision },
});
expect(save.statusCode).toBe(409);
});
it('keeps the first downloaded layout valid after more than 100 chapter edits', async () => {
const seeded = await seedAudiobook('Long lived download');
const before = await getManifest(seeded.bookId);
for (let index = 0; index < 105; index += 1) {
const response = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${seeded.bookId}/metadata`,
headers: authHeader(ctx.adminToken),
payload: { audioMetadata: { chapters: [{ title: `Edit ${index}`, startMs: 0 }] } },
});
expect(response.statusCode).toBe(200);
}
expect((await putPlayback(before, before.revision, seeded.bookId)).statusCode).toBe(200);
expect(
await ctx.db.select().from(schema.audiobookManifestRevisions).where(eq(schema.audiobookManifestRevisions.bookId, seeded.bookId)),
).toHaveLength(1);
});
it('preserves an unregistered pre-upgrade manifest before a migration replaces chapters', async () => {
const seeded = await seedAudiobook('Legacy imported download');
const files = await ctx.db.select().from(schema.bookFiles).where(eq(schema.bookFiles.bookId, seeded.bookId));
const legacy = computeAudiobookManifestRevision(orderAudiobookTracks(files), resolveStoredAudiobookChapters(SEEDED_CHAPTERS, files) ?? []);
const migration = ctx.app.get(MigrationImportRepository);
await migration.upsertBookMetadata(seeded.bookId, { chapters: [{ title: 'Imported chapters', startMs: 0 }], chaptersSource: 'migration' });
const manifest = await getManifest(seeded.bookId);
expect((await putPlayback(manifest, legacy, seeded.bookId)).statusCode).toBe(200);
const versions = await ctx.db.select().from(schema.bookChapterVersions).where(eq(schema.bookChapterVersions.bookId, seeded.bookId));
expect(versions.map((version) => version.chapters)).toContainEqual(SEEDED_CHAPTERS);
});
it('keeps a client on the pre-edit manifest syncing after its chapters are edited', async () => {
const before = await getManifest();
expect((await putPlayback(before, before.revision)).statusCode).toBe(200);
const edited = await patchChapters([
{ title: 'Two', startMs: 60_000 },
{ title: '', startMs: 0 },
]);
expect(edited.statusCode).toBe(200);
expect(edited.json<BookDetail>().audioMetadata).toMatchObject({
chapters: [
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Two', startMs: 60_000 },
],
chaptersSource: 'manual',
});
const after = await getManifest();
expect(after.revision).not.toBe(before.revision);
expect(after.schemaVersion).toBe(2);
const stale = await putPlayback(before, before.revision);
expect(stale.statusCode).toBe(200);
expect(stale.json<AudiobookPlaybackState>().manifestRevision).toBe(after.revision);
});
it('keeps a bookmark whose chapter id came from the previous manifest', async () => {
const before = await getManifest();
expect((await patchChapters([{ title: 'Only', startMs: 0 }])).statusCode).toBe(200);
const created = await ctx.app.inject({
method: 'POST',
url: `/api/v1/audiobooks/${bookId}/bookmarks`,
headers: authHeader(listener.accessToken),
payload: { clientId: randomUUID(), positionMs: 3_000, title: 'Stale chapter', chapterId: before.chapters[0]!.id },
});
expect(created.statusCode).toBeLessThan(300);
expect(created.json<{ chapterId: string | null }>().chapterId).toBeNull();
});
it('refuses a manual chapter write once chapters are locked', async () => {
const lock = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${bookId}/metadata-locks`,
headers: authHeader(ctx.adminToken),
payload: { lockedFields: ['chapters'] },
});
expect(lock.statusCode).toBe(200);
expect((await patchChapters([{ title: 'Blocked', startMs: 0 }])).statusCode).toBe(409);
const unlock = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${bookId}/metadata-locks`,
headers: authHeader(ctx.adminToken),
payload: { lockedFields: [] },
});
expect(unlock.statusCode).toBe(200);
});
it('still refuses a revision once a track itself changed length', async () => {
const before = await getManifest();
await ctx.db.update(schema.bookFiles).set({ durationSeconds: 125 }).where(eq(schema.bookFiles.id, secondFileId));
const stale = await putPlayback(before, before.revision);
expect(stale.statusCode).toBe(412);
});
describe('chapter editor', () => {
let editorBookId!: number;
let editorFilePaths!: string[];
const readEmbeddedChapters = vi.fn();
function editorRequest(method: 'GET' | 'PUT' | 'POST', path: string, token: string, payload?: Record<string, unknown>) {
return ctx.app.inject({ method, url: `/api/v1/books/${editorBookId}/chapters${path}`, headers: authHeader(token), payload });
}
async function loadState(token = ctx.adminToken): Promise<BookChaptersState> {
const response = await editorRequest('GET', '', token);
expect(response.statusCode).toBe(200);
return response.json<BookChaptersState>();
}
beforeAll(async () => {
const seeded = await seedAudiobook('Chapter Editor');
editorBookId = seeded.bookId;
editorFilePaths = seeded.filePaths;
// The harness replaces MetadataService wholesale, so reading the audio files is stubbed here.
Object.assign(ctx.metadataMock, { readEmbeddedChapters });
});
it('lets a listener read the chapter state but not change it', async () => {
const state = await loadState(listener.accessToken);
expect(state).toMatchObject({
chapters: SEEDED_CHAPTERS,
source: 'embedded',
derived: false,
locked: false,
updatedBy: null,
totalDurationMs: 180_000,
tracks: [
{ name: 'Part 1.mp3', startMs: 0, durationMs: 60_000 },
{ name: 'Part 2.mp3', startMs: 60_000, durationMs: 120_000 },
],
versionCount: 0,
});
expect(state.revision).toMatch(/^[0-9a-f]{64}$/);
const save = await editorRequest('PUT', '', listener.accessToken, { chapters: SEEDED_CHAPTERS, baseRevision: state.revision });
expect(save.statusCode).toBe(403);
expect((await editorRequest('GET', '/versions', listener.accessToken)).statusCode).toBe(403);
expect((await editorRequest('POST', '/revert', listener.accessToken, { baseRevision: state.revision })).statusCode).toBe(403);
});
it('saves against the loaded revision, keeps the original as a version, and keeps older manifests syncing', async () => {
const manifestBefore = await getManifest(editorBookId);
const initial = await loadState();
const saved = await editorRequest('PUT', '', ctx.adminToken, {
chapters: [
{ title: ' Opening ', startMs: 0 },
{ title: 'Middle', startMs: 90_000 },
],
baseRevision: initial.revision,
});
expect(saved.statusCode).toBe(200);
const state = saved.json<BookChaptersState>();
expect(state).toMatchObject({
chapters: [
{ title: 'Opening', startMs: 0 },
{ title: 'Middle', startMs: 90_000 },
],
source: 'manual',
derived: false,
locked: true,
updatedBy: { id: expect.any(Number), name: expect.any(String) },
versionCount: 2,
});
expect(state.revision).not.toBe(initial.revision);
expect(await loadState()).toEqual(state);
const stale = await editorRequest('PUT', '', ctx.adminToken, { chapters: SEEDED_CHAPTERS, baseRevision: initial.revision });
expect(stale.statusCode).toBe(409);
expect(stale.json()).toMatchObject({ errorCode: BOOK_CHAPTERS_ERROR_CODES.stale });
const descending = await editorRequest('PUT', '', ctx.adminToken, {
chapters: [
{ title: 'One', startMs: 0 },
{ title: 'Two', startMs: 50_000 },
{ title: 'Three', startMs: 40_000 },
],
baseRevision: state.revision,
});
expect(descending.statusCode).toBe(400);
expect(descending.json()).toMatchObject({ errorCode: BOOK_CHAPTERS_ERROR_CODES.notAscending, errorMeta: { index: 2 } });
const versions = await editorRequest('GET', '/versions', ctx.adminToken);
expect(versions.statusCode).toBe(200);
const [edit, original] = versions.json<BookChapterVersionSummary[]>();
expect(versions.json<BookChapterVersionSummary[]>()).toHaveLength(2);
expect(edit).toMatchObject({ source: 'manual', chapterCount: 2, createdBy: state.updatedBy });
expect(original).toMatchObject({ source: 'embedded', chapterCount: 2, createdBy: null });
const originalVersion = await editorRequest('GET', `/versions/${original!.id}`, ctx.adminToken);
expect(originalVersion.statusCode).toBe(200);
expect(originalVersion.json<BookChapterVersion>().chapters).toEqual(SEEDED_CHAPTERS);
expect((await editorRequest('GET', '/versions/999999999', ctx.adminToken)).statusCode).toBe(404);
const playback = await putPlayback(manifestBefore, manifestBefore.revision, editorBookId);
expect(playback.statusCode).toBe(200);
expect(playback.json<AudiobookPlaybackState>().manifestRevision).toBe((await getManifest(editorBookId)).revision);
});
it('reverts to the chapters the files carry and unlocks, refusing when a file cannot be read', async () => {
const before = await loadState();
expect(before.locked).toBe(true);
const stale = await editorRequest('POST', '/revert', ctx.adminToken, { baseRevision: '0'.repeat(64) });
expect(stale.statusCode).toBe(409);
expect(stale.json()).toMatchObject({ errorCode: BOOK_CHAPTERS_ERROR_CODES.stale });
readEmbeddedChapters.mockResolvedValueOnce(null);
const unreadable = await editorRequest('POST', '/revert', ctx.adminToken, { baseRevision: before.revision });
expect(unreadable.statusCode).toBe(409);
expect(unreadable.json()).toMatchObject({ errorCode: BOOK_CHAPTERS_ERROR_CODES.filesUnreadable });
expect(await loadState()).toEqual(before);
const fromFiles = [
{ title: 'File One', startMs: 0 },
{ title: 'File Two', startMs: 60_000 },
{ title: 'File Three', startMs: 120_000 },
];
readEmbeddedChapters.mockResolvedValueOnce(fromFiles);
const reverted = await editorRequest('POST', '/revert', ctx.adminToken, { baseRevision: before.revision });
expect(reverted.statusCode).toBe(200);
expect(readEmbeddedChapters).toHaveBeenLastCalledWith(editorFilePaths, editorBookId);
expect(reverted.json<BookChaptersState>()).toMatchObject({
chapters: fromFiles,
source: 'embedded',
locked: false,
updatedBy: null,
versionCount: 3,
});
});
});
});
@@ -0,0 +1,452 @@
import { execFile as execFileCallback } from 'child_process';
import { randomUUID } from 'crypto';
import { mkdir, stat, writeFile } from 'fs/promises';
import { createRequire } from 'module';
import { dirname, join } from 'path';
import { promisify } from 'util';
import { eq } from 'drizzle-orm';
import { BOOK_CHAPTERS_ERROR_CODES, type BookChaptersState, type WriteBookChaptersResult } from '@bookorbit/types';
import { FileLockService, bookOperationLockKey } from '../src/common/services/file-lock.service';
import { computeAudiobookManifestRevision, orderAudiobookTracks } from '../src/common/utils/audiobook-chapters.utils';
import type { AudiobookManifest } from '@bookorbit/types';
import * as schema from '../src/db/schema';
import { MetadataService } from '../src/modules/metadata/metadata.service';
import { AudiobookChaptersService } from '../src/modules/audiobook-chapters/audiobook-chapters.service';
import { ChapterJobService } from '../src/modules/chapter-editor/chapter-job.service';
import { type BookChapterJobState } from '@bookorbit/types';
import { mergeAudioChapters } from '../src/modules/metadata/extractors/audio-chapter-merge';
import {
authHeader,
closeAuthorizationMatrixE2EContext,
createAuthorizationMatrixE2EContext,
createLibraryWithFolder,
type AuthorizationMatrixE2EContext,
type CreatedLibrary,
} from './e2e/authorization-matrix/authorization-matrix-harness';
const execFile = promisify(execFileCallback);
const requireInstaller = createRequire(__filename);
const FFMPEG = (requireInstaller('@ffmpeg-installer/ffmpeg') as { path: string }).path;
const FFPROBE = (requireInstaller('@ffprobe-installer/ffprobe') as { path: string }).path;
interface ProbedChapter {
title: string;
startMs: number;
endMs: number;
}
async function probe(absolutePath: string): Promise<{ durationMs: number; chapters: ProbedChapter[]; dataStreams: number }> {
const { stdout } = await execFile(FFPROBE, [
'-v',
'error',
'-print_format',
'json',
'-show_format',
'-show_chapters',
'-show_streams',
absolutePath,
]);
const data = JSON.parse(stdout) as {
format: { duration: string };
chapters: { start_time: string; end_time: string; tags?: { title?: string } }[];
streams: { codec_type: string }[];
};
return {
durationMs: Math.round(Number(data.format.duration) * 1000),
chapters: data.chapters.map((chapter) => ({
title: chapter.tags?.title ?? '',
startMs: Math.round(Number(chapter.start_time) * 1000),
endMs: Math.round(Number(chapter.end_time) * 1000),
})),
dataStreams: data.streams.filter((stream) => stream.codec_type === 'data').length,
};
}
/** A tone of the given length, in the format its extension names. */
async function makeAudio(absolutePath: string, seconds: number, chapters: { title: string; startMs: number; endMs: number }[] = []): Promise<void> {
await mkdir(dirname(absolutePath), { recursive: true });
const args = ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', `sine=frequency=440:sample_rate=22050:duration=${seconds}`];
if (chapters.length > 0) {
const metadataPath = `${absolutePath}.ffmeta`;
const lines = [
';FFMETADATA1',
...chapters.flatMap((chapter) => [
'[CHAPTER]',
'TIMEBASE=1/1000',
`START=${chapter.startMs}`,
`END=${chapter.endMs}`,
`title=${chapter.title}`,
]),
];
await writeFile(metadataPath, `${lines.join('\n')}\n`);
args.push('-f', 'ffmetadata', '-i', metadataPath, '-map', '0', '-map_chapters', '1');
}
args.push('-ac', '1', ...(absolutePath.endsWith('.mp3') ? ['-c:a', 'libmp3lame'] : ['-c:a', 'aac']), absolutePath);
await execFile(FFMPEG, args);
}
// Writes real files with real ffmpeg: the chapters a book is saved with land in each audio file on
// that file's own clock, and reading the files back gives the same book.
describe('Writing audiobook chapters into the audio files (e2e)', { timeout: 120_000 }, () => {
let ctx!: AuthorizationMatrixE2EContext;
let library!: CreatedLibrary;
async function getState(bookId: number): Promise<BookChaptersState> {
const response = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}/chapters`, headers: authHeader(ctx.adminToken) });
expect(response.statusCode).toBe(200);
return response.json<BookChaptersState>();
}
async function save(bookId: number, chapters: { title: string; startMs: number }[]): Promise<BookChaptersState> {
const response = await ctx.app.inject({
method: 'PUT',
url: `/api/v1/books/${bookId}/chapters`,
headers: authHeader(ctx.adminToken),
payload: { chapters, baseRevision: (await getState(bookId)).revision },
});
expect(response.statusCode).toBe(200);
return response.json<BookChaptersState>();
}
function write(bookId: number, baseRevision: string) {
return ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${bookId}/chapters/write`,
headers: authHeader(ctx.adminToken),
payload: { baseRevision },
});
}
async function setWriteBack(enabled: boolean) {
await ctx.db
.update(schema.libraries)
.set({ fileWriteEnabled: enabled, fileWriteAudioEnabled: true, fileWriteWriteCover: false, fileRenameEnabled: false })
.where(eq(schema.libraries.id, library.libraryId));
}
beforeAll(async () => {
// The app finds its tools on PATH unless told otherwise; point it at the same binaries the
// fixtures are made with so the suite does not depend on what the machine has installed.
process.env.FFMPEG_PATH = FFMPEG;
process.env.FFPROBE_PATH = FFPROBE;
ctx = await createAuthorizationMatrixE2EContext();
library = await createLibraryWithFolder(ctx, { name: `chapter-write-${randomUUID()}` });
await setWriteBack(true);
});
afterAll(async () => {
await closeAuthorizationMatrixE2EContext(ctx);
});
it('splits the chapters across a multi-file book and reads back the same book', async () => {
const parts = [
{ name: 'Part 1.mp3', seconds: 20 },
{ name: 'Part 2.mp3', seconds: 20 },
{ name: 'Part 3.mp3', seconds: 20 },
];
const slugFolder = join(library.folderPath, `parts-${randomUUID()}`);
await Promise.all(parts.map((part) => makeAudio(join(slugFolder, part.name), part.seconds)));
const { bookId, paths } = await seedBookFrom(slugFolder, 'Three Parts', parts);
const saved = await save(bookId, [
{ title: 'Opening', startMs: 0 },
{ title: 'One', startMs: 5_000 },
{ title: 'Two', startMs: 30_000 },
{ title: 'Three', startMs: 45_000 },
]);
expect(saved.writeBack).toEqual({ available: true, reason: null, fileCount: 3 });
const starts = saved.tracks.map((track) => track.startMs!);
const response = await write(bookId, saved.revision);
expect(response.statusCode).toBe(200);
expect(response.json<WriteBookChaptersResult>()).toEqual({ status: 'success', filesWritten: 3, filesTotal: 3 });
const probed = await Promise.all(paths.map((path) => probe(path)));
expect(probed.map((file) => file.chapters.map(({ title, startMs }) => [title, startMs]))).toEqual([
[
['Opening', 0],
['One', 5_000],
],
// "One" is still playing when the second file opens, so that file starts by naming it.
[
['One', 0],
['Two', 30_000 - starts[1]!],
],
[
['Two', 0],
['Three', 45_000 - starts[2]!],
],
]);
probed.forEach((file) => expect(file.chapters.at(-1)!.endMs).toBeCloseTo(file.durationMs, -2));
// The e2e app stubs out metadata reading, so merge the files here with the scanner's own merge.
const readBack = mergeAudioChapters(
probed.map((file, index) => ({
absolutePath: paths[index]!,
chapters: file.chapters.map(({ title, startMs }) => ({ title, startMs })),
durationMs: file.durationMs,
})),
)!;
expect(readBack.map((chapter) => chapter.title)).toEqual(['Opening', 'One', 'Two', 'Three']);
readBack.forEach((chapter, index) => expect(Math.abs(chapter.startMs - saved.chapters[index]!.startMs)).toBeLessThan(250));
});
it('replaces the chapters of an M4B that already has some and keeps the rest of the file', async () => {
const folder = join(library.folderPath, `m4b-${randomUUID()}`);
const part = { name: 'Book.m4b', seconds: 30 };
await makeAudio(join(folder, part.name), part.seconds, [
{ title: 'Old A', startMs: 0, endMs: 15_000 },
{ title: 'Old B', startMs: 15_000, endMs: 30_000 },
]);
const { bookId, paths } = await seedBookFrom(folder, 'One M4B', [part]);
const saved = await save(bookId, [
{ title: 'New A', startMs: 0 },
{ title: 'New B', startMs: 10_000 },
{ title: 'New C', startMs: 20_000 },
]);
expect((await write(bookId, saved.revision)).json<WriteBookChaptersResult>().status).toBe('success');
const written = await probe(paths[0]!);
expect(written.chapters.map(({ title, startMs }) => [title, startMs])).toEqual([
['New A', 0],
['New B', 10_000],
['New C', 20_000],
]);
// A plain metadata write-back of an M4B with chapters used to fail copying the chapter track.
const metadataWrite = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${bookId}/write-and-rename`,
headers: authHeader(ctx.adminToken),
});
expect(metadataWrite.statusCode).toBe(201);
expect(metadataWrite.json<{ write: { status: string } }>().write.status).toBe('success');
// Write-and-rename may also move the file to the library's naming pattern.
const [file] = await ctx.db
.select({ absolutePath: schema.bookFiles.absolutePath })
.from(schema.bookFiles)
.where(eq(schema.bookFiles.bookId, bookId));
expect((await probe(file!.absolutePath)).chapters.map((chapter) => chapter.title)).toEqual(['New A', 'New B', 'New C']);
});
it('refuses a stale list, a library without write-back and a format that cannot hold chapters', async () => {
const folder = join(library.folderPath, `refusals-${randomUUID()}`);
const part = { name: 'Book.mp3', seconds: 10 };
await makeAudio(join(folder, part.name), part.seconds);
const { bookId } = await seedBookFrom(folder, 'Refusals', [part]);
const first = await save(bookId, [{ title: 'Only', startMs: 0 }]);
const second = await save(bookId, [
{ title: 'Only', startMs: 0 },
{ title: 'Later', startMs: 5_000 },
]);
const stale = await write(bookId, first.revision);
expect(stale.statusCode).toBe(409);
expect(stale.json<{ errorCode: string }>().errorCode).toBe(BOOK_CHAPTERS_ERROR_CODES.stale);
await setWriteBack(false);
try {
expect((await getState(bookId)).writeBack).toEqual({ available: false, reason: 'library_disabled', fileCount: 1 });
const disabled = await write(bookId, second.revision);
expect(disabled.statusCode).toBe(409);
expect(disabled.json<{ errorCode: string; errorMeta: { reason: string } }>()).toMatchObject({
errorCode: BOOK_CHAPTERS_ERROR_CODES.writeUnavailable,
errorMeta: { reason: 'library_disabled' },
});
} finally {
await setWriteBack(true);
}
const flacFolder = join(library.folderPath, `flac-${randomUUID()}`);
const flac = { name: 'Book.flac', seconds: 5 };
await mkdir(flacFolder, { recursive: true });
await execFile(FFMPEG, ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'sine=duration=5', join(flacFolder, flac.name)]);
const { bookId: flacBookId } = await seedBookFrom(flacFolder, 'Flac', [flac]);
expect((await getState(flacBookId)).writeBack).toEqual({ available: false, reason: 'format_not_supported', fileCount: 1 });
});
/** Seeds a book over files already made in `folder`, keeping their real sizes and lengths. */
it('roundtrips fractional file durations and a chapter that continues from zero using verified provenance', async () => {
const parts = [
{ name: 'Part 1.m4b', seconds: 20.4 },
{ name: 'Part 2.m4b', seconds: 20.4 },
];
const folder = join(library.folderPath, `fractional-${randomUUID()}`);
for (const part of parts) await makeAudio(join(folder, part.name), part.seconds);
const seeded = await seedBookFrom(folder, 'Fractional timeline', parts);
const saved = await save(seeded.bookId, [
{ title: 'One', startMs: 0 },
{ title: 'Two', startMs: 30000 },
]);
expect((await write(seeded.bookId, saved.revision)).json<WriteBookChaptersResult>()).toMatchObject({ status: 'success', filesWritten: 2 });
const actual = await MetadataService.prototype.readEmbeddedChapters.call(
{ audiobookChapters: ctx.app.get(AudiobookChaptersService) } as never,
seeded.paths,
seeded.bookId,
);
expect(actual).toEqual(saved.chapters);
});
it('retains the text following an interior carriage return when writing chapters', async () => {
const folder = join(library.folderPath, `carriage-${randomUUID()}`);
await makeAudio(join(folder, 'Part 1.m4b'), 10);
const seeded = await seedBookFrom(folder, 'Carriage return', [{ name: 'Part 1.m4b' }]);
const saved = await save(seeded.bookId, [{ title: 'Hello\rWorld', startMs: 0 }]);
expect((await write(seeded.bookId, saved.revision)).json<WriteBookChaptersResult>()).toMatchObject({ status: 'success' });
expect((await probe(seeded.paths[0])).chapters[0].title).toBe('Hello\nWorld');
});
it('refuses an old write snapshot when metadata changes while it waits for the book lock', async () => {
const folder = join(library.folderPath, `waiting-${randomUUID()}`);
await makeAudio(join(folder, 'Book.m4b'), 10);
const seeded = await seedBookFrom(folder, 'Queued snapshot', [{ name: 'Book.m4b' }]);
const saved = await save(seeded.bookId, [{ title: 'Before', startMs: 0 }]);
let release!: () => void;
let entered!: () => void;
const ready = new Promise<void>((resolve) => {
entered = resolve;
});
const blocker = ctx.app.get(FileLockService).withLock(bookOperationLockKey(seeded.bookId), async () => {
entered();
await new Promise<void>((resolve) => {
release = resolve;
});
});
await ready;
const response = write(seeded.bookId, saved.revision);
try {
await ctx.db
.update(schema.bookMetadata)
.set({ chapters: [{ title: 'After', startMs: 0 }], chaptersUpdatedAt: new Date() })
.where(eq(schema.bookMetadata.bookId, seeded.bookId));
} finally {
release();
}
await blocker;
expect((await response).statusCode).toBe(409);
expect((await probe(seeded.paths[0])).chapters).toEqual([]);
});
it('preserves an unregistered pre-upgrade revision before a harmless tag write', async () => {
const folder = join(library.folderPath, `legacy-${randomUUID()}`);
await makeAudio(join(folder, 'Book.m4b'), 10);
const seeded = await seedBookFrom(folder, 'Legacy offline tags', [{ name: 'Book.m4b' }]);
const files = await ctx.db.select().from(schema.bookFiles).where(eq(schema.bookFiles.bookId, seeded.bookId));
const legacy = computeAudiobookManifestRevision(orderAudiobookTracks(files), []);
const changed = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${seeded.bookId}/write-and-rename`,
headers: authHeader(ctx.adminToken),
});
expect(changed.json<{ write: { status: string } }>().write.status).toBe('success');
const manifest = (
await ctx.app.inject({ method: 'GET', url: `/api/v1/audiobooks/${seeded.bookId}/manifest`, headers: authHeader(ctx.adminToken) })
).json<AudiobookManifest>();
const playback = await ctx.app.inject({
method: 'PUT',
url: `/api/v1/audiobooks/${seeded.bookId}/playback-state`,
headers: authHeader(ctx.adminToken),
payload: {
assetId: manifest.assets[0].assetId,
positionMs: 2000,
capturedAt: new Date().toISOString(),
operationId: randomUUID(),
baseRevision: 0,
manifestRevision: legacy,
},
});
expect(playback.statusCode).toBe(200);
});
it('runs a recoverable chapter job once for a repeated revision request and exposes progress', async () => {
const folder = join(library.folderPath, `job-${randomUUID()}`);
await makeAudio(join(folder, 'Part 1.m4b'), 10);
const seeded = await seedBookFrom(folder, 'Async operation', [{ name: 'Part 1.m4b' }]);
const saved = await save(seeded.bookId, [{ title: 'First', startMs: 0 }]);
const start = () =>
ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${seeded.bookId}/chapters/jobs/write`,
headers: authHeader(ctx.adminToken),
payload: { baseRevision: saved.revision },
});
const first = (await start()).json<BookChapterJobState>();
expect((await start()).json<BookChapterJobState>().id).toBe(first.id);
const recovered = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${seeded.bookId}/chapters/jobs`,
headers: authHeader(ctx.adminToken),
});
expect(recovered.json<BookChapterJobState>().id).toBe(first.id);
await ctx.app.get(ChapterJobService).tick();
const status = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${seeded.bookId}/chapters/jobs/${first.id}`,
headers: authHeader(ctx.adminToken),
});
expect(status.json<BookChapterJobState>()).toMatchObject({ status: 'success', filesWritten: 1, filesTotal: 1, result: { status: 'success' } });
});
it('reads embedded chapters again after a completed job instead of caching them by editor revision', async () => {
const folder = join(library.folderPath, `read-job-${randomUUID()}`);
await makeAudio(join(folder, 'Book.m4b'), 10);
const seeded = await seedBookFrom(folder, 'Fresh embedded reads', [{ name: 'Book.m4b' }]);
const readChapters = vi
.fn()
.mockResolvedValueOnce([{ title: 'First read', startMs: 0 }])
.mockResolvedValueOnce([{ title: 'Fresh read', startMs: 0 }]);
Object.assign(ctx.metadataMock, { readEmbeddedChapters: readChapters });
const baseRevision = (await getState(seeded.bookId)).revision;
for (const title of ['First read', 'Fresh read']) {
const reading = (
await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${seeded.bookId}/chapters/jobs/read`,
headers: authHeader(ctx.adminToken),
payload: { baseRevision },
})
).json<BookChapterJobState>();
expect(reading.status).toBe('queued');
await ctx.app.get(ChapterJobService).tick();
const completed = (
await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${seeded.bookId}/chapters/jobs/${reading.id}`,
headers: authHeader(ctx.adminToken),
})
).json<BookChapterJobState>();
expect(completed).toMatchObject({ status: 'success', result: { chapters: [{ title, startMs: 0 }] } });
}
});
async function seedBookFrom(folder: string, title: string, parts: { name: string }[]) {
const relFolder = folder.slice(library.folderPath.length + 1);
const [book] = await ctx.db
.insert(schema.books)
.values({ libraryId: library.libraryId, libraryFolderId: library.libraryFolderId, folderPath: folder, status: 'present' })
.returning({ id: schema.books.id });
await ctx.db.insert(schema.bookMetadata).values({ bookId: book!.id, title });
const paths = parts.map((part) => join(folder, part.name));
const rows = [];
for (const [index, part] of parts.entries()) {
const { durationMs } = await probe(paths[index]!);
rows.push({
bookId: book!.id,
libraryFolderId: library.libraryFolderId,
absolutePath: paths[index]!,
relPath: `${relFolder}/${part.name}`,
ino: BigInt(Math.floor(Math.random() * 1e12)),
sizeBytes: (await stat(paths[index]!)).size,
format: part.name.split('.').pop()!,
role: 'content' as const,
sortOrder: index + 1,
durationSeconds: Math.round(durationMs / 1000),
});
}
const files = await ctx.db.insert(schema.bookFiles).values(rows).returning({ id: schema.bookFiles.id });
await ctx.db.update(schema.books).set({ primaryFileId: files[0]!.id }).where(eq(schema.books.id, book!.id));
return { bookId: book!.id, paths };
}
});
+699
View File
@@ -0,0 +1,699 @@
import { randomUUID } from 'crypto';
import { HttpException, HttpStatus } from '@nestjs/common';
import { eq, inArray } from 'drizzle-orm';
import {
BOOK_CHAPTERS_ERROR_CODES,
Permission,
type AudiobookChapter,
type AudiobookChapterSource,
type BookChaptersState,
type ChapterHealthPage,
type ChapterHealthSummary,
type ChapterLookupQueueState,
type ChapterLookupResult,
type ChapterLookupReviewSummary,
type EnqueueChapterLookupResult,
} from '@bookorbit/types';
import * as schema from '../src/db/schema';
import { ChapterLookupService } from '../src/modules/chapter-editor/chapter-lookup.service';
import { ChapterHealthRepository } from '../src/modules/chapter-health/chapter-health.repository';
import { ChapterEditorService } from '../src/modules/chapter-editor/chapter-editor.service';
import { ChapterLookupWorker } from '../src/modules/chapter-health/chapter-lookup.worker';
import {
authHeader,
closeAuthorizationMatrixE2EContext,
createAuthorizationMatrixE2EContext,
createLibraryWithFolder,
createUserAndLogin,
grantLibraryAccess,
type AuthorizationMatrixE2EContext,
type CreatedLibrary,
type TestUserSession,
} from './e2e/authorization-matrix/authorization-matrix-harness';
interface SeedBook {
title: string;
files?: number;
fileSeconds?: number;
format?: string;
chapters?: AudiobookChapter[] | null;
source?: AudiobookChapterSource | null;
chaptersUpdatedAt?: Date | null;
audibleId?: string | null;
lockedFields?: string[];
status?: string;
library?: CreatedLibrary;
}
function chapterList(titles: readonly string[], spacingMs = 60_000): AudiobookChapter[] {
return titles.map((title, index) => ({ title, startMs: index * spacingMs }));
}
function range(count: number, title: (index: number) => string): string[] {
return Array.from({ length: count }, (_, index) => title(index + 1));
}
const REVIEW_SUMMARY: ChapterLookupReviewSummary = {
provider: 'audible',
chapterCount: 3,
runtimeMs: 1_215_000,
totalDurationMs: 1_200_000,
isAccurate: true,
};
describe('Chapter health (e2e)', { timeout: 60_000 }, () => {
let ctx!: AuthorizationMatrixE2EContext;
let libraryA!: CreatedLibrary;
let libraryB!: CreatedLibrary;
let libraryAName!: string;
let editor!: TestUserSession;
let outsider!: TestUserSession;
let plainUser!: TestUserSession;
const ids: Record<string, number> = {};
async function seedBook(input: SeedBook): Promise<number> {
const library = input.library ?? libraryA;
const slug = `chapter-health-${randomUUID()}`;
const [book] = await ctx.db
.insert(schema.books)
.values({
libraryId: library.libraryId,
libraryFolderId: library.libraryFolderId,
folderPath: `${library.folderPath}/${slug}`,
status: input.status ?? 'present',
})
.returning({ id: schema.books.id });
const bookId = book!.id;
const chapters = input.chapters ?? null;
await ctx.db.insert(schema.bookMetadata).values({
bookId,
title: input.title,
chapters,
chaptersSource: input.source ?? (chapters ? 'embedded' : null),
chaptersUpdatedAt: input.chaptersUpdatedAt ?? null,
audibleId: input.audibleId ?? null,
lockedFields: input.lockedFields ?? [],
});
const format = input.format ?? 'mp3';
const parts = Array.from({ length: input.files ?? 1 }, (_, index) => index + 1);
const files = await ctx.db
.insert(schema.bookFiles)
.values(
parts.map((part) => ({
bookId,
libraryFolderId: library.libraryFolderId,
absolutePath: `${library.folderPath}/${slug}/Part ${part}.${format}`,
relPath: `${slug}/Part ${part}.${format}`,
ino: BigInt(part),
sizeBytes: 1_000 * part,
format,
role: 'content',
sortOrder: part,
durationSeconds: input.fileSeconds ?? 600,
})),
)
.returning({ id: schema.bookFiles.id });
await ctx.db.update(schema.books).set({ primaryFileId: files[0]!.id }).where(eq(schema.books.id, bookId));
return bookId;
}
async function request<T>(
user: TestUserSession | 'admin',
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
url: string,
payload?: Record<string, unknown>,
): Promise<{ status: number; body: T }> {
const token = user === 'admin' ? ctx.adminToken : user.accessToken;
const response = await ctx.app.inject({ method, url: `/api/v1${url}`, headers: authHeader(token), ...(payload ? { payload } : {}) });
return { status: response.statusCode, body: (response.body ? response.json() : null) as T };
}
async function summary(user: TestUserSession | 'admin', query = ''): Promise<ChapterHealthSummary> {
const response = await request<ChapterHealthSummary>(user, 'GET', `/chapter-health/summary${query}`);
expect(response.status).toBe(200);
return response.body;
}
async function titlesIn(user: TestUserSession, category: string, query = ''): Promise<string[]> {
const response = await request<ChapterHealthPage>(user, 'GET', `/chapter-health/books?category=${category}${query}`);
expect(response.status).toBe(200);
return response.body.items.map((item) => item.title ?? '');
}
async function queueRow(bookId: number) {
const [row] = await ctx.db.select().from(schema.chapterLookupQueue).where(eq(schema.chapterLookupQueue.bookId, bookId));
return row ?? null;
}
async function metadataRow(bookId: number) {
const [row] = await ctx.db
.select({
chapters: schema.bookMetadata.chapters,
chaptersSource: schema.bookMetadata.chaptersSource,
chaptersUpdatedBy: schema.bookMetadata.chaptersUpdatedBy,
lockedFields: schema.bookMetadata.lockedFields,
})
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, bookId));
return row!;
}
async function versionsOf(bookId: number) {
return ctx.db.select().from(schema.bookChapterVersions).where(eq(schema.bookChapterVersions.bookId, bookId));
}
beforeAll(async () => {
ctx = await createAuthorizationMatrixE2EContext();
libraryAName = `chapter-health-a-${randomUUID()}`;
libraryA = await createLibraryWithFolder(ctx, { name: libraryAName });
libraryB = await createLibraryWithFolder(ctx, { name: `chapter-health-b-${randomUUID()}` });
editor = await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] });
outsider = await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] });
plainUser = await createUserAndLogin(ctx);
await grantLibraryAccess(ctx, editor.userId, libraryA.libraryId, 'editor');
await grantLibraryAccess(ctx, outsider.userId, libraryB.libraryId, 'editor');
await grantLibraryAccess(ctx, plainUser.userId, libraryA.libraryId);
// Titles sort in seeding order, which is the order every list below is read in.
ids.singleNone = await seedBook({ title: 'Alpha Single No Chapters', audibleId: ' B0SINGLE01 ' });
ids.singleOne = await seedBook({ title: 'Bravo Single One Chapter', chapters: chapterList(['Whole Book']) });
ids.threeNone = await seedBook({ title: 'Charlie Three Files', files: 3, audibleId: 'B0THREE001' });
ids.threeOne = await seedBook({ title: 'Delta Three Files One Chapter', files: 3, chapters: chapterList(['Whole Book']) });
ids.digits = await seedBook({
title: 'Echo Numbered Digits',
chapters: chapterList(range(10, (n) => String(n).padStart(3, '0'))),
audibleId: 'B0DIGITS01',
});
ids.tracks = await seedBook({
title: 'Foxtrot Track Titles',
files: 2,
chapters: chapterList(range(10, (n) => `Track ${String(n).padStart(2, '0')}`)),
});
ids.mostly = await seedBook({
title: 'Golf Mostly Numbered',
chapters: chapterList([...range(8, (n) => String(n).padStart(2, '0')), 'Epilogue', 'Afterword']),
});
ids.partly = await seedBook({
title: 'Hotel Partly Numbered',
chapters: chapterList([...range(7, (n) => String(n).padStart(2, '0')), 'Interlude', 'Epilogue', 'Afterword']),
});
ids.real = await seedBook({ title: 'India Real Chapters', chapters: chapterList(range(10, (n) => `Chapter ${n}`)), audibleId: 'B0REAL0001' });
ids.manual = await seedBook({ title: 'Juliet Hand Edited', files: 2, chapters: chapterList(['Opening', 'Middle'], 300_000), source: 'manual' });
ids.reviewOpen = await seedBook({
title: 'Kilo Review Open',
files: 2,
chapters: chapterList(
range(3, (n) => `Chapter ${n}`),
300_000,
),
chaptersUpdatedAt: new Date('2026-09-01T00:00:00.000Z'),
audibleId: 'B0REVIEW01',
});
ids.reviewSettled = await seedBook({
title: 'Lima Review Settled',
files: 2,
chapters: chapterList(
range(3, (n) => `Chapter ${n}`),
300_000,
),
chaptersUpdatedAt: new Date('2026-09-03T00:00:00.000Z'),
audibleId: 'B0REVIEW02',
});
ids.missing = await seedBook({ title: 'Mike Missing', status: 'missing', audibleId: 'B0MISSING1' });
ids.ebook = await seedBook({ title: 'November Ebook Only', format: 'epub', audibleId: 'B0EBOOK001' });
ids.otherLibrary = await seedBook({ title: 'Oscar Other Library', library: libraryB, audibleId: 'B0OTHER001' });
const finishedAt = new Date('2026-09-02T00:00:00.000Z');
await ctx.db.insert(schema.chapterLookupQueue).values(
[ids.reviewOpen!, ids.reviewSettled!].map((bookId) => ({
bookId,
userId: editor.userId,
status: 'review' as const,
reason: 'runtime_mismatch',
result: REVIEW_SUMMARY,
attemptCount: 1,
finishedAt,
})),
);
});
afterAll(async () => {
await closeAuthorizationMatrixE2EContext(ctx);
});
it('counts each category over the libraries the user can reach', async () => {
const expected: ChapterHealthSummary = {
counts: { no_chapters: 2, one_per_file: 1, numbered_tracks: 3, needs_review: 1, edited: 1 },
withAsin: { no_chapters: 1, one_per_file: 1, numbered_tracks: 1, needs_review: 1, edited: 0 },
};
await expect(summary(editor)).resolves.toEqual(expected);
await expect(summary(editor, `?libraryId=${libraryA.libraryId}`)).resolves.toEqual(expected);
await expect(summary('admin', `?libraryId=${libraryA.libraryId}`)).resolves.toEqual(expected);
});
it('puts each book in the categories its files and chapters call for', async () => {
await expect(titlesIn(editor, 'no_chapters')).resolves.toEqual(['Alpha Single No Chapters', 'Bravo Single One Chapter']);
await expect(titlesIn(editor, 'one_per_file')).resolves.toEqual(['Charlie Three Files']);
// "Chapter 1".."Chapter 10" are real chapters, and 7 numbered titles out of 10 fall short of 80%.
await expect(titlesIn(editor, 'numbered_tracks')).resolves.toEqual(['Echo Numbered Digits', 'Foxtrot Track Titles', 'Golf Mostly Numbered']);
// A later chapter save settles a review.
await expect(titlesIn(editor, 'needs_review')).resolves.toEqual(['Kilo Review Open']);
await expect(titlesIn(editor, 'edited')).resolves.toEqual(['Juliet Hand Edited']);
});
it('describes each listed book, attaching the review only where one waits', async () => {
const numbered = await request<ChapterHealthPage>(editor, 'GET', '/chapter-health/books?category=numbered_tracks');
expect(numbered.body.items[0]).toEqual({
bookId: ids.digits,
title: 'Echo Numbered Digits',
authors: [],
libraryName: libraryAName,
audioFiles: 1,
chapterCount: 10,
source: 'embedded',
audibleId: 'B0DIGITS01',
review: null,
});
const single = await request<ChapterHealthPage>(editor, 'GET', '/chapter-health/books?category=no_chapters&pageSize=1');
expect(single.body.items[0]).toMatchObject({ bookId: ids.singleNone, audibleId: 'B0SINGLE01', chapterCount: 0, source: null });
const review = await request<ChapterHealthPage>(editor, 'GET', '/chapter-health/books?category=needs_review');
expect(review.body).toEqual({
total: 1,
items: [
expect.objectContaining({
bookId: ids.reviewOpen,
audioFiles: 2,
chapterCount: 3,
review: { reason: 'runtime_mismatch', summary: REVIEW_SUMMARY },
}),
],
});
});
it('pages through a category with a stable total', async () => {
const page = (number: number) =>
request<ChapterHealthPage>(editor, 'GET', `/chapter-health/books?category=no_chapters&pageSize=1&page=${number}`);
const [first, second, third] = await Promise.all([page(1), page(2), page(3)]);
expect([first.body.total, second.body.total, third.body.total]).toEqual([2, 2, 2]);
expect(first.body.items.map((item) => item.bookId)).toEqual([ids.singleNone]);
expect(second.body.items.map((item) => item.bookId)).toEqual([ids.singleOne]);
expect(third.body.items).toEqual([]);
});
it('refuses a malformed listing', async () => {
await expect(request(editor, 'GET', '/chapter-health/books?category=bogus')).resolves.toMatchObject({ status: 400 });
await expect(request(editor, 'GET', '/chapter-health/books')).resolves.toMatchObject({ status: 400 });
await expect(request(editor, 'GET', '/chapter-health/books?category=edited&pageSize=101')).resolves.toMatchObject({ status: 400 });
});
it('covers no library where the user is only a viewer, even with the metadata editing permission', async () => {
const viewer = await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] });
await grantLibraryAccess(ctx, viewer.userId, libraryA.libraryId, 'viewer');
await expect(summary(viewer)).resolves.toEqual({
counts: { no_chapters: 0, one_per_file: 0, numbered_tracks: 0, needs_review: 0, edited: 0 },
withAsin: { no_chapters: 0, one_per_file: 0, numbered_tracks: 0, needs_review: 0, edited: 0 },
});
await expect(request(viewer, 'GET', `/chapter-health/summary?libraryId=${libraryA.libraryId}`)).resolves.toMatchObject({ status: 403 });
await expect(request(viewer, 'POST', '/chapter-health/lookups', { bookIds: [ids.singleNone!] })).resolves.toMatchObject({
status: 200,
body: { queued: 0, skipped: 1 },
});
});
it('shows a user only the libraries they can reach, and refuses one they cannot name', async () => {
await expect(summary(outsider)).resolves.toEqual({
counts: { no_chapters: 1, one_per_file: 0, numbered_tracks: 0, needs_review: 0, edited: 0 },
withAsin: { no_chapters: 1, one_per_file: 0, numbered_tracks: 0, needs_review: 0, edited: 0 },
});
await expect(titlesIn(outsider, 'no_chapters')).resolves.toEqual(['Oscar Other Library']);
await expect(titlesIn(outsider, 'needs_review')).resolves.toEqual([]);
for (const url of [
`/chapter-health/summary?libraryId=${libraryA.libraryId}`,
`/chapter-health/books?category=no_chapters&libraryId=${libraryA.libraryId}`,
]) {
await expect(request(outsider, 'GET', url)).resolves.toMatchObject({ status: 403 });
}
await expect(
request(outsider, 'POST', '/chapter-health/lookups', { category: 'no_chapters', libraryId: libraryA.libraryId }),
).resolves.toMatchObject({
status: 403,
});
await expect(request(editor, 'GET', `/chapter-health/books?category=no_chapters&libraryId=${libraryB.libraryId}`)).resolves.toMatchObject({
status: 403,
});
// A superuser reaches every library.
await expect(summary('admin', `?libraryId=${libraryB.libraryId}`)).resolves.toMatchObject({ counts: { no_chapters: 1 } });
});
it('refuses a user without the metadata editing permission', async () => {
await expect(request(plainUser, 'GET', '/chapter-health/summary')).resolves.toMatchObject({ status: 403 });
await expect(request(plainUser, 'POST', '/chapter-health/lookups', { bookIds: [ids.singleNone] })).resolves.toMatchObject({ status: 403 });
});
it('queues lookups by book or by category, leaving out books without an ASIN or out of reach', async () => {
const byIds = await request<EnqueueChapterLookupResult>(editor, 'POST', '/chapter-health/lookups', {
bookIds: [ids.singleNone, ids.singleOne, ids.otherLibrary, 999_999, ids.singleNone, ids.missing, ids.ebook],
});
// A missing book and an ebook have ASINs too, but nothing to put chapters on.
expect(byIds).toEqual({ status: 200, body: { queued: 1, skipped: 5 } });
const byCategory = await request<EnqueueChapterLookupResult>(editor, 'POST', '/chapter-health/lookups', { category: 'one_per_file' });
expect(byCategory).toEqual({ status: 200, body: { queued: 1, skipped: 0 } });
const scoped = await request<EnqueueChapterLookupResult>(editor, 'POST', '/chapter-health/lookups', {
category: 'numbered_tracks',
libraryId: libraryA.libraryId,
});
expect(scoped).toEqual({ status: 200, body: { queued: 1, skipped: 0 } });
await expect(request(editor, 'POST', '/chapter-health/lookups', {})).resolves.toMatchObject({ status: 400 });
await expect(request(editor, 'POST', '/chapter-health/lookups', { bookIds: [] })).resolves.toMatchObject({ status: 400 });
await expect(request(editor, 'POST', '/chapter-health/lookups', { category: 'everything' })).resolves.toMatchObject({ status: 400 });
const outside = await request<EnqueueChapterLookupResult>(outsider, 'POST', '/chapter-health/lookups', { bookIds: [ids.singleNone] });
expect(outside).toEqual({ status: 200, body: { queued: 0, skipped: 1 } });
const queued = await ctx.db
.select({ bookId: schema.chapterLookupQueue.bookId, userId: schema.chapterLookupQueue.userId, status: schema.chapterLookupQueue.status })
.from(schema.chapterLookupQueue)
.where(eq(schema.chapterLookupQueue.status, 'queued'));
expect(queued.sort((left, right) => left.bookId - right.bookId)).toEqual(
[ids.singleNone!, ids.threeNone!, ids.digits!].map((bookId) => ({ bookId, userId: editor.userId, status: 'queued' })),
);
});
it("reports and cancels only the user's own queue", async () => {
await expect(request<ChapterLookupQueueState>(editor, 'GET', '/chapter-health/lookups/state')).resolves.toEqual({
status: 200,
body: { queued: 3, processing: null, finished: { applied: 0, review: 1, not_found: 0, failed: 0, skipped: 1 } },
});
await expect(request<ChapterLookupQueueState>(outsider, 'GET', '/chapter-health/lookups/state')).resolves.toMatchObject({
body: { queued: 0, finished: { review: 0 } },
});
await expect(request(outsider, 'DELETE', '/chapter-health/lookups/queued')).resolves.toEqual({ status: 200, body: { removed: 0 } });
await expect(request(editor, 'DELETE', '/chapter-health/lookups/queued')).resolves.toEqual({ status: 200, body: { removed: 3 } });
await expect(request<ChapterLookupQueueState>(editor, 'GET', '/chapter-health/lookups/state')).resolves.toMatchObject({
body: { queued: 0, finished: { review: 1, skipped: 1 } },
});
});
it('applies confident lookups and leaves the rest for a person, without reaching Audible', async () => {
const worker = ctx.app.get(ChapterLookupWorker);
const lookupService = ctx.app.get(ChapterLookupService);
const asin = (bookId: number) => `B0WORKER${String(bookId).padStart(2, '0')}`.slice(0, 20);
// One hour of audio; Audible's runtime adds a 2 s lead-in and a 3 s sign-off.
const confident = await seedBook({ title: 'Papa Confident', fileSeconds: 3_600 });
const mismatched = await seedBook({ title: 'Quebec Mismatched', fileSeconds: 3_600 });
const missing = await seedBook({ title: 'Romeo Not Found', fileSeconds: 3_600 });
const lockedChapters = chapterList(['Mine', 'Also Mine'], 1_800_000);
const locked = await seedBook({
title: 'Sierra Locked',
fileSeconds: 3_600,
chapters: lockedChapters,
source: 'manual',
lockedFields: ['chapters'],
});
const flaky = await seedBook({ title: 'Tango Unreachable', fileSeconds: 3_600 });
const lostAsin = await seedBook({ title: 'Uniform Lost ASIN', fileSeconds: 3_600 });
const books = [confident, mismatched, missing, locked, flaky, lostAsin];
for (const bookId of books) {
await ctx.db
.update(schema.bookMetadata)
.set({ audibleId: asin(bookId) })
.where(eq(schema.bookMetadata.bookId, bookId));
}
const found: ChapterLookupResult = {
provider: 'audible',
asin: asin(confident),
region: 'us',
chapters: [
{ title: 'Opening Credits', startMs: 0, lengthMs: 60_000, children: [] },
{ title: 'Chapter 1', startMs: 60_000, lengthMs: 1_740_000, children: [] },
{ title: 'Chapter 2', startMs: 1_800_000, lengthMs: 1_795_000, children: [] },
{ title: 'End Credits', startMs: 3_595_000, lengthMs: 10_000, children: [] },
],
runtimeMs: 3_605_000,
brandIntroMs: 2_000,
brandOutroMs: 3_000,
isAccurate: true,
};
const lookup = vi.spyOn(lookupService, 'lookup').mockImplementation((requested) => {
if (requested === asin(confident) || requested === asin(locked)) return Promise.resolve(found);
if (requested === asin(mismatched)) return Promise.resolve({ ...found, runtimeMs: 3_625_000 });
if (requested === asin(missing)) {
return Promise.reject(
new HttpException(
{ message: 'No chapters were found for that ASIN', errorCode: BOOK_CHAPTERS_ERROR_CODES.lookupNotFound },
HttpStatus.NOT_FOUND,
),
);
}
if (requested === asin(flaky)) {
return Promise.reject(new HttpException({ message: 'The chapter sources could not be reached' }, HttpStatus.BAD_GATEWAY));
}
return Promise.reject(new Error(`unexpected lookup ${requested}`));
});
try {
const enqueued = await request<EnqueueChapterLookupResult>(editor, 'POST', '/chapter-health/lookups', { bookIds: books });
expect(enqueued.body).toEqual({ queued: 6, skipped: 0 });
// The ASIN was cleared after the book was queued.
await ctx.db.update(schema.bookMetadata).set({ audibleId: null }).where(eq(schema.bookMetadata.bookId, lostAsin));
// Tests drive the worker; nothing ticked on its own.
expect((await queueRow(confident))!.status).toBe('queued');
await worker.tick();
// A failed lookup waits before its next try; check the wait, then bring each retry forward.
for (let retry = 0; retry < 2; retry += 1) {
const waiting = await queueRow(flaky);
expect(waiting).toMatchObject({ status: 'queued' });
expect(waiting!.availableAt.getTime()).toBeGreaterThan(Date.now() + 30_000);
await ctx.db
.update(schema.chapterLookupQueue)
.set({ availableAt: new Date(Date.now() - 1_000) })
.where(eq(schema.chapterLookupQueue.bookId, flaky));
await worker.tick();
}
expect(lookup.mock.calls.map(([requested, region]) => [requested, region])).toEqual([
[asin(confident), 'us'],
[asin(mismatched), 'us'],
[asin(missing), 'us'],
[asin(locked), 'us'],
[asin(flaky), 'us'],
[asin(flaky), 'us'],
[asin(flaky), 'us'],
]);
} finally {
lookup.mockRestore();
}
const applied = [
{ title: 'Opening Credits', startMs: 0 },
{ title: 'Chapter 1', startMs: 58_000 },
{ title: 'Chapter 2', startMs: 1_798_000 },
{ title: 'End Credits', startMs: 3_593_000 },
];
const summaryOf = (runtimeMs: number): ChapterLookupReviewSummary => ({
provider: 'audible',
chapterCount: 4,
runtimeMs,
totalDurationMs: 3_600_000,
isAccurate: true,
});
expect(await queueRow(confident)).toMatchObject({
status: 'applied',
reason: null,
result: summaryOf(3_605_000),
attemptCount: 1,
lastError: null,
finishedAt: expect.any(Date),
});
expect(await metadataRow(confident)).toEqual({
chapters: applied,
chaptersSource: 'provider',
chaptersUpdatedBy: null,
lockedFields: ['chapters'],
});
expect((await versionsOf(confident)).map((row) => ({ source: row.source, chapterCount: row.chapterCount, createdBy: row.createdBy }))).toEqual([
{ source: 'provider', chapterCount: 4, createdBy: editor.userId },
]);
const state = await request<BookChaptersState>(editor, 'GET', `/books/${confident}/chapters`);
expect(state.body).toMatchObject({ chapters: applied, source: 'provider', locked: true, versionCount: 1 });
expect(await queueRow(mismatched)).toMatchObject({ status: 'review', reason: 'runtime_mismatch', result: summaryOf(3_625_000) });
expect(await metadataRow(mismatched)).toEqual({ chapters: null, chaptersSource: null, chaptersUpdatedBy: null, lockedFields: [] });
expect(await versionsOf(mismatched)).toEqual([]);
expect(await queueRow(missing)).toMatchObject({ status: 'not_found', reason: null, result: null });
expect(await queueRow(locked)).toMatchObject({ status: 'review', reason: 'locked', result: summaryOf(3_605_000) });
expect(await metadataRow(locked)).toMatchObject({ chapters: lockedChapters, chaptersSource: 'manual', lockedFields: ['chapters'] });
expect(await versionsOf(locked)).toEqual([]);
expect(await queueRow(flaky)).toMatchObject({
status: 'failed',
attemptCount: 3,
lastError: 'The chapter sources could not be reached',
finishedAt: expect.any(Date),
});
expect(await queueRow(lostAsin)).toMatchObject({ status: 'skipped', reason: 'no_asin', result: null });
await expect(request<ChapterLookupQueueState>(editor, 'GET', '/chapter-health/lookups/state')).resolves.toEqual({
status: 200,
body: { queued: 0, processing: null, finished: { applied: 1, review: 3, not_found: 1, failed: 1, skipped: 2 } },
});
await expect(titlesIn(editor, 'needs_review')).resolves.toEqual(['Kilo Review Open', 'Quebec Mismatched', 'Sierra Locked']);
await expect(titlesIn(editor, 'no_chapters')).resolves.not.toContain('Papa Confident');
});
it('settles a review once the chapters are saved after it', async () => {
const [mismatched] = await ctx.db
.select({ bookId: schema.bookMetadata.bookId })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.title, 'Quebec Mismatched'));
const bookId = mismatched!.bookId;
const state = await request<BookChaptersState>(editor, 'GET', `/books/${bookId}/chapters`);
const saved = await request(editor, 'PUT', `/books/${bookId}/chapters`, {
baseRevision: state.body.revision,
chapters: [
{ title: 'Opening', startMs: 0 },
{ title: 'Second Half', startMs: 1_800_000 },
],
});
expect(saved.status).toBe(200);
await expect(titlesIn(editor, 'needs_review')).resolves.toEqual(['Kilo Review Open', 'Sierra Locked']);
await expect(titlesIn(editor, 'edited')).resolves.toEqual(['Juliet Hand Edited', 'Quebec Mismatched', 'Sierra Locked']);
// The outcome stays queued for the person; only the category drops it.
expect((await queueRow(bookId))!.status).toBe('review');
});
it('clears finished outcomes but keeps the reviews still waiting', async () => {
await expect(request(outsider, 'DELETE', '/chapter-health/lookups/finished')).resolves.toEqual({ status: 200, body: { removed: 0 } });
await expect(request(editor, 'DELETE', '/chapter-health/lookups/finished')).resolves.toEqual({ status: 200, body: { removed: 6 } });
const left = await ctx.db.select({ status: schema.chapterLookupQueue.status }).from(schema.chapterLookupQueue);
expect(left.map((row) => row.status)).toEqual(['review', 'review']);
});
it('dismisses a waiting review only for a user who can reach the book', async () => {
await expect(request(outsider, 'DELETE', `/chapter-health/lookups/${ids.reviewOpen}`)).resolves.toMatchObject({ status: 404 });
await expect(request(editor, 'DELETE', `/chapter-health/lookups/${ids.singleNone}`)).resolves.toMatchObject({ status: 404 });
await expect(request(editor, 'DELETE', `/chapter-health/lookups/${ids.reviewOpen}`)).resolves.toEqual({ status: 204, body: null });
await expect(request(editor, 'DELETE', `/chapter-health/lookups/${ids.reviewOpen}`)).resolves.toMatchObject({ status: 404 });
await expect(titlesIn(editor, 'needs_review')).resolves.toEqual(['Sierra Locked']);
const remaining = await ctx.db
.select({ bookId: schema.chapterLookupQueue.bookId })
.from(schema.chapterLookupQueue)
.where(inArray(schema.chapterLookupQueue.bookId, [ids.reviewOpen!, ids.reviewSettled!]));
expect(remaining).toEqual([]);
});
it('hides content-filtered books from health reads, queue status, enqueue, dismissal and execution', async () => {
const restricted = await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] });
const isolated = await createLibraryWithFolder(ctx, { name: `health-filter-${randomUUID()}` });
await grantLibraryAccess(ctx, restricted.userId, isolated.libraryId, 'editor');
const hidden = await seedBook({ title: 'Hidden chapters', audibleId: 'B0HIDDEN', library: isolated });
const [tag] = await ctx.db
.insert(schema.tags)
.values({ name: `hidden-${randomUUID()}` })
.returning();
await ctx.db.insert(schema.bookTags).values({ bookId: hidden, tagId: tag.id });
await ctx.db.insert(schema.userContentFilterTags).values({ userId: restricted.userId, filterType: 'exclude', tagId: tag.id });
expect((await request<ChapterHealthSummary>(restricted, 'GET', '/chapter-health/summary')).body.counts.no_chapters).toBe(0);
expect((await request<ChapterHealthPage>(restricted, 'GET', '/chapter-health/books?category=no_chapters')).body.items).toEqual([]);
expect((await request<EnqueueChapterLookupResult>(restricted, 'POST', '/chapter-health/lookups', { bookIds: [hidden] })).body).toEqual({
queued: 0,
skipped: 1,
});
await ctx.db.insert(schema.chapterLookupQueue).values({ bookId: hidden, userId: restricted.userId, status: 'review', finishedAt: new Date() });
expect((await request<ChapterLookupQueueState>(restricted, 'GET', '/chapter-health/lookups/state')).body.finished.review).toBe(0);
expect((await request(restricted, 'DELETE', `/chapter-health/lookups/${hidden}`)).status).toBe(404);
expect(await ctx.app.get(ChapterEditorService).bulkLookup(hidden, restricted.userId)).toMatchObject({
status: 'skipped',
reason: 'access_changed',
});
});
it('does not reset a processing claim and ignores late finishes from an older claim', async () => {
const repo = ctx.app.get(ChapterHealthRepository);
const book = await seedBook({ title: 'Claim safety', audibleId: 'B0CLAIM' });
await repo.enqueue([book], editor.userId);
const first = await repo.claimNext();
expect(first?.bookId).toBe(book);
expect(await repo.enqueue([book], outsider.userId)).toBe(0);
await repo.requeueProcessing();
const second = await repo.claimNext();
expect(second?.claimToken).not.toBe(first?.claimToken);
await repo.finish(book, { status: 'applied', reason: null, result: null }, first!.claimToken);
const [waiting] = await ctx.db.select().from(schema.chapterLookupQueue).where(eq(schema.chapterLookupQueue.bookId, book));
expect(waiting.status).toBe('processing');
await repo.finish(book, { status: 'failed', reason: null, result: null }, second!.claimToken);
});
it('advances category batches past 5000 completed candidates in a large library', async () => {
const large = await createLibraryWithFolder(ctx, { name: `large-health-${randomUUID()}` });
const seededIds: number[] = [];
for (let offset = 0; offset < 5100; offset += 500) {
const count = Math.min(500, 5100 - offset);
const books = await ctx.db
.insert(schema.books)
.values(
Array.from({ length: count }, (_, index) => ({
libraryId: large.libraryId,
libraryFolderId: large.libraryFolderId,
folderPath: `${large.folderPath}/book-${offset + index}`,
status: 'present' as const,
})),
)
.returning({ id: schema.books.id });
seededIds.push(...books.map((book) => book.id));
await ctx.db
.insert(schema.bookMetadata)
.values(books.map((book) => ({ bookId: book.id, title: `Book ${book.id}`, audibleId: `B${String(book.id).padStart(9, '0')}` })));
await ctx.db.insert(schema.bookFiles).values(
books.map((book) => ({
bookId: book.id,
libraryFolderId: large.libraryFolderId,
absolutePath: `${large.folderPath}/book-${book.id}/audio.mp3`,
relPath: `book-${book.id}/audio.mp3`,
ino: BigInt(book.id),
sizeBytes: 1000,
format: 'mp3',
role: 'content' as const,
durationSeconds: 60,
})),
);
}
const payload = { category: 'no_chapters', libraryId: large.libraryId };
expect((await request<EnqueueChapterLookupResult>('admin', 'POST', '/chapter-health/lookups', payload)).body.queued).toBe(5000);
await ctx.db
.update(schema.chapterLookupQueue)
.set({ status: 'not_found', finishedAt: new Date() })
.where(inArray(schema.chapterLookupQueue.bookId, seededIds));
expect((await request<EnqueueChapterLookupResult>('admin', 'POST', '/chapter-health/lookups', payload)).body.queued).toBe(100);
const listing = await request<ChapterHealthPage>(
'admin',
'GET',
`/chapter-health/books?category=no_chapters&libraryId=${large.libraryId}&page=102`,
);
expect(listing.body.total).toBe(5100);
expect(listing.body.items).toHaveLength(50);
});
});
@@ -1,10 +1,10 @@
{
"totalRoutes": 679,
"totalRoutes": 697,
"byPermission": {
"(none)": 289,
"(none)": 290,
"Permission.ManageMetadataConfig": 41,
"Permission.EmailSend": 34,
"Permission.LibraryEditMetadata": 37,
"Permission.LibraryEditMetadata": 54,
"Permission.BookDockAccess": 17,
"Permission.ManageAppSettings": 62,
"Permission.ManageLibraries": 24,
@@ -419,6 +419,138 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-editor/chapter-editor.controller.ts",
"className": "ChapterEditorController",
"methodName": "getState",
"httpMethod": "GET",
"path": "/books/:bookId/chapters",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-editor/chapter-editor.controller.ts",
"className": "ChapterEditorController",
"methodName": "save",
"httpMethod": "PUT",
"path": "/books/:bookId/chapters",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-editor/chapter-editor.controller.ts",
"className": "ChapterEditorController",
"methodName": "revert",
"httpMethod": "POST",
"path": "/books/:bookId/chapters/revert",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-editor/chapter-editor.controller.ts",
"className": "ChapterEditorController",
"methodName": "writeToFiles",
"httpMethod": "POST",
"path": "/books/:bookId/chapters/write",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-editor/chapter-editor.controller.ts",
"className": "ChapterEditorController",
"methodName": "readEmbedded",
"httpMethod": "GET",
"path": "/books/:bookId/chapters/embedded",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-editor/chapter-editor.controller.ts",
"className": "ChapterEditorController",
"methodName": "lookup",
"httpMethod": "GET",
"path": "/books/:bookId/chapters/lookup",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-editor/chapter-editor.controller.ts",
"className": "ChapterEditorController",
"methodName": "readEbook",
"httpMethod": "GET",
"path": "/books/:bookId/chapters/ebook",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-editor/chapter-editor.controller.ts",
"className": "ChapterEditorController",
"methodName": "analyze",
"httpMethod": "POST",
"path": "/books/:bookId/chapters/analyze",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-editor/chapter-editor.controller.ts",
"className": "ChapterEditorController",
"methodName": "waveform",
"httpMethod": "GET",
"path": "/books/:bookId/chapters/waveform",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-editor/chapter-editor.controller.ts",
"className": "ChapterEditorController",
"methodName": "listVersions",
"httpMethod": "GET",
"path": "/books/:bookId/chapters/versions",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-editor/chapter-editor.controller.ts",
"className": "ChapterEditorController",
"methodName": "getVersion",
"httpMethod": "GET",
"path": "/books/:bookId/chapters/versions/:versionId",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book/book.controller.ts",
"className": "BookController",
@@ -35,6 +35,90 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-health/chapter-health.controller.ts",
"className": "ChapterHealthController",
"methodName": "summary",
"httpMethod": "GET",
"path": "/chapter-health/summary",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-health/chapter-health.controller.ts",
"className": "ChapterHealthController",
"methodName": "list",
"httpMethod": "GET",
"path": "/chapter-health/books",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-health/chapter-health.controller.ts",
"className": "ChapterHealthController",
"methodName": "enqueue",
"httpMethod": "POST",
"path": "/chapter-health/lookups",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-health/chapter-health.controller.ts",
"className": "ChapterHealthController",
"methodName": "state",
"httpMethod": "GET",
"path": "/chapter-health/lookups/state",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-health/chapter-health.controller.ts",
"className": "ChapterHealthController",
"methodName": "cancel",
"httpMethod": "DELETE",
"path": "/chapter-health/lookups/queued",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-health/chapter-health.controller.ts",
"className": "ChapterHealthController",
"methodName": "clearFinished",
"httpMethod": "DELETE",
"path": "/chapter-health/lookups/finished",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/chapter-health/chapter-health.controller.ts",
"className": "ChapterHealthController",
"methodName": "dismissReview",
"httpMethod": "DELETE",
"path": "/chapter-health/lookups/:bookId",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-dock/book-dock.controller.ts",
"className": "BookDockController",
@@ -104,6 +104,7 @@ export interface BookMutationState {
durationSeconds: number | null;
abridged: boolean;
chapters: unknown;
chaptersSource: string | null;
lockedFields: string[];
lastMetadataFetchAt: Date | null;
} | null;
@@ -290,6 +291,7 @@ export async function readBookMutationState(ctx: MetadataLockE2EContext, bookId:
durationSeconds: bookMetadata.durationSeconds,
abridged: bookMetadata.abridged,
chapters: bookMetadata.chapters,
chaptersSource: bookMetadata.chaptersSource,
lockedFields: bookMetadata.lockedFields,
lastMetadataFetchAt: bookMetadata.lastMetadataFetchAt,
})
@@ -327,6 +329,7 @@ export async function readBookMutationState(ctx: MetadataLockE2EContext, bookId:
durationSeconds: meta.durationSeconds,
abridged: meta.abridged,
chapters: meta.chapters,
chaptersSource: meta.chaptersSource,
lockedFields: [...meta.lockedFields],
lastMetadataFetchAt: meta.lastMetadataFetchAt,
}
@@ -136,6 +136,12 @@ export async function seedAudiobookshelfMigrationScenario(ctx: MigrationAudioboo
publishedYear: '2022',
series: [{ name: 'Harbor Cycle', sequence: '1' }],
}),
// Hand-edited chapters, as an Audiobookshelf user curates them in its chapter editor.
setItemChapters(ctx.audiobookshelf, sourceItems.glass.id, [
{ start: 0, end: 12.5, title: 'Prologue: The Breakwater' },
{ start: 12.5, end: 60, title: 'Chapter 1: Low Tide' },
{ start: 60, end: 150, title: 'Chapter 2: Open Water' },
]),
setItemMetadata(ctx.audiobookshelf, sourceItems.map.id, {
title: 'A Map of Quiet Stars',
authors: [{ name: 'Imani Reed' }],
@@ -406,6 +412,19 @@ async function setItemMetadata(service: AudiobookshelfService, itemId: string, m
await absJson(service, { method: 'PATCH', path: `/api/items/${itemId}/media`, token: service.rootToken, payload: { metadata } });
}
async function setItemChapters(
service: AudiobookshelfService,
itemId: string,
chapters: Array<{ start: number; end: number; title: string }>,
): Promise<void> {
await absJson(service, {
method: 'POST',
path: `/api/items/${itemId}/chapters`,
token: service.rootToken,
payload: { chapters: chapters.map((chapter, id) => ({ id, ...chapter })) },
});
}
async function createAbsUser(
service: AudiobookshelfService,
libraryId: string,
+25 -1
View File
@@ -323,7 +323,8 @@ describe('metadata-lock e2e', { timeout: 30_000 }, () => {
openLibraryId: null,
durationSeconds: expected.goodreads.durationSeconds,
abridged: expected.goodreads.abridged,
chapters: expected.goodreads.chapters,
chapters: expected.goodreads.chapters?.map(({ title, startMs }) => ({ title, startMs })),
chaptersSource: 'provider',
coverSource: null,
});
expect(state.narrators).toEqual(['Locked Narrator']);
@@ -336,6 +337,29 @@ describe('metadata-lock e2e', { timeout: 30_000 }, () => {
await expect(findCoverFilePath(ctx, scanned.bookId)).resolves.toBeNull();
});
it('keeps locked chapters through a provider refresh', async () => {
const scanned = await scanSingleEpub('refresh-locked-chapters', { title: 'Locked Chapters Base' });
const seed = await patchMetadata(scanned.bookId, { audioMetadata: { chapters: [{ title: 'Curated Chapter', startMs: 0 }] } });
expect(seed.statusCode).toBe(200);
expect((await patchLocks(scanned.bookId, ['chapters'])).statusCode).toBe(200);
const refresh = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${scanned.bookId}/refresh-metadata`,
headers: authHeader(ctx.adminToken),
});
expect(refresh.statusCode).toBe(201);
const state = await readBookMutationState(ctx, scanned.bookId);
expect(state.metadata).toMatchObject({
chapters: [{ title: 'Curated Chapter', startMs: 0 }],
chaptersSource: 'manual',
lockedFields: ['chapters'],
});
expect(state.metadata?.lastMetadataFetchAt).not.toBeNull();
});
it('keeps locked fields untouched during bulk metadata fetch while persisting unlocked fields', async () => {
const scanned = await scanSingleEpub('bulk-fetch', { title: 'Bulk Base' });
const expected = buildMetadataLockCandidates('Bulk Base', ctx.coverServer.urlFor);
@@ -339,6 +339,19 @@ describe('Migration Audiobookshelf live and backup (e2e)', { timeout: 300_000 },
const glassAudio = audioRows.find((row) => row.userId === scenario.targetUsers.maya.id && row.bookId === scenario.targetBooks.glass.bookId);
expect(glassAudio?.positionSeconds).toBeCloseTo(15, 0);
const [glassMetadata] = await ctx.db
.select({ chapters: schema.bookMetadata.chapters, chaptersSource: schema.bookMetadata.chaptersSource })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, scenario.targetBooks.glass.bookId));
expect(glassMetadata).toEqual({
chapters: [
{ title: 'Prologue: The Breakwater', startMs: 0 },
{ title: 'Chapter 1: Low Tide', startMs: 12_500 },
{ title: 'Chapter 2: Open Water', startMs: 60_000 },
],
chaptersSource: 'migration',
});
const readingRows = await ctx.db
.select()
.from(schema.readingProgress)