feat: request an ebook and audiobook as a pair

Link two ordinary requests as companions, filed into one book, with a Both search,
grouped list rows, a tabbed release picker and a book-page request for the missing
format. Retire comic requests, keeping old rows readable. File Book Dock covers by the
field rules with a shape check, and re-pick a moved book's primary by its new library.
This commit is contained in:
neon
2026-10-08 16:12:30 +00:00
parent 01b5638ea7
commit 39ac2f1efd
102 changed files with 28539 additions and 518 deletions
+32 -1
View File
@@ -31,7 +31,7 @@ import type { BookMovePreviewResult, BookMoveProgressEvent, BookMoveSummary } fr
import { Permission } from '@bookorbit/types';
import { annotations, bookFiles, bookMoveJobs, books, collectionBooks, collections, libraries, userBookStatus } from '../src/db/schema';
import { createEpubFixture, createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder';
import { createEpubFixture, createM4bFixture, createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder';
import {
authHeader,
closeMetadataWriteE2EContext,
@@ -716,6 +716,37 @@ describe('Cross-library book move (e2e)', () => {
SCENARIO_TIMEOUT_MS,
);
it(
'picks the primary file by the format priority of the library the book moved into',
async () => {
const src = await createSource({ mode: 'book_per_folder' });
const dst = await createTarget({ mode: 'book_per_folder' });
await ctx.db
.update(libraries)
.set({ formatPriority: ['m4b', 'epub'] })
.where(eq(libraries.id, dst.libraryId));
const title = `Both formats ${randomUUID().slice(0, 8)}`;
await createM4bFixture(src.folderPath, `${title}/${title}.m4b`, { title });
const book = await seedEpub(src, `${title}/${title}.epub`, title);
const formatOf = async (fileId: number | null) => (await readFileRows(book.bookId)).find((file) => file.id === fileId)?.relPath ?? null;
const primaryOf = async () =>
(await ctx.db.select({ primaryFileId: books.primaryFileId }).from(books).where(eq(books.id, book.bookId)))[0]?.primaryFileId ?? null;
expect(await formatOf(await primaryOf())).toMatch(/\.epub$/);
const summary = await moveAndSummarize({
selection: { bookIds: [book.bookId] },
targetLibraryId: dst.libraryId,
targetFolderId: dst.libraryFolderId,
collisionPolicy: 'suggested',
});
expect(summary.succeeded).toBe(1);
expect(await formatOf(await primaryOf())).toMatch(/\.m4b$/);
},
SCENARIO_TIMEOUT_MS,
);
it(
'streams one progress frame per book before the done summary',
async () => {
+227 -2
View File
@@ -2,7 +2,14 @@ import { randomUUID } from 'crypto';
import { and, eq } from 'drizzle-orm';
import { Permission } from '@bookorbit/types';
import type { BookRequestBulkResult, BookRequestItem, BookRequestPage, BookRequestSubmitResult, IndexerItem } from '@bookorbit/types';
import type {
BookRequestBulkResult,
BookRequestItem,
BookRequestPage,
BookRequestPairSubmitResult,
BookRequestSubmitResult,
IndexerItem,
} from '@bookorbit/types';
import * as schema from '../src/db/schema';
import {
@@ -40,7 +47,7 @@ const SELF_FULFIL_PERMISSIONS = [Permission.BookRequestAccess, Permission.BookRe
interface SubmitOptions {
title?: string;
mediaKind?: 'ebook' | 'audiobook' | 'comic';
mediaKind?: 'ebook' | 'audiobook';
targetLibraryId?: number | null;
selfServe?: boolean;
userId?: number;
@@ -517,6 +524,224 @@ describe('Book requests (e2e)', () => {
);
});
describe('ebook and audiobook pairs', () => {
function submitPair(session: TestUserSession, title: string, overrides: Record<string, unknown> = {}) {
return inject('POST', '/book-requests/pairs', session, {
title,
authors: ['Fixture Author'],
ebook: { targetLibraryId: library.libraryId },
audiobook: { targetLibraryId: library.libraryId },
...overrides,
});
}
async function submitPairOk(
session: TestUserSession,
title: string,
overrides: Record<string, unknown> = {},
): Promise<BookRequestPairSubmitResult> {
const response = await submitPair(session, title, overrides);
expect(response.statusCode).toBe(201);
return response.json() as BookRequestPairSubmitResult;
}
/** A book holding one ebook file, in a library that keeps one book per folder. */
async function seedEbookBook(target: CreatedLibrary): Promise<number> {
const folderPath = `${target.folderPath}/${randomUUID()}`;
const [book] = await ctx.db
.insert(schema.books)
.values({ libraryId: target.libraryId, libraryFolderId: target.libraryFolderId, folderPath })
.returning({ id: schema.books.id });
await ctx.db.insert(schema.bookFiles).values({
bookId: book!.id,
libraryFolderId: target.libraryFolderId,
absolutePath: `${folderPath}/book.epub`,
ino: BigInt(Math.floor(Math.random() * 1_000_000_000)),
format: 'epub',
role: 'content',
});
return book!.id;
}
it(
'files both halves as requests of their own that name each other',
async () => {
const { ebook, audiobook } = await submitPairOk(requester, `Pair ${randomUUID()}`);
expect(ebook.subscribed).toBe(false);
expect(audiobook.subscribed).toBe(false);
expect(ebook.request.mediaKind).toBe('ebook');
expect(audiobook.request.mediaKind).toBe('audiobook');
expect(ebook.request.status).toBe('pending');
expect(audiobook.request.status).toBe('pending');
expect(ebook.request.companionRequestId).toBe(audiobook.request.id);
expect(audiobook.request.companionRequestId).toBe(ebook.request.id);
},
SCENARIO_TIMEOUT_MS,
);
it(
'lists a pair as one entry led by its ebook when asked to group, and as two entries otherwise',
async () => {
const { ebook, audiobook } = await submitPairOk(requester, `Pair ${randomUUID()}`);
const grouped = (await inject('GET', '/book-requests?groupPairs=true&limit=100', requester)).json() as BookRequestPage;
const lead = grouped.items.find((item) => item.id === ebook.request.id);
expect(lead?.companion?.id).toBe(audiobook.request.id);
expect(grouped.items.some((item) => item.id === audiobook.request.id)).toBe(false);
const flat = (await inject('GET', '/book-requests?limit=100', requester)).json() as BookRequestPage;
const ids = flat.items.map((item) => item.id);
expect(ids).toEqual(expect.arrayContaining([ebook.request.id, audiobook.request.id]));
// A client that never asked to group must not be handed the nested shape.
expect(flat.items.find((item) => item.id === ebook.request.id)).not.toHaveProperty('companion');
},
SCENARIO_TIMEOUT_MS,
);
it(
'keeps a grouped pair on a filtered queue when only one half matches the filter',
async () => {
const { ebook, audiobook } = await submitPairOk(requester, `Pair ${randomUUID()}`);
const approved = await inject('POST', `/admin/book-requests/${audiobook.request.id}/approve`, moderator, {});
expect(approved.statusCode).toBe(200);
const queue = (await inject('GET', '/admin/book-requests?groupPairs=true&status=approved&limit=100', moderator)).json() as BookRequestPage;
const lead = queue.items.find((item) => item.id === ebook.request.id);
expect(lead?.status).toBe('pending');
expect(lead?.companion?.status).toBe('approved');
},
SCENARIO_TIMEOUT_MS,
);
it(
'joins a half somebody already asked for and links it to the half it created',
async () => {
const title = `Pair ${randomUUID()}`;
const existing = await submitOk(secondRequester, { title });
const { ebook, audiobook } = await submitPairOk(requester, title);
expect(ebook.subscribed).toBe(true);
expect(ebook.request.id).toBe(existing.request.id);
expect(audiobook.subscribed).toBe(false);
expect(ebook.request.companionRequestId).toBe(audiobook.request.id);
expect(audiobook.request.companionRequestId).toBe(existing.request.id);
},
SCENARIO_TIMEOUT_MS,
);
it(
'refuses the whole pair, writing neither half, when the rules refuse one of them',
async () => {
const title = `Pair ${randomUUID()}`;
const response = await submitPair(requester, title, { audiobook: { targetLibraryId: otherLibrary.libraryId } });
expect(response.statusCode).toBe(403);
expect((response.json() as { errorCode?: string }).errorCode).toBe('SUBMIT_LIBRARY_FORBIDDEN');
const rows = await ctx.db.select({ id: schema.bookRequests.id }).from(schema.bookRequests).where(eq(schema.bookRequests.title, title));
expect(rows).toHaveLength(0);
},
SCENARIO_TIMEOUT_MS,
);
it(
'files a missing format against the book it was asked from, and refuses a format the book already has',
async () => {
const folderLibrary = await createLibraryWithFolder(ctx, { mode: 'book_per_folder', name: `book-requests-folder-${randomUUID()}` });
await grantLibraryAccess(ctx, requester.userId, folderLibrary.libraryId, 'viewer');
const bookId = await seedEbookBook(folderLibrary);
const added = await inject('POST', '/book-requests', requester, {
title: `Joined ${randomUUID()}`,
mediaKind: 'audiobook',
authors: ['Fixture Author'],
targetBookId: bookId,
targetLibraryId: library.libraryId,
});
expect(added.statusCode).toBe(201);
const { request } = added.json() as BookRequestSubmitResult;
expect(request.targetBookId).toBe(bookId);
// The book decides where the format lands, whatever library the form had selected.
expect(request.targetLibraryId).toBe(folderLibrary.libraryId);
const duplicate = await inject('POST', '/book-requests', requester, {
title: `Joined ${randomUUID()}`,
mediaKind: 'ebook',
targetBookId: bookId,
});
expect(duplicate.statusCode).toBe(400);
expect((duplicate.json() as { errorCode?: string }).errorCode).toBe('SUBMIT_TARGET_BOOK_HAS_MEDIUM');
},
SCENARIO_TIMEOUT_MS,
);
it(
'treats a book in a library the requester cannot reach as one that does not exist',
async () => {
const hidden = await createLibraryWithFolder(ctx, { mode: 'book_per_folder', name: `book-requests-hidden-${randomUUID()}` });
const bookId = await seedEbookBook(hidden);
const response = await inject('POST', '/book-requests', requester, {
title: `Joined ${randomUUID()}`,
mediaKind: 'audiobook',
targetBookId: bookId,
});
expect(response.statusCode).toBe(400);
expect((response.json() as { errorCode?: string }).errorCode).toBe('SUBMIT_TARGET_BOOK_NOT_FOUND');
},
SCENARIO_TIMEOUT_MS,
);
});
describe('the retired comic medium', () => {
it(
'refuses a new comic request, writing nothing',
async () => {
const title = `Retired ${randomUUID()}`;
const response = await inject('POST', '/book-requests', requester, { title, mediaKind: 'comic', targetLibraryId: library.libraryId });
expect(response.statusCode).toBe(400);
const rows = await ctx.db.select({ id: schema.bookRequests.id }).from(schema.bookRequests).where(eq(schema.bookRequests.title, title));
expect(rows).toHaveLength(0);
},
SCENARIO_TIMEOUT_MS,
);
/** Released iOS builds decode `comic` here as required; leaving it out breaks their whole request screen. */
it(
'still answers comic, with no destination, among the default destinations',
async () => {
const response = await inject('GET', '/book-requests/default-destinations', requester);
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ comic: { libraryId: null, libraryName: null, folderId: null } });
},
SCENARIO_TIMEOUT_MS,
);
it(
'keeps an existing comic request readable and closable, but will not approve it',
async () => {
const created = await submitOk(requester);
await ctx.db.update(schema.bookRequests).set({ mediaKind: 'comic' }).where(eq(schema.bookRequests.id, created.request.id));
const read = await inject('GET', `/book-requests/${created.request.id}`, requester);
expect((read.json() as BookRequestItem).mediaKind).toBe('comic');
const approve = await inject('POST', `/admin/book-requests/${created.request.id}/approve`, moderator, {});
expect(approve.statusCode).toBe(400);
expect(await statusOf(created.request.id)).toBe('pending');
const cancel = await inject('POST', `/book-requests/${created.request.id}/cancel`, requester, {});
expect(cancel.statusCode).toBe(200);
expect(await statusOf(created.request.id)).toBe('cancelled');
},
SCENARIO_TIMEOUT_MS,
);
});
describe('held imports', () => {
it(
'discards a held import, removing the dock entry and failing the request',
@@ -1,5 +1,5 @@
{
"totalRoutes": 630,
"totalRoutes": 631,
"byPermission": {
"(none)": 278,
"Permission.ManageMetadataConfig": 34,
@@ -25,7 +25,7 @@
"Permission.HardcoverSync": 1,
"Permission.ReadwiseSync": 1,
"Permission.StorygraphSync": 1,
"Permission.BookRequestAccess": 25,
"Permission.BookRequestAccess": 26,
"Permission.ManageBookRequests": 20,
"Permission.BookRequestSelfFulfill": 11,
"Permission.PodcastManageFeeds": 16,
@@ -203,6 +203,18 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/book-request.controller.ts",
"className": "BookRequestController",
"methodName": "submitPair",
"httpMethod": "POST",
"path": "/book-requests/pairs",
"permissions": ["Permission.BookRequestAccess"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/book-request.controller.ts",
"className": "BookRequestController",