feat(cover): give books with both media separate ebook and audiobook covers

Keep one cover slot per medium and show the one each surface needs: the
library's shape for the face, square art on audio screens, the ebook slot
for Kobo and OPDS. Legacy covers convert on upgrade or on first read, and a
write that names no slot keeps working for released iOS builds. Also read
the dev client origin from one constant.
This commit is contained in:
neon
2026-09-23 19:55:25 +00:00
parent 4b86ff73cf
commit e28bd67fa3
298 changed files with 34879 additions and 2448 deletions
+150 -28
View File
@@ -1,13 +1,17 @@
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import { rm, stat } from 'fs/promises';
import { and, eq } from 'drizzle-orm';
import { readdir, rm, stat } from 'fs/promises';
import { join } from 'path';
import * as unzipper from 'unzipper';
import { Permission } from '@bookorbit/types';
import { books } from '../src/db/schema/books';
import { appSettings } from '../src/db/schema/auth';
import { bookCovers } from '../src/db/schema/book-covers';
import { bookFiles, books } from '../src/db/schema/books';
import { bookMetadata } from '../src/db/schema/metadata';
import { CoverSlotBackfillService } from '../src/modules/cover/cover-slot-backfill.service';
import {
authHeader,
@@ -23,6 +27,7 @@ import {
type TestUserSession,
} from './e2e/metadata-write/metadata-write-harness';
import { createCbzFixture, createEpubFixture, createPdfFixture, writeFixtureFile } from './e2e/metadata-write/metadata-write-fixture-builder';
import { createSlotCoverArtifacts } from './e2e/slot-cover-artifacts';
type InjectResponse = Awaited<ReturnType<MetadataWriteE2EContext['app']['inject']>>;
@@ -50,28 +55,6 @@ async function listZipEntries(response: InjectResponse): Promise<string[]> {
return zip.files.map((file) => file.path).sort();
}
async function createBookCoverArtifacts(
ctx: MetadataWriteE2EContext,
bookId: number,
options: {
coverExtension?: 'jpg' | 'png';
coverContent?: Buffer;
thumbnailContent?: Buffer;
} = {},
): Promise<void> {
const coverExtension = options.coverExtension ?? 'jpg';
await writeFixtureFile(
ctx.fixture.booksPath,
`covers/${bookId}/cover_custom.${coverExtension}`,
options.coverContent ?? Buffer.from(`cover-${bookId}`, 'utf8'),
);
await writeFixtureFile(
ctx.fixture.booksPath,
`covers/${bookId}/thumbnail.jpg`,
options.thumbnailContent ?? Buffer.from(`thumbnail-${bookId}`, 'utf8'),
);
}
describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
let ctx!: MetadataWriteE2EContext;
@@ -133,7 +116,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
await grantLibraryAccess(ctx, crossLibraryUser.userId, hiddenLibrary.libraryId, 'viewer');
await grantLibraryAccess(ctx, downloadUser.userId, visibleLibrary.libraryId, 'viewer');
await createBookCoverArtifacts(ctx, visibleEpub.bookId, {
await createSlotCoverArtifacts(ctx, visibleEpub.bookId, {
coverExtension: 'png',
coverContent: Buffer.from('cover-png', 'utf8'),
thumbnailContent: Buffer.from('thumbnail-jpg', 'utf8'),
@@ -344,6 +327,59 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
items: [{ id: visibleEpub.bookId, files: [{ id: visibleEpub.bookFileId, role: 'primary', sizeBytes: primaryFileSize }] }],
});
});
it('filters books with audiobook media by whether their audio cover slot is filled', async () => {
const [pdfFile] = await ctx.db
.select({ libraryFolderId: bookFiles.libraryFolderId })
.from(bookFiles)
.where(eq(bookFiles.id, visiblePdf.bookFileId));
const [audioFile] = await ctx.db
.insert(bookFiles)
.values({
bookId: visiblePdf.bookId,
libraryFolderId: pdfFile!.libraryFolderId,
absolutePath: join(visibleLibrary.folderPath, 'contracts/beta-contract.m4b'),
relPath: 'contracts/beta-contract.m4b',
ino: 987_654_321n,
format: 'm4b',
role: 'content',
})
.returning({ id: bookFiles.id });
const audioCoverBookIds = async (operator: 'isMissing' | 'isPresent') => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(limitedUser.accessToken),
payload: {
filter: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'audioCover', operator }] },
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 20 },
},
});
expect(response.statusCode).toBe(201);
return (response.json() as { items: { id: number }[] }).items.map((item) => item.id);
};
try {
expect(await audioCoverBookIds('isMissing')).toEqual([visiblePdf.bookId]);
expect(await audioCoverBookIds('isPresent')).toEqual([]);
await ctx.db.insert(bookCovers).values({ bookId: visiblePdf.bookId, medium: 'audio', source: 'custom', origin: 'upload' });
expect(await audioCoverBookIds('isMissing')).toEqual([]);
expect(await audioCoverBookIds('isPresent')).toEqual([visiblePdf.bookId]);
await ctx.db
.update(bookCovers)
.set({ dormantSince: new Date() })
.where(and(eq(bookCovers.bookId, visiblePdf.bookId), eq(bookCovers.medium, 'audio')));
expect(await audioCoverBookIds('isMissing')).toEqual([visiblePdf.bookId]);
expect(await audioCoverBookIds('isPresent')).toEqual([]);
} finally {
await ctx.db.delete(bookCovers).where(and(eq(bookCovers.bookId, visiblePdf.bookId), eq(bookCovers.medium, 'audio')));
await ctx.db.delete(bookFiles).where(eq(bookFiles.id, audioFile!.id));
}
});
});
describe('bulk query selections', () => {
@@ -482,6 +518,12 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
positionSeconds: null,
textUpdatedAt: null,
narrationPercentage: null,
narrationUpdatedAt: null,
mediaOverlaySectionIndex: null,
mediaOverlayFragment: null,
});
const limitedBookProgress = await ctx.app.inject({
@@ -518,6 +560,9 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
positionSeconds: null,
mediaOverlaySectionIndex: null,
mediaOverlayFragment: null,
updatedAt: null,
},
]);
@@ -534,7 +579,10 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
libraryId: visibleLibrary.libraryId,
libraryName: visibleLibraryName,
title: 'Alpha Contract EPUB',
coverSource: null,
coverSource: 'custom',
coverMedia: ['ebook'],
covers: { ebook: { source: 'custom', updatedAt: expect.any(String), width: null, height: null }, audio: null },
coverVersion: expect.stringMatching(/^ebook:/),
files: [
{
id: visibleEpub.bookFileId,
@@ -569,7 +617,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
headers: authHeader(limitedUser.accessToken),
});
expect(inaccessibleDetail.statusCode).toBe(403);
expect(inaccessibleDetail.statusCode).toBe(404);
});
});
@@ -684,6 +732,20 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
expect(cover.headers.etag).toBeTruthy();
expect(cover.headers['cache-control']).toBe('private, max-age=86400');
const invalidMedium = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/cover?medium=print`,
headers: authHeader(limitedUser.accessToken),
});
expectError(invalidMedium, 400);
const invalidStrict = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/cover?strict=yes`,
headers: authHeader(limitedUser.accessToken),
});
expectError(invalidStrict, 400);
const cachedCover = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleEpub.bookId}/cover`,
@@ -740,6 +802,66 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
expectError(missingThumbnail, 404, `No thumbnail for book ${visibleEpub.bookId}`);
});
it('restores a cover summary that disagrees with the slots when the upgrade runs, without moving updated_at', async () => {
await ctx.db.insert(bookCovers).values({ bookId: visibleCbz.bookId, medium: 'ebook', source: 'custom', origin: 'upload' });
await ctx.db.update(bookMetadata).set({ coverSource: null }).where(eq(bookMetadata.bookId, visibleCbz.bookId));
const [before] = await ctx.db
.select({ updatedAt: bookMetadata.updatedAt })
.from(bookMetadata)
.where(eq(bookMetadata.bookId, visibleCbz.bookId));
const marker = JSON.stringify({ version: 3, stage: 'c', lastBookId: 0 });
await ctx.db
.insert(appSettings)
.values({ key: 'cover_slots_backfill', value: marker })
.onConflictDoUpdate({ target: appSettings.key, set: { value: marker } });
try {
const result = await ctx.app.get(CoverSlotBackfillService).run();
expect(result.repaired).toBeGreaterThanOrEqual(1);
const [after] = await ctx.db
.select({ coverSource: bookMetadata.coverSource, updatedAt: bookMetadata.updatedAt })
.from(bookMetadata)
.where(eq(bookMetadata.bookId, visibleCbz.bookId));
expect(after).toEqual({ coverSource: 'custom', updatedAt: before!.updatedAt });
await expect(ctx.app.get(CoverSlotBackfillService).run()).resolves.toMatchObject({ repaired: 0 });
} finally {
await ctx.db.delete(bookCovers).where(eq(bookCovers.bookId, visibleCbz.bookId));
await ctx.db.update(bookMetadata).set({ coverSource: null }).where(eq(bookMetadata.bookId, visibleCbz.bookId));
}
});
it('moves a cover left in the old root layout into its slot on first read, with no coverSource needed', async () => {
const coverDir = join(ctx.fixture.booksPath, 'covers', String(visibleCbz.bookId));
await writeFixtureFile(ctx.fixture.booksPath, `covers/${visibleCbz.bookId}/cover_custom.jpg`, Buffer.from('legacy-cover', 'utf8'));
await writeFixtureFile(ctx.fixture.booksPath, `covers/${visibleCbz.bookId}/thumbnail.jpg`, Buffer.from('legacy-thumbnail', 'utf8'));
try {
const thumbnail = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleCbz.bookId}/thumbnail`,
headers: authHeader(limitedUser.accessToken),
});
expect(thumbnail.statusCode).toBe(200);
expect(thumbnail.body).toBe('legacy-thumbnail');
const slots = await ctx.db.select().from(bookCovers).where(eq(bookCovers.bookId, visibleCbz.bookId));
expect(slots).toEqual([expect.objectContaining({ medium: 'ebook', source: 'custom', origin: 'legacy', dormantSince: null })]);
expect(await readdir(coverDir)).toEqual(['ebook']);
expect((await readdir(join(coverDir, 'ebook'))).sort()).toEqual(['cover_custom.jpg', 'thumbnail.jpg']);
const cover = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${visibleCbz.bookId}/cover`,
headers: authHeader(limitedUser.accessToken),
});
expect(cover.statusCode).toBe(200);
expect(cover.body).toBe('legacy-cover');
} finally {
await ctx.db.delete(bookCovers).where(eq(bookCovers.bookId, visibleCbz.bookId));
await ctx.db.update(bookMetadata).set({ coverSource: null }).where(eq(bookMetadata.bookId, visibleCbz.bookId));
await rm(coverDir, { recursive: true, force: true });
}
});
});
describe('user-supplied added dates', () => {
+20 -2
View File
@@ -1,4 +1,7 @@
import { randomUUID } from 'crypto';
import { mkdtemp, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { expect, vi } from 'vitest';
import fastifyCookie from '@fastify/cookie';
import { count, eq, inArray, isNull, sql } from 'drizzle-orm';
@@ -104,9 +107,20 @@ export interface E2EContext {
app: NestFastifyApplication;
db: Db;
adminToken: string;
/** Set when the context owns a throwaway data folder, which close removes again. */
isolatedAppData: { path: string; previous: string | undefined } | null;
}
export async function createE2EContext(): Promise<E2EContext> {
/**
* Scans write and prune covers under APP_DATA_PATH, which `.env` points at the developer's own
* data folder. Suites that scan and do not set their own path pass `isolateAppData`.
*/
export async function createE2EContext(options: { isolateAppData?: boolean } = {}): Promise<E2EContext> {
let isolatedAppData: E2EContext['isolatedAppData'] = null;
if (options.isolateAppData) {
isolatedAppData = { path: await mkdtemp(join(tmpdir(), 'bookorbit-e2e-data-')), previous: process.env.APP_DATA_PATH };
process.env.APP_DATA_PATH = isolatedAppData.path;
}
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
@@ -130,11 +144,15 @@ export async function createE2EContext(): Promise<E2EContext> {
const db = app.get<Db>(DB);
const adminToken = await getAdminToken(app);
return { app, db, adminToken };
return { app, db, adminToken, isolatedAppData };
}
export async function closeE2EContext(ctx: E2EContext): Promise<void> {
await ctx.app.close();
if (!ctx.isolatedAppData) return;
if (ctx.isolatedAppData.previous === undefined) delete process.env.APP_DATA_PATH;
else process.env.APP_DATA_PATH = ctx.isolatedAppData.previous;
await rm(ctx.isolatedAppData.path, { recursive: true, force: true });
}
export interface SeedLibraryInput {
@@ -17,6 +17,7 @@ import * as schema from '../../../src/db/schema';
import { MetadataService } from '../../../src/modules/metadata/metadata.service';
import { BookDockWatcherService } from '../../../src/modules/book-dock/book-dock-watcher.service';
import { makeMetadataNoopMock, seedLibrary, waitForScanCompletion, type MetadataNoopMock } from '../app-harness';
import { createSlotCoverArtifacts, type SlotCoverArtifactOptions } from '../slot-cover-artifacts';
import {
buildFb2Fixture,
createAuthorizationFixtureRoot,
@@ -310,18 +311,9 @@ export async function locateBookByAbsolutePath(ctx: AuthorizationMatrixE2EContex
export async function createBookCoverArtifacts(
ctx: AuthorizationMatrixE2EContext,
bookId: number,
options: {
coverExtension?: 'jpg' | 'png';
coverContent?: Buffer;
thumbnailContent?: Buffer;
} = {},
options: SlotCoverArtifactOptions = {},
): Promise<void> {
const coverExtension = options.coverExtension ?? 'jpg';
const coverContent = options.coverContent ?? Buffer.from(`cover-${bookId}`, 'utf8');
const thumbnailContent = options.thumbnailContent ?? Buffer.from(`thumbnail-${bookId}`, 'utf8');
await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/cover.${coverExtension}`, coverContent);
await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/thumbnail.jpg`, thumbnailContent);
await createSlotCoverArtifacts(ctx, bookId, options);
}
export async function createBookDockRow(
@@ -1,7 +1,7 @@
{
"totalRoutes": 612,
"totalRoutes": 614,
"byPermission": {
"(none)": 260,
"(none)": 262,
"Permission.ManageMetadataConfig": 34,
"Permission.EmailSend": 30,
"Permission.LibraryEditMetadata": 35,
@@ -515,6 +515,18 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/reader-preferences/reader-preferences.controller.ts",
"className": "ReaderPreferencesController",
"methodName": "patchPreference",
"httpMethod": "PATCH",
"path": "/reader/preferences/:bookFileId",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/reader-preferences/reader-preferences.controller.ts",
"className": "ReaderPreferencesController",
@@ -551,6 +563,18 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/reader-preferences/reader-preferences.controller.ts",
"className": "ReaderPreferencesController",
"methodName": "patchDefault",
"httpMethod": "PATCH",
"path": "/reader/defaults/:formatGroup",
"permissions": [],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/reader-preferences/reader-preferences.controller.ts",
"className": "ReaderPreferencesController",
@@ -339,9 +339,12 @@ export async function findCoverFilePath(
prefix: string = COVER_EXTRACTED_FILE_PREFIX,
): Promise<string | null> {
const dir = coverDirPath(ctx.fixture.booksPath, bookId);
const entries = await readdir(dir).catch(() => [] as string[]);
const fileName = entries.find((entry) => entry.startsWith(prefix));
return fileName ? join(dir, fileName) : null;
for (const candidate of [dir, join(dir, 'ebook'), join(dir, 'audio')]) {
const entries = await readdir(candidate).catch(() => [] as string[]);
const fileName = entries.find((entry) => entry.startsWith(prefix));
if (fileName) return join(candidate, fileName);
}
return null;
}
export async function uploadBookCover(
+4 -21
View File
@@ -14,7 +14,8 @@ import * as schema from '../../../src/db/schema';
import { MetadataService } from '../../../src/modules/metadata/metadata.service';
import { BookDockWatcherService } from '../../../src/modules/book-dock/book-dock-watcher.service';
import { seedLibrary, waitForScanCompletion } from '../app-harness';
import { createOpdsFixtureRoot, type OpdsFixtureRoot, writeFixtureFile } from './opds-fixture-builder';
import { createSlotCoverArtifacts, type SlotCoverArtifactOptions } from '../slot-cover-artifacts';
import { createOpdsFixtureRoot, type OpdsFixtureRoot } from './opds-fixture-builder';
type Db = NodePgDatabase<typeof schema>;
@@ -193,26 +194,8 @@ export async function locateBookByAbsolutePath(ctx: OpdsE2EContext, absolutePath
return row;
}
export async function createBookCoverArtifacts(
ctx: OpdsE2EContext,
bookId: number,
options: {
coverExtension?: 'jpg' | 'png';
coverContent?: Buffer;
thumbnailContent?: Buffer;
} = {},
): Promise<void> {
const coverExtension = options.coverExtension ?? 'jpg';
const coverContent = options.coverContent ?? Buffer.from(`cover-${bookId}`, 'utf8');
const thumbnailContent = options.thumbnailContent ?? Buffer.from(`thumbnail-${bookId}`, 'utf8');
await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/cover.${coverExtension}`, coverContent);
await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/thumbnail.jpg`, thumbnailContent);
await ctx.db
.insert(schema.bookMetadata)
.values({ bookId, coverSource: 'custom' })
.onConflictDoUpdate({ target: schema.bookMetadata.bookId, set: { coverSource: 'custom' } });
export async function createBookCoverArtifacts(ctx: OpdsE2EContext, bookId: number, options: SlotCoverArtifactOptions = {}): Promise<void> {
await createSlotCoverArtifacts(ctx, bookId, options);
}
export async function createUserAndLogin(
@@ -0,0 +1,43 @@
import { ZipArchive } from 'archiver';
import { createWriteStream } from 'fs';
import { mkdir, writeFile } from 'fs/promises';
import { dirname } from 'path';
import sharp from 'sharp';
export function jpeg(width: number, height: number): Promise<Buffer> {
return sharp({ create: { width, height, channels: 3, background: { r: 40, g: 110, b: 180 } } })
.jpeg()
.toBuffer();
}
/** A minimal EPUB 3 whose manifest names a cover image, so the scanner extracts real art from it. */
export async function writeEpubWithCover(path: string, cover: Buffer): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const opf = `<?xml version="1.0"?><package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="uid">urn:e2e:cover-slots</dc:identifier><dc:title>Cover Slots</dc:title><dc:language>en</dc:language></metadata>
<manifest><item id="cover" href="cover.jpg" media-type="image/jpeg" properties="cover-image" /><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml" /></manifest>
<spine><itemref idref="c1" /></spine></package>`;
const output = createWriteStream(path);
const archive = new ZipArchive({ zlib: { level: 6 } });
await new Promise<void>((resolve, reject) => {
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
archive.append(Buffer.from('application/epub+zip'), { name: 'mimetype', store: true });
archive.append(
Buffer.from(
'<?xml version="1.0"?><container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml" /></rootfiles></container>',
),
{ name: 'META-INF/container.xml' },
);
archive.append(Buffer.from(opf), { name: 'OPS/content.opf' });
archive.append(Buffer.from('<html xmlns="http://www.w3.org/1999/xhtml"><body><p>x</p></body></html>'), { name: 'OPS/c1.xhtml' });
archive.append(cover, { name: 'OPS/cover.jpg' });
void archive.finalize();
});
}
export async function writeImage(path: string, width: number, height: number): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, await jpeg(width, height));
}
+6 -2
View File
@@ -1,7 +1,6 @@
export {
assertNoIntegrityViolations,
closeE2EContext as closeScannerE2EContext,
createE2EContext as createScannerE2EContext,
loadLibraryBookState,
seedLibrary,
triggerAndWaitForLibraryScan,
@@ -14,7 +13,12 @@ export {
} from '../app-harness';
import { FileWatcherService } from '../../../src/modules/scanner/file-watcher.service';
import type { E2EContext } from '../app-harness';
import { createE2EContext, type E2EContext } from '../app-harness';
/** Scans write covers, so every scanner suite gets its own data folder. */
export function createScannerE2EContext(): Promise<E2EContext> {
return createE2EContext({ isolateAppData: true });
}
export async function startLibraryWatcher(ctx: E2EContext, libraryId: number, paths: string[]): Promise<void> {
const watcher = ctx.app.get(FileWatcherService);
+44
View File
@@ -0,0 +1,44 @@
import { mkdir, writeFile } from 'fs/promises';
import { join } from 'path';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { CoverMedium } from '@bookorbit/types';
import { COVER_THUMBNAIL_FILE_NAME, bookCoverSlotDirPath } from '../../src/common/book-cover-storage';
import * as schema from '../../src/db/schema';
type SlotCoverContext = {
db: NodePgDatabase<typeof schema>;
fixture: { booksPath: string };
};
export type SlotCoverArtifactOptions = {
medium?: CoverMedium;
source?: 'custom' | 'extracted';
coverExtension?: 'jpg' | 'png';
coverContent?: Buffer;
thumbnailContent?: Buffer;
};
/**
* Lays a cover out the way the cover store does: the files in the slot's directory, the slot row,
* and the `cover_source` summary beside it. The bytes need not be an image; the row leaves the
* measured size empty, as the store does for anything it cannot read.
*/
export async function createSlotCoverArtifacts(ctx: SlotCoverContext, bookId: number, options: SlotCoverArtifactOptions = {}): Promise<void> {
const medium = options.medium ?? 'ebook';
const source = options.source ?? 'custom';
const dir = bookCoverSlotDirPath(ctx.fixture.booksPath, bookId, medium);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, `cover_${source}.${options.coverExtension ?? 'jpg'}`), options.coverContent ?? Buffer.from(`cover-${bookId}`, 'utf8'));
await writeFile(join(dir, COVER_THUMBNAIL_FILE_NAME), options.thumbnailContent ?? Buffer.from(`thumbnail-${bookId}`, 'utf8'));
const row = { source, origin: source === 'custom' ? 'upload' : 'embedded', width: null, height: null, updatedAt: new Date(), dormantSince: null };
await ctx.db
.insert(schema.bookCovers)
.values({ bookId, medium, ...row })
.onConflictDoUpdate({ target: [schema.bookCovers.bookId, schema.bookCovers.medium], set: row });
await ctx.db
.insert(schema.bookMetadata)
.values({ bookId, coverSource: source })
.onConflictDoUpdate({ target: schema.bookMetadata.bookId, set: { coverSource: source } });
}
+5 -1
View File
@@ -217,8 +217,12 @@ describe('Email lifecycle (e2e)', { timeout: 180_000 }, () => {
expect(senderListBeforeShare.statusCode).toBe(200);
expect(senderListBeforeShare.json()).toEqual([]);
const senderToggleShare = await apiAs(senderOnly, 'PATCH', `/api/v1/email/providers/${createdProvider.id}/share`);
expectError(senderToggleShare, 403, Permission.ManageEmail);
const managerToggleShare = await apiAs(managerOnly, 'PATCH', `/api/v1/email/providers/${createdProvider.id}/share`);
expectError(managerToggleShare, 403, 'Only superusers can share providers');
expect(managerToggleShare.statusCode).toBe(200);
expect((managerToggleShare.json() as { isShared: boolean }).isShared).toBe(true);
const superToggleShare = await apiAs(superManager, 'PATCH', `/api/v1/email/providers/${createdProvider.id}/share`);
expectError(superToggleShare, 403, 'Cannot modify this provider');
@@ -4,6 +4,7 @@ import { join } from 'path';
import { and, eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { CoverSlotBackfillService } from '../src/modules/cover/cover-slot-backfill.service';
import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
import {
authHeader,
@@ -179,6 +180,30 @@ describe('Kobo cover image identity (e2e)', { timeout: 180_000 }, () => {
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
});
it('keeps the CoverImageId when Stage A converts the legacy cover', async () => {
const coverIdBefore = coverIdHeldByDevice;
const [before] = await ctx.db
.select({ coverUpdatedAt: schema.bookMetadata.coverUpdatedAt })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, bookId));
const result = await ctx.app.get(CoverSlotBackfillService).run();
expect(result.converted).toBeGreaterThanOrEqual(1);
const [after] = await ctx.db
.select({ coverUpdatedAt: schema.bookMetadata.coverUpdatedAt })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, bookId));
const [slot] = await ctx.db.select().from(schema.bookCovers).where(eq(schema.bookCovers.bookId, bookId));
expect(slot).toMatchObject({ medium: 'ebook', source: 'custom', origin: 'legacy' });
expect(after!.coverUpdatedAt!.getTime()).toBe(before!.coverUpdatedAt!.getTime());
await editMetadata({ title: 'Kobo Cover Identity, Converted' });
const delivered = await drainDelivered();
expect(delivered).toHaveLength(1);
expect(delivered[0]!.CoverImageId).toBe(coverIdBefore);
});
it('keeps the CoverImageId across a metadata edit while still delivering the change', async () => {
const coverIdBefore = coverIdHeldByDevice;
expect(await thumbnailStatus(coverIdBefore)).toBe(200);
@@ -20,7 +20,8 @@ import {
type AuthorizationMatrixE2EContext,
type TestUserSession,
} from './e2e/authorization-matrix/authorization-matrix-harness';
import { createEpubFixture, writeFixtureFile } from './e2e/authorization-matrix/authorization-matrix-fixture-builder';
import { createEpubFixture } from './e2e/authorization-matrix/authorization-matrix-fixture-builder';
import { createSlotCoverArtifacts } from './e2e/slot-cover-artifacts';
type InjectResponse = Awaited<ReturnType<AuthorizationMatrixE2EContext['app']['inject']>>;
@@ -734,7 +735,8 @@ describe('Library admin workflows (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =
format: 'epub',
});
expect(uploadedBookFile?.absolutePath).toContain('Uploaded Contract Title.epub');
expect(uploadedBookFile?.relPath).toBe('Uploaded Contract Title.epub');
// The default upload pattern files a book under its first author, and this fixture names none.
expect(uploadedBookFile?.relPath).toBe('Unknown Author/Uploaded Contract Title.epub');
const uploadedMetadataRow = await ctx.db.query.bookMetadata.findFirst({
where: eq(schema.bookMetadata.bookId, uploadBody.bookId),
@@ -880,8 +882,10 @@ describe('Library admin workflows (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =
},
]);
await writeFixtureFile(ctx.fixture.booksPath, `covers/${uploadedBookId}/cover_custom.jpg`, Buffer.from('cover', 'utf8'));
await writeFixtureFile(ctx.fixture.booksPath, `covers/${uploadedBookId}/thumbnail.jpg`, Buffer.from('thumb', 'utf8'));
await createSlotCoverArtifacts(ctx, uploadedBookId, {
coverContent: Buffer.from('cover', 'utf8'),
thumbnailContent: Buffer.from('thumb', 'utf8'),
});
const reorderResponse = await ctx.app.inject({
method: 'POST',
+5 -5
View File
@@ -758,12 +758,12 @@ describe('Migration Booklore API to DB (e2e)', { timeout: 240_000 }, () => {
const isbnCoverDir = coverDirectoryForBook(ctx, scenario.books.isbn.bookId);
const audioCoverDir = coverDirectoryForBook(ctx, scenario.books.audio.bookId);
await access(join(isbnCoverDir, 'cover_custom.jpg'));
await access(join(isbnCoverDir, 'thumbnail.jpg'));
await access(join(audioCoverDir, 'cover_custom.png'));
await access(join(audioCoverDir, 'thumbnail.jpg'));
await access(join(isbnCoverDir, 'ebook', 'cover_custom.jpg'));
await access(join(isbnCoverDir, 'ebook', 'thumbnail.jpg'));
await access(join(audioCoverDir, 'audio', 'cover_custom.png'));
await access(join(audioCoverDir, 'audio', 'thumbnail.jpg'));
const audioCoverBytes = await readFile(join(audioCoverDir, 'cover_custom.png'));
const audioCoverBytes = await readFile(join(audioCoverDir, 'audio', 'cover_custom.png'));
expect(audioCoverBytes[0]).toBe(0x89);
expect(audioCoverBytes[1]).toBe(0x50);
+205
View File
@@ -0,0 +1,205 @@
import { and, eq, inArray } from 'drizzle-orm';
import { rm } from 'fs/promises';
import { join } from 'path';
import * as schema from '../src/db/schema';
import {
authHeader,
closeMetadataWriteE2EContext,
createLibraryWithFolder,
createMetadataWriteE2EContext,
locateBookFileByRelPath,
triggerAndWaitForLibraryScan,
type MetadataWriteE2EContext,
} from './e2e/metadata-write/metadata-write-harness';
import { createEpubFixture, createPdfFixture, writeFixtureFile } from './e2e/metadata-write/metadata-write-fixture-builder';
const SCENARIO_TIMEOUT_MS = 120_000;
type Sweep = {
status: 'running' | 'completed' | 'failed';
brokenCovers: number;
orphanedCoverDirs: number;
};
async function waitForSweep(ctx: MetadataWriteE2EContext): Promise<Sweep> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/maintenance/missing-resources/sweep',
headers: authHeader(ctx.adminToken),
});
expect(response.statusCode).toBe(200);
const sweep = response.json() as Sweep | null;
if (sweep?.status === 'completed') return sweep;
if (sweep?.status === 'failed') throw new Error('Missing Resources sweep failed');
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error('Missing Resources sweep did not complete');
}
describe('Missing Resources slot covers (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
let ctx!: MetadataWriteE2EContext;
beforeAll(async () => {
ctx = await createMetadataWriteE2EContext();
}, 60_000);
afterAll(async () => {
await closeMetadataWriteE2EContext(ctx);
});
it('finds, measures, and cleans slot-aware cover resources', async () => {
const library = await createLibraryWithFolder(ctx, { name: 'Missing Resources slots' });
await createEpubFixture(library.folderPath, 'covers/valid.epub', { title: 'Valid slot cover' });
await createPdfFixture(library.folderPath, 'covers/broken.pdf', 'Broken slot cover');
await createEpubFixture(library.folderPath, 'covers/half.epub', { title: 'Half broken slot covers' });
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
const valid = await locateBookFileByRelPath(ctx, library.libraryId, 'covers/valid.epub');
const broken = await locateBookFileByRelPath(ctx, library.libraryId, 'covers/broken.pdf');
const half = await locateBookFileByRelPath(ctx, library.libraryId, 'covers/half.epub');
const now = new Date();
await ctx.db.delete(schema.bookCovers).where(inArray(schema.bookCovers.bookId, [valid.bookId, broken.bookId, half.bookId]));
await ctx.db.insert(schema.bookCovers).values([
{
bookId: valid.bookId,
medium: 'ebook',
source: 'extracted',
origin: 'embedded',
updatedAt: now,
},
{
bookId: broken.bookId,
medium: 'ebook',
source: 'extracted',
origin: 'embedded',
updatedAt: now,
},
{ bookId: half.bookId, medium: 'ebook', source: 'extracted', origin: 'embedded', updatedAt: now },
{ bookId: half.bookId, medium: 'audio', source: 'custom', origin: 'upload', updatedAt: now },
]);
await ctx.db
.update(schema.bookMetadata)
.set({ coverSource: 'extracted', coverUpdatedAt: now, updatedAt: now })
.where(eq(schema.bookMetadata.bookId, valid.bookId));
await ctx.db
.update(schema.bookMetadata)
.set({ coverSource: 'extracted', coverUpdatedAt: now, updatedAt: now })
.where(eq(schema.bookMetadata.bookId, broken.bookId));
await ctx.db
.update(schema.bookMetadata)
.set({ coverSource: 'custom', coverUpdatedAt: now, updatedAt: now })
.where(eq(schema.bookMetadata.bookId, half.bookId));
await writeFixtureFile(ctx.fixture.booksPath, `covers/${valid.bookId}/ebook/cover_extracted.jpg`, Buffer.from('valid-cover'));
await rm(join(ctx.fixture.booksPath, 'covers', String(broken.bookId)), { recursive: true, force: true });
await rm(join(ctx.fixture.booksPath, 'covers', String(half.bookId)), { recursive: true, force: true });
await writeFixtureFile(ctx.fixture.booksPath, `covers/${half.bookId}/ebook/cover_extracted.jpg`, Buffer.from('half-cover'));
const orphanBookId = 2_000_000_000;
const orphanCover = Buffer.from('orphan-cover');
const orphanThumbnail = Buffer.from('orphan-thumbnail');
await writeFixtureFile(ctx.fixture.booksPath, `covers/${orphanBookId}/audio/cover_custom.jpg`, orphanCover);
await writeFixtureFile(ctx.fixture.booksPath, `covers/${orphanBookId}/audio/thumbnail.jpg`, orphanThumbnail);
const detailResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${valid.bookId}`,
headers: authHeader(ctx.adminToken),
});
expect(detailResponse.statusCode).toBe(200);
expect(detailResponse.json()).toMatchObject({
coverMedia: ['ebook'],
covers: {
ebook: { source: 'extracted', updatedAt: now.toISOString(), width: null, height: null },
audio: null,
},
coverVersion: `ebook:${now.toISOString()}:-`,
});
const cardsResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(ctx.adminToken),
payload: { pagination: { page: 0, size: 50 } },
});
expect(cardsResponse.statusCode).toBe(201);
const cards = (cardsResponse.json() as { items: Array<{ id: number; coverVersion: string }> }).items;
expect(cards.find((card) => card.id === valid.bookId)?.coverVersion).toBe(`ebook:${now.toISOString()}:-`);
const start = await ctx.app.inject({
method: 'POST',
url: '/api/v1/maintenance/missing-resources/sweep',
headers: authHeader(ctx.adminToken),
});
expect(start.statusCode).toBe(201);
const sweep = await waitForSweep(ctx);
expect(sweep).toMatchObject({ brokenCovers: 2, orphanedCoverDirs: 1 });
const brokenResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/maintenance/missing-resources/broken-covers',
headers: authHeader(ctx.adminToken),
});
expect(brokenResponse.statusCode).toBe(200);
const brokenPage = brokenResponse.json() as { total: number; items: Array<{ id: number }> };
expect(brokenPage.total).toBe(2);
expect(brokenPage.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: broken.bookId, coverSource: 'extracted', slots: [{ medium: 'ebook', source: 'extracted' }] }),
expect.objectContaining({ id: half.bookId, coverSource: 'custom', slots: [{ medium: 'audio', source: 'custom' }] }),
]),
);
const orphanResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/maintenance/missing-resources/orphaned-covers',
headers: authHeader(ctx.adminToken),
});
expect(orphanResponse.statusCode).toBe(200);
expect(orphanResponse.json()).toMatchObject({
total: 1,
items: [
{
bookId: orphanBookId,
fileCount: 2,
sizeBytes: orphanCover.length + orphanThumbnail.length,
media: ['audio'],
},
],
});
const cleanup = await ctx.app.inject({
method: 'POST',
url: '/api/v1/maintenance/missing-resources/broken-covers/clean',
headers: authHeader(ctx.adminToken),
payload: { bookIds: [broken.bookId, half.bookId] },
});
expect(cleanup.statusCode).toBe(201);
expect(cleanup.json()).toMatchObject({ category: 'broken_covers', requested: 2, cleaned: 2, skipped: 0, remaining: 0 });
const [removedSlot] = await ctx.db
.select()
.from(schema.bookCovers)
.where(and(eq(schema.bookCovers.bookId, broken.bookId), eq(schema.bookCovers.medium, 'ebook')));
const [brokenMetadata] = await ctx.db
.select({ coverSource: schema.bookMetadata.coverSource, coverUpdatedAt: schema.bookMetadata.coverUpdatedAt })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, broken.bookId));
const [validSlot] = await ctx.db.select().from(schema.bookCovers).where(eq(schema.bookCovers.bookId, valid.bookId));
expect(removedSlot).toBeUndefined();
expect(brokenMetadata).toMatchObject({ coverSource: null, coverUpdatedAt: expect.any(Date) });
expect(validSlot).toBeDefined();
const halfSlots = await ctx.db
.select({ medium: schema.bookCovers.medium })
.from(schema.bookCovers)
.where(eq(schema.bookCovers.bookId, half.bookId));
const [halfMetadata] = await ctx.db
.select({ coverSource: schema.bookMetadata.coverSource })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, half.bookId));
expect(halfSlots).toEqual([{ medium: 'ebook' }]);
expect(halfMetadata).toEqual({ coverSource: 'extracted' });
});
});
@@ -7,6 +7,7 @@ import { and, count, eq, like } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import { WATCHER_DEBOUNCE_MS } from '../src/modules/scanner/file-watcher.service';
import type { FixtureEntry, FixtureTree } from './e2e/scanner/scanner-fixture-builder';
import { jpeg, writeEpubWithCover, writeImage } from './e2e/scanner/cover-slot-fixtures';
import { createFixtureTree, file } from './e2e/scanner/scanner-fixture-builder';
import {
assertNoIntegrityViolations,
@@ -623,7 +624,8 @@ const structuralScenarios: StructuralScenario[] = [
requiresCaseRename: true,
},
{
id: 'watcher-non-content-file-creates-ignored',
// A folder image can fill an empty cover slot, so a new one rescans its book folder.
id: 'watcher-folder-image-create-rescans-book',
trigger: 'watcher',
libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }],
entries: [file('lib-a/Book/book.epub')],
@@ -631,15 +633,22 @@ const structuralScenarios: StructuralScenario[] = [
{ type: 'writeFile', path: 'lib-a/Book/cover.jpg' },
{ type: 'writeFile', path: 'lib-a/Book/metadata.opf' },
],
expected: { a: { statusByFolder: { Book: 'present' }, presentCount: 1, missingCount: 0 } },
expected: {
a: {
statusByFolder: { Book: 'present' },
fileOwners: { 'Book/book.epub': 'Book', 'Book/cover.jpg': 'Book', 'Book/metadata.opf': 'Book' },
presentCount: 1,
missingCount: 0,
},
},
},
{
id: 'watcher-non-content-file-delete-ignored',
id: 'watcher-folder-image-delete-keeps-book',
trigger: 'watcher',
libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }],
entries: [file('lib-a/Book/book.epub'), file('lib-a/Book/cover.jpg')],
operations: [{ type: 'deleteFile', path: 'lib-a/Book/cover.jpg' }],
expected: { a: { statusByFolder: { Book: 'present' }, presentCount: 1, missingCount: 0 } },
expected: { a: { statusByFolder: { Book: 'present' }, absentFilePaths: ['Book/cover.jpg'], presentCount: 1, missingCount: 0 } },
},
{
id: 'watcher-book-per-file-delete-then-restore',
@@ -1245,6 +1254,50 @@ async function runStatefulScenario(context: ScannerE2EContext, scenario: Statefu
}
const statefulScenarios: StatefulScenario[] = [
{
id: 'stateful-mixed-media-cover-slots',
trigger: 'manual',
fixturePrefix: 'scanner-file-ops-cover-slots-',
fixtureEntries: [file('lib-a/Book/01.mp3')],
run: async (context, fixture) => {
await writeEpubWithCover(join(fixture.rootPath, 'lib-a/Book/book.epub'), await jpeg(200, 300));
await writeImage(join(fixture.rootPath, 'lib-a/Book/folder.jpg'), 500, 500);
const rootA = await realpath(join(fixture.rootPath, 'lib-a'));
const library = await seedLibrary(context.db, {
rootPath: rootA,
mode: 'book_per_folder',
watch: false,
name: `scanner-file-ops-cover-slots-${randomUUID()}`,
});
await triggerAndWaitForLibraryScan(context, library.libraryId);
const book = await getBookByFolderPath(context.db, library.libraryId, join(rootA, 'Book'));
const slots = async () =>
Object.fromEntries(
(await context.db.select().from(schema.bookCovers).where(eq(schema.bookCovers.bookId, book.id))).map((slot) => [slot.medium, slot]),
);
// The EPUB's own art is the book cover; the mp3 has none, so the square folder image is the audiobook cover.
expect(await slots()).toMatchObject({
ebook: { origin: 'embedded', source: 'extracted', width: 200, height: 300, dormantSince: null },
audio: { origin: 'folder_image', source: 'extracted', width: 500, height: 500, dormantSince: null },
});
const coverDir = join(context.isolatedAppData!.path, 'covers', String(book.id));
await expect(stat(join(coverDir, 'ebook', 'thumbnail.jpg'))).resolves.toBeDefined();
await expect(stat(join(coverDir, 'audio', 'thumbnail.jpg'))).resolves.toBeDefined();
await applyOperation(fixture.rootPath, { type: 'deleteFile', path: 'lib-a/Book/01.mp3' });
await triggerAndWaitForLibraryScan(context, library.libraryId);
expect((await slots()).audio?.dormantSince).toBeInstanceOf(Date);
const [metadata] = await context.db.select().from(schema.bookMetadata).where(eq(schema.bookMetadata.bookId, book.id));
expect(metadata?.coverSource).toBe('extracted');
await applyOperation(fixture.rootPath, { type: 'writeFile', path: 'lib-a/Book/01.mp3' });
await triggerAndWaitForLibraryScan(context, library.libraryId);
expect((await slots()).audio).toMatchObject({ origin: 'folder_image', dormantSince: null });
await assertNoIntegrityViolations(context.db);
},
},
{
id: 'stateful-manual-delete-primary-preserves-data',
trigger: 'manual',