feat(metadata): store and write hardcover edition IDs

Adds hardcoverEditionId to the database schema and shared types, enabling it to be stored as first-class metadata. Updates the file metadata parsers and writers (EPUB, PDF, CBZ) to read and embed this ID, and exposes it in the frontend metadata editor.

Closes #420
This commit is contained in:
neon
2026-06-25 18:55:22 +00:00
parent d1442b6d44
commit 2af4895e69
70 changed files with 11679 additions and 7 deletions
@@ -207,6 +207,7 @@ const currentSource = computed<MetadataSource>(() => ({
narrators: [], narrators: [],
durationSeconds: null, durationSeconds: null,
abridged: null, abridged: null,
hardcoverEditionId: null,
})) }))
function openSearch() { function openSearch() {
@@ -83,6 +83,7 @@ function makeDetail(overrides: Partial<BookDetail> = {}): BookDetail {
seriesIndex: null, seriesIndex: null,
rating: null, rating: null,
coverSource: null, coverSource: null,
hardcoverEditionId: null,
providerIds: {}, providerIds: {},
authors: [], authors: [],
genres: [], genres: [],
@@ -94,6 +94,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
seriesIndex: null, seriesIndex: null,
rating: null, rating: null,
coverSource: null, coverSource: null,
hardcoverEditionId: null,
providerIds: {}, providerIds: {},
authors: [], authors: [],
genres: [], genres: [],
@@ -57,6 +57,7 @@ const DIRECT_PATCH_FIELDS = [
'goodreadsId', 'goodreadsId',
'amazonId', 'amazonId',
'hardcoverId', 'hardcoverId',
'hardcoverEditionId',
'openLibraryId', 'openLibraryId',
'itunesId', 'itunesId',
'audibleId', 'audibleId',
@@ -525,6 +526,7 @@ function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch {
goodreadsId: preview.goodreadsId, goodreadsId: preview.goodreadsId,
amazonId: preview.amazonId, amazonId: preview.amazonId,
hardcoverId: preview.hardcoverId, hardcoverId: preview.hardcoverId,
hardcoverEditionId: preview.hardcoverEditionId,
openLibraryId: preview.openLibraryId, openLibraryId: preview.openLibraryId,
itunesId: preview.itunesId, itunesId: preview.itunesId,
audibleId: preview.audibleId, audibleId: preview.audibleId,
@@ -40,6 +40,7 @@ const currentSource = computed<MetadataSource>(() => ({
narrators: props.book.audioMetadata?.narrators.map((n) => n.name) ?? [], narrators: props.book.audioMetadata?.narrators.map((n) => n.name) ?? [],
durationSeconds: props.book.audioMetadata?.durationSeconds ?? null, durationSeconds: props.book.audioMetadata?.durationSeconds ?? null,
abridged: props.book.audioMetadata?.abridged ?? null, abridged: props.book.audioMetadata?.abridged ?? null,
hardcoverEditionId: props.book.hardcoverEditionId,
})) }))
const { const {
@@ -49,6 +49,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
seriesIndex: null, seriesIndex: null,
rating: null, rating: null,
coverSource: 'extracted', coverSource: 'extracted',
hardcoverEditionId: null,
providerIds: {}, providerIds: {},
authors: [{ id: 1, name: 'Author One', sortName: null }], authors: [{ id: 1, name: 'Author One', sortName: null }],
genres: [], genres: [],
@@ -38,6 +38,7 @@ function makeBook(overrides = {}) {
seriesIndex: null, seriesIndex: null,
rating: null, rating: null,
coverSource: null, coverSource: null,
hardcoverEditionId: null,
providerIds: {}, providerIds: {},
authors: [], authors: [],
genres: [], genres: [],
@@ -21,6 +21,7 @@ describe('useMetadataDiff', () => {
narrators: [], narrators: [],
durationSeconds: null, durationSeconds: null,
abridged: null, abridged: null,
hardcoverEditionId: null,
} }
const mockCandidate1: MetadataCandidate = { const mockCandidate1: MetadataCandidate = {
@@ -45,6 +46,8 @@ describe('useMetadataDiff', () => {
{ key: 'goodreads' as MetadataProviderKey, label: 'Goodreads', identifiable: true }, { key: 'goodreads' as MetadataProviderKey, label: 'Goodreads', identifiable: true },
] ]
const hardcoverProviders = [{ key: 'hardcover' as MetadataProviderKey, label: 'Hardcover', identifiable: true }]
it('initializes with fields from active provider', () => { it('initializes with fields from active provider', () => {
const candidates = ref([mockCandidate1, mockCandidate2]) const candidates = ref([mockCandidate1, mockCandidate2])
const activeProvider = ref<MetadataProviderKey>('google') const activeProvider = ref<MetadataProviderKey>('google')
@@ -105,6 +108,81 @@ describe('useMetadataDiff', () => {
expect(formPatch.goodreadsId).toBe('gr1') expect(formPatch.goodreadsId).toBe('gr1')
}) })
it('shows and applies Hardcover edition IDs as lockable metadata fields', () => {
const candidate: MetadataCandidate = {
provider: 'hardcover',
providerId: 'the-name-of-the-wind',
hardcoverEditionId: '1001',
title: 'Hardcover Title',
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('hardcover')
const { fields, toggleField, buildPatch } = useMetadataDiff(mockCurrent, candidates, activeProvider, hardcoverProviders)
const editionField = fields.value.find((f) => f.key === 'hardcoverEditionId')
expect(editionField).toEqual(
expect.objectContaining({
bookValue: '',
candidateDisplay: '1001',
isLocked: false,
}),
)
toggleField('hardcoverEditionId')
expect(buildPatch().formPatch).toEqual({
hardcoverEditionId: '1001',
hardcoverId: 'the-name-of-the-wind',
})
})
it('auto-includes unlocked Hardcover edition IDs when applying other Hardcover fields', () => {
const candidate: MetadataCandidate = {
provider: 'hardcover',
providerId: 'the-name-of-the-wind',
hardcoverEditionId: '1001',
title: 'Hardcover Title',
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('hardcover')
const { toggleField, buildPatch } = useMetadataDiff(mockCurrent, candidates, activeProvider, hardcoverProviders)
toggleField('title')
expect(buildPatch().formPatch).toMatchObject({
title: 'Hardcover Title',
hardcoverId: 'the-name-of-the-wind',
hardcoverEditionId: '1001',
})
})
it('does not auto-include locked Hardcover edition IDs', () => {
const candidate: MetadataCandidate = {
provider: 'hardcover',
providerId: 'the-name-of-the-wind',
hardcoverEditionId: '1001',
title: 'Hardcover Title',
}
const candidates = ref([candidate])
const activeProvider = ref<MetadataProviderKey>('hardcover')
const { toggleField, buildPatch } = useMetadataDiff(
mockCurrent,
candidates,
activeProvider,
hardcoverProviders,
undefined,
undefined,
ref(['hardcoverEditionId'] as const),
)
toggleField('title')
expect(buildPatch().formPatch).toMatchObject({
title: 'Hardcover Title',
hardcoverId: 'the-name-of-the-wind',
})
expect(buildPatch().formPatch.hardcoverEditionId).toBeUndefined()
})
it('builds series memberships when series name and index are picked from the same provider', () => { it('builds series memberships when series name and index are picked from the same provider', () => {
const audibleCandidate: MetadataCandidate = { const audibleCandidate: MetadataCandidate = {
provider: 'audible', provider: 'audible',
@@ -30,6 +30,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
seriesIndex: null, seriesIndex: null,
rating: null, rating: null,
coverSource: null, coverSource: null,
hardcoverEditionId: null,
providerIds: {}, providerIds: {},
authors: [], authors: [],
genres: [], genres: [],
@@ -213,6 +214,25 @@ describe('useMetadataEditor', () => {
}) })
}) })
it('loads and saves Hardcover edition IDs separately from Hardcover book IDs', async () => {
const book = makeBook({ providerIds: { hardcover: 'old-book-slug' }, hardcoverEditionId: '8941973' })
apiMock.mockResolvedValue({ ok: true, json: async () => ({ ...book, hardcoverEditionId: '8941974' }) })
const { form, load, save } = useMetadataEditor()
load(book)
expect(form.hardcoverId).toBe('old-book-slug')
expect(form.hardcoverEditionId).toBe('8941973')
form.hardcoverEditionId = '8941974'
await save(book.id, [])
const [, req] = apiMock.mock.calls[0] as [string, RequestInit]
expect(JSON.parse(String(req.body))).toEqual({
metadata: { hardcoverEditionId: '8941974' },
lockedFields: [],
})
})
it('normalizes a zero page count to null in the save payload', async () => { it('normalizes a zero page count to null in the save payload', async () => {
const book = makeBook({ pageCount: 320 }) const book = makeBook({ pageCount: 320 })
apiMock.mockResolvedValue({ ok: true, json: async () => book }) apiMock.mockResolvedValue({ ok: true, json: async () => book })
@@ -31,6 +31,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
seriesIndex: null, seriesIndex: null,
rating: null, rating: null,
coverSource: null, coverSource: null,
hardcoverEditionId: null,
providerIds: {}, providerIds: {},
authors: [], authors: [],
genres: [], genres: [],
@@ -18,6 +18,7 @@ export interface FileMetadata {
goodreadsId?: string | null goodreadsId?: string | null
amazonId?: string | null amazonId?: string | null
hardcoverId?: string | null hardcoverId?: string | null
hardcoverEditionId?: string | null
openLibraryId?: string | null openLibraryId?: string | null
itunesId?: string | null itunesId?: string | null
audibleId?: string | null audibleId?: string | null
@@ -43,6 +43,7 @@ export type DiffFieldKey =
| 'durationSeconds' | 'durationSeconds'
| 'abridged' | 'abridged'
| 'coverUrl' | 'coverUrl'
| 'hardcoverEditionId'
| ProviderIdPatchField | ProviderIdPatchField
| 'sourceUrl' | 'sourceUrl'
| ComicDiffFieldKey | ComicDiffFieldKey
@@ -93,6 +94,7 @@ export interface MetadataPatch {
goodreadsId?: string | null goodreadsId?: string | null
amazonId?: string | null amazonId?: string | null
hardcoverId?: string | null hardcoverId?: string | null
hardcoverEditionId?: string | null
openLibraryId?: string | null openLibraryId?: string | null
itunesId?: string | null itunesId?: string | null
audibleId?: string | null audibleId?: string | null
@@ -123,6 +125,7 @@ export const FIELD_DEFS: { key: DiffFieldKey; label: string }[] = [
{ key: 'narrators', label: 'Narrators' }, { key: 'narrators', label: 'Narrators' },
{ key: 'durationSeconds', label: 'Duration (seconds)' }, { key: 'durationSeconds', label: 'Duration (seconds)' },
{ key: 'abridged', label: 'Abridged' }, { key: 'abridged', label: 'Abridged' },
{ key: 'hardcoverEditionId', label: 'Hardcover Edition ID' },
] ]
export interface ComicFieldDef { export interface ComicFieldDef {
@@ -516,6 +519,14 @@ export function useMetadataDiff(
if (idField && formPatch[idField] === undefined) { if (idField && formPatch[idField] === undefined) {
formPatch[idField] = candidate.providerId formPatch[idField] = candidate.providerId
} }
if (
provider === 'hardcover' &&
candidate.hardcoverEditionId &&
formPatch.hardcoverEditionId === undefined &&
!lockedFieldSet.value.has('hardcoverEditionId')
) {
formPatch.hardcoverEditionId = candidate.hardcoverEditionId
}
} }
// Preserve existing provider IDs on the book that we aren't overwriting // Preserve existing provider IDs on the book that we aren't overwriting
@@ -32,6 +32,7 @@ const ROOT_FIELDS = [
'goodreadsId', 'goodreadsId',
'amazonId', 'amazonId',
'hardcoverId', 'hardcoverId',
'hardcoverEditionId',
'openLibraryId', 'openLibraryId',
'itunesId', 'itunesId',
'audibleId', 'audibleId',
@@ -126,6 +127,7 @@ export function useMetadataEditor() {
goodreadsId: null as string | null, goodreadsId: null as string | null,
amazonId: null as string | null, amazonId: null as string | null,
hardcoverId: null as string | null, hardcoverId: null as string | null,
hardcoverEditionId: null as string | null,
openLibraryId: null as string | null, openLibraryId: null as string | null,
itunesId: null as string | null, itunesId: null as string | null,
audibleId: null as string | null, audibleId: null as string | null,
@@ -177,6 +179,7 @@ export function useMetadataEditor() {
form.goodreadsId = book.providerIds.goodreads ?? null form.goodreadsId = book.providerIds.goodreads ?? null
form.amazonId = book.providerIds.amazon ?? null form.amazonId = book.providerIds.amazon ?? null
form.hardcoverId = book.providerIds.hardcover ?? null form.hardcoverId = book.providerIds.hardcover ?? null
form.hardcoverEditionId = book.hardcoverEditionId
form.openLibraryId = book.providerIds.openLibrary ?? null form.openLibraryId = book.providerIds.openLibrary ?? null
form.itunesId = book.providerIds.itunes ?? null form.itunesId = book.providerIds.itunes ?? null
form.audibleId = book.providerIds.audible ?? null form.audibleId = book.providerIds.audible ?? null
@@ -9,6 +9,7 @@ describe('buildFileMetadataPatch', () => {
goodreadsId: 'goodreads-1', goodreadsId: 'goodreads-1',
amazonId: 'B00TEST', amazonId: 'B00TEST',
hardcoverId: 'hardcover-1', hardcoverId: 'hardcover-1',
hardcoverEditionId: '8941973',
openLibraryId: 'OL123W', openLibraryId: 'OL123W',
itunesId: 'itunes-1', itunesId: 'itunes-1',
audibleId: 'audible-1', audibleId: 'audible-1',
@@ -21,6 +22,7 @@ describe('buildFileMetadataPatch', () => {
goodreadsId: 'goodreads-1', goodreadsId: 'goodreads-1',
amazonId: 'B00TEST', amazonId: 'B00TEST',
hardcoverId: 'hardcover-1', hardcoverId: 'hardcover-1',
hardcoverEditionId: '8941973',
openLibraryId: 'OL123W', openLibraryId: 'OL123W',
itunesId: 'itunes-1', itunesId: 'itunes-1',
audibleId: 'audible-1', audibleId: 'audible-1',
@@ -24,6 +24,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
seriesIndex: null, seriesIndex: null,
rating: null, rating: null,
coverSource: null, coverSource: null,
hardcoverEditionId: null,
providerIds: {}, providerIds: {},
authors: [{ id: 1, name: 'Hanya Yanagihara', sortName: null }], authors: [{ id: 1, name: 'Hanya Yanagihara', sortName: null }],
genres: [], genres: [],
@@ -13,6 +13,7 @@ describe('provider ID fields', () => {
'goodreadsId', 'goodreadsId',
'amazonId', 'amazonId',
'hardcoverId', 'hardcoverId',
'hardcoverEditionId',
'openLibraryId', 'openLibraryId',
'itunesId', 'itunesId',
'audibleId', 'audibleId',
@@ -25,15 +26,20 @@ describe('provider ID fields', () => {
}) })
it('filters provider ID fields to the effective provider set', () => { it('filters provider ID fields to the effective provider set', () => {
const fields = filterProviderIdFields([provider(MetadataProviderKey.KOBO), provider(MetadataProviderKey.COMICVINE)]) const fields = filterProviderIdFields([
provider(MetadataProviderKey.HARDCOVER),
provider(MetadataProviderKey.KOBO),
provider(MetadataProviderKey.COMICVINE),
])
expect(fields.map((field) => field.field)).toEqual(['koboId', 'comicvineId']) expect(fields.map((field) => field.field)).toEqual(['hardcoverId', 'hardcoverEditionId', 'koboId', 'comicvineId'])
}) })
it('reports provider ID field availability by mapped provider key', () => { it('reports provider ID field availability by mapped provider key', () => {
const providers = [provider(MetadataProviderKey.GOOGLE)] const providers = [provider(MetadataProviderKey.GOOGLE)]
expect(isProviderIdFieldAvailable('googleBooksId', providers)).toBe(true) expect(isProviderIdFieldAvailable('googleBooksId', providers)).toBe(true)
expect(isProviderIdFieldAvailable('hardcoverEditionId', providers)).toBe(false)
expect(isProviderIdFieldAvailable('koboId', providers)).toBe(false) expect(isProviderIdFieldAvailable('koboId', providers)).toBe(false)
expect(isProviderIdFieldAvailable('futureProviderId' as ProviderIdFormField, providers)).toBe(true) expect(isProviderIdFieldAvailable('futureProviderId' as ProviderIdFormField, providers)).toBe(true)
expect(isProviderIdFormField('googleBooksId')).toBe(true) expect(isProviderIdFormField('googleBooksId')).toBe(true)
@@ -47,6 +47,7 @@ function makeDetail(overrides: Partial<BookDetail> = {}): BookDetail {
seriesMemberships: [], seriesMemberships: [],
rating: null, rating: null,
coverSource: null, coverSource: null,
hardcoverEditionId: null,
providerIds: {}, providerIds: {},
authors: [], authors: [],
genres: [], genres: [],
@@ -76,6 +77,8 @@ describe('mergeBookCardWithDetail', () => {
publishedYear: 2021, publishedYear: 2021,
language: 'en', language: 'en',
pageCount: 350, pageCount: 350,
providerIds: { hardcover: 'new-book-slug' },
hardcoverEditionId: '8941973',
seriesName: 'My Series', seriesName: 'My Series',
seriesIndex: 2, seriesIndex: 2,
rating: 8, rating: 8,
@@ -92,6 +95,8 @@ describe('mergeBookCardWithDetail', () => {
expect(result.publishedYear).toBe(2021) expect(result.publishedYear).toBe(2021)
expect(result.language).toBe('en') expect(result.language).toBe('en')
expect(result.pageCount).toBe(350) expect(result.pageCount).toBe(350)
expect(result.hardcoverId).toBe('new-book-slug')
expect(result.hardcoverEditionId).toBe('8941973')
expect(result.seriesName).toBe('My Series') expect(result.seriesName).toBe('My Series')
expect(result.seriesIndex).toBe(2) expect(result.seriesIndex).toBe(2)
expect(result.rating).toBe(8) expect(result.rating).toBe(8)
@@ -21,6 +21,8 @@ export function mergeBookCardWithDetail(book: BookCard, detail: BookDetail): Boo
publishedYear: detail.publishedYear, publishedYear: detail.publishedYear,
language: detail.language, language: detail.language,
pageCount: detail.pageCount, pageCount: detail.pageCount,
hardcoverId: detail.providerIds.hardcover ?? null,
hardcoverEditionId: detail.hardcoverEditionId,
seriesName: detail.seriesName, seriesName: detail.seriesName,
seriesIndex: detail.seriesIndex, seriesIndex: detail.seriesIndex,
seriesMemberships: detail.seriesMemberships, seriesMemberships: detail.seriesMemberships,
@@ -17,6 +17,7 @@ const FILE_METADATA_PATCH_FIELDS = [
'goodreadsId', 'goodreadsId',
'amazonId', 'amazonId',
'hardcoverId', 'hardcoverId',
'hardcoverEditionId',
'openLibraryId', 'openLibraryId',
'itunesId', 'itunesId',
'audibleId', 'audibleId',
@@ -5,6 +5,7 @@ export const PROVIDER_ID_FIELDS = [
{ provider: MetadataProviderKey.GOODREADS, field: 'goodreadsId', label: 'Goodreads' }, { provider: MetadataProviderKey.GOODREADS, field: 'goodreadsId', label: 'Goodreads' },
{ provider: MetadataProviderKey.AMAZON, field: 'amazonId', label: 'Amazon' }, { provider: MetadataProviderKey.AMAZON, field: 'amazonId', label: 'Amazon' },
{ provider: MetadataProviderKey.HARDCOVER, field: 'hardcoverId', label: 'Hardcover' }, { provider: MetadataProviderKey.HARDCOVER, field: 'hardcoverId', label: 'Hardcover' },
{ provider: MetadataProviderKey.HARDCOVER, field: 'hardcoverEditionId', label: 'Hardcover Edition' },
{ provider: MetadataProviderKey.OPEN_LIBRARY, field: 'openLibraryId', label: 'OpenLibrary' }, { provider: MetadataProviderKey.OPEN_LIBRARY, field: 'openLibraryId', label: 'OpenLibrary' },
{ provider: MetadataProviderKey.ITUNES, field: 'itunesId', label: 'iTunes' }, { provider: MetadataProviderKey.ITUNES, field: 'itunesId', label: 'iTunes' },
{ provider: MetadataProviderKey.AUDIBLE, field: 'audibleId', label: 'Audible' }, { provider: MetadataProviderKey.AUDIBLE, field: 'audibleId', label: 'Audible' },
@@ -76,6 +76,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
seriesIndex: null, seriesIndex: null,
rating: 2, rating: 2,
coverSource: null, coverSource: null,
hardcoverEditionId: null,
providerIds: {}, providerIds: {},
authors: [], authors: [],
genres: [], genres: [],
+4
View File
@@ -70,6 +70,8 @@ export type BookCard = {
publisher: string | null; publisher: string | null;
pageCount: number | null; pageCount: number | null;
isbn13: string | null; isbn13: string | null;
hardcoverId?: string | null;
hardcoverEditionId?: string | null;
narrators: string[]; narrators: string[];
tags: string[]; tags: string[];
collapsedSeries?: import("./series-collapse").CollapsedSeriesInfo; collapsedSeries?: import("./series-collapse").CollapsedSeriesInfo;
@@ -132,6 +134,7 @@ export type BookDetail = {
seriesMemberships?: BookSeriesMembership[]; seriesMemberships?: BookSeriesMembership[];
rating: number | null; rating: number | null;
coverSource: "extracted" | "custom" | null; coverSource: "extracted" | "custom" | null;
hardcoverEditionId: string | null;
providerIds: ProviderIds; providerIds: ProviderIds;
authors: { id: number; name: string; sortName: string | null }[]; authors: { id: number; name: string; sortName: string | null }[];
genres: string[]; genres: string[];
@@ -173,6 +176,7 @@ export type BookMetadataRefreshPreviewFields = {
goodreadsId?: string | null; goodreadsId?: string | null;
amazonId?: string | null; amazonId?: string | null;
hardcoverId?: string | null; hardcoverId?: string | null;
hardcoverEditionId?: string | null;
openLibraryId?: string | null; openLibraryId?: string | null;
itunesId?: string | null; itunesId?: string | null;
audibleId?: string | null; audibleId?: string | null;
+2
View File
@@ -21,6 +21,7 @@ export const COMMON_PROVIDER_BOOK_FILE_WRITE_FIELDS = [
"goodreadsId", "goodreadsId",
"amazonId", "amazonId",
"hardcoverId", "hardcoverId",
"hardcoverEditionId",
"openLibraryId", "openLibraryId",
"ranobedbId", "ranobedbId",
"koboId", "koboId",
@@ -74,6 +75,7 @@ export const BOOK_FILE_WRITE_FIELD_LABELS = {
goodreadsId: "Goodreads ID", goodreadsId: "Goodreads ID",
amazonId: "Amazon ID", amazonId: "Amazon ID",
hardcoverId: "Hardcover ID", hardcoverId: "Hardcover ID",
hardcoverEditionId: "Hardcover edition ID",
openLibraryId: "Open Library ID", openLibraryId: "Open Library ID",
ranobedbId: "RanobeDB ID", ranobedbId: "RanobeDB ID",
koboId: "Kobo ID", koboId: "Kobo ID",
+2
View File
@@ -40,6 +40,7 @@ export interface MetadataSeriesMembership {
export interface MetadataCandidate { export interface MetadataCandidate {
provider: MetadataProviderKey; provider: MetadataProviderKey;
providerId: string; providerId: string;
hardcoverEditionId?: string;
title: string; title: string;
subtitle?: string; subtitle?: string;
authors?: string[]; authors?: string[];
@@ -114,4 +115,5 @@ export interface MetadataSource {
narrators: string[]; narrators: string[];
durationSeconds: number | null; durationSeconds: number | null;
abridged: boolean | null; abridged: boolean | null;
hardcoverEditionId: string | null;
} }
+2
View File
@@ -21,6 +21,7 @@ export const BOOK_METADATA_LOCK_FIELDS = [
"goodreadsId", "goodreadsId",
"amazonId", "amazonId",
"hardcoverId", "hardcoverId",
"hardcoverEditionId",
"openLibraryId", "openLibraryId",
"itunesId", "itunesId",
"audibleId", "audibleId",
@@ -82,6 +83,7 @@ export const BOOK_METADATA_LOCK_GROUPS: BookMetadataLockGroup[] = [
"goodreadsId", "goodreadsId",
"amazonId", "amazonId",
"hardcoverId", "hardcoverId",
"hardcoverEditionId",
"openLibraryId", "openLibraryId",
"itunesId", "itunesId",
"audibleId", "audibleId",
@@ -0,0 +1 @@
ALTER TABLE "book_metadata" ADD COLUMN "hardcover_edition_id" varchar(50);
File diff suppressed because it is too large Load Diff
@@ -176,6 +176,13 @@
"when": 1782366176669, "when": 1782366176669,
"tag": "0024_custom_metadata", "tag": "0024_custom_metadata",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1782412211738,
"tag": "0025_add_hardcover_edition_id",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -56,6 +56,7 @@ export const bookMetadata = pgTable(
goodreadsId: varchar('goodreads_id', { length: 50 }), goodreadsId: varchar('goodreads_id', { length: 50 }),
amazonId: varchar('amazon_id', { length: 20 }), amazonId: varchar('amazon_id', { length: 20 }),
hardcoverId: varchar('hardcover_id', { length: 255 }), hardcoverId: varchar('hardcover_id', { length: 255 }),
hardcoverEditionId: varchar('hardcover_edition_id', { length: 50 }),
openLibraryId: varchar('open_library_id', { length: 50 }), openLibraryId: varchar('open_library_id', { length: 50 }),
itunesId: varchar('itunes_id', { length: 50 }), itunesId: varchar('itunes_id', { length: 50 }),
koboId: varchar('kobo_id', { length: 255 }), koboId: varchar('kobo_id', { length: 255 }),
@@ -127,6 +127,7 @@ describe('BookDockMetadataService', () => {
goodreadsId: null, goodreadsId: null,
amazonId: null, amazonId: null,
hardcoverId: null, hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null, openLibraryId: null,
itunesId: null, itunesId: null,
coverBuffer: Buffer.from('cover'), coverBuffer: Buffer.from('cover'),
@@ -183,6 +184,7 @@ describe('BookDockMetadataService', () => {
goodreadsId: null, goodreadsId: null,
amazonId: null, amazonId: null,
hardcoverId: null, hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null, openLibraryId: null,
itunesId: null, itunesId: null,
coverBuffer: null, coverBuffer: null,
@@ -255,6 +255,7 @@ describe('BookMetadataFetchOrchestratorService', () => {
seriesIndex: 2, seriesIndex: 2,
duration: 3600, duration: 3600,
abridged: false, abridged: false,
hardcoverEditionId: '8941973',
chapters: [{ title: 'Ch 1', startMs: 0 }], chapters: [{ title: 'Ch 1', startMs: 0 }],
authors: ['Author A'], authors: ['Author A'],
genres: ['Genre A'], genres: ['Genre A'],
@@ -264,6 +265,7 @@ describe('BookMetadataFetchOrchestratorService', () => {
}, },
providerIds: { providerIds: {
[MetadataProviderKey.GOOGLE]: 'g1', [MetadataProviderKey.GOOGLE]: 'g1',
[MetadataProviderKey.HARDCOVER]: 'new-orleans-rush',
[MetadataProviderKey.AUDIBLE]: 'a1', [MetadataProviderKey.AUDIBLE]: 'a1',
[MetadataProviderKey.KOBO]: 'kobo-1', [MetadataProviderKey.KOBO]: 'kobo-1',
}, },
@@ -281,6 +283,8 @@ describe('BookMetadataFetchOrchestratorService', () => {
durationSeconds: 3600, durationSeconds: 3600,
abridged: false, abridged: false,
googleBooksId: 'g1', googleBooksId: 'g1',
hardcoverId: 'new-orleans-rush',
hardcoverEditionId: '8941973',
audibleId: 'a1', audibleId: 'a1',
koboId: 'kobo-1', koboId: 'kobo-1',
}), }),
@@ -281,6 +281,8 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
if (filteredProviderIds[MetadataProviderKey.GOODREADS]) scalarFields.goodreadsId = filteredProviderIds[MetadataProviderKey.GOODREADS]; if (filteredProviderIds[MetadataProviderKey.GOODREADS]) scalarFields.goodreadsId = filteredProviderIds[MetadataProviderKey.GOODREADS];
if (filteredProviderIds[MetadataProviderKey.AMAZON]) scalarFields.amazonId = filteredProviderIds[MetadataProviderKey.AMAZON]; if (filteredProviderIds[MetadataProviderKey.AMAZON]) scalarFields.amazonId = filteredProviderIds[MetadataProviderKey.AMAZON];
if (filteredProviderIds[MetadataProviderKey.HARDCOVER]) scalarFields.hardcoverId = filteredProviderIds[MetadataProviderKey.HARDCOVER]; if (filteredProviderIds[MetadataProviderKey.HARDCOVER]) scalarFields.hardcoverId = filteredProviderIds[MetadataProviderKey.HARDCOVER];
const hardcoverEditionId = this.asNullableString(filteredResolved.hardcoverEditionId);
if (hardcoverEditionId !== undefined) scalarFields.hardcoverEditionId = hardcoverEditionId;
if (filteredProviderIds[MetadataProviderKey.OPEN_LIBRARY]) scalarFields.openLibraryId = filteredProviderIds[MetadataProviderKey.OPEN_LIBRARY]; if (filteredProviderIds[MetadataProviderKey.OPEN_LIBRARY]) scalarFields.openLibraryId = filteredProviderIds[MetadataProviderKey.OPEN_LIBRARY];
if (filteredProviderIds[MetadataProviderKey.ITUNES]) scalarFields.itunesId = filteredProviderIds[MetadataProviderKey.ITUNES]; if (filteredProviderIds[MetadataProviderKey.ITUNES]) scalarFields.itunesId = filteredProviderIds[MetadataProviderKey.ITUNES];
if (filteredProviderIds[MetadataProviderKey.AUDIBLE]) scalarFields.audibleId = filteredProviderIds[MetadataProviderKey.AUDIBLE]; if (filteredProviderIds[MetadataProviderKey.AUDIBLE]) scalarFields.audibleId = filteredProviderIds[MetadataProviderKey.AUDIBLE];
@@ -372,6 +374,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
goodreadsId?: string | null; goodreadsId?: string | null;
amazonId?: string | null; amazonId?: string | null;
hardcoverId?: string | null; hardcoverId?: string | null;
hardcoverEditionId?: string | null;
openLibraryId?: string | null; openLibraryId?: string | null;
itunesId?: string | null; itunesId?: string | null;
audibleId?: string | null; audibleId?: string | null;
@@ -48,12 +48,13 @@ describe('BookMetadataLockService', () => {
}); });
it('filters automated dto updates and preserves unlocked chapters', async () => { it('filters automated dto updates and preserves unlocked chapters', async () => {
const { service } = makeService(['title', 'narrators', 'comicIssueNumber', 'googleBooksId']); const { service } = makeService(['title', 'narrators', 'comicIssueNumber', 'googleBooksId', 'hardcoverEditionId']);
const result = await service.filterAutomatedBookUpdate(12, { const result = await service.filterAutomatedBookUpdate(12, {
title: 'Locked title', title: 'Locked title',
authors: ['Allowed Author'], authors: ['Allowed Author'],
googleBooksId: 'g-id', googleBooksId: 'g-id',
hardcoverEditionId: '1001',
audioMetadata: { audioMetadata: {
narrators: ['Locked Narrator'], narrators: ['Locked Narrator'],
chapters: [{ title: 'Chapter 1', startMs: 0 }], chapters: [{ title: 'Chapter 1', startMs: 0 }],
@@ -73,7 +74,7 @@ describe('BookMetadataLockService', () => {
volumeName: 'Allowed Volume', volumeName: 'Allowed Volume',
}, },
}); });
expect(result.skippedFields).toEqual(['title', 'narrators', 'googleBooksId', 'comicIssueNumber']); expect(result.skippedFields).toEqual(['title', 'narrators', 'googleBooksId', 'hardcoverEditionId', 'comicIssueNumber']);
}); });
it('assertFieldsUnlocked passes when none of the checked fields are locked', async () => { it('assertFieldsUnlocked passes when none of the checked fields are locked', async () => {
@@ -135,13 +136,14 @@ describe('BookMetadataLockService', () => {
}); });
it('filters resolved metadata and provider ids for locked fields', async () => { it('filters resolved metadata and provider ids for locked fields', async () => {
const { service } = makeService(['cover', 'authors', 'openLibraryId', 'comicVolumeName']); const { service } = makeService(['cover', 'authors', 'openLibraryId', 'hardcoverEditionId', 'comicVolumeName']);
const result = await service.filterResolvedMetadata( const result = await service.filterResolvedMetadata(
12, 12,
{ {
title: 'Allowed Title', title: 'Allowed Title',
authors: ['Locked Author'], authors: ['Locked Author'],
hardcoverEditionId: '1001',
coverUrl: 'https://example.com/cover.jpg', coverUrl: 'https://example.com/cover.jpg',
comicMetadata: { comicMetadata: {
issueNumber: '7', issueNumber: '7',
@@ -163,6 +165,6 @@ describe('BookMetadataLockService', () => {
expect(result.providerIds).toEqual({ expect(result.providerIds).toEqual({
[MetadataProviderKey.GOOGLE]: 'g-id', [MetadataProviderKey.GOOGLE]: 'g-id',
}); });
expect(result.skippedFields).toEqual(['authors', 'openLibraryId', 'comicVolumeName', 'cover']); expect(result.skippedFields).toEqual(['authors', 'hardcoverEditionId', 'openLibraryId', 'comicVolumeName', 'cover']);
}); });
}); });
@@ -163,6 +163,7 @@ export class BookMetadataLockService {
this.copyResolvedField(filteredResolved, resolved, 'narrators', 'narrators', lockedSet, skippedFields); this.copyResolvedField(filteredResolved, resolved, 'narrators', 'narrators', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'duration', 'durationSeconds', lockedSet, skippedFields); this.copyResolvedField(filteredResolved, resolved, 'duration', 'durationSeconds', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'abridged', 'abridged', lockedSet, skippedFields); this.copyResolvedField(filteredResolved, resolved, 'abridged', 'abridged', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'hardcoverEditionId', 'hardcoverEditionId', lockedSet, skippedFields);
if (resolved.chapters !== undefined) { if (resolved.chapters !== undefined) {
filteredResolved.chapters = resolved.chapters; filteredResolved.chapters = resolved.chapters;
@@ -246,6 +247,7 @@ export class BookMetadataLockService {
this.copyUpdateField(filteredDto, dto, 'goodreadsId', 'goodreadsId', lockedSet, skippedFields); this.copyUpdateField(filteredDto, dto, 'goodreadsId', 'goodreadsId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'amazonId', 'amazonId', lockedSet, skippedFields); this.copyUpdateField(filteredDto, dto, 'amazonId', 'amazonId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'hardcoverId', 'hardcoverId', lockedSet, skippedFields); this.copyUpdateField(filteredDto, dto, 'hardcoverId', 'hardcoverId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'hardcoverEditionId', 'hardcoverEditionId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'openLibraryId', 'openLibraryId', lockedSet, skippedFields); this.copyUpdateField(filteredDto, dto, 'openLibraryId', 'openLibraryId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'itunesId', 'itunesId', lockedSet, skippedFields); this.copyUpdateField(filteredDto, dto, 'itunesId', 'itunesId', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'audibleId', 'audibleId', lockedSet, skippedFields); this.copyUpdateField(filteredDto, dto, 'audibleId', 'audibleId', lockedSet, skippedFields);
@@ -377,6 +379,7 @@ export class BookMetadataLockService {
this.addFieldIfPresent(fields, dto, 'goodreadsId', 'goodreadsId'); this.addFieldIfPresent(fields, dto, 'goodreadsId', 'goodreadsId');
this.addFieldIfPresent(fields, dto, 'amazonId', 'amazonId'); this.addFieldIfPresent(fields, dto, 'amazonId', 'amazonId');
this.addFieldIfPresent(fields, dto, 'hardcoverId', 'hardcoverId'); this.addFieldIfPresent(fields, dto, 'hardcoverId', 'hardcoverId');
this.addFieldIfPresent(fields, dto, 'hardcoverEditionId', 'hardcoverEditionId');
this.addFieldIfPresent(fields, dto, 'openLibraryId', 'openLibraryId'); this.addFieldIfPresent(fields, dto, 'openLibraryId', 'openLibraryId');
this.addFieldIfPresent(fields, dto, 'itunesId', 'itunesId'); this.addFieldIfPresent(fields, dto, 'itunesId', 'itunesId');
this.addFieldIfPresent(fields, dto, 'audibleId', 'audibleId'); this.addFieldIfPresent(fields, dto, 'audibleId', 'audibleId');
@@ -85,6 +85,7 @@ describe('BookRepository', () => {
const result = await repo.findCards({ where: undefined as never, orderBy: [] as never, limit: 25, offset: 0, userId: 7 }); const result = await repo.findCards({ where: undefined as never, orderBy: [] as never, limit: 25, offset: 0, userId: 7 });
expect(db.select).toHaveBeenNthCalledWith(1, expect.objectContaining({ hardcoverId: expect.anything(), hardcoverEditionId: expect.anything() }));
expect(result).toEqual({ expect(result).toEqual({
rows, rows,
authorRows, authorRows,
@@ -63,6 +63,8 @@ type CollapsedRawRow = {
locked_fields: string[] | null; locked_fields: string[] | null;
subtitle: string | null; subtitle: string | null;
isbn13: string | null; isbn13: string | null;
hardcover_id: string | null;
hardcover_edition_id: string | null;
publisher: string | null; publisher: string | null;
page_count: number | null; page_count: number | null;
sort_title: string | null; sort_title: string | null;
@@ -167,6 +169,8 @@ export class BookRepository {
publisher: bookMetadata.publisher, publisher: bookMetadata.publisher,
pageCount: bookMetadata.pageCount, pageCount: bookMetadata.pageCount,
isbn13: bookMetadata.isbn13, isbn13: bookMetadata.isbn13,
hardcoverId: bookMetadata.hardcoverId,
hardcoverEditionId: bookMetadata.hardcoverEditionId,
metadataScore: bookMetadata.metadataScore, metadataScore: bookMetadata.metadataScore,
_total: sql<number>`count(*) over()`.as('_total'), _total: sql<number>`count(*) over()`.as('_total'),
}) })
@@ -356,6 +360,8 @@ export class BookRepository {
publisher: string | null; publisher: string | null;
pageCount: number | null; pageCount: number | null;
isbn13: string | null; isbn13: string | null;
hardcoverId: string | null;
hardcoverEditionId: string | null;
bookCount: number | null; bookCount: number | null;
readCount: number | null; readCount: number | null;
coverBookIds: number[] | null; coverBookIds: number[] | null;
@@ -409,6 +415,8 @@ export class BookRepository {
book_metadata.page_count, book_metadata.page_count,
book_metadata.subtitle, book_metadata.subtitle,
book_metadata.isbn13, book_metadata.isbn13,
book_metadata.hardcover_id,
book_metadata.hardcover_edition_id,
book_metadata.metadata_score, book_metadata.metadata_score,
NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series
FROM books FROM books
@@ -538,6 +546,8 @@ export class BookRepository {
base.page_count, base.page_count,
base.subtitle, base.subtitle,
base.isbn13, base.isbn13,
base.hardcover_id,
base.hardcover_edition_id,
base.metadata_score, base.metadata_score,
base.primary_author_sort_name AS author_sort_name, base.primary_author_sort_name AS author_sort_name,
COALESCE(base.norm_series, lower(base.title)) AS sort_title, COALESCE(base.norm_series, lower(base.title)) AS sort_title,
@@ -602,6 +612,8 @@ export class BookRepository {
publisher: r.publisher, publisher: r.publisher,
pageCount: r.page_count !== null ? Number(r.page_count) : null, pageCount: r.page_count !== null ? Number(r.page_count) : null,
isbn13: r.isbn13, isbn13: r.isbn13,
hardcoverId: r.hardcover_id,
hardcoverEditionId: r.hardcover_edition_id,
bookCount: r.book_count !== null ? Number(r.book_count) : null, bookCount: r.book_count !== null ? Number(r.book_count) : null,
readCount: r.read_count !== null ? Number(r.read_count) : null, readCount: r.read_count !== null ? Number(r.read_count) : null,
coverBookIds: r.cover_book_ids, coverBookIds: r.cover_book_ids,
@@ -327,6 +327,8 @@ function makeBookCard(id: number, overrides?: Record<string, unknown>) {
publisher: 'Pub', publisher: 'Pub',
pageCount: 320, pageCount: 320,
isbn13: '9780000000000', isbn13: '9780000000000',
hardcoverId: 'hardcover-book-slug',
hardcoverEditionId: '8941973',
narrators: [], narrators: [],
...(overrides ?? {}), ...(overrides ?? {}),
}; };
@@ -629,6 +631,8 @@ describe('BookService', () => {
expect(exported.content).toContain('\uFEFF'); expect(exported.content).toContain('\uFEFF');
expect(exported.content).toContain('# schemaVersion=1'); expect(exported.content).toContain('# schemaVersion=1');
expect(exported.content).toContain('bookId,libraryId,libraryName,status,title'); expect(exported.content).toContain('bookId,libraryId,libraryName,status,title');
expect(exported.content).toContain('isbn13,hardcoverId,hardcoverEditionId,genres');
expect(exported.content).toContain('9780000000000,hardcover-book-slug,8941973');
expect(exported.content).toContain('1,5,Main Library,present,Book 1'); expect(exported.content).toContain('1,5,Main Library,present,Book 1');
}); });
+12
View File
@@ -277,6 +277,7 @@ export class BookService {
goodreadsId?: string | null; goodreadsId?: string | null;
amazonId?: string | null; amazonId?: string | null;
hardcoverId?: string | null; hardcoverId?: string | null;
hardcoverEditionId?: string | null;
openLibraryId?: string | null; openLibraryId?: string | null;
itunesId?: string | null; itunesId?: string | null;
audibleId?: string | null; audibleId?: string | null;
@@ -309,6 +310,7 @@ export class BookService {
| 'goodreadsId' | 'goodreadsId'
| 'amazonId' | 'amazonId'
| 'hardcoverId' | 'hardcoverId'
| 'hardcoverEditionId'
| 'openLibraryId' | 'openLibraryId'
| 'itunesId' | 'itunesId'
| 'audibleId' | 'audibleId'
@@ -354,6 +356,7 @@ export class BookService {
if (r.seriesIndex !== undefined) preview.seriesIndex = r.seriesIndex as number | null; if (r.seriesIndex !== undefined) preview.seriesIndex = r.seriesIndex as number | null;
if (r.seriesMemberships !== undefined) preview.seriesMemberships = r.seriesMemberships as BookMetadataRefreshPreviewFields['seriesMemberships']; if (r.seriesMemberships !== undefined) preview.seriesMemberships = r.seriesMemberships as BookMetadataRefreshPreviewFields['seriesMemberships'];
if (r.coverUrl !== undefined) preview.coverUrl = r.coverUrl as string; if (r.coverUrl !== undefined) preview.coverUrl = r.coverUrl as string;
if (r.hardcoverEditionId !== undefined) preview.hardcoverEditionId = r.hardcoverEditionId as string | null;
if (r.comicMetadata !== undefined) preview.comicMetadata = r.comicMetadata as BookMetadataRefreshPreviewFields['comicMetadata']; if (r.comicMetadata !== undefined) preview.comicMetadata = r.comicMetadata as BookMetadataRefreshPreviewFields['comicMetadata'];
if (r.narrators !== undefined || r.duration !== undefined || r.abridged !== undefined || r.chapters !== undefined) { if (r.narrators !== undefined || r.duration !== undefined || r.abridged !== undefined || r.chapters !== undefined) {
@@ -697,6 +700,8 @@ export class BookService {
'publisher', 'publisher',
'pageCount', 'pageCount',
'isbn13', 'isbn13',
'hardcoverId',
'hardcoverEditionId',
'genres', 'genres',
'tags', 'tags',
'narrators', 'narrators',
@@ -850,6 +855,8 @@ export class BookService {
publisher: row.publisher, publisher: row.publisher,
pageCount: row.pageCount, pageCount: row.pageCount,
isbn13: row.isbn13, isbn13: row.isbn13,
hardcoverId: row.hardcoverId ?? null,
hardcoverEditionId: row.hardcoverEditionId ?? null,
genres: row.genres, genres: row.genres,
tags: row.tags, tags: row.tags,
narrators: row.narrators, narrators: row.narrators,
@@ -1458,6 +1465,7 @@ export class BookService {
if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null; if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null;
if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null; if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null;
if (dto.hardcoverId !== undefined) scalarFields.hardcoverId = dto.hardcoverId ?? null; if (dto.hardcoverId !== undefined) scalarFields.hardcoverId = dto.hardcoverId ?? null;
if (dto.hardcoverEditionId !== undefined) scalarFields.hardcoverEditionId = dto.hardcoverEditionId ?? null;
if (dto.openLibraryId !== undefined) scalarFields.openLibraryId = dto.openLibraryId ?? null; if (dto.openLibraryId !== undefined) scalarFields.openLibraryId = dto.openLibraryId ?? null;
if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null; if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null;
if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null; if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null;
@@ -2704,6 +2712,7 @@ export class BookService {
seriesMemberships: seriesMembershipRows, seriesMemberships: seriesMembershipRows,
rating: personalRating, rating: personalRating,
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null, coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
hardcoverEditionId: meta?.hardcoverEditionId ?? null,
lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields), lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields),
providerIds: { providerIds: {
[MetadataProviderKey.GOOGLE]: meta?.googleBooksId ?? null, [MetadataProviderKey.GOOGLE]: meta?.googleBooksId ?? null,
@@ -2823,6 +2832,7 @@ export class BookService {
goodreadsId: parsed.goodreadsId, goodreadsId: parsed.goodreadsId,
amazonId: parsed.amazonId, amazonId: parsed.amazonId,
hardcoverId: parsed.hardcoverId, hardcoverId: parsed.hardcoverId,
hardcoverEditionId: parsed.hardcoverEditionId,
openLibraryId: parsed.openLibraryId, openLibraryId: parsed.openLibraryId,
itunesId: parsed.itunesId, itunesId: parsed.itunesId,
koboId: parsed.koboId, koboId: parsed.koboId,
@@ -2856,6 +2866,7 @@ export class BookService {
goodreadsId: parsed.goodreadsId, goodreadsId: parsed.goodreadsId,
amazonId: parsed.amazonId, amazonId: parsed.amazonId,
hardcoverId: parsed.hardcoverId, hardcoverId: parsed.hardcoverId,
hardcoverEditionId: parsed.hardcoverEditionId,
openLibraryId: parsed.openLibraryId, openLibraryId: parsed.openLibraryId,
itunesId: parsed.itunesId, itunesId: parsed.itunesId,
koboId: parsed.koboId, koboId: parsed.koboId,
@@ -2906,6 +2917,7 @@ export class BookService {
goodreadsId: parsed.goodreadsId, goodreadsId: parsed.goodreadsId,
amazonId: parsed.amazonId, amazonId: parsed.amazonId,
hardcoverId: parsed.hardcoverId, hardcoverId: parsed.hardcoverId,
hardcoverEditionId: parsed.hardcoverEditionId,
openLibraryId: parsed.openLibraryId, openLibraryId: parsed.openLibraryId,
itunesId: parsed.itunesId, itunesId: parsed.itunesId,
koboId: parsed.koboId, koboId: parsed.koboId,
@@ -51,6 +51,7 @@ export class BookDetailDto {
seriesMemberships: BookSeriesMembership[]; seriesMemberships: BookSeriesMembership[];
rating: number | null; rating: number | null;
coverSource: 'extracted' | 'custom' | null; coverSource: 'extracted' | 'custom' | null;
hardcoverEditionId: string | null;
providerIds: ProviderIds; providerIds: ProviderIds;
authors: { id: number; name: string; sortName: string | null }[]; authors: { id: number; name: string; sortName: string | null }[];
genres: string[]; genres: string[];
@@ -56,6 +56,7 @@ export class UpdateBookMetadataDto {
@IsOptional() @IsString() @MaxLength(50) goodreadsId?: string | null; @IsOptional() @IsString() @MaxLength(50) goodreadsId?: string | null;
@IsOptional() @IsString() @MaxLength(20) amazonId?: string | null; @IsOptional() @IsString() @MaxLength(20) amazonId?: string | null;
@IsOptional() @IsString() @MaxLength(255) hardcoverId?: 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) openLibraryId?: string | null;
@IsOptional() @IsString() @MaxLength(50) itunesId?: string | null; @IsOptional() @IsString() @MaxLength(50) itunesId?: string | null;
@IsOptional() @IsString() @MaxLength(20) audibleId?: string | null; @IsOptional() @IsString() @MaxLength(20) audibleId?: string | null;
@@ -18,6 +18,8 @@ function makeBookRow(id: number, overrides?: Partial<Parameters<typeof assembleB
publisher: null, publisher: null,
pageCount: null, pageCount: null,
isbn13: null, isbn13: null,
hardcoverId: null,
hardcoverEditionId: null,
...overrides, ...overrides,
}; };
} }
@@ -47,6 +49,15 @@ describe('assembleBookCards', () => {
expect(card.files).toEqual([{ id: 10, format: 'epub', role: 'primary', sizeBytes: 4096 }]); expect(card.files).toEqual([{ id: 10, format: 'epub', role: 'primary', sizeBytes: 4096 }]);
}); });
it('passes through Hardcover book and edition identifiers', () => {
const rows = [makeBookRow(1, { hardcoverId: 'new-orleans-rush', hardcoverEditionId: '8941973' })];
const [card] = assembleBookCards(rows, [], [], [], []);
expect(card.hardcoverId).toBe('new-orleans-rush');
expect(card.hardcoverEditionId).toBe('8941973');
});
it('falls back to basename of folderPath when title is null', () => { it('falls back to basename of folderPath when title is null', () => {
const rows = [makeBookRow(2, { title: null, folderPath: '/books/my-book-folder' })]; const rows = [makeBookRow(2, { title: null, folderPath: '/books/my-book-folder' })];
@@ -351,6 +362,8 @@ function makeBookCard(id: number, overrides?: Record<string, unknown>) {
publisher: null as string | null, publisher: null as string | null,
pageCount: null as number | null, pageCount: null as number | null,
isbn13: null as string | null, isbn13: null as string | null,
hardcoverId: null as string | null,
hardcoverEditionId: null as string | null,
narrators: [] as string[], narrators: [] as string[],
...overrides, ...overrides,
}; };
@@ -31,6 +31,8 @@ type BookRow = {
publisher: string | null; publisher: string | null;
pageCount: number | null; pageCount: number | null;
isbn13: string | null; isbn13: string | null;
hardcoverId: string | null;
hardcoverEditionId: string | null;
}; };
type CollapsedBookRow = BookRow & { type CollapsedBookRow = BookRow & {
@@ -185,6 +187,8 @@ export function assembleBookCards(
publisher: row.publisher ?? null, publisher: row.publisher ?? null,
pageCount: row.pageCount ?? null, pageCount: row.pageCount ?? null,
isbn13: row.isbn13 ?? null, isbn13: row.isbn13 ?? null,
hardcoverId: row.hardcoverId ?? null,
hardcoverEditionId: row.hardcoverEditionId ?? null,
narrators: narratorsByBook.get(row.id) ?? [], narrators: narratorsByBook.get(row.id) ?? [],
tags: tagsByBook.get(row.id) ?? [], tags: tagsByBook.get(row.id) ?? [],
}; };
@@ -34,6 +34,7 @@ export const COMIC_INFO_MANAGED_NOTES_KEYS = [
'goodreadsId', 'goodreadsId',
'amazonId', 'amazonId',
'hardcoverId', 'hardcoverId',
'hardcoverEditionId',
'googleBooksId', 'googleBooksId',
'openLibraryId', 'openLibraryId',
'ranobedbId', 'ranobedbId',
@@ -57,6 +58,7 @@ export const EPUB_PROVIDER_IDENTIFIER_SCHEMES = {
goodreadsId: 'GOODREADS', goodreadsId: 'GOODREADS',
amazonId: 'AMAZON', amazonId: 'AMAZON',
hardcoverId: 'HARDCOVER', hardcoverId: 'HARDCOVER',
hardcoverEditionId: 'HARDCOVER_EDITION',
googleBooksId: 'GOOGLE', googleBooksId: 'GOOGLE',
openLibraryId: 'OPENLIBRARY', openLibraryId: 'OPENLIBRARY',
ranobedbId: 'RANOBEDB', ranobedbId: 'RANOBEDB',
@@ -94,6 +94,7 @@ describe('FileWriteRepository', () => {
goodreadsId: 'gr', goodreadsId: 'gr',
amazonId: 'a', amazonId: 'a',
hardcoverId: 'h', hardcoverId: 'h',
hardcoverEditionId: '8941973',
openLibraryId: 'ol', openLibraryId: 'ol',
ranobedbId: 'rn', ranobedbId: 'rn',
koboId: 'kb', koboId: 'kb',
@@ -206,6 +206,7 @@ export class FileWriteRepository {
goodreadsId: meta.goodreadsId, goodreadsId: meta.goodreadsId,
amazonId: meta.amazonId, amazonId: meta.amazonId,
hardcoverId: meta.hardcoverId, hardcoverId: meta.hardcoverId,
hardcoverEditionId: meta.hardcoverEditionId,
openLibraryId: meta.openLibraryId, openLibraryId: meta.openLibraryId,
ranobedbId: meta.ranobedbId, ranobedbId: meta.ranobedbId,
koboId: meta.koboId, koboId: meta.koboId,
@@ -28,6 +28,7 @@ describe('buildComicInfoXml', () => {
rating: 8, rating: 8,
isbn13: '9780441172719', isbn13: '9780441172719',
goodreadsId: '44767458-dune', goodreadsId: '44767458-dune',
hardcoverEditionId: '8941973',
ranobedbId: 'ranobe-1', ranobedbId: 'ranobe-1',
subtitle: 'Anniversary Edition', subtitle: 'Anniversary Edition',
isbn10: '0441172717', isbn10: '0441172717',
@@ -47,6 +48,7 @@ describe('buildComicInfoXml', () => {
'rating', 'rating',
'isbn13', 'isbn13',
'goodreadsId', 'goodreadsId',
'hardcoverEditionId',
'ranobedbId', 'ranobedbId',
'subtitle', 'subtitle',
'isbn10', 'isbn10',
@@ -64,6 +66,7 @@ describe('buildComicInfoXml', () => {
expect(parsed.ComicInfo.Web).toBe('https://www.goodreads.com/book/show/44767458-dune'); expect(parsed.ComicInfo.Web).toBe('https://www.goodreads.com/book/show/44767458-dune');
expect(parsed.ComicInfo.Notes).toContain('[bookorbit:subtitle] Anniversary Edition'); expect(parsed.ComicInfo.Notes).toContain('[bookorbit:subtitle] Anniversary Edition');
expect(parsed.ComicInfo.Notes).toContain('[bookorbit:isbn10] 0441172717'); expect(parsed.ComicInfo.Notes).toContain('[bookorbit:isbn10] 0441172717');
expect(parsed.ComicInfo.Notes).toContain('[bookorbit:hardcoverEditionId] 8941973');
expect(parsed.ComicInfo.Notes).toContain('[bookorbit:ranobedbId] ranobe-1'); expect(parsed.ComicInfo.Notes).toContain('[bookorbit:ranobedbId] ranobe-1');
expect(parsed.ComicInfo['@_xmlns:xsi']).toBeDefined(); expect(parsed.ComicInfo['@_xmlns:xsi']).toBeDefined();
expect(parsed.ComicInfo['@_xmlns:xsd']).toBeDefined(); expect(parsed.ComicInfo['@_xmlns:xsd']).toBeDefined();
@@ -77,6 +77,7 @@ describe('epub-opf-builder', () => {
goodreadsId: '42129393', goodreadsId: '42129393',
openLibraryId: 'OL20652610W', openLibraryId: 'OL20652610W',
hardcoverId: 'new-orleans-rush', hardcoverId: 'new-orleans-rush',
hardcoverEditionId: '8941973',
ranobedbId: 'ranobe-1', ranobedbId: 'ranobe-1',
itunesId: '123456789', itunesId: '123456789',
}); });
@@ -88,6 +89,8 @@ describe('epub-opf-builder', () => {
expect(result.newOpfXml).toContain('opf:scheme="GOODREADS"'); expect(result.newOpfXml).toContain('opf:scheme="GOODREADS"');
expect(result.newOpfXml).toContain('opf:scheme="OPENLIBRARY"'); expect(result.newOpfXml).toContain('opf:scheme="OPENLIBRARY"');
expect(result.newOpfXml).toContain('opf:scheme="HARDCOVER"'); expect(result.newOpfXml).toContain('opf:scheme="HARDCOVER"');
expect(result.newOpfXml).toContain('opf:scheme="HARDCOVER_EDITION"');
expect(result.newOpfXml).toContain('>8941973<');
expect(result.newOpfXml).toContain('opf:scheme="RANOBEDB"'); expect(result.newOpfXml).toContain('opf:scheme="RANOBEDB"');
expect(result.newOpfXml).toContain('>ranobe-1<'); expect(result.newOpfXml).toContain('>ranobe-1<');
expect(result.newOpfXml).toContain('opf:scheme="ITUNES"'); expect(result.newOpfXml).toContain('opf:scheme="ITUNES"');
@@ -77,6 +77,7 @@ describe('buildXmp', () => {
googleBooksId: 'google-1', googleBooksId: 'google-1',
amazonId: 'amazon-1', amazonId: 'amazon-1',
hardcoverId: 'hardcover-1', hardcoverId: 'hardcover-1',
hardcoverEditionId: '8941973',
openLibraryId: 'OL123W', openLibraryId: 'OL123W',
itunesId: 'itunes-1', itunesId: 'itunes-1',
tags: ['classic'], tags: ['classic'],
@@ -99,6 +100,7 @@ describe('buildXmp', () => {
'googleBooksId', 'googleBooksId',
'amazonId', 'amazonId',
'hardcoverId', 'hardcoverId',
'hardcoverEditionId',
'openLibraryId', 'openLibraryId',
'itunesId', 'itunesId',
'tags', 'tags',
@@ -110,6 +112,7 @@ describe('buildXmp', () => {
expect(xmp).toContain('<dc:publisher>Ace</dc:publisher>'); expect(xmp).toContain('<dc:publisher>Ace</dc:publisher>');
expect(xmp).toContain('<bookorbit:isbn13>9780441172719</bookorbit:isbn13>'); expect(xmp).toContain('<bookorbit:isbn13>9780441172719</bookorbit:isbn13>');
expect(xmp).toContain('<bookorbit:googleBooksId>google-1</bookorbit:googleBooksId>'); expect(xmp).toContain('<bookorbit:googleBooksId>google-1</bookorbit:googleBooksId>');
expect(xmp).toContain('<bookorbit:hardcoverEditionId>8941973</bookorbit:hardcoverEditionId>');
expect(xmp).toContain('<bookorbit:itunesId>itunes-1</bookorbit:itunesId>'); expect(xmp).toContain('<bookorbit:itunesId>itunes-1</bookorbit:itunesId>');
}); });
}); });
@@ -21,6 +21,7 @@ export interface BookWritePayload {
goodreadsId?: string | null; goodreadsId?: string | null;
amazonId?: string | null; amazonId?: string | null;
hardcoverId?: string | null; hardcoverId?: string | null;
hardcoverEditionId?: string | null;
openLibraryId?: string | null; openLibraryId?: string | null;
ranobedbId?: string | null; ranobedbId?: string | null;
koboId?: string | null; koboId?: string | null;
@@ -486,6 +486,33 @@ describe('MetadataFetchPipeline', () => {
expect(providerIds).toEqual({ [MetadataProviderKey.GOOGLE]: 'g1' }); expect(providerIds).toEqual({ [MetadataProviderKey.GOOGLE]: 'g1' });
}); });
it('passes through Hardcover edition id with provider ids when saveProviderIds is enabled', async () => {
const prefs = createPreferences((fields) => {
fields.title = {
enabled: true,
providers: [MetadataProviderKey.HARDCOVER],
mergeStrategy: 'overwriteIfProvided',
};
});
prefs.options = {
genres: { mode: 'firstProvider', blocklist: [] },
saveProviderIds: true,
};
preferencesService.getGlobal.mockResolvedValue(prefs);
resolver.resolve.mockReturnValue(prefs);
resolver.withForwardCompatibility.mockReturnValue(prefs);
registry.all.mockReturnValue([{ key: MetadataProviderKey.HARDCOVER }] as never);
fetchService.search.mockReturnValue(
of(candidate(MetadataProviderKey.HARDCOVER, 'the-name-of-the-wind', { title: 'Fetched Title', hardcoverEditionId: '1001' })),
);
const { resolved, providerIds } = await pipeline.runWithSources({ title: 'Query' }, {});
expect(resolved.hardcoverEditionId).toBe('1001');
expect(providerIds).toEqual({ [MetadataProviderKey.HARDCOVER]: 'the-name-of-the-wind' });
});
it('does not return provider ids when saveProviderIds is disabled', async () => { it('does not return provider ids when saveProviderIds is disabled', async () => {
const prefs = createPreferences((fields) => { const prefs = createPreferences((fields) => {
fields.title = { fields.title = {
@@ -510,6 +537,33 @@ describe('MetadataFetchPipeline', () => {
expect(providerIds).toEqual({}); expect(providerIds).toEqual({});
}); });
it('does not pass through Hardcover edition id when provider id saving is disabled', async () => {
const prefs = createPreferences((fields) => {
fields.title = {
enabled: true,
providers: [MetadataProviderKey.HARDCOVER],
mergeStrategy: 'overwriteIfProvided',
};
});
prefs.options = {
genres: { mode: 'firstProvider', blocklist: [] },
saveProviderIds: false,
};
preferencesService.getGlobal.mockResolvedValue(prefs);
resolver.resolve.mockReturnValue(prefs);
resolver.withForwardCompatibility.mockReturnValue(prefs);
registry.all.mockReturnValue([{ key: MetadataProviderKey.HARDCOVER }] as never);
fetchService.search.mockReturnValue(
of(candidate(MetadataProviderKey.HARDCOVER, 'the-name-of-the-wind', { title: 'Fetched Title', hardcoverEditionId: '1001' })),
);
const { resolved, providerIds } = await pipeline.runWithSources({ title: 'Query' }, {});
expect(resolved.hardcoverEditionId).toBeUndefined();
expect(providerIds).toEqual({});
});
it('keeps the first candidate from each provider to avoid provider stream reordering issues', async () => { it('keeps the first candidate from each provider to avoid provider stream reordering issues', async () => {
const prefs = createPreferences((fields) => { const prefs = createPreferences((fields) => {
fields.description = { fields.description = {
@@ -24,6 +24,7 @@ import { MetadataSearchParams } from './providers/metadata-search-params';
export type ResolvedMetadataFields = Partial<Record<MetadataField, string | string[] | number | null>> & { export type ResolvedMetadataFields = Partial<Record<MetadataField, string | string[] | number | null>> & {
coverUrl?: string; coverUrl?: string;
hardcoverEditionId?: string | null;
seriesMemberships?: MetadataSeriesMembership[]; seriesMemberships?: MetadataSeriesMembership[];
chapters?: AudiobookChapter[]; chapters?: AudiobookChapter[];
comicMetadata?: ComicMetadataFields; comicMetadata?: ComicMetadataFields;
@@ -295,6 +296,9 @@ export class MetadataFetchPipeline {
if (preferences.options?.saveProviderIds) { if (preferences.options?.saveProviderIds) {
for (const candidate of byProvider.values()) { for (const candidate of byProvider.values()) {
if (candidate.providerId) providerIds[candidate.provider] = candidate.providerId; if (candidate.providerId) providerIds[candidate.provider] = candidate.providerId;
if (candidate.provider === MetadataProviderKey.HARDCOVER && candidate.hardcoverEditionId) {
result.hardcoverEditionId = candidate.hardcoverEditionId;
}
} }
} }
@@ -70,6 +70,7 @@ describe('mapSearchDocument', () => {
coverUrl: 'https://assets.hardcover.app/cover.jpg', coverUrl: 'https://assets.hardcover.app/cover.jpg',
sourceUrl: 'https://hardcover.app/books/the-name-of-the-wind', sourceUrl: 'https://hardcover.app/books/the-name-of-the-wind',
}); });
expect(result.hardcoverEditionId).toBeUndefined();
}); });
it('prefers release_year over release_date for publishedYear', () => { it('prefers release_year over release_date for publishedYear', () => {
@@ -144,6 +145,7 @@ describe('mapBookWithEditions', () => {
expect(result).toEqual({ expect(result).toEqual({
provider: MetadataProviderKey.HARDCOVER, provider: MetadataProviderKey.HARDCOVER,
providerId: 'the-name-of-the-wind', providerId: 'the-name-of-the-wind',
hardcoverEditionId: '1001',
title: 'The Name of the Wind', title: 'The Name of the Wind',
subtitle: 'Edition Subtitle', subtitle: 'Edition Subtitle',
description: 'A story about a wizard.', description: 'A story about a wizard.',
@@ -182,7 +184,9 @@ describe('mapBookWithEditions', () => {
const results = mapBookWithEditions(book); const results = mapBookWithEditions(book);
expect(results).toHaveLength(2); expect(results).toHaveLength(2);
expect(results[0].isbn13).toBe('9780000000001'); expect(results[0].isbn13).toBe('9780000000001');
expect(results[0].hardcoverEditionId).toBe('1001');
expect(results[1].isbn13).toBe('9780000000002'); expect(results[1].isbn13).toBe('9780000000002');
expect(results[1].hardcoverEditionId).toBe('1002');
}); });
it('uses edition authors when present', () => { it('uses edition authors when present', () => {
@@ -76,6 +76,7 @@ function mapEdition(edition: HardcoverEdition, book: HardcoverBookWithEditions):
return { return {
provider: MetadataProviderKey.HARDCOVER, provider: MetadataProviderKey.HARDCOVER,
providerId: book.slug, providerId: book.slug,
hardcoverEditionId: String(edition.id),
title: edition.title ?? book.title, title: edition.title ?? book.title,
subtitle: edition.subtitle ?? book.subtitle, subtitle: edition.subtitle ?? book.subtitle,
description: book.description, description: book.description,
@@ -45,6 +45,7 @@ export class ComicFormatExtractor implements FormatExtractor {
goodreadsId: comicMetadata?.goodreadsId ?? null, goodreadsId: comicMetadata?.goodreadsId ?? null,
amazonId: comicMetadata?.amazonId ?? null, amazonId: comicMetadata?.amazonId ?? null,
hardcoverId: comicMetadata?.hardcoverId ?? null, hardcoverId: comicMetadata?.hardcoverId ?? null,
hardcoverEditionId: comicMetadata?.hardcoverEditionId ?? null,
openLibraryId: comicMetadata?.openLibraryId ?? null, openLibraryId: comicMetadata?.openLibraryId ?? null,
ranobedbId: comicMetadata?.ranobedbId ?? null, ranobedbId: comicMetadata?.ranobedbId ?? null,
koboId: comicMetadata?.koboId ?? null, koboId: comicMetadata?.koboId ?? null,
@@ -19,6 +19,7 @@ export interface ParsedBookData {
goodreadsId?: string | null; goodreadsId?: string | null;
amazonId?: string | null; amazonId?: string | null;
hardcoverId?: string | null; hardcoverId?: string | null;
hardcoverEditionId?: string | null;
openLibraryId?: string | null; openLibraryId?: string | null;
ranobedbId?: string | null; ranobedbId?: string | null;
koboId?: string | null; koboId?: string | null;
@@ -130,6 +130,7 @@ describe('metadata format extractors', () => {
goodreadsId: '234248', goodreadsId: '234248',
amazonId: 'B00B7NPRY8', amazonId: 'B00B7NPRY8',
hardcoverId: null, hardcoverId: null,
hardcoverEditionId: '8941973',
openLibraryId: 'OL7353617M', openLibraryId: 'OL7353617M',
ranobedbId: 'ranobe-1', ranobedbId: 'ranobe-1',
itunesId: null, itunesId: null,
@@ -147,6 +148,7 @@ describe('metadata format extractors', () => {
googleBooksId: 'abc123', googleBooksId: 'abc123',
goodreadsId: '234248', goodreadsId: '234248',
amazonId: 'B00B7NPRY8', amazonId: 'B00B7NPRY8',
hardcoverEditionId: '8941973',
openLibraryId: 'OL7353617M', openLibraryId: 'OL7353617M',
ranobedbId: 'ranobe-1', ranobedbId: 'ranobe-1',
}), }),
@@ -174,6 +176,7 @@ describe('metadata format extractors', () => {
goodreadsId: null, goodreadsId: null,
amazonId: null, amazonId: null,
hardcoverId: null, hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null, openLibraryId: null,
ranobedbId: null, ranobedbId: null,
itunesId: null, itunesId: null,
@@ -415,6 +418,7 @@ describe('metadata format extractors', () => {
goodreadsId: null, goodreadsId: null,
amazonId: null, amazonId: null,
hardcoverId: null, hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null, openLibraryId: null,
ranobedbId: null, ranobedbId: null,
itunesId: null, itunesId: null,
@@ -22,6 +22,7 @@ export function hasOpfMetadata(metadata: ParsedOpf): boolean {
metadata.goodreadsId !== null || metadata.goodreadsId !== null ||
metadata.amazonId !== null || metadata.amazonId !== null ||
metadata.hardcoverId !== null || metadata.hardcoverId !== null ||
metadata.hardcoverEditionId !== null ||
metadata.openLibraryId !== null || metadata.openLibraryId !== null ||
metadata.ranobedbId !== null || metadata.ranobedbId !== null ||
metadata.koboId !== null || metadata.koboId !== null ||
@@ -52,6 +53,7 @@ export function mapOpfMetadata(metadata: ParsedOpf, cover: Buffer | null): Parse
goodreadsId: metadata.goodreadsId, goodreadsId: metadata.goodreadsId,
amazonId: metadata.amazonId, amazonId: metadata.amazonId,
hardcoverId: metadata.hardcoverId, hardcoverId: metadata.hardcoverId,
hardcoverEditionId: metadata.hardcoverEditionId,
openLibraryId: metadata.openLibraryId, openLibraryId: metadata.openLibraryId,
ranobedbId: metadata.ranobedbId, ranobedbId: metadata.ranobedbId,
koboId: metadata.koboId, koboId: metadata.koboId,
@@ -34,6 +34,7 @@ export class PdfFormatExtractor implements FormatExtractor {
goodreadsId: pdf.goodreadsId, goodreadsId: pdf.goodreadsId,
amazonId: pdf.amazonId, amazonId: pdf.amazonId,
hardcoverId: pdf.hardcoverId, hardcoverId: pdf.hardcoverId,
hardcoverEditionId: pdf.hardcoverEditionId,
openLibraryId: pdf.openLibraryId, openLibraryId: pdf.openLibraryId,
ranobedbId: pdf.ranobedbId, ranobedbId: pdf.ranobedbId,
koboId: pdf.koboId, koboId: pdf.koboId,
@@ -199,6 +199,7 @@ describe('extractCbzMetadata', () => {
Manual note Manual note
[bookorbit:ranobedbId] ranobe-1 [bookorbit:ranobedbId] ranobe-1
[bookorbit:googleBooksId] google-1 [bookorbit:googleBooksId] google-1
[bookorbit:hardcoverEditionId] 8941973
</Notes> </Notes>
</ComicInfo>`; </ComicInfo>`;
mockReadFile.mockResolvedValue(buildZipWithComicInfo(xml) as unknown as Buffer); mockReadFile.mockResolvedValue(buildZipWithComicInfo(xml) as unknown as Buffer);
@@ -206,6 +207,7 @@ describe('extractCbzMetadata', () => {
const r = await extractCbzMetadata('/book.cbz'); const r = await extractCbzMetadata('/book.cbz');
expect(r?.ranobedbId).toBe('ranobe-1'); expect(r?.ranobedbId).toBe('ranobe-1');
expect(r?.googleBooksId).toBe('google-1'); expect(r?.googleBooksId).toBe('google-1');
expect(r?.hardcoverEditionId).toBe('8941973');
}); });
}); });
@@ -43,6 +43,7 @@ export interface ParsedCbzMetadata {
goodreadsId: string | null; goodreadsId: string | null;
amazonId: string | null; amazonId: string | null;
hardcoverId: string | null; hardcoverId: string | null;
hardcoverEditionId: string | null;
openLibraryId: string | null; openLibraryId: string | null;
ranobedbId: string | null; ranobedbId: string | null;
koboId: string | null; koboId: string | null;
@@ -266,6 +267,7 @@ function parseComicInfoXml(xmlBuf: Buffer): ParsedCbzMetadata | null {
goodreadsId: managedNotes.get('goodreadsId') ?? providerIdsFromWeb.goodreadsId ?? null, goodreadsId: managedNotes.get('goodreadsId') ?? providerIdsFromWeb.goodreadsId ?? null,
amazonId: managedNotes.get('amazonId') ?? providerIdsFromWeb.amazonId ?? null, amazonId: managedNotes.get('amazonId') ?? providerIdsFromWeb.amazonId ?? null,
hardcoverId: managedNotes.get('hardcoverId') ?? providerIdsFromWeb.hardcoverId ?? null, hardcoverId: managedNotes.get('hardcoverId') ?? providerIdsFromWeb.hardcoverId ?? null,
hardcoverEditionId: managedNotes.get('hardcoverEditionId') ?? null,
openLibraryId: managedNotes.get('openLibraryId') ?? providerIdsFromWeb.openLibraryId ?? null, openLibraryId: managedNotes.get('openLibraryId') ?? providerIdsFromWeb.openLibraryId ?? null,
ranobedbId: managedNotes.get('ranobedbId') ?? null, ranobedbId: managedNotes.get('ranobedbId') ?? null,
koboId: managedNotes.get('koboId') ?? providerIdsFromWeb.koboId ?? null, koboId: managedNotes.get('koboId') ?? providerIdsFromWeb.koboId ?? null,
@@ -325,6 +327,7 @@ function parseComicBookInfoJson(comment: string): ParsedCbzMetadata | null {
goodreadsId: null, goodreadsId: null,
amazonId: null, amazonId: null,
hardcoverId: null, hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null, openLibraryId: null,
ranobedbId: null, ranobedbId: null,
koboId: null, koboId: null,
@@ -384,6 +384,12 @@ describe('parseOpf', () => {
expect(r.hardcoverId).toBe('new-orleans-rush'); expect(r.hardcoverId).toBe('new-orleans-rush');
}); });
it('parses Hardcover edition ID from opf:scheme, urn, and prefix formats', () => {
expect(parseOpf(epub2Opf(`<dc:identifier opf:scheme="HARDCOVER_EDITION">8941973</dc:identifier>`)).hardcoverEditionId).toBe('8941973');
expect(parseOpf(epub2Opf(`<dc:identifier>urn:hardcover_edition:8941974</dc:identifier>`)).hardcoverEditionId).toBe('8941974');
expect(parseOpf(epub3Opf(`<dc:identifier>hardcover-edition:8941975</dc:identifier>`)).hardcoverEditionId).toBe('8941975');
});
it('parses iTunes ID from opf:scheme attribute', () => { it('parses iTunes ID from opf:scheme attribute', () => {
const xml = epub2Opf(`<dc:identifier opf:scheme="ITUNES">123456789</dc:identifier>`); const xml = epub2Opf(`<dc:identifier opf:scheme="ITUNES">123456789</dc:identifier>`);
const r = parseOpf(xml); const r = parseOpf(xml);
@@ -461,6 +467,7 @@ describe('parseOpf', () => {
expect(r.amazonId).toBeNull(); expect(r.amazonId).toBeNull();
expect(r.goodreadsId).toBeNull(); expect(r.goodreadsId).toBeNull();
expect(r.hardcoverId).toBeNull(); expect(r.hardcoverId).toBeNull();
expect(r.hardcoverEditionId).toBeNull();
expect(r.openLibraryId).toBeNull(); expect(r.openLibraryId).toBeNull();
expect(r.ranobedbId).toBeNull(); expect(r.ranobedbId).toBeNull();
expect(r.itunesId).toBeNull(); expect(r.itunesId).toBeNull();
@@ -474,6 +481,7 @@ describe('parseOpf', () => {
<dc:identifier>google:ABCD1234</dc:identifier> <dc:identifier>google:ABCD1234</dc:identifier>
<dc:identifier>openlibrary:OL99999999W</dc:identifier> <dc:identifier>openlibrary:OL99999999W</dc:identifier>
<dc:identifier>hardcover:test-book-slug</dc:identifier> <dc:identifier>hardcover:test-book-slug</dc:identifier>
<dc:identifier>hardcover_edition:8941973</dc:identifier>
<dc:identifier>kobo:test-kobo-id</dc:identifier> <dc:identifier>kobo:test-kobo-id</dc:identifier>
<dc:identifier>itunes:987654321</dc:identifier> <dc:identifier>itunes:987654321</dc:identifier>
<dc:identifier>lubimyczytac:lub-99999</dc:identifier> <dc:identifier>lubimyczytac:lub-99999</dc:identifier>
@@ -485,6 +493,7 @@ describe('parseOpf', () => {
expect(r.googleBooksId).toBe('ABCD1234'); expect(r.googleBooksId).toBe('ABCD1234');
expect(r.openLibraryId).toBe('OL99999999W'); expect(r.openLibraryId).toBe('OL99999999W');
expect(r.hardcoverId).toBe('test-book-slug'); expect(r.hardcoverId).toBe('test-book-slug');
expect(r.hardcoverEditionId).toBe('8941973');
expect(r.koboId).toBe('test-kobo-id'); expect(r.koboId).toBe('test-kobo-id');
expect(r.itunesId).toBe('987654321'); expect(r.itunesId).toBe('987654321');
expect(r.lubimyczytacId).toBe('lub-99999'); expect(r.lubimyczytacId).toBe('lub-99999');
+25 -1
View File
@@ -20,6 +20,7 @@ export interface ParsedOpf {
goodreadsId: string | null; goodreadsId: string | null;
amazonId: string | null; amazonId: string | null;
hardcoverId: string | null; hardcoverId: string | null;
hardcoverEditionId: string | null;
openLibraryId: string | null; openLibraryId: string | null;
ranobedbId: string | null; ranobedbId: string | null;
koboId: string | null; koboId: string | null;
@@ -88,7 +89,18 @@ function parseBookOrbitTags(raw: string | null): string[] {
} }
} }
type ProviderKey = 'google' | 'amazon' | 'goodreads' | 'hardcover' | 'openlibrary' | 'ranobedb' | 'kobo' | 'lubimyczytac' | 'aladin' | 'itunes'; type ProviderKey =
| 'google'
| 'amazon'
| 'goodreads'
| 'hardcover'
| 'hardcoverEdition'
| 'openlibrary'
| 'ranobedb'
| 'kobo'
| 'lubimyczytac'
| 'aladin'
| 'itunes';
// Calibre 9.x (opf3) writes provider identifiers as bare `prefix:value` text inside <dc:identifier>. // Calibre 9.x (opf3) writes provider identifiers as bare `prefix:value` text inside <dc:identifier>.
// Only these known prefixes are recognized, as a lowest-priority fallback after opf:scheme and urn:. // Only these known prefixes are recognized, as a lowest-priority fallback after opf:scheme and urn:.
@@ -100,6 +112,9 @@ const PREFIX_TO_PROVIDER: Record<string, ProviderKey> = {
google: 'google', google: 'google',
openlibrary: 'openlibrary', openlibrary: 'openlibrary',
hardcover: 'hardcover', hardcover: 'hardcover',
hardcover_edition: 'hardcoverEdition',
'hardcover-edition': 'hardcoverEdition',
hardcoveredition: 'hardcoverEdition',
kobo: 'kobo', kobo: 'kobo',
itunes: 'itunes', itunes: 'itunes',
lubimyczytac: 'lubimyczytac', lubimyczytac: 'lubimyczytac',
@@ -261,6 +276,7 @@ export function parseOpf(xml: string): ParsedOpf {
let schemeGoodreadsId: string | null = null; let schemeGoodreadsId: string | null = null;
let schemeAmazonId: string | null = null; let schemeAmazonId: string | null = null;
let schemeHardcoverId: string | null = null; let schemeHardcoverId: string | null = null;
let schemeHardcoverEditionId: string | null = null;
let schemeOpenLibraryId: string | null = null; let schemeOpenLibraryId: string | null = null;
let schemeRanobedbId: string | null = null; let schemeRanobedbId: string | null = null;
let schemeKoboId: string | null = null; let schemeKoboId: string | null = null;
@@ -272,6 +288,7 @@ export function parseOpf(xml: string): ParsedOpf {
let urnGoodreadsId: string | null = null; let urnGoodreadsId: string | null = null;
let urnAmazonId: string | null = null; let urnAmazonId: string | null = null;
let urnHardcoverId: string | null = null; let urnHardcoverId: string | null = null;
let urnHardcoverEditionId: string | null = null;
let urnOpenLibraryId: string | null = null; let urnOpenLibraryId: string | null = null;
let urnRanobedbId: string | null = null; let urnRanobedbId: string | null = null;
let urnKoboId: string | null = null; let urnKoboId: string | null = null;
@@ -300,6 +317,9 @@ export function parseOpf(xml: string): ParsedOpf {
if (scheme === 'amazon') schemeAmazonId ??= value || null; if (scheme === 'amazon') schemeAmazonId ??= value || null;
if (scheme === 'goodreads') schemeGoodreadsId ??= value || null; if (scheme === 'goodreads') schemeGoodreadsId ??= value || null;
if (scheme === 'hardcover') schemeHardcoverId ??= value || null; if (scheme === 'hardcover') schemeHardcoverId ??= value || null;
if (scheme === 'hardcover_edition' || scheme === 'hardcover-edition' || scheme === 'hardcoveredition') {
schemeHardcoverEditionId ??= value || null;
}
if (scheme === 'openlibrary') schemeOpenLibraryId ??= value || null; if (scheme === 'openlibrary') schemeOpenLibraryId ??= value || null;
if (scheme === 'ranobedb') schemeRanobedbId ??= value || null; if (scheme === 'ranobedb') schemeRanobedbId ??= value || null;
if (scheme === 'kobo') schemeKoboId ??= value || null; if (scheme === 'kobo') schemeKoboId ??= value || null;
@@ -311,6 +331,8 @@ export function parseOpf(xml: string): ParsedOpf {
if (value.startsWith('urn:goodreads:')) urnGoodreadsId ??= value.slice('urn:goodreads:'.length) || null; if (value.startsWith('urn:goodreads:')) urnGoodreadsId ??= value.slice('urn:goodreads:'.length) || null;
if (value.startsWith('urn:amazon:')) urnAmazonId ??= value.slice('urn:amazon:'.length) || null; if (value.startsWith('urn:amazon:')) urnAmazonId ??= value.slice('urn:amazon:'.length) || null;
if (value.startsWith('urn:hardcover:')) urnHardcoverId ??= value.slice('urn:hardcover:'.length) || null; if (value.startsWith('urn:hardcover:')) urnHardcoverId ??= value.slice('urn:hardcover:'.length) || null;
if (value.startsWith('urn:hardcover_edition:')) urnHardcoverEditionId ??= value.slice('urn:hardcover_edition:'.length) || null;
if (value.startsWith('urn:hardcover-edition:')) urnHardcoverEditionId ??= value.slice('urn:hardcover-edition:'.length) || null;
if (value.startsWith('urn:google:')) urnGoogleBooksId ??= value.slice('urn:google:'.length) || null; if (value.startsWith('urn:google:')) urnGoogleBooksId ??= value.slice('urn:google:'.length) || null;
if (value.startsWith('urn:openlibrary:')) urnOpenLibraryId ??= value.slice('urn:openlibrary:'.length) || null; if (value.startsWith('urn:openlibrary:')) urnOpenLibraryId ??= value.slice('urn:openlibrary:'.length) || null;
if (value.startsWith('urn:ranobedb:')) urnRanobedbId ??= value.slice('urn:ranobedb:'.length) || null; if (value.startsWith('urn:ranobedb:')) urnRanobedbId ??= value.slice('urn:ranobedb:'.length) || null;
@@ -337,6 +359,7 @@ export function parseOpf(xml: string): ParsedOpf {
const goodreadsId = schemeGoodreadsId ?? urnGoodreadsId ?? prefixIds.goodreads ?? null; const goodreadsId = schemeGoodreadsId ?? urnGoodreadsId ?? prefixIds.goodreads ?? null;
const amazonId = schemeAmazonId ?? urnAmazonId ?? prefixIds.amazon ?? null; const amazonId = schemeAmazonId ?? urnAmazonId ?? prefixIds.amazon ?? null;
const hardcoverId = schemeHardcoverId ?? urnHardcoverId ?? prefixIds.hardcover ?? null; const hardcoverId = schemeHardcoverId ?? urnHardcoverId ?? prefixIds.hardcover ?? null;
const hardcoverEditionId = schemeHardcoverEditionId ?? urnHardcoverEditionId ?? prefixIds.hardcoverEdition ?? null;
const openLibraryId = schemeOpenLibraryId ?? urnOpenLibraryId ?? prefixIds.openlibrary ?? null; const openLibraryId = schemeOpenLibraryId ?? urnOpenLibraryId ?? prefixIds.openlibrary ?? null;
const ranobedbId = schemeRanobedbId ?? urnRanobedbId ?? prefixIds.ranobedb ?? null; const ranobedbId = schemeRanobedbId ?? urnRanobedbId ?? prefixIds.ranobedb ?? null;
const koboId = schemeKoboId ?? urnKoboId ?? prefixIds.kobo ?? null; const koboId = schemeKoboId ?? urnKoboId ?? prefixIds.kobo ?? null;
@@ -461,6 +484,7 @@ export function parseOpf(xml: string): ParsedOpf {
goodreadsId, goodreadsId,
amazonId, amazonId,
hardcoverId, hardcoverId,
hardcoverEditionId,
openLibraryId, openLibraryId,
ranobedbId, ranobedbId,
koboId, koboId,
@@ -37,6 +37,7 @@ describe('parsePdfFileInWorker', () => {
goodreadsId: null, goodreadsId: null,
amazonId: null, amazonId: null,
hardcoverId: null, hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null, openLibraryId: null,
ranobedbId: null, ranobedbId: null,
koboId: null, koboId: null,
@@ -73,6 +73,7 @@ function makeParsed(overrides: Partial<PdfParsed> = {}): PdfParsed {
goodreadsId: null, goodreadsId: null,
amazonId: null, amazonId: null,
hardcoverId: null, hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null, openLibraryId: null,
ranobedbId: null, ranobedbId: null,
koboId: null, koboId: null,
@@ -119,6 +120,7 @@ describe('parsePdfFile', () => {
goodreadsId: 'gr1', goodreadsId: 'gr1',
amazonId: 'a1', amazonId: 'a1',
hardcoverId: 'h1', hardcoverId: 'h1',
hardcoverEditionId: '8941973',
openLibraryId: 'ol1', openLibraryId: 'ol1',
ranobedbId: 'ranobe-1', ranobedbId: 'ranobe-1',
itunesId: 'it1', itunesId: 'it1',
@@ -131,6 +133,7 @@ describe('parsePdfFile', () => {
title: 'XMP Title', title: 'XMP Title',
authors: [{ name: 'XMP Author', sortName: null }], authors: [{ name: 'XMP Author', sortName: null }],
tags: ['favorite'], tags: ['favorite'],
hardcoverEditionId: '8941973',
ranobedbId: 'ranobe-1', ranobedbId: 'ranobe-1',
pageCount: 999, pageCount: 999,
coverBuffer: Buffer.from([0xff, 0xd8, 0xff]), coverBuffer: Buffer.from([0xff, 0xd8, 0xff]),
@@ -277,6 +280,7 @@ describe('parsePdfFile', () => {
goodreadsId: null, goodreadsId: null,
amazonId: null, amazonId: null,
hardcoverId: null, hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null, openLibraryId: null,
ranobedbId: null, ranobedbId: null,
itunesId: null, itunesId: null,
@@ -315,6 +319,7 @@ describe('parsePdfFile', () => {
goodreadsId: null, goodreadsId: null,
amazonId: null, amazonId: null,
hardcoverId: null, hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null, openLibraryId: null,
ranobedbId: null, ranobedbId: null,
itunesId: null, itunesId: null,
@@ -27,6 +27,7 @@ export interface PdfParsed {
goodreadsId: string | null; goodreadsId: string | null;
amazonId: string | null; amazonId: string | null;
hardcoverId: string | null; hardcoverId: string | null;
hardcoverEditionId: string | null;
openLibraryId: string | null; openLibraryId: string | null;
ranobedbId: string | null; ranobedbId: string | null;
koboId: string | null; koboId: string | null;
@@ -158,6 +159,7 @@ export async function parsePdfBuffer(absolutePath: string, buf: Buffer, options:
goodreadsId: xmp?.goodreadsId ?? null, goodreadsId: xmp?.goodreadsId ?? null,
amazonId: xmp?.amazonId ?? null, amazonId: xmp?.amazonId ?? null,
hardcoverId: xmp?.hardcoverId ?? null, hardcoverId: xmp?.hardcoverId ?? null,
hardcoverEditionId: xmp?.hardcoverEditionId ?? null,
openLibraryId: xmp?.openLibraryId ?? null, openLibraryId: xmp?.openLibraryId ?? null,
ranobedbId: xmp?.ranobedbId ?? null, ranobedbId: xmp?.ranobedbId ?? null,
koboId: xmp?.koboId ?? null, koboId: xmp?.koboId ?? null,
@@ -192,6 +192,11 @@ describe('parseXmp', () => {
const r = parseXmp(xmpDoc('<bookorbit:amazonId>B001234567</bookorbit:amazonId>')); const r = parseXmp(xmpDoc('<bookorbit:amazonId>B001234567</bookorbit:amazonId>'));
expect(r?.amazonId).toBe('B001234567'); expect(r?.amazonId).toBe('B001234567');
}); });
it('parses bookorbit:hardcoverEditionId', () => {
const r = parseXmp(xmpDoc('<bookorbit:hardcoverEditionId>8941973</bookorbit:hardcoverEditionId>'));
expect(r?.hardcoverEditionId).toBe('8941973');
});
}); });
describe('multiple rdf:Description blocks', () => { describe('multiple rdf:Description blocks', () => {
@@ -23,6 +23,7 @@ export interface XmpParsed {
goodreadsId: string | null; goodreadsId: string | null;
amazonId: string | null; amazonId: string | null;
hardcoverId: string | null; hardcoverId: string | null;
hardcoverEditionId: string | null;
openLibraryId: string | null; openLibraryId: string | null;
ranobedbId: string | null; ranobedbId: string | null;
koboId: string | null; koboId: string | null;
@@ -158,6 +159,7 @@ export function parseXmp(xmpXml: string): XmpParsed | null {
goodreadsId: str(merged[`${px}:goodreadsId`]), goodreadsId: str(merged[`${px}:goodreadsId`]),
amazonId: str(merged[`${px}:amazonId`]), amazonId: str(merged[`${px}:amazonId`]),
hardcoverId: str(merged[`${px}:hardcoverId`]), hardcoverId: str(merged[`${px}:hardcoverId`]),
hardcoverEditionId: str(merged[`${px}:hardcoverEditionId`]),
openLibraryId: str(merged[`${px}:openLibraryId`]), openLibraryId: str(merged[`${px}:openLibraryId`]),
ranobedbId: str(merged[`${px}:ranobedbId`]), ranobedbId: str(merged[`${px}:ranobedbId`]),
koboId: str(merged[`${px}:koboId`]), koboId: str(merged[`${px}:koboId`]),
@@ -289,6 +289,7 @@ describe('MetadataService', () => {
goodreadsId: null, goodreadsId: null,
amazonId: null, amazonId: null,
hardcoverId: null, hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null, openLibraryId: null,
ranobedbId: null, ranobedbId: null,
itunesId: null, itunesId: null,
@@ -566,6 +567,7 @@ describe('MetadataService', () => {
goodreadsId: 'goodreads-1', goodreadsId: 'goodreads-1',
amazonId: 'amazon-1', amazonId: 'amazon-1',
hardcoverId: 'hardcover-1', hardcoverId: 'hardcover-1',
hardcoverEditionId: '8941973',
openLibraryId: 'open-library-1', openLibraryId: 'open-library-1',
ranobedbId: 'ranobe-1', ranobedbId: 'ranobe-1',
itunesId: 'itunes-1', itunesId: 'itunes-1',
@@ -595,6 +597,7 @@ describe('MetadataService', () => {
goodreadsId: 'goodreads-1', goodreadsId: 'goodreads-1',
amazonId: 'amazon-1', amazonId: 'amazon-1',
hardcoverId: 'hardcover-1', hardcoverId: 'hardcover-1',
hardcoverEditionId: '8941973',
openLibraryId: 'open-library-1', openLibraryId: 'open-library-1',
ranobedbId: 'ranobe-1', ranobedbId: 'ranobe-1',
itunesId: 'itunes-1', itunesId: 'itunes-1',
@@ -587,6 +587,7 @@ export class MetadataService {
goodreadsId: data.goodreadsId, goodreadsId: data.goodreadsId,
amazonId: data.amazonId, amazonId: data.amazonId,
hardcoverId: data.hardcoverId, hardcoverId: data.hardcoverId,
hardcoverEditionId: data.hardcoverEditionId,
openLibraryId: data.openLibraryId, openLibraryId: data.openLibraryId,
ranobedbId: data.ranobedbId, ranobedbId: data.ranobedbId,
koboId: data.koboId, koboId: data.koboId,
@@ -613,6 +614,7 @@ export class MetadataService {
if (filtered.goodreadsId !== undefined) scalarFields.goodreadsId = filtered.goodreadsId; if (filtered.goodreadsId !== undefined) scalarFields.goodreadsId = filtered.goodreadsId;
if (filtered.amazonId !== undefined) scalarFields.amazonId = filtered.amazonId; if (filtered.amazonId !== undefined) scalarFields.amazonId = filtered.amazonId;
if (filtered.hardcoverId !== undefined) scalarFields.hardcoverId = filtered.hardcoverId; if (filtered.hardcoverId !== undefined) scalarFields.hardcoverId = filtered.hardcoverId;
if (filtered.hardcoverEditionId !== undefined) scalarFields.hardcoverEditionId = filtered.hardcoverEditionId;
if (filtered.openLibraryId !== undefined) scalarFields.openLibraryId = filtered.openLibraryId; if (filtered.openLibraryId !== undefined) scalarFields.openLibraryId = filtered.openLibraryId;
if (filtered.ranobedbId !== undefined) scalarFields.ranobedbId = filtered.ranobedbId; if (filtered.ranobedbId !== undefined) scalarFields.ranobedbId = filtered.ranobedbId;
if (filtered.koboId !== undefined) scalarFields.koboId = filtered.koboId; if (filtered.koboId !== undefined) scalarFields.koboId = filtered.koboId;
@@ -548,6 +548,8 @@ export class ScannerRepository {
publisher: bookMetadata.publisher, publisher: bookMetadata.publisher,
pageCount: bookMetadata.pageCount, pageCount: bookMetadata.pageCount,
isbn13: bookMetadata.isbn13, isbn13: bookMetadata.isbn13,
hardcoverId: bookMetadata.hardcoverId,
hardcoverEditionId: bookMetadata.hardcoverEditionId,
}) })
.from(books) .from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) .leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))