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
@@ -0,0 +1,23 @@
import { canonicalIsbn13, splitMetadataIsbn } from './isbn-normalize';
describe('ISBN identifiers', () => {
it.each([
['0-306-40615-2', { isbn10: '0306406152' }],
['0-9752298-0-x', { isbn10: '097522980X' }],
['978-0-306-40615-7', { isbn13: '9780306406157' }],
['9798896602590', { isbn13: '9798896602590' }],
['', {}],
[null, {}],
['not an ISBN', {}],
] as const)('places %s in the correct field without creating a counterpart', (value, expected) => {
expect(splitMetadataIsbn(value)).toEqual(expected);
});
it('compares equivalent identifiers while rejecting invalid checksums', () => {
expect(canonicalIsbn13('0306406152')).toBe('9780306406157');
expect(canonicalIsbn13('9780306406157')).toBe('9780306406157');
expect(canonicalIsbn13('9798896602590')).toBe('9798896602590');
expect(canonicalIsbn13('0306406153')).toBeUndefined();
expect(canonicalIsbn13('9780306406158')).toBeUndefined();
});
});
@@ -11,3 +11,40 @@ export function normalizeIsbn(raw: string): string {
export function normalizeMetadataIsbn(value: string | null | undefined): string {
return value ? normalizeIsbn(value) : '';
}
export function splitMetadataIsbn(value: string | null | undefined): { isbn10?: string; isbn13?: string } {
const normalized = normalizeMetadataIsbn(value);
if (/^[0-9]{9}[0-9X]$/.test(normalized)) return { isbn10: normalized };
if (/^97[89][0-9]{10}$/.test(normalized)) return { isbn13: normalized };
return {};
}
export function isValidIsbn10(n: string): boolean {
if (!/^[0-9]{9}[0-9X]$/.test(n)) return false;
let sum = 0;
for (let i = 0; i < 10; i++) {
const c = n[i];
const v = c === 'X' ? 10 : c.charCodeAt(0) - 48;
sum += (i + 1) * v;
}
return sum % 11 === 0;
}
export function isValidIsbn13(n: string): boolean {
if (!/^97[89][0-9]{10}$/.test(n)) return false;
let sum = 0;
for (let i = 0; i < 13; i++) {
const v = n.charCodeAt(i) - 48;
sum += i % 2 === 0 ? v : v * 3;
}
return sum % 10 === 0;
}
export function canonicalIsbn13(value: string | null | undefined): string | undefined {
const normalized = normalizeMetadataIsbn(value);
if (isValidIsbn13(normalized)) return normalized;
if (!isValidIsbn10(normalized)) return undefined;
const prefix = `978${normalized.slice(0, 9)}`;
const sum = [...prefix].reduce((total, digit, index) => total + Number(digit) * (index % 2 === 0 ? 1 : 3), 0);
return `${prefix}${(10 - (sum % 10)) % 10}`;
}
@@ -4,6 +4,13 @@ import { buildPatternTokens, patternReferencesToken } from './pattern-tokens.uti
describe('pattern-tokens.utils', () => {
describe('buildPatternTokens', () => {
it('uses either ISBN for the naming token and prefers ISBN-13', () => {
const input = { authors: [], originalStem: 'book', format: 'mobi' };
expect(buildPatternTokens({ ...input, metadata: { isbn10: '097522980X' } }).isbn).toBe('097522980X');
expect(buildPatternTokens({ ...input, metadata: { isbn10: '0306406152', isbn13: '9780306406157' } }).isbn).toBe('9780306406157');
expect(buildPatternTokens({ ...input, metadata: { isbn10: '0306406152', isbn13: ' ' } }).isbn).toBe('0306406152');
});
const metadata = {
title: 'Dune',
subtitle: 'A Novel',
@@ -3,8 +3,7 @@ import { formatSeriesIndex } from './series-index-format.utils';
/**
* The metadata a naming pattern can draw on. Every consumer reads these same field names
* off its own row shape, so callers pass the row through rather than mapping it field by
* field. `isbn13` is the only token whose source differs between callers: the KOReader
* catalogue falls back to ISBN-10, and resolves that before calling.
* field. The ISBN token prefers ISBN-13 and falls back to ISBN-10.
*/
export interface PatternTokenMetadata {
title?: string | null;
@@ -12,6 +11,7 @@ export interface PatternTokenMetadata {
publisher?: string | null;
language?: string | null;
isbn13?: string | null;
isbn10?: string | null;
publishedYear?: number | null;
seriesName?: string | null;
seriesIndex?: string | null;
@@ -52,7 +52,8 @@ export function buildPatternTokens(input: PatternTokenInput): Record<string, str
if (metadata.subtitle) tokens['subtitle'] = metadata.subtitle;
if (metadata.publisher) tokens['publisher'] = metadata.publisher;
if (metadata.language) tokens['language'] = metadata.language;
if (metadata.isbn13) tokens['isbn'] = metadata.isbn13;
const isbn = metadata.isbn13?.trim() || metadata.isbn10?.trim();
if (isbn) tokens['isbn'] = isbn;
if (metadata.publishedYear) tokens['year'] = String(metadata.publishedYear);
if (metadata.seriesName) tokens['series'] = metadata.seriesName;
@@ -556,7 +556,7 @@ describe('BookDockIngestService', () => {
isbn: '9781401284770',
isAudiobook: false,
},
{},
{ isbn10: undefined, isbn13: '9781401284770' },
);
});
@@ -658,6 +658,21 @@ describe('BookDockIngestService', () => {
expect(persistedMetadata.communityRatings).toEqual([{ provider: 'hardcover', rating: 4.5, ratingCount: 1000 }]);
});
it.each([
[{ isbn10: '0-306-40615-2' }, { isbn13: '9780306406157' }, 90],
[{ isbn13: '978-0-306-40615-7' }, { isbn13: '9780306406157' }, 95],
[{ isbn10: '0306406152' }, { isbn13: '9780525512196' }, 10],
])('compares dock ISBN editions across formats (%j, %j)', async (embedded, fetched, confidence) => {
const { service, appSettings, repo, metadataFetchPipeline } = makeService();
appSettings.isBookDockAutoFetchEnabled.mockResolvedValue(true);
repo.findById.mockResolvedValue({ id: 9, fileName: 'book.epub', status: 'ready', embeddedMetadata: embedded });
(metadataFetchPipeline as any).runWithSources = vi.fn().mockResolvedValue({ resolved: fetched, sources: {} });
await (service as any).autoFetchMetadataAsync(9);
expect(repo.update).toHaveBeenLastCalledWith(9, expect.objectContaining({ confidence }));
});
it('falls back to ready status when metadata fetch pipeline throws', async () => {
const { service, appSettings, repo, metadataFetchPipeline } = makeService();
appSettings.isBookDockAutoFetchEnabled.mockResolvedValue(true);
@@ -7,6 +7,7 @@ import { Readable } from 'stream';
import { isAudioFormat, MetadataProviderKey, resolveBookDockSearchTitle, type BookDockMetadata } from '@bookorbit/types';
import type { BookDockFileRow } from '../../db/schema';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { canonicalIsbn13, normalizeMetadataIsbn } from '../../common/text-match/isbn-normalize';
import { waitForDirectoryStability } from '../../common/utils/fs-stability.utils';
import { SUPPORTED_BOOK_FORMATS, UploadValidatorService } from '../upload/upload-validator.service';
import { UploadStorageService } from '../upload/upload-storage.service';
@@ -348,7 +349,7 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
const params = {
title: resolveBookDockSearchTitle(row.fileName, meta?.title),
author: meta?.authors?.[0] ?? undefined,
isbn: meta?.isbn13 ?? meta?.isbn10 ?? undefined,
isbn: meta?.isbn13?.trim() || meta?.isbn10?.trim() || undefined,
isAudiobook: row.format != null && isAudioFormat(row.format),
};
if (!params.title && !params.isbn) return;
@@ -356,7 +357,14 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
await this.repo.update(fileId, { status: 'fetching' });
this.emitChange();
try {
const { resolved, sources, providerIds = {} } = await this.metadataFetchPipeline.runWithSources(params, {});
const {
resolved,
sources,
providerIds = {},
} = await this.metadataFetchPipeline.runWithSources(params, {
isbn10: meta?.isbn10,
isbn13: meta?.isbn13,
});
// A dock file has one medium, so its one cover is that medium's slot.
if (params.isAudiobook) {
resolved.coverUrl = resolved.audioCoverUrl;
@@ -505,13 +513,20 @@ function authorSimilarity(embAuthors: string[], fetchAuthors: string[]): number
}
function computeConfidence(embedded: BookDockMetadata, fetched: BookDockMetadata): number {
const embedded13 = normalizeMetadataIsbn(embedded.isbn13);
const embedded10 = normalizeMetadataIsbn(embedded.isbn10);
const fetched13 = normalizeMetadataIsbn(fetched.isbn13);
const fetched10 = normalizeMetadataIsbn(fetched.isbn10);
// ISBN exact match - definitive
if (embedded.isbn13 && fetched.isbn13 && embedded.isbn13 === fetched.isbn13) return 95;
if (embedded.isbn10 && fetched.isbn10 && embedded.isbn10 === fetched.isbn10) return 90;
if (embedded13 && embedded13 === fetched13) return 95;
if (embedded10 && embedded10 === fetched10) return 90;
const embIsbn = embedded13 || embedded10;
const fetchIsbn = fetched13 || fetched10;
const canonical = canonicalIsbn13(embIsbn);
if (canonical && canonical === canonicalIsbn13(fetchIsbn)) return 90;
// Conflicting ISBNs - almost certainly wrong book
const embIsbn = embedded.isbn13 ?? embedded.isbn10;
const fetchIsbn = fetched.isbn13 ?? fetched.isbn10;
if (embIsbn && fetchIsbn && embIsbn !== fetchIsbn) return 10;
let score = 0;
@@ -12,6 +12,8 @@ const baseBook = (): BookEligibilityData => ({
publishedYear: 2020,
language: 'en',
pageCount: 320,
isbn10: null,
isbn13: null,
communityRating: [{ provider: 'hardcover', rating: 4.25 }],
seriesName: 'Series',
seriesIndex: '1',
@@ -36,6 +38,16 @@ const baseConfig = (): BookMetadataFetchConfig => ({
});
describe('BookMetadataFetchEligibilityService', () => {
it('treats either ISBN as satisfying the ISBN condition', () => {
const service = new BookMetadataFetchEligibilityService();
const config = baseConfig();
config.conditions.missingFields = { enabled: true, fields: ['isbn'] };
expect(service.isEligible(baseBook(), config)).toBe(true);
expect(service.isEligible({ ...baseBook(), isbn10: '0306406152' }, config)).toBe(false);
expect(service.isEligible({ ...baseBook(), isbn13: '9780306406157' }, config)).toBe(false);
expect(service.isEligible({ ...baseBook(), isbn10: ' ', isbn13: '' }, config)).toBe(true);
});
let service: BookMetadataFetchEligibilityService;
beforeEach(() => {
@@ -11,6 +11,8 @@ export interface BookEligibilityData {
publishedYear: number | null;
language: string | null;
pageCount: number | null;
isbn10: string | null;
isbn13: string | null;
communityRating: readonly unknown[];
seriesName: string | null;
seriesIndex: string | null;
@@ -80,6 +82,8 @@ export class BookMetadataFetchEligibilityService {
return book.language === null || book.language === '';
case 'pageCount':
return book.pageCount === null;
case 'isbn':
return !book.isbn10?.trim() && !book.isbn13?.trim();
case 'communityRating':
return book.communityRating.length === 0;
case 'seriesName':
@@ -279,6 +279,8 @@ describe('BookMetadataFetchOrchestratorService', () => {
publishedYear: 2020,
language: 'en',
pageCount: 200,
isbn10: '0306406152',
isbn13: '9780306406157',
communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345, updatedAt: '2026-06-25T00:00:00.000Z' }],
seriesName: 'Series',
seriesIndex: '2',
@@ -310,6 +312,8 @@ describe('BookMetadataFetchOrchestratorService', () => {
title: 'Resolved',
subtitle: 'Sub',
publisher: 'Pub',
isbn10: '0306406152',
isbn13: '9780306406157',
durationSeconds: 3600,
abridged: false,
googleBooksId: 'g1',
@@ -222,7 +222,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
const searchParams: MetadataSearchParams = {
title: meta?.title ?? undefined,
author: authorRows[0]?.name ?? undefined,
isbn: meta?.isbn13 ?? meta?.isbn10 ?? undefined,
isbn: meta?.isbn13?.trim() || meta?.isbn10?.trim() || undefined,
seriesName: meta?.seriesName ?? undefined,
seriesIndex: meta?.seriesIndex ?? undefined,
existingProviderIds: this.collectProviderIds(meta ?? {}),
@@ -240,6 +240,8 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
publishedYear: meta?.publishedYear,
language: meta?.language,
pageCount: meta?.pageCount,
isbn10: meta?.isbn10,
isbn13: meta?.isbn13,
communityRating: communityRatingRows,
seriesName: meta?.seriesName,
seriesIndex: meta?.seriesIndex,
@@ -250,6 +252,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
chapters: meta?.chapters,
hardcoverEditionId: meta?.hardcoverEditionId,
};
if (meta?.lockedFields?.length) existingFields.lockedFields = meta.lockedFields;
const coverInputs = coverFetchInputs(coverState);
Object.assign(existingFields, coverInputs.existing);
@@ -329,6 +332,10 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
if (language !== undefined) scalarFields.language = language;
const pageCount = this.asNullableNumber(filteredResolved.pageCount);
if (pageCount !== undefined) scalarFields.pageCount = pageCount;
const isbn10 = this.asNullableString(filteredResolved.isbn10);
if (isbn10 !== undefined) scalarFields.isbn10 = isbn10;
const isbn13 = this.asNullableString(filteredResolved.isbn13);
if (isbn13 !== undefined) scalarFields.isbn13 = isbn13;
const seriesName = this.asNullableString(filteredResolved.seriesName);
if (seriesName !== undefined) scalarFields.seriesName = seriesName;
const seriesIndex = filteredResolved.seriesIndex === null ? null : (parseSeriesIndex(filteredResolved.seriesIndex) ?? undefined);
@@ -442,6 +449,8 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
publishedYear: meta?.publishedYear ?? null,
language: meta?.language ?? null,
pageCount: meta?.pageCount ?? null,
isbn10: meta?.isbn10 ?? null,
isbn13: meta?.isbn13 ?? null,
communityRating: communityRatingRows,
seriesName: meta?.seriesName ?? null,
seriesIndex: meta?.seriesIndex ?? null,
@@ -156,6 +156,19 @@ describe('BookMetadataFetchQueueRepository', () => {
expect(sqlCalls.some((call) => call.slice(1).includes(bookFiles.mediaOverlayAvailable))).toBe(true);
});
it('checks both ISBN columns when scheduling books without an ISBN', async () => {
const { db, selectBuilder } = makeDb();
selectBuilder.where.mockResolvedValueOnce([{ bookId: 7 }]);
const repo = new BookMetadataFetchQueueRepository(db as never);
const config = baseConfig();
config.conditions.missingFields = { enabled: true, fields: ['isbn'] };
await expect(repo.fetchEligibleBookIds(config, 5)).resolves.toEqual([7]);
expect(vi.mocked(sql).mock.calls.some((call) => call.slice(1).includes(bookMetadata.isbn10) && call.slice(1).includes(bookMetadata.isbn13))).toBe(
true,
);
expect(eq).toHaveBeenCalledWith(books.libraryId, 5);
});
it('returns empty results without touching the database when no conditions are enabled', async () => {
const { db } = makeDb();
const repo = new BookMetadataFetchQueueRepository(db as never);
@@ -382,6 +382,8 @@ export class BookMetadataFetchQueueRepository {
return sql`(${bookMetadata.language} IS NULL OR ${bookMetadata.language} = '')`;
case 'pageCount':
return sql`${bookMetadata.pageCount} IS NULL`;
case 'isbn':
return sql`(COALESCE(TRIM(${bookMetadata.isbn10}), '') = '' AND COALESCE(TRIM(${bookMetadata.isbn13}), '') = '')`;
case 'communityRating':
return sql`NOT EXISTS (SELECT 1 FROM ${bookCommunityRatings} WHERE ${bookCommunityRatings.bookId} = ${bookMetadata.bookId})`;
case 'seriesName':
@@ -18,6 +18,13 @@ function makeService(lockedFields: string[] = []) {
}
describe('BookMetadataLockService', () => {
it('keeps ISBN locks independent during automated refreshes', async () => {
const { service } = makeService(['isbn13']);
const result = await service.filterResolvedMetadata(12, { isbn10: '0306406152', isbn13: '9780306406157' }, {});
expect(result.resolved).toEqual({ isbn10: '0306406152' });
expect(result.skippedFields).toEqual(['isbn13']);
});
it('normalizes, deduplicates, and orders locked fields', () => {
const { service } = makeService();
@@ -148,6 +148,8 @@ export class BookMetadataLockService {
this.copyResolvedField(filteredResolved, resolved, 'publishedYear', 'publishedYear', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'language', 'language', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'pageCount', 'pageCount', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'isbn10', 'isbn10', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'isbn13', 'isbn13', lockedSet, skippedFields);
if (resolved.seriesMemberships !== undefined || resolved.seriesName !== undefined || resolved.seriesIndex !== undefined) {
if (lockedSet.has('seriesName') || lockedSet.has('seriesIndex')) {
if (lockedSet.has('seriesName')) skippedFields.add('seriesName');
@@ -33,6 +33,7 @@ function makeBook(overrides: Partial<MoveBookData> = {}): MoveBookData {
subtitle: null,
publisher: null,
language: null,
isbn10: null,
isbn13: null,
publishedYear: null,
seriesName: null,
@@ -146,6 +147,16 @@ describe('eligibility', () => {
expect(expectReady(outcome).targetPath).toBe('/libB/Frank Herbert/Dune.epub');
});
it('uses ISBN-10 when the destination naming pattern needs an ISBN', () => {
const book = makeBook();
book.metadata.isbn10 = '0306406152';
const outcome = planner.plan(
makeInput({ books: [book], target: makeTarget({ organizationMode: 'book_per_file', fileNamingPattern: '{isbn}' }), pattern: '{isbn}' }),
)[0];
expect(expectReady(outcome).targetPath).toBe('/libB/0306406152.epub');
});
it('refuses when no naming pattern is configured', () => {
expect(expectIneligible(planner.plan(makeInput({ pattern: null }))[0])).toMatchObject({ reason: 'pattern_unresolved' });
});
@@ -51,6 +51,7 @@ export interface MoveBookData {
subtitle: string | null;
publisher: string | null;
language: string | null;
isbn10: string | null;
isbn13: string | null;
publishedYear: number | null;
seriesName: string | null;
@@ -138,6 +139,7 @@ export class BookMoveRepository {
subtitle: bookMetadata.subtitle,
publisher: bookMetadata.publisher,
language: bookMetadata.language,
isbn10: bookMetadata.isbn10,
isbn13: bookMetadata.isbn13,
publishedYear: bookMetadata.publishedYear,
seriesName: bookMetadata.seriesName,
@@ -231,6 +233,7 @@ export class BookMoveRepository {
subtitle: row.subtitle,
publisher: row.publisher,
language: row.language,
isbn10: row.isbn10,
isbn13: row.isbn13,
publishedYear: row.publishedYear,
seriesName: row.seriesName,
@@ -40,6 +40,7 @@ function makeBook(overrides: Partial<MoveBookData> = {}): MoveBookData {
subtitle: null,
publisher: null,
language: null,
isbn10: null,
isbn13: null,
publishedYear: null,
seriesName: null,
+15 -7
View File
@@ -989,10 +989,10 @@ describe('BookService', () => {
fileRows: [{ format: 'epub', role: 'primary', mediaOverlayAvailable: false }],
});
pipeline.runWithSources.mockResolvedValue({
resolved: { title: 'New Title' },
resolved: { title: 'New Title', isbn10: '0306406152', isbn13: '9780306406157' },
sources: {},
providerIds: {},
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 1 }),
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 3 }),
});
coverStore.fetchState.mockResolvedValue({ media: { hasEbook: true, hasAudio: false }, filled: { ebook: true, audio: false }, locked: [] });
const updateSpy = vi.spyOn(service, 'updateMetadata');
@@ -1001,8 +1001,8 @@ describe('BookService', () => {
expect(coverStore.fetchState).toHaveBeenCalledWith(1);
expect(result).toEqual({
metadata: { title: 'New Title' },
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 1 }),
metadata: { title: 'New Title', isbn10: '0306406152', isbn13: '9780306406157' },
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 3 }),
});
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(user.id, 7, false);
expect(pipeline.runWithSources).toHaveBeenCalledWith(
@@ -1027,6 +1027,8 @@ describe('BookService', () => {
language: null,
pageCount: null,
communityRating: [],
isbn10: null,
isbn13: '978123',
seriesName: null,
seriesIndex: null,
genres: [],
@@ -1054,7 +1056,7 @@ describe('BookService', () => {
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: { title: 'Resolved', authors: ['A'], genres: ['G'], coverUrl: 'https://img/c.jpg' },
resolved: { title: 'Resolved', authors: ['A'], genres: ['G'], isbn10: '0306406152', isbn13: '9780306406157', coverUrl: 'https://img/c.jpg' },
sources: {},
providerIds: {},
});
@@ -1068,7 +1070,12 @@ describe('BookService', () => {
const result = await service.refreshMetadata(1, false, user);
expect(updateSpy).toHaveBeenCalledWith(1, { title: 'Resolved', authors: ['A'], genres: ['G'] }, user, { postSaveMode: 'schedule' });
expect(updateSpy).toHaveBeenCalledWith(
1,
{ title: 'Resolved', authors: ['A'], genres: ['G'], isbn10: '0306406152', isbn13: '9780306406157' },
user,
{ postSaveMode: 'schedule' },
);
expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith([{ url: 'https://img/c.jpg' }], 1, 'ebook');
expect(getDetailSpy).toHaveBeenCalledWith(1, user);
expect(result).toEqual({ id: 1, title: 'Final' });
@@ -5153,7 +5160,7 @@ describe('BookService', () => {
publisher: 'Mobi Pub',
publishedDate: '2001-02-03',
language: 'en',
isbn: '9781111111111',
isbn: '0-9752298-0-x',
authors: ['Mobius'],
tags: ['Adventure'],
} as never);
@@ -5190,6 +5197,7 @@ describe('BookService', () => {
expect.objectContaining({
title: 'Mobi Title',
publishedYear: 2001,
isbn10: '097522980X',
authors: ['Mobius'],
}),
);
+12 -5
View File
@@ -68,7 +68,6 @@ import type {
JumpBucketsResponse,
JumpBucketsQuery,
MetadataFetchDiagnostics,
MetadataField,
ReadStatus,
ReadAloudProgressSync,
ReadAloudProgressSyncMode,
@@ -87,7 +86,8 @@ import { MetadataService } from '../metadata/metadata.service';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { LibraryService } from '../library/library.service';
import { books } from '../../db/schema';
import { MetadataFetchPipeline, ResolvedMetadataFields } from '../metadata-fetch/metadata-fetch-pipeline';
import { ExistingMetadataFields, MetadataFetchPipeline, ResolvedMetadataFields } from '../metadata-fetch/metadata-fetch-pipeline';
import { splitMetadataIsbn } from '../../common/text-match/isbn-normalize';
import type { MetadataSearchParams } from '../metadata-fetch/providers/metadata-search-params';
import { FileRenameService, RENAME_RELEVANT_FIELDS } from '../file-write/file-rename.service';
import { FileWriteService } from '../file-write/file-write.service';
@@ -426,6 +426,8 @@ export class BookService {
if (r.publishedYear !== undefined) preview.publishedYear = r.publishedYear as number | null;
if (r.language !== undefined) preview.language = r.language as string | null;
if (r.pageCount !== undefined) preview.pageCount = r.pageCount as number | null;
if (r.isbn10 !== undefined) preview.isbn10 = r.isbn10 as string;
if (r.isbn13 !== undefined) preview.isbn13 = r.isbn13 as string;
if (r.communityRatings !== undefined) preview.communityRatings = r.communityRatings as BookCommunityRating[];
if (r.seriesName !== undefined) preview.seriesName = r.seriesName as string | null;
if (r.seriesIndex !== undefined) preview.seriesIndex = r.seriesIndex as string | null;
@@ -2885,7 +2887,7 @@ export class BookService {
const searchParams: MetadataSearchParams = {
title: meta?.title ?? undefined,
author: authorRows[0]?.name ?? undefined,
isbn: meta?.isbn13 ?? meta?.isbn10 ?? undefined,
isbn: meta?.isbn13?.trim() || meta?.isbn10?.trim() || undefined,
seriesName: meta?.seriesName ?? undefined,
seriesIndex: meta?.seriesIndex ?? undefined,
existingProviderIds: providerIds,
@@ -2894,7 +2896,7 @@ export class BookService {
maxCandidatesPerProvider: 1,
};
const existingFields: Partial<Record<MetadataField, unknown>> = {
const existingFields: ExistingMetadataFields = {
title: meta?.title,
subtitle: meta?.subtitle,
description: meta?.description,
@@ -2903,6 +2905,8 @@ export class BookService {
publishedYear: meta?.publishedYear,
language: meta?.language,
pageCount: meta?.pageCount,
isbn10: meta?.isbn10,
isbn13: meta?.isbn13,
communityRating: communityRatingRows,
seriesName: meta?.seriesName,
seriesIndex: meta?.seriesIndex,
@@ -2910,6 +2914,7 @@ export class BookService {
duration: meta?.durationSeconds ?? undefined,
abridged: meta?.abridged ?? undefined,
};
if (meta?.lockedFields?.length) existingFields.lockedFields = meta.lockedFields;
const coverState = await this.coverStore.fetchState(id);
if (!coverState) throw new NotFoundException(`Book ${id} not found`);
const coverInputs = coverFetchInputs(coverState);
@@ -2948,6 +2953,8 @@ export class BookService {
if (r.publishedYear !== undefined) dto.publishedYear = r.publishedYear as number | null;
if (r.language !== undefined) dto.language = r.language as string | null;
if (r.pageCount !== undefined) dto.pageCount = r.pageCount as number | null;
if (r.isbn10 !== undefined) dto.isbn10 = r.isbn10 as string;
if (r.isbn13 !== undefined) dto.isbn13 = r.isbn13 as string;
if (r.communityRatings !== undefined) dto.communityRatings = r.communityRatings as UpdateBookMetadataDto['communityRatings'];
if (r.seriesName !== undefined) dto.seriesName = r.seriesName as string | null;
if (r.seriesIndex !== undefined) dto.seriesIndex = r.seriesIndex as string | null;
@@ -3605,7 +3612,7 @@ export class BookService {
publishedDate,
publishedYear: year,
language: parsed.language,
isbn13: parsed.isbn,
...splitMetadataIsbn(parsed.isbn),
authors: parsed.authors.length > 0 ? parsed.authors : undefined,
genres: parsed.tags.length > 0 ? parsed.tags : undefined,
};
@@ -37,6 +37,7 @@ describe('BulkRenameRepository', () => {
subtitle: null,
publisher: null,
language: 'en',
isbn10: '0306406152',
isbn13: null,
publishedYear: 2001,
seriesName: null,
@@ -100,7 +101,7 @@ describe('BulkRenameRepository', () => {
absolutePath: '/lib/old/a.epub',
authors: ['Author One', 'Author Two'],
narrators: ['Narrator One'],
metadata: { language: 'en', publishedYear: 2001 },
metadata: { language: 'en', publishedYear: 2001, isbn10: '0306406152', isbn13: null },
});
expect(result[1]).toMatchObject({ bookId: 2, authors: ['Author Three'], narrators: ['Narrator Two', 'Narrator Three'] });
@@ -26,6 +26,7 @@ export interface BulkRenameBookData {
subtitle: string | null;
publisher: string | null;
language: string | null;
isbn10: string | null;
isbn13: string | null;
publishedYear: number | null;
seriesName: string | null;
@@ -62,6 +63,7 @@ export class BulkRenameRepository {
subtitle: bookMetadata.subtitle,
publisher: bookMetadata.publisher,
language: bookMetadata.language,
isbn10: bookMetadata.isbn10,
isbn13: bookMetadata.isbn13,
publishedYear: bookMetadata.publishedYear,
seriesName: bookMetadata.seriesName,
@@ -161,6 +163,7 @@ export class BulkRenameRepository {
subtitle: row.subtitle,
publisher: row.publisher,
language: row.language,
isbn10: row.isbn10,
isbn13: row.isbn13,
publishedYear: row.publishedYear,
seriesName: row.seriesName,
@@ -30,6 +30,7 @@ describe('FileRenameRepository', () => {
subtitle: 'Book One',
publisher: 'Ace',
language: 'en',
isbn10: '0441172717',
isbn13: '9780441172719',
publishedYear: 1965,
seriesName: 'Dune',
@@ -81,6 +82,7 @@ describe('FileRenameRepository', () => {
subtitle: 'Book One',
publisher: 'Ace',
language: 'en',
isbn10: '0441172717',
isbn13: '9780441172719',
publishedYear: 1965,
seriesName: 'Dune',
@@ -31,6 +31,7 @@ export interface BookRenameData {
subtitle: string | null;
publisher: string | null;
language: string | null;
isbn10: string | null;
isbn13: string | null;
publishedYear: number | null;
seriesName: string | null;
@@ -79,6 +80,7 @@ export class FileRenameRepository {
subtitle: bookMetadata.subtitle,
publisher: bookMetadata.publisher,
language: bookMetadata.language,
isbn10: bookMetadata.isbn10,
isbn13: bookMetadata.isbn13,
publishedYear: bookMetadata.publishedYear,
seriesName: bookMetadata.seriesName,
@@ -130,6 +132,7 @@ export class FileRenameRepository {
subtitle: row.subtitle,
publisher: row.publisher,
language: row.language,
isbn10: row.isbn10,
isbn13: row.isbn13,
publishedYear: row.publishedYear,
seriesName: row.seriesName,
@@ -56,6 +56,7 @@ describe('FileRenameService', () => {
subtitle: null,
publisher: 'Ace',
language: 'en',
isbn10: null,
isbn13: '9780441172719',
publishedYear: 1965,
seriesName: null,
@@ -183,6 +184,15 @@ describe('FileRenameService', () => {
expect(notificationService.notify).not.toHaveBeenCalled();
});
it('uses ISBN-10 in the naming pattern when ISBN-13 is absent', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({ fileNamingPattern: '{isbn}', metadata: { isbn10: '0306406152', isbn13: null } }),
);
await expect(service.performRename(5, 12)).resolves.toEqual(expect.objectContaining({ status: 'success', newPath: '/library/0306406152.epub' }));
});
it('skips and notifies when another book already owns the target path', async () => {
const { service, renameRepo, notificationService } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
@@ -23,7 +23,7 @@ const FILE_RENAME_EVENT = 'file.rename';
const FILE_RENAME_ROLLBACK_EVENT = 'file.rename_rollback';
const DEFAULT_RENAME_DEBOUNCE_MS = 3_000;
export const RENAME_RELEVANT_FIELDS = new Set(['title', 'authors', 'seriesName', 'seriesIndex', 'publishedYear'] as const);
export const RENAME_RELEVANT_FIELDS = new Set(['title', 'authors', 'seriesName', 'seriesIndex', 'publishedYear', 'isbn10', 'isbn13'] as const);
type RenameBookFile = Awaited<ReturnType<FileRenameRepository['findAllBookFiles']>>[number];
@@ -364,7 +364,8 @@ export class KoreaderCatalogService {
subtitle: row.subtitle,
publisher: row.publisher,
language: row.language,
isbn13: row.isbn13 ?? row.isbn10,
isbn13: row.isbn13,
isbn10: row.isbn10,
publishedYear: row.publishedYear,
seriesName: row.seriesName,
seriesIndex: row.seriesIndex,
@@ -738,7 +739,8 @@ export class KoreaderCatalogService {
subtitle: detail.subtitle,
publisher: detail.publisher,
language: detail.language,
isbn13: detail.isbn13 ?? detail.isbn10,
isbn13: detail.isbn13,
isbn10: detail.isbn10,
publishedYear: detail.publishedYear,
seriesName: detail.seriesName,
seriesIndex: detail.seriesIndex,
@@ -26,6 +26,7 @@ function makeBookData(overrides: Partial<BulkRenameBookData> & { bookId?: number
subtitle: null,
publisher: null,
language: null,
isbn10: null,
isbn13: null,
publishedYear: null,
seriesName: null,
@@ -1,6 +1,6 @@
import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
import { candidateHasNormalizedIsbn, normalizeMetadataIsbn } from './isbn-match';
import { candidateHasNormalizedIsbn, candidatesShareIsbn, normalizeMetadataIsbn } from './isbn-match';
function candidate(data: Partial<MetadataCandidate> = {}): MetadataCandidate {
return {
@@ -12,6 +12,13 @@ function candidate(data: Partial<MetadataCandidate> = {}): MetadataCandidate {
}
describe('isbn-match', () => {
it('matches equivalent ISBN-10 and ISBN-13 across providers', () => {
expect(candidateHasNormalizedIsbn(candidate({ isbn10: '0306406152' }), '9780306406157')).toBe(true);
expect(candidateHasNormalizedIsbn(candidate({ isbn13: '9780306406157' }), '0306406152')).toBe(true);
expect(candidatesShareIsbn(candidate({ isbn10: '0306406152' }), candidate({ isbn13: '9780306406157' }))).toBe(true);
expect(candidatesShareIsbn(candidate({ isbn10: '0306406152' }), candidate({ isbn13: '9781635766264' }))).toBe(false);
});
it('normalizes formatted ISBN values for comparison', () => {
const normalized = normalizeMetadataIsbn('978-952-333-158-7');
@@ -1,20 +1,19 @@
import { MetadataCandidate } from '@bookorbit/types';
import { normalizeMetadataIsbn } from '../../common/text-match/isbn-normalize';
import { canonicalIsbn13, normalizeMetadataIsbn } from '../../common/text-match/isbn-normalize';
export { normalizeMetadataIsbn };
export function candidateHasNormalizedIsbn(candidate: MetadataCandidate, normalizedIsbn: string): boolean {
const canonical = canonicalIsbn13(normalizedIsbn);
return (
normalizedIsbn.length > 0 &&
(normalizeMetadataIsbn(candidate.isbn10) === normalizedIsbn || normalizeMetadataIsbn(candidate.isbn13) === normalizedIsbn)
[candidate.isbn10, candidate.isbn13].some(
(value) => normalizeMetadataIsbn(value) === normalizedIsbn || (canonical !== undefined && canonicalIsbn13(value) === canonical),
)
);
}
export function candidatesShareIsbn(left: MetadataCandidate, right: MetadataCandidate): boolean {
const isbn13 = normalizeMetadataIsbn(left.isbn13);
if (isbn13 && normalizeMetadataIsbn(right.isbn13) === isbn13) return true;
const isbn10 = normalizeMetadataIsbn(left.isbn10);
return isbn10.length > 0 && normalizeMetadataIsbn(right.isbn10) === isbn10;
return [left.isbn10, left.isbn13].some((value) => candidateHasNormalizedIsbn(right, normalizeMetadataIsbn(value)));
}
@@ -114,6 +114,105 @@ describe('MetadataFetchPipeline', () => {
);
});
describe('ISBN resolution', () => {
function primeIsbns(mergeStrategy: FieldPreference['mergeStrategy'] = 'fillMissing', enabled = true) {
const preferences = createPreferences((fields) => {
fields.isbn = { enabled, providers: [MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY], mergeStrategy };
});
preferencesService.getGlobal.mockResolvedValue(preferences);
resolver.resolve.mockReturnValue(preferences);
resolver.withForwardCompatibility.mockReturnValue(preferences);
registry.all.mockReturnValue([{ key: MetadataProviderKey.GOOGLE }, { key: MetadataProviderKey.OPEN_LIBRARY }] as never);
}
it('fills a normalized pair from one provider in priority order', async () => {
primeIsbns();
fetchService.searchCandidates.mockReturnValue(
of(
candidate(MetadataProviderKey.GOOGLE, 'g1', { isbn10: '0-306-40615-2', isbn13: '978-0-306-40615-7' }),
candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1', { isbn13: '9781635766264' }),
),
);
const { resolved, sources } = await pipeline.runWithSources({ title: SHARED_CANDIDATE_TITLE }, {});
expect(resolved).toMatchObject({ isbn10: '0306406152', isbn13: '9780306406157' });
expect(sources).toMatchObject({ isbn10: 'google', isbn13: 'google' });
});
it.each([
{ input: { isbn10: '0-9752298-0-x' }, expected: { isbn10: '097522980X' } },
{ input: { isbn13: '978-0-306-40615-7' }, expected: { isbn13: '9780306406157' } },
{ input: { isbn13: '9798896602590' }, expected: { isbn13: '9798896602590' } },
])('keeps a single provider identifier in its own field: $expected', async ({ input, expected }) => {
primeIsbns();
fetchService.searchCandidates.mockReturnValue(of(candidate(MetadataProviderKey.GOOGLE, 'g1', input)));
const resolved = await pipeline.run({ title: SHARED_CANDIDATE_TITLE }, { isbn10: ' ', isbn13: '' });
expect({ isbn10: resolved.isbn10, isbn13: resolved.isbn13 }).toEqual(expected);
expect(resolved.isbn).toBeUndefined();
});
it('uses a fallback with the same edition to fill a missing counterpart', async () => {
primeIsbns();
fetchService.searchCandidates.mockReturnValue(
of(
candidate(MetadataProviderKey.GOOGLE, 'g1', { isbn13: '9781635766264' }),
candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1', { isbn10: '0306406152', isbn13: '9780306406157' }),
),
);
const resolved = await pipeline.run({ title: SHARED_CANDIDATE_TITLE }, { isbn10: '0-306-40615-2' });
expect(resolved.isbn10).toBeUndefined();
expect(resolved.isbn13).toBe('9780306406157');
});
it('does not combine different editions or accept malformed provider ISBNs', async () => {
primeIsbns();
fetchService.searchCandidates.mockReturnValue(
of(
candidate(MetadataProviderKey.GOOGLE, 'g1', { isbn10: '0306406152', isbn13: '9781635766264' }),
candidate(MetadataProviderKey.OPEN_LIBRARY, 'ol1', { isbn13: '9780306406158' }),
),
);
const resolved = await pipeline.run({ title: SHARED_CANDIDATE_TITLE }, {});
expect(resolved.isbn10).toBeUndefined();
expect(resolved.isbn13).toBeUndefined();
});
it('does not borrow a missing ISBN from another edition', async () => {
primeIsbns();
fetchService.searchCandidates.mockReturnValue(of(candidate(MetadataProviderKey.GOOGLE, 'g1', { isbn13: '9781635766264' })));
const resolved = await pipeline.run({ title: SHARED_CANDIDATE_TITLE }, { isbn10: '0306406152' });
expect(resolved.isbn13).toBeUndefined();
});
it('does not change the edition of a locked ISBN when overwrite is configured', async () => {
primeIsbns('overwriteIfProvided');
fetchService.searchCandidates.mockReturnValue(
of(candidate(MetadataProviderKey.GOOGLE, 'g1', { isbn10: '0306406152', isbn13: '9780306406157' })),
);
const resolved = await pipeline.run({ title: SHARED_CANDIDATE_TITLE }, { isbn13: '9781635766264', lockedFields: ['isbn13'] });
expect(resolved.isbn10).toBeUndefined();
expect(resolved.isbn13).toBeUndefined();
});
it('keeps existing ISBNs by default and supports disabling the rule', async () => {
primeIsbns();
fetchService.searchCandidates.mockReturnValue(of(candidate(MetadataProviderKey.GOOGLE, 'g1', { isbn13: '9781635766264' })));
const resolved = await pipeline.run({ title: SHARED_CANDIDATE_TITLE }, { isbn10: '0306406152', isbn13: '9780306406157' });
expect(resolved.isbn13).toBeUndefined();
primeIsbns('fillMissing', false);
expect((await pipeline.run({ title: SHARED_CANDIDATE_TITLE }, {})).isbn13).toBeUndefined();
});
it('replaces a complete pair only when configured and respects preserve-existing mode', async () => {
primeIsbns('overwriteIfProvided');
fetchService.searchCandidates.mockReturnValue(
of(candidate(MetadataProviderKey.GOOGLE, 'g1', { isbn10: '0306406152', isbn13: '9780306406157' })),
);
const existing = { isbn13: '9781635766264' };
expect(await pipeline.run({ title: SHARED_CANDIDATE_TITLE }, existing)).toMatchObject({ isbn10: '0306406152', isbn13: '9780306406157' });
expect((await pipeline.run({ title: SHARED_CANDIDATE_TITLE }, existing, undefined, { preserveExisting: true })).isbn10).toBeUndefined();
});
});
it('derives enabled provider keys from active fields, filters unknown providers, and de-duplicates keys', async () => {
const global = createPreferences((fields) => {
fields.title = {
@@ -27,6 +27,7 @@ import { SeriesExpectedCountService } from '../../common/services/series-expecte
import { applyGenreFetchOptions, createGenreBlocklistTokenSet, mergeExistingGenres } from '../../common/utils/genre-fetch-options.utils';
import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { canonicalIsbn13, isValidIsbn10, isValidIsbn13, normalizeMetadataIsbn } from '../../common/text-match/isbn-normalize';
import { acceptAgainstAnchor, resolveCandidateAgreement } from './candidate-agreement';
import { MetadataFetchService } from './metadata-fetch.service';
import { ProviderRegistry } from './provider-registry';
@@ -44,6 +45,8 @@ export interface ResolvedCoverChoice {
}
export type ResolvedMetadataFields = Partial<Record<MetadataField, string | string[] | number | null>> & {
isbn10?: string;
isbn13?: string;
/** The ebook slot's first choice. */
coverUrl?: string;
coverChoices?: ResolvedCoverChoice[];
@@ -60,6 +63,9 @@ export type ResolvedMetadataFields = Partial<Record<MetadataField, string | stri
/** `cover` is the ebook slot and `audioCover` the audio slot; each is present when its slot is filled. */
export interface ExistingMetadataFields extends Partial<Record<MetadataField, unknown>> {
isbn10?: string | null;
isbn13?: string | null;
lockedFields?: readonly string[];
chapters?: unknown;
comicMetadata?: Partial<Record<keyof ComicMetadataFields, unknown>> | null;
hardcoverEditionId?: unknown;
@@ -662,6 +668,15 @@ export class MetadataFetchPipeline {
if (field === 'cover' || field === 'audioCover') continue;
const mergeStrategy = options?.preserveExisting ? 'fillMissing' : fieldPreference.mergeStrategy;
if (field === 'isbn') {
const isbn = this.resolveIsbns(fieldPreference.providers, identifying, existing, mergeStrategy);
if (isbn) {
Object.assign(result, isbn.values);
for (const key of Object.keys(isbn.values)) sources[key] = isbn.provider;
}
continue;
}
if (field === 'genres' && preferences.options?.genres.mode === 'merge') {
const { genres, sourceProvider } = this.mergeGenres(
fieldPreference.providers as MetadataProviderKey[],
@@ -774,6 +789,42 @@ export class MetadataFetchPipeline {
return { resolved: result, sources, providerIds };
}
private resolveIsbns(
providers: readonly string[],
candidates: CandidatesByProvider,
existing: ExistingMetadataFields,
strategy: MetadataMergeStrategy,
): { values: Pick<ResolvedMetadataFields, 'isbn10' | 'isbn13'>; provider: string } | undefined {
const fillMissing = strategy === 'fillMissing';
const locked = new Set(existing.lockedFields);
const missing10 = !existing.isbn10?.trim();
const missing13 = !existing.isbn13?.trim();
if (fillMissing && !missing10 && !missing13) return undefined;
for (const provider of providers) {
const candidate = candidates.get(provider);
if (!candidate) continue;
const normalized10 = normalizeMetadataIsbn(candidate.isbn10);
const normalized13 = normalizeMetadataIsbn(candidate.isbn13);
const isbn10 = isValidIsbn10(normalized10) ? normalized10 : undefined;
const isbn13 = isValidIsbn13(normalized13) ? normalized13 : undefined;
const canonical = canonicalIsbn13(isbn13 ?? isbn10);
if (!canonical || (isbn10 && isbn13 && canonicalIsbn13(isbn10) !== isbn13)) continue;
// Keep the pair on one edition, including when only one side can be filled.
const retained = [
...((fillMissing || !isbn10 || locked.has('isbn10')) && !missing10 ? [existing.isbn10] : []),
...((fillMissing || !isbn13 || locked.has('isbn13')) && !missing13 ? [existing.isbn13] : []),
];
if (retained.some((value) => canonicalIsbn13(value) !== canonical)) continue;
const values: Pick<ResolvedMetadataFields, 'isbn10' | 'isbn13'> = {};
if (isbn10 && (!fillMissing || missing10)) values.isbn10 = isbn10;
if (isbn13 && (!fillMissing || missing13)) values.isbn13 = isbn13;
if (Object.keys(values).length > 0) return { values, provider };
}
return undefined;
}
private extractField(candidate: MetadataCandidate, field: MetadataField): unknown {
const map: Partial<Record<MetadataField, keyof MetadataCandidate>> = {
title: 'title',
@@ -13,6 +13,7 @@ describe('MetadataPreferenceResolver', () => {
const defaults = resolver.getDefaultPreferences();
expect(Object.keys(defaults.fields)).toHaveLength(ALL_METADATA_FIELDS.length);
expect(defaults.fields.isbn).toMatchObject({ enabled: true, mergeStrategy: 'fillMissing' });
const fieldsWithItunes: (keyof typeof defaults.fields)[] = ['title', 'subtitle', 'description', 'authors'];
for (const field of fieldsWithItunes) {
@@ -49,6 +49,7 @@ const PROVIDERS_WITH_ITUNES: MetadataProviderKey[] = [
];
const FIELD_DEFAULTS: Partial<Record<MetadataField, Partial<FieldPreference>>> = {
isbn: { mergeStrategy: 'fillMissing' },
title: { mergeStrategy: 'overwriteIfProvided', providers: PROVIDERS_WITH_ITUNES },
subtitle: { providers: PROVIDERS_WITH_ITUNES },
description: { providers: PROVIDERS_WITH_ITUNES },
@@ -502,10 +502,19 @@ describe('metadata format extractors', () => {
expect.objectContaining({
publishedYear: 1992,
cover: null,
isbn13: '9780553380958',
}),
);
});
it('puts a formatted MOBI ISBN-10 in isbn10', async () => {
mockParseMobiFile.mockResolvedValue({ title: 'Book', isbn: '0-9752298-0-x', authors: [], tags: [] } as never);
mockExtractMobiCover.mockResolvedValue(null);
const result = await new MobiFormatExtractor().extract('/books/book.mobi');
expect(result?.isbn10).toBe('097522980X');
expect(result?.isbn13).toBeUndefined();
});
it('pdf extractor returns null for unparseable PDFs', async () => {
mockParsePdfFile.mockResolvedValue(null);
@@ -1,4 +1,5 @@
import { extractMobiCover, parseMobiFile } from '../lib/mobi-parser';
import { splitMetadataIsbn } from '../../../common/text-match/isbn-normalize';
import { parsePublishedDateKey, parsePublishedYear, publishedYearFromDateKey } from '../../../common/utils/published-date.utils';
import type { FormatExtractor, ParsedBookData } from './format-extractor.interface';
@@ -12,7 +13,7 @@ export class MobiFormatExtractor implements FormatExtractor {
return {
title: mobi.title,
description: mobi.description,
isbn13: mobi.isbn,
...splitMetadataIsbn(mobi.isbn),
publisher: mobi.publisher,
publishedDate,
publishedYear,
+2 -23
View File
@@ -1,4 +1,4 @@
import { normalizeIsbn } from '../../../common/text-match/isbn-normalize';
import { isValidIsbn10, isValidIsbn13, normalizeIsbn } from '../../../common/text-match/isbn-normalize';
export interface IsbnHit {
value: string;
@@ -8,28 +8,7 @@ export interface IsbnHit {
const LABEL_LOOKBACK = 25;
export { normalizeIsbn };
export function isValidIsbn10(n: string): boolean {
if (!/^[0-9]{9}[0-9X]$/.test(n)) return false;
let sum = 0;
for (let i = 0; i < 10; i++) {
const c = n[i];
const v = c === 'X' ? 10 : c.charCodeAt(0) - 48;
sum += (i + 1) * v;
}
return sum % 11 === 0;
}
export function isValidIsbn13(n: string): boolean {
if (!/^97[89][0-9]{10}$/.test(n)) return false;
let sum = 0;
for (let i = 0; i < 13; i++) {
const v = n.charCodeAt(i) - 48;
sum += i % 2 === 0 ? v : v * 3;
}
return sum % 10 === 0;
}
export { isValidIsbn10, isValidIsbn13, normalizeIsbn };
/**
* Find checksum-valid ISBN-10/13 strings inside free text. Each hit records whether
@@ -610,6 +610,30 @@ describe('UploadService', () => {
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Stephen King/The Stand.mobi');
});
it.each(['9780385121682', '097522980X'])('mobi metadata populates the ISBN naming token: %s', async (isbn) => {
validator.validateFormat.mockReturnValue('mobi');
validator.sanitizeFilename.mockReturnValue('book.mobi');
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['mobi'], fileNamingPattern: '{authors:first}/{title}-{isbn}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockParseMobiFile.mockResolvedValue({
title: 'The Stand',
authors: ['Stephen King'],
publisher: 'Doubleday',
isbn,
publishedDate: '1978-10-03',
language: 'en',
description: null,
tags: [],
});
storage.streamToTemp.mockResolvedValue({ tempPath: 'upload.bin', sizeBytes: 456 });
const result = await service.upload(1, 2, 'raw.mobi', {} as any, user);
expect(result.filename).toBe(`The Stand-${isbn}.mobi`);
expect(storage.moveToPath.mock.lastCall?.[1]).toBe(`/library/Stephen King/The Stand-${isbn}.mobi`);
});
it('pdf metadata populates tokens', async () => {
validator.validateFormat.mockReturnValue('pdf');
validator.sanitizeFilename.mockReturnValue('doc.pdf');
+4 -2
View File
@@ -6,6 +6,7 @@ import { Readable } from 'stream';
import { and, asc, eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { splitMetadataIsbn } from '../../common/text-match/isbn-normalize';
import { buildPatternTokens, patternReferencesToken } from '../../common/utils/pattern-tokens.utils';
import { selectPrimaryFileKeepingCurrent } from '../../common/utils/primary-file-selection.utils';
@@ -250,7 +251,7 @@ export class UploadService {
shouldCleanupDestination = true;
await this.storage.moveToPath(tempPath, destination);
// File is on disk. Do not delete it on any subsequent failure — the scanner
// File is on disk. Do not delete it on any subsequent failure; the scanner
// will reconcile any orphan. This also prevents the catch block from deleting
// a file that a concurrent upload may have written to the same path.
shouldCleanupDestination = false;
@@ -462,6 +463,7 @@ export class UploadService {
seriesName?: string | null;
seriesIndex?: string | null;
isbn13?: string | null;
isbn10?: string | null;
authors: { name: string }[];
narrators?: string[];
} | null = null;
@@ -483,7 +485,7 @@ export class UploadService {
publisher: mobi.publisher,
publishedYear: year,
language: mobi.language,
isbn13: mobi.isbn,
...splitMetadataIsbn(mobi.isbn),
seriesName: null,
seriesIndex: null,
authors: mobi.authors.map((name) => ({ name })),
@@ -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,