mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(metadata): persist hardcover and open library ids during book dock import
Pass external provider IDs (including Hardcover and Open Library IDs) from book dock metadata selection through ingest and finalization so they are saved to book metadata on import. Closes #704
This commit is contained in:
@@ -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<BookDockMetadata>({})
|
||||
|
||||
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<BookDockMetadata> {
|
||||
return {
|
||||
...passthroughMetadata.value,
|
||||
title: form.title || undefined,
|
||||
subtitle: form.subtitle || undefined,
|
||||
authors: form.authors
|
||||
@@ -215,7 +219,7 @@ const currentSource = computed<MetadataSource>(() => ({
|
||||
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<MetadataSource>(() => ({
|
||||
.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<ProviderIds>(() => ({
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -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<void>>(),
|
||||
loadProviders: vi.fn<() => Promise<void>>(),
|
||||
search: vi.fn<(...args: unknown[]) => Promise<void>>(),
|
||||
saveMetadata: vi.fn<(...args: unknown[]) => Promise<null>>(),
|
||||
filteredResults: [] as MetadataCandidate[],
|
||||
}))
|
||||
|
||||
vi.mock('../../composables/useBookDockDetail', () => ({
|
||||
useBookDockDetail: () => ({
|
||||
saved: ref(false),
|
||||
saveError: ref(null),
|
||||
saveMetadata: vi.fn<(...args: unknown[]) => Promise<null>>().mockResolvedValue(null),
|
||||
saveMetadata: mocks.saveMetadata,
|
||||
setTarget: vi.fn<(...args: unknown[]) => Promise<null>>().mockResolvedValue(null),
|
||||
discardFile: vi.fn<(...args: unknown[]) => Promise<void>>().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: '<div data-test="metadata-search-panel" />',
|
||||
})
|
||||
|
||||
const MetadataDiffPanelStub = defineComponent({
|
||||
name: 'MetadataDiffPanel',
|
||||
emits: ['apply'],
|
||||
template: '<div data-test="metadata-diff-panel" />',
|
||||
})
|
||||
|
||||
function makeFile(overrides: Partial<BookDockFile> = {}): 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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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<Record<string, unknown>>) {
|
||||
@@ -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);
|
||||
|
||||
@@ -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<MetadataProviderKey>(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<void> {
|
||||
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<string>();
|
||||
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<MetadataProviderKey, { provider: MetadataProviderKey; rating: number; ratingCount: number | null }>();
|
||||
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;
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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<Record<MetadataProviderKey, keyof BookDockMetadata>> = {
|
||||
[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<BookDockFileRow, 'format' | 'absolutePath'>): string | null {
|
||||
const format = row.format ?? extname(row.absolutePath).toLowerCase().slice(1);
|
||||
return isSupportedFormat(format) ? format : null;
|
||||
|
||||
@@ -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>): 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> = {}): 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<typeof mkdir>;
|
||||
const mockWriteFile = writeFile as MockedFunction<typeof writeFile>;
|
||||
const mockExtractCover = extractCover as MockedFunction<typeof extractCover>;
|
||||
const mockGenerateThumbnail = generateThumbnail as MockedFunction<typeof generateThumbnail>;
|
||||
const mockImageExt = imageExt as MockedFunction<typeof imageExt>;
|
||||
const mockParsePdfFile = parsePdfFile as MockedFunction<typeof parsePdfFile>;
|
||||
const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction<typeof extractEpubMetadata>;
|
||||
const mockParseMobiFile = parseMobiFile as MockedFunction<typeof parseMobiFile>;
|
||||
const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction<typeof extractCbzMetadata>;
|
||||
const mockExtractCbrMetadata = extractCbrMetadata as MockedFunction<typeof extractCbrMetadata>;
|
||||
const mockExtractCb7Metadata = extractCb7Metadata as MockedFunction<typeof extractCb7Metadata>;
|
||||
const mockParseFb2File = parseFb2File as MockedFunction<typeof parseFb2File>;
|
||||
const mockExtractAudioMetadata = extractAudioMetadata as MockedFunction<typeof extractAudioMetadata>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>; 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<BookDockMetadata> {
|
||||
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<BookDockMetadata> {
|
||||
const parsed = await extractEpubMetadata(absolutePath);
|
||||
if (!parsed) return {};
|
||||
return this.toBookDockMetadata(mapOpfMetadata(parsed, null));
|
||||
}
|
||||
|
||||
private async fromPdf(absolutePath: string): Promise<BookDockMetadata> {
|
||||
return this.toPdfMetadata(await this.extractPdfData(absolutePath, false));
|
||||
}
|
||||
|
||||
private async fromMobi(absolutePath: string): Promise<BookDockMetadata> {
|
||||
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<BookDockMetadata> {
|
||||
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<BookDockMetadata> {
|
||||
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<string> {
|
||||
await mkdir(coversDir, { recursive: true });
|
||||
|
||||
@@ -165,46 +62,8 @@ export class BookDockMetadataService {
|
||||
return coverPath;
|
||||
}
|
||||
|
||||
private async extractPdfData(absolutePath: string, extractCover: boolean): Promise<PdfParsed | null> {
|
||||
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<BookDockMetadata> {
|
||||
const metadata: Partial<BookDockMetadata> = {};
|
||||
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<Record<keyof ComicMetadataFields, string | string[] | null | undefined>>,
|
||||
): 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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>) => 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<typeof vi.fn>;
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -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<string, FormatExtractor>;
|
||||
|
||||
constructor() {
|
||||
const audio = new AudioFormatExtractor();
|
||||
const mobi = new MobiFormatExtractor();
|
||||
const epub = new EpubFormatExtractor();
|
||||
const extractors = new Map<string, FormatExtractor>([
|
||||
['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<ParsedBookData | null> {
|
||||
return (await this.extractors.get(format)?.extract(absolutePath)) ?? null;
|
||||
}
|
||||
|
||||
async extractWithCoverFallback(absolutePath: string, format: string): Promise<MetadataExtractionResult> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<typeof mkdir>;
|
||||
const mockReadFile = readFile as MockedFunction<typeof readFile>;
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
@@ -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<string, FormatExtractor>;
|
||||
|
||||
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<string>('storage.appDataPath')!;
|
||||
const audio = new AudioFormatExtractor();
|
||||
const mobi = new MobiFormatExtractor();
|
||||
const epub = new EpubFormatExtractor();
|
||||
this.extractorMap = new Map<string, FormatExtractor>([
|
||||
['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<void> {
|
||||
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<boolean> {
|
||||
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))];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user