mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
d21c68d13c
Add a Chapters tab to edit chapter timing and titles, look chapters up on Audible or in the ebook, snap starts to pauses, keep a version history and write chapters into M4B, M4A and MP3 files. Add a chapter health tool with a bulk Audible lookup queue. Editing needs Editor access to the book's library. Chapter edits keep released iOS playback working by accepting a stale manifest revision while the track layout is unchanged. Ref #1120
681 lines
29 KiB
TypeScript
681 lines
29 KiB
TypeScript
import { ScannerService } from '../src/modules/scanner/scanner.service';
|
|
import { eq } from 'drizzle-orm';
|
|
import { bookMetadata, bookAuthors, bookGenres } from '../src/db/schema';
|
|
import { randomUUID } from 'crypto';
|
|
import { readFile, rename } from 'fs/promises';
|
|
import { join } from 'path';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { MetadataProviderKey } from '@bookorbit/types';
|
|
|
|
import { MetadataPreferencesService } from '../src/modules/metadata-preferences/metadata-preferences.service';
|
|
|
|
import { buildMetadataLockCandidates } from './e2e/metadata-lock/metadata-lock-test-providers';
|
|
import {
|
|
BLUE_PNG_BYTES,
|
|
COVER_CUSTOM_FILE_PREFIX,
|
|
GREEN_PNG_BYTES,
|
|
RED_PNG_BYTES,
|
|
authHeader,
|
|
closeMetadataLockE2EContext,
|
|
createLibraryWithFolder,
|
|
createMetadataLockE2EContext,
|
|
findCoverFilePath,
|
|
locateBookFileByRelPath,
|
|
readBookMutationState,
|
|
triggerAndWaitForLibraryScan,
|
|
uploadBookCover,
|
|
waitForCondition,
|
|
waitForScanCompletion,
|
|
waitForMetadataFetchIdle,
|
|
type MetadataLockE2EContext,
|
|
} from './e2e/metadata-lock/metadata-lock-harness';
|
|
import { createCbzFixture, createEpubFixture, createEpubWithCoverFixture } from './e2e/metadata-lock/metadata-lock-fixture-builder';
|
|
|
|
describe('metadata-lock e2e', { timeout: 30_000 }, () => {
|
|
let ctx: MetadataLockE2EContext;
|
|
|
|
beforeAll(async () => {
|
|
ctx = await createMetadataLockE2EContext();
|
|
// The stand-in Goodreads answers every field, the audiobook ones included, while the default
|
|
// rules for those name only the audiobook sources that really carry them, and keep a measured
|
|
// duration. These tests are about locks, so those rules say what the others do: overwrite
|
|
// whatever is not locked, from the one source that answers.
|
|
const preferences = ctx.app.get(MetadataPreferencesService);
|
|
const global = await preferences.getGlobal();
|
|
for (const field of ['narrators', 'duration', 'abridged'] as const) {
|
|
global.fields[field] = { enabled: true, providers: [MetadataProviderKey.GOODREADS], mergeStrategy: 'overwriteIfProvided' };
|
|
}
|
|
await preferences.setGlobal(global);
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await closeMetadataLockE2EContext(ctx);
|
|
});
|
|
|
|
it.each(['book_per_file', 'book_per_folder'] as const)(
|
|
'merges relative regex metadata and preserves unchanged and locked fields (%s)',
|
|
async (mode) => {
|
|
const library = await createLibraryWithFolder(ctx, { mode });
|
|
const fullScan = async () => {
|
|
const { jobId } = await ctx.app.get(ScannerService).startScan(library.libraryId, 'manual', true);
|
|
await waitForScanCompletion(ctx.db, jobId);
|
|
};
|
|
const relPath = 'My.Series/08 - File.Name.epub';
|
|
await createEpubFixture(library.folderPath, relPath, { title: 'Embedded title' });
|
|
const config = { rules: [{ pattern: '^(?<series>[^/]+)/', flags: '' }] };
|
|
const configure = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/libraries/${library.libraryId}`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { icon: 'BookOpen', regexMetadata: config, metadataPrecedence: ['regex', 'embedded', 'opfFile'] },
|
|
});
|
|
expect(configure.statusCode).toBe(200);
|
|
expect(configure.json().regexMetadata).toEqual(config);
|
|
const preview = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/libraries/regex-metadata/preview',
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { libraryId: library.libraryId, config, relativePath: relPath },
|
|
});
|
|
expect(preview.statusCode).toBe(200);
|
|
expect(preview.json()).toMatchObject({ inputPath: 'My.Series/08 - File.Name', metadata: { seriesName: 'My.Series' } });
|
|
await fullScan();
|
|
const located = await locateBookFileByRelPath(ctx, library.libraryId, relPath);
|
|
const read = async () => (await ctx.db.select().from(bookMetadata).where(eq(bookMetadata.bookId, located.bookId)))[0];
|
|
expect(await read()).toMatchObject({ title: 'Embedded title', seriesName: 'My.Series' });
|
|
const originalAuthors = await ctx.db.select().from(bookAuthors).where(eq(bookAuthors.bookId, located.bookId));
|
|
const changed = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/libraries/${library.libraryId}`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { regexMetadata: { rules: [{ pattern: '^(?<series>[^/]+)/(?<seriesIndex>[^ ]+) - (?<title>.+)$', flags: '' }] } },
|
|
});
|
|
expect(changed.statusCode).toBe(200);
|
|
await fullScan();
|
|
expect(await read()).toMatchObject({ title: 'Embedded title', seriesIndex: null });
|
|
expect((await patchLocks(located.bookId, ['title'])).statusCode).toBe(200);
|
|
await pause(1100);
|
|
await createEpubFixture(library.folderPath, relPath, { title: 'Changed embedded title with a different size' });
|
|
await fullScan();
|
|
expect(await read()).toMatchObject({ title: 'Embedded title', seriesName: 'My.Series', seriesIndex: '08' });
|
|
expect(await ctx.db.select().from(bookAuthors).where(eq(bookAuthors.bookId, located.bookId))).toEqual(originalAuthors);
|
|
},
|
|
);
|
|
|
|
it.each(['book_per_file', 'book_per_folder'] as const)('preserves unlocked relations on a partial regex rescan (%s)', async (mode) => {
|
|
const library = await createLibraryWithFolder(ctx, { mode });
|
|
const fullScan = async () => {
|
|
const { jobId } = await ctx.app.get(ScannerService).startScan(library.libraryId, 'manual', true);
|
|
await waitForScanCompletion(ctx.db, jobId);
|
|
};
|
|
const relPath = 'Path Series/book.epub';
|
|
await createEpubWithCoverFixture(library.folderPath, relPath, { title: 'Original title' });
|
|
await fullScan();
|
|
const { bookId } = await locateBookFileByRelPath(ctx, library.libraryId, relPath);
|
|
expect((await patchMetadata(bookId, { authors: ['Existing Author'], genres: ['Existing Genre'] })).statusCode).toBe(200);
|
|
expect((await patchLocks(bookId, [])).statusCode).toBe(200);
|
|
const originalAuthors = await ctx.db.select().from(bookAuthors).where(eq(bookAuthors.bookId, bookId));
|
|
const originalGenres = await ctx.db.select().from(bookGenres).where(eq(bookGenres.bookId, bookId));
|
|
expect(originalAuthors).toHaveLength(1);
|
|
expect(originalGenres).toHaveLength(1);
|
|
const configure = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/libraries/${library.libraryId}`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: {
|
|
icon: 'BookOpen',
|
|
regexMetadata: { rules: [{ pattern: '^(?<series>[^/]+)/', flags: '' }] },
|
|
metadataPrecedence: ['regex', 'embedded', 'opfFile'],
|
|
},
|
|
});
|
|
expect(configure.statusCode).toBe(200);
|
|
await pause(1100);
|
|
await createEpubWithCoverFixture(library.folderPath, relPath, { title: 'Changed embedded title with a different size' });
|
|
await fullScan();
|
|
|
|
expect((await locateBookFileByRelPath(ctx, library.libraryId, relPath)).bookId).toBe(bookId);
|
|
const [metadata] = await ctx.db.select().from(bookMetadata).where(eq(bookMetadata.bookId, bookId));
|
|
expect(metadata).toMatchObject({ title: 'Changed embedded title with a different size', seriesName: 'Path Series', lockedFields: [] });
|
|
expect(await ctx.db.select().from(bookAuthors).where(eq(bookAuthors.bookId, bookId))).toEqual(originalAuthors);
|
|
expect(await ctx.db.select().from(bookGenres).where(eq(bookGenres.bookId, bookId))).toEqual(originalGenres);
|
|
});
|
|
|
|
it('normalizes and persists lock updates and rejects duplicate lock fields', async () => {
|
|
const scanned = await scanSingleEpub('lock-contract', { title: 'Lock Contract Base' });
|
|
|
|
const success = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/books/${scanned.bookId}/metadata-locks`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { lockedFields: ['cover', 'authors', 'title'] },
|
|
});
|
|
|
|
expect(success.statusCode).toBe(200);
|
|
expect(success.json()).toMatchObject({
|
|
lockedFields: ['title', 'authors', 'cover'],
|
|
});
|
|
|
|
const stateAfterSuccess = await readBookMutationState(ctx, scanned.bookId);
|
|
expect(stateAfterSuccess.metadata?.lockedFields).toEqual(['title', 'authors', 'cover']);
|
|
|
|
const duplicate = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/books/${scanned.bookId}/metadata-locks`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { lockedFields: ['title', 'cover', 'title'] },
|
|
});
|
|
|
|
expect(duplicate.statusCode).toBe(400);
|
|
|
|
const stateAfterFailure = await readBookMutationState(ctx, scanned.bookId);
|
|
expect(stateAfterFailure.metadata?.lockedFields).toEqual(['title', 'authors', 'cover']);
|
|
});
|
|
|
|
it('unlocks all fields when an empty array is patched', async () => {
|
|
const scanned = await scanSingleEpub('unlock-all', { title: 'Unlock All Base' });
|
|
|
|
const lock = await patchLocks(scanned.bookId, ['title', 'cover']);
|
|
expect(lock.statusCode).toBe(200);
|
|
|
|
const stateAfterLock = await readBookMutationState(ctx, scanned.bookId);
|
|
expect(stateAfterLock.metadata?.lockedFields).toEqual(['title', 'cover']);
|
|
|
|
const unlock = await patchLocks(scanned.bookId, []);
|
|
expect(unlock.statusCode).toBe(200);
|
|
expect(unlock.json()).toMatchObject({ lockedFields: [] });
|
|
|
|
const stateAfterUnlock = await readBookMutationState(ctx, scanned.bookId);
|
|
expect(stateAfterUnlock.metadata?.lockedFields).toEqual([]);
|
|
});
|
|
|
|
it('rejects manual metadata writes atomically when any targeted field is locked', async () => {
|
|
const scanned = await scanSingleEpub('manual-atomic', { title: 'Manual Atomic Base' });
|
|
|
|
const seed = await patchMetadata(scanned.bookId, {
|
|
title: 'Manual Atomic Base',
|
|
publisher: 'Seed Publisher',
|
|
});
|
|
expect(seed.statusCode).toBe(200);
|
|
|
|
const lock = await patchLocks(scanned.bookId, ['title']);
|
|
expect(lock.statusCode).toBe(200);
|
|
|
|
const blocked = await patchMetadata(scanned.bookId, {
|
|
title: 'Blocked Manual Title',
|
|
publisher: 'Blocked Publisher',
|
|
});
|
|
expect(blocked.statusCode).toBe(409);
|
|
|
|
const stateAfterBlocked = await readBookMutationState(ctx, scanned.bookId);
|
|
expect(stateAfterBlocked.metadata).toMatchObject({
|
|
title: 'Manual Atomic Base',
|
|
publisher: 'Seed Publisher',
|
|
});
|
|
|
|
const allowed = await patchMetadata(scanned.bookId, { publisher: 'Allowed Publisher' });
|
|
expect(allowed.statusCode).toBe(200);
|
|
|
|
const stateAfterAllowed = await readBookMutationState(ctx, scanned.bookId);
|
|
expect(stateAfterAllowed.metadata).toMatchObject({
|
|
title: 'Manual Atomic Base',
|
|
publisher: 'Allowed Publisher',
|
|
lockedFields: ['title'],
|
|
});
|
|
});
|
|
|
|
it('returns preview metadata without mutating the book or downloading covers', async () => {
|
|
const scanned = await scanSingleEpub('refresh-preview', { title: 'Preview Base' });
|
|
const seed = await patchMetadata(scanned.bookId, { title: 'Preview Base' });
|
|
expect(seed.statusCode).toBe(200);
|
|
const expected = buildMetadataLockCandidates('Preview Base', ctx.coverServer.urlFor);
|
|
const before = await readBookMutationState(ctx, scanned.bookId);
|
|
|
|
const response = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/books/${scanned.bookId}/refresh-metadata?preview=true`,
|
|
headers: authHeader(ctx.adminToken),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
expect(response.json()).toMatchObject({
|
|
metadata: {
|
|
subtitle: expected.goodreads.subtitle,
|
|
description: expected.goodreads.description,
|
|
publisher: expected.goodreads.publisher,
|
|
language: expected.goodreads.language,
|
|
pageCount: expected.goodreads.pageCount,
|
|
isbn10: expected.goodreads.isbn10,
|
|
isbn13: expected.goodreads.isbn13,
|
|
goodreadsId: expected.goodreads.providerId,
|
|
openLibraryId: expected.openLibrary.providerId,
|
|
coverUrl: expected.goodreads.coverUrl,
|
|
audioMetadata: {
|
|
narrators: expected.goodreads.narrators,
|
|
durationSeconds: expected.goodreads.durationSeconds,
|
|
abridged: expected.goodreads.abridged,
|
|
chapters: expected.goodreads.chapters,
|
|
},
|
|
comicMetadata: {
|
|
issueNumber: expected.goodreads.comicMetadata?.issueNumber,
|
|
volumeName: expected.goodreads.comicMetadata?.volumeName,
|
|
storyArcs: expected.goodreads.comicMetadata?.storyArcs,
|
|
},
|
|
},
|
|
diagnostics: {
|
|
reason: null,
|
|
activeProviders: ['goodreads', 'openLibrary'],
|
|
candidateProviders: ['goodreads', 'openLibrary'],
|
|
candidateCount: 2,
|
|
},
|
|
});
|
|
|
|
const after = await readBookMutationState(ctx, scanned.bookId);
|
|
expect(after).toEqual(before);
|
|
await expect(findCoverFilePath(ctx, scanned.bookId)).resolves.toBeNull();
|
|
});
|
|
|
|
it('refreshes unlocked fields while preserving locked fields from the API through the database', async () => {
|
|
const scanned = await scanSingleEpub('refresh-persist', { title: 'Refresh Base' });
|
|
const expected = buildMetadataLockCandidates('Refresh Base', ctx.coverServer.urlFor);
|
|
|
|
const seed = await patchMetadata(scanned.bookId, {
|
|
title: 'Refresh Base',
|
|
subtitle: 'Locked subtitle',
|
|
description: 'Seed description',
|
|
publisher: 'Seed publisher',
|
|
language: 'en',
|
|
pageCount: 111,
|
|
audioMetadata: {
|
|
narrators: ['Locked Narrator'],
|
|
durationSeconds: 1111,
|
|
abridged: false,
|
|
chapters: [{ title: 'Seed Chapter', startMs: 0, durationMs: 60_000 }],
|
|
},
|
|
comicMetadata: {
|
|
issueNumber: 'LOCK-1',
|
|
volumeName: 'Seed Volume',
|
|
storyArcs: ['Seed Arc'],
|
|
},
|
|
});
|
|
expect(seed.statusCode).toBe(200);
|
|
|
|
const lock = await patchLocks(scanned.bookId, ['subtitle', 'narrators', 'comicIssueNumber', 'cover', 'openLibraryId', 'isbn13']);
|
|
expect(lock.statusCode).toBe(200);
|
|
|
|
const refresh = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/books/${scanned.bookId}/refresh-metadata`,
|
|
headers: authHeader(ctx.adminToken),
|
|
});
|
|
|
|
expect(refresh.statusCode).toBe(201);
|
|
|
|
const state = await readBookMutationState(ctx, scanned.bookId);
|
|
expect(state.metadata).toMatchObject({
|
|
subtitle: 'Locked subtitle',
|
|
description: expected.goodreads.description,
|
|
publisher: expected.goodreads.publisher,
|
|
language: expected.goodreads.language,
|
|
pageCount: expected.goodreads.pageCount,
|
|
isbn10: expected.goodreads.isbn10,
|
|
isbn13: null,
|
|
goodreadsId: expected.goodreads.providerId,
|
|
openLibraryId: null,
|
|
durationSeconds: expected.goodreads.durationSeconds,
|
|
abridged: expected.goodreads.abridged,
|
|
chapters: expected.goodreads.chapters?.map(({ title, startMs }) => ({ title, startMs })),
|
|
chaptersSource: 'provider',
|
|
coverSource: null,
|
|
});
|
|
expect(state.narrators).toEqual(['Locked Narrator']);
|
|
expect(state.comicMetadata).toEqual({
|
|
issueNumber: 'LOCK-1',
|
|
volumeName: expected.goodreads.comicMetadata?.volumeName ?? null,
|
|
storyArcs: [...(expected.goodreads.comicMetadata?.storyArcs ?? [])].sort(),
|
|
});
|
|
expect(state.metadata?.lastMetadataFetchAt).not.toBeNull();
|
|
await expect(findCoverFilePath(ctx, scanned.bookId)).resolves.toBeNull();
|
|
});
|
|
|
|
it('keeps locked chapters through a provider refresh', async () => {
|
|
const scanned = await scanSingleEpub('refresh-locked-chapters', { title: 'Locked Chapters Base' });
|
|
|
|
const seed = await patchMetadata(scanned.bookId, { audioMetadata: { chapters: [{ title: 'Curated Chapter', startMs: 0 }] } });
|
|
expect(seed.statusCode).toBe(200);
|
|
expect((await patchLocks(scanned.bookId, ['chapters'])).statusCode).toBe(200);
|
|
|
|
const refresh = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/books/${scanned.bookId}/refresh-metadata`,
|
|
headers: authHeader(ctx.adminToken),
|
|
});
|
|
expect(refresh.statusCode).toBe(201);
|
|
|
|
const state = await readBookMutationState(ctx, scanned.bookId);
|
|
expect(state.metadata).toMatchObject({
|
|
chapters: [{ title: 'Curated Chapter', startMs: 0 }],
|
|
chaptersSource: 'manual',
|
|
lockedFields: ['chapters'],
|
|
});
|
|
expect(state.metadata?.lastMetadataFetchAt).not.toBeNull();
|
|
});
|
|
|
|
it('keeps locked fields untouched during bulk metadata fetch while persisting unlocked fields', async () => {
|
|
const scanned = await scanSingleEpub('bulk-fetch', { title: 'Bulk Base' });
|
|
const expected = buildMetadataLockCandidates('Bulk Base', ctx.coverServer.urlFor);
|
|
|
|
const seed = await patchMetadata(scanned.bookId, {
|
|
title: 'Bulk Base',
|
|
description: 'Locked bulk description',
|
|
publisher: 'Seed bulk publisher',
|
|
audioMetadata: {
|
|
narrators: ['Locked Bulk Narrator'],
|
|
durationSeconds: 777,
|
|
},
|
|
comicMetadata: {
|
|
volumeName: 'Seed Bulk Volume',
|
|
},
|
|
});
|
|
expect(seed.statusCode).toBe(200);
|
|
|
|
const lock = await patchLocks(scanned.bookId, ['description', 'cover', 'openLibraryId', 'narrators']);
|
|
expect(lock.statusCode).toBe(200);
|
|
|
|
const config = await ctx.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/v1/book-metadata-fetch/config',
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: {
|
|
enabled: true,
|
|
triggerOnImport: false,
|
|
conditions: {
|
|
scoreThreshold: { enabled: false, threshold: 60 },
|
|
missingFields: { enabled: false, fields: [] },
|
|
neverFetched: { enabled: true },
|
|
},
|
|
},
|
|
});
|
|
expect(config.statusCode).toBe(200);
|
|
|
|
const run = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/book-metadata-fetch/run/${scanned.libraryId}`,
|
|
headers: authHeader(ctx.adminToken),
|
|
});
|
|
expect(run.statusCode).toBe(201);
|
|
expect(run.json()).toMatchObject({ queued: 1 });
|
|
|
|
await waitForMetadataFetchIdle(ctx);
|
|
|
|
const state = await readBookMutationState(ctx, scanned.bookId);
|
|
expect(state.metadata).toMatchObject({
|
|
description: 'Locked bulk description',
|
|
publisher: expected.goodreads.publisher,
|
|
language: expected.goodreads.language,
|
|
pageCount: expected.goodreads.pageCount,
|
|
goodreadsId: expected.goodreads.providerId,
|
|
openLibraryId: null,
|
|
durationSeconds: expected.goodreads.durationSeconds,
|
|
coverSource: null,
|
|
});
|
|
expect(state.narrators).toEqual(['Locked Bulk Narrator']);
|
|
expect(state.comicMetadata).toEqual({
|
|
issueNumber: expected.goodreads.comicMetadata?.issueNumber ?? null,
|
|
volumeName: expected.goodreads.comicMetadata?.volumeName ?? null,
|
|
storyArcs: [...(expected.goodreads.comicMetadata?.storyArcs ?? [])].sort(),
|
|
});
|
|
expect(state.metadata?.lastMetadataFetchAt).not.toBeNull();
|
|
await expect(findCoverFilePath(ctx, scanned.bookId)).resolves.toBeNull();
|
|
});
|
|
|
|
it('blocks locked custom cover mutations after a successful unlocked upload', async () => {
|
|
const scanned = await scanSingleEpub('custom-cover', { title: 'Custom Cover Base' });
|
|
|
|
const upload = await uploadBookCover(ctx, scanned.bookId, RED_PNG_BYTES);
|
|
expect(upload.statusCode).toBe(204);
|
|
|
|
const customPath = await findCoverFilePath(ctx, scanned.bookId, COVER_CUSTOM_FILE_PREFIX);
|
|
expect(customPath).not.toBeNull();
|
|
await expect(readFile(customPath!)).resolves.toEqual(RED_PNG_BYTES);
|
|
|
|
const lock = await patchLocks(scanned.bookId, ['cover']);
|
|
expect(lock.statusCode).toBe(200);
|
|
|
|
const uploadBlocked = await uploadBookCover(ctx, scanned.bookId, BLUE_PNG_BYTES, 'blocked-cover.png');
|
|
expect(uploadBlocked.statusCode).toBe(409);
|
|
|
|
const deleteBlocked = await ctx.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/books/${scanned.bookId}/cover`,
|
|
headers: authHeader(ctx.adminToken),
|
|
});
|
|
expect(deleteBlocked.statusCode).toBe(409);
|
|
|
|
const state = await readBookMutationState(ctx, scanned.bookId);
|
|
expect(state.metadata).toMatchObject({
|
|
coverSource: 'custom',
|
|
lockedFields: ['cover'],
|
|
});
|
|
await expect(readFile(customPath!)).resolves.toEqual(RED_PNG_BYTES);
|
|
});
|
|
|
|
it('preserves locked scanner metadata while still updating unlocked extracted fields on rescan', async () => {
|
|
const cbzRelPath = uniqueRelPath('scanner-rescan', 'book.cbz');
|
|
const epubRelPath = cbzRelPath.replace(/\.cbz$/, '.epub');
|
|
const library = await createLibraryWithFolder(ctx, {
|
|
mode: 'book_per_folder',
|
|
allowedFormats: ['cbz', 'epub'],
|
|
name: `metadata-lock-scanner-${randomUUID()}`,
|
|
});
|
|
|
|
await createCbzFixture(library.folderPath, cbzRelPath, {
|
|
title: 'Scanner Original Title',
|
|
publisher: 'Original Publisher',
|
|
});
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
|
|
const initial = await locateBookFileByRelPath(ctx, library.libraryId, cbzRelPath);
|
|
|
|
const seed = await patchMetadata(initial.bookId, {
|
|
title: 'Scanner Locked Title',
|
|
publisher: 'Original Publisher',
|
|
});
|
|
expect(seed.statusCode).toBe(200);
|
|
|
|
const lock = await patchLocks(initial.bookId, ['title']);
|
|
expect(lock.statusCode).toBe(200);
|
|
|
|
// Filesystem timestamp resolution is 1s on many OSes; wait so the new file gets a newer mtime.
|
|
await pause(1_100);
|
|
await createEpubWithCoverFixture(library.folderPath, epubRelPath, {
|
|
title: 'Scanner EPUB Winner',
|
|
coverBytes: BLUE_PNG_BYTES,
|
|
});
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
|
|
const rescanned = await locateBookFileByRelPath(ctx, library.libraryId, epubRelPath);
|
|
expect(rescanned.bookId).toBe(initial.bookId);
|
|
|
|
const state = await readBookMutationState(ctx, rescanned.bookId);
|
|
expect(state.metadata).toMatchObject({
|
|
title: 'Scanner Locked Title',
|
|
coverSource: 'extracted',
|
|
lockedFields: ['title'],
|
|
});
|
|
|
|
const extractedCoverPath = await findCoverFilePath(ctx, rescanned.bookId);
|
|
expect(extractedCoverPath).not.toBeNull();
|
|
await expect(readFile(extractedCoverPath!)).resolves.toEqual(BLUE_PNG_BYTES);
|
|
}, 60_000);
|
|
|
|
it('imports ComicInfo Genre and Tags as separate genres and tags on scan', async () => {
|
|
const library = await createLibraryWithFolder(ctx, {
|
|
mode: 'book_per_file',
|
|
allowedFormats: ['cbz'],
|
|
name: `metadata-lock-comic-tags-${randomUUID()}`,
|
|
});
|
|
const folder = `comic-tags-${randomUUID()}`;
|
|
await createCbzFixture(library.folderPath, `${folder}/tagged.cbz`, { title: 'Tagged Comic', genre: 'Science Fiction', tags: 'Foo, Bar' });
|
|
await createCbzFixture(library.folderPath, `${folder}/tags-only.cbz`, { title: 'Tags Only Comic', tags: 'Ninja, School Life' });
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
|
|
const tagged = await locateBookFileByRelPath(ctx, library.libraryId, `${folder}/tagged.cbz`);
|
|
const tagsOnly = await locateBookFileByRelPath(ctx, library.libraryId, `${folder}/tags-only.cbz`);
|
|
|
|
await expect(readBookMutationState(ctx, tagged.bookId)).resolves.toMatchObject({ genres: ['Science Fiction'], tags: ['Bar', 'Foo'] });
|
|
await expect(readBookMutationState(ctx, tagsOnly.bookId)).resolves.toMatchObject({ genres: [], tags: ['Ninja', 'School Life'] });
|
|
});
|
|
|
|
it('replaces comic genres and tags from a re-tagged file on rescan, except locked tags', async () => {
|
|
const library = await createLibraryWithFolder(ctx, {
|
|
mode: 'book_per_file',
|
|
allowedFormats: ['cbz'],
|
|
name: `metadata-lock-comic-retag-${randomUUID()}`,
|
|
});
|
|
const folder = `comic-retag-${randomUUID()}`;
|
|
const lockedRelPath = `${folder}/locked.cbz`;
|
|
const unlockedRelPath = `${folder}/unlocked.cbz`;
|
|
await createCbzFixture(library.folderPath, lockedRelPath, { title: 'Locked Tags Comic', genre: 'Science Fiction', tags: 'Time Travel' });
|
|
await createCbzFixture(library.folderPath, unlockedRelPath, { title: 'Unlocked Tags Comic', genre: 'Science Fiction', tags: 'Time Travel' });
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
|
|
const locked = await locateBookFileByRelPath(ctx, library.libraryId, lockedRelPath);
|
|
const unlocked = await locateBookFileByRelPath(ctx, library.libraryId, unlockedRelPath);
|
|
expect((await patchLocks(locked.bookId, ['tags'])).statusCode).toBe(200);
|
|
|
|
// Filesystem timestamp resolution is 1s on many OSes; wait so the re-tagged files get a newer mtime.
|
|
await pause(1_100);
|
|
await retagComic(library.folderPath, lockedRelPath, { title: 'Locked Tags Comic', tags: 'Found Family' });
|
|
await retagComic(library.folderPath, unlockedRelPath, { title: 'Unlocked Tags Comic', tags: 'Found Family' });
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
|
|
expect((await locateBookFileByRelPath(ctx, library.libraryId, lockedRelPath)).bookId).toBe(locked.bookId);
|
|
expect((await locateBookFileByRelPath(ctx, library.libraryId, unlockedRelPath)).bookId).toBe(unlocked.bookId);
|
|
await expect(readBookMutationState(ctx, locked.bookId)).resolves.toMatchObject({ genres: [], tags: ['Time Travel'] });
|
|
await expect(readBookMutationState(ctx, unlocked.bookId)).resolves.toMatchObject({ genres: [], tags: ['Found Family'] });
|
|
}, 60_000);
|
|
|
|
it('refreshes extracted covers only for unlocked books during scanner cover refresh', async () => {
|
|
const library = await createLibraryWithFolder(ctx, {
|
|
mode: 'book_per_file',
|
|
allowedFormats: ['epub'],
|
|
name: `metadata-lock-cover-refresh-${randomUUID()}`,
|
|
});
|
|
|
|
const unlockedRelPath = uniqueRelPath('cover-refresh', 'unlocked.epub');
|
|
const lockedRelPath = uniqueRelPath('cover-refresh', 'locked.epub');
|
|
|
|
await createEpubWithCoverFixture(library.folderPath, unlockedRelPath, {
|
|
title: 'Unlocked Cover Book',
|
|
coverBytes: RED_PNG_BYTES,
|
|
});
|
|
await createEpubWithCoverFixture(library.folderPath, lockedRelPath, {
|
|
title: 'Locked Cover Book',
|
|
coverBytes: GREEN_PNG_BYTES,
|
|
});
|
|
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
|
|
const unlocked = await locateBookFileByRelPath(ctx, library.libraryId, unlockedRelPath);
|
|
const locked = await locateBookFileByRelPath(ctx, library.libraryId, lockedRelPath);
|
|
|
|
const unlockedCoverBefore = await findCoverFilePath(ctx, unlocked.bookId);
|
|
const lockedCoverBefore = await findCoverFilePath(ctx, locked.bookId);
|
|
await expect(readFile(unlockedCoverBefore!)).resolves.toEqual(RED_PNG_BYTES);
|
|
await expect(readFile(lockedCoverBefore!)).resolves.toEqual(GREEN_PNG_BYTES);
|
|
|
|
const lockResponse = await patchLocks(locked.bookId, ['cover']);
|
|
expect(lockResponse.statusCode).toBe(200);
|
|
|
|
await createEpubWithCoverFixture(library.folderPath, unlockedRelPath, {
|
|
title: 'Unlocked Cover Book',
|
|
coverBytes: BLUE_PNG_BYTES,
|
|
});
|
|
await createEpubWithCoverFixture(library.folderPath, lockedRelPath, {
|
|
title: 'Locked Cover Book',
|
|
coverBytes: RED_PNG_BYTES,
|
|
});
|
|
|
|
const refresh = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: `/api/v1/scanner/libraries/${library.libraryId}/refresh-covers`,
|
|
headers: authHeader(ctx.adminToken),
|
|
});
|
|
|
|
expect(refresh.statusCode).toBe(202);
|
|
expect(refresh.json()).toMatchObject({ queued: 2 });
|
|
|
|
await waitForCondition(async () => {
|
|
const path = await findCoverFilePath(ctx, unlocked.bookId);
|
|
if (!path) throw new Error('Unlocked extracted cover path missing');
|
|
const bytes = await readFile(path);
|
|
expect(bytes).toEqual(BLUE_PNG_BYTES);
|
|
}, 15_000);
|
|
|
|
await waitForCondition(async () => {
|
|
const path = await findCoverFilePath(ctx, locked.bookId);
|
|
if (!path) throw new Error('Locked extracted cover path missing');
|
|
const bytes = await readFile(path);
|
|
expect(bytes).toEqual(GREEN_PNG_BYTES);
|
|
}, 15_000);
|
|
});
|
|
|
|
async function patchLocks(bookId: number, lockedFields: string[]) {
|
|
return ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/books/${bookId}/metadata-locks`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload: { lockedFields },
|
|
});
|
|
}
|
|
|
|
async function patchMetadata(bookId: number, payload: Record<string, unknown>) {
|
|
return ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/books/${bookId}/metadata`,
|
|
headers: authHeader(ctx.adminToken),
|
|
payload,
|
|
});
|
|
}
|
|
|
|
async function scanSingleEpub(prefix: string, input: { title: string; language?: string }) {
|
|
const library = await createLibraryWithFolder(ctx, {
|
|
mode: 'book_per_file',
|
|
allowedFormats: ['epub'],
|
|
name: `metadata-lock-${prefix}-${randomUUID()}`,
|
|
});
|
|
|
|
const relPath = uniqueRelPath(prefix, 'book.epub');
|
|
await createEpubFixture(library.folderPath, relPath, {
|
|
title: input.title,
|
|
language: input.language ?? 'en',
|
|
});
|
|
|
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
|
const located = await locateBookFileByRelPath(ctx, library.libraryId, relPath);
|
|
|
|
return {
|
|
...library,
|
|
relPath,
|
|
bookId: located.bookId,
|
|
absolutePath: located.absolutePath,
|
|
};
|
|
}
|
|
|
|
// Taggers write a new archive and rename it over the old one. The rename moves the folder's mtime,
|
|
// which is what makes an incremental scan look inside the folder again.
|
|
async function retagComic(folderPath: string, relPath: string, input: Parameters<typeof createCbzFixture>[2]) {
|
|
const stagingRelPath = `${relPath}.retag`;
|
|
await createCbzFixture(folderPath, stagingRelPath, input);
|
|
await rename(join(folderPath, stagingRelPath), join(folderPath, relPath));
|
|
}
|
|
|
|
function uniqueRelPath(prefix: string, fileName: string): string {
|
|
return `${prefix}-${randomUUID()}/${fileName}`;
|
|
}
|
|
|
|
function pause(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
});
|