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