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