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