fix(metadata): preserve ISBN fields across metadata workflows

Carry ISBN-10 and ISBN-13 independently from accepted providers through
Auto-fill previews, saves and background refreshes. Preserve existing
identifiers and field locks, validate provider checksums and avoid
combining different editions.

Classify embedded ISBNs correctly and support ISBN-10 fallback in
search, Book Dock confidence and file naming.
This commit is contained in:
neon
2026-10-07 20:07:23 +00:00
parent 6c7d91eaac
commit d719c4ee5b
51 changed files with 545 additions and 64 deletions
@@ -248,6 +248,7 @@ export async function setBookMetadata(
seriesName?: string | null;
seriesIndex?: string | null;
language?: string | null;
isbn10?: string | null;
isbn13?: string | null;
authors?: string[];
},
@@ -264,6 +265,7 @@ export async function setBookMetadata(
...(metadata.seriesName !== undefined && { seriesName: metadata.seriesName }),
...(metadata.seriesIndex !== undefined && { seriesIndex: metadata.seriesIndex }),
...(metadata.language !== undefined && { language: metadata.language }),
...(metadata.isbn10 !== undefined && { isbn10: metadata.isbn10 }),
...(metadata.isbn13 !== undefined && { isbn13: metadata.isbn13 }),
...(metadata.authors !== undefined && { authors: metadata.authors }),
},
@@ -94,6 +94,8 @@ export interface BookMutationState {
publishedYear: number | null;
language: string | null;
pageCount: number | null;
isbn10: string | null;
isbn13: string | null;
coverSource: string | null;
goodreadsId: string | null;
openLibraryId: string | null;
@@ -277,6 +279,8 @@ export async function readBookMutationState(ctx: MetadataLockE2EContext, bookId:
publishedYear: bookMetadata.publishedYear,
language: bookMetadata.language,
pageCount: bookMetadata.pageCount,
isbn10: bookMetadata.isbn10,
isbn13: bookMetadata.isbn13,
coverSource: bookMetadata.coverSource,
goodreadsId: bookMetadata.goodreadsId,
openLibraryId: bookMetadata.openLibraryId,
@@ -311,6 +315,8 @@ export async function readBookMutationState(ctx: MetadataLockE2EContext, bookId:
publishedYear: meta.publishedYear,
language: meta.language,
pageCount: meta.pageCount,
isbn10: meta.isbn10,
isbn13: meta.isbn13,
coverSource: meta.coverSource,
goodreadsId: meta.goodreadsId,
openLibraryId: meta.openLibraryId,
@@ -50,6 +50,8 @@ export function buildMetadataLockCandidates(
authors: ['Refreshed Author'],
description: 'Refreshed description from fake Goodreads',
publisher: 'Refreshed Publisher',
isbn10: '0306406152',
isbn13: '9780306406157',
publishedYear: 2024,
language: 'fr',
pageCount: 321,
+25
View File
@@ -150,6 +150,31 @@ describe('Bulk file rename (e2e)', { timeout: SUITE_TIMEOUT_MS }, () => {
}
});
it.each([
{ isbn10: '0306406152', isbn13: null, expected: '0306406152' },
{ isbn10: '0306406152', isbn13: '9780306406157', expected: '9780306406157' },
])('renames using the available ISBN ($expected) without changing its field', async ({ isbn10, isbn13, expected }) => {
const lib = await createLibrary(ctx, { mode: 'book_per_file', fileRenameEnabled: true, fileNamingPattern: '{isbn}' });
await createEpubFile(join(lib.folderPath, 'isbn-source.epub'));
await triggerAndWaitForScan(ctx, lib.libraryId);
const [book] = await findAllBooksInLibrary(ctx, lib.libraryId);
await setBookMetadata(ctx, book.bookId, { isbn10, isbn13 });
const preview = await getBulkRenamePreview(ctx, lib.libraryId);
expect(preview.items).toHaveLength(1);
expect(preview.items[0].status).toBe('will_rename');
const result = await executeBulkRename(ctx, lib.libraryId);
expect(getDoneEvent(result.events)).toMatchObject({ succeeded: 1, failed: 0 });
await expect(pathExists(join(lib.folderPath, `${expected}.epub`))).resolves.toBe(true);
expect((await findAllBooksInLibrary(ctx, lib.libraryId))[0].relPath).toBe(`${expected}.epub`);
const [metadata] = await ctx.db
.select({ isbn10: schema.bookMetadata.isbn10, isbn13: schema.bookMetadata.isbn13 })
.from(schema.bookMetadata)
.where(eq(schema.bookMetadata.bookId, book.bookId));
expect(metadata).toEqual({ isbn10, isbn13 });
});
it('reports unchanged for books already at the correct path', async () => {
const lib = await createLibrary(ctx, {
mode: 'book_per_file',
+5 -1
View File
@@ -138,6 +138,8 @@ describe('metadata-lock e2e', { timeout: 30_000 }, () => {
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,
@@ -191,7 +193,7 @@ describe('metadata-lock e2e', { timeout: 30_000 }, () => {
});
expect(seed.statusCode).toBe(200);
const lock = await patchLocks(scanned.bookId, ['subtitle', 'narrators', 'comicIssueNumber', 'cover', 'openLibraryId']);
const lock = await patchLocks(scanned.bookId, ['subtitle', 'narrators', 'comicIssueNumber', 'cover', 'openLibraryId', 'isbn13']);
expect(lock.statusCode).toBe(200);
const refresh = await ctx.app.inject({
@@ -209,6 +211,8 @@ describe('metadata-lock e2e', { timeout: 30_000 }, () => {
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,