diff --git a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue index 9678044f9..209e84eda 100644 --- a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue +++ b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue @@ -714,6 +714,8 @@ function showSaveResultToast(write: WriteResult | null, libraryAutoWriteEnabled: function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch { return { + isbn10: preview.isbn10, + isbn13: preview.isbn13, title: preview.title, subtitle: preview.subtitle, description: preview.description, diff --git a/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.test.ts b/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.test.ts index 8d9754973..a5ab559ba 100644 --- a/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.test.ts +++ b/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.test.ts @@ -23,6 +23,11 @@ vi.mock('../../../composables/useCoverVersions', () => ({ }), })) +vi.mock('../../../composables/useMetadataSearchPreferences', async () => { + const { ref } = await vi.importActual('vue') + return { useMetadataSearchPreferences: () => ({ autoSearchOnOpen: ref(true) }) } +}) + vi.mock('../../../composables/useMetadataSearch', async () => { const vue = await vi.importActual('vue') @@ -150,6 +155,12 @@ describe('MetadataSearchDrawer', () => { expect(metadataSearchMocks.search).not.toHaveBeenCalled() }) + it('uses ISBN-10 when ISBN-13 is empty', async () => { + mountDrawer([], { isbn13: ' ', isbn10: '0306406152' } as Partial) + await flushPromises() + expect(metadataSearchMocks.search).toHaveBeenCalledWith(expect.objectContaining({ isbn: '0306406152' })) + }) + it('filters sources and retries one without re-running the whole search', async () => { const wrapper = mountDrawer() await flushPromises() diff --git a/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.vue b/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.vue index 5b8add311..483f2af88 100644 --- a/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.vue +++ b/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.vue @@ -50,7 +50,7 @@ const mainCoverUrl = computed(() => slotCoverUrl(mainCoverMedium.value)) const searchDefaults = computed(() => ({ title: props.book.title ?? undefined, author: props.book.authors[0]?.name ?? undefined, - isbn: props.book.isbn13 ?? props.book.isbn10 ?? undefined, + isbn: props.book.isbn13?.trim() || props.book.isbn10?.trim() || undefined, })) const currentSource = computed(() => ({ diff --git a/client/src/features/book/components/detail/tabs/__tests__/EditMetadataTab.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/EditMetadataTab.spec.ts index 8b3798800..ae400bb4c 100644 --- a/client/src/features/book/components/detail/tabs/__tests__/EditMetadataTab.spec.ts +++ b/client/src/features/book/components/detail/tabs/__tests__/EditMetadataTab.spec.ts @@ -198,6 +198,54 @@ describe('EditMetadataTab cover tiles', () => { expect(saveButton(wrapper).attributes('disabled')).toBeUndefined() }) + it('applies preview ISBNs to the editor and saves them only after Save', async () => { + vi.mocked(api).mockImplementation(async (url, init) => { + if (String(url).includes('/refresh-metadata?preview=true')) { + return json({ + metadata: { isbn10: '0306406152', isbn13: '9780306406157' }, + diagnostics: { candidateProviders: ['google'], enabledUnreferencedProviders: [] }, + }) + } + if (init?.method === 'PATCH') return json({ book: savedBook, write: null, libraryAutoWriteEnabled: false }) + if (String(url).includes('/metadata-fetch/providers')) return json([]) + return json({}) + }) + const wrapper = mountTab(makeBook()) + await flushPromises() + const autoFillButton = wrapper.findAll('button').find((button) => button.text().includes('book.detail.editMetadata.autoFill'))! + await autoFillButton.trigger('click') + await flushPromises() + expect(patchCalls()).toHaveLength(0) + expect((wrapper.get('[data-field="isbn10"] input').element as HTMLInputElement).value).toBe('0306406152') + expect((wrapper.get('[data-field="isbn13"] input').element as HTMLInputElement).value).toBe('9780306406157') + await saveButton(wrapper).trigger('click') + await flushPromises() + const body = JSON.parse(patchCalls()[0]![1]!.body as string) + expect(body.metadata).toMatchObject({ isbn10: '0306406152', isbn13: '9780306406157' }) + }) + + it.each(['isbn10', 'isbn13'] as const)('preserves the %s lock when applying preview ISBNs', async (lockedField) => { + vi.mocked(api).mockImplementation(async (url) => { + if (String(url).includes('/refresh-metadata?preview=true')) { + return json({ + metadata: { isbn10: '0306406152', isbn13: '9780306406157' }, + diagnostics: { candidateProviders: ['google'], enabledUnreferencedProviders: [] }, + }) + } + if (String(url).includes('/metadata-fetch/providers')) return json([]) + return json({}) + }) + const wrapper = mountTab(makeBook({ lockedFields: [lockedField] })) + await flushPromises() + await wrapper.get('[aria-label="book.detail.editMetadata.autoFill"]').trigger('click') + await flushPromises() + expect((wrapper.get(`[data-field="${lockedField}"] input`).element as HTMLInputElement).value).toBe('') + const unlockedField = lockedField === 'isbn10' ? 'isbn13' : 'isbn10' + expect((wrapper.get(`[data-field="${unlockedField}"] input`).element as HTMLInputElement).value).toBe( + unlockedField === 'isbn10' ? '0306406152' : '9780306406157', + ) + }) + it('opens reminder preferences from the popover without leaving the unsaved book form', async () => { const wrapper = mountTab(makeBook()) document.body.append(wrapper.element) diff --git a/client/src/features/settings/metadata-preferences/lib/field-rules.ts b/client/src/features/settings/metadata-preferences/lib/field-rules.ts index 6ee31e807..989b37170 100644 --- a/client/src/features/settings/metadata-preferences/lib/field-rules.ts +++ b/client/src/features/settings/metadata-preferences/lib/field-rules.ts @@ -5,7 +5,7 @@ import type { FieldPreference, MetadataField, MetadataProviderKey, ProviderStatu export const FIELD_GROUPS: { id: string; fields: MetadataField[] }[] = [ { id: 'core', fields: ['title', 'subtitle', 'description', 'cover', 'audioCover'] }, { id: 'contributors', fields: ['authors'] }, - { id: 'publication', fields: ['publisher', 'publishedYear', 'language', 'pageCount', 'communityRating'] }, + { id: 'publication', fields: ['publisher', 'isbn', 'publishedYear', 'language', 'pageCount', 'communityRating'] }, { id: 'series', fields: ['seriesName', 'seriesIndex'] }, { id: 'classification', fields: ['genres'] }, { id: 'audiobook', fields: ['narrators', 'duration', 'abridged'] }, diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 079e35acb..17f613bbc 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1570,6 +1570,7 @@ "publishedYear": "Published year", "language": "Language", "pageCount": "Page count", + "isbn": "ISBNs", "communityRating": "Community rating", "seriesName": "Series name", "seriesIndex": "Series index", diff --git a/packages/types/src/book.ts b/packages/types/src/book.ts index 894133365..7dd37d380 100644 --- a/packages/types/src/book.ts +++ b/packages/types/src/book.ts @@ -349,6 +349,8 @@ export type BookMetadataSaveResult = { }; export type BookMetadataRefreshPreviewFields = { + isbn10?: string | null; + isbn13?: string | null; title?: string | null; subtitle?: string | null; description?: string | null; diff --git a/packages/types/src/metadata-preferences.ts b/packages/types/src/metadata-preferences.ts index a7f44824e..aee5b712e 100644 --- a/packages/types/src/metadata-preferences.ts +++ b/packages/types/src/metadata-preferences.ts @@ -8,6 +8,7 @@ export type MetadataField = | "audioCover" | "authors" | "publisher" + | "isbn" | "publishedYear" | "language" | "pageCount" @@ -27,6 +28,7 @@ export const ALL_METADATA_FIELDS: MetadataField[] = [ "audioCover", "authors", "publisher", + "isbn", "publishedYear", "language", "pageCount", diff --git a/server/src/common/text-match/isbn-normalize.test.ts b/server/src/common/text-match/isbn-normalize.test.ts new file mode 100644 index 000000000..fd96327dd --- /dev/null +++ b/server/src/common/text-match/isbn-normalize.test.ts @@ -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(); + }); +}); diff --git a/server/src/common/text-match/isbn-normalize.ts b/server/src/common/text-match/isbn-normalize.ts index 47c7b2ac7..8079d7ba3 100644 --- a/server/src/common/text-match/isbn-normalize.ts +++ b/server/src/common/text-match/isbn-normalize.ts @@ -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}`; +} diff --git a/server/src/common/utils/pattern-tokens.utils.test.ts b/server/src/common/utils/pattern-tokens.utils.test.ts index f83003038..54ae6727f 100644 --- a/server/src/common/utils/pattern-tokens.utils.test.ts +++ b/server/src/common/utils/pattern-tokens.utils.test.ts @@ -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', diff --git a/server/src/common/utils/pattern-tokens.utils.ts b/server/src/common/utils/pattern-tokens.utils.ts index 8ba89e690..e779950b6 100644 --- a/server/src/common/utils/pattern-tokens.utils.ts +++ b/server/src/common/utils/pattern-tokens.utils.ts @@ -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 { 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); diff --git a/server/src/modules/book-dock/book-dock-ingest.service.ts b/server/src/modules/book-dock/book-dock-ingest.service.ts index 57f22c351..c34ba0d31 100644 --- a/server/src/modules/book-dock/book-dock-ingest.service.ts +++ b/server/src/modules/book-dock/book-dock-ingest.service.ts @@ -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; diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.test.ts index 1cb09672e..59888846c 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.test.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.test.ts @@ -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(() => { diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.ts index c83dc2046..e25505a20 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.ts @@ -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': diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts index 5430fdb87..3b10dc88e 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts @@ -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', diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts index a45cfda5d..0e3932e04 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts @@ -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, diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts index ad8465483..dc41d13f9 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts @@ -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); diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.ts index 6509924b5..ae21e1daa 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.ts @@ -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': diff --git a/server/src/modules/book-metadata-lock/book-metadata-lock.service.test.ts b/server/src/modules/book-metadata-lock/book-metadata-lock.service.test.ts index c0b4760b0..a172e62eb 100644 --- a/server/src/modules/book-metadata-lock/book-metadata-lock.service.test.ts +++ b/server/src/modules/book-metadata-lock/book-metadata-lock.service.test.ts @@ -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(); diff --git a/server/src/modules/book-metadata-lock/book-metadata-lock.service.ts b/server/src/modules/book-metadata-lock/book-metadata-lock.service.ts index 6ceed3beb..eb173cdaa 100644 --- a/server/src/modules/book-metadata-lock/book-metadata-lock.service.ts +++ b/server/src/modules/book-metadata-lock/book-metadata-lock.service.ts @@ -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'); diff --git a/server/src/modules/book-move/book-move-planner.service.test.ts b/server/src/modules/book-move/book-move-planner.service.test.ts index 794dc6bda..be125b119 100644 --- a/server/src/modules/book-move/book-move-planner.service.test.ts +++ b/server/src/modules/book-move/book-move-planner.service.test.ts @@ -33,6 +33,7 @@ function makeBook(overrides: Partial = {}): 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' }); }); diff --git a/server/src/modules/book-move/book-move.repository.ts b/server/src/modules/book-move/book-move.repository.ts index 1aa1b1ec5..6f988a2bf 100644 --- a/server/src/modules/book-move/book-move.repository.ts +++ b/server/src/modules/book-move/book-move.repository.ts @@ -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, diff --git a/server/src/modules/book-move/book-move.service.test.ts b/server/src/modules/book-move/book-move.service.test.ts index 23419f05c..7372daac4 100644 --- a/server/src/modules/book-move/book-move.service.test.ts +++ b/server/src/modules/book-move/book-move.service.test.ts @@ -40,6 +40,7 @@ function makeBook(overrides: Partial = {}): MoveBookData { subtitle: null, publisher: null, language: null, + isbn10: null, isbn13: null, publishedYear: null, seriesName: null, diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts index 7e0ea98db..733007595 100644 --- a/server/src/modules/book/book.service.test.ts +++ b/server/src/modules/book/book.service.test.ts @@ -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'], }), ); diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index 20cdf7cac..50d74c60f 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -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> = { + 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, }; diff --git a/server/src/modules/file-write/bulk-rename.repository.test.ts b/server/src/modules/file-write/bulk-rename.repository.test.ts index dcb666f7c..09b1f184f 100644 --- a/server/src/modules/file-write/bulk-rename.repository.test.ts +++ b/server/src/modules/file-write/bulk-rename.repository.test.ts @@ -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'] }); diff --git a/server/src/modules/file-write/bulk-rename.repository.ts b/server/src/modules/file-write/bulk-rename.repository.ts index 70aee6a80..a73a740b6 100644 --- a/server/src/modules/file-write/bulk-rename.repository.ts +++ b/server/src/modules/file-write/bulk-rename.repository.ts @@ -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, diff --git a/server/src/modules/file-write/file-rename.repository.test.ts b/server/src/modules/file-write/file-rename.repository.test.ts index fa62bfae4..ac815aed5 100644 --- a/server/src/modules/file-write/file-rename.repository.test.ts +++ b/server/src/modules/file-write/file-rename.repository.test.ts @@ -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', diff --git a/server/src/modules/file-write/file-rename.repository.ts b/server/src/modules/file-write/file-rename.repository.ts index d72afc43d..d6ac4a7b2 100644 --- a/server/src/modules/file-write/file-rename.repository.ts +++ b/server/src/modules/file-write/file-rename.repository.ts @@ -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, diff --git a/server/src/modules/file-write/file-rename.service.test.ts b/server/src/modules/file-write/file-rename.service.test.ts index 96a2dae7a..74d69ff3a 100644 --- a/server/src/modules/file-write/file-rename.service.test.ts +++ b/server/src/modules/file-write/file-rename.service.test.ts @@ -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( diff --git a/server/src/modules/file-write/file-rename.service.ts b/server/src/modules/file-write/file-rename.service.ts index 48fde64d3..1d0638ed8 100644 --- a/server/src/modules/file-write/file-rename.service.ts +++ b/server/src/modules/file-write/file-rename.service.ts @@ -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>[number]; diff --git a/server/src/modules/koreader/koreader-catalog.service.ts b/server/src/modules/koreader/koreader-catalog.service.ts index 10bab8fbf..5ffa0f367 100644 --- a/server/src/modules/koreader/koreader-catalog.service.ts +++ b/server/src/modules/koreader/koreader-catalog.service.ts @@ -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, diff --git a/server/src/modules/library/bulk-rename.service.test.ts b/server/src/modules/library/bulk-rename.service.test.ts index da6582851..6c7e0f37b 100644 --- a/server/src/modules/library/bulk-rename.service.test.ts +++ b/server/src/modules/library/bulk-rename.service.test.ts @@ -26,6 +26,7 @@ function makeBookData(overrides: Partial & { bookId?: number subtitle: null, publisher: null, language: null, + isbn10: null, isbn13: null, publishedYear: null, seriesName: null, diff --git a/server/src/modules/metadata-fetch/isbn-match.test.ts b/server/src/modules/metadata-fetch/isbn-match.test.ts index d50a2619e..9b5934ee5 100644 --- a/server/src/modules/metadata-fetch/isbn-match.test.ts +++ b/server/src/modules/metadata-fetch/isbn-match.test.ts @@ -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 { return { @@ -12,6 +12,13 @@ function candidate(data: Partial = {}): 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'); diff --git a/server/src/modules/metadata-fetch/isbn-match.ts b/server/src/modules/metadata-fetch/isbn-match.ts index b469c3fd6..a5b5e9522 100644 --- a/server/src/modules/metadata-fetch/isbn-match.ts +++ b/server/src/modules/metadata-fetch/isbn-match.ts @@ -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))); } diff --git a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts index 45b6f2ad7..527b49094 100644 --- a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts +++ b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts @@ -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 = { diff --git a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts index 0b25ee700..b605adb08 100644 --- a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts +++ b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts @@ -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> & { + isbn10?: string; + isbn13?: string; /** The ebook slot's first choice. */ coverUrl?: string; coverChoices?: ResolvedCoverChoice[]; @@ -60,6 +63,9 @@ export type ResolvedMetadataFields = Partial> { + isbn10?: string | null; + isbn13?: string | null; + lockedFields?: readonly string[]; chapters?: unknown; comicMetadata?: Partial> | 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; 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 = {}; + 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> = { title: 'title', diff --git a/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts b/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts index 9663061c4..8a0c0f58e 100644 --- a/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts +++ b/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts @@ -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) { diff --git a/server/src/modules/metadata-preferences/metadata-preference-resolver.ts b/server/src/modules/metadata-preferences/metadata-preference-resolver.ts index 02fa7152d..ed17a9b4f 100644 --- a/server/src/modules/metadata-preferences/metadata-preference-resolver.ts +++ b/server/src/modules/metadata-preferences/metadata-preference-resolver.ts @@ -49,6 +49,7 @@ const PROVIDERS_WITH_ITUNES: MetadataProviderKey[] = [ ]; const FIELD_DEFAULTS: Partial>> = { + isbn: { mergeStrategy: 'fillMissing' }, title: { mergeStrategy: 'overwriteIfProvided', providers: PROVIDERS_WITH_ITUNES }, subtitle: { providers: PROVIDERS_WITH_ITUNES }, description: { providers: PROVIDERS_WITH_ITUNES }, diff --git a/server/src/modules/metadata/extractors/format-extractors.test.ts b/server/src/modules/metadata/extractors/format-extractors.test.ts index 0413f5d07..488c6e958 100644 --- a/server/src/modules/metadata/extractors/format-extractors.test.ts +++ b/server/src/modules/metadata/extractors/format-extractors.test.ts @@ -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); diff --git a/server/src/modules/metadata/extractors/mobi-format.extractor.ts b/server/src/modules/metadata/extractors/mobi-format.extractor.ts index d0f63f4d1..d102b1453 100644 --- a/server/src/modules/metadata/extractors/mobi-format.extractor.ts +++ b/server/src/modules/metadata/extractors/mobi-format.extractor.ts @@ -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, diff --git a/server/src/modules/metadata/lib/isbn-detect.ts b/server/src/modules/metadata/lib/isbn-detect.ts index d92fd46af..7390fa1c8 100644 --- a/server/src/modules/metadata/lib/isbn-detect.ts +++ b/server/src/modules/metadata/lib/isbn-detect.ts @@ -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 diff --git a/server/src/modules/upload/upload.service.test.ts b/server/src/modules/upload/upload.service.test.ts index 7757ae5f0..da78b4dc5 100644 --- a/server/src/modules/upload/upload.service.test.ts +++ b/server/src/modules/upload/upload.service.test.ts @@ -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'); diff --git a/server/src/modules/upload/upload.service.ts b/server/src/modules/upload/upload.service.ts index 51ab96bb6..4867ec158 100644 --- a/server/src/modules/upload/upload.service.ts +++ b/server/src/modules/upload/upload.service.ts @@ -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 })), diff --git a/server/test/e2e/file-rename/file-rename-harness.ts b/server/test/e2e/file-rename/file-rename-harness.ts index 5413d22f8..306a79edc 100644 --- a/server/test/e2e/file-rename/file-rename-harness.ts +++ b/server/test/e2e/file-rename/file-rename-harness.ts @@ -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 }), }, diff --git a/server/test/e2e/metadata-lock/metadata-lock-harness.ts b/server/test/e2e/metadata-lock/metadata-lock-harness.ts index 7abb172ea..4c0056e2f 100644 --- a/server/test/e2e/metadata-lock/metadata-lock-harness.ts +++ b/server/test/e2e/metadata-lock/metadata-lock-harness.ts @@ -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, diff --git a/server/test/e2e/metadata-lock/metadata-lock-test-providers.ts b/server/test/e2e/metadata-lock/metadata-lock-test-providers.ts index 9778ec563..6613ea194 100644 --- a/server/test/e2e/metadata-lock/metadata-lock-test-providers.ts +++ b/server/test/e2e/metadata-lock/metadata-lock-test-providers.ts @@ -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, diff --git a/server/test/file-rename.e2e-spec.ts b/server/test/file-rename.e2e-spec.ts index 800a4b65e..1d1de1154 100644 --- a/server/test/file-rename.e2e-spec.ts +++ b/server/test/file-rename.e2e-spec.ts @@ -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', diff --git a/server/test/metadata-lock.e2e-spec.ts b/server/test/metadata-lock.e2e-spec.ts index 54003446e..702bfad84 100644 --- a/server/test/metadata-lock.e2e-spec.ts +++ b/server/test/metadata-lock.e2e-spec.ts @@ -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,