diff --git a/client/src/features/book-dock/components/BookDockFileSheet.vue b/client/src/features/book-dock/components/BookDockFileSheet.vue index 9b146b959..de3309b38 100644 --- a/client/src/features/book-dock/components/BookDockFileSheet.vue +++ b/client/src/features/book-dock/components/BookDockFileSheet.vue @@ -10,6 +10,7 @@ import { type MetadataCandidate, type MetadataSource, type MetadataProviderKey, + type ProviderIds, } from '@bookorbit/types' import BookDockStatusBadge from './BookDockStatusBadge.vue' import MetadataSearchPanel from '@/features/book/components/detail/tabs/MetadataSearchPanel.vue' @@ -64,11 +65,13 @@ const form = reactive({ genres: '', }) const selectedCoverUrl = ref('') +const passthroughMetadata = ref({}) watch( () => props.file.id, () => { const m = meta.value + passthroughMetadata.value = { ...m } form.title = m.title ?? '' form.subtitle = m.subtitle ?? '' form.authors = m.authors?.join(', ') ?? '' @@ -114,6 +117,7 @@ onUnmounted(() => { function buildMetadataPatchFromForm(): Partial { return { + ...passthroughMetadata.value, title: form.title || undefined, subtitle: form.subtitle || undefined, authors: form.authors @@ -215,7 +219,7 @@ const currentSource = computed(() => ({ publishedDate: form.publishedDate || null, publishedYear: form.publishedYear ? ((n) => (isNaN(n) ? null : n))(parseInt(form.publishedYear, 10)) : null, language: form.language || null, - pageCount: null, + pageCount: passthroughMetadata.value.pageCount ?? null, seriesName: form.seriesName || null, seriesIndex: form.seriesIndex ? ((n) => (isNaN(n) ? null : n))(parseFloat(form.seriesIndex)) : null, isbn10: form.isbn10 || null, @@ -232,11 +236,31 @@ const currentSource = computed(() => ({ .map((g) => g.trim()) .filter(Boolean) : [], - narrators: [], - durationSeconds: null, - abridged: null, - hardcoverEditionId: null, - communityRatings: [], + narrators: passthroughMetadata.value.narrators ?? [], + durationSeconds: passthroughMetadata.value.durationSeconds ?? null, + abridged: passthroughMetadata.value.abridged ?? null, + hardcoverEditionId: passthroughMetadata.value.hardcoverEditionId ?? null, + communityRatings: (passthroughMetadata.value.communityRatings ?? []).map((rating) => ({ + ...rating, + ratingCount: rating.ratingCount ?? null, + updatedAt: null, + })), +})) + +const providerIds = computed(() => ({ + google: passthroughMetadata.value.googleBooksId ?? null, + goodreads: passthroughMetadata.value.goodreadsId ?? null, + amazon: passthroughMetadata.value.amazonId ?? null, + hardcover: passthroughMetadata.value.hardcoverId ?? null, + openLibrary: passthroughMetadata.value.openLibraryId ?? null, + itunes: passthroughMetadata.value.itunesId ?? null, + audible: passthroughMetadata.value.audibleId ?? null, + librofm: passthroughMetadata.value.librofmId ?? null, + kobo: passthroughMetadata.value.koboId ?? null, + comicvine: passthroughMetadata.value.comicvineId ?? null, + ranobedb: passthroughMetadata.value.ranobedbId ?? null, + lubimyczytac: passthroughMetadata.value.lubimyczytacId ?? null, + aladin: passthroughMetadata.value.aladinId ?? null, })) function openSearch() { @@ -265,6 +289,9 @@ function backFromDiff() { async function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) { const p = patch.formPatch + const bookDockPatch = { ...p } + delete bookDockPatch.customMetadata + passthroughMetadata.value = { ...passthroughMetadata.value, ...bookDockPatch } if ('title' in p) form.title = p.title ?? '' if ('subtitle' in p) form.subtitle = p.subtitle ?? '' if ('description' in p) form.description = p.description ?? '' @@ -281,6 +308,7 @@ async function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string if (patch.coverUrl !== undefined) { selectedCoverUrl.value = patch.coverUrl + passthroughMetadata.value.coverUrl = patch.coverUrl } if (debounceTimer) { @@ -330,19 +358,27 @@ function openFetchedDiff() { provider: 'auto' as MetadataProviderKey, providerId: '', title: f.title ?? '', - subtitle: f.subtitle, + subtitle: f.subtitle ?? undefined, authors: f.authors, - description: f.description, - publisher: f.publisher, - publishedYear: f.publishedYear, - language: f.language, - pageCount: f.pageCount, - isbn10: f.isbn10, - isbn13: f.isbn13, - seriesName: f.seriesName, - seriesIndex: f.seriesIndex, + description: f.description ?? undefined, + publisher: f.publisher ?? undefined, + publishedDate: f.publishedDate ?? undefined, + publishedYear: f.publishedYear ?? undefined, + language: f.language ?? undefined, + pageCount: f.pageCount ?? undefined, + isbn10: f.isbn10 ?? undefined, + isbn13: f.isbn13 ?? undefined, + seriesName: f.seriesName ?? undefined, + seriesIndex: f.seriesIndex ?? undefined, genres: f.genres, - coverUrl: f.coverUrl, + coverUrl: f.coverUrl ?? undefined, + narrators: f.narrators, + durationSeconds: f.durationSeconds ?? undefined, + abridged: f.abridged ?? undefined, + hardcoverEditionId: f.hardcoverEditionId ?? undefined, + seriesMemberships: f.seriesMemberships ?? undefined, + chapters: f.chapters ?? undefined, + comicMetadata: f.comicMetadata ?? undefined, } diffSource.value = 'fetched' metaView.value = 'diff' @@ -628,7 +664,7 @@ onMounted(() => { :providers="providers" :back-label="diffSource === 'fetched' ? t('common.back') : t('bookDock.sheet.results')" :current-cover-url="currentBookDockCoverUrl" - :provider-ids="(file.fetchedMetadataSources as any) ?? undefined" + :provider-ids="providerIds" @back="backFromDiff" @apply="handleApply" /> diff --git a/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts b/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts index c9e271809..805efd77e 100644 --- a/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts +++ b/client/src/features/book-dock/components/__tests__/BookDockFileSheet.spec.ts @@ -1,20 +1,22 @@ import { shallowMount } from '@vue/test-utils' import { defineComponent, reactive, ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { BookDockFile } from '@bookorbit/types' +import type { BookDockFile, MetadataCandidate } from '@bookorbit/types' import BookDockFileSheet from '../BookDockFileSheet.vue' const mocks = vi.hoisted(() => ({ fetchLibraries: vi.fn<() => Promise>(), loadProviders: vi.fn<() => Promise>(), search: vi.fn<(...args: unknown[]) => Promise>(), + saveMetadata: vi.fn<(...args: unknown[]) => Promise>(), + filteredResults: [] as MetadataCandidate[], })) vi.mock('../../composables/useBookDockDetail', () => ({ useBookDockDetail: () => ({ saved: ref(false), saveError: ref(null), - saveMetadata: vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(null), + saveMetadata: mocks.saveMetadata, setTarget: vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(null), discardFile: vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(undefined), coverUrl: (id: number) => `/api/v1/book-dock/files/${id}/cover`, @@ -30,7 +32,7 @@ vi.mock('@/features/library/composables/useLibraries', () => ({ vi.mock('@/features/book/composables/useMetadataSearch', () => ({ useMetadataSearch: () => ({ - filteredResults: ref([]), + filteredResults: ref(mocks.filteredResults), providerCounts: reactive({}), isStreaming: ref(false), hasSearched: ref(false), @@ -55,6 +57,12 @@ const MetadataSearchPanelStub = defineComponent({ template: '
', }) +const MetadataDiffPanelStub = defineComponent({ + name: 'MetadataDiffPanel', + emits: ['apply'], + template: '
', +}) + function makeFile(overrides: Partial = {}): BookDockFile { return { id: 1, @@ -83,7 +91,7 @@ function mountSheet(file: BookDockFile) { global: { stubs: { MetadataSearchPanel: MetadataSearchPanelStub, - MetadataDiffPanel: true, + MetadataDiffPanel: MetadataDiffPanelStub, BookDockStatusBadge: true, }, }, @@ -103,6 +111,8 @@ describe('BookDockFileSheet metadata search defaults', () => { vi.clearAllMocks() mocks.fetchLibraries.mockResolvedValue(undefined) mocks.loadProviders.mockResolvedValue(undefined) + mocks.saveMetadata.mockResolvedValue(null) + mocks.filteredResults.length = 0 }) it.each([ @@ -150,4 +160,54 @@ describe('BookDockFileSheet metadata search defaults', () => { isbn: '9781401284770', }) }) + + it('persists every metadata field emitted by the search diff', async () => { + const candidate: MetadataCandidate = { + provider: 'hardcover', + providerId: 'hardcover-book', + title: 'Dune', + } + mocks.filteredResults.push(candidate) + const wrapper = mountSheet(makeFile()) + const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Search') + await searchButton!.trigger('click') + wrapper.getComponent(MetadataSearchPanelStub).vm.$emit('select', candidate) + await wrapper.vm.$nextTick() + + wrapper.getComponent(MetadataDiffPanelStub).vm.$emit('apply', { + formPatch: { + title: 'Dune', + pageCount: 688, + narrators: ['Simon Vance'], + durationSeconds: 1200, + abridged: false, + seriesMemberships: [{ seriesName: 'Dune', seriesIndex: 1 }], + communityRatings: [{ provider: 'hardcover', rating: 4.5, ratingCount: 1000 }], + hardcoverId: 'hardcover-book', + hardcoverEditionId: 'hardcover-edition', + openLibraryId: 'OL1W', + comicMetadata: { issueNumber: '1', pencillers: ['Artist'] }, + }, + coverUrl: 'https://covers.example/dune.jpg', + }) + await wrapper.vm.$nextTick() + + expect(mocks.saveMetadata).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + title: 'Dune', + pageCount: 688, + narrators: ['Simon Vance'], + durationSeconds: 1200, + abridged: false, + seriesMemberships: [{ seriesName: 'Dune', seriesIndex: 1 }], + communityRatings: [{ provider: 'hardcover', rating: 4.5, ratingCount: 1000 }], + hardcoverId: 'hardcover-book', + hardcoverEditionId: 'hardcover-edition', + openLibraryId: 'OL1W', + comicMetadata: { issueNumber: '1', pencillers: ['Artist'] }, + coverUrl: 'https://covers.example/dune.jpg', + }), + ) + }) }) diff --git a/packages/types/src/book-dock.ts b/packages/types/src/book-dock.ts index 556026447..64b50eed9 100644 --- a/packages/types/src/book-dock.ts +++ b/packages/types/src/book-dock.ts @@ -1,4 +1,5 @@ import type { AudiobookChapter } from "./audiobook"; +import type { ComicMetadataFields, MetadataProviderKey, MetadataSeriesMembership } from "./metadata-fetch"; export type BookDockFileStatus = "pending" | "extracting" | "fetching" | "ready" | "error"; export type BookDockAutoFinalizeMetadataMode = "safe_merge" | "fetched_only" | "embedded_only"; @@ -16,24 +17,42 @@ export function resolveBookDockSearchTitle(fileName: string, metadataTitle?: str } export interface BookDockMetadata { - title?: string; - subtitle?: string; + title?: string | null; + subtitle?: string | null; authors?: string[]; narrators?: string[]; - description?: string; - publisher?: string; - publishedDate?: string; - publishedYear?: number; - language?: string; - pageCount?: number; - isbn10?: string; - isbn13?: string; - seriesName?: string; - seriesIndex?: number; + description?: string | null; + publisher?: string | null; + publishedDate?: string | null; + publishedYear?: number | null; + language?: string | null; + pageCount?: number | null; + isbn10?: string | null; + isbn13?: string | null; + seriesName?: string | null; + seriesIndex?: number | null; + seriesMemberships?: MetadataSeriesMembership[] | null; genres?: string[]; - coverUrl?: string; - durationSeconds?: number; - chapters?: AudiobookChapter[]; + coverUrl?: string | null; + durationSeconds?: number | null; + abridged?: boolean | null; + chapters?: AudiobookChapter[] | null; + communityRatings?: Array<{ provider: MetadataProviderKey; rating: number; ratingCount?: number | null }> | null; + googleBooksId?: string | null; + goodreadsId?: string | null; + amazonId?: string | null; + hardcoverId?: string | null; + hardcoverEditionId?: string | null; + openLibraryId?: string | null; + itunesId?: string | null; + audibleId?: string | null; + librofmId?: string | null; + koboId?: string | null; + comicvineId?: string | null; + ranobedbId?: string | null; + lubimyczytacId?: string | null; + aladinId?: string | null; + comicMetadata?: ComicMetadataFields | null; } export interface BookDockFile { diff --git a/scripts/e2e/suite-registry.mjs b/scripts/e2e/suite-registry.mjs index c2935ed7e..fb9914cf2 100644 --- a/scripts/e2e/suite-registry.mjs +++ b/scripts/e2e/suite-registry.mjs @@ -122,22 +122,22 @@ export const E2E_SUITES = Object.freeze({ ...SHARED_DB_AND_HELPER_PATHS, ], }, - "book-bucket-ingest-finalize": { - id: "book-bucket-ingest-finalize", - name: "Book Bucket Ingest Finalize", + "book-dock-ingest-finalize": { + id: "book-dock-ingest-finalize", + name: "Book Dock Ingest Finalize", timeout: 45, lane: "full", - description: "Book Bucket ingest and finalize suite", - vitestTarget: "test/book-bucket-ingest-finalize.e2e-spec.ts", - junitOutput: `${TEST_RESULTS_DIR}/book-bucket-ingest-finalize-e2e-junit.xml`, + description: "Book Dock ingest and finalize suite", + vitestTarget: "test/book-dock-ingest-finalize.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/book-dock-ingest-finalize-e2e-junit.xml`, prepareDedicatedDatabase: true, useDedicatedDatabase: true, changedPaths: [ - "server/src/modules/book-bucket/**", + "server/src/modules/book-dock/**", "server/src/modules/upload/**", "server/src/modules/metadata/**", - "server/test/book-bucket-ingest-finalize.e2e-spec.ts", - "server/test/e2e/book-bucket/**", + "server/test/book-dock-ingest-finalize.e2e-spec.ts", + "server/test/e2e/book-dock/**", ...SHARED_DB_AND_HELPER_PATHS, ], }, diff --git a/server/src/modules/book-dock/book-dock-finalize.service.test.ts b/server/src/modules/book-dock/book-dock-finalize.service.test.ts index d2ab1c500..12a35c27c 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.test.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.test.ts @@ -45,6 +45,10 @@ function makeService() { replaceAuthors: vi.fn().mockResolvedValue(undefined), replaceGenres: vi.fn().mockResolvedValue(undefined), replaceNarrators: vi.fn().mockResolvedValue(undefined), + upsertComicMetadata: vi.fn().mockResolvedValue(undefined), + }; + const bookReadService = { + replaceCommunityRatings: vi.fn().mockResolvedValue(undefined), }; const validator = { validateFormat: vi.fn(), @@ -66,6 +70,10 @@ function makeService() { isPaused: vi.fn().mockResolvedValue(false), getCachedPaused: vi.fn().mockReturnValue(false), }; + const seriesMemberships = { + replaceForBook: vi.fn().mockResolvedValue(undefined), + syncPrimaryFromMetadata: vi.fn().mockResolvedValue(undefined), + }; const service = new BookDockFinalizeService( db as never, @@ -73,6 +81,7 @@ function makeService() { libraryService as never, appSettings as never, metadataService as never, + bookReadService as never, validator as never, storage as never, processor as never, @@ -80,9 +89,26 @@ function makeService() { gateway as never, { notify: vi.fn().mockResolvedValue(undefined) } as never, processingState as never, + undefined as never, + seriesMemberships as never, ); - return { service, db, repo, libraryService, appSettings, metadataService, validator, storage, processor, events, gateway, processingState }; + return { + service, + db, + repo, + libraryService, + appSettings, + metadataService, + bookReadService, + validator, + storage, + processor, + events, + gateway, + processingState, + seriesMemberships, + }; } function makeRow(overrides?: Partial>) { @@ -1203,6 +1229,79 @@ describe('BookDockFinalizeService', () => { ); }); + it('applyMetadata persists provider IDs and every structured field emitted by metadata search', async () => { + const { service, db, metadataService, bookReadService, seriesMemberships } = makeService(); + const updateChain = { + set: vi.fn(), + where: vi.fn().mockResolvedValue(undefined), + }; + updateChain.set.mockReturnValue(updateChain); + db.update.mockReturnValue(updateChain); + + await (service as any).applyMetadata( + 19, + makeRow({ + coverPath: null, + selectedMetadata: { + title: 'Dune', + pageCount: 688, + narrators: ['Simon Vance'], + durationSeconds: 1200, + abridged: false, + seriesMemberships: [ + { seriesName: 'Dune', seriesIndex: 1 }, + { seriesName: 'Dune Chronicles', seriesIndex: 1 }, + ], + communityRatings: [{ provider: 'hardcover', rating: 4.5, ratingCount: 1000 }], + googleBooksId: 'google-id', + goodreadsId: 'goodreads-id', + amazonId: 'amazon-id', + hardcoverId: 'hardcover-book', + hardcoverEditionId: 'hardcover-edition', + openLibraryId: 'OL1W', + itunesId: 'itunes-id', + audibleId: 'audible-id', + librofmId: 'librofm-id', + koboId: 'kobo-id', + comicvineId: 'comicvine-id', + ranobedbId: 'ranobedb-id', + lubimyczytacId: 'lubimyczytac-id', + aladinId: 'aladin-id', + comicMetadata: { issueNumber: '1', pencillers: ['Artist'] }, + } as BookDockMetadata, + }), + ); + + expect(updateChain.set).toHaveBeenCalledWith( + expect.objectContaining({ + pageCount: 688, + durationSeconds: 1200, + abridged: false, + googleBooksId: 'google-id', + goodreadsId: 'goodreads-id', + amazonId: 'amazon-id', + hardcoverId: 'hardcover-book', + hardcoverEditionId: 'hardcover-edition', + openLibraryId: 'OL1W', + itunesId: 'itunes-id', + audibleId: 'audible-id', + librofmId: 'librofm-id', + koboId: 'kobo-id', + comicvineId: 'comicvine-id', + ranobedbId: 'ranobedb-id', + lubimyczytacId: 'lubimyczytac-id', + aladinId: 'aladin-id', + }), + ); + expect(seriesMemberships.replaceForBook).toHaveBeenCalledWith(19, [ + { seriesName: 'Dune', seriesIndex: 1 }, + { seriesName: 'Dune Chronicles', seriesIndex: 1 }, + ]); + expect(bookReadService.replaceCommunityRatings).toHaveBeenCalledWith(19, [{ provider: 'hardcover', rating: 4.5, ratingCount: 1000 }]); + expect(metadataService.upsertComicMetadata).toHaveBeenCalledWith(19, { issueNumber: '1', pencillers: ['Artist'] }); + expect(metadataService.replaceNarrators).toHaveBeenCalledWith(19, [{ name: 'Simon Vance', sortName: null }]); + }); + it('applyMetadata prefers selected coverUrl and skips extracted cover copy when download succeeds', async () => { const { service, db, metadataService } = makeService(); metadataService.downloadAndSaveCover.mockResolvedValueOnce(true); diff --git a/server/src/modules/book-dock/book-dock-finalize.service.ts b/server/src/modules/book-dock/book-dock-finalize.service.ts index 177281bf2..36cceccea 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.ts @@ -25,8 +25,11 @@ import type { BookDockFinalizePreviewStatus, BookDockFinalizeResult, BookDockMetadata, + ComicMetadataFields, + MetadataSeriesMembership, } from '@bookorbit/types'; -import { NotificationType, resolveUploadPath } from '@bookorbit/types'; +import { MetadataProviderKey, NotificationType, resolveUploadPath } from '@bookorbit/types'; +import { BookReadService } from '../book/book-read.service'; import { NotificationService } from '../notification/notification.service'; import { SeriesIdentityService } from '../../common/services/series-identity.service'; import { SeriesMembershipService } from '../../common/services/series-membership.service'; @@ -68,6 +71,7 @@ const MAX_PUBLISHED_YEAR = 2200; const PUBLISHED_YEAR_RANGE_CONSTRAINT = 'book_metadata_published_year_range_chk'; const INVALID_PUBLISHED_YEAR_MESSAGE = `Invalid metadata: published year must be between ${MIN_PUBLISHED_YEAR} and ${MAX_PUBLISHED_YEAR}.`; const INVALID_METADATA_MESSAGE = 'Invalid metadata values for this file. Review metadata fields and try again.'; +const METADATA_PROVIDER_KEYS = new Set(Object.values(MetadataProviderKey)); type NormalizedFinalizeMetadata = { title: string | null; @@ -85,6 +89,23 @@ type NormalizedFinalizeMetadata = { authors: string[]; genres: string[]; coverUrl: string | null; + googleBooksId: string | null; + goodreadsId: string | null; + amazonId: string | null; + hardcoverId: string | null; + hardcoverEditionId: string | null; + openLibraryId: string | null; + itunesId: string | null; + audibleId: string | null; + librofmId: string | null; + koboId: string | null; + comicvineId: string | null; + ranobedbId: string | null; + lubimyczytacId: string | null; + aladinId: string | null; + seriesMemberships: MetadataSeriesMembership[] | undefined; + communityRatings: Array<{ provider: MetadataProviderKey; rating: number; ratingCount: number | null }> | undefined; + comicMetadata: ComicMetadataFields | undefined; }; type FinalizeCandidateAnalysis = { @@ -112,6 +133,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots private readonly libraryService: LibraryService, private readonly appSettings: AppSettingsService, private readonly metadataService: MetadataService, + private readonly bookReadService: BookReadService, private readonly validator: UploadValidatorService, private readonly storage: UploadStorageService, private readonly processor: UploadProcessorService, @@ -899,7 +921,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots private async applyMetadata(bookId: number, row: BookDockFileRow): Promise { const meta = normalizeFinalizeMetadata(row.selectedMetadata ?? row.embeddedMetadata); - const audio = resolveAudioFinalizeFields(row.embeddedMetadata ?? row.selectedMetadata); + const audio = resolveAudioFinalizeFields(row.embeddedMetadata, row.selectedMetadata); let selectedCoverApplied = false; const selectedCoverUrl = meta.coverUrl; @@ -929,6 +951,20 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots seriesName: meta.seriesName ?? null, seriesIndex: meta.seriesIndex ?? null, pageCount: meta.pageCount ?? null, + googleBooksId: meta.googleBooksId, + goodreadsId: meta.goodreadsId, + amazonId: meta.amazonId, + hardcoverId: meta.hardcoverId, + hardcoverEditionId: meta.hardcoverEditionId, + openLibraryId: meta.openLibraryId, + itunesId: meta.itunesId, + audibleId: meta.audibleId, + librofmId: meta.librofmId, + koboId: meta.koboId, + comicvineId: meta.comicvineId, + ranobedbId: meta.ranobedbId, + lubimyczytacId: meta.lubimyczytacId, + aladinId: meta.aladinId, updatedAt: new Date(), }; const patch = (await this.seriesIdentity?.resolveMetadataPatch(scalarFields)) ?? scalarFields; @@ -937,7 +973,19 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots .update(bookMetadata) .set({ ...patch, ...buildAudioMetadataPatch(audio) }) .where(eq(bookMetadata.bookId, bookId)); - await this.seriesMemberships?.syncPrimaryFromMetadata(bookId); + if (meta.seriesMemberships !== undefined) { + await this.seriesMemberships?.replaceForBook(bookId, meta.seriesMemberships); + } else { + await this.seriesMemberships?.syncPrimaryFromMetadata(bookId); + } + + if (meta.communityRatings !== undefined) { + await this.bookReadService.replaceCommunityRatings(bookId, meta.communityRatings); + } + + if (meta.comicMetadata) { + await this.metadataService.upsertComicMetadata(bookId, meta.comicMetadata); + } if (meta.authors.length > 0) { await this.metadataService.replaceAuthors( @@ -1143,30 +1191,108 @@ function normalizeFinalizeMetadata(meta: BookDockMetadata | null | undefined): N authors: normalizeStringArray(meta?.authors, 500), genres: normalizeStringArray(meta?.genres, 200), coverUrl: normalizeText(meta?.coverUrl), + googleBooksId: normalizeText(meta?.googleBooksId, 50), + goodreadsId: normalizeText(meta?.goodreadsId, 50), + amazonId: normalizeText(meta?.amazonId, 20), + hardcoverId: normalizeText(meta?.hardcoverId, 255), + hardcoverEditionId: normalizeText(meta?.hardcoverEditionId, 50), + openLibraryId: normalizeText(meta?.openLibraryId, 50), + itunesId: normalizeText(meta?.itunesId, 50), + audibleId: normalizeText(meta?.audibleId, 20), + librofmId: normalizeText(meta?.librofmId, 50), + koboId: normalizeText(meta?.koboId, 255), + comicvineId: normalizeText(meta?.comicvineId, 50), + ranobedbId: normalizeText(meta?.ranobedbId, 50), + lubimyczytacId: normalizeText(meta?.lubimyczytacId, 512), + aladinId: normalizeText(meta?.aladinId, 20), + seriesMemberships: normalizeSeriesMemberships(meta?.seriesMemberships), + communityRatings: normalizeCommunityRatings(meta?.communityRatings), + comicMetadata: normalizeComicMetadata(meta?.comicMetadata), }; } +function normalizeSeriesMemberships(value: BookDockMetadata['seriesMemberships']): MetadataSeriesMembership[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) return []; + + const memberships: MetadataSeriesMembership[] = []; + const seen = new Set(); + for (const item of value) { + const seriesName = normalizeText(item?.seriesName, 500); + if (!seriesName) continue; + const key = seriesName.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + memberships.push({ seriesName, seriesIndex: normalizeReal(item.seriesIndex) }); + } + return memberships; +} + +function normalizeCommunityRatings( + value: BookDockMetadata['communityRatings'], +): Array<{ provider: MetadataProviderKey; rating: number; ratingCount: number | null }> | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) return []; + + const ratings = new Map(); + for (const item of value) { + if (!item || !METADATA_PROVIDER_KEYS.has(item.provider)) continue; + if (!Number.isFinite(item.rating) || item.rating < 0 || item.rating > 5) continue; + const ratingCount = Number.isInteger(item.ratingCount) && item.ratingCount! >= 0 ? item.ratingCount! : null; + ratings.set(item.provider, { provider: item.provider, rating: item.rating, ratingCount }); + } + return [...ratings.values()]; +} + +function normalizeComicMetadata(value: BookDockMetadata['comicMetadata']): ComicMetadataFields | undefined { + if (!value || typeof value !== 'object') return undefined; + + const comic: ComicMetadataFields = {}; + const issueNumber = normalizeText(value.issueNumber, 50); + const volumeName = normalizeText(value.volumeName, 500); + if (issueNumber !== null) comic.issueNumber = issueNumber; + if (volumeName !== null) comic.volumeName = volumeName; + + const arrayFields = ['pencillers', 'inkers', 'colorists', 'letterers', 'coverArtists', 'characters', 'teams', 'locations', 'storyArcs'] as const; + for (const field of arrayFields) { + const normalized = normalizeStringArray(value[field], 500); + if (normalized.length > 0) comic[field] = normalized; + } + + return Object.keys(comic).length > 0 ? comic : undefined; +} + type AudioFinalizeFields = { durationSeconds: number | null; chapters: AudiobookChapter[] | null; narrators: string[]; + abridged: boolean | null; }; -function resolveAudioFinalizeFields(meta: BookDockMetadata | null | undefined): AudioFinalizeFields { +function resolveAudioFinalizeFields( + embedded: BookDockMetadata | null | undefined, + selected: BookDockMetadata | null | undefined, +): AudioFinalizeFields { return { - durationSeconds: normalizeDurationSeconds(meta?.durationSeconds), - chapters: normalizeChapters(meta?.chapters), - narrators: normalizeStringArray(meta?.narrators, 500), + durationSeconds: normalizeDurationSeconds(selected?.durationSeconds !== undefined ? selected.durationSeconds : embedded?.durationSeconds), + chapters: normalizeChapters(selected?.chapters !== undefined ? selected.chapters : embedded?.chapters), + narrators: normalizeStringArray(selected?.narrators !== undefined ? selected.narrators : embedded?.narrators, 500), + abridged: normalizeAbridged(selected?.abridged !== undefined ? selected.abridged : embedded?.abridged), }; } -function buildAudioMetadataPatch(audio: AudioFinalizeFields): { durationSeconds?: number; chapters?: AudiobookChapter[] } { - const patch: { durationSeconds?: number; chapters?: AudiobookChapter[] } = {}; +function buildAudioMetadataPatch(audio: AudioFinalizeFields): { durationSeconds?: number; chapters?: AudiobookChapter[]; abridged?: boolean } { + const patch: { durationSeconds?: number; chapters?: AudiobookChapter[]; abridged?: boolean } = {}; if (audio.durationSeconds !== null) patch.durationSeconds = audio.durationSeconds; if (audio.chapters !== null) patch.chapters = audio.chapters; + if (audio.abridged !== null) patch.abridged = audio.abridged; return patch; } +function normalizeAbridged(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null; +} + function normalizeDurationSeconds(value: unknown): number | null { const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value.trim()) : NaN; if (!Number.isFinite(parsed) || parsed <= 0) return null; diff --git a/server/src/modules/book-dock/book-dock-ingest.service.test.ts b/server/src/modules/book-dock/book-dock-ingest.service.test.ts index 8f9bb9710..7c953393d 100644 --- a/server/src/modules/book-dock/book-dock-ingest.service.test.ts +++ b/server/src/modules/book-dock/book-dock-ingest.service.test.ts @@ -403,8 +403,13 @@ describe('BookDockIngestService', () => { title: 'Dune', isbn13: '9780441172719', authors: ['Frank Herbert'], + hardcoverEditionId: 'hardcover-edition', }, sources: { title: { provider: 'google' } }, + providerIds: { + hardcover: 'hardcover-book', + openLibrary: 'OL1W', + }, }); await (service as any).autoFetchMetadataAsync(9); @@ -416,7 +421,17 @@ describe('BookDockIngestService', () => { expect.objectContaining({ status: 'ready', confidence: 95, - fetchedMetadata: expect.objectContaining({ title: 'Dune', isbn13: '9780441172719' }), + fetchedMetadata: expect.objectContaining({ + title: 'Dune', + isbn13: '9780441172719', + hardcoverId: 'hardcover-book', + hardcoverEditionId: 'hardcover-edition', + openLibraryId: 'OL1W', + }), + fetchedMetadataSources: expect.objectContaining({ + hardcoverId: 'hardcover', + openLibraryId: 'openLibrary', + }), }), ); }); 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 bed4e9636..d466ba099 100644 --- a/server/src/modules/book-dock/book-dock-ingest.service.ts +++ b/server/src/modules/book-dock/book-dock-ingest.service.ts @@ -4,7 +4,7 @@ import { basename, extname, join } from 'path'; import { mkdir, realpath, stat } from 'fs/promises'; import { Readable } from 'stream'; -import { resolveBookDockSearchTitle, type BookDockMetadata } from '@bookorbit/types'; +import { MetadataProviderKey, resolveBookDockSearchTitle, type BookDockMetadata } from '@bookorbit/types'; import type { BookDockFileRow } from '../../db/schema'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { SUPPORTED_BOOK_FORMATS, UploadValidatorService } from '../upload/upload-validator.service'; @@ -235,12 +235,18 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe await this.repo.update(fileId, { status: 'fetching' }); await this.emitSummary(); try { - const { resolved, sources } = await this.metadataFetchPipeline.runWithSources(params, {}); + const { resolved, sources, providerIds = {} } = await this.metadataFetchPipeline.runWithSources(params, {}); const fetched: Record = {}; for (const [field, value] of Object.entries(resolved)) { if (value === undefined) continue; fetched[field] = value; } + for (const [provider, providerId] of Object.entries(providerIds)) { + const field = BOOK_DOCK_PROVIDER_ID_FIELDS[provider as MetadataProviderKey]; + if (!field || !providerId) continue; + fetched[field] = providerId; + sources[field] = provider; + } const updates = Object.keys(fetched).length > 0 ? { @@ -277,6 +283,22 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe } } +const BOOK_DOCK_PROVIDER_ID_FIELDS: Partial> = { + [MetadataProviderKey.GOOGLE]: 'googleBooksId', + [MetadataProviderKey.GOODREADS]: 'goodreadsId', + [MetadataProviderKey.AMAZON]: 'amazonId', + [MetadataProviderKey.HARDCOVER]: 'hardcoverId', + [MetadataProviderKey.OPEN_LIBRARY]: 'openLibraryId', + [MetadataProviderKey.ITUNES]: 'itunesId', + [MetadataProviderKey.AUDIBLE]: 'audibleId', + [MetadataProviderKey.LIBROFM]: 'librofmId', + [MetadataProviderKey.KOBO]: 'koboId', + [MetadataProviderKey.COMICVINE]: 'comicvineId', + [MetadataProviderKey.RANOBEDB]: 'ranobedbId', + [MetadataProviderKey.LUBIMYCZYTAC]: 'lubimyczytacId', + [MetadataProviderKey.ALADIN]: 'aladinId', +}; + function resolveSupportedFormat(row: Pick): string | null { const format = row.format ?? extname(row.absolutePath).toLowerCase().slice(1); return isSupportedFormat(format) ? format : null; diff --git a/server/src/modules/book-dock/book-dock-metadata.service.test.ts b/server/src/modules/book-dock/book-dock-metadata.service.test.ts index de457f680..e8d142e37 100644 --- a/server/src/modules/book-dock/book-dock-metadata.service.test.ts +++ b/server/src/modules/book-dock/book-dock-metadata.service.test.ts @@ -3,601 +3,212 @@ vi.mock('fs/promises', () => ({ writeFile: vi.fn(), })); -vi.mock('../metadata/lib/pdf-parser', () => ({ - parsePdfFile: vi.fn(), -})); - -vi.mock('../metadata/lib/epub', () => ({ - extractEpubMetadata: vi.fn(), -})); - -vi.mock('../metadata/lib/mobi-parser', () => ({ - parseMobiFile: vi.fn(), -})); - -vi.mock('../metadata/lib/cbz-metadata', () => ({ - extractCbzMetadata: vi.fn(), - extractCbrMetadata: vi.fn(), - extractCb7Metadata: vi.fn(), -})); - -vi.mock('../metadata/lib/fb2-parser', () => ({ - parseFb2File: vi.fn(), -})); - vi.mock('../metadata/lib/cover', () => ({ - extractCover: vi.fn(), generateThumbnail: vi.fn(), imageExt: vi.fn(), })); -vi.mock('../metadata/extractors/audio.extractor', () => ({ - extractAudioMetadata: vi.fn(), -})); - import { Logger } from '@nestjs/common'; import { mkdir, writeFile } from 'fs/promises'; -import type { MockedFunction } from 'vitest'; -import type { ParsedOpf } from '../metadata/lib/opf-parser'; - -import { extractEpubMetadata } from '../metadata/lib/epub'; -import { parseMobiFile } from '../metadata/lib/mobi-parser'; -import { extractCb7Metadata, extractCbrMetadata, extractCbzMetadata } from '../metadata/lib/cbz-metadata'; -import { parseFb2File } from '../metadata/lib/fb2-parser'; -import { extractCover, generateThumbnail, imageExt } from '../metadata/lib/cover'; -import { extractAudioMetadata, type AudioExtractResult } from '../metadata/extractors/audio.extractor'; -import { parsePdfFile } from '../metadata/lib/pdf-parser'; +import type { ParsedBookData } from '../metadata/extractors/format-extractor.interface'; +import { generateThumbnail, imageExt } from '../metadata/lib/cover'; import { BookDockMetadataService } from './book-dock-metadata.service'; -function makeAudioResult(overrides?: Partial): AudioExtractResult { - return { - title: null, - subtitle: null, - authors: [], - narrators: [], - publisher: null, - publishedYear: null, - description: null, - language: null, - seriesName: null, - seriesIndex: null, - genres: [], - audibleId: null, - durationSeconds: null, - chapters: [], - coverBytes: null, - ...overrides, - }; -} +const mockMkdir = vi.mocked(mkdir); +const mockWriteFile = vi.mocked(writeFile); +const mockGenerateThumbnail = vi.mocked(generateThumbnail); +const mockImageExt = vi.mocked(imageExt); -function makeParsedOpf(overrides: Partial = {}): ParsedOpf { - return { - title: null, - subtitle: null, - description: null, - isbn10: null, - isbn13: null, - publisher: null, - publishedYear: null, - language: null, - pageCount: null, - rating: null, - seriesName: null, - seriesIndex: null, - authors: [], - genres: [], - tags: [], - googleBooksId: null, - goodreadsId: null, - amazonId: null, - hardcoverId: null, - hardcoverEditionId: null, - openLibraryId: null, - ranobedbId: null, - koboId: null, - lubimyczytacId: null, - aladinId: null, - itunesId: null, - customMetadata: {}, - coverHref: null, - ...overrides, - }; -} +const richMetadata: ParsedBookData = { + title: 'Dune', + subtitle: 'Deluxe Edition', + description: 'A desert planet.', + isbn10: '0441172717', + isbn13: '9780441172719', + publisher: 'Ace', + publishedDate: '1965-08-01', + publishedYear: 1965, + language: 'en', + seriesName: 'Dune', + seriesIndex: 1, + authors: [{ name: 'Frank Herbert', sortName: 'Herbert, Frank' }], + genres: ['Science Fiction'], + googleBooksId: 'google-id', + goodreadsId: 'goodreads-id', + amazonId: 'amazon-id', + hardcoverId: 'hardcover-book', + hardcoverEditionId: 'hardcover-edition', + openLibraryId: 'OL1W', + itunesId: 'itunes-id', + audibleId: 'audible-id', + librofmId: 'librofm-id', + koboId: 'kobo-id', + ranobedbId: 'ranobedb-id', + lubimyczytacId: 'lubimyczytac-id', + aladinId: 'aladin-id', + narrators: ['Simon Vance'], + durationSeconds: 1200, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + pageCount: 688, + comicMetadata: { + issueNumber: '1', + volumeName: null, + pencillers: ['Artist'], + inkers: [], + colorists: [], + letterers: [], + coverArtists: [], + characters: [], + teams: [], + locations: [], + storyArcs: [], + }, + cover: Buffer.from('cover-bytes'), +}; -const mockMkdir = mkdir as MockedFunction; -const mockWriteFile = writeFile as MockedFunction; -const mockExtractCover = extractCover as MockedFunction; -const mockGenerateThumbnail = generateThumbnail as MockedFunction; -const mockImageExt = imageExt as MockedFunction; -const mockParsePdfFile = parsePdfFile as MockedFunction; -const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction; -const mockParseMobiFile = parseMobiFile as MockedFunction; -const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction; -const mockExtractCbrMetadata = extractCbrMetadata as MockedFunction; -const mockExtractCb7Metadata = extractCb7Metadata as MockedFunction; -const mockParseFb2File = parseFb2File as MockedFunction; -const mockExtractAudioMetadata = extractAudioMetadata as MockedFunction; - -describe('BookDockMetadataService', () => { +function makeService() { const repo = { update: vi.fn().mockResolvedValue(undefined), }; + const extractionService = { + extractWithCoverFallback: vi.fn().mockResolvedValue({ + metadata: richMetadata, + cover: richMetadata.cover, + }), + }; + const service = new BookDockMetadataService(repo as never, extractionService as never); + return { service, repo, extractionService }; +} +describe('BookDockMetadataService', () => { beforeEach(() => { - vi.resetAllMocks(); - repo.update.mockResolvedValue(undefined); + vi.clearAllMocks(); mockMkdir.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined); - mockExtractCover.mockResolvedValue(null); - mockGenerateThumbnail.mockResolvedValue(Buffer.from('thumb')); - mockImageExt.mockReturnValue('png'); - mockParsePdfFile.mockResolvedValue(null); - mockExtractEpubMetadata.mockResolvedValue(null); - mockParseMobiFile.mockResolvedValue(null); - mockExtractCbzMetadata.mockResolvedValue(null); - mockExtractCbrMetadata.mockResolvedValue(null); - mockExtractCb7Metadata.mockResolvedValue(null); - mockParseFb2File.mockResolvedValue(null); - mockExtractAudioMetadata.mockResolvedValue(makeAudioResult()); + mockGenerateThumbnail.mockResolvedValue(Buffer.from('thumbnail-bytes')); + mockImageExt.mockReturnValue('jpg'); }); - it('extractAndSave(pdf) reuses a single parse result for metadata and cover persistence', async () => { - mockParsePdfFile.mockResolvedValue({ - title: 'PDF Title', - subtitle: null, - description: null, - publisher: 'PDF Publisher', - publishedYear: null, - language: null, - authors: [{ name: 'Author A', sortName: null }], - genres: ['Genre A'], - tags: [], - isbn10: null, - isbn13: null, - seriesName: null, - seriesIndex: null, - rating: null, - pageCount: 200, - googleBooksId: null, - goodreadsId: null, - amazonId: null, - hardcoverId: null, - hardcoverEditionId: null, - openLibraryId: null, - itunesId: null, - coverBuffer: Buffer.from('cover'), - }); + it('adapts the canonical extraction result without dropping supported metadata', async () => { + const { service, repo, extractionService } = makeService(); - const service = new BookDockMetadataService(repo as never); + await service.extractAndSave(7, '/incoming/dune.epub', 'epub', '/book-dock/covers'); - await service.extractAndSave(5, '/tmp/book.pdf', 'pdf', '/tmp/covers'); - - expect(mockParsePdfFile).toHaveBeenCalledTimes(1); - expect(mockParsePdfFile).toHaveBeenCalledWith( - '/tmp/book.pdf', - expect.objectContaining({ - extractCover: true, - onWarning: expect.any(Function), - }), - ); - expect(mockExtractCover).not.toHaveBeenCalled(); - expect(repo.update).toHaveBeenNthCalledWith(1, 5, { status: 'extracting' }); - expect(repo.update).toHaveBeenNthCalledWith(2, 5, { - embeddedMetadata: { - title: 'PDF Title', - publisher: 'PDF Publisher', - pageCount: 200, - authors: ['Author A'], - genres: ['Genre A'], - }, - coverPath: '/tmp/covers/5.png', - status: 'ready', - }); - expect(mockMkdir).toHaveBeenCalledWith('/tmp/covers', { recursive: true }); - expect(mockWriteFile).toHaveBeenCalledWith('/tmp/covers/5.png', Buffer.from('cover')); - expect(mockWriteFile).toHaveBeenCalledWith('/tmp/covers/5_thumb.jpg', Buffer.from('thumb')); - }); - - it('extractAndSave(pdf) leaves coverPath null when no cover bytes are available', async () => { - mockParsePdfFile.mockResolvedValue({ - title: 'PDF Title', - subtitle: null, - description: null, - publisher: null, - publishedYear: null, - language: null, - authors: [], - genres: [], - tags: [], - isbn10: null, - isbn13: null, - seriesName: null, - seriesIndex: null, - rating: null, - pageCount: 12, - googleBooksId: null, - goodreadsId: null, - amazonId: null, - hardcoverId: null, - hardcoverEditionId: null, - openLibraryId: null, - itunesId: null, - coverBuffer: null, - }); - - const service = new BookDockMetadataService(repo as never); - - await service.extractAndSave(6, '/tmp/book.pdf', 'pdf', '/tmp/covers'); - - expect(repo.update).toHaveBeenNthCalledWith(2, 6, { - embeddedMetadata: { - title: 'PDF Title', - publisher: undefined, - pageCount: 12, - authors: undefined, - genres: undefined, - }, - coverPath: null, - status: 'ready', - }); - expect(mockWriteFile).not.toHaveBeenCalled(); - }); - - it('extractAndSave(non-pdf) falls back to generic extraction path and keeps cover null when not found', async () => { - const service = new BookDockMetadataService(repo as never); - - await service.extractAndSave(7, '/tmp/book.bin', 'unknown', '/tmp/covers'); - - expect(mockExtractCover).toHaveBeenCalledWith('/tmp/book.bin', 'unknown'); + expect(extractionService.extractWithCoverFallback).toHaveBeenCalledOnce(); + expect(extractionService.extractWithCoverFallback).toHaveBeenCalledWith('/incoming/dune.epub', 'epub'); + expect(repo.update).toHaveBeenNthCalledWith(1, 7, { status: 'extracting' }); expect(repo.update).toHaveBeenNthCalledWith(2, 7, { + embeddedMetadata: { + title: 'Dune', + subtitle: 'Deluxe Edition', + description: 'A desert planet.', + publisher: 'Ace', + publishedDate: '1965-08-01', + publishedYear: 1965, + language: 'en', + isbn10: '0441172717', + isbn13: '9780441172719', + seriesName: 'Dune', + seriesIndex: 1, + pageCount: 688, + authors: ['Frank Herbert'], + genres: ['Science Fiction'], + narrators: ['Simon Vance'], + durationSeconds: 1200, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + googleBooksId: 'google-id', + goodreadsId: 'goodreads-id', + amazonId: 'amazon-id', + hardcoverId: 'hardcover-book', + hardcoverEditionId: 'hardcover-edition', + openLibraryId: 'OL1W', + itunesId: 'itunes-id', + audibleId: 'audible-id', + librofmId: 'librofm-id', + koboId: 'kobo-id', + ranobedbId: 'ranobedb-id', + lubimyczytacId: 'lubimyczytac-id', + aladinId: 'aladin-id', + comicMetadata: { issueNumber: '1', pencillers: ['Artist'] }, + }, + coverPath: '/book-dock/covers/7.jpg', + status: 'ready', + }); + expect(mockMkdir).toHaveBeenCalledWith('/book-dock/covers', { recursive: true }); + expect(mockWriteFile).toHaveBeenCalledWith('/book-dock/covers/7.jpg', Buffer.from('cover-bytes')); + expect(mockWriteFile).toHaveBeenCalledWith('/book-dock/covers/7_thumb.jpg', Buffer.from('thumbnail-bytes')); + }); + + it('stores an empty ready result when the canonical extractor has no metadata', async () => { + const { service, repo, extractionService } = makeService(); + extractionService.extractWithCoverFallback.mockResolvedValue({ metadata: null, cover: null }); + + await service.extractAndSave(8, '/incoming/unknown.bin', 'unknown', '/book-dock/covers'); + + expect(repo.update).toHaveBeenNthCalledWith(2, 8, { embeddedMetadata: {}, coverPath: null, status: 'ready', }); - }); - - it('extractAndSave writes error status when metadata extraction fails', async () => { - mockParsePdfFile.mockRejectedValueOnce(new Error('parse failure')); - const service = new BookDockMetadataService(repo as never); - - await service.extractAndSave(8, '/tmp/broken.pdf', 'pdf', '/tmp/covers'); - - expect(repo.update).toHaveBeenNthCalledWith(2, 8, { - status: 'error', - errorMessage: 'parse failure', - }); - }); - - it('logPdfParseWarning emits distinct messages for large-buffer and parser-warning scenarios', () => { - const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); - const service = new BookDockMetadataService(repo as never); - - (service as any).logPdfParseWarning({ - code: 'buffered-large-pdf', - absolutePath: '/tmp/large.pdf', - sizeBytes: 5000, - thresholdBytes: 4000, - }); - (service as any).logPdfParseWarning({ - code: 'xmp_parse_failed', - absolutePath: '/tmp/bad.pdf', - errorClass: 'Error', - errorMessage: 'invalid xmp', - }); - - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('buffered-large-pdf')); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('xmp_parse_failed')); - }); - - it('extractMetadata maps epub subjects and Calibre page count instead of BookOrbit tags', async () => { - mockExtractEpubMetadata.mockResolvedValue( - makeParsedOpf({ - title: 'Epub Title', - subtitle: 'Epub Sub', - description: 'Desc', - publisher: 'Pub', - publishedYear: 2001, - language: 'en', - isbn10: '123456789X', - isbn13: '9780306406157', - seriesName: 'Series', - seriesIndex: 1.5, - pageCount: 598, - authors: [{ name: 'Author A', sortName: null }], - genres: ['Krimi & Thriller'], - tags: ['Imported Tag'], - }), - ); - const service = new BookDockMetadataService(repo as never); - - await expect((service as any).extractMetadata('/tmp/book.epub', 'epub')).resolves.toEqual({ - title: 'Epub Title', - subtitle: 'Epub Sub', - description: 'Desc', - publisher: 'Pub', - publishedYear: 2001, - language: 'en', - isbn10: '123456789X', - isbn13: '9780306406157', - seriesName: 'Series', - seriesIndex: 1.5, - pageCount: 598, - authors: ['Author A'], - genres: ['Krimi & Thriller'], - }); - }); - - it('extractMetadata leaves epub genres empty when only BookOrbit tags are present', async () => { - mockExtractEpubMetadata.mockResolvedValue( - makeParsedOpf({ - title: 'Tagged Epub', - tags: ['Imported Tag'], - }), - ); - const service = new BookDockMetadataService(repo as never); - - const metadata = await (service as any).extractMetadata('/tmp/tagged.epub', 'epub'); - - expect(metadata.title).toBe('Tagged Epub'); - expect(metadata.genres).toBeUndefined(); - expect(metadata.pageCount).toBeUndefined(); - }); - - it('extractAndSave(epub) stores subject genres and page count in embedded metadata', async () => { - mockExtractEpubMetadata.mockResolvedValue( - makeParsedOpf({ - title: '1794', - isbn13: '9783492317948', - seriesName: 'Winge und Cardell', - seriesIndex: 2, - pageCount: 598, - authors: [{ name: 'Niklas Natt och Dag', sortName: 'Dag, Niklas Natt och' }], - genres: ['Krimi & Thriller'], - tags: [], - }), - ); - const service = new BookDockMetadataService(repo as never); - - await service.extractAndSave(13, '/tmp/1794.epub', 'epub', '/tmp/covers'); - - expect(mockExtractCover).toHaveBeenCalledWith('/tmp/1794.epub', 'epub'); - expect(repo.update).toHaveBeenNthCalledWith( - 2, - 13, - expect.objectContaining({ - embeddedMetadata: expect.objectContaining({ - title: '1794', - isbn13: '9783492317948', - seriesName: 'Winge und Cardell', - seriesIndex: 2, - pageCount: 598, - authors: ['Niklas Natt och Dag'], - genres: ['Krimi & Thriller'], - }), - coverPath: null, - status: 'ready', - }), - ); - }); - - it('extractMetadata handles mobi-family formats and parses year prefix when valid', async () => { - mockParseMobiFile.mockResolvedValue({ - title: 'Mobi Title', - description: 'Mobi Desc', - publisher: 'Mobi Pub', - publishedDate: '1999-01-01', - language: 'en', - isbn: '9780306406157', - authors: ['Author A'], - tags: ['Tag 1'], - } as never); - const service = new BookDockMetadataService(repo as never); - - await expect((service as any).extractMetadata('/tmp/book.azw3', 'azw3')).resolves.toEqual({ - title: 'Mobi Title', - description: 'Mobi Desc', - publisher: 'Mobi Pub', - publishedDate: '1999-01-01', - publishedYear: 1999, - language: 'en', - isbn13: '9780306406157', - authors: ['Author A'], - genres: ['Tag 1'], - }); - await expect((service as any).extractMetadata('/tmp/book.azw', 'azw')).resolves.toEqual( - expect.objectContaining({ - title: 'Mobi Title', - }), - ); - }); - - it('extractMetadata maps cbz/cbr/cb7 and fb2 parser outputs', async () => { - mockExtractCbzMetadata.mockResolvedValue({ - title: 'Comic Z', - description: null, - publisher: 'Pub', - publishedYear: 2020, - language: 'en', - seriesName: 'Series', - seriesIndex: 1, - authors: [{ name: 'Artist Z', sortName: null }], - tags: ['Action'], - } as never); - mockExtractCbrMetadata.mockResolvedValue({ - title: 'Comic R', - description: 'Desc', - publisher: null, - publishedYear: null, - language: null, - seriesName: null, - seriesIndex: null, - authors: [], - tags: [], - } as never); - mockExtractCb7Metadata.mockResolvedValue({ - title: 'Comic 7', - description: null, - publisher: null, - publishedYear: null, - language: null, - seriesName: null, - seriesIndex: null, - authors: [{ name: 'Artist 7', sortName: null }], - tags: ['Tag7'], - } as never); - mockParseFb2File.mockResolvedValue({ - title: 'FB2 Book', - description: 'FB2 Desc', - publishedYear: 1990, - language: 'ru', - seriesName: 'FB2 Series', - seriesIndex: 2, - authors: [{ name: 'FB2 Author', sortName: null }], - genres: ['Drama'], - } as never); - const service = new BookDockMetadataService(repo as never); - - await expect((service as any).extractMetadata('/tmp/comic.cbz', 'cbz')).resolves.toEqual( - expect.objectContaining({ - title: 'Comic Z', - authors: ['Artist Z'], - genres: ['Action'], - }), - ); - await expect((service as any).extractMetadata('/tmp/comic.cbr', 'cbr')).resolves.toEqual({ - title: 'Comic R', - description: 'Desc', - publisher: undefined, - publishedYear: undefined, - language: undefined, - seriesName: undefined, - seriesIndex: undefined, - authors: undefined, - genres: undefined, - }); - await expect((service as any).extractMetadata('/tmp/comic.cb7', 'cb7')).resolves.toEqual( - expect.objectContaining({ - title: 'Comic 7', - authors: ['Artist 7'], - genres: ['Tag7'], - }), - ); - await expect((service as any).extractMetadata('/tmp/book.fb2', 'fb2')).resolves.toEqual( - expect.objectContaining({ - title: 'FB2 Book', - authors: ['FB2 Author'], - genres: ['Drama'], - }), - ); - }); - - it('extractAndSave(m4b) extracts chapters, duration, narrators and embedded cover in a single parse', async () => { - mockExtractAudioMetadata.mockResolvedValue( - makeAudioResult({ - title: 'Artificial Condition', - subtitle: 'The Murderbot Diaries', - authors: [{ name: 'Martha Wells', sortName: null }], - narrators: ['Kevin R. Free'], - publisher: 'Recorded Books', - publishedYear: 2018, - description: 'Murderbot returns.', - language: 'en', - seriesName: 'The Murderbot Diaries', - seriesIndex: 2, - genres: ['Science Fiction'], - durationSeconds: 12218, - chapters: [ - { title: 'Chapter 1', startMs: 0 }, - { title: 'Chapter 2', startMs: 804850 }, - ], - coverBytes: Buffer.from('cover'), - }), - ); - mockImageExt.mockReturnValue('jpg'); - - const service = new BookDockMetadataService(repo as never); - - await service.extractAndSave(10, '/tmp/book.m4b', 'm4b', '/tmp/covers'); - - expect(mockExtractAudioMetadata).toHaveBeenCalledTimes(1); - expect(mockExtractAudioMetadata).toHaveBeenCalledWith('/tmp/book.m4b'); - expect(mockExtractCover).not.toHaveBeenCalled(); - expect(repo.update).toHaveBeenNthCalledWith(2, 10, { - embeddedMetadata: { - title: 'Artificial Condition', - subtitle: 'The Murderbot Diaries', - description: 'Murderbot returns.', - publisher: 'Recorded Books', - publishedYear: 2018, - language: 'en', - seriesName: 'The Murderbot Diaries', - seriesIndex: 2, - authors: ['Martha Wells'], - narrators: ['Kevin R. Free'], - genres: ['Science Fiction'], - durationSeconds: 12218, - chapters: [ - { title: 'Chapter 1', startMs: 0 }, - { title: 'Chapter 2', startMs: 804850 }, - ], - }, - coverPath: '/tmp/covers/10.jpg', - status: 'ready', - }); - expect(mockWriteFile).toHaveBeenCalledWith('/tmp/covers/10.jpg', Buffer.from('cover')); - }); - - it('extractAndSave(mp3) omits absent fields and leaves cover null when there is no embedded art', async () => { - mockExtractAudioMetadata.mockResolvedValue( - makeAudioResult({ - title: 'Solo Track', - durationSeconds: 3600, - chapters: [], - coverBytes: null, - }), - ); - - const service = new BookDockMetadataService(repo as never); - - await service.extractAndSave(11, '/tmp/book.mp3', 'mp3', '/tmp/covers'); - - const patch = repo.update.mock.calls[1]?.[1] as { - embeddedMetadata: Record; - coverPath: string | null; - status: string; - }; - expect(patch.status).toBe('ready'); - expect(patch.coverPath).toBeNull(); - expect(patch.embeddedMetadata).toEqual(expect.objectContaining({ title: 'Solo Track', durationSeconds: 3600 })); - expect(patch.embeddedMetadata.chapters).toBeUndefined(); - expect(patch.embeddedMetadata.narrators).toBeUndefined(); + expect(mockMkdir).not.toHaveBeenCalled(); expect(mockWriteFile).not.toHaveBeenCalled(); }); - it('extractAndSave(audio) stays ready with empty metadata when the file has no readable tags', async () => { - mockExtractAudioMetadata.mockResolvedValue(makeAudioResult()); + it('omits null and empty canonical fields from staging metadata', async () => { + const { service, repo, extractionService } = makeService(); + extractionService.extractWithCoverFallback.mockResolvedValue({ + metadata: { + title: null, + authors: [], + genres: [], + narrators: [], + chapters: [], + hardcoverId: null, + comicMetadata: null, + cover: null, + }, + cover: null, + }); - const service = new BookDockMetadataService(repo as never); + await service.extractAndSave(9, '/incoming/empty.epub', 'epub', '/book-dock/covers'); - await service.extractAndSave(12, '/tmp/empty.flac', 'flac', '/tmp/covers'); - - const patch = repo.update.mock.calls[1]?.[1] as { embeddedMetadata: Record; coverPath: string | null; status: string }; - expect(patch.status).toBe('ready'); - expect(patch.coverPath).toBeNull(); - expect(Object.values(patch.embeddedMetadata).every((value) => value === undefined)).toBe(true); + expect(repo.update).toHaveBeenNthCalledWith(2, 9, { + embeddedMetadata: expect.objectContaining({ + title: undefined, + authors: undefined, + genres: undefined, + narrators: undefined, + chapters: undefined, + }), + coverPath: null, + status: 'ready', + }); }); - it.each(['m4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac'])( - 'routes %s uploads through the audio extractor, not the generic cover path', - async (format) => { - mockExtractAudioMetadata.mockResolvedValue(makeAudioResult({ durationSeconds: 100 })); + it('marks the staging row as failed when centralized extraction throws', async () => { + const { service, repo, extractionService } = makeService(); + const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + extractionService.extractWithCoverFallback.mockRejectedValue(new TypeError('parser failed')); - const service = new BookDockMetadataService(repo as never); + await service.extractAndSave(10, '/incoming/bad.pdf', 'pdf', '/book-dock/covers'); - await service.extractAndSave(1, `/tmp/audio.${format}`, format, '/tmp/covers'); + expect(repo.update).toHaveBeenNthCalledWith(2, 10, { + status: 'error', + errorMessage: 'parser failed', + }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('errorClass=TypeError')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('error="parser failed"')); + }); - expect(mockExtractAudioMetadata).toHaveBeenCalledWith(`/tmp/audio.${format}`); - expect(mockExtractCover).not.toHaveBeenCalled(); - }, - ); + it('marks the staging row as failed when cover persistence throws', async () => { + const { service, repo } = makeService(); + mockWriteFile.mockRejectedValueOnce(new Error('disk full')); + + await service.extractAndSave(11, '/incoming/dune.epub', 'epub', '/book-dock/covers'); + + expect(repo.update).toHaveBeenNthCalledWith(2, 11, { + status: 'error', + errorMessage: 'disk full', + }); + }); }); diff --git a/server/src/modules/book-dock/book-dock-metadata.service.ts b/server/src/modules/book-dock/book-dock-metadata.service.ts index 2e8e2fbb5..d630f783f 100644 --- a/server/src/modules/book-dock/book-dock-metadata.service.ts +++ b/server/src/modules/book-dock/book-dock-metadata.service.ts @@ -2,46 +2,29 @@ import { Injectable, Logger } from '@nestjs/common'; import { mkdir, writeFile } from 'fs/promises'; import { join } from 'path'; -import type { BookDockMetadata } from '@bookorbit/types'; -import { isAudioFormat } from '@bookorbit/types'; -import { extractEpubMetadata } from '../metadata/lib/epub'; -import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata'; -import { parseFb2File } from '../metadata/lib/fb2-parser'; -import { parseMobiFile } from '../metadata/lib/mobi-parser'; -import { parsePdfFile, type PdfParsed, type PdfParseWarning } from '../metadata/lib/pdf-parser'; -import { mapOpfMetadata } from '../metadata/extractors/opf-metadata.mapper'; +import type { BookDockMetadata, ComicMetadataFields } from '@bookorbit/types'; import type { ParsedBookData } from '../metadata/extractors/format-extractor.interface'; -import { extractAudioMetadata, type AudioExtractResult } from '../metadata/extractors/audio.extractor'; -import { extractCover, generateThumbnail, imageExt } from '../metadata/lib/cover'; -import { detectComicContainerFormat } from '../../common/comic-format-detect'; +import { generateThumbnail, imageExt } from '../metadata/lib/cover'; +import { MetadataExtractionService } from '../metadata/metadata-extraction.service'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; -import { parsePublishedDateKey, parsePublishedYear, publishedYearFromDateKey } from '../../common/utils/published-date.utils'; import { BookDockRepository } from './book-dock.repository'; @Injectable() export class BookDockMetadataService { private readonly logger = new Logger(BookDockMetadataService.name); - constructor(private readonly repo: BookDockRepository) {} + constructor( + private readonly repo: BookDockRepository, + private readonly extractionService: MetadataExtractionService, + ) {} async extractAndSave(fileId: number, absolutePath: string, format: string, coversDir: string): Promise { const startedAt = Date.now(); await this.repo.update(fileId, { status: 'extracting' }); try { - let metadata: BookDockMetadata; - let coverBytes: Buffer | null; - - if (format === 'pdf') { - const parsedPdf = await this.extractPdfData(absolutePath, true); - metadata = this.toPdfMetadata(parsedPdf); - coverBytes = parsedPdf?.coverBuffer ?? null; - } else if (isAudioFormat(format)) { - const audio = await extractAudioMetadata(absolutePath); - metadata = this.toAudioMetadata(audio); - coverBytes = audio.coverBytes; - } else { - [metadata, coverBytes] = await Promise.all([this.extractMetadata(absolutePath, format), extractCover(absolutePath, format)]); - } + const extracted = await this.extractionService.extractWithCoverFallback(absolutePath, format); + const metadata = this.toBookDockMetadata(extracted.metadata); + const coverBytes = extracted.cover; let coverPath: string | null = null; if (coverBytes && coverBytes.length > 0) { @@ -66,92 +49,6 @@ export class BookDockMetadataService { } } - private async extractMetadata(absolutePath: string, format: string): Promise { - switch (format) { - case 'epub': - return this.fromEpub(absolutePath); - case 'pdf': - return this.fromPdf(absolutePath); - case 'mobi': - case 'azw3': - case 'azw': - return this.fromMobi(absolutePath); - case 'cbz': - return this.fromCbx(absolutePath, 'cbz'); - case 'cbr': - return this.fromCbx(absolutePath, 'cbr'); - case 'cb7': - return this.fromCbx(absolutePath, 'cb7'); - case 'fb2': - return this.fromFb2(absolutePath); - default: - return {}; - } - } - - private async fromEpub(absolutePath: string): Promise { - const parsed = await extractEpubMetadata(absolutePath); - if (!parsed) return {}; - return this.toBookDockMetadata(mapOpfMetadata(parsed, null)); - } - - private async fromPdf(absolutePath: string): Promise { - return this.toPdfMetadata(await this.extractPdfData(absolutePath, false)); - } - - private async fromMobi(absolutePath: string): Promise { - const parsed = await parseMobiFile(absolutePath); - if (!parsed) return {}; - const publishedDate = parsePublishedDateKey(parsed.publishedDate); - const year = publishedDate ? publishedYearFromDateKey(publishedDate) : parsePublishedYear(parsed.publishedDate); - return { - title: parsed.title ?? undefined, - description: parsed.description ?? undefined, - publisher: parsed.publisher ?? undefined, - publishedDate, - publishedYear: year, - language: parsed.language ?? undefined, - isbn13: parsed.isbn ?? undefined, - authors: parsed.authors.length > 0 ? parsed.authors : undefined, - genres: parsed.tags.length > 0 ? parsed.tags : undefined, - }; - } - - private async fromCbx(absolutePath: string, format: 'cbz' | 'cbr' | 'cb7'): Promise { - const actualFormat = await detectComicContainerFormat(absolutePath, format); - const extractor = actualFormat === 'cbz' ? extractCbzMetadata : actualFormat === 'cbr' ? extractCbrMetadata : extractCb7Metadata; - const parsed = await extractor(absolutePath); - if (!parsed) return {}; - return { - title: parsed.title ?? undefined, - description: parsed.description ?? undefined, - publisher: parsed.publisher ?? undefined, - publishedDate: parsed.publishedDate ?? undefined, - publishedYear: parsed.publishedYear ?? undefined, - language: parsed.language ?? undefined, - seriesName: parsed.seriesName ?? undefined, - seriesIndex: parsed.seriesIndex ?? undefined, - authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined, - genres: parsed.tags.length > 0 ? parsed.tags : undefined, - }; - } - - private async fromFb2(absolutePath: string): Promise { - const parsed = await parseFb2File(absolutePath); - if (!parsed) return {}; - return { - title: parsed.title ?? undefined, - description: parsed.description ?? undefined, - publishedDate: parsed.publishedDate ?? undefined, - publishedYear: parsed.publishedYear ?? undefined, - language: parsed.language ?? undefined, - seriesName: parsed.seriesName ?? undefined, - seriesIndex: parsed.seriesIndex ?? undefined, - authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined, - genres: parsed.genres.length > 0 ? parsed.genres : undefined, - }; - } - private async saveCover(fileId: number, bytes: Buffer, coversDir: string): Promise { await mkdir(coversDir, { recursive: true }); @@ -165,46 +62,8 @@ export class BookDockMetadataService { return coverPath; } - private async extractPdfData(absolutePath: string, extractCover: boolean): Promise { - return parsePdfFile(absolutePath, { - extractCover, - onWarning: (warning) => this.logPdfParseWarning(warning), - }); - } - - private toAudioMetadata(audio: AudioExtractResult): BookDockMetadata { - return { - title: audio.title ?? undefined, - subtitle: audio.subtitle ?? undefined, - description: audio.description ?? undefined, - publisher: audio.publisher ?? undefined, - publishedDate: audio.publishedDate ?? undefined, - publishedYear: audio.publishedYear ?? undefined, - language: audio.language ?? undefined, - seriesName: audio.seriesName ?? undefined, - seriesIndex: audio.seriesIndex ?? undefined, - authors: audio.authors.length > 0 ? audio.authors.map((a) => a.name) : undefined, - narrators: audio.narrators.length > 0 ? audio.narrators : undefined, - genres: audio.genres.length > 0 ? audio.genres : undefined, - durationSeconds: audio.durationSeconds ?? undefined, - chapters: audio.chapters.length > 0 ? audio.chapters : undefined, - }; - } - - private toPdfMetadata(parsed: PdfParsed | null): BookDockMetadata { - if (!parsed) return {}; - return { - title: parsed.title ?? undefined, - publisher: parsed.publisher ?? undefined, - publishedDate: parsed.publishedDate ?? undefined, - publishedYear: parsed.publishedYear ?? undefined, - pageCount: parsed.pageCount ?? undefined, - authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined, - genres: parsed.genres.length > 0 ? parsed.genres : undefined, - }; - } - - private toBookDockMetadata(data: ParsedBookData): BookDockMetadata { + private toBookDockMetadata(data: ParsedBookData | null): BookDockMetadata { + if (!data) return {}; return { title: data.title ?? undefined, subtitle: data.subtitle ?? undefined, @@ -220,18 +79,71 @@ export class BookDockMetadataService { pageCount: data.pageCount ?? undefined, authors: data.authors.length > 0 ? data.authors.map((a) => a.name) : undefined, genres: data.genres.length > 0 ? data.genres : undefined, + narrators: data.narrators?.length ? data.narrators : undefined, + durationSeconds: data.durationSeconds ?? undefined, + chapters: data.chapters?.length ? data.chapters : undefined, + ...providerIdMetadata(data), + ...(data.comicMetadata ? { comicMetadata: normalizeBookDockComicMetadata(data.comicMetadata) } : {}), }; } - - private logPdfParseWarning(warning: PdfParseWarning): void { - if (warning.code === 'buffered-large-pdf') { - this.logger.warn( - `[book_dock.pdf_parse] [end] path="${warning.absolutePath}" code=${warning.code} sizeBytes=${warning.sizeBytes ?? 0} thresholdBytes=${warning.thresholdBytes ?? 0} - large pdf buffered in memory`, - ); - return; - } - this.logger.warn( - `[book_dock.pdf_parse] [fail] path="${warning.absolutePath}" code=${warning.code} errorClass=${warning.errorClass} error="${warning.errorMessage}" - pdf parse warning emitted`, - ); - } +} + +type ProviderIdMetadata = Partial< + Record< + | 'googleBooksId' + | 'goodreadsId' + | 'amazonId' + | 'hardcoverId' + | 'hardcoverEditionId' + | 'openLibraryId' + | 'itunesId' + | 'audibleId' + | 'librofmId' + | 'koboId' + | 'comicvineId' + | 'ranobedbId' + | 'lubimyczytacId' + | 'aladinId', + string | null + > +>; + +function providerIdMetadata(data: ProviderIdMetadata): Partial { + const metadata: Partial = {}; + const fields = [ + 'googleBooksId', + 'goodreadsId', + 'amazonId', + 'hardcoverId', + 'hardcoverEditionId', + 'openLibraryId', + 'itunesId', + 'audibleId', + 'librofmId', + 'koboId', + 'comicvineId', + 'ranobedbId', + 'lubimyczytacId', + 'aladinId', + ] as const; + for (const field of fields) { + const value = data[field]; + if (value) metadata[field] = value; + } + return metadata; +} + +function normalizeBookDockComicMetadata( + value: Partial>, +): ComicMetadataFields { + const metadata: ComicMetadataFields = {}; + if (value.issueNumber) metadata.issueNumber = value.issueNumber as string; + if (value.volumeName) metadata.volumeName = value.volumeName as string; + + const arrayFields = ['pencillers', 'inkers', 'colorists', 'letterers', 'coverArtists', 'characters', 'teams', 'locations', 'storyArcs'] as const; + for (const field of arrayFields) { + const entries = value[field]; + if (Array.isArray(entries) && entries.length > 0) metadata[field] = entries; + } + return metadata; } diff --git a/server/src/modules/book-dock/book-dock.dto.test.ts b/server/src/modules/book-dock/book-dock.dto.test.ts index d48075454..c64dcea8c 100644 --- a/server/src/modules/book-dock/book-dock.dto.test.ts +++ b/server/src/modules/book-dock/book-dock.dto.test.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; +import { ValidationPipe } from '@nestjs/common'; import { BulkEditBookDockDto, BulkSetTargetDto, FinalizeBookDockDto, UpdateBookDockFileDto } from './dto'; import { ListBookDockFilesDto } from './dto/list-book-dock-files.dto'; @@ -44,6 +45,39 @@ describe('BookDock DTO validation', () => { expect((await errorsFor(UpdateBookDockFileDto, { selectedMetadata: { publishedYear: 1984.5 } })).length).toBeGreaterThan(0); }); + it('UpdateBookDockFileDto accepts every metadata field emitted by metadata search through the production validation path', async () => { + const pipe = new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }); + const selectedMetadata = { + title: 'Dune', + pageCount: 688, + narrators: ['Simon Vance'], + durationSeconds: 1200, + abridged: false, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + seriesMemberships: [{ seriesName: 'Dune', seriesIndex: 1 }], + communityRatings: [{ provider: 'hardcover', rating: 4.5, ratingCount: 1000 }], + googleBooksId: 'google-id', + goodreadsId: 'goodreads-id', + amazonId: 'amazon-id', + hardcoverId: 'hardcover-id', + hardcoverEditionId: 'hardcover-edition-id', + openLibraryId: 'OL1W', + itunesId: 'itunes-id', + audibleId: 'audible-id', + librofmId: 'librofm-id', + koboId: 'kobo-id', + comicvineId: 'comicvine-id', + ranobedbId: 'ranobedb-id', + lubimyczytacId: 'lubimyczytac-id', + aladinId: 'aladin-id', + comicMetadata: { issueNumber: '1', pencillers: ['Artist'] }, + }; + + await expect(pipe.transform({ selectedMetadata }, { type: 'body', metatype: UpdateBookDockFileDto, data: undefined })).resolves.toMatchObject({ + selectedMetadata, + }); + }); + it('BulkSetTargetDto validates ids, filters, and nullable target fields', async () => { expect( ( diff --git a/server/src/modules/book-dock/book-dock.module.ts b/server/src/modules/book-dock/book-dock.module.ts index 4124b08f7..586153666 100644 --- a/server/src/modules/book-dock/book-dock.module.ts +++ b/server/src/modules/book-dock/book-dock.module.ts @@ -5,6 +5,7 @@ import type { StringValue } from 'ms'; import { AppSettingsModule } from '../app-settings/app-settings.module'; import { AuthModule } from '../auth/auth.module'; +import { BookModule } from '../book/book.module'; import { LibraryModule } from '../library/library.module'; import { MetadataFetchModule } from '../metadata-fetch/metadata-fetch.module'; import { MetadataModule } from '../metadata/metadata.module'; @@ -25,6 +26,7 @@ import { BookDockRepository } from './book-dock.repository'; imports: [ UploadModule, AuthModule, + BookModule, LibraryModule, MetadataFetchModule, MetadataModule, diff --git a/server/src/modules/book-dock/dto/index.ts b/server/src/modules/book-dock/dto/index.ts index bead44139..59d9bf578 100644 --- a/server/src/modules/book-dock/dto/index.ts +++ b/server/src/modules/book-dock/dto/index.ts @@ -1,6 +1,7 @@ import { IsArray, IsBoolean, + IsIn, IsInt, IsNotEmpty, IsNumber, @@ -20,6 +21,7 @@ import { ValidatorConstraint, } from 'class-validator'; import { Type } from 'class-transformer'; +import { MetadataProviderKey } from '@bookorbit/types'; @ValidatorConstraint({ name: 'hasCompleteDefaultDestination', async: false }) class HasCompleteDefaultDestinationConstraint implements ValidatorConstraintInterface { @@ -47,22 +49,82 @@ function HasCompleteDefaultDestination(options?: ValidationOptions) { }; } +class BookDockAudiobookChapterDto { + @IsString() title!: string; + @IsInt() @Min(0) startMs!: number; + @IsOptional() @IsInt() @Min(0) durationMs?: number | null; +} + +class BookDockSeriesMembershipDto { + @IsString() @MaxLength(500) seriesName!: string; + @IsOptional() @IsNumber() seriesIndex?: number | null; +} + +class BookDockCommunityRatingDto { + @IsIn(Object.values(MetadataProviderKey)) provider!: MetadataProviderKey; + @IsNumber() @Min(0) @Max(5) rating!: number; + @IsOptional() @IsInt() @Min(0) ratingCount?: number | null; +} + +class BookDockComicMetadataDto { + @IsOptional() @IsString() @MaxLength(50) issueNumber?: string; + @IsOptional() @IsString() @MaxLength(500) volumeName?: string; + @IsOptional() @IsArray() @IsString({ each: true }) pencillers?: string[]; + @IsOptional() @IsArray() @IsString({ each: true }) inkers?: string[]; + @IsOptional() @IsArray() @IsString({ each: true }) colorists?: string[]; + @IsOptional() @IsArray() @IsString({ each: true }) letterers?: string[]; + @IsOptional() @IsArray() @IsString({ each: true }) coverArtists?: string[]; + @IsOptional() @IsArray() @IsString({ each: true }) characters?: string[]; + @IsOptional() @IsArray() @IsString({ each: true }) teams?: string[]; + @IsOptional() @IsArray() @IsString({ each: true }) locations?: string[]; + @IsOptional() @IsArray() @IsString({ each: true }) storyArcs?: string[]; +} + export class BookDockMetadataFieldsDto { - @IsOptional() @IsString() title?: string; - @IsOptional() @IsString() subtitle?: string; + @IsOptional() @IsString() @MaxLength(1000) title?: string | null; + @IsOptional() @IsString() @MaxLength(1000) subtitle?: string | null; @IsOptional() @IsString() description?: string; - @IsOptional() @IsString() publisher?: string; - @IsOptional() @IsString() language?: string; - @IsOptional() @IsString() isbn10?: string; - @IsOptional() @IsString() isbn13?: string; - @IsOptional() @IsString() seriesName?: string; + @IsOptional() @IsString() @MaxLength(500) publisher?: string | null; + @IsOptional() @IsString() @MaxLength(100) language?: string | null; + @IsOptional() @IsString() @MaxLength(10) isbn10?: string | null; + @IsOptional() @IsString() @MaxLength(13) isbn13?: string | null; + @IsOptional() @IsString() @MaxLength(500) seriesName?: string | null; @IsOptional() @IsString() coverUrl?: string; - @IsOptional() @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) publishedDate?: string; - @IsOptional() @IsInt() @Min(1000) @Max(2200) publishedYear?: number; - @IsOptional() @IsNumber() pageCount?: number; - @IsOptional() @IsNumber() seriesIndex?: number; + @IsOptional() @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) publishedDate?: string | null; + @IsOptional() @IsInt() @Min(1000) @Max(2200) publishedYear?: number | null; + @IsOptional() @IsInt() @Min(0) pageCount?: number | null; + @IsOptional() @IsNumber() seriesIndex?: number | null; @IsOptional() @IsArray() @IsString({ each: true }) authors?: string[]; @IsOptional() @IsArray() @IsString({ each: true }) genres?: string[]; + @IsOptional() @IsArray() @IsString({ each: true }) narrators?: string[]; + @IsOptional() @IsInt() @Min(0) durationSeconds?: number | null; + @IsOptional() @IsBoolean() abridged?: boolean | null; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => BookDockAudiobookChapterDto) chapters?: BookDockAudiobookChapterDto[] | null; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BookDockSeriesMembershipDto) + seriesMemberships?: BookDockSeriesMembershipDto[] | null; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BookDockCommunityRatingDto) + communityRatings?: BookDockCommunityRatingDto[] | null; + @IsOptional() @IsString() @MaxLength(50) googleBooksId?: string | null; + @IsOptional() @IsString() @MaxLength(50) goodreadsId?: string | null; + @IsOptional() @IsString() @MaxLength(20) amazonId?: string | null; + @IsOptional() @IsString() @MaxLength(255) hardcoverId?: string | null; + @IsOptional() @IsString() @MaxLength(50) hardcoverEditionId?: string | null; + @IsOptional() @IsString() @MaxLength(50) openLibraryId?: string | null; + @IsOptional() @IsString() @MaxLength(50) itunesId?: string | null; + @IsOptional() @IsString() @MaxLength(20) audibleId?: string | null; + @IsOptional() @IsString() @MaxLength(50) librofmId?: string | null; + @IsOptional() @IsString() @MaxLength(255) koboId?: string | null; + @IsOptional() @IsString() @MaxLength(50) comicvineId?: string | null; + @IsOptional() @IsString() @MaxLength(50) ranobedbId?: string | null; + @IsOptional() @IsString() @MaxLength(512) lubimyczytacId?: string | null; + @IsOptional() @IsString() @MaxLength(20) aladinId?: string | null; + @IsOptional() @ValidateNested() @Type(() => BookDockComicMetadataDto) comicMetadata?: BookDockComicMetadataDto | null; } export class UpdateBookDockFileDto { diff --git a/server/src/modules/metadata/metadata-extraction.service.test.ts b/server/src/modules/metadata/metadata-extraction.service.test.ts new file mode 100644 index 000000000..b651cc155 --- /dev/null +++ b/server/src/modules/metadata/metadata-extraction.service.test.ts @@ -0,0 +1,190 @@ +import { Logger } from '@nestjs/common'; + +import type { ParsedBookData } from './extractors/format-extractor.interface'; +import { MetadataExtractionService } from './metadata-extraction.service'; + +const mocks = vi.hoisted(() => ({ + audio: vi.fn(), + cover: vi.fn(), + comic: { + cbz: vi.fn(), + cbr: vi.fn(), + cb7: vi.fn(), + }, + epub: vi.fn(), + fb2: vi.fn(), + mobi: vi.fn(), + opf: vi.fn(), + pdf: vi.fn(), + pdfOptions: undefined as { onWarning?: (warning: Record) => void; extractCover?: boolean } | undefined, +})); + +vi.mock('./lib/cover', () => ({ + extractCover: mocks.cover, +})); + +vi.mock('./extractors/audio-format.extractor', () => ({ + AudioFormatExtractor: class AudioFormatExtractor { + extract = mocks.audio; + }, +})); + +vi.mock('./extractors/comic-format.extractor', () => ({ + ComicFormatExtractor: class ComicFormatExtractor { + extract: ReturnType; + + constructor(format: keyof typeof mocks.comic) { + this.extract = mocks.comic[format]; + } + }, +})); + +vi.mock('./extractors/epub-format.extractor', () => ({ + EpubFormatExtractor: class EpubFormatExtractor { + extract = mocks.epub; + }, +})); + +vi.mock('./extractors/fb2-format.extractor', () => ({ + Fb2FormatExtractor: class Fb2FormatExtractor { + extract = mocks.fb2; + }, +})); + +vi.mock('./extractors/mobi-format.extractor', () => ({ + MobiFormatExtractor: class MobiFormatExtractor { + extract = mocks.mobi; + }, +})); + +vi.mock('./extractors/opf-format.extractor', () => ({ + OpfFormatExtractor: class OpfFormatExtractor { + extract = mocks.opf; + }, +})); + +vi.mock('./extractors/pdf-format.extractor', () => ({ + PdfFormatExtractor: class PdfFormatExtractor { + extract = mocks.pdf; + + constructor(options: typeof mocks.pdfOptions) { + mocks.pdfOptions = options; + } + }, +})); + +const extracted: ParsedBookData = { + title: 'Canonical title', + authors: [{ name: 'Author', sortName: null }], + genres: ['Fantasy'], + cover: Buffer.from('cover'), +}; + +describe('MetadataExtractionService', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.pdfOptions = undefined; + for (const extractor of [ + mocks.audio, + mocks.comic.cbz, + mocks.comic.cbr, + mocks.comic.cb7, + mocks.epub, + mocks.fb2, + mocks.mobi, + mocks.opf, + mocks.pdf, + ]) { + extractor.mockResolvedValue(extracted); + } + mocks.cover.mockResolvedValue(null); + }); + + it.each([ + ['epub', mocks.epub], + ['kepub', mocks.epub], + ['opf', mocks.opf], + ['pdf', mocks.pdf], + ['mobi', mocks.mobi], + ['azw3', mocks.mobi], + ['azw', mocks.mobi], + ['cbz', mocks.comic.cbz], + ['cbr', mocks.comic.cbr], + ['cb7', mocks.comic.cb7], + ['fb2', mocks.fb2], + ['m4b', mocks.audio], + ['mp3', mocks.audio], + ['m4a', mocks.audio], + ['opus', mocks.audio], + ['ogg', mocks.audio], + ['flac', mocks.audio], + ])('routes %s through its canonical extractor', async (format, extractor) => { + const service = new MetadataExtractionService(); + + await expect(service.extract(`/books/book.${format}`, format)).resolves.toBe(extracted); + + expect(service.supports(format)).toBe(true); + expect(extractor).toHaveBeenCalledWith(`/books/book.${format}`); + }); + + it('returns null for unsupported formats without invoking an extractor', async () => { + const service = new MetadataExtractionService(); + + await expect(service.extract('/books/book.txt', 'txt')).resolves.toBeNull(); + + expect(service.supports('txt')).toBe(false); + expect(mocks.epub).not.toHaveBeenCalled(); + expect(mocks.audio).not.toHaveBeenCalled(); + }); + + it('preserves null extraction results and propagates extractor failures', async () => { + const service = new MetadataExtractionService(); + mocks.epub.mockResolvedValueOnce(null); + mocks.pdf.mockRejectedValueOnce(new Error('pdf failed')); + + await expect(service.extract('/books/empty.epub', 'epub')).resolves.toBeNull(); + await expect(service.extract('/books/bad.pdf', 'pdf')).rejects.toThrow('pdf failed'); + }); + + it('uses the shared cover fallback only when canonical metadata is unavailable', async () => { + const service = new MetadataExtractionService(); + mocks.epub.mockResolvedValueOnce(null); + mocks.cover.mockResolvedValueOnce(Buffer.from('fallback-cover')); + + await expect(service.extractWithCoverFallback('/books/cover-only.epub', 'epub')).resolves.toEqual({ + metadata: null, + cover: Buffer.from('fallback-cover'), + }); + expect(mocks.cover).toHaveBeenCalledWith('/books/cover-only.epub', 'epub'); + + await expect(service.extractWithCoverFallback('/books/complete.epub', 'epub')).resolves.toEqual({ + metadata: extracted, + cover: extracted.cover, + }); + expect(mocks.cover).toHaveBeenCalledTimes(1); + }); + + it('enables PDF cover extraction and sanitizes parser warnings', () => { + const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + new MetadataExtractionService(); + + expect(mocks.pdfOptions?.extractCover).toBe(true); + mocks.pdfOptions?.onWarning?.({ + code: 'cover-extraction-failed', + absolutePath: '/books/bad\npath.pdf', + errorClass: 'Error', + errorMessage: 'bad\nmessage', + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('path="/books/bad path.pdf"')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('error="bad message"')); + + mocks.pdfOptions?.onWarning?.({ + code: 'buffered-large-pdf', + absolutePath: '/books/large.pdf', + sizeBytes: 10_000_000, + thresholdBytes: 5_000_000, + }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('sizeBytes=10000000 thresholdBytes=5000000')); + }); +}); diff --git a/server/src/modules/metadata/metadata-extraction.service.ts b/server/src/modules/metadata/metadata-extraction.service.ts new file mode 100644 index 000000000..67cf060c8 --- /dev/null +++ b/server/src/modules/metadata/metadata-extraction.service.ts @@ -0,0 +1,79 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; +import { AudioFormatExtractor } from './extractors/audio-format.extractor'; +import { ComicFormatExtractor } from './extractors/comic-format.extractor'; +import { EpubFormatExtractor } from './extractors/epub-format.extractor'; +import { Fb2FormatExtractor } from './extractors/fb2-format.extractor'; +import type { FormatExtractor, ParsedBookData } from './extractors/format-extractor.interface'; +import { MobiFormatExtractor } from './extractors/mobi-format.extractor'; +import { OpfFormatExtractor } from './extractors/opf-format.extractor'; +import { PdfFormatExtractor } from './extractors/pdf-format.extractor'; +import { extractCover } from './lib/cover'; +import type { PdfParseWarning } from './lib/pdf-parser'; + +export const METADATA_AUDIO_FORMATS = ['m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'] as const; + +export interface MetadataExtractionResult { + metadata: ParsedBookData | null; + cover: Buffer | null; +} + +@Injectable() +export class MetadataExtractionService { + private readonly logger = new Logger(MetadataExtractionService.name); + private readonly extractors: ReadonlyMap; + + constructor() { + const audio = new AudioFormatExtractor(); + const mobi = new MobiFormatExtractor(); + const epub = new EpubFormatExtractor(); + const extractors = new Map([ + ['epub', epub], + ['kepub', epub], + ['opf', new OpfFormatExtractor()], + ['pdf', new PdfFormatExtractor({ extractCover: true, onWarning: (warning) => this.logPdfParseWarning(warning) })], + ['mobi', mobi], + ['azw3', mobi], + ['azw', mobi], + ['cbz', new ComicFormatExtractor('cbz')], + ['cbr', new ComicFormatExtractor('cbr')], + ['cb7', new ComicFormatExtractor('cb7')], + ['fb2', new Fb2FormatExtractor()], + ]); + + for (const format of METADATA_AUDIO_FORMATS) { + extractors.set(format, audio); + } + this.extractors = extractors; + } + + supports(format: string): boolean { + return this.extractors.has(format); + } + + async extract(absolutePath: string, format: string): Promise { + return (await this.extractors.get(format)?.extract(absolutePath)) ?? null; + } + + async extractWithCoverFallback(absolutePath: string, format: string): Promise { + const metadata = await this.extract(absolutePath, format); + if (metadata) return { metadata, cover: metadata.cover }; + + const cover = await extractCover(absolutePath, format); + return { metadata: null, cover }; + } + + private logPdfParseWarning(warning: PdfParseWarning): void { + const pathValue = sanitizeLogValue(warning.absolutePath); + if (warning.code === 'buffered-large-pdf') { + this.logger.warn( + `[metadata.pdf_parse] [end] path="${pathValue}" code=${warning.code} sizeBytes=${warning.sizeBytes ?? 0} thresholdBytes=${warning.thresholdBytes ?? 0} - large pdf buffered in memory`, + ); + return; + } + this.logger.warn( + `[metadata.pdf_parse] [fail] path="${pathValue}" code=${warning.code} errorClass=${warning.errorClass} error="${sanitizeLogValue(warning.errorMessage)}" - pdf parse warning emitted`, + ); + } +} diff --git a/server/src/modules/metadata/metadata.module.test.ts b/server/src/modules/metadata/metadata.module.test.ts index be46acf57..5c9ab1d4c 100644 --- a/server/src/modules/metadata/metadata.module.test.ts +++ b/server/src/modules/metadata/metadata.module.test.ts @@ -3,6 +3,7 @@ import 'reflect-metadata'; vi.mock('../embedding/embedding.module', () => ({ EmbeddingModule: class EmbeddingModule {} })); import { MetadataModule } from './metadata.module'; +import { MetadataExtractionService } from './metadata-extraction.service'; import { MetadataEventsService } from './metadata-events.service'; import { MetadataService } from './metadata.service'; import { ComicMetadataRepository } from './comic-metadata.repository'; @@ -12,7 +13,9 @@ describe('MetadataModule', () => { const providers = Reflect.getMetadata('providers', MetadataModule); const exportedProviders = Reflect.getMetadata('exports', MetadataModule); - expect(providers).toEqual(expect.arrayContaining([MetadataService, MetadataEventsService, ComicMetadataRepository])); - expect(exportedProviders).toEqual(expect.arrayContaining([MetadataService, MetadataEventsService, ComicMetadataRepository])); + expect(providers).toEqual(expect.arrayContaining([MetadataService, MetadataExtractionService, MetadataEventsService, ComicMetadataRepository])); + expect(exportedProviders).toEqual( + expect.arrayContaining([MetadataService, MetadataExtractionService, MetadataEventsService, ComicMetadataRepository]), + ); }); }); diff --git a/server/src/modules/metadata/metadata.module.ts b/server/src/modules/metadata/metadata.module.ts index f767ef7f8..1e39b4ac0 100644 --- a/server/src/modules/metadata/metadata.module.ts +++ b/server/src/modules/metadata/metadata.module.ts @@ -5,12 +5,13 @@ import { EmbeddingModule } from '../embedding/embedding.module'; import { MetadataScoreModule } from '../metadata-score/metadata-score.module'; import { NarratorModule } from '../narrator/narrator.module'; import { ComicMetadataRepository } from './comic-metadata.repository'; +import { MetadataExtractionService } from './metadata-extraction.service'; import { MetadataEventsService } from './metadata-events.service'; import { MetadataService } from './metadata.service'; @Module({ imports: [BookMetadataLockModule, EmbeddingModule, MetadataScoreModule, NarratorModule], - providers: [MetadataService, MetadataEventsService, ComicMetadataRepository], - exports: [MetadataService, MetadataEventsService, ComicMetadataRepository], + providers: [MetadataService, MetadataExtractionService, MetadataEventsService, ComicMetadataRepository], + exports: [MetadataService, MetadataExtractionService, MetadataEventsService, ComicMetadataRepository], }) export class MetadataModule {} diff --git a/server/src/modules/metadata/metadata.service.test.ts b/server/src/modules/metadata/metadata.service.test.ts index ed58c11ba..dc6acd876 100644 --- a/server/src/modules/metadata/metadata.service.test.ts +++ b/server/src/modules/metadata/metadata.service.test.ts @@ -95,6 +95,7 @@ import { parsePdfFile } from './lib/pdf-parser'; import { extractAudioMetadata, parseAudioDuration } from './extractors/audio.extractor'; import { METADATA_AUTHORS_REPLACED } from './metadata-events.service'; import { MetadataService } from './metadata.service'; +import { MetadataExtractionService } from './metadata-extraction.service'; const mockMkdir = mkdir as MockedFunction; const mockReadFile = readFile as MockedFunction; @@ -214,6 +215,7 @@ describe('MetadataService', () => { return new MetadataService( db as never, config as never, + new MetadataExtractionService(), (overrides?.scoreService ?? { calculateAndSave: vi.fn().mockResolvedValue(undefined) }) as never, (overrides?.narratorService ?? { replaceForBook: vi.fn().mockResolvedValue(undefined) }) as never, (overrides?.comicMetadataRepository ?? { upsert: vi.fn().mockResolvedValue(undefined) }) as never, @@ -1182,22 +1184,6 @@ describe('MetadataService', () => { expect(bookTagLinks).toEqual([{ bookId: 41, tagId: 601 }]); }); - it('logs buffered-large-pdf warnings with size metadata', () => { - const service = makeService(makeDb().db); - const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); - - (service as any).logPdfParseWarning({ - code: 'buffered-large-pdf', - absolutePath: '/tmp/large.pdf', - sizeBytes: 10_000_000, - thresholdBytes: 5_000_000, - errorClass: 'None', - errorMessage: 'none', - }); - - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('large pdf buffered in memory')); - }); - it('aggregateAudioDuration sums only files that match the selected primary audio format', async () => { const primaryWhere = vi.fn().mockResolvedValue([{ format: 'm4b' }]); const primaryInnerJoin = vi.fn().mockReturnValue({ where: primaryWhere }); diff --git a/server/src/modules/metadata/metadata.service.ts b/server/src/modules/metadata/metadata.service.ts index d24961895..f6c3c5267 100644 --- a/server/src/modules/metadata/metadata.service.ts +++ b/server/src/modules/metadata/metadata.service.ts @@ -33,16 +33,9 @@ import { NarratorService } from '../narrator/narrator.service'; import { authors, bookAuthors, bookGenres, bookMetadata, books, bookTags, genres, tags } from '../../db/schema'; import { type ComicMetadataFields, isAudioFormat } from '@bookorbit/types'; import { parseAudioDuration } from './extractors/audio.extractor'; -import { AudioFormatExtractor } from './extractors/audio-format.extractor'; -import { ComicFormatExtractor } from './extractors/comic-format.extractor'; -import { EpubFormatExtractor } from './extractors/epub-format.extractor'; -import { Fb2FormatExtractor } from './extractors/fb2-format.extractor'; -import { MobiFormatExtractor } from './extractors/mobi-format.extractor'; -import { OpfFormatExtractor } from './extractors/opf-format.extractor'; -import { PdfFormatExtractor } from './extractors/pdf-format.extractor'; -import type { FormatExtractor, ParsedBookData } from './extractors/format-extractor.interface'; +import type { ParsedBookData } from './extractors/format-extractor.interface'; import { generateThumbnail, imageExt } from './lib/cover'; -import type { PdfParseWarning } from './lib/pdf-parser'; +import { METADATA_AUDIO_FORMATS, MetadataExtractionService } from './metadata-extraction.service'; import { MetadataEventsService, METADATA_AUTHORS_REPLACED } from './metadata-events.service'; type Db = NodePgDatabase; @@ -53,7 +46,6 @@ interface RelationMutationOptions { emitEvent?: boolean; } -const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'] as const; const MAX_RELATION_NAME_LENGTH = 200; const EXTRACTED_COVER_SOURCE = 'extracted'; const MIN_PUBLISHED_YEAR = 1000; @@ -72,11 +64,11 @@ function normalizePublishedYear(year: number | null | undefined): number | null export class MetadataService { private readonly logger = new Logger(MetadataService.name); private readonly appDataPath: string; - private readonly extractorMap: Map; constructor( @Inject(DB) private readonly db: Db, private readonly config: ConfigService, + private readonly extractionService: MetadataExtractionService, private readonly scoreService: MetadataScoreService, private readonly narratorService: NarratorService, private readonly comicMetadataRepository: ComicMetadataRepository, @@ -87,26 +79,6 @@ export class MetadataService { @Optional() private readonly seriesMemberships?: SeriesMembershipService, ) { this.appDataPath = this.config.get('storage.appDataPath')!; - const audio = new AudioFormatExtractor(); - const mobi = new MobiFormatExtractor(); - const epub = new EpubFormatExtractor(); - this.extractorMap = new Map([ - ['epub', epub], - ['kepub', epub], - ['opf', new OpfFormatExtractor()], - ['pdf', new PdfFormatExtractor({ extractCover: true, onWarning: (warning) => this.logPdfParseWarning(warning) })], - ['mobi', mobi], - ['azw3', mobi], - ['azw', mobi], - ['cbz', new ComicFormatExtractor('cbz')], - ['cbr', new ComicFormatExtractor('cbr')], - ['cb7', new ComicFormatExtractor('cb7')], - ['fb2', new Fb2FormatExtractor()], - ]); - - for (const format of AUDIO_FORMATS) { - this.extractorMap.set(format, audio); - } } // ── Public API ─────────────────────────────────────────────────────────────── @@ -121,15 +93,14 @@ export class MetadataService { this.logger.debug(`[${event}] [start] bookId=${bookId} format=${format} - metadata extraction started`); try { - const extractor = this.extractorMap.get(format); - if (!extractor) { + if (!this.extractionService.supports(format)) { this.logger.debug( `[${event}] [end] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} extractorFound=false - metadata extraction skipped`, ); return false; } - const data = await extractor.extract(absolutePath); + const data = await this.extractionService.extract(absolutePath, format); if (!data) { this.logger.debug( `[${event}] [end] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} parsed=false - metadata extraction skipped`, @@ -163,9 +134,8 @@ export class MetadataService { // Saves audio-specific fields that no ebook format can provide, plus audio provider IDs. // Cover is intentionally excluded - the winner ebook owns cover. async extractAudioChaptersAndNarrators(bookId: number, absolutePath: string, format: string): Promise { - const extractor = this.extractorMap.get(format); - if (!extractor) return; - const data = await extractor.extract(absolutePath); + if (!this.extractionService.supports(format)) return; + const data = await this.extractionService.extract(absolutePath, format); if (!data) return; const { dto: filtered } = await this.bookMetadataLockService.filterAutomatedBookUpdate(bookId, { @@ -243,8 +213,7 @@ export class MetadataService { async refreshCoverForBook(bookId: number, absolutePath: string, format: string): Promise { const event = 'metadata.cover_refresh'; const startedAt = Date.now(); - const extractor = this.extractorMap.get(format); - if (!extractor) { + if (!this.extractionService.supports(format)) { this.logger.debug( `[${event}] [end] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} refreshed=false extractorFound=false - cover refresh skipped`, ); @@ -258,7 +227,7 @@ export class MetadataService { ); return false; } - const data = await extractor.extract(absolutePath); + const data = await this.extractionService.extract(absolutePath, format); if (!data?.cover) { this.logger.debug( `[${event}] [end] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} refreshed=false coverFound=false - cover refresh skipped`, @@ -297,7 +266,7 @@ export class MetadataService { .select({ format: schema.bookFiles.format }) .from(schema.books) .innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId)) - .where(and(eq(schema.books.id, bookId), inArray(schema.bookFiles.format, [...AUDIO_FORMATS]))); + .where(and(eq(schema.books.id, bookId), inArray(schema.bookFiles.format, [...METADATA_AUDIO_FORMATS]))); if (!primary?.format) return; const rows = await this.db @@ -802,20 +771,6 @@ export class MetadataService { } } - private logPdfParseWarning(warning: PdfParseWarning): void { - const pathValue = sanitizeLogValue(warning.absolutePath); - if (warning.code === 'buffered-large-pdf') { - this.logger.warn( - `[metadata.pdf_parse] [end] path="${pathValue}" code=${warning.code} sizeBytes=${warning.sizeBytes ?? 0} thresholdBytes=${warning.thresholdBytes ?? 0} - large pdf buffered in memory`, - ); - return; - } - const errorMessage = sanitizeLogValue(warning.errorMessage); - this.logger.warn( - `[metadata.pdf_parse] [fail] path="${pathValue}" code=${warning.code} errorClass=${warning.errorClass} error="${errorMessage}" - pdf parse warning emitted`, - ); - } - private normalizeUniqueRelationNames(values: string[]): string[] { return [...new Set(values.map((value) => value.trim().substring(0, MAX_RELATION_NAME_LENGTH)).filter(Boolean))]; } diff --git a/server/test/book-dock-ingest-finalize.e2e-spec.ts b/server/test/book-dock-ingest-finalize.e2e-spec.ts index 66506d9d9..8b6001f6b 100644 --- a/server/test/book-dock-ingest-finalize.e2e-spec.ts +++ b/server/test/book-dock-ingest-finalize.e2e-spec.ts @@ -123,6 +123,9 @@ describe('Book Dock ingest + finalize (e2e)', () => { title: 'Edited Review Title', authors: ['Edited Review Author'], genres: ['Mystery'], + hardcoverId: 'edited-review-book', + hardcoverEditionId: 'edited-review-edition', + openLibraryId: 'OL123W', }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, @@ -135,6 +138,9 @@ describe('Book Dock ingest + finalize (e2e)', () => { title: 'Edited Review Title', authors: ['Edited Review Author'], genres: ['Mystery'], + hardcoverId: 'edited-review-book', + hardcoverEditionId: 'edited-review-edition', + openLibraryId: 'OL123W', }); expect(updated.targetLibraryId).toBe(destination.libraryId); expect(updated.targetFolderId).toBe(destination.libraryFolderId); @@ -311,6 +317,9 @@ describe('Book Dock ingest + finalize (e2e)', () => { selectedMetadata: { title: 'Finalize Success Title', authors: ['Finalize Author'], + hardcoverId: 'finalize-success-book', + hardcoverEditionId: 'finalize-success-edition', + openLibraryId: 'OL456W', }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, @@ -358,11 +367,21 @@ describe('Book Dock ingest + finalize (e2e)', () => { expect(await fileExists(bookDockRow.absolutePath)).toBe(false); const [metadata] = await context.db - .select({ title: schema.bookMetadata.title }) + .select({ + title: schema.bookMetadata.title, + hardcoverId: schema.bookMetadata.hardcoverId, + hardcoverEditionId: schema.bookMetadata.hardcoverEditionId, + openLibraryId: schema.bookMetadata.openLibraryId, + }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, finalizedBookId)) .limit(1); - expect(metadata?.title).toBe('Finalize Success Title'); + expect(metadata).toMatchObject({ + title: 'Finalize Success Title', + hardcoverId: 'finalize-success-book', + hardcoverEditionId: 'finalize-success-edition', + openLibraryId: 'OL456W', + }); expect(await getBookDockRow(context, bookDockRow.id)).toBeUndefined(); });