From 329d1b6643ce22aafb595377ec5bce1033bbd726 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:59:53 +0000 Subject: [PATCH] feat(metadata): add Kobo metadata provider Add Kobo as a first-class metadata source so books can keep Kobo IDs across fetch, edit, import, scoring, and file-write flows. Provide the Python cloudscraper setup needed for production fetches while keeping local setup explicit. Closes #247 --- .gitignore | 1 + Dockerfile | 8 +- client/public/assets/provider-icons/kobo.svg | 9 + .../components/__tests__/DetailsTab.spec.ts | 5 + .../components/detail/tabs/DetailsTab.vue | 9 + .../detail/tabs/EditMetadataTab.vue | 3 + .../__tests__/useMetadataEditor.spec.ts | 17 + .../book/composables/useFileMetadata.ts | 1 + .../book/composables/useMetadataDiff.ts | 4 + .../book/composables/useMetadataEditor.ts | 3 + .../book/composables/useMetadataSearch.ts | 1 + .../book/composables/useRefreshMetadata.ts | 1 + .../features/book/lib/file-metadata-patch.ts | 1 + .../src/features/book/lib/provider-icons.ts | 1 + .../components/ProviderConfigPanel.vue | 26 + .../src/lib/__tests__/provider-colors.spec.ts | 17 +- client/src/lib/provider-colors.ts | 2 + docs/DEVELOPMENT.md | 9 +- packages/types/src/file-write.ts | 2 + packages/types/src/metadata-fetch.ts | 1 + packages/types/src/metadata-lock.ts | 3 +- packages/types/src/metadata-preferences.ts | 7 + packages/types/src/metadata-score.ts | 4 +- server/package.json | 1 + server/requirements/kobo-cloudscraper.txt | 1 + server/src/config/config.test.ts | 4 + server/src/config/config.ts | 1 + server/src/config/env.validation.ts | 4 + server/src/db/migrations/0015_add_kobo_id.sql | 1 + .../src/db/migrations/meta/0015_snapshot.json | 10581 ++++++++++++++++ server/src/db/migrations/meta/_journal.json | 7 + server/src/db/schema/metadata.ts | 1 + ...etadata-fetch-orchestrator.service.test.ts | 2 + ...ook-metadata-fetch-orchestrator.service.ts | 3 + .../book-metadata-lock.service.ts | 3 + server/src/modules/book/book.service.test.ts | 3 + server/src/modules/book/book.service.ts | 20 +- .../book/dto/update-book-metadata.dto.ts | 1 + .../providers/itunes-cover-provider.test.ts | 1 + .../file-write/file-write.constants.ts | 4 + .../file-write/file-write.repository.ts | 1 + .../book-write-payload.interface.ts | 1 + .../metadata-fetch-pipeline.test.ts | 56 +- .../metadata-fetch/metadata-fetch-pipeline.ts | 13 +- .../metadata-fetch.controller.test.ts | 81 +- .../metadata-fetch.controller.ts | 19 +- .../metadata-fetch.module.test.ts | 3 + .../metadata-fetch/metadata-fetch.module.ts | 2 + .../metadata-fetch.repository.ts | 2 + .../metadata-fetch.service.test.ts | 10 + .../metadata-fetch/metadata-fetch.service.ts | 10 +- .../providers/amazon/amazon.provider.test.ts | 1 + .../audible/audible.provider.test.ts | 1 + .../audnexus/audnexus.provider.test.ts | 1 + .../comicvine/comicvine.provider.test.ts | 2 + .../goodreads/goodreads.provider.test.ts | 1 + .../providers/google/google.provider.test.ts | 1 + .../hardcover/hardcover.provider.test.ts | 1 + .../providers/itunes/itunes.provider.test.ts | 1 + .../kobo/kobo-cloudscraper.fetcher.test.ts | 117 + .../kobo/kobo-cloudscraper.fetcher.ts | 288 + .../providers/kobo/kobo.provider.test.ts | 200 + .../providers/kobo/kobo.provider.ts | 247 + .../providers/kobo/kobo.scraper.test.ts | 140 + .../providers/kobo/kobo.scraper.ts | 375 + .../providers/metadata-provider.ts | 1 + .../open-library.provider.test.ts | 1 + .../providers/provider-constants.ts | 3 + .../ranobedb/ranobedb.provider.test.ts | 1 + .../dto/update-provider-config.dto.ts | 7 + .../metadata-preference-resolver.test.ts | 10 +- .../metadata-preference-resolver.ts | 5 +- .../provider-config.service.test.ts | 4 + .../provider-config.service.ts | 33 + .../metadata-score/dto/update-weights.dto.ts | 1 + .../metadata-score.controller.test.ts | 1 + .../metadata-score.repository.ts | 1 + .../metadata-score.scorer.test.ts | 3 + .../metadata-score/metadata-score.scorer.ts | 2 + .../metadata-score.service.test.ts | 5 + .../extractors/comic-format.extractor.ts | 1 + .../extractors/format-extractor.interface.ts | 1 + .../extractors/opf-metadata.mapper.ts | 2 + .../extractors/pdf-format.extractor.ts | 1 + .../src/modules/metadata/lib/cbz-metadata.ts | 8 +- server/src/modules/metadata/lib/opf-parser.ts | 7 + server/src/modules/metadata/lib/pdf-parser.ts | 2 + .../modules/metadata/lib/pdf-xmp-reader.ts | 2 + .../src/modules/metadata/metadata.service.ts | 2 + .../adapters/source-adapter.types.ts | 1 + .../migration/executor/executor-utils.test.ts | 18 + .../migration/executor/executor-utils.ts | 2 + .../migration-import.repository.test.ts | 2 + .../executor/migration-import.repository.ts | 1 + .../statistics/statistics.repository.ts | 4 +- server/src/scripts/setup-kobo-cloudscraper.ts | 59 + 96 files changed, 12509 insertions(+), 29 deletions(-) create mode 100644 client/public/assets/provider-icons/kobo.svg create mode 100644 server/requirements/kobo-cloudscraper.txt create mode 100644 server/src/db/migrations/0015_add_kobo_id.sql create mode 100644 server/src/db/migrations/meta/0015_snapshot.json create mode 100644 server/src/modules/metadata-fetch/providers/kobo/kobo-cloudscraper.fetcher.test.ts create mode 100644 server/src/modules/metadata-fetch/providers/kobo/kobo-cloudscraper.fetcher.ts create mode 100644 server/src/modules/metadata-fetch/providers/kobo/kobo.provider.test.ts create mode 100644 server/src/modules/metadata-fetch/providers/kobo/kobo.provider.ts create mode 100644 server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.test.ts create mode 100644 server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.ts create mode 100644 server/src/scripts/setup-kobo-cloudscraper.ts diff --git a/.gitignore b/.gitignore index dc7305a7a..db31a2892 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ .env.local client/.env server/.env +server/.venv/ *.local data diff --git a/Dockerfile b/Dockerfile index f4c248d31..c1883e9e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,9 +42,15 @@ WORKDIR /app ARG APP_VERSION=dev ENV APP_VERSION=${APP_VERSION} +ENV KOBO_CLOUDSCRAPER_PYTHON=/opt/bookorbit-python/bin/python + +COPY server/requirements/kobo-cloudscraper.txt /tmp/kobo-cloudscraper-requirements.txt RUN apk upgrade --no-cache && \ - apk add --no-cache poppler-utils su-exec ffmpeg && \ + apk add --no-cache poppler-utils su-exec ffmpeg python3 py3-pip && \ + python3 -m venv /opt/bookorbit-python && \ + /opt/bookorbit-python/bin/python -m pip install --no-cache-dir -r /tmp/kobo-cloudscraper-requirements.txt && \ + rm -f /tmp/kobo-cloudscraper-requirements.txt && \ rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx ENV NODE_ENV=production diff --git a/client/public/assets/provider-icons/kobo.svg b/client/public/assets/provider-icons/kobo.svg new file mode 100644 index 000000000..9a8829266 --- /dev/null +++ b/client/public/assets/provider-icons/kobo.svg @@ -0,0 +1,9 @@ + + Kobo + + + + diff --git a/client/src/features/book/components/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/__tests__/DetailsTab.spec.ts index 06d636c1c..819d81b98 100644 --- a/client/src/features/book/components/__tests__/DetailsTab.spec.ts +++ b/client/src/features/book/components/__tests__/DetailsTab.spec.ts @@ -156,6 +156,7 @@ describe('DetailsTab - present state', () => { providerIds: { amazon: '0345415000', goodreads: '12345', + kobo: 'beautiful-ugly-3', ranobedb: '1287', }, }), @@ -165,6 +166,10 @@ describe('DetailsTab - present state', () => { expect(wrapper.find('a[title="Open in Amazon"]').exists()).toBe(true) expect(wrapper.find('a[title="Open in Goodreads"]').exists()).toBe(true) + const koboLink = wrapper.find('a[title="Open in Kobo"]') + expect(koboLink.exists()).toBe(true) + expect(koboLink.attributes('href')).toBe('https://www.kobo.com/us/en/ebook/beautiful-ugly-3') + expect(koboLink.find('img[alt="Kobo"][src="/assets/provider-icons/kobo.svg"]').exists()).toBe(true) const ranobedbLink = wrapper.find('a[title="Open in RanobeDB"]') expect(ranobedbLink.exists()).toBe(true) expect(ranobedbLink.attributes('href')).toBe('https://ranobedb.org/book/1287') diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index 1ea525ea1..448c46518 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -580,6 +580,15 @@ const providerLinks = computed(() => { fallback: 'Au', }) } + if (ids.kobo) { + out.push({ + key: 'kobo', + label: 'Kobo', + url: `https://www.kobo.com/us/en/ebook/${encodeURIComponent(ids.kobo)}`, + iconUrl: providerIconPath('kobo'), + fallback: 'K', + }) + } if (ids.ranobedb) { out.push({ key: 'ranobedb', diff --git a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue index 3a6516b4d..320453504 100644 --- a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue +++ b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue @@ -67,6 +67,7 @@ const DIRECT_PATCH_FIELDS = [ 'openLibraryId', 'itunesId', 'audibleId', + 'koboId', 'comicvineId', 'ranobedbId', ] as const @@ -162,6 +163,7 @@ const providerIdFields = [ { field: 'openLibraryId' as const, label: 'OpenLibrary' }, { field: 'itunesId' as const, label: 'iTunes' }, { field: 'audibleId' as const, label: 'Audible' }, + { field: 'koboId' as const, label: 'Kobo' }, { field: 'comicvineId' as const, label: 'ComicVine' }, { field: 'ranobedbId' as const, label: 'RanobeDB' }, ] @@ -413,6 +415,7 @@ function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch { openLibraryId: preview.openLibraryId, itunesId: preview.itunesId, audibleId: preview.audibleId, + koboId: preview.koboId, comicvineId: preview.comicvineId, ranobedbId: preview.ranobedbId, comicMetadata: preview.comicMetadata, diff --git a/client/src/features/book/composables/__tests__/useMetadataEditor.spec.ts b/client/src/features/book/composables/__tests__/useMetadataEditor.spec.ts index fa5ca8015..88ac07428 100644 --- a/client/src/features/book/composables/__tests__/useMetadataEditor.spec.ts +++ b/client/src/features/book/composables/__tests__/useMetadataEditor.spec.ts @@ -169,6 +169,23 @@ describe('useMetadataEditor', () => { }) }) + it('loads and saves Kobo provider IDs', async () => { + const book = makeBook({ providerIds: { kobo: 'old-kobo-id' } }) + apiMock.mockResolvedValue({ ok: true, json: async () => ({ ...book, providerIds: { kobo: 'new-kobo-id' } }) }) + + const { form, load, save } = useMetadataEditor() + load(book) + expect(form.koboId).toBe('old-kobo-id') + + form.koboId = 'new-kobo-id' + await save(book.id) + + const [, req] = apiMock.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(String(req.body))).toEqual({ + koboId: 'new-kobo-id', + }) + }) + it('can save lock-only changes through the atomic endpoint without metadata fields', async () => { const book = makeBook() apiMock.mockResolvedValue({ ok: true, json: async () => ({ ...book, lockedFields: ['title'] }) }) diff --git a/client/src/features/book/composables/useFileMetadata.ts b/client/src/features/book/composables/useFileMetadata.ts index 58a5796c9..f3a9c4ed8 100644 --- a/client/src/features/book/composables/useFileMetadata.ts +++ b/client/src/features/book/composables/useFileMetadata.ts @@ -21,6 +21,7 @@ export interface FileMetadata { openLibraryId?: string | null itunesId?: string | null audibleId?: string | null + koboId?: string | null comicvineId?: string | null ranobedbId?: string | null authors?: string[] diff --git a/client/src/features/book/composables/useMetadataDiff.ts b/client/src/features/book/composables/useMetadataDiff.ts index 412e6a60f..504e3bdb1 100644 --- a/client/src/features/book/composables/useMetadataDiff.ts +++ b/client/src/features/book/composables/useMetadataDiff.ts @@ -93,6 +93,7 @@ export interface MetadataPatch { openLibraryId?: string | null itunesId?: string | null audibleId?: string | null + koboId?: string | null comicvineId?: string | null ranobedbId?: string | null comicMetadata?: ComicMetadataFields @@ -154,6 +155,7 @@ export type ProviderIdPatchField = | 'openLibraryId' | 'itunesId' | 'audibleId' + | 'koboId' | 'comicvineId' | 'ranobedbId' @@ -168,6 +170,7 @@ export const PROVIDER_ID_FIELD: Record(Object.values(PROVIDER_ID_FIELD).filter((v): v is ProviderIdPatchField => v !== undefined)) @@ -187,6 +190,7 @@ export const PROVIDER_ID_LABEL: Record = { audnexus: 'AudNexus ID', comicvine: 'ComicVine ID', ranobedb: 'RanobeDB ID', + kobo: 'Kobo ID', } export function getCandidateValueFrom(candidate: MetadataCandidate, key: DiffFieldKey): string { diff --git a/client/src/features/book/composables/useMetadataEditor.ts b/client/src/features/book/composables/useMetadataEditor.ts index 28347ca55..c8868572e 100644 --- a/client/src/features/book/composables/useMetadataEditor.ts +++ b/client/src/features/book/composables/useMetadataEditor.ts @@ -25,6 +25,7 @@ const ROOT_FIELDS = [ 'openLibraryId', 'itunesId', 'audibleId', + 'koboId', 'comicvineId', 'ranobedbId', ] as const @@ -79,6 +80,7 @@ export function useMetadataEditor() { openLibraryId: null as string | null, itunesId: null as string | null, audibleId: null as string | null, + koboId: null as string | null, comicvineId: null as string | null, ranobedbId: null as string | null, comicIssueNumber: null as string | null, @@ -125,6 +127,7 @@ export function useMetadataEditor() { form.openLibraryId = book.providerIds.openLibrary ?? null form.itunesId = book.providerIds.itunes ?? null form.audibleId = book.providerIds.audible ?? null + form.koboId = book.providerIds.kobo ?? null form.comicvineId = book.providerIds.comicvine ?? null form.ranobedbId = book.providerIds.ranobedb ?? null const cm = book.comicMetadata diff --git a/client/src/features/book/composables/useMetadataSearch.ts b/client/src/features/book/composables/useMetadataSearch.ts index c6cc7b7f1..a098a426a 100644 --- a/client/src/features/book/composables/useMetadataSearch.ts +++ b/client/src/features/book/composables/useMetadataSearch.ts @@ -94,6 +94,7 @@ export function useMetadataSearch() { 'hardcover', 'google', 'itunes', + 'kobo', 'openLibrary', ] diff --git a/client/src/features/book/composables/useRefreshMetadata.ts b/client/src/features/book/composables/useRefreshMetadata.ts index 9f490ffd2..0c0499551 100644 --- a/client/src/features/book/composables/useRefreshMetadata.ts +++ b/client/src/features/book/composables/useRefreshMetadata.ts @@ -25,6 +25,7 @@ export interface MetadataRefreshPreview { openLibraryId?: string | null itunesId?: string | null audibleId?: string | null + koboId?: string | null comicvineId?: string | null ranobedbId?: string | null audioMetadata?: { diff --git a/client/src/features/book/lib/file-metadata-patch.ts b/client/src/features/book/lib/file-metadata-patch.ts index d28cc6563..13a016511 100644 --- a/client/src/features/book/lib/file-metadata-patch.ts +++ b/client/src/features/book/lib/file-metadata-patch.ts @@ -20,6 +20,7 @@ const FILE_METADATA_PATCH_FIELDS = [ 'openLibraryId', 'itunesId', 'audibleId', + 'koboId', 'comicvineId', 'ranobedbId', 'authors', diff --git a/client/src/features/book/lib/provider-icons.ts b/client/src/features/book/lib/provider-icons.ts index 9f9360f03..727ce900c 100644 --- a/client/src/features/book/lib/provider-icons.ts +++ b/client/src/features/book/lib/provider-icons.ts @@ -8,6 +8,7 @@ const PROVIDER_ICON_PATHS = { openLibrary: `${PROVIDER_ICON_BASE_PATH}/openlibrary.svg`, itunes: `${PROVIDER_ICON_BASE_PATH}/apple-books.svg`, audible: `${PROVIDER_ICON_BASE_PATH}/audible.svg`, + kobo: `${PROVIDER_ICON_BASE_PATH}/kobo.svg`, ranobedb: `${PROVIDER_ICON_BASE_PATH}/ranobedb.svg`, } as const diff --git a/client/src/features/settings/metadata-preferences/components/ProviderConfigPanel.vue b/client/src/features/settings/metadata-preferences/components/ProviderConfigPanel.vue index 1fca9d962..6daabfc43 100644 --- a/client/src/features/settings/metadata-preferences/components/ProviderConfigPanel.vue +++ b/client/src/features/settings/metadata-preferences/components/ProviderConfigPanel.vue @@ -81,6 +81,9 @@ const AUDIBLE_DOMAINS = [ 'audible.in', ] +const KOBO_COUNTRIES = ['us', 'ca', 'gb', 'au', 'nz', 'de', 'fr', 'it', 'es', 'nl', 'pt', 'br', 'jp'] +const KOBO_LANGUAGES = ['en', 'fr', 'de', 'it', 'es', 'nl', 'pt', 'ja', 'all'] + type FieldDef = { key: string label: string @@ -154,6 +157,29 @@ const rows: RowDef[] = [ hint: "Apple's digital book catalog. Choose whether to fetch high or standard resolution covers.", fields: [{ key: 'coverResolution', label: 'Cover Resolution', type: 'select', options: ['high', 'standard'] }], }, + { + key: 'kobo', + label: 'Kobo', + hint: "Scrapes Kobo's public book pages. Country and language must match Kobo URL paths; bot protection can block requests.", + fields: [ + { + key: 'country', + label: 'Country', + type: 'select', + options: KOBO_COUNTRIES, + alwaysEditable: true, + widthClass: 'md:w-[5.25rem] md:min-w-[5.25rem]', + }, + { + key: 'language', + label: 'Language', + type: 'select', + options: KOBO_LANGUAGES, + alwaysEditable: true, + widthClass: 'md:w-[5.25rem] md:min-w-[5.25rem]', + }, + ], + }, { key: 'audible', label: 'Audible', diff --git a/client/src/lib/__tests__/provider-colors.spec.ts b/client/src/lib/__tests__/provider-colors.spec.ts index 90348ec2e..818dbab48 100644 --- a/client/src/lib/__tests__/provider-colors.spec.ts +++ b/client/src/lib/__tests__/provider-colors.spec.ts @@ -20,7 +20,20 @@ describe('getProviderColor', () => { }) it('returns a color for all known providers', () => { - const providers = ['google', 'amazon', 'goodreads', 'hardcover', 'openLibrary', 'itunes', 'audible', 'audnexus', 'comicvine', 'ranobedb', 'auto'] + const providers = [ + 'google', + 'amazon', + 'goodreads', + 'hardcover', + 'openLibrary', + 'itunes', + 'audible', + 'audnexus', + 'comicvine', + 'ranobedb', + 'kobo', + 'auto', + ] for (const provider of providers) { expect(getProviderColor(provider)).toBeTruthy() } @@ -33,7 +46,7 @@ describe('PROVIDER_SHORT_LABELS', () => { }) it('has labels for all providers', () => { - const providers = ['google', 'amazon', 'goodreads', 'hardcover', 'openLibrary', 'itunes', 'audible', 'audnexus', 'comicvine', 'ranobedb'] + const providers = ['google', 'amazon', 'goodreads', 'hardcover', 'openLibrary', 'itunes', 'audible', 'audnexus', 'comicvine', 'ranobedb', 'kobo'] for (const provider of providers) { expect(PROVIDER_SHORT_LABELS[provider]).toBeTruthy() } diff --git a/client/src/lib/provider-colors.ts b/client/src/lib/provider-colors.ts index 498f842c5..3f1976591 100644 --- a/client/src/lib/provider-colors.ts +++ b/client/src/lib/provider-colors.ts @@ -11,6 +11,7 @@ const PROVIDER_HEX: Record = { audnexus: '#FF5ADD', comicvine: '#ffdb0f', ranobedb: '#a78cff', + kobo: '#e23434', auto: '#8B5CF6', } @@ -27,6 +28,7 @@ export const PROVIDER_SHORT_LABELS: Record = { audnexus: 'AudNexus', comicvine: 'ComicVine', ranobedb: 'RanobeDB', + kobo: 'Kobo', auto: 'Fetched', } diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3ffeb5927..863221462 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -472,10 +472,11 @@ All commands are run from the repository root unless noted otherwise. ### Setup -| Command | Description | -| -------------- | ---------------------------------------------------- | -| `pnpm setup` | One-command bootstrap (env, deps, DB, migrate, seed) | -| `pnpm install` | Install all workspace dependencies | +| Command | Description | +| ----------------------------------------------- | ---------------------------------------------------- | +| `pnpm setup` | One-command bootstrap (env, deps, DB, migrate, seed) | +| `pnpm install` | Install all workspace dependencies | +| `cd server && pnpm run setup:kobo-cloudscraper` | Setup Python venv for Kobo metadata fetcher | ### Development diff --git a/packages/types/src/file-write.ts b/packages/types/src/file-write.ts index 7013b5c92..6c2d3fc46 100644 --- a/packages/types/src/file-write.ts +++ b/packages/types/src/file-write.ts @@ -23,6 +23,7 @@ export const COMMON_PROVIDER_BOOK_FILE_WRITE_FIELDS = [ "hardcoverId", "openLibraryId", "ranobedbId", + "koboId", ] as const; export const COMIC_BOOK_FILE_WRITE_FIELDS = [ @@ -73,6 +74,7 @@ export const BOOK_FILE_WRITE_FIELD_LABELS = { hardcoverId: "Hardcover ID", openLibraryId: "Open Library ID", ranobedbId: "RanobeDB ID", + koboId: "Kobo ID", comicIssueNumber: "Issue number", comicVolumeName: "Volume", comicPencillers: "Pencillers", diff --git a/packages/types/src/metadata-fetch.ts b/packages/types/src/metadata-fetch.ts index 2d8d2f6ca..70bd9a44e 100644 --- a/packages/types/src/metadata-fetch.ts +++ b/packages/types/src/metadata-fetch.ts @@ -11,6 +11,7 @@ export const MetadataProviderKey = { AUDNEXUS: "audnexus", COMICVINE: "comicvine", RANOBEDB: "ranobedb", + KOBO: "kobo", } as const; export interface ComicMetadataFields { diff --git a/packages/types/src/metadata-lock.ts b/packages/types/src/metadata-lock.ts index 1dc56165c..201908e87 100644 --- a/packages/types/src/metadata-lock.ts +++ b/packages/types/src/metadata-lock.ts @@ -24,6 +24,7 @@ export const BOOK_METADATA_LOCK_FIELDS = [ "openLibraryId", "itunesId", "audibleId", + "koboId", "comicvineId", "ranobedbId", "comicIssueNumber", @@ -74,7 +75,7 @@ export const BOOK_METADATA_LOCK_GROUPS: BookMetadataLockGroup[] = [ { key: "providers", label: "Provider IDs", - fields: ["googleBooksId", "goodreadsId", "amazonId", "hardcoverId", "openLibraryId", "itunesId", "audibleId", "comicvineId", "ranobedbId"], + fields: ["googleBooksId", "goodreadsId", "amazonId", "hardcoverId", "openLibraryId", "itunesId", "audibleId", "koboId", "comicvineId", "ranobedbId"], }, { key: "comic", diff --git a/packages/types/src/metadata-preferences.ts b/packages/types/src/metadata-preferences.ts index 7f7a111cf..3be278ae2 100644 --- a/packages/types/src/metadata-preferences.ts +++ b/packages/types/src/metadata-preferences.ts @@ -109,6 +109,12 @@ export interface ComicVineProviderConfig { apiKey: string; } +export interface KoboProviderConfig { + enabled: boolean; + country: string; + language: string; +} + export interface ProviderConfigurations { google: GoogleProviderConfig; amazon: AmazonProviderConfig; @@ -120,6 +126,7 @@ export interface ProviderConfigurations { audnexus: AudnexusProviderConfig; comicvine: ComicVineProviderConfig; ranobedb: SimpleProviderConfig; + kobo: KoboProviderConfig; } export interface ProviderStatus { diff --git a/packages/types/src/metadata-score.ts b/packages/types/src/metadata-score.ts index 1011276e4..93249995c 100644 --- a/packages/types/src/metadata-score.ts +++ b/packages/types/src/metadata-score.ts @@ -20,7 +20,8 @@ export type MetadataScoreField = | "amazonId" | "hardcoverId" | "openLibraryId" - | "itunesId"; + | "itunesId" + | "koboId"; export type MetadataScoreGroup = "core" | "publishing" | "classification" | "enrichment" | "providers"; @@ -53,6 +54,7 @@ export const METADATA_SCORE_FIELDS: Record = { diff --git a/server/package.json b/server/package.json index 403261e41..feb925217 100644 --- a/server/package.json +++ b/server/package.json @@ -14,6 +14,7 @@ "url": "https://github.com/bookorbit/bookorbit/issues" }, "scripts": { + "setup:kobo-cloudscraper": "tsx src/scripts/setup-kobo-cloudscraper.ts", "prestart": "pnpm --filter @bookorbit/types build", "prestart:dev": "pnpm --filter @bookorbit/types build", "prestart:debug": "pnpm --filter @bookorbit/types build", diff --git a/server/requirements/kobo-cloudscraper.txt b/server/requirements/kobo-cloudscraper.txt new file mode 100644 index 000000000..8236f3ed1 --- /dev/null +++ b/server/requirements/kobo-cloudscraper.txt @@ -0,0 +1 @@ +cloudscraper-enhanced==3.0.0 diff --git a/server/src/config/config.test.ts b/server/src/config/config.test.ts index 4a546876c..236c544fb 100644 --- a/server/src/config/config.test.ts +++ b/server/src/config/config.test.ts @@ -10,6 +10,7 @@ function resetEnv(): void { delete process.env.APP_URL; delete process.env.APP_VERSION; delete process.env.OIDC_ALLOW_LOCAL_ISSUERS; + delete process.env.KOBO_CLOUDSCRAPER_PYTHON; delete process.env.DATABASE_URL; delete process.env.JWT_SECRET; delete process.env.JWT_EXPIRES_IN; @@ -43,6 +44,7 @@ describe('config', () => { appUrl: 'http://localhost:5173', version: 'Local build', oidcAllowLocalIssuers: false, + koboCloudscraperPython: undefined, }); }); @@ -51,12 +53,14 @@ describe('config', () => { process.env.APP_URL = 'https://bookorbit.local'; process.env.APP_VERSION = 'v2.3.4'; process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'true'; + process.env.KOBO_CLOUDSCRAPER_PYTHON = '/opt/bookorbit-python/bin/python'; expect(appConfig()).toEqual({ nodeEnv: 'production', appUrl: 'https://bookorbit.local', version: 'v2.3.4', oidcAllowLocalIssuers: true, + koboCloudscraperPython: '/opt/bookorbit-python/bin/python', }); }); diff --git a/server/src/config/config.ts b/server/src/config/config.ts index 7b7d5af24..f38ffd4ac 100644 --- a/server/src/config/config.ts +++ b/server/src/config/config.ts @@ -6,6 +6,7 @@ export const appConfig = registerAs('app', () => ({ appUrl: process.env.APP_URL ?? 'http://localhost:5173', version: process.env.APP_VERSION ?? 'Local build', oidcAllowLocalIssuers: parseBooleanFlag(process.env.OIDC_ALLOW_LOCAL_ISSUERS, false), + koboCloudscraperPython: process.env.KOBO_CLOUDSCRAPER_PYTHON?.trim() || undefined, })); export const dbConfig = registerAs('db', () => ({ diff --git a/server/src/config/env.validation.ts b/server/src/config/env.validation.ts index 1cb04ef9e..60c01a20a 100644 --- a/server/src/config/env.validation.ts +++ b/server/src/config/env.validation.ts @@ -59,6 +59,10 @@ const envSchema = z.object({ message: 'CSP_ALLOW_CLOUDFLARE_INSIGHTS must be one of true/false/1/0/yes/no/on/off', }) .optional(), + KOBO_CLOUDSCRAPER_PYTHON: z + .string() + .transform((val) => val.trim()) + .optional(), }); export function validateEnv(config: Record) { diff --git a/server/src/db/migrations/0015_add_kobo_id.sql b/server/src/db/migrations/0015_add_kobo_id.sql new file mode 100644 index 000000000..bb55228fd --- /dev/null +++ b/server/src/db/migrations/0015_add_kobo_id.sql @@ -0,0 +1 @@ +ALTER TABLE "book_metadata" ADD COLUMN "kobo_id" varchar(255); \ No newline at end of file diff --git a/server/src/db/migrations/meta/0015_snapshot.json b/server/src/db/migrations/meta/0015_snapshot.json new file mode 100644 index 000000000..2688f5bdf --- /dev/null +++ b/server/src/db/migrations/meta/0015_snapshot.json @@ -0,0 +1,10581 @@ +{ + "id": "ef24e2c9-92cf-4c40-a3db-48887d83bf8f", + "prevId": "65d729b0-fb09-4f6f-bdd7-b26ae19b7530", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "actor_username": { + "name": "actor_username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_user_id": { + "name": "idx_audit_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_resource": { + "name": "idx_audit_resource", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_action": { + "name": "idx_audit_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_ip": { + "name": "idx_audit_ip", + "columns": [ + { + "expression": "ip", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_created_at": { + "name": "idx_audit_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_user_id_users_id_fk": { + "name": "audit_log_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_settings_key_unique": { + "name": "app_settings_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.magic_access_tokens": { + "name": "magic_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "raw_token": { + "name": "raw_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "magic_access_tokens_user_id_idx": { + "name": "magic_access_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "magic_access_tokens_user_id_users_id_fk": { + "name": "magic_access_tokens_user_id_users_id_fk", + "tableFrom": "magic_access_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "magic_access_tokens_created_by_users_id_fk": { + "name": "magic_access_tokens_created_by_users_id_fk", + "tableFrom": "magic_access_tokens", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "magic_access_tokens_token_hash_unique": { + "name": "magic_access_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "magic_access_tokens_use_count_nonneg_chk": { + "name": "magic_access_tokens_use_count_nonneg_chk", + "value": "\"magic_access_tokens\".\"use_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "password_reset_tokens_expires_at_idx": { + "name": "password_reset_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_tokens_token_hash_unique": { + "name": "password_reset_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "password_reset_tokens_expires_after_created_chk": { + "name": "password_reset_tokens_expires_after_created_chk", + "value": "\"password_reset_tokens\".\"expires_at\" > \"password_reset_tokens\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_token_hash": { + "name": "replaced_by_token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_user_id_idx": { + "name": "refresh_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_expires_at_idx": { + "name": "refresh_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_hash_unique": { + "name": "refresh_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "refresh_tokens_expires_after_created_chk": { + "name": "refresh_tokens_expires_after_created_chk", + "value": "\"refresh_tokens\".\"expires_at\" > \"refresh_tokens\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.user_content_filter_genres": { + "name": "user_content_filter_genres", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "filter_type": { + "name": "filter_type", + "type": "content_filter_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_content_filter_genres_genre_id_idx": { + "name": "user_content_filter_genres_genre_id_idx", + "columns": [ + { + "expression": "genre_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_content_filter_genres_user_id_users_id_fk": { + "name": "user_content_filter_genres_user_id_users_id_fk", + "tableFrom": "user_content_filter_genres", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_content_filter_genres_genre_id_genres_id_fk": { + "name": "user_content_filter_genres_genre_id_genres_id_fk", + "tableFrom": "user_content_filter_genres", + "tableTo": "genres", + "columnsFrom": ["genre_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_content_filter_genres_user_id_filter_type_genre_id_pk": { + "name": "user_content_filter_genres_user_id_filter_type_genre_id_pk", + "columns": ["user_id", "filter_type", "genre_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_content_filter_tags": { + "name": "user_content_filter_tags", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "filter_type": { + "name": "filter_type", + "type": "content_filter_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_content_filter_tags_tag_id_idx": { + "name": "user_content_filter_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_content_filter_tags_user_id_users_id_fk": { + "name": "user_content_filter_tags_user_id_users_id_fk", + "tableFrom": "user_content_filter_tags", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_content_filter_tags_tag_id_tags_id_fk": { + "name": "user_content_filter_tags_tag_id_tags_id_fk", + "tableFrom": "user_content_filter_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_content_filter_tags_user_id_filter_type_tag_id_pk": { + "name": "user_content_filter_tags_user_id_filter_type_tag_id_pk", + "columns": ["user_id", "filter_type", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library_access": { + "name": "user_library_access", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "access_level": { + "name": "access_level", + "type": "library_access_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + } + }, + "indexes": { + "user_library_access_library_user_idx": { + "name": "user_library_access_library_user_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_access_user_id_users_id_fk": { + "name": "user_library_access_user_id_users_id_fk", + "tableFrom": "user_library_access", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_access_library_id_libraries_id_fk": { + "name": "user_library_access_library_id_libraries_id_fk", + "tableFrom": "user_library_access", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_library_access_user_id_library_id_pk": { + "name": "user_library_access_user_id_library_id_pk", + "columns": ["user_id", "library_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_permissions": { + "name": "user_permissions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "permission_name": { + "name": "permission_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_permissions_user_id_users_id_fk": { + "name": "user_permissions_user_id_users_id_fk", + "tableFrom": "user_permissions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_permissions_user_id_permission_name_pk": { + "name": "user_permissions_user_id_permission_name_pk", + "columns": ["user_id", "permission_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_superuser": { + "name": "is_superuser", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default_password": { + "name": "is_default_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "failed_login_attempts": { + "name": "failed_login_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "oidc_subject": { + "name": "oidc_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_issuer": { + "name": "oidc_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_source": { + "name": "avatar_source", + "type": "user_avatar_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "avatar_version": { + "name": "avatar_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provisioning_method": { + "name": "provisioning_method", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_lower_uidx": { + "name": "users_username_lower_uidx", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_lower_uidx": { + "name": "users_email_lower_uidx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"email\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_oidc_subject_issuer_uidx": { + "name": "users_oidc_subject_issuer_uidx", + "columns": [ + { + "expression": "oidc_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oidc_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"oidc_subject\" is not null and \"users\".\"oidc_issuer\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": { + "users_provisioning_method_chk": { + "name": "users_provisioning_method_chk", + "value": "\"users\".\"provisioning_method\" in ('local', 'manual', 'oidc', 'shared')" + }, + "users_token_version_nonnegative_chk": { + "name": "users_token_version_nonnegative_chk", + "value": "\"users\".\"token_version\" >= 0" + }, + "users_failed_login_attempts_nonnegative_chk": { + "name": "users_failed_login_attempts_nonnegative_chk", + "value": "\"users\".\"failed_login_attempts\" >= 0" + }, + "users_avatar_version_nonnegative_chk": { + "name": "users_avatar_version_nonnegative_chk", + "value": "\"users\".\"avatar_version\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.comic_metadata": { + "name": "comic_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "volume_name": { + "name": "volume_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pencillers": { + "name": "pencillers", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "inkers": { + "name": "inkers", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "colorists": { + "name": "colorists", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "letterers": { + "name": "letterers", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "cover_artists": { + "name": "cover_artists", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "characters": { + "name": "characters", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "teams": { + "name": "teams", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "locations": { + "name": "locations", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "story_arcs": { + "name": "story_arcs", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comic_metadata_book_id_books_id_fk": { + "name": "comic_metadata_book_id_books_id_fk", + "tableFrom": "comic_metadata", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "comic_metadata_book_id_unique": { + "name": "comic_metadata_book_id_unique", + "nullsNotDistinct": false, + "columns": ["book_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_plan_artifacts": { + "name": "migration_plan_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_snapshot_hash": { + "name": "source_snapshot_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "profile_hash": { + "name": "profile_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "plan_hash": { + "name": "plan_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_data": { + "name": "source_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "migration_plan_artifacts_plan_hash_uidx": { + "name": "migration_plan_artifacts_plan_hash_uidx", + "columns": [ + { + "expression": "plan_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_plan_artifacts_source_id_idx": { + "name": "migration_plan_artifacts_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_plan_artifacts_profile_id_idx": { + "name": "migration_plan_artifacts_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_plan_artifacts_source_id_migration_sources_id_fk": { + "name": "migration_plan_artifacts_source_id_migration_sources_id_fk", + "tableFrom": "migration_plan_artifacts", + "tableTo": "migration_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_plan_artifacts_profile_id_migration_profiles_id_fk": { + "name": "migration_plan_artifacts_profile_id_migration_profiles_id_fk", + "tableFrom": "migration_plan_artifacts", + "tableTo": "migration_profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_plan_artifacts_created_by_user_id_users_id_fk": { + "name": "migration_plan_artifacts_created_by_user_id_users_id_fk", + "tableFrom": "migration_plan_artifacts", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "migration_plan_artifacts_profile_source_fk": { + "name": "migration_plan_artifacts_profile_source_fk", + "tableFrom": "migration_plan_artifacts", + "tableTo": "migration_profiles", + "columnsFrom": ["profile_id", "source_id"], + "columnsTo": ["id", "source_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migration_plan_artifacts_id_source_profile_unique": { + "name": "migration_plan_artifacts_id_source_profile_unique", + "nullsNotDistinct": false, + "columns": ["id", "source_id", "profile_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_profiles": { + "name": "migration_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "user_mappings": { + "name": "user_mappings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "path_mappings": { + "name": "path_mappings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "migration_profiles_source_name_version_uidx": { + "name": "migration_profiles_source_name_version_uidx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_profiles_source_id_idx": { + "name": "migration_profiles_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_profiles_source_id_migration_sources_id_fk": { + "name": "migration_profiles_source_id_migration_sources_id_fk", + "tableFrom": "migration_profiles", + "tableTo": "migration_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_profiles_created_by_user_id_users_id_fk": { + "name": "migration_profiles_created_by_user_id_users_id_fk", + "tableFrom": "migration_profiles", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "migration_profiles_id_source_id_unique": { + "name": "migration_profiles_id_source_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "source_id"] + } + }, + "policies": {}, + "checkConstraints": { + "migration_profiles_version_positive_chk": { + "name": "migration_profiles_version_positive_chk", + "value": "\"migration_profiles\".\"version\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.migration_run_metrics": { + "name": "migration_run_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "imported": { + "name": "imported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped": { + "name": "skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unresolved": { + "name": "unresolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed": { + "name": "failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "migration_run_metrics_run_stage_entity_uidx": { + "name": "migration_run_metrics_run_stage_entity_uidx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_run_metrics_run_id_idx": { + "name": "migration_run_metrics_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_run_metrics_run_id_migration_runs_id_fk": { + "name": "migration_run_metrics_run_id_migration_runs_id_fk", + "tableFrom": "migration_run_metrics", + "tableTo": "migration_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "migration_run_metrics_processed_nonnegative_chk": { + "name": "migration_run_metrics_processed_nonnegative_chk", + "value": "\"migration_run_metrics\".\"processed\" >= 0" + }, + "migration_run_metrics_imported_nonnegative_chk": { + "name": "migration_run_metrics_imported_nonnegative_chk", + "value": "\"migration_run_metrics\".\"imported\" >= 0" + }, + "migration_run_metrics_skipped_nonnegative_chk": { + "name": "migration_run_metrics_skipped_nonnegative_chk", + "value": "\"migration_run_metrics\".\"skipped\" >= 0" + }, + "migration_run_metrics_unresolved_nonnegative_chk": { + "name": "migration_run_metrics_unresolved_nonnegative_chk", + "value": "\"migration_run_metrics\".\"unresolved\" >= 0" + }, + "migration_run_metrics_failed_nonnegative_chk": { + "name": "migration_run_metrics_failed_nonnegative_chk", + "value": "\"migration_run_metrics\".\"failed\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "plan_artifact_id": { + "name": "plan_artifact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "target_key": { + "name": "target_key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'bookorbit'" + }, + "state": { + "name": "state", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "current_stage": { + "name": "current_stage", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "migration_runs_source_target_state_idx": { + "name": "migration_runs_source_target_state_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_runs_state_idx": { + "name": "migration_runs_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_source_id_migration_sources_id_fk": { + "name": "migration_runs_source_id_migration_sources_id_fk", + "tableFrom": "migration_runs", + "tableTo": "migration_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_profile_id_migration_profiles_id_fk": { + "name": "migration_runs_profile_id_migration_profiles_id_fk", + "tableFrom": "migration_runs", + "tableTo": "migration_profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_plan_artifact_id_migration_plan_artifacts_id_fk": { + "name": "migration_runs_plan_artifact_id_migration_plan_artifacts_id_fk", + "tableFrom": "migration_runs", + "tableTo": "migration_plan_artifacts", + "columnsFrom": ["plan_artifact_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "migration_runs_triggered_by_user_id_users_id_fk": { + "name": "migration_runs_triggered_by_user_id_users_id_fk", + "tableFrom": "migration_runs", + "tableTo": "users", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "migration_runs_profile_source_fk": { + "name": "migration_runs_profile_source_fk", + "tableFrom": "migration_runs", + "tableTo": "migration_profiles", + "columnsFrom": ["profile_id", "source_id"], + "columnsTo": ["id", "source_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "migration_runs_plan_artifact_source_profile_fk": { + "name": "migration_runs_plan_artifact_source_profile_fk", + "tableFrom": "migration_runs", + "tableTo": "migration_plan_artifacts", + "columnsFrom": ["plan_artifact_id", "source_id", "profile_id"], + "columnsTo": ["id", "source_id", "profile_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "migration_runs_state_chk": { + "name": "migration_runs_state_chk", + "value": "\"state\" IN ('draft', 'preflight_failed', 'dry_run_ready', 'running', 'failed', 'completed')" + }, + "migration_runs_started_before_ended_chk": { + "name": "migration_runs_started_before_ended_chk", + "value": "\"migration_runs\".\"ended_at\" is null or \"migration_runs\".\"started_at\" is null or \"migration_runs\".\"ended_at\" >= \"migration_runs\".\"started_at\"" + } + }, + "isRLSEnabled": false + }, + "public.migration_sources": { + "name": "migration_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_config": { + "name": "connection_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "migration_sources_type_name_uidx": { + "name": "migration_sources_type_name_uidx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_sources_created_by_user_id_idx": { + "name": "migration_sources_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_sources_created_by_user_id_users_id_fk": { + "name": "migration_sources_created_by_user_id_users_id_fk", + "tableFrom": "migration_sources", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.libraries": { + "name": "libraries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cover_aspect_ratio": { + "name": "cover_aspect_ratio", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'2/3'" + }, + "watch": { + "name": "watch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_scan_cron_expression": { + "name": "auto_scan_cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_precedence": { + "name": "metadata_precedence", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"folderStructure\",\"embedded\",\"nfoFile\",\"opfFile\",\"sidecar\"]'::jsonb" + }, + "format_priority": { + "name": "format_priority", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"epub\",\"pdf\",\"cbz\",\"cbr\",\"cb7\",\"mobi\",\"azw3\",\"azw\",\"fb2\",\"m4b\",\"mp3\",\"m4a\",\"opus\",\"ogg\",\"flac\"]'::jsonb" + }, + "allowed_formats": { + "name": "allowed_formats", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "organization_mode": { + "name": "organization_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'book_per_folder'" + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reading_threshold": { + "name": "reading_threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0.25 + }, + "mark_as_finished_percent_complete": { + "name": "mark_as_finished_percent_complete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 98 + }, + "file_write_enabled": { + "name": "file_write_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_write_write_cover": { + "name": "file_write_write_cover", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "file_write_epub_enabled": { + "name": "file_write_epub_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "file_write_epub_max_file_size_mb": { + "name": "file_write_epub_max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "file_write_pdf_enabled": { + "name": "file_write_pdf_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "file_write_pdf_max_file_size_mb": { + "name": "file_write_pdf_max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "file_write_cbx_enabled": { + "name": "file_write_cbx_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_write_cbx_max_file_size_mb": { + "name": "file_write_cbx_max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "file_write_audio_enabled": { + "name": "file_write_audio_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_write_audio_max_file_size_mb": { + "name": "file_write_audio_max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "file_rename_enabled": { + "name": "file_rename_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_naming_pattern": { + "name": "file_naming_pattern", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "metadata_fetch_preferences": { + "name": "metadata_fetch_preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "book_metadata_fetch_config": { + "name": "book_metadata_fetch_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "book_metadata_fetch_last_run_at": { + "name": "book_metadata_fetch_last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "book_metadata_fetch_last_queued_count": { + "name": "book_metadata_fetch_last_queued_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scan_mode": { + "name": "scan_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "poll_interval_seconds": { + "name": "poll_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "libraries_name_lower_uidx": { + "name": "libraries_name_lower_uidx", + "columns": [ + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "libraries_display_order_nonnegative_chk": { + "name": "libraries_display_order_nonnegative_chk", + "value": "\"libraries\".\"display_order\" >= 0" + }, + "libraries_organization_mode_chk": { + "name": "libraries_organization_mode_chk", + "value": "\"libraries\".\"organization_mode\" in ('book_per_folder', 'book_per_file')" + }, + "libraries_reading_threshold_range_chk": { + "name": "libraries_reading_threshold_range_chk", + "value": "\"libraries\".\"reading_threshold\" >= 0 and \"libraries\".\"reading_threshold\" <= 100" + }, + "libraries_mark_finished_percent_range_chk": { + "name": "libraries_mark_finished_percent_range_chk", + "value": "\"libraries\".\"mark_as_finished_percent_complete\" >= 0 and \"libraries\".\"mark_as_finished_percent_complete\" <= 100" + }, + "libraries_scan_mode_chk": { + "name": "libraries_scan_mode_chk", + "value": "\"libraries\".\"scan_mode\" in ('auto', 'manual')" + }, + "libraries_poll_interval_nonnegative_chk": { + "name": "libraries_poll_interval_nonnegative_chk", + "value": "\"libraries\".\"poll_interval_seconds\" is null or \"libraries\".\"poll_interval_seconds\" >= 0" + }, + "libraries_file_write_epub_max_size_chk": { + "name": "libraries_file_write_epub_max_size_chk", + "value": "\"libraries\".\"file_write_epub_max_file_size_mb\" >= 1" + }, + "libraries_file_write_pdf_max_size_chk": { + "name": "libraries_file_write_pdf_max_size_chk", + "value": "\"libraries\".\"file_write_pdf_max_file_size_mb\" >= 1" + }, + "libraries_file_write_cbx_max_size_chk": { + "name": "libraries_file_write_cbx_max_size_chk", + "value": "\"libraries\".\"file_write_cbx_max_file_size_mb\" >= 1" + }, + "libraries_file_write_audio_max_size_chk": { + "name": "libraries_file_write_audio_max_size_chk", + "value": "\"libraries\".\"file_write_audio_max_file_size_mb\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.library_folders": { + "name": "library_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "library_folders_library_id_idx": { + "name": "library_folders_library_id_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "library_folders_library_path_uidx": { + "name": "library_folders_library_path_uidx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_folders_library_id_libraries_id_fk": { + "name": "library_folders_library_id_libraries_id_fk", + "tableFrom": "library_folders", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "library_folders_id_library_id_unique": { + "name": "library_folders_id_library_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "library_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_files": { + "name": "book_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_folder_id": { + "name": "library_folder_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "absolute_path": { + "name": "absolute_path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "rel_path": { + "name": "rel_path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": false + }, + "ino": { + "name": "ino", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "mtime": { + "name": "mtime", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'content'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "book_files_absolute_path_uidx": { + "name": "book_files_absolute_path_uidx", + "columns": [ + { + "expression": "absolute_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_book_id_idx": { + "name": "book_files_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_library_folder_id_idx": { + "name": "book_files_library_folder_id_idx", + "columns": [ + { + "expression": "library_folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_file_hash_idx": { + "name": "book_files_file_hash_idx", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_ino_idx": { + "name": "book_files_ino_idx", + "columns": [ + { + "expression": "ino", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_format_idx": { + "name": "book_files_format_idx", + "columns": [ + { + "expression": "format", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_library_folder_file_hash_idx": { + "name": "book_files_library_folder_file_hash_idx", + "columns": [ + { + "expression": "library_folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_files_library_folder_ino_idx": { + "name": "book_files_library_folder_ino_idx", + "columns": [ + { + "expression": "library_folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ino", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_files_book_id_books_id_fk": { + "name": "book_files_book_id_books_id_fk", + "tableFrom": "book_files", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_files_library_folder_id_library_folders_id_fk": { + "name": "book_files_library_folder_id_library_folders_id_fk", + "tableFrom": "book_files", + "tableTo": "library_folders", + "columnsFrom": ["library_folder_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_files_book_folder_consistency_fk": { + "name": "book_files_book_folder_consistency_fk", + "tableFrom": "book_files", + "tableTo": "books", + "columnsFrom": ["book_id", "library_folder_id"], + "columnsTo": ["id", "library_folder_id"], + "onDelete": "no action", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_files_role_chk": { + "name": "book_files_role_chk", + "value": "\"book_files\".\"role\" in ('content', 'cover', 'metadata', 'supplement')" + }, + "book_files_size_bytes_nonnegative_chk": { + "name": "book_files_size_bytes_nonnegative_chk", + "value": "\"book_files\".\"size_bytes\" is null or \"book_files\".\"size_bytes\" >= 0" + }, + "book_files_duration_seconds_nonnegative_chk": { + "name": "book_files_duration_seconds_nonnegative_chk", + "value": "\"book_files\".\"duration_seconds\" is null or \"book_files\".\"duration_seconds\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.books": { + "name": "books", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_folder_id": { + "name": "library_folder_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "primary_file_id": { + "name": "primary_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "folder_path": { + "name": "folder_path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'present'" + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "books_library_id_folder_path_idx": { + "name": "books_library_id_folder_path_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "books_primary_file_id_idx": { + "name": "books_primary_file_id_idx", + "columns": [ + { + "expression": "primary_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "books_library_status_idx": { + "name": "books_library_status_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "books_library_added_at_idx": { + "name": "books_library_added_at_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"added_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "books_library_id_libraries_id_fk": { + "name": "books_library_id_libraries_id_fk", + "tableFrom": "books", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "books_library_folder_id_library_folders_id_fk": { + "name": "books_library_folder_id_library_folders_id_fk", + "tableFrom": "books", + "tableTo": "library_folders", + "columnsFrom": ["library_folder_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "books_primary_file_id_book_files_id_fk": { + "name": "books_primary_file_id_book_files_id_fk", + "tableFrom": "books", + "tableTo": "book_files", + "columnsFrom": ["primary_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "books_library_folder_library_fk": { + "name": "books_library_folder_library_fk", + "tableFrom": "books", + "tableTo": "library_folders", + "columnsFrom": ["library_folder_id", "library_id"], + "columnsTo": ["id", "library_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "books_id_library_folder_id_unique": { + "name": "books_id_library_folder_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "library_folder_id"] + } + }, + "policies": {}, + "checkConstraints": { + "books_status_chk": { + "name": "books_status_chk", + "value": "\"books\".\"status\" in ('present', 'missing', 'processing')" + } + }, + "isRLSEnabled": false + }, + "public.collection_books": { + "name": "collection_books", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collection_books_book_id_idx": { + "name": "collection_books_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_books_collection_id_collections_id_fk": { + "name": "collection_books_collection_id_collections_id_fk", + "tableFrom": "collection_books", + "tableTo": "collections", + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_books_book_id_books_id_fk": { + "name": "collection_books_book_id_books_id_fk", + "tableFrom": "collection_books", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collection_books_collection_id_book_id_pk": { + "name": "collection_books_collection_id_book_id_pk", + "columns": ["collection_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_to_kobo": { + "name": "sync_to_kobo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_user_name_uidx": { + "name": "collections_user_name_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_user_display_name_idx": { + "name": "collections_user_display_name_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.smart_scopes": { + "name": "smart_scopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "default_sort": { + "name": "default_sort", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "smart_scopes_user_id_users_id_fk": { + "name": "smart_scopes_user_id_users_id_fk", + "tableFrom": "smart_scopes", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "smart_scopes_user_id_name_unique": { + "name": "smart_scopes_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_series": { + "name": "book_series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "normalized_name": { + "name": "normalized_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "book_series_normalized_name_uidx": { + "name": "book_series_normalized_name_uidx", + "columns": [ + { + "expression": "normalized_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_series_name_trgm_idx": { + "name": "book_series_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "book_series_name_lower_idx": { + "name": "book_series_name_lower_idx", + "columns": [ + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.author_enrichment_queue": { + "name": "author_enrichment_queue", + "schema": "", + "columns": { + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reason": { + "name": "reason", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_http_status": { + "name": "last_http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "author_enrichment_queue_status_next_attempt_idx": { + "name": "author_enrichment_queue_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "author_enrichment_queue_next_attempt_idx": { + "name": "author_enrichment_queue_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "author_enrichment_queue_single_processing_idx": { + "name": "author_enrichment_queue_single_processing_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"author_enrichment_queue\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "author_enrichment_queue_author_id_authors_id_fk": { + "name": "author_enrichment_queue_author_id_authors_id_fk", + "tableFrom": "author_enrichment_queue", + "tableTo": "authors", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "author_enrichment_queue_status_chk": { + "name": "author_enrichment_queue_status_chk", + "value": "\"author_enrichment_queue\".\"status\" in ('queued', 'processing', 'rate_limited', 'failed', 'done')" + }, + "author_enrichment_queue_reason_chk": { + "name": "author_enrichment_queue_reason_chk", + "value": "\"author_enrichment_queue\".\"reason\" in ('unknown', 'metadata_replace', 'manual_backfill', 'manual_backfill_all', 'author_rename', 'author_merge_target')" + }, + "author_enrichment_queue_attempt_count_nonnegative_chk": { + "name": "author_enrichment_queue_attempt_count_nonnegative_chk", + "value": "\"author_enrichment_queue\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.authors": { + "name": "authors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "sort_name": { + "name": "sort_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_enriched_at": { + "name": "last_enriched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "authors_name_trgm_idx": { + "name": "authors_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "authors_name_unique": { + "name": "authors_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_authors": { + "name": "book_authors", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "book_authors_author_id_idx": { + "name": "book_authors_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_authors_book_display_idx": { + "name": "book_authors_book_display_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_authors_book_id_books_id_fk": { + "name": "book_authors_book_id_books_id_fk", + "tableFrom": "book_authors", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_authors_author_id_authors_id_fk": { + "name": "book_authors_author_id_authors_id_fk", + "tableFrom": "book_authors", + "tableTo": "authors", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_authors_book_id_author_id_pk": { + "name": "book_authors_book_id_author_id_pk", + "columns": ["book_id", "author_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_genres": { + "name": "book_genres", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "book_genres_genre_id_idx": { + "name": "book_genres_genre_id_idx", + "columns": [ + { + "expression": "genre_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_genres_book_id_books_id_fk": { + "name": "book_genres_book_id_books_id_fk", + "tableFrom": "book_genres", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_genres_genre_id_genres_id_fk": { + "name": "book_genres_genre_id_genres_id_fk", + "tableFrom": "book_genres", + "tableTo": "genres", + "columnsFrom": ["genre_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_genres_book_id_genre_id_pk": { + "name": "book_genres_book_id_genre_id_pk", + "columns": ["book_id", "genre_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_metadata": { + "name": "book_metadata", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "subtitle": { + "name": "subtitle", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isbn10": { + "name": "isbn10", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "isbn13": { + "name": "isbn13", + "type": "varchar(13)", + "primaryKey": false, + "notNull": false + }, + "publisher": { + "name": "publisher", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "published_year": { + "name": "published_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_name": { + "name": "series_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "series_index": { + "name": "series_index", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cover_source": { + "name": "cover_source", + "type": "varchar(9)", + "primaryKey": false, + "notNull": false + }, + "google_books_id": { + "name": "google_books_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "goodreads_id": { + "name": "goodreads_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "amazon_id": { + "name": "amazon_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "hardcover_id": { + "name": "hardcover_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "open_library_id": { + "name": "open_library_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "itunes_id": { + "name": "itunes_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "kobo_id": { + "name": "kobo_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "metadata_score": { + "name": "metadata_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_metadata_fetch_at": { + "name": "last_metadata_fetch_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(256)", + "primaryKey": false, + "notNull": false + }, + "last_written_at": { + "name": "last_written_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "abridged": { + "name": "abridged", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "audible_id": { + "name": "audible_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "comicvine_id": { + "name": "comicvine_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "ranobedb_id": { + "name": "ranobedb_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "chapters": { + "name": "chapters", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked_fields": { + "name": "locked_fields", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bm_title_trgm_idx": { + "name": "bm_title_trgm_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_title_lower_idx": { + "name": "bm_title_lower_idx", + "columns": [ + { + "expression": "lower(\"title\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_series_trgm_idx": { + "name": "bm_series_trgm_idx", + "columns": [ + { + "expression": "series_name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_series_id_idx": { + "name": "bm_series_id_idx", + "columns": [ + { + "expression": "series_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_publisher_trgm_idx": { + "name": "bm_publisher_trgm_idx", + "columns": [ + { + "expression": "publisher", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_language_idx": { + "name": "bm_language_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_language_trgm_idx": { + "name": "bm_language_trgm_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bm_published_year_idx": { + "name": "bm_published_year_idx", + "columns": [ + { + "expression": "published_year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_series_name_index_idx": { + "name": "bm_series_name_index_idx", + "columns": [ + { + "expression": "series_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "series_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_isbn10_idx": { + "name": "bm_isbn10_idx", + "columns": [ + { + "expression": "isbn10", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_isbn13_idx": { + "name": "bm_isbn13_idx", + "columns": [ + { + "expression": "isbn13", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bm_embedding_hnsw_cosine_idx": { + "name": "bm_embedding_hnsw_cosine_idx", + "columns": [ + { + "expression": "\"embedding\" vector_cosine_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "book_metadata_book_id_books_id_fk": { + "name": "book_metadata_book_id_books_id_fk", + "tableFrom": "book_metadata", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_metadata_series_id_book_series_id_fk": { + "name": "book_metadata_series_id_book_series_id_fk", + "tableFrom": "book_metadata", + "tableTo": "book_series", + "columnsFrom": ["series_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_metadata_rating_range_chk": { + "name": "book_metadata_rating_range_chk", + "value": "\"book_metadata\".\"rating\" is null or (\"book_metadata\".\"rating\" >= 1 and \"book_metadata\".\"rating\" <= 10)" + }, + "book_metadata_published_year_range_chk": { + "name": "book_metadata_published_year_range_chk", + "value": "\"book_metadata\".\"published_year\" is null or (\"book_metadata\".\"published_year\" >= 1000 and \"book_metadata\".\"published_year\" <= 2200)" + }, + "book_metadata_page_count_nonnegative_chk": { + "name": "book_metadata_page_count_nonnegative_chk", + "value": "\"book_metadata\".\"page_count\" is null or \"book_metadata\".\"page_count\" >= 0" + }, + "book_metadata_duration_seconds_nonnegative_chk": { + "name": "book_metadata_duration_seconds_nonnegative_chk", + "value": "\"book_metadata\".\"duration_seconds\" is null or \"book_metadata\".\"duration_seconds\" >= 0" + }, + "book_metadata_cover_source_chk": { + "name": "book_metadata_cover_source_chk", + "value": "\"book_metadata\".\"cover_source\" is null or \"book_metadata\".\"cover_source\" in ('extracted', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.book_metadata_fetch_queue": { + "name": "book_metadata_fetch_queue", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reason": { + "name": "reason", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'manual_trigger'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_http_status": { + "name": "last_http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bmfq_status_idx": { + "name": "bmfq_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bmfq_created_at_idx": { + "name": "bmfq_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bmfq_status_created_book_idx": { + "name": "bmfq_status_created_book_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_metadata_fetch_queue_book_id_books_id_fk": { + "name": "book_metadata_fetch_queue_book_id_books_id_fk", + "tableFrom": "book_metadata_fetch_queue", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_metadata_fetch_queue_status_chk": { + "name": "book_metadata_fetch_queue_status_chk", + "value": "\"book_metadata_fetch_queue\".\"status\" in ('queued', 'processing', 'failed')" + }, + "book_metadata_fetch_queue_reason_chk": { + "name": "book_metadata_fetch_queue_reason_chk", + "value": "\"book_metadata_fetch_queue\".\"reason\" in ('event_import', 'manual_trigger', 'manual_retry')" + }, + "book_metadata_fetch_queue_attempt_count_nonnegative_chk": { + "name": "book_metadata_fetch_queue_attempt_count_nonnegative_chk", + "value": "\"book_metadata_fetch_queue\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.book_tags": { + "name": "book_tags", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "book_tags_tag_id_idx": { + "name": "book_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_tags_book_id_books_id_fk": { + "name": "book_tags_book_id_books_id_fk", + "tableFrom": "book_tags", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_tags_tag_id_tags_id_fk": { + "name": "book_tags_tag_id_tags_id_fk", + "tableFrom": "book_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_tags_book_id_tag_id_pk": { + "name": "book_tags_book_id_tag_id_pk", + "columns": ["book_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.genres": { + "name": "genres", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "genres_name_trgm_idx": { + "name": "genres_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "genres_name_unique": { + "name": "genres_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tags_name_trgm_idx": { + "name": "tags_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_narrators": { + "name": "book_narrators", + "schema": "", + "columns": { + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "narrator_id": { + "name": "narrator_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "book_narrators_narrator_id_idx": { + "name": "book_narrators_narrator_id_idx", + "columns": [ + { + "expression": "narrator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_narrators_book_id_books_id_fk": { + "name": "book_narrators_book_id_books_id_fk", + "tableFrom": "book_narrators", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_narrators_narrator_id_narrators_id_fk": { + "name": "book_narrators_narrator_id_narrators_id_fk", + "tableFrom": "book_narrators", + "tableTo": "narrators", + "columnsFrom": ["narrator_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_narrators_book_id_narrator_id_pk": { + "name": "book_narrators_book_id_narrator_id_pk", + "columns": ["book_id", "narrator_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.narrators": { + "name": "narrators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "sort_name": { + "name": "sort_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "narrators_name_trgm_idx": { + "name": "narrators_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "narrators_name_unique": { + "name": "narrators_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_dir_scan_state": { + "name": "library_dir_scan_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "library_folder_id": { + "name": "library_folder_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dir_path": { + "name": "dir_path", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": true + }, + "last_seen_mtime_ms": { + "name": "last_seen_mtime_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "library_dir_scan_state_folder_dir_uidx": { + "name": "library_dir_scan_state_folder_dir_uidx", + "columns": [ + { + "expression": "library_folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dir_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "library_dir_scan_state_folder_idx": { + "name": "library_dir_scan_state_folder_idx", + "columns": [ + { + "expression": "library_folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_dir_scan_state_library_folder_id_library_folders_id_fk": { + "name": "library_dir_scan_state_library_folder_id_library_folders_id_fk", + "tableFrom": "library_dir_scan_state", + "tableTo": "library_folders", + "columnsFrom": ["library_folder_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scan_jobs": { + "name": "scan_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "triggered_by": { + "name": "triggered_by", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "added_count": { + "name": "added_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_count": { + "name": "updated_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "missing_count": { + "name": "missing_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scan_jobs_library_status_idx": { + "name": "scan_jobs_library_status_idx", + "columns": [ + { + "expression": "library_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scan_jobs_library_id_libraries_id_fk": { + "name": "scan_jobs_library_id_libraries_id_fk", + "tableFrom": "scan_jobs", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scan_jobs_status_chk": { + "name": "scan_jobs_status_chk", + "value": "\"scan_jobs\".\"status\" in ('running', 'completed', 'failed')" + }, + "scan_jobs_triggered_by_chk": { + "name": "scan_jobs_triggered_by_chk", + "value": "\"scan_jobs\".\"triggered_by\" in ('manual', 'watcher', 'schedule')" + }, + "scan_jobs_added_count_nonnegative_chk": { + "name": "scan_jobs_added_count_nonnegative_chk", + "value": "\"scan_jobs\".\"added_count\" >= 0" + }, + "scan_jobs_updated_count_nonnegative_chk": { + "name": "scan_jobs_updated_count_nonnegative_chk", + "value": "\"scan_jobs\".\"updated_count\" >= 0" + }, + "scan_jobs_missing_count_nonnegative_chk": { + "name": "scan_jobs_missing_count_nonnegative_chk", + "value": "\"scan_jobs\".\"missing_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.annotations": { + "name": "annotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cfi": { + "name": "cfi", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'yellow'" + }, + "style": { + "name": "style", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'highlight'" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chapter_title": { + "name": "chapter_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotations_user_id_idx": { + "name": "annotations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "annotations_user_book_idx": { + "name": "annotations_user_book_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotations_user_id_users_id_fk": { + "name": "annotations_user_id_users_id_fk", + "tableFrom": "annotations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "annotations_book_id_books_id_fk": { + "name": "annotations_book_id_books_id_fk", + "tableFrom": "annotations", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "annotations_style_chk": { + "name": "annotations_style_chk", + "value": "\"annotations\".\"style\" in ('highlight', 'underline', 'strikethrough', 'squiggly')" + } + }, + "isRLSEnabled": false + }, + "public.audiobook_progress": { + "name": "audiobook_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "current_file_id": { + "name": "current_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position_seconds": { + "name": "position_seconds", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "abp_user_id_idx": { + "name": "abp_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audiobook_progress_user_id_users_id_fk": { + "name": "audiobook_progress_user_id_users_id_fk", + "tableFrom": "audiobook_progress", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_progress_book_id_books_id_fk": { + "name": "audiobook_progress_book_id_books_id_fk", + "tableFrom": "audiobook_progress", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_progress_current_file_id_book_files_id_fk": { + "name": "audiobook_progress_current_file_id_book_files_id_fk", + "tableFrom": "audiobook_progress", + "tableTo": "book_files", + "columnsFrom": ["current_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_progress_user_id_book_id_pk": { + "name": "audiobook_progress_user_id_book_id_pk", + "columns": ["user_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "audiobook_progress_percentage_range_chk": { + "name": "audiobook_progress_percentage_range_chk", + "value": "\"audiobook_progress\".\"percentage\" >= 0 and \"audiobook_progress\".\"percentage\" <= 100" + }, + "audiobook_progress_position_seconds_nonnegative_chk": { + "name": "audiobook_progress_position_seconds_nonnegative_chk", + "value": "\"audiobook_progress\".\"position_seconds\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.bookmarks": { + "name": "bookmarks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cfi": { + "name": "cfi", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "position_seconds": { + "name": "position_seconds", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bookmarks_user_book_idx": { + "name": "bookmarks_user_book_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookmarks_user_book_cfi_uidx": { + "name": "bookmarks_user_book_cfi_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cfi", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"bookmarks\".\"cfi\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookmarks_user_book_pos_uidx": { + "name": "bookmarks_user_book_pos_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position_seconds", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"bookmarks\".\"position_seconds\" is not null and \"bookmarks\".\"cfi\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookmarks_user_id_users_id_fk": { + "name": "bookmarks_user_id_users_id_fk", + "tableFrom": "bookmarks", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarks_book_id_books_id_fk": { + "name": "bookmarks_book_id_books_id_fk", + "tableFrom": "bookmarks", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reader_default_preferences": { + "name": "reader_default_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format_group": { + "name": "format_group", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rdp_user_format_idx": { + "name": "rdp_user_format_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "format_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reader_default_preferences_user_id_users_id_fk": { + "name": "reader_default_preferences_user_id_users_id_fk", + "tableFrom": "reader_default_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reader_default_preferences_format_group_chk": { + "name": "reader_default_preferences_format_group_chk", + "value": "\"reader_default_preferences\".\"format_group\" in ('epub', 'pdf', 'cbx', 'audio')" + } + }, + "isRLSEnabled": false + }, + "public.reader_preferences": { + "name": "reader_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rp_user_file_idx": { + "name": "rp_user_file_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reader_preferences_user_id_users_id_fk": { + "name": "reader_preferences_user_id_users_id_fk", + "tableFrom": "reader_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reader_preferences_book_file_id_book_files_id_fk": { + "name": "reader_preferences_book_file_id_book_files_id_fk", + "tableFrom": "reader_preferences", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reading_progress": { + "name": "reading_progress", + "schema": "", + "columns": { + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cfi": { + "name": "cfi", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "position_seconds": { + "name": "position_seconds", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "kobo_location_source": { + "name": "kobo_location_source", + "type": "varchar(4096)", + "primaryKey": false, + "notNull": false + }, + "kobo_location_type": { + "name": "kobo_location_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "kobo_location_value": { + "name": "kobo_location_value", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "kobo_content_source_progress_percent": { + "name": "kobo_content_source_progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "koreader_progress": { + "name": "koreader_progress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reading_progress_user_id_idx": { + "name": "reading_progress_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reading_progress_user_updated_at_idx": { + "name": "reading_progress_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reading_progress_book_file_id_book_files_id_fk": { + "name": "reading_progress_book_file_id_book_files_id_fk", + "tableFrom": "reading_progress", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reading_progress_user_id_users_id_fk": { + "name": "reading_progress_user_id_users_id_fk", + "tableFrom": "reading_progress", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reading_progress_book_file_id_user_id_pk": { + "name": "reading_progress_book_file_id_user_id_pk", + "columns": ["book_file_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reading_progress_percentage_range_chk": { + "name": "reading_progress_percentage_range_chk", + "value": "\"reading_progress\".\"percentage\" >= 0 and \"reading_progress\".\"percentage\" <= 100" + }, + "reading_progress_page_number_nonnegative_chk": { + "name": "reading_progress_page_number_nonnegative_chk", + "value": "\"reading_progress\".\"page_number\" is null or \"reading_progress\".\"page_number\" >= 0" + }, + "reading_progress_position_seconds_nonnegative_chk": { + "name": "reading_progress_position_seconds_nonnegative_chk", + "value": "\"reading_progress\".\"position_seconds\" is null or \"reading_progress\".\"position_seconds\" >= 0" + }, + "reading_progress_kobo_content_source_progress_range_chk": { + "name": "reading_progress_kobo_content_source_progress_range_chk", + "value": "\"reading_progress\".\"kobo_content_source_progress_percent\" is null or (\"reading_progress\".\"kobo_content_source_progress_percent\" >= 0 and \"reading_progress\".\"kobo_content_source_progress_percent\" <= 100)" + } + }, + "isRLSEnabled": false + }, + "public.reading_sessions": { + "name": "reading_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "progress_delta": { + "name": "progress_delta", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "end_progress": { + "name": "end_progress", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rs_user_session_id_uidx": { + "name": "rs_user_session_id_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rs_user_started_at_idx": { + "name": "rs_user_started_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rs_book_file_started_at_idx": { + "name": "rs_book_file_started_at_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rs_user_book_file_idx": { + "name": "rs_user_book_file_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reading_sessions_user_id_users_id_fk": { + "name": "reading_sessions_user_id_users_id_fk", + "tableFrom": "reading_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reading_sessions_book_file_id_book_files_id_fk": { + "name": "reading_sessions_book_file_id_book_files_id_fk", + "tableFrom": "reading_sessions", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reading_sessions_duration_seconds_nonnegative_chk": { + "name": "reading_sessions_duration_seconds_nonnegative_chk", + "value": "\"reading_sessions\".\"duration_seconds\" >= 0" + }, + "reading_sessions_end_progress_range_chk": { + "name": "reading_sessions_end_progress_range_chk", + "value": "\"reading_sessions\".\"end_progress\" is null or (\"reading_sessions\".\"end_progress\" >= 0 and \"reading_sessions\".\"end_progress\" <= 100)" + }, + "reading_sessions_ended_after_started_chk": { + "name": "reading_sessions_ended_after_started_chk", + "value": "\"reading_sessions\".\"ended_at\" >= \"reading_sessions\".\"started_at\"" + } + }, + "isRLSEnabled": false + }, + "public.user_book_ratings": { + "name": "user_book_ratings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ubr_user_id_idx": { + "name": "ubr_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ubr_book_id_idx": { + "name": "ubr_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ubr_book_user_rating_idx": { + "name": "ubr_book_user_rating_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_book_ratings_user_id_users_id_fk": { + "name": "user_book_ratings_user_id_users_id_fk", + "tableFrom": "user_book_ratings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_book_ratings_book_id_books_id_fk": { + "name": "user_book_ratings_book_id_books_id_fk", + "tableFrom": "user_book_ratings", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_book_ratings_user_id_book_id_pk": { + "name": "user_book_ratings_user_id_book_id_pk", + "columns": ["user_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_book_ratings_rating_range_chk": { + "name": "user_book_ratings_rating_range_chk", + "value": "\"user_book_ratings\".\"rating\" >= 1 and \"user_book_ratings\".\"rating\" <= 5" + } + }, + "isRLSEnabled": false + }, + "public.user_book_status": { + "name": "user_book_status", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'unread'" + }, + "source": { + "name": "source", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ubs_user_id_idx": { + "name": "ubs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ubs_user_status_idx": { + "name": "ubs_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ubs_book_user_idx": { + "name": "ubs_book_user_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_book_status_user_id_users_id_fk": { + "name": "user_book_status_user_id_users_id_fk", + "tableFrom": "user_book_status", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_book_status_book_id_books_id_fk": { + "name": "user_book_status_book_id_books_id_fk", + "tableFrom": "user_book_status", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_book_status_user_id_book_id_pk": { + "name": "user_book_status_user_id_book_id_pk", + "columns": ["user_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_book_status_status_chk": { + "name": "user_book_status_status_chk", + "value": "\"user_book_status\".\"status\" in ('unread', 'want_to_read', 'reading', 'on_hold', 'rereading', 'read', 'skimmed', 'abandoned')" + }, + "user_book_status_source_chk": { + "name": "user_book_status_source_chk", + "value": "\"user_book_status\".\"source\" in ('auto', 'manual')" + }, + "user_book_status_finished_after_started_chk": { + "name": "user_book_status_finished_after_started_chk", + "value": "\"user_book_status\".\"finished_at\" is null or \"user_book_status\".\"started_at\" is null or \"user_book_status\".\"finished_at\" >= \"user_book_status\".\"started_at\"" + } + }, + "isRLSEnabled": false + }, + "public.user_reading_daily_stats": { + "name": "user_reading_daily_stats", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "library_id": { + "name": "library_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "reading_seconds": { + "name": "reading_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "progress_delta": { + "name": "progress_delta", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sessions_count": { + "name": "sessions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "urds_user_day_idx": { + "name": "urds_user_day_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_reading_daily_stats_user_id_users_id_fk": { + "name": "user_reading_daily_stats_user_id_users_id_fk", + "tableFrom": "user_reading_daily_stats", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_reading_daily_stats_library_id_libraries_id_fk": { + "name": "user_reading_daily_stats_library_id_libraries_id_fk", + "tableFrom": "user_reading_daily_stats", + "tableTo": "libraries", + "columnsFrom": ["library_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_reading_daily_stats_user_id_library_id_day_pk": { + "name": "user_reading_daily_stats_user_id_library_id_day_pk", + "columns": ["user_id", "library_id", "day"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_reading_daily_stats_reading_seconds_nonnegative_chk": { + "name": "user_reading_daily_stats_reading_seconds_nonnegative_chk", + "value": "\"user_reading_daily_stats\".\"reading_seconds\" >= 0" + }, + "user_reading_daily_stats_sessions_count_nonnegative_chk": { + "name": "user_reading_daily_stats_sessions_count_nonnegative_chk", + "value": "\"user_reading_daily_stats\".\"sessions_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.oidc_group_mappings": { + "name": "oidc_group_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "oidc_group_claim": { + "name": "oidc_group_claim", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_name": { + "name": "permission_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oidc_group_mappings_provider_claim_uidx": { + "name": "oidc_group_mappings_provider_claim_uidx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oidc_group_claim", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oidc_group_mappings_provider_id_oidc_providers_id_fk": { + "name": "oidc_group_mappings_provider_id_oidc_providers_id_fk", + "tableFrom": "oidc_group_mappings", + "tableTo": "oidc_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_identities": { + "name": "oidc_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "oidc_subject": { + "name": "oidc_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_issuer": { + "name": "oidc_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oidc_identities_provider_subject_uidx": { + "name": "oidc_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oidc_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_identities_user_provider_uidx": { + "name": "oidc_identities_user_provider_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_identities_issuer_subject_uidx": { + "name": "oidc_identities_issuer_subject_uidx", + "columns": [ + { + "expression": "oidc_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oidc_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_identities_user_id_idx": { + "name": "oidc_identities_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oidc_identities_user_id_users_id_fk": { + "name": "oidc_identities_user_id_users_id_fk", + "tableFrom": "oidc_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oidc_identities_provider_id_oidc_providers_id_fk": { + "name": "oidc_identities_provider_id_oidc_providers_id_fk", + "tableFrom": "oidc_identities", + "tableTo": "oidc_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_permission_grants": { + "name": "oidc_permission_grants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "permission_name": { + "name": "permission_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oidc_permission_grants_user_id_users_id_fk": { + "name": "oidc_permission_grants_user_id_users_id_fk", + "tableFrom": "oidc_permission_grants", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oidc_permission_grants_provider_id_oidc_providers_id_fk": { + "name": "oidc_permission_grants_provider_id_oidc_providers_id_fk", + "tableFrom": "oidc_permission_grants", + "tableTo": "oidc_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oidc_permission_grants_user_id_provider_id_permission_name_pk": { + "name": "oidc_permission_grants_user_id_provider_id_permission_name_pk", + "columns": ["user_id", "provider_id", "permission_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_providers": { + "name": "oidc_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "issuer_uri": { + "name": "issuer_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openid profile email'" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_mapping": { + "name": "claim_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"username\":\"preferred_username\",\"name\":\"name\",\"email\":\"email\",\"groups\":\"groups\"}'::jsonb" + }, + "auto_provision": { + "name": "auto_provision", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"enabled\":false,\"allowLocalLinking\":false,\"defaultPermissionNames\":[]}'::jsonb" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oidc_providers_enabled_order_idx": { + "name": "oidc_providers_enabled_order_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oidc_providers_slug_unique": { + "name": "oidc_providers_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "oidc_providers_issuer_uri_unique": { + "name": "oidc_providers_issuer_uri_unique", + "nullsNotDistinct": false, + "columns": ["issuer_uri"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_sessions": { + "name": "oidc_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "oidc_subject": { + "name": "oidc_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_issuer": { + "name": "oidc_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_session_id": { + "name": "oidc_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token_hint": { + "name": "id_token_hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idp_refresh_token": { + "name": "idp_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oidc_sessions_user_active_idx": { + "name": "oidc_sessions_user_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"oidc_sessions\".\"revoked\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_sessions_subject_issuer_idx": { + "name": "oidc_sessions_subject_issuer_idx", + "columns": [ + { + "expression": "oidc_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oidc_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_sessions_sid_idx": { + "name": "oidc_sessions_sid_idx", + "columns": [ + { + "expression": "oidc_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_sessions_expires_at_idx": { + "name": "oidc_sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oidc_sessions_provider_id_idx": { + "name": "oidc_sessions_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oidc_sessions_user_id_users_id_fk": { + "name": "oidc_sessions_user_id_users_id_fk", + "tableFrom": "oidc_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oidc_sessions_provider_id_oidc_providers_id_fk": { + "name": "oidc_sessions_provider_id_oidc_providers_id_fk", + "tableFrom": "oidc_sessions", + "tableTo": "oidc_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_states": { + "name": "oidc_states", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oidc_states_expires_at_idx": { + "name": "oidc_states_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oidc_states_provider_id_oidc_providers_id_fk": { + "name": "oidc_states_provider_id_oidc_providers_id_fk", + "tableFrom": "oidc_states", + "tableTo": "oidc_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_used_jtis": { + "name": "oidc_used_jtis", + "schema": "", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oidc_used_jtis_expires_at_idx": { + "name": "oidc_used_jtis_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opds_users": { + "name": "opds_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "opds_sort_order", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'recent'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "opds_users_username_lower_uidx": { + "name": "opds_users_username_lower_uidx", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "opds_users_user_id_users_id_fk": { + "name": "opds_users_user_id_users_id_fk", + "tableFrom": "opds_users", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "opds_users_username_unique": { + "name": "opds_users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_book_entitlements": { + "name": "kobo_book_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cover_image_id": { + "name": "cover_image_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "needs_legacy_numeric_removal": { + "name": "needs_legacy_numeric_removal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kobo_book_entitlements_user_id_idx": { + "name": "kobo_book_entitlements_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kobo_book_entitlements_book_id_idx": { + "name": "kobo_book_entitlements_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kobo_book_entitlements_user_id_users_id_fk": { + "name": "kobo_book_entitlements_user_id_users_id_fk", + "tableFrom": "kobo_book_entitlements", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kobo_book_entitlements_book_id_books_id_fk": { + "name": "kobo_book_entitlements_book_id_books_id_fk", + "tableFrom": "kobo_book_entitlements", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_book_entitlements_user_book_unique": { + "name": "kobo_book_entitlements_user_book_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "book_id"] + }, + "kobo_book_entitlements_user_entitlement_unique": { + "name": "kobo_book_entitlements_user_entitlement_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "entitlement_id"] + }, + "kobo_book_entitlements_user_cover_unique": { + "name": "kobo_book_entitlements_user_cover_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "cover_image_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_devices": { + "name": "kobo_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kobo_devices_user_id_idx": { + "name": "kobo_devices_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kobo_devices_user_id_users_id_fk": { + "name": "kobo_devices_user_id_users_id_fk", + "tableFrom": "kobo_devices", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_devices_token_unique": { + "name": "kobo_devices_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_library_snapshots": { + "name": "kobo_library_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kobo_library_snapshots_user_id_users_id_fk": { + "name": "kobo_library_snapshots_user_id_users_id_fk", + "tableFrom": "kobo_library_snapshots", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_library_snapshots_user_id_unique": { + "name": "kobo_library_snapshots_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_reading_states": { + "name": "kobo_reading_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at_kobo": { + "name": "created_at_kobo", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "last_modified_kobo": { + "name": "last_modified_kobo", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "priority_timestamp": { + "name": "priority_timestamp", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "current_bookmark": { + "name": "current_bookmark", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "statistics": { + "name": "statistics", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_info": { + "name": "status_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kobo_reading_states_user_id_users_id_fk": { + "name": "kobo_reading_states_user_id_users_id_fk", + "tableFrom": "kobo_reading_states", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kobo_reading_states_book_id_books_id_fk": { + "name": "kobo_reading_states_book_id_books_id_fk", + "tableFrom": "kobo_reading_states", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_reading_states_user_id_book_id_unique": { + "name": "kobo_reading_states_user_id_book_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "book_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_snapshot_books": { + "name": "kobo_snapshot_books", + "schema": "", + "columns": { + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "synced": { + "name": "synced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pending_delete": { + "name": "pending_delete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_new": { + "name": "is_new", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_by_device": { + "name": "removed_by_device", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "delivery_hash": { + "name": "delivery_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "metadata_hash": { + "name": "metadata_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kobo_snapshot_books_snapshot_synced_book_idx": { + "name": "kobo_snapshot_books_snapshot_synced_book_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "synced", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk": { + "name": "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk", + "tableFrom": "kobo_snapshot_books", + "tableTo": "kobo_library_snapshots", + "columnsFrom": ["snapshot_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kobo_snapshot_books_book_id_books_id_fk": { + "name": "kobo_snapshot_books_book_id_books_id_fk", + "tableFrom": "kobo_snapshot_books", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kobo_snapshot_books_snapshot_id_book_id_pk": { + "name": "kobo_snapshot_books_snapshot_id_book_id_pk", + "columns": ["snapshot_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kobo_sync_settings": { + "name": "kobo_sync_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reading_threshold": { + "name": "reading_threshold", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "finished_threshold": { + "name": "finished_threshold", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 99 + }, + "convert_to_kepub": { + "name": "convert_to_kepub", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "force_enable_hyphenation": { + "name": "force_enable_hyphenation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kepub_conversion_limit_mb": { + "name": "kepub_conversion_limit_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "two_way_progress_sync": { + "name": "two_way_progress_sync", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kobo_sync_settings_user_id_users_id_fk": { + "name": "kobo_sync_settings_user_id_users_id_fk", + "tableFrom": "kobo_sync_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kobo_sync_settings_user_id_unique": { + "name": "kobo_sync_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "kobo_sync_settings_reading_threshold_range_chk": { + "name": "kobo_sync_settings_reading_threshold_range_chk", + "value": "\"kobo_sync_settings\".\"reading_threshold\" >= 0 and \"kobo_sync_settings\".\"reading_threshold\" <= 100" + }, + "kobo_sync_settings_finished_threshold_range_chk": { + "name": "kobo_sync_settings_finished_threshold_range_chk", + "value": "\"kobo_sync_settings\".\"finished_threshold\" >= 0 and \"kobo_sync_settings\".\"finished_threshold\" <= 100" + }, + "kobo_sync_settings_conversion_limit_nonnegative_chk": { + "name": "kobo_sync_settings_conversion_limit_nonnegative_chk", + "value": "\"kobo_sync_settings\".\"kepub_conversion_limit_mb\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.book_dock_files": { + "name": "book_dock_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "absolute_path": { + "name": "absolute_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "embedded_metadata": { + "name": "embedded_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "selected_metadata": { + "name": "selected_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fetched_metadata": { + "name": "fetched_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_library_id": { + "name": "target_library_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "target_folder_id": { + "name": "target_folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fetched_metadata_sources": { + "name": "fetched_metadata_sources", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_edited_at": { + "name": "metadata_edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "book_dock_files_status_idx": { + "name": "book_dock_files_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_dock_files_uploaded_by_idx": { + "name": "book_dock_files_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_dock_files_target_library_id_libraries_id_fk": { + "name": "book_dock_files_target_library_id_libraries_id_fk", + "tableFrom": "book_dock_files", + "tableTo": "libraries", + "columnsFrom": ["target_library_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "book_dock_files_target_folder_id_library_folders_id_fk": { + "name": "book_dock_files_target_folder_id_library_folders_id_fk", + "tableFrom": "book_dock_files", + "tableTo": "library_folders", + "columnsFrom": ["target_folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "book_dock_files_uploaded_by_users_id_fk": { + "name": "book_dock_files_uploaded_by_users_id_fk", + "tableFrom": "book_dock_files", + "tableTo": "users", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "book_dock_files_absolute_path_unique": { + "name": "book_dock_files_absolute_path_unique", + "nullsNotDistinct": false, + "columns": ["absolute_path"] + } + }, + "policies": {}, + "checkConstraints": { + "book_dock_files_status_chk": { + "name": "book_dock_files_status_chk", + "value": "\"book_dock_files\".\"status\" in ('pending', 'extracting', 'fetching', 'ready', 'error')" + }, + "book_dock_files_confidence_range_chk": { + "name": "book_dock_files_confidence_range_chk", + "value": "\"book_dock_files\".\"confidence\" is null or (\"book_dock_files\".\"confidence\" >= 0 and \"book_dock_files\".\"confidence\" <= 100)" + } + }, + "isRLSEnabled": false + }, + "public.file_write_log": { + "name": "file_write_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "fields_written": { + "name": "fields_written", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "triggered_by": { + "name": "triggered_by", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "written_at": { + "name": "written_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fwl_book_id_written_at_idx": { + "name": "fwl_book_id_written_at_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "written_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_write_log_book_id_books_id_fk": { + "name": "file_write_log_book_id_books_id_fk", + "tableFrom": "file_write_log", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_write_log_book_file_id_book_files_id_fk": { + "name": "file_write_log_book_file_id_book_files_id_fk", + "tableFrom": "file_write_log", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "file_write_log_user_id_users_id_fk": { + "name": "file_write_log_user_id_users_id_fk", + "tableFrom": "file_write_log", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_write_log_status_chk": { + "name": "file_write_log_status_chk", + "value": "\"file_write_log\".\"status\" in ('success', 'skipped', 'failed')" + }, + "file_write_log_triggered_by_chk": { + "name": "file_write_log_triggered_by_chk", + "value": "\"file_write_log\".\"triggered_by\" in ('auto', 'sync')" + }, + "file_write_log_duration_ms_nonnegative_chk": { + "name": "file_write_log_duration_ms_nonnegative_chk", + "value": "\"file_write_log\".\"duration_ms\" is null or \"file_write_log\".\"duration_ms\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_templates": { + "name": "email_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_templates_one_default_per_user_uidx": { + "name": "email_templates_one_default_per_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_templates\".\"is_default\" = true and \"email_templates\".\"user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_templates_system_name_unique": { + "name": "email_templates_system_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_templates\".\"user_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_templates_user_id_users_id_fk": { + "name": "email_templates_user_id_users_id_fk", + "tableFrom": "email_templates", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_templates_user_id_name_unique": { + "name": "email_templates_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_providers": { + "name": "email_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "from_address": { + "name": "from_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "auth": { + "name": "auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ssl": { + "name": "ssl", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "start_tls": { + "name": "start_tls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_system_provider": { + "name": "is_system_provider", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tls_reject_unauthorized": { + "name": "tls_reject_unauthorized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_providers_one_default_per_user_uidx": { + "name": "email_providers_one_default_per_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_providers\".\"is_default\" = true and \"email_providers\".\"user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_providers_one_system_provider_uidx": { + "name": "email_providers_one_system_provider_uidx", + "columns": [ + { + "expression": "is_system_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_providers\".\"is_system_provider\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_providers_user_id_users_id_fk": { + "name": "email_providers_user_id_users_id_fk", + "tableFrom": "email_providers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_providers_user_id_name_unique": { + "name": "email_providers_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + }, + "email_providers_id_user_id_unique": { + "name": "email_providers_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "email_providers_port_range_chk": { + "name": "email_providers_port_range_chk", + "value": "\"email_providers\".\"port\" >= 1 and \"email_providers\".\"port\" <= 65535" + } + }, + "isRLSEnabled": false + }, + "public.email_recipient_group_members": { + "name": "email_recipient_group_members", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_recipient_group_members_user_id_users_id_fk": { + "name": "email_recipient_group_members_user_id_users_id_fk", + "tableFrom": "email_recipient_group_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_recipient_group_members_group_id_email_recipient_groups_id_fk": { + "name": "email_recipient_group_members_group_id_email_recipient_groups_id_fk", + "tableFrom": "email_recipient_group_members", + "tableTo": "email_recipient_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_recipient_group_members_recipient_id_email_recipients_id_fk": { + "name": "email_recipient_group_members_recipient_id_email_recipients_id_fk", + "tableFrom": "email_recipient_group_members", + "tableTo": "email_recipients", + "columnsFrom": ["recipient_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_recipient_group_members_group_user_fk": { + "name": "email_recipient_group_members_group_user_fk", + "tableFrom": "email_recipient_group_members", + "tableTo": "email_recipient_groups", + "columnsFrom": ["group_id", "user_id"], + "columnsTo": ["id", "user_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "email_recipient_group_members_recipient_user_fk": { + "name": "email_recipient_group_members_recipient_user_fk", + "tableFrom": "email_recipient_group_members", + "tableTo": "email_recipients", + "columnsFrom": ["recipient_id", "user_id"], + "columnsTo": ["id", "user_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_recipient_group_members_group_id_recipient_id_pk": { + "name": "email_recipient_group_members_group_id_recipient_id_pk", + "columns": ["group_id", "recipient_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_recipient_groups": { + "name": "email_recipient_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "default_template_id": { + "name": "default_template_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_recipient_groups_user_id_users_id_fk": { + "name": "email_recipient_groups_user_id_users_id_fk", + "tableFrom": "email_recipient_groups", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_recipient_groups_default_template_id_email_templates_id_fk": { + "name": "email_recipient_groups_default_template_id_email_templates_id_fk", + "tableFrom": "email_recipient_groups", + "tableTo": "email_templates", + "columnsFrom": ["default_template_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_recipient_groups_user_id_name_unique": { + "name": "email_recipient_groups_user_id_name_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name"] + }, + "email_recipient_groups_id_user_id_unique": { + "name": "email_recipient_groups_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_recipients": { + "name": "email_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "preferred_format": { + "name": "preferred_format", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "default_template_id": { + "name": "default_template_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_recipients_user_email_lower_uidx": { + "name": "email_recipients_user_email_lower_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_recipients_one_default_per_user_uidx": { + "name": "email_recipients_one_default_per_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_recipients\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_recipients_user_id_users_id_fk": { + "name": "email_recipients_user_id_users_id_fk", + "tableFrom": "email_recipients", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_recipients_default_template_id_email_templates_id_fk": { + "name": "email_recipients_default_template_id_email_templates_id_fk", + "tableFrom": "email_recipients", + "tableTo": "email_templates", + "columnsFrom": ["default_template_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_recipients_user_id_email_unique": { + "name": "email_recipients_user_id_email_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "email"] + }, + "email_recipients_id_user_id_unique": { + "name": "email_recipients_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "email_recipients_device_type_chk": { + "name": "email_recipients_device_type_chk", + "value": "\"email_recipients\".\"device_type\" is null or \"email_recipients\".\"device_type\" in ('kindle', 'kobo', 'other')" + }, + "email_recipients_preferred_format_chk": { + "name": "email_recipients_preferred_format_chk", + "value": "\"email_recipients\".\"preferred_format\" is null or \"email_recipients\".\"preferred_format\" in ('epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr')" + } + }, + "isRLSEnabled": false + }, + "public.email_preferences": { + "name": "email_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "default_provider_id": { + "name": "default_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "default_recipient_id": { + "name": "default_recipient_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "default_template_id": { + "name": "default_template_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_preferences_user_id_users_id_fk": { + "name": "email_preferences_user_id_users_id_fk", + "tableFrom": "email_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_preferences_default_provider_id_email_providers_id_fk": { + "name": "email_preferences_default_provider_id_email_providers_id_fk", + "tableFrom": "email_preferences", + "tableTo": "email_providers", + "columnsFrom": ["default_provider_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_preferences_default_recipient_id_email_recipients_id_fk": { + "name": "email_preferences_default_recipient_id_email_recipients_id_fk", + "tableFrom": "email_preferences", + "tableTo": "email_recipients", + "columnsFrom": ["default_recipient_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_preferences_default_template_id_email_templates_id_fk": { + "name": "email_preferences_default_template_id_email_templates_id_fk", + "tableFrom": "email_preferences", + "tableTo": "email_templates", + "columnsFrom": ["default_template_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_preferences_default_recipient_owner_fk": { + "name": "email_preferences_default_recipient_owner_fk", + "tableFrom": "email_preferences", + "tableTo": "email_recipients", + "columnsFrom": ["default_recipient_id", "user_id"], + "columnsTo": ["id", "user_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_preferences_user_id_unique": { + "name": "email_preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_send_log": { + "name": "email_send_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "template_id": { + "name": "template_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "to_email": { + "name": "to_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "to_name": { + "name": "to_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_send_log_user_created_at_idx": { + "name": "email_send_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_send_log_created_at_idx": { + "name": "email_send_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_send_log_status_next_retry_idx": { + "name": "email_send_log_status_next_retry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_send_log_user_id_users_id_fk": { + "name": "email_send_log_user_id_users_id_fk", + "tableFrom": "email_send_log", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_send_log_book_id_books_id_fk": { + "name": "email_send_log_book_id_books_id_fk", + "tableFrom": "email_send_log", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_send_log_book_file_id_book_files_id_fk": { + "name": "email_send_log_book_file_id_book_files_id_fk", + "tableFrom": "email_send_log", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_send_log_provider_id_email_providers_id_fk": { + "name": "email_send_log_provider_id_email_providers_id_fk", + "tableFrom": "email_send_log", + "tableTo": "email_providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_send_log_template_id_email_templates_id_fk": { + "name": "email_send_log_template_id_email_templates_id_fk", + "tableFrom": "email_send_log", + "tableTo": "email_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_send_log_status_chk": { + "name": "email_send_log_status_chk", + "value": "\"email_send_log\".\"status\" in ('pending', 'sent', 'failed')" + }, + "email_send_log_attempt_count_nonnegative_chk": { + "name": "email_send_log_attempt_count_nonnegative_chk", + "value": "\"email_send_log\".\"attempt_count\" >= 0" + }, + "email_send_log_sent_after_created_chk": { + "name": "email_send_log_sent_after_created_chk", + "value": "\"email_send_log\".\"sent_at\" is null or \"email_send_log\".\"sent_at\" >= \"email_send_log\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_url": { + "name": "action_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_id_idx": { + "name": "notifications_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_unread_idx": { + "name": "notifications_user_unread_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_at_idx": { + "name": "notifications_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dismissed_duplicate_pairs": { + "name": "dismissed_duplicate_pairs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id_a": { + "name": "entity_id_a", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entity_id_b": { + "name": "entity_id_b", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_by": { + "name": "dismissed_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dismissed_duplicate_pairs_entity_a_idx": { + "name": "dismissed_duplicate_pairs_entity_a_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id_a", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dismissed_duplicate_pairs_entity_b_idx": { + "name": "dismissed_duplicate_pairs_entity_b_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id_b", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dismissed_duplicate_pairs_dismissed_by_users_id_fk": { + "name": "dismissed_duplicate_pairs_dismissed_by_users_id_fk", + "tableFrom": "dismissed_duplicate_pairs", + "tableTo": "users", + "columnsFrom": ["dismissed_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dismissed_duplicate_pairs_unique": { + "name": "dismissed_duplicate_pairs_unique", + "nullsNotDistinct": false, + "columns": ["entity_type", "entity_id_a", "entity_id_b"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dismissed_inline_duplicate_pairs": { + "name": "dismissed_inline_duplicate_pairs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "value_a": { + "name": "value_a", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "value_b": { + "name": "value_b", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_by": { + "name": "dismissed_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dismissed_inline_pairs_entity_a_idx": { + "name": "dismissed_inline_pairs_entity_a_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_a", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dismissed_inline_pairs_entity_b_idx": { + "name": "dismissed_inline_pairs_entity_b_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_b", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dismissed_inline_duplicate_pairs_dismissed_by_users_id_fk": { + "name": "dismissed_inline_duplicate_pairs_dismissed_by_users_id_fk", + "tableFrom": "dismissed_inline_duplicate_pairs", + "tableTo": "users", + "columnsFrom": ["dismissed_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dismissed_inline_duplicate_pairs_unique": { + "name": "dismissed_inline_duplicate_pairs_unique", + "nullsNotDistinct": false, + "columns": ["entity_type", "value_a", "value_b"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entity_duplicate_candidates": { + "name": "entity_duplicate_candidates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id_a": { + "name": "entity_id_a", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entity_id_b": { + "name": "entity_id_b", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sim_score": { + "name": "sim_score", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "entity_dup_candidates_type_sim_idx": { + "name": "entity_dup_candidates_type_sim_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sim_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entity_dup_candidates_entity_a_idx": { + "name": "entity_dup_candidates_entity_a_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id_a", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entity_dup_candidates_entity_b_idx": { + "name": "entity_dup_candidates_entity_b_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id_b", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_duplicate_candidates_unique": { + "name": "entity_duplicate_candidates_unique", + "nullsNotDistinct": false, + "columns": ["entity_type", "entity_id_a", "entity_id_b"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entity_duplicate_scan_status": { + "name": "entity_duplicate_scan_status", + "schema": "", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": true, + "notNull": true + }, + "is_computing": { + "name": "is_computing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_pairs": { + "name": "total_pairs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "threshold": { + "name": "threshold", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_count": { + "name": "total_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fonts": { + "name": "user_fonts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "family_name": { + "name": "family_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "stored_file_name": { + "name": "stored_file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 400 + }, + "style": { + "name": "style", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uf_user_hash_uidx": { + "name": "uf_user_hash_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uf_user_family_weight_style_uidx": { + "name": "uf_user_family_weight_style_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "family_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "style", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fonts_user_id_users_id_fk": { + "name": "user_fonts_user_id_users_id_fk", + "tableFrom": "user_fonts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_fonts_format_chk": { + "name": "user_fonts_format_chk", + "value": "\"user_fonts\".\"format\" in ('ttf', 'otf', 'woff', 'woff2')" + }, + "user_fonts_weight_chk": { + "name": "user_fonts_weight_chk", + "value": "\"user_fonts\".\"weight\" >= 100 and \"user_fonts\".\"weight\" <= 900 and \"user_fonts\".\"weight\" % 100 = 0" + }, + "user_fonts_style_chk": { + "name": "user_fonts_style_chk", + "value": "\"user_fonts\".\"style\" in ('normal', 'italic')" + } + }, + "isRLSEnabled": false + }, + "public.book_file_chapters": { + "name": "book_file_chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "href": { + "name": "href", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "spine_index": { + "name": "spine_index", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "book_file_chapters_book_file_id_chapter_index_uidx": { + "name": "book_file_chapters_book_file_id_chapter_index_uidx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chapter_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_file_chapters_book_file_id_idx": { + "name": "book_file_chapters_book_file_id_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_file_chapters_book_file_id_book_files_id_fk": { + "name": "book_file_chapters_book_file_id_book_files_id_fk", + "tableFrom": "book_file_chapters", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_file_hash_history": { + "name": "book_file_hash_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "book_file_hash_history_book_file_id_file_hash_idx": { + "name": "book_file_hash_history_book_file_id_file_hash_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "book_file_hash_history_file_hash_idx": { + "name": "book_file_hash_history_file_hash_idx", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "book_file_hash_history_book_file_id_book_files_id_fk": { + "name": "book_file_hash_history_book_file_id_book_files_id_fk", + "tableFrom": "book_file_hash_history", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "book_file_hash_history_reason_chk": { + "name": "book_file_hash_history_reason_chk", + "value": "\"book_file_hash_history\".\"reason\" in ('file_write', 'external_change', 'rescan')" + } + }, + "isRLSEnabled": false + }, + "public.koreader_device_progress": { + "name": "koreader_device_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "book_file_id": { + "name": "book_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device": { + "name": "device", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'KOReader'" + }, + "device_id": { + "name": "device_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_timestamp": { + "name": "sync_timestamp", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "orphaned": { + "name": "orphaned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "orphaned_hash": { + "name": "orphaned_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "koreader_device_progress_book_user_device_uidx": { + "name": "koreader_device_progress_book_user_device_uidx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"koreader_device_progress\".\"orphaned\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_device_progress_orphaned_hash_idx": { + "name": "koreader_device_progress_orphaned_hash_idx", + "columns": [ + { + "expression": "orphaned_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"koreader_device_progress\".\"orphaned\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_device_progress_user_updated_at_idx": { + "name": "koreader_device_progress_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_device_progress_book_file_id_idx": { + "name": "koreader_device_progress_book_file_id_idx", + "columns": [ + { + "expression": "book_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"koreader_device_progress\".\"book_file_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "koreader_device_progress_book_file_id_book_files_id_fk": { + "name": "koreader_device_progress_book_file_id_book_files_id_fk", + "tableFrom": "koreader_device_progress", + "tableTo": "book_files", + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "koreader_device_progress_user_id_users_id_fk": { + "name": "koreader_device_progress_user_id_users_id_fk", + "tableFrom": "koreader_device_progress", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "koreader_device_progress_percentage_range_chk": { + "name": "koreader_device_progress_percentage_range_chk", + "value": "\"koreader_device_progress\".\"percentage\" is null or (\"koreader_device_progress\".\"percentage\" >= 0 and \"koreader_device_progress\".\"percentage\" <= 1)" + } + }, + "isRLSEnabled": false + }, + "public.koreader_users": { + "name": "koreader_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_md5": { + "name": "password_md5", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "sync_enabled": { + "name": "sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "koreader_users_user_id_uidx": { + "name": "koreader_users_user_id_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "koreader_users_username_uidx": { + "name": "koreader_users_username_uidx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "koreader_users_user_id_users_id_fk": { + "name": "koreader_users_user_id_users_id_fk", + "tableFrom": "koreader_users", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.achievements": { + "name": "achievements", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "icon_name": { + "name": "icon_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "achievements_category_sort_idx": { + "name": "achievements_category_sort_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "achievements_group_key_idx": { + "name": "achievements_group_key_idx", + "columns": [ + { + "expression": "group_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "achievements_category_chk": { + "name": "achievements_category_chk", + "value": "\"achievements\".\"category\" in ('reading', 'library', 'exploration', 'dedication')" + }, + "achievements_rarity_chk": { + "name": "achievements_rarity_chk", + "value": "\"achievements\".\"rarity\" in ('common', 'rare', 'epic', 'legendary')" + }, + "achievements_tier_chk": { + "name": "achievements_tier_chk", + "value": "\"achievements\".\"tier\" is null or (\"achievements\".\"tier\" >= 1 and \"achievements\".\"tier\" <= 4)" + } + }, + "isRLSEnabled": false + }, + "public.user_achievements": { + "name": "user_achievements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "achievement_key": { + "name": "achievement_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "awarded_at": { + "name": "awarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "context_json": { + "name": "context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_achievements_user_key_uidx": { + "name": "user_achievements_user_key_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "achievement_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achievements_user_id_idx": { + "name": "user_achievements_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achievements_user_awarded_at_idx": { + "name": "user_achievements_user_awarded_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "awarded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_achievements_user_id_users_id_fk": { + "name": "user_achievements_user_id_users_id_fk", + "tableFrom": "user_achievements", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_achievements_achievement_key_achievements_key_fk": { + "name": "user_achievements_achievement_key_achievements_key_fk", + "tableFrom": "user_achievements", + "tableTo": "achievements", + "columnsFrom": ["achievement_key"], + "columnsTo": ["key"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hardcover_book_state": { + "name": "hardcover_book_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hardcover_book_id": { + "name": "hardcover_book_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hardcover_edition_id": { + "name": "hardcover_edition_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hardcover_user_book_id": { + "name": "hardcover_user_book_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hardcover_read_id": { + "name": "hardcover_read_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "match_method": { + "name": "match_method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "match_error": { + "name": "match_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_synced_status": { + "name": "last_synced_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "last_synced_progress": { + "name": "last_synced_progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "last_synced_rating": { + "name": "last_synced_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_synced_started_at": { + "name": "last_synced_started_at", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "last_synced_finished_at": { + "name": "last_synced_finished_at", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hardcover_book_state_user_id_users_id_fk": { + "name": "hardcover_book_state_user_id_users_id_fk", + "tableFrom": "hardcover_book_state", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hardcover_book_state_book_id_books_id_fk": { + "name": "hardcover_book_state_book_id_books_id_fk", + "tableFrom": "hardcover_book_state", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hardcover_book_state_user_book_uidx": { + "name": "hardcover_book_state_user_book_uidx", + "nullsNotDistinct": false, + "columns": ["user_id", "book_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hardcover_user_settings": { + "name": "hardcover_user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "api_token": { + "name": "api_token", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_sync_on_status_change": { + "name": "auto_sync_on_status_change", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_sync_on_progress_update": { + "name": "auto_sync_on_progress_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_sync_on_rating_change": { + "name": "auto_sync_on_rating_change", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "privacy_setting_id": { + "name": "privacy_setting_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hardcover_user_settings_user_id_users_id_fk": { + "name": "hardcover_user_settings_user_id_users_id_fk", + "tableFrom": "hardcover_user_settings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hardcover_user_settings_user_id_unique": { + "name": "hardcover_user_settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "up_user_category_idx": { + "name": "up_user_category_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.content_filter_type": { + "name": "content_filter_type", + "schema": "public", + "values": ["include", "exclude"] + }, + "public.library_access_level": { + "name": "library_access_level", + "schema": "public", + "values": ["viewer", "editor", "owner"] + }, + "public.user_avatar_source": { + "name": "user_avatar_source", + "schema": "public", + "values": ["none", "external", "uploaded"] + }, + "public.opds_sort_order": { + "name": "opds_sort_order", + "schema": "public", + "values": ["recent", "title_asc", "title_desc", "author_asc", "author_desc", "series_asc", "series_desc"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index 00aa7c32d..f2e6016e4 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1780622112610, "tag": "0014_kobo-two-way-progress-sync", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1780678523024, + "tag": "0015_add_kobo_id", + "breakpoints": true } ] } diff --git a/server/src/db/schema/metadata.ts b/server/src/db/schema/metadata.ts index 0391d2ca2..7605ee742 100644 --- a/server/src/db/schema/metadata.ts +++ b/server/src/db/schema/metadata.ts @@ -58,6 +58,7 @@ export const bookMetadata = pgTable( hardcoverId: varchar('hardcover_id', { length: 255 }), openLibraryId: varchar('open_library_id', { length: 50 }), itunesId: varchar('itunes_id', { length: 50 }), + koboId: varchar('kobo_id', { length: 255 }), metadataScore: integer('metadata_score'), lastMetadataFetchAt: timestamp('last_metadata_fetch_at', { withTimezone: true }), embedding: embedding256('embedding'), diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts index e670d0e82..97aafb81b 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts @@ -240,6 +240,7 @@ describe('BookMetadataFetchOrchestratorService', () => { providerIds: { [MetadataProviderKey.GOOGLE]: 'g1', [MetadataProviderKey.AUDIBLE]: 'a1', + [MetadataProviderKey.KOBO]: 'kobo-1', }, skippedFields: [], }); @@ -256,6 +257,7 @@ describe('BookMetadataFetchOrchestratorService', () => { abridged: false, googleBooksId: 'g1', audibleId: 'a1', + koboId: 'kobo-1', }), ); expect(metadataService.replaceAuthors).toHaveBeenCalledWith(88, [{ name: 'Author A', sortName: null }]); diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts index d0f602328..07a19add5 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts @@ -288,6 +288,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst if (filteredProviderIds[MetadataProviderKey.OPEN_LIBRARY]) scalarFields.openLibraryId = filteredProviderIds[MetadataProviderKey.OPEN_LIBRARY]; if (filteredProviderIds[MetadataProviderKey.ITUNES]) scalarFields.itunesId = filteredProviderIds[MetadataProviderKey.ITUNES]; if (filteredProviderIds[MetadataProviderKey.AUDIBLE]) scalarFields.audibleId = filteredProviderIds[MetadataProviderKey.AUDIBLE]; + if (filteredProviderIds[MetadataProviderKey.KOBO]) scalarFields.koboId = filteredProviderIds[MetadataProviderKey.KOBO]; if (filteredProviderIds[MetadataProviderKey.COMICVINE]) scalarFields.comicvineId = filteredProviderIds[MetadataProviderKey.COMICVINE]; if (filteredProviderIds[MetadataProviderKey.RANOBEDB]) scalarFields.ranobedbId = filteredProviderIds[MetadataProviderKey.RANOBEDB]; @@ -376,6 +377,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst openLibraryId?: string | null; itunesId?: string | null; audibleId?: string | null; + koboId?: string | null; comicvineId?: string | null; ranobedbId?: string | null; }): Partial> { @@ -387,6 +389,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst if (meta.openLibraryId) ids[MetadataProviderKey.OPEN_LIBRARY] = meta.openLibraryId; if (meta.itunesId) ids[MetadataProviderKey.ITUNES] = meta.itunesId; if (meta.audibleId) ids[MetadataProviderKey.AUDIBLE] = meta.audibleId; + if (meta.koboId) ids[MetadataProviderKey.KOBO] = meta.koboId; if (meta.comicvineId) ids[MetadataProviderKey.COMICVINE] = meta.comicvineId; if (meta.ranobedbId) ids[MetadataProviderKey.RANOBEDB] = meta.ranobedbId; return ids; diff --git a/server/src/modules/book-metadata-lock/book-metadata-lock.service.ts b/server/src/modules/book-metadata-lock/book-metadata-lock.service.ts index c4c3275df..9dcc2f7d4 100644 --- a/server/src/modules/book-metadata-lock/book-metadata-lock.service.ts +++ b/server/src/modules/book-metadata-lock/book-metadata-lock.service.ts @@ -16,6 +16,7 @@ const PROVIDER_ID_LOCK_FIELDS_BY_PROVIDER: Partial { openLibraryId: null, itunesId: null, audibleId: null, + koboId: 'beautiful-ugly-3', comicvineId: null, ranobedbId: 'ranobe-detail', chapters: null, @@ -3096,6 +3097,7 @@ describe('BookService', () => { expect(result.comicMetadata).toEqual(expect.objectContaining({ issueNumber: '1', teams: ['House Atreides'] })); expect(result.rating).toBe(5); expect(result.providerIds.google).toBe('g1'); + expect(result.providerIds.kobo).toBe('beautiful-ugly-3'); expect(result.providerIds.ranobedb).toBe('ranobe-detail'); expect(result.fileWriteStatus).toEqual({ enabled: true, @@ -3147,6 +3149,7 @@ describe('BookService', () => { openLibraryId: null, itunesId: null, audibleId: null, + koboId: null, comicvineId: null, ranobedbId: null, chapters: null, diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index 5d5e9bd94..1fa6a3fe6 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -263,6 +263,7 @@ export class BookService { openLibraryId?: string | null; itunesId?: string | null; audibleId?: string | null; + koboId?: string | null; comicvineId?: string | null; ranobedbId?: string | null; }): Partial> { @@ -274,6 +275,7 @@ export class BookService { if (meta.openLibraryId) providerIds[MetadataProviderKey.OPEN_LIBRARY] = meta.openLibraryId; if (meta.itunesId) providerIds[MetadataProviderKey.ITUNES] = meta.itunesId; if (meta.audibleId) providerIds[MetadataProviderKey.AUDIBLE] = meta.audibleId; + if (meta.koboId) providerIds[MetadataProviderKey.KOBO] = meta.koboId; if (meta.comicvineId) providerIds[MetadataProviderKey.COMICVINE] = meta.comicvineId; if (meta.ranobedbId) providerIds[MetadataProviderKey.RANOBEDB] = meta.ranobedbId; return providerIds; @@ -282,7 +284,16 @@ export class BookService { private applyResolvedProviderIds( dto: Pick< UpdateBookMetadataDto, - 'googleBooksId' | 'goodreadsId' | 'amazonId' | 'hardcoverId' | 'openLibraryId' | 'itunesId' | 'audibleId' | 'comicvineId' | 'ranobedbId' + | 'googleBooksId' + | 'goodreadsId' + | 'amazonId' + | 'hardcoverId' + | 'openLibraryId' + | 'itunesId' + | 'audibleId' + | 'koboId' + | 'comicvineId' + | 'ranobedbId' >, providerIds: Partial>, ): void { @@ -293,6 +304,7 @@ export class BookService { if (providerIds[MetadataProviderKey.OPEN_LIBRARY]) dto.openLibraryId = providerIds[MetadataProviderKey.OPEN_LIBRARY]; if (providerIds[MetadataProviderKey.ITUNES]) dto.itunesId = providerIds[MetadataProviderKey.ITUNES]; if (providerIds[MetadataProviderKey.AUDIBLE]) dto.audibleId = providerIds[MetadataProviderKey.AUDIBLE]; + if (providerIds[MetadataProviderKey.KOBO]) dto.koboId = providerIds[MetadataProviderKey.KOBO]; if (providerIds[MetadataProviderKey.COMICVINE]) dto.comicvineId = providerIds[MetadataProviderKey.COMICVINE]; if (providerIds[MetadataProviderKey.RANOBEDB]) dto.ranobedbId = providerIds[MetadataProviderKey.RANOBEDB]; } @@ -1225,6 +1237,7 @@ export class BookService { if (dto.openLibraryId !== undefined) scalarFields.openLibraryId = dto.openLibraryId ?? null; if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null; if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null; + if (dto.koboId !== undefined) scalarFields.koboId = dto.koboId ?? null; if (dto.comicvineId !== undefined) scalarFields.comicvineId = dto.comicvineId ?? null; if (dto.ranobedbId !== undefined) scalarFields.ranobedbId = dto.ranobedbId ?? null; if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null; @@ -2087,6 +2100,7 @@ export class BookService { openLibraryId?: string; itunesId?: string; audibleId?: string; + koboId?: string; comicvineId?: string; ranobedbId?: string; audioMetadata?: { @@ -2470,6 +2484,7 @@ export class BookService { [MetadataProviderKey.OPEN_LIBRARY]: meta?.openLibraryId ?? null, [MetadataProviderKey.ITUNES]: meta?.itunesId ?? null, [MetadataProviderKey.AUDIBLE]: meta?.audibleId ?? null, + [MetadataProviderKey.KOBO]: meta?.koboId ?? null, [MetadataProviderKey.COMICVINE]: meta?.comicvineId ?? null, [MetadataProviderKey.RANOBEDB]: meta?.ranobedbId ?? null, }, @@ -2577,6 +2592,7 @@ export class BookService { hardcoverId: parsed.hardcoverId, openLibraryId: parsed.openLibraryId, itunesId: parsed.itunesId, + koboId: parsed.koboId, ranobedbId: parsed.ranobedbId, authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined, genres: parsed.genres.length > 0 ? parsed.genres : undefined, @@ -2606,6 +2622,7 @@ export class BookService { hardcoverId: parsed.hardcoverId, openLibraryId: parsed.openLibraryId, itunesId: parsed.itunesId, + koboId: parsed.koboId, ranobedbId: parsed.ranobedbId, authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined, genres: parsed.genres.length > 0 ? parsed.genres : undefined, @@ -2653,6 +2670,7 @@ export class BookService { hardcoverId: parsed.hardcoverId, openLibraryId: parsed.openLibraryId, itunesId: parsed.itunesId, + koboId: parsed.koboId, ranobedbId: parsed.ranobedbId, authors: parsed.authors.length > 0 ? parsed.authors.map((a) => a.name) : undefined, genres: cbzGenres.length > 0 ? cbzGenres : undefined, diff --git a/server/src/modules/book/dto/update-book-metadata.dto.ts b/server/src/modules/book/dto/update-book-metadata.dto.ts index 0133719c5..e1b4af7ec 100644 --- a/server/src/modules/book/dto/update-book-metadata.dto.ts +++ b/server/src/modules/book/dto/update-book-metadata.dto.ts @@ -51,6 +51,7 @@ export class UpdateBookMetadataDto { @IsOptional() @IsString() @MaxLength(50) openLibraryId?: string | null; @IsOptional() @IsString() @MaxLength(50) itunesId?: string | null; @IsOptional() @IsString() @MaxLength(20) audibleId?: string | null; + @IsOptional() @IsString() @MaxLength(255) koboId?: string | null; @IsOptional() @ValidateNested() @Type(() => AudioMetadataDto) audioMetadata?: AudioMetadataDto; @IsOptional() @IsString() @MaxLength(50) comicvineId?: string | null; @IsOptional() @IsString() @MaxLength(50) ranobedbId?: string | null; diff --git a/server/src/modules/cover/providers/itunes-cover-provider.test.ts b/server/src/modules/cover/providers/itunes-cover-provider.test.ts index 31fcce46d..9f3f12697 100644 --- a/server/src/modules/cover/providers/itunes-cover-provider.test.ts +++ b/server/src/modules/cover/providers/itunes-cover-provider.test.ts @@ -14,6 +14,7 @@ const baseConfig: ProviderConfigurations = { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; function mockJsonResponse(body: unknown, status = 200): Response { diff --git a/server/src/modules/file-write/file-write.constants.ts b/server/src/modules/file-write/file-write.constants.ts index 25f411648..34f41d7ad 100644 --- a/server/src/modules/file-write/file-write.constants.ts +++ b/server/src/modules/file-write/file-write.constants.ts @@ -25,6 +25,7 @@ export const COMIC_INFO_PROVIDER_ID_KEYS = [ 'hardcoverId', 'googleBooksId', 'openLibraryId', + 'koboId', ] as const satisfies readonly BookWritePayloadKey[]; export const COMIC_INFO_MANAGED_NOTES_KEYS = [ @@ -36,6 +37,7 @@ export const COMIC_INFO_MANAGED_NOTES_KEYS = [ 'googleBooksId', 'openLibraryId', 'ranobedbId', + 'koboId', ] as const satisfies readonly BookWritePayloadKey[]; type ComicInfoProviderKey = (typeof COMIC_INFO_PROVIDER_ID_KEYS)[number]; @@ -46,6 +48,7 @@ export const COMIC_INFO_PROVIDER_WEB_URL_BUILDERS: Record `https://hardcover.app/books/${id}`, googleBooksId: (id: string) => `https://books.google.com/books?id=${id}`, openLibraryId: (id: string) => `https://openlibrary.org/works/${id}`, + koboId: (id: string) => `https://www.kobo.com/us/en/ebook/${id}`, }; export const EPUB_PROVIDER_IDENTIFIER_SCHEMES = { @@ -55,5 +58,6 @@ export const EPUB_PROVIDER_IDENTIFIER_SCHEMES = { googleBooksId: 'GOOGLE', openLibraryId: 'OPENLIBRARY', ranobedbId: 'RANOBEDB', + koboId: 'KOBO', itunesId: 'ITUNES', } as const satisfies Partial>; diff --git a/server/src/modules/file-write/file-write.repository.ts b/server/src/modules/file-write/file-write.repository.ts index 7f566a556..4fab2ce23 100644 --- a/server/src/modules/file-write/file-write.repository.ts +++ b/server/src/modules/file-write/file-write.repository.ts @@ -184,6 +184,7 @@ export class FileWriteRepository { hardcoverId: meta.hardcoverId, openLibraryId: meta.openLibraryId, ranobedbId: meta.ranobedbId, + koboId: meta.koboId, itunesId: meta.itunesId, audibleId: meta.audibleId, rating: meta.rating, diff --git a/server/src/modules/file-write/interfaces/book-write-payload.interface.ts b/server/src/modules/file-write/interfaces/book-write-payload.interface.ts index d6cbacc77..c7d32504d 100644 --- a/server/src/modules/file-write/interfaces/book-write-payload.interface.ts +++ b/server/src/modules/file-write/interfaces/book-write-payload.interface.ts @@ -22,6 +22,7 @@ export interface BookWritePayload { hardcoverId?: string | null; openLibraryId?: string | null; ranobedbId?: string | null; + koboId?: string | null; comicIssueNumber?: string | null; comicVolumeName?: string | null; comicPencillers?: string[]; diff --git a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts index fccb38e27..9837725fe 100644 --- a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts +++ b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.test.ts @@ -6,11 +6,13 @@ import { MetadataFetchPreferences, MetadataField, MetadataProviderKey, + ProviderConfigurations, } from '@bookorbit/types'; import { of } from 'rxjs'; import { MetadataPreferenceResolver } from '../metadata-preferences/metadata-preference-resolver'; import { MetadataPreferencesService } from '../metadata-preferences/metadata-preferences.service'; +import { ProviderConfigService } from '../metadata-preferences/provider-config.service'; import { MetadataFetchPipeline } from './metadata-fetch-pipeline'; import { MetadataFetchService } from './metadata-fetch.service'; import { ProviderRegistry } from './provider-registry'; @@ -44,6 +46,7 @@ function candidate(provider: MetadataProviderKey, providerId: string, data: Part describe('MetadataFetchPipeline', () => { let fetchService: Mocked; let preferencesService: Mocked; + let providerConfig: Mocked; let resolver: Mocked; let registry: Mocked; let pipeline: MetadataFetchPipeline; @@ -58,6 +61,10 @@ describe('MetadataFetchPipeline', () => { getForLibrary: vi.fn(), } as unknown as Mocked; + providerConfig = { + getConfig: vi.fn().mockResolvedValue(makeProviderConfig()), + } as unknown as Mocked; + resolver = { resolve: vi.fn(), withForwardCompatibility: vi.fn(), @@ -68,7 +75,7 @@ describe('MetadataFetchPipeline', () => { } as unknown as Mocked; const throttleTracker = { isThrottled: vi.fn().mockReturnValue(false) } as unknown as ProviderThrottleTracker; - pipeline = new MetadataFetchPipeline(fetchService, preferencesService, resolver, registry, throttleTracker); + pipeline = new MetadataFetchPipeline(fetchService, preferencesService, resolver, registry, throttleTracker, providerConfig); }); it('derives enabled provider keys from active fields, filters unknown providers, and de-duplicates keys', async () => { @@ -101,6 +108,37 @@ describe('MetadataFetchPipeline', () => { expect(fetchService.search).toHaveBeenCalledWith({ title: 'Query' }, [MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY]); }); + it('filters derived provider keys by enabled provider config', async () => { + const global = createPreferences((fields) => { + fields.title = { + enabled: true, + providers: [MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY, MetadataProviderKey.KOBO], + mergeStrategy: 'overwriteIfProvided', + }; + }); + + providerConfig.getConfig.mockResolvedValue( + makeProviderConfig({ + google: { enabled: false, apiKey: '' }, + openLibrary: { enabled: false }, + kobo: { enabled: true, country: 'us', language: 'en' }, + }), + ); + preferencesService.getGlobal.mockResolvedValue(global); + resolver.resolve.mockReturnValue(global); + resolver.withForwardCompatibility.mockReturnValue(global); + registry.all.mockReturnValue([ + { key: MetadataProviderKey.GOOGLE }, + { key: MetadataProviderKey.OPEN_LIBRARY }, + { key: MetadataProviderKey.KOBO }, + ] as never); + fetchService.search.mockReturnValue(of(candidate(MetadataProviderKey.KOBO, 'k1', { title: 'Kobo Title' }))); + + await pipeline.run({ title: 'Query' }, {}); + + expect(fetchService.search).toHaveBeenCalledWith({ title: 'Query' }, [MetadataProviderKey.KOBO]); + }); + it('applies fillMissing without overwriting existing fields and records sources', async () => { const prefs = createPreferences((fields) => { fields.title = { @@ -413,3 +451,19 @@ describe('MetadataFetchPipeline', () => { expect(result.title).toBe('Library Title'); }); }); + +function makeProviderConfig(overrides: Partial = {}): ProviderConfigurations { + return { + google: { enabled: true, apiKey: '', ...overrides.google }, + amazon: { enabled: true, domain: 'amazon.com', cookie: '', ...overrides.amazon }, + goodreads: { enabled: true, ...overrides.goodreads }, + hardcover: { enabled: true, apiKey: 'hardcover-key', ...overrides.hardcover }, + openLibrary: { enabled: true, ...overrides.openLibrary }, + itunes: { enabled: true, coverResolution: 'high', ...overrides.itunes }, + audible: { enabled: true, domain: 'com', ...overrides.audible }, + audnexus: { enabled: true, ...overrides.audnexus }, + comicvine: { enabled: true, apiKey: 'comicvine-key', ...overrides.comicvine }, + ranobedb: { enabled: true, ...overrides.ranobedb }, + kobo: { enabled: true, country: 'us', language: 'en', ...overrides.kobo }, + }; +} diff --git a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts index 5b94eff88..5da59fc7d 100644 --- a/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts +++ b/server/src/modules/metadata-fetch/metadata-fetch-pipeline.ts @@ -7,10 +7,12 @@ import { MetadataFetchPreferences, MetadataField, MetadataProviderKey, + ProviderConfigurations, } from '@bookorbit/types'; import { firstValueFrom, toArray } from 'rxjs'; import { MetadataPreferenceResolver } from '../metadata-preferences/metadata-preference-resolver'; +import { ProviderConfigService } from '../metadata-preferences/provider-config.service'; import { MetadataPreferencesService } from '../metadata-preferences/metadata-preferences.service'; import { createGenreBlocklistTokenSet, filterGenresAgainstBlocklist } from '../../common/utils/genre-blocklist.utils'; import { MetadataFetchService } from './metadata-fetch.service'; @@ -35,6 +37,7 @@ export class MetadataFetchPipeline { private readonly resolver: MetadataPreferenceResolver, private readonly registry: ProviderRegistry, private readonly throttleTracker: ProviderThrottleTracker, + private readonly providerConfig: ProviderConfigService, ) {} async run( @@ -59,12 +62,12 @@ export class MetadataFetchPipeline { existingFields: Partial>, libraryId?: number, ): Promise<{ resolved: ResolvedMetadataFields; sources: Record; providerIds: ResolvedProviderIds }> { - const global = await this.preferencesService.getGlobal(); + const [global, providerConfig] = await Promise.all([this.preferencesService.getGlobal(), this.providerConfig.getConfig()]); const overrides = libraryId ? (await this.preferencesService.getForLibrary(libraryId, global)).overrides : null; const registeredKeys = this.registry.all().map((p) => p.key) as MetadataProviderKey[]; const preferences: MetadataFetchPreferences = this.resolver.withForwardCompatibility(this.resolver.resolve(global, overrides), registeredKeys); - const enabledProviders = this.deriveProviderSet(preferences, registeredKeys); + const enabledProviders = this.deriveProviderSet(preferences, registeredKeys, providerConfig); const candidates = await firstValueFrom(this.fetchService.search(params, enabledProviders).pipe(toArray()), { defaultValue: [] as MetadataCandidate[], }); @@ -76,13 +79,15 @@ export class MetadataFetchPipeline { return this.applyPreferences(preferences, byProvider, existingFields); } - private deriveProviderSet(preferences: MetadataFetchPreferences, registeredKeys: MetadataProviderKey[]) { + private deriveProviderSet(preferences: MetadataFetchPreferences, registeredKeys: MetadataProviderKey[], providerConfig: ProviderConfigurations) { const registered = new Set(registeredKeys); const keys = new Set(); for (const [, fieldPreference] of Object.entries(preferences.fields) as [MetadataField, FieldPreference][]) { if (!fieldPreference.enabled) continue; - fieldPreference.providers.filter((providerKey) => registered.has(providerKey)).forEach((providerKey) => keys.add(providerKey)); + fieldPreference.providers + .filter((providerKey) => registered.has(providerKey) && providerConfig[providerKey]?.enabled !== false) + .forEach((providerKey) => keys.add(providerKey)); } const active: MetadataProviderKey[] = []; diff --git a/server/src/modules/metadata-fetch/metadata-fetch.controller.test.ts b/server/src/modules/metadata-fetch/metadata-fetch.controller.test.ts index b8f3bdb68..c69a0de9e 100644 --- a/server/src/modules/metadata-fetch/metadata-fetch.controller.test.ts +++ b/server/src/modules/metadata-fetch/metadata-fetch.controller.test.ts @@ -1,4 +1,4 @@ -import { MetadataProviderKey, ProviderThrottleRuntimeSnapshot } from '@bookorbit/types'; +import { MetadataProviderKey, ProviderConfigurations, ProviderThrottleRuntimeSnapshot } from '@bookorbit/types'; import type { Mocked } from 'vitest'; import { firstValueFrom, of, toArray } from 'rxjs'; @@ -21,6 +21,14 @@ describe('MetadataFetchController', () => { let throttleTracker: Mocked; let controller: MetadataFetchController; let user: RequestUser; + const providerInfos = [ + { key: MetadataProviderKey.GOOGLE, label: 'Google Books', identifiable: true }, + { key: MetadataProviderKey.AMAZON, label: 'Amazon', identifiable: true }, + { key: MetadataProviderKey.OPEN_LIBRARY, label: 'OpenLibrary', identifiable: false }, + { key: MetadataProviderKey.AUDIBLE, label: 'Audible', identifiable: true }, + { key: MetadataProviderKey.AUDNEXUS, label: 'AudNexus', identifiable: false }, + { key: MetadataProviderKey.KOBO, label: 'Kobo', identifiable: true }, + ]; beforeEach(() => { service = { @@ -34,7 +42,7 @@ describe('MetadataFetchController', () => { } as unknown as Mocked; providerConfig = { - getConfig: vi.fn().mockResolvedValue({}), + getConfig: vi.fn().mockResolvedValue(makeProviderConfig()), getProviderStatuses: vi.fn(), } as unknown as Mocked; @@ -46,6 +54,7 @@ describe('MetadataFetchController', () => { throttleTracker = { snapshot: vi.fn(), } as unknown as Mocked; + registry.all.mockReturnValue(providerInfos as never); controller = new MetadataFetchController(service, registry, providerConfig, throttleTracker, metadataPreferences); user = { @@ -144,10 +153,34 @@ describe('MetadataFetchController', () => { existingProviderIds: {}, isAudiobook: false, }, - undefined, + [ + MetadataProviderKey.GOOGLE, + MetadataProviderKey.OPEN_LIBRARY, + MetadataProviderKey.AUDIBLE, + MetadataProviderKey.AUDNEXUS, + MetadataProviderKey.KOBO, + ], ); }); + it('uses enabled provider config when stream providers are omitted', async () => { + providerConfig.getConfig.mockResolvedValue( + makeProviderConfig({ + google: { enabled: false, apiKey: '' }, + openLibrary: { enabled: false }, + audible: { enabled: false, domain: 'com' }, + audnexus: { enabled: false }, + kobo: { enabled: true, country: 'us', language: 'en' }, + }), + ); + service.search.mockReturnValue(of({ provider: MetadataProviderKey.KOBO, providerId: 'dune-1', title: 'Dune' })); + + const stream = await controller.stream({ title: 'Dune' }, user); + await firstValueFrom(stream.pipe(toArray())); + + expect(service.search).toHaveBeenCalledWith(expect.objectContaining({ title: 'Dune' }), [MetadataProviderKey.KOBO]); + }); + it('infers audiobook search when audiobook providers are requested', async () => { service.search.mockReturnValue(of({ provider: MetadataProviderKey.AUDIBLE, providerId: 'B001', title: 'Audio Result' })); @@ -183,11 +216,22 @@ describe('MetadataFetchController', () => { existingProviderIds: { [MetadataProviderKey.AUDIBLE]: 'B0ABC12345' }, isAudiobook: true, }), - undefined, + [ + MetadataProviderKey.GOOGLE, + MetadataProviderKey.OPEN_LIBRARY, + MetadataProviderKey.AUDIBLE, + MetadataProviderKey.AUDNEXUS, + MetadataProviderKey.KOBO, + ], ); }); it('delegates lookup requests to the metadata fetch service and filters blocklisted genres', async () => { + providerConfig.getConfig.mockResolvedValue( + makeProviderConfig({ + amazon: { enabled: true, domain: 'amazon.com', cookie: '' }, + }), + ); const resolver = new MetadataPreferenceResolver(); const preferences = resolver.getDefaultPreferences(); preferences.options!.genres.blocklist = ['Adult']; @@ -206,6 +250,19 @@ describe('MetadataFetchController', () => { expect(result).toEqual({ provider: MetadataProviderKey.AMAZON, providerId: 'B123', title: 'Amazon Title', genres: ['Mystery'] }); }); + it('returns null for lookup requests when the provider is disabled', async () => { + providerConfig.getConfig.mockResolvedValue( + makeProviderConfig({ + amazon: { enabled: false, domain: 'amazon.com', cookie: '' }, + }), + ); + + const result = await controller.lookup({ provider: MetadataProviderKey.AMAZON, id: 'B123' }); + + expect(result).toBeNull(); + expect(service.lookupById).not.toHaveBeenCalled(); + }); + it('returns runtime provider throttle state for admin metadata settings', async () => { const config = { google: { enabled: true, apiKey: '' } }; providerConfig.getConfig.mockResolvedValue(config as never); @@ -242,3 +299,19 @@ describe('MetadataFetchController', () => { expect(result).toEqual(runtime); }); }); + +function makeProviderConfig(overrides: Partial = {}): ProviderConfigurations { + return { + google: { enabled: true, apiKey: '', ...overrides.google }, + amazon: { enabled: false, domain: 'amazon.com', cookie: '', ...overrides.amazon }, + goodreads: { enabled: false, ...overrides.goodreads }, + hardcover: { enabled: false, apiKey: '', ...overrides.hardcover }, + openLibrary: { enabled: true, ...overrides.openLibrary }, + itunes: { enabled: false, coverResolution: 'high', ...overrides.itunes }, + audible: { enabled: true, domain: 'com', ...overrides.audible }, + audnexus: { enabled: true, ...overrides.audnexus }, + comicvine: { enabled: false, apiKey: '', ...overrides.comicvine }, + ranobedb: { enabled: false, ...overrides.ranobedb }, + kobo: { enabled: true, country: 'us', language: 'en', ...overrides.kobo }, + }; +} diff --git a/server/src/modules/metadata-fetch/metadata-fetch.controller.ts b/server/src/modules/metadata-fetch/metadata-fetch.controller.ts index 263b80d1d..d8449d0cd 100644 --- a/server/src/modules/metadata-fetch/metadata-fetch.controller.ts +++ b/server/src/modules/metadata-fetch/metadata-fetch.controller.ts @@ -70,22 +70,35 @@ export class MetadataFetchController { isAudiobook, }; - const preferences = await this.metadataPreferences.getGlobal(); + const [preferences, providerKeys] = await Promise.all([this.metadataPreferences.getGlobal(), this.resolveEnabledProviderKeys(dto.providers)]); const blockedGenreTokens = createGenreBlocklistTokenSet(preferences.options?.genres.blocklist); return this.metadataFetchService - .search(params, dto.providers) + .search(params, providerKeys) .pipe(map((candidate: MetadataCandidate) => ({ data: filterCandidateGenresAgainstBlocklist(candidate, blockedGenreTokens) }))); } @Get('lookup') async lookup(@Query() dto: LookupMetadataDto): Promise { + const [enabledProvider] = await this.resolveEnabledProviderKeys([dto.provider]); + if (!enabledProvider) return null; + const [candidate, preferences] = await Promise.all([ - this.metadataFetchService.lookupById(dto.provider, dto.id), + this.metadataFetchService.lookupById(enabledProvider, dto.id), this.metadataPreferences.getGlobal(), ]); if (!candidate) return null; const blockedGenreTokens = createGenreBlocklistTokenSet(preferences.options?.genres.blocklist); return filterCandidateGenresAgainstBlocklist(candidate, blockedGenreTokens); } + + private async resolveEnabledProviderKeys(requestedProviders: MetadataProviderKey[] | undefined): Promise { + const config = await this.providerConfig.getConfig(); + const registeredProviders = this.registry.all(); + const enabledProviders = new Set( + registeredProviders.filter((provider) => config[provider.key]?.enabled !== false).map((provider) => provider.key), + ); + const providerKeys = requestedProviders ?? registeredProviders.map((provider) => provider.key); + return providerKeys.filter((providerKey) => enabledProviders.has(providerKey)); + } } diff --git a/server/src/modules/metadata-fetch/metadata-fetch.module.test.ts b/server/src/modules/metadata-fetch/metadata-fetch.module.test.ts index 3236054e5..77bc186dc 100644 --- a/server/src/modules/metadata-fetch/metadata-fetch.module.test.ts +++ b/server/src/modules/metadata-fetch/metadata-fetch.module.test.ts @@ -17,6 +17,7 @@ import { AudibleProvider } from './providers/audible/audible.provider'; import { AudnexusProvider } from './providers/audnexus/audnexus.provider'; import { HardcoverProvider } from './providers/hardcover/hardcover.provider'; import { ComicVineProvider } from './providers/comicvine/comicvine.provider'; +import { KoboProvider } from './providers/kobo/kobo.provider'; import { RanobeDbProvider } from './providers/ranobedb/ranobedb.provider'; describe('MetadataFetchModule', () => { @@ -35,6 +36,7 @@ describe('MetadataFetchModule', () => { HardcoverProvider, ComicVineProvider, RanobeDbProvider, + KoboProvider, ProviderRegistry, MetadataFetchRepository, MetadataFetchService, @@ -59,6 +61,7 @@ describe('MetadataFetchModule', () => { HardcoverProvider, ComicVineProvider, RanobeDbProvider, + KoboProvider, ]); const google = { key: 'google' }; diff --git a/server/src/modules/metadata-fetch/metadata-fetch.module.ts b/server/src/modules/metadata-fetch/metadata-fetch.module.ts index 8113e6e20..2c4184136 100644 --- a/server/src/modules/metadata-fetch/metadata-fetch.module.ts +++ b/server/src/modules/metadata-fetch/metadata-fetch.module.ts @@ -20,6 +20,7 @@ import { HardcoverClient } from './providers/hardcover/hardcover.client'; import { HardcoverProvider } from './providers/hardcover/hardcover.provider'; import { ComicVineClient } from './providers/comicvine/comicvine.client'; import { ComicVineProvider } from './providers/comicvine/comicvine.provider'; +import { KoboProvider } from './providers/kobo/kobo.provider'; import { RanobeDbClient } from './providers/ranobedb/ranobedb.client'; import { RanobeDbProvider } from './providers/ranobedb/ranobedb.provider'; @@ -34,6 +35,7 @@ const PROVIDER_CLASSES = [ HardcoverProvider, ComicVineProvider, RanobeDbProvider, + KoboProvider, ]; @Module({ diff --git a/server/src/modules/metadata-fetch/metadata-fetch.repository.ts b/server/src/modules/metadata-fetch/metadata-fetch.repository.ts index 41b1fbb08..bd5535f0c 100644 --- a/server/src/modules/metadata-fetch/metadata-fetch.repository.ts +++ b/server/src/modules/metadata-fetch/metadata-fetch.repository.ts @@ -17,6 +17,7 @@ export interface StoredProviderIdsRow { openLibraryId: string | null; itunesId: string | null; audibleId: string | null; + koboId: string | null; comicvineId: string | null; ranobedbId: string | null; } @@ -36,6 +37,7 @@ export class MetadataFetchRepository { openLibraryId: bookMetadata.openLibraryId, itunesId: bookMetadata.itunesId, audibleId: bookMetadata.audibleId, + koboId: bookMetadata.koboId, comicvineId: bookMetadata.comicvineId, ranobedbId: bookMetadata.ranobedbId, }) diff --git a/server/src/modules/metadata-fetch/metadata-fetch.service.test.ts b/server/src/modules/metadata-fetch/metadata-fetch.service.test.ts index 8602b11a7..e3f656946 100644 --- a/server/src/modules/metadata-fetch/metadata-fetch.service.test.ts +++ b/server/src/modules/metadata-fetch/metadata-fetch.service.test.ts @@ -310,7 +310,9 @@ describe('MetadataFetchService', () => { openLibraryId: 'ol-1', itunesId: null, audibleId: 'B0ABC12345', + koboId: 'beautiful-ugly-3', comicvineId: 'cv-1', + ranobedbId: null, }); metadataFetchRepository.hasLibraryAccess.mockResolvedValue(true); @@ -324,7 +326,9 @@ describe('MetadataFetchService', () => { [MetadataProviderKey.OPEN_LIBRARY]: 'ol-1', [MetadataProviderKey.ITUNES]: undefined, [MetadataProviderKey.AUDIBLE]: 'B0ABC12345', + [MetadataProviderKey.KOBO]: 'beautiful-ugly-3', [MetadataProviderKey.COMICVINE]: 'cv-1', + [MetadataProviderKey.RANOBEDB]: undefined, }); expect(metadataFetchRepository.hasLibraryAccess).toHaveBeenCalledWith(5, 7); }); @@ -339,7 +343,9 @@ describe('MetadataFetchService', () => { openLibraryId: null, itunesId: null, audibleId: null, + koboId: null, comicvineId: null, + ranobedbId: null, }); await expect(service.getStoredProviderIds(99, makeUser({ isSuperuser: true }))).resolves.toEqual({ @@ -350,7 +356,9 @@ describe('MetadataFetchService', () => { [MetadataProviderKey.OPEN_LIBRARY]: undefined, [MetadataProviderKey.ITUNES]: undefined, [MetadataProviderKey.AUDIBLE]: undefined, + [MetadataProviderKey.KOBO]: undefined, [MetadataProviderKey.COMICVINE]: undefined, + [MetadataProviderKey.RANOBEDB]: undefined, }); expect(metadataFetchRepository.hasLibraryAccess).not.toHaveBeenCalled(); }); @@ -371,7 +379,9 @@ describe('MetadataFetchService', () => { openLibraryId: null, itunesId: null, audibleId: null, + koboId: null, comicvineId: null, + ranobedbId: null, }); metadataFetchRepository.hasLibraryAccess.mockResolvedValue(false); diff --git a/server/src/modules/metadata-fetch/metadata-fetch.service.ts b/server/src/modules/metadata-fetch/metadata-fetch.service.ts index b87841ec7..3eac505d5 100644 --- a/server/src/modules/metadata-fetch/metadata-fetch.service.ts +++ b/server/src/modules/metadata-fetch/metadata-fetch.service.ts @@ -69,6 +69,7 @@ export class MetadataFetchService { [MetadataProviderKey.OPEN_LIBRARY]: row.openLibraryId ?? undefined, [MetadataProviderKey.ITUNES]: row.itunesId ?? undefined, [MetadataProviderKey.AUDIBLE]: row.audibleId ?? undefined, + [MetadataProviderKey.KOBO]: row.koboId ?? undefined, [MetadataProviderKey.COMICVINE]: row.comicvineId ?? undefined, [MetadataProviderKey.RANOBEDB]: row.ranobedbId ?? undefined, }; @@ -79,7 +80,7 @@ export class MetadataFetchService { this.logger.log(`[metadata_fetch.provider_search] [start] provider=${provider.key} - provider fetch started`); try { - const { results, timedOut } = await this.withTimeout((signal) => this.fetchFromProvider(provider, { ...params, signal })); + const { results, timedOut } = await this.withTimeout((signal) => this.fetchFromProvider(provider, { ...params, signal }), provider.timeoutMs); if (timedOut) { this.logger.warn( @@ -137,7 +138,10 @@ export class MetadataFetchService { return filterAndRank(await provider.search(fallbackParams), fallbackParams); } - private withTimeout(run: (signal: AbortSignal) => Promise): Promise { + private withTimeout( + run: (signal: AbortSignal) => Promise, + timeoutMs: number = MetadataFetchService.PROVIDER_TIMEOUT_MS, + ): Promise { let timer: NodeJS.Timeout | undefined; const controller = new AbortController(); @@ -145,7 +149,7 @@ export class MetadataFetchService { timer = setTimeout(() => { controller.abort(); resolve({ results: [], timedOut: true }); - }, MetadataFetchService.PROVIDER_TIMEOUT_MS); + }, timeoutMs); }); const providerPromise = run(controller.signal) diff --git a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.test.ts b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.test.ts index 92072eb91..8ac8c57da 100644 --- a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.test.ts @@ -20,6 +20,7 @@ describe('AmazonProvider', () => { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; beforeEach(async () => { diff --git a/server/src/modules/metadata-fetch/providers/audible/audible.provider.test.ts b/server/src/modules/metadata-fetch/providers/audible/audible.provider.test.ts index 200674b0c..601fef772 100644 --- a/server/src/modules/metadata-fetch/providers/audible/audible.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/audible/audible.provider.test.ts @@ -38,6 +38,7 @@ const baseConfig: ProviderConfigurations = { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; function makeProvider(config: ProviderConfigurations = baseConfig): { diff --git a/server/src/modules/metadata-fetch/providers/audnexus/audnexus.provider.test.ts b/server/src/modules/metadata-fetch/providers/audnexus/audnexus.provider.test.ts index c2b555bbe..0b0fa535f 100644 --- a/server/src/modules/metadata-fetch/providers/audnexus/audnexus.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/audnexus/audnexus.provider.test.ts @@ -29,6 +29,7 @@ describe('AudnexusProvider', () => { audnexus: { enabled: true }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; beforeEach(async () => { diff --git a/server/src/modules/metadata-fetch/providers/comicvine/comicvine.provider.test.ts b/server/src/modules/metadata-fetch/providers/comicvine/comicvine.provider.test.ts index b9d8fc8c1..4c3008cf8 100644 --- a/server/src/modules/metadata-fetch/providers/comicvine/comicvine.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/comicvine/comicvine.provider.test.ts @@ -18,6 +18,8 @@ const mockConfig: ProviderConfigurations = { audible: { enabled: false, domain: 'com' }, audnexus: { enabled: false }, comicvine: { enabled: true, apiKey: 'test-key' }, + ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; const mockVolume = { diff --git a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.test.ts b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.test.ts index 39c085321..c8de28f77 100644 --- a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.test.ts @@ -19,6 +19,7 @@ describe('GoodreadsProvider', () => { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; function goodreadsBookHtml(bookId: string, title: string): string { diff --git a/server/src/modules/metadata-fetch/providers/google/google.provider.test.ts b/server/src/modules/metadata-fetch/providers/google/google.provider.test.ts index 8512478a5..87787463e 100644 --- a/server/src/modules/metadata-fetch/providers/google/google.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/google/google.provider.test.ts @@ -19,6 +19,7 @@ describe('GoogleProvider', () => { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; beforeEach(async () => { diff --git a/server/src/modules/metadata-fetch/providers/hardcover/hardcover.provider.test.ts b/server/src/modules/metadata-fetch/providers/hardcover/hardcover.provider.test.ts index 068aa1072..494edd207 100644 --- a/server/src/modules/metadata-fetch/providers/hardcover/hardcover.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/hardcover/hardcover.provider.test.ts @@ -17,6 +17,7 @@ const mockConfig: ProviderConfigurations = { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; const mockDocument: HardcoverSearchDocument = { diff --git a/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.test.ts b/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.test.ts index 1cdd8dc42..0794f5842 100644 --- a/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/itunes/itunes.provider.test.ts @@ -19,6 +19,7 @@ describe('ITunesProvider', () => { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; beforeEach(async () => { diff --git a/server/src/modules/metadata-fetch/providers/kobo/kobo-cloudscraper.fetcher.test.ts b/server/src/modules/metadata-fetch/providers/kobo/kobo-cloudscraper.fetcher.test.ts new file mode 100644 index 000000000..bae07c242 --- /dev/null +++ b/server/src/modules/metadata-fetch/providers/kobo/kobo-cloudscraper.fetcher.test.ts @@ -0,0 +1,117 @@ +import { execFile } from 'child_process'; +import { existsSync } from 'fs'; + +import { fetchKoboHtmlWithCloudscraper, KoboCloudscraperUnavailableError, KoboCloudscraperFetchResult } from './kobo-cloudscraper.fetcher'; + +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +vi.mock('fs', () => ({ + existsSync: vi.fn(), +})); + +const execFileMock = vi.mocked(execFile); +const existsSyncMock = vi.mocked(existsSync); + +describe('fetchKoboHtmlWithCloudscraper', () => { + beforeEach(() => { + execFileMock.mockReset(); + existsSyncMock.mockReturnValue(false); + }); + + it('runs the configured Python executable and parses cloudscraper output', async () => { + const output: KoboCloudscraperFetchResult = { + status: 200, + url: 'https://www.kobo.com/us/en/ebook/fourth-wing-1', + headers: { Server: 'cloudflare' }, + html: '

Fourth Wing

', + attempts: 3, + challenge: false, + }; + mockExecFileSuccess(output); + + await expect( + fetchKoboHtmlWithCloudscraper('https://www.kobo.com/us/en/search?query=Fourth+Wing', { + maxAttempts: 15, + pythonPath: '/tmp/python', + timeoutMs: 30_000, + }), + ).resolves.toEqual({ + ...output, + headers: { server: 'cloudflare' }, + }); + + expect(execFileMock).toHaveBeenCalledWith( + '/tmp/python', + ['-c', expect.any(String), expect.stringContaining('"maxAttempts":15')], + expect.objectContaining({ timeout: 30_000 }), + expect.any(Function), + ); + + const payload = getCloudscraperPayload(0); + expect(payload).toEqual( + expect.objectContaining({ + maxAttempts: 15, + requestTimeoutSec: 10, + deadlineSec: 29, + }), + ); + }); + + it('falls back to the next Python candidate when the first is unavailable', async () => { + mockExecFileError(makeExecError('spawn python3 ENOENT', { code: 'ENOENT' })); + mockExecFileSuccess({ + status: 200, + url: 'https://www.kobo.com/us/en/ebook/dune', + headers: {}, + html: '

Dune

', + attempts: 1, + challenge: false, + }); + + const result = await fetchKoboHtmlWithCloudscraper('https://www.kobo.com/us/en/search?query=Dune', { + timeoutMs: 30_000, + }); + + expect(result.url).toBe('https://www.kobo.com/us/en/ebook/dune'); + expect(execFileMock).toHaveBeenNthCalledWith(1, 'python3', expect.any(Array), expect.any(Object), expect.any(Function)); + expect(execFileMock).toHaveBeenNthCalledWith(2, 'python', expect.any(Array), expect.any(Object), expect.any(Function)); + }); + + it('reports cloudscraper as unavailable when every Python candidate is missing the module', async () => { + const stderr = JSON.stringify({ errorClass: 'ModuleNotFoundError', error: "No module named 'cloudscraper'", unavailable: true }); + mockExecFileError(makeExecError('module missing', { stderr })); + mockExecFileError(makeExecError('module missing', { stderr })); + + await expect(fetchKoboHtmlWithCloudscraper('https://www.kobo.com/us/en/search?query=Dune', { timeoutMs: 30_000 })).rejects.toBeInstanceOf( + KoboCloudscraperUnavailableError, + ); + }); +}); + +function getCloudscraperPayload(callIndex: number): Record { + const args = execFileMock.mock.calls[callIndex]?.[1]; + if (!Array.isArray(args) || typeof args[2] !== 'string') { + throw new Error('missing cloudscraper payload'); + } + return JSON.parse(args[2]) as Record; +} + +function mockExecFileSuccess(result: KoboCloudscraperFetchResult): void { + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + callback?.(null, JSON.stringify(result), ''); + return undefined as never; + }); +} + +function mockExecFileError(error: Error): void { + execFileMock.mockImplementationOnce((_file, _args, _options, callback) => { + callback?.(error, '', (error as { stderr?: string }).stderr ?? ''); + return undefined as never; + }); +} + +function makeExecError(message: string, fields: Partial): Error { + return Object.assign(new Error(message), fields); +} diff --git a/server/src/modules/metadata-fetch/providers/kobo/kobo-cloudscraper.fetcher.ts b/server/src/modules/metadata-fetch/providers/kobo/kobo-cloudscraper.fetcher.ts new file mode 100644 index 000000000..3fe496678 --- /dev/null +++ b/server/src/modules/metadata-fetch/providers/kobo/kobo-cloudscraper.fetcher.ts @@ -0,0 +1,288 @@ +import { execFile as execFileCallback } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { promisify } from 'util'; + +const execFile = promisify(execFileCallback); + +const MAX_STDOUT_BYTES = 16 * 1024 * 1024; +const DEFAULT_MAX_ATTEMPTS = 15; +const DEADLINE_BUFFER_MS = 1_000; +const MAX_REQUEST_TIMEOUT_SEC = 10; +const BUNDLED_PYTHON_PATH = '/opt/bookorbit-python/bin/python'; +const LOCAL_PYTHON_PATH = + process.platform === 'win32' + ? join(process.cwd(), '.venv', 'kobo-cloudscraper', 'Scripts', 'python.exe') + : join(process.cwd(), '.venv', 'kobo-cloudscraper', 'bin', 'python'); + +const CLOUDSCRAPER_SCRIPT = String.raw` +import json +import sys +import time + +try: + import cloudscraper +except Exception as exc: + print(json.dumps({"errorClass": exc.__class__.__name__, "error": str(exc), "unavailable": True}), file=sys.stderr) + sys.exit(2) + +CHALLENGE_MARKERS = ( + "challenge-error-text", + "challenge-form", + "cf-chl", + "cf_chl", + "challenged | kobo.com", +) + + +def is_challenge_page(text, response): + lowered = (text or "").lower() + if response.headers.get("cf-mitigated", "").lower() == "challenge": + return True + return any(marker in lowered for marker in CHALLENGE_MARKERS) + + +def build_result(response, attempts): + text = response.text or "" + return { + "status": response.status_code, + "url": response.url, + "headers": dict(response.headers), + "html": text, + "attempts": attempts, + "challenge": is_challenge_page(text, response), + } + + +def main(): + payload = json.loads(sys.argv[1]) + url = payload["url"] + request_timeout = max(1, int(payload.get("requestTimeoutSec", 30))) + deadline_seconds = max(1.0, float(payload.get("deadlineSec", request_timeout))) + deadline_at = time.monotonic() + deadline_seconds + max_attempts = max(1, int(payload.get("maxAttempts", 15))) + + scraper = cloudscraper.create_scraper( + browser={ + "custom": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + }, + interpreter="native", + ecdhCurve="secp384r1", + ) + + last_result = None + last_error = None + for attempt in range(1, max_attempts + 1): + remaining = deadline_at - time.monotonic() + if remaining <= 1.0: + last_error = last_error or {"errorClass": "KoboCloudscraperTimeout", "error": "cloudscraper deadline exceeded", "attempts": attempt - 1} + break + + try: + response = scraper.get(url, timeout=max(1.0, min(request_timeout, remaining - 0.5))) + last_result = build_result(response, attempt) + if response.status_code == 200 and not last_result["challenge"]: + print(json.dumps(last_result)) + return + if response.status_code == 429: + print(json.dumps(last_result)) + return + except Exception as exc: + last_error = {"errorClass": exc.__class__.__name__, "error": str(exc), "attempts": attempt} + + if attempt < max_attempts: + remaining = deadline_at - time.monotonic() + if remaining <= 1.0: + break + time.sleep(min(1.0, remaining - 0.5)) + + if last_result is not None: + print(json.dumps(last_result)) + return + + print(json.dumps(last_error or {"errorClass": "KoboCloudscraperError", "error": "no response"}), file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(json.dumps({"errorClass": exc.__class__.__name__, "error": str(exc)}), file=sys.stderr) + sys.exit(1) +`; + +export interface KoboCloudscraperFetchResult { + status: number; + url: string; + headers: Record; + html: string; + attempts: number; + challenge: boolean; +} + +export interface KoboCloudscraperFetchOptions { + maxAttempts?: number; + pythonPath?: string; + signal?: AbortSignal; + timeoutMs: number; +} + +type ExecFileError = Error & { + code?: string | number; + killed?: boolean; + signal?: NodeJS.Signals; + stderr?: string | Buffer; + stdout?: string | Buffer; +}; + +export class KoboCloudscraperError extends Error { + constructor(message: string) { + super(message); + this.name = 'KoboCloudscraperError'; + } +} + +export class KoboCloudscraperUnavailableError extends KoboCloudscraperError { + constructor(message: string) { + super(message); + this.name = 'KoboCloudscraperUnavailableError'; + } +} + +export async function fetchKoboHtmlWithCloudscraper(url: string, options: KoboCloudscraperFetchOptions): Promise { + const deadlineSec = Math.max(1, Math.floor((options.timeoutMs - DEADLINE_BUFFER_MS) / 1000)); + const payload = JSON.stringify({ + url, + maxAttempts: options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + requestTimeoutSec: Math.max(1, Math.min(MAX_REQUEST_TIMEOUT_SEC, Math.ceil(deadlineSec / 3))), + deadlineSec, + }); + const errors: string[] = []; + + for (const pythonPath of getPythonCandidates(options.pythonPath)) { + try { + const output = await execFile(pythonPath, ['-c', CLOUDSCRAPER_SCRIPT, payload], { + maxBuffer: MAX_STDOUT_BYTES, + signal: options.signal, + timeout: options.timeoutMs, + windowsHide: true, + }); + return parseCloudscraperResult(getExecStdout(output)); + } catch (error) { + if (isAbortError(error)) { + throw error; + } + + const message = formatExecError(error); + if (isUnavailableError(error, message)) { + errors.push(`${pythonPath}: ${message}`); + continue; + } + + throw new KoboCloudscraperError(message); + } + } + + throw new KoboCloudscraperUnavailableError(errors.length ? errors.join('; ') : 'python cloudscraper unavailable'); +} + +function getExecStdout(output: string | Buffer | { stdout: string | Buffer }): string | Buffer { + if (typeof output === 'string' || Buffer.isBuffer(output)) { + return output; + } + return output.stdout; +} + +function getPythonCandidates(configuredPath: string | undefined): string[] { + const candidates: string[] = []; + if (configuredPath) { + candidates.push(configuredPath); + } + if (existsSync(LOCAL_PYTHON_PATH)) { + candidates.push(LOCAL_PYTHON_PATH); + } + if (existsSync(BUNDLED_PYTHON_PATH)) { + candidates.push(BUNDLED_PYTHON_PATH); + } + candidates.push('python3', 'python'); + return [...new Set(candidates)]; +} + +function parseCloudscraperResult(stdout: string | Buffer): KoboCloudscraperFetchResult { + const raw = stdout.toString(); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new KoboCloudscraperError(`invalid cloudscraper output: ${message}`); + } + + if (!isRecord(parsed)) { + throw new KoboCloudscraperError('invalid cloudscraper output'); + } + + return { + status: toNumber(parsed.status), + url: toStringValue(parsed.url), + headers: normalizeHeaders(parsed.headers), + html: toStringValue(parsed.html), + attempts: Math.max(1, toNumber(parsed.attempts)), + challenge: Boolean(parsed.challenge), + }; +} + +function normalizeHeaders(value: unknown): Record { + if (!isRecord(value)) return {}; + const headers: Record = {}; + for (const [key, headerValue] of Object.entries(value)) { + headers[key.toLowerCase()] = String(headerValue); + } + return headers; +} + +function formatExecError(error: unknown): string { + const execError = error as ExecFileError; + const stderr = execError.stderr?.toString().trim(); + if (stderr) { + try { + const parsed = JSON.parse(stderr) as unknown; + if (isRecord(parsed)) { + const errorClass = toStringValue(parsed.errorClass) || 'PythonError'; + const message = toStringValue(parsed.error) || 'cloudscraper failed'; + return `${errorClass}: ${message}`; + } + } catch { + return stderr; + } + return stderr; + } + if (execError.killed || execError.code === 'ETIMEDOUT') { + return 'cloudscraper timed out'; + } + return error instanceof Error ? error.message : String(error); +} + +function isUnavailableError(error: unknown, message: string): boolean { + const execError = error as ExecFileError; + return execError.code === 'ENOENT' || /No module named ['"]cloudscraper['"]/.test(message); +} + +function isAbortError(error: unknown): boolean { + const execError = error as ExecFileError; + return execError.name === 'AbortError' || execError.code === 'ABORT_ERR'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function toNumber(value: unknown): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function toStringValue(value: unknown): string { + return typeof value === 'string' ? value : ''; +} diff --git a/server/src/modules/metadata-fetch/providers/kobo/kobo.provider.test.ts b/server/src/modules/metadata-fetch/providers/kobo/kobo.provider.test.ts new file mode 100644 index 000000000..bd4c1debe --- /dev/null +++ b/server/src/modules/metadata-fetch/providers/kobo/kobo.provider.test.ts @@ -0,0 +1,200 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MetadataProviderKey, ProviderConfigurations } from '@bookorbit/types'; + +import { appConfig } from '../../../../config/config'; +import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service'; +import { ProviderThrottleError } from '../../provider-throttle.error'; +import { fetchKoboHtmlWithCloudscraper } from './kobo-cloudscraper.fetcher'; +import { KoboProvider } from './kobo.provider'; + +vi.mock('./kobo-cloudscraper.fetcher', () => ({ + fetchKoboHtmlWithCloudscraper: vi.fn(), +})); + +describe('KoboProvider', () => { + let provider: KoboProvider; + let providerConfig: ProviderConfigService; + const mockFetchKoboHtml = vi.mocked(fetchKoboHtmlWithCloudscraper); + + const mockConfig: ProviderConfigurations = { + google: { enabled: false, apiKey: '' }, + amazon: { enabled: true, domain: 'amazon.com', cookie: '' }, + goodreads: { enabled: true }, + hardcover: { enabled: false, apiKey: '' }, + openLibrary: { enabled: true }, + itunes: { enabled: true, coverResolution: 'high' }, + audible: { enabled: false, domain: 'com' }, + audnexus: { enabled: false }, + comicvine: { enabled: false, apiKey: '' }, + ranobedb: { enabled: false }, + kobo: { enabled: true, country: 'us', language: 'en' }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + KoboProvider, + { + provide: ProviderConfigService, + useValue: { + getConfig: vi.fn().mockResolvedValue(mockConfig), + }, + }, + { + provide: appConfig.KEY, + useValue: { + koboCloudscraperPython: undefined, + }, + }, + ], + }).compile(); + + provider = module.get(KoboProvider); + providerConfig = module.get(ProviderConfigService); + }); + + afterEach(() => { + vi.restoreAllMocks(); + mockFetchKoboHtml.mockReset(); + }); + + it('returns empty results when disabled', async () => { + vi.spyOn(providerConfig, 'getConfig').mockResolvedValue({ + ...mockConfig, + kobo: { enabled: false, country: 'us', language: 'en' }, + }); + + await expect(provider.search({ title: 'Fourth Wing' })).resolves.toEqual([]); + }); + + it('searches Kobo pages and maps product details to metadata candidates', async () => { + const searchHtml = ` + + `; + const bookHtml = ` +

Fourth Wing

+ Empyrean Book 1 + Rebecca Yarros +
640Pages
+ + `; + + mockFetchKoboHtml + .mockResolvedValueOnce( + makeCloudscraperResult(searchHtml, 'https://www.kobo.com/us/en/search?query=Fourth+Wing&fcmedia=Book&pageNumber=1&fclanguages=en'), + ) + .mockResolvedValueOnce(makeCloudscraperResult(bookHtml, 'https://www.kobo.com/us/en/ebook/fourth-wing-1')); + + const result = await provider.search({ title: 'Fourth Wing' }); + + expect(mockFetchKoboHtml).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('https://www.kobo.com/us/en/search?'), + expect.objectContaining({ maxAttempts: 1, timeoutMs: 5_000 }), + ); + expect(mockFetchKoboHtml).toHaveBeenNthCalledWith( + 2, + 'https://www.kobo.com/us/en/ebook/fourth-wing-1', + expect.objectContaining({ maxAttempts: 1, timeoutMs: 5_000 }), + ); + expect(result).toEqual([ + expect.objectContaining({ + provider: MetadataProviderKey.KOBO, + providerId: 'fourth-wing-1', + title: 'Fourth Wing', + subtitle: 'Empyrean Book 1', + authors: ['Rebecca Yarros'], + publisher: 'Entangled Publishing, LLC', + pageCount: 640, + isbn13: '9781649374042', + sourceUrl: 'https://www.kobo.com/us/en/ebook/fourth-wing-1', + }), + ]); + }); + + it('retries Kobo search with a fresh subprocess when cloudscraper times out', async () => { + const searchHtml = ` + + `; + const bookHtml = ` +

A Head Full of Ghosts

+ Paul Tremblay + `; + + mockFetchKoboHtml + .mockRejectedValueOnce(Object.assign(new Error('cloudscraper timed out'), { name: 'KoboCloudscraperError' })) + .mockResolvedValueOnce( + makeCloudscraperResult( + searchHtml, + 'https://www.kobo.com/us/en/search?query=A+Head+Full+of+Ghosts+Paul+Tremblay&fcmedia=Book&pageNumber=1&fclanguages=en', + ), + ) + .mockResolvedValueOnce(makeCloudscraperResult(bookHtml, 'https://www.kobo.com/us/en/ebook/a-head-full-of-ghosts')); + + const result = await provider.search({ title: 'A Head Full of Ghosts', author: 'Paul Tremblay' }); + + expect(mockFetchKoboHtml).toHaveBeenCalledTimes(3); + expect(mockFetchKoboHtml).toHaveBeenNthCalledWith(1, expect.stringContaining('A+Head+Full+of+Ghosts+Paul+Tremblay'), expect.any(Object)); + expect(mockFetchKoboHtml).toHaveBeenNthCalledWith(2, expect.stringContaining('A+Head+Full+of+Ghosts+Paul+Tremblay'), expect.any(Object)); + expect(result[0]).toEqual( + expect.objectContaining({ + provider: MetadataProviderKey.KOBO, + providerId: 'a-head-full-of-ghosts', + title: 'A Head Full of Ghosts', + authors: ['Paul Tremblay'], + }), + ); + }); + + it('handles Kobo search redirects to product pages', async () => { + const bookHtml = `

Direct Hit

`; + mockFetchKoboHtml + .mockResolvedValueOnce(makeCloudscraperResult(bookHtml, 'https://www.kobo.com/us/en/ebook/direct-hit')) + .mockResolvedValueOnce(makeCloudscraperResult(bookHtml, 'https://www.kobo.com/us/en/ebook/direct-hit')); + + const result = await provider.search({ isbn: '9781649374042' }); + + expect(mockFetchKoboHtml).toHaveBeenCalledTimes(2); + expect(result[0]?.providerId).toBe('direct-hit'); + }); + + it('returns empty results when Kobo serves a challenge page', async () => { + const challengeHtml = 'Challenged | Kobo.comEnable cookies'; + mockFetchKoboHtml.mockResolvedValueOnce( + makeCloudscraperResult(challengeHtml, 'https://www.kobo.com/us/en/search', 403, { 'cf-mitigated': 'challenge' }), + ); + + await expect(provider.search({ title: 'Fourth Wing' })).resolves.toEqual([]); + }); + + it('looks up books by Kobo slug using configured region', async () => { + const bookHtml = `

Lookup Book

`; + mockFetchKoboHtml.mockResolvedValueOnce(makeCloudscraperResult(bookHtml, 'https://www.kobo.com/us/en/ebook/lookup-book')); + + const result = await provider.lookupById('lookup-book'); + + expect(mockFetchKoboHtml).toHaveBeenCalledWith('https://www.kobo.com/us/en/ebook/lookup-book', expect.any(Object)); + expect(result?.providerId).toBe('lookup-book'); + }); + + it('rethrows provider throttle errors', async () => { + mockFetchKoboHtml.mockResolvedValue(makeCloudscraperResult('', 'https://www.kobo.com/us/en/search', 429, { 'retry-after': '120' })); + + await expect(provider.search({ title: 'Fourth Wing' })).rejects.toBeInstanceOf(ProviderThrottleError); + }); +}); + +function makeCloudscraperResult(html: string, url: string, status = 200, headers: Record = {}) { + return { + status, + url, + headers, + html, + attempts: 1, + challenge: headers['cf-mitigated'] === 'challenge', + }; +} diff --git a/server/src/modules/metadata-fetch/providers/kobo/kobo.provider.ts b/server/src/modules/metadata-fetch/providers/kobo/kobo.provider.ts new file mode 100644 index 000000000..cdc5485dc --- /dev/null +++ b/server/src/modules/metadata-fetch/providers/kobo/kobo.provider.ts @@ -0,0 +1,247 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types'; + +import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils'; +import { appConfig } from '../../../../config/config'; +import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service'; +import { ProviderThrottleError } from '../../provider-throttle.error'; +import { IdentifiableProvider } from '../metadata-provider'; +import { PROVIDER_DELAYS_MS, PROVIDER_LIMITS, PROVIDER_TIMEOUT_MS } from '../provider-constants'; +import { MetadataSearchParams } from '../metadata-search-params'; +import { normalizeMaxCandidates, sleep } from '../provider-utils'; +import { fetchKoboHtmlWithCloudscraper } from './kobo-cloudscraper.fetcher'; +import { + buildKoboBookUrl, + buildKoboSearchUrl, + extractKoboProviderId, + extractKoboSearchResults, + isKoboChallengePage, + isKoboProductUrl, + KoboRegionConfig, + normalizeKoboProviderId, + parseKoboBookPage, +} from './kobo.scraper'; + +type FetchOperation = 'search' | 'lookup'; +type FetchContext = { + op: FetchOperation; + query?: string; + providerId?: string; +}; +type FetchedHtml = { + html: string; + url: string; + attempts: number; +}; + +const KOBO_FETCH_TIMEOUT_MS = 5_000; +const KOBO_SEARCH_FETCH_ATTEMPTS = 3; +const KOBO_LOOKUP_FETCH_ATTEMPTS = 2; +const KOBO_FETCH_RETRY_DELAY_MS = 250; + +@Injectable() +export class KoboProvider implements IdentifiableProvider { + readonly key = MetadataProviderKey.KOBO; + readonly label = 'Kobo'; + readonly identifiable = true as const; + readonly timeoutMs = PROVIDER_TIMEOUT_MS.KOBO_SCRAPE; + + private readonly logger = new Logger(KoboProvider.name); + + constructor( + private readonly providerConfig: ProviderConfigService, + @Inject(appConfig.KEY) private readonly appConfiguration: ConfigType, + ) {} + + async search(params: MetadataSearchParams): Promise { + const config = await this.providerConfig.getConfig().then((c) => c.kobo); + if (!config.enabled) return []; + + const maxCandidates = normalizeMaxCandidates(params.maxCandidatesPerProvider, PROVIDER_LIMITS.KOBO_MAX_RESULTS); + const links = await this.searchBookLinks(params, config, maxCandidates, params.signal); + const results: MetadataCandidate[] = []; + + for (const link of links.slice(0, maxCandidates)) { + if (params.signal?.aborted) break; + if (results.length > 0) { + await sleep(PROVIDER_DELAYS_MS.KOBO_BETWEEN_REQUESTS, params.signal); + } + const candidate = await this.fetchByUrl(link.url, link.providerId, params.signal); + if (candidate) results.push(candidate); + if (params.signal?.aborted) break; + } + + return results; + } + + async lookupById(providerId: string, signal?: AbortSignal): Promise { + const config = await this.providerConfig.getConfig().then((c) => c.kobo); + if (!config.enabled) return null; + const normalizedProviderId = normalizeKoboProviderId(providerId); + if (!normalizedProviderId) return null; + return this.fetchByUrl(buildKoboBookUrl(normalizedProviderId, config), normalizedProviderId, signal); + } + + private async searchBookLinks( + params: MetadataSearchParams, + config: KoboRegionConfig, + limit: number, + signal?: AbortSignal, + ): Promise> { + const query = params.isbn?.trim() || [params.title, params.author].filter(Boolean).join(' '); + if (!query) return []; + + const url = buildKoboSearchUrl(query, 1, config); + const fetched = await this.fetchHtml(url, { op: 'search', query }, signal); + if (!fetched) return []; + + if (isKoboProductUrl(fetched.url)) { + const providerId = extractKoboProviderId(fetched.url); + return providerId ? [{ providerId, url: fetched.url }] : []; + } + + return extractKoboSearchResults(fetched.html, limit); + } + + private async fetchByUrl(url: string, fallbackProviderId: string, signal?: AbortSignal): Promise { + const fetched = await this.fetchHtml(url, { op: 'lookup', providerId: fallbackProviderId }, signal); + if (!fetched) return null; + + const data = parseKoboBookPage(fetched.html, fetched.url); + const providerId = data.providerId || extractKoboProviderId(fetched.url) || fallbackProviderId; + if (!data.title || !providerId) return null; + + return { + provider: MetadataProviderKey.KOBO, + providerId, + title: data.title, + subtitle: data.subtitle, + authors: data.authors?.length ? data.authors : undefined, + description: data.description, + publisher: data.publisher, + publishedYear: data.publishedYear, + language: data.language, + pageCount: data.pageCount, + isbn10: data.isbn10, + isbn13: data.isbn13, + seriesName: data.seriesName, + seriesIndex: data.seriesIndex, + genres: data.genres?.length ? data.genres : undefined, + coverUrl: data.coverUrl, + sourceUrl: fetched.url, + }; + } + + private async fetchHtml(url: string, context: FetchContext, signal?: AbortSignal): Promise { + const startedAt = Date.now(); + const safeQuery = context.query ? sanitizeLogValue(context.query) : undefined; + const safeProviderId = context.providerId ? sanitizeLogValue(context.providerId) : undefined; + const subject = `${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''}`; + this.logger.log(`[kobo] [start] op=${context.op}${subject} - kobo fetch started`); + + try { + const maxAttempts = context.op === 'search' ? KOBO_SEARCH_FETCH_ATTEMPTS : KOBO_LOOKUP_FETCH_ATTEMPTS; + const fetched = await this.fetchWithCloudscraperRetry(url, maxAttempts, signal); + + if (fetched.status === 429) { + throw new ProviderThrottleError(parseRetryAfterSeconds(fetched.headers['retry-after'])); + } + + if (isKoboChallengePage(fetched.html, toChallengeResponse(fetched))) { + this.logger.warn( + `[kobo] [fail] op=${context.op}${subject} status=${fetched.status} durationMs=${Date.now() - startedAt} attempts=${fetched.attempts} errorClass=KoboChallenge error="cloudflare challenge" - kobo fetch blocked`, + ); + return null; + } + + if (fetched.status < 200 || fetched.status >= 300) { + this.logger.warn( + `[kobo] [fail] op=${context.op}${subject} status=${fetched.status} durationMs=${Date.now() - startedAt} attempts=${fetched.attempts} errorClass=HttpError error="non-ok response" - kobo fetch failed`, + ); + return null; + } + + this.logger.log( + `[kobo] [end] op=${context.op}${subject} status=${fetched.status} durationMs=${Date.now() - startedAt} attempts=${fetched.attempts} bytes=${fetched.html.length} - kobo fetch completed`, + ); + return { html: fetched.html, url: fetched.url || url, attempts: fetched.attempts }; + } catch (error) { + if (error instanceof ProviderThrottleError) { + this.logger.warn( + `[kobo] [fail] op=${context.op}${subject} durationMs=${Date.now() - startedAt} errorClass=ProviderThrottleError error="throttled" - kobo fetch throttled`, + ); + throw error; + } + + const errorClass = error instanceof Error ? error.name : 'UnknownError'; + const message = sanitizeLogValue(error instanceof Error ? error.message : String(error)); + this.logger.warn( + `[kobo] [fail] op=${context.op}${subject} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${message}" - kobo fetch failed`, + ); + return null; + } + } + + private async fetchWithCloudscraperRetry( + url: string, + maxAttempts: number, + signal?: AbortSignal, + ): Promise }> { + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (signal?.aborted) { + throw createAbortError(); + } + + try { + const fetched = await fetchKoboHtmlWithCloudscraper(url, { + maxAttempts: 1, + pythonPath: this.appConfiguration.koboCloudscraperPython, + signal, + timeoutMs: KOBO_FETCH_TIMEOUT_MS, + }); + return { ...fetched, attempts: attempt }; + } catch (error) { + lastError = error; + if (signal?.aborted || isAbortError(error) || attempt >= maxAttempts) { + throw error; + } + await sleep(KOBO_FETCH_RETRY_DELAY_MS, signal); + } + } + + throw lastError instanceof Error ? lastError : new Error('cloudscraper failed'); + } +} + +function createAbortError(): Error { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + return error; +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +function toChallengeResponse(fetched: { status: number; headers: Record }) { + return { + status: fetched.status, + headers: { + get: (name: string) => fetched.headers[name.toLowerCase()] ?? null, + }, + }; +} + +function parseRetryAfterSeconds(retryAfter: string | undefined): number | undefined { + if (!retryAfter) return undefined; + const numeric = Number(retryAfter); + if (Number.isFinite(numeric) && numeric > 0) return Math.ceil(numeric); + + const retryAtEpochMs = Date.parse(retryAfter); + if (Number.isNaN(retryAtEpochMs)) return undefined; + const remainingSeconds = Math.ceil((retryAtEpochMs - Date.now()) / 1000); + return remainingSeconds > 0 ? remainingSeconds : undefined; +} diff --git a/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.test.ts b/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.test.ts new file mode 100644 index 000000000..83824b379 --- /dev/null +++ b/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.test.ts @@ -0,0 +1,140 @@ +import { + buildKoboBookUrl, + buildKoboSearchUrl, + extractKoboProviderId, + extractKoboSearchResults, + isKoboChallengePage, + normalizeKoboProviderId, + parseKoboBookPage, +} from './kobo.scraper'; + +describe('Kobo scraper', () => { + it('builds regional search and lookup URLs like the Kobo metadata plugin', () => { + expect(buildKoboSearchUrl('Fourth Wing Rebecca Yarros', 2, { country: 'au', language: 'en' })).toBe( + 'https://www.kobo.com/au/en/search?query=Fourth+Wing+Rebecca+Yarros&fcmedia=Book&pageNumber=2&fclanguages=en', + ); + expect(buildKoboBookUrl('fourth-wing-1', { country: 'au', language: 'en' })).toBe('https://www.kobo.com/au/en/ebook/fourth-wing-1'); + expect(buildKoboBookUrl('fourth-wing-1', { country: 'au', language: 'all' })).toBe('https://www.kobo.com/au/ebook/fourth-wing-1'); + }); + + it('extracts and normalizes Kobo provider IDs', () => { + expect(extractKoboProviderId('https://www.kobo.com/us/en/ebook/fourth-wing-1?utm=1')).toBe('fourth-wing-1'); + expect(extractKoboProviderId('/us/en/ebook/fourth-wing-1')).toBe('fourth-wing-1'); + expect(normalizeKoboProviderId(' https://www.kobo.com/us/en/ebook/fourth-wing-1 ')).toBe('fourth-wing-1'); + }); + + it('extracts search results from the current Kobo search layout and dedupes duplicate anchors', () => { + const html = ` + + `; + + expect(extractKoboSearchResults(html, 10)).toEqual([ + { providerId: 'fourth-wing-1', url: 'https://www.kobo.com/us/en/ebook/fourth-wing-1' }, + { providerId: 'iron-flame-1', url: 'https://www.kobo.com/us/en/ebook/iron-flame-1' }, + ]); + }); + + it('extracts search results from the legacy product field layout', () => { + const html = ` +

Old Layout Book

+ `; + + expect(extractKoboSearchResults(html, 1)).toEqual([{ providerId: 'old-layout-book', url: 'https://www.kobo.com/au/en/ebook/old-layout-book' }]); + }); + + it('parses Kobo product pages using the Calibre plugin selectors', () => { + const html = ` + + + +

Fourth Wing: Empyrean Book 1

+ + Rebecca Yarros + + Book 1 - + The Empyrean + + +
    + + +
+
A young dragon rider enters a war college.
+ + + `; + + expect(parseKoboBookPage(html, 'https://www.kobo.com/us/en/ebook/fourth-wing-1')).toEqual({ + providerId: 'fourth-wing-1', + title: 'Fourth Wing', + subtitle: 'Empyrean Book 1', + authors: ['Rebecca Yarros'], + description: 'A young dragon rider enters a war college.', + publisher: 'Entangled Publishing, LLC', + publishedYear: 2023, + language: 'English', + pageCount: 640, + isbn10: undefined, + isbn13: '9781649374042', + seriesName: 'The Empyrean', + seriesIndex: 1, + genres: ['Fantasy', 'Romance'], + coverUrl: 'https://cdn.kobo.com/book-images/44f0/1650/2200/100/False/cover.jpg', + sourceUrl: 'https://www.kobo.com/us/en/ebook/fourth-wing-1', + }); + }); + + it('parses visible subtitle and page stats from the current Kobo layout', () => { + const html = ` +

Beautiful Ugly

+ A Novel + Alice Feeney +
+
+
304Pages
+
7 - 8Hours to read
+
+
+ + `; + + expect(parseKoboBookPage(html, 'https://www.kobo.com/us/en/ebook/beautiful-ugly-3')).toEqual( + expect.objectContaining({ + providerId: 'beautiful-ugly-3', + title: 'Beautiful Ugly', + subtitle: 'A Novel', + authors: ['Alice Feeney'], + publisher: 'Flatiron Books', + publishedYear: 2025, + language: 'English', + pageCount: 304, + isbn13: '9781250337795', + }), + ); + }); + + it('detects Kobo Cloudflare challenge pages', () => { + const html = 'Challenged | Kobo.comEnable JavaScript and cookies'; + expect(isKoboChallengePage(html)).toBe(true); + expect(isKoboChallengePage('ok', { headers: new Headers({ 'cf-mitigated': 'challenge' }) })).toBe(true); + }); +}); diff --git a/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.ts b/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.ts new file mode 100644 index 000000000..64d4ace6b --- /dev/null +++ b/server/src/modules/metadata-fetch/providers/kobo/kobo.scraper.ts @@ -0,0 +1,375 @@ +import * as cheerio from 'cheerio'; +import type { CheerioAPI } from 'cheerio'; + +export interface KoboRegionConfig { + country: string; + language: string; +} + +export interface KoboSearchResultLink { + providerId: string; + url: string; +} + +export interface KoboBookData { + providerId?: string; + title?: string; + subtitle?: string; + authors?: string[]; + description?: string; + publisher?: string; + publishedYear?: number; + language?: string; + pageCount?: number; + isbn10?: string; + isbn13?: string; + seriesName?: string; + seriesIndex?: number; + genres?: string[]; + coverUrl?: string; + sourceUrl?: string; +} + +type KoboDetails = { + publisher?: string; + publishedYear?: number; + language?: string; + pageCount?: number; + isbn10?: string; + isbn13?: string; + providerId?: string; + bookId?: string; +}; + +type ChallengeResponse = { + status?: number; + headers?: { + get(name: string): string | null; + }; +}; + +const KOBO_BASE_URL = 'https://www.kobo.com'; +const CHALLENGE_PATTERNS = [/challenge-form/i, /challenge-error-text/i, /cf-chl/i, /cf_chl/i, /\s*Challenged\s*\|\s*Kobo\.com\s*<\/title>/i]; + +export function buildKoboSearchUrl(query: string, page: number, config: KoboRegionConfig): string { + const country = normalizeKoboPathSegment(config.country, 'us'); + const language = normalizeKoboPathSegment(config.language, 'en'); + const pageNumber = Number.isFinite(page) ? Math.max(1, Math.floor(page)) : 1; + const url = new URL(`/${country}/${language}/search`, KOBO_BASE_URL); + url.searchParams.set('query', query); + url.searchParams.set('fcmedia', 'Book'); + url.searchParams.set('pageNumber', String(pageNumber)); + url.searchParams.set('fclanguages', language); + return url.toString(); +} + +export function buildKoboBookUrl(providerId: string, config: KoboRegionConfig): string { + const country = normalizeKoboPathSegment(config.country, 'us'); + const language = normalizeKoboPathSegment(config.language, 'en'); + const id = normalizeKoboProviderId(providerId); + const path = language === 'all' ? `/${country}/ebook/${encodeURIComponent(id)}` : `/${country}/${language}/ebook/${encodeURIComponent(id)}`; + return new URL(path, KOBO_BASE_URL).toString(); +} + +export function extractKoboSearchResults(html: string, limit: number): KoboSearchResultLink[] { + const $ = cheerio.load(html); + const results: KoboSearchResultLink[] = []; + const seen = new Set<string>(); + const anchors = [ + ...$('div[data-testid="search-result-widget"] a[data-testid="title"]').toArray(), + ...$('h2.title.product-field a').toArray(), + ...$('[data-testid="search-result-widget"] a[href*="/ebook/"]').toArray(), + ]; + + for (const el of anchors) { + if (results.length >= limit) break; + const href = $(el).attr('href'); + const providerId = href ? extractKoboProviderId(href) : undefined; + if (!href || !providerId || seen.has(providerId)) continue; + seen.add(providerId); + results.push({ providerId, url: resolveKoboUrl(href) }); + } + + return results; +} + +export function parseKoboBookPage(html: string, sourceUrl?: string): KoboBookData { + const $ = cheerio.load(html); + const { title, subtitle } = extractTitle($); + const details = extractDetails($); + const sourceProviderId = sourceUrl ? extractKoboProviderId(sourceUrl) : undefined; + const bookId = details.bookId ? normalizeIsbn(details.bookId) || details.bookId : undefined; + + return { + providerId: sourceProviderId ?? details.providerId ?? bookId, + title, + subtitle, + authors: extractAuthors($), + description: extractDescription($), + publisher: details.publisher, + publishedYear: details.publishedYear, + language: details.language, + pageCount: details.pageCount ?? extractPageCount($), + isbn10: details.isbn10, + isbn13: details.isbn13, + seriesName: extractSeriesName($), + seriesIndex: extractSeriesIndex($), + genres: extractGenres($), + coverUrl: extractCoverUrl($), + sourceUrl, + }; +} + +export function extractKoboProviderId(value: string): string | undefined { + const raw = value.trim(); + if (!raw) return undefined; + let url: URL; + try { + url = new URL(raw, KOBO_BASE_URL); + } catch { + const fallbackSlug = raw.split('/ebook/').pop()?.split(/[?#/]/)[0]; + return fallbackSlug ? normalizeKoboProviderId(fallbackSlug) || undefined : undefined; + } + + const marker = '/ebook/'; + const index = url.pathname.indexOf(marker); + if (index === -1) return undefined; + const remainder = url.pathname.slice(index + marker.length); + const slug = remainder.split('/')[0]; + return normalizeKoboProviderId(decodeURIComponent(slug)) || undefined; +} + +export function normalizeKoboProviderId(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ''; + const fromUrl = trimmed.includes('/ebook/') ? extractKoboProviderId(trimmed) : undefined; + const raw = fromUrl ?? trimmed; + return ( + raw + .split(/[?#]/)[0] + ?.replace(/^\/+|\/+$/g, '') + .trim() ?? '' + ); +} + +export function isKoboProductUrl(url: string): boolean { + try { + return new URL(url, KOBO_BASE_URL).pathname.includes('/ebook/'); + } catch { + return false; + } +} + +export function isKoboChallengePage(html: string, response?: ChallengeResponse): boolean { + const cfMitigated = response?.headers?.get('cf-mitigated')?.toLowerCase() === 'challenge'; + if (cfMitigated) return true; + if (response?.status === 403 && /cloudflare/i.test(html)) return true; + return CHALLENGE_PATTERNS.some((pattern) => pattern.test(html)); +} + +function extractTitle($: CheerioAPI): { title?: string; subtitle?: string } { + const explicitSubtitle = + cleanText($('span.subtitle.product-field').first().text()) || + cleanText($('[data-testid="subtitle"]').first().text()) || + cleanText($('meta[property="book:subtitle"]').attr('content')); + const raw = + cleanText($('h1.title.product-field').first().text()) || + cleanText($('h1[data-testid="title"]').first().text()) || + cleanText($('meta[property="og:title"]').attr('content')) || + cleanText($('h1').first().text()); + if (!raw) return {}; + + const normalized = raw.replace(/\s+eBook\s+by\s+.+$/i, '').trim(); + const colon = normalized.indexOf(':'); + if (colon > 0) { + return { + title: normalized.slice(0, colon).trim(), + subtitle: explicitSubtitle || normalized.slice(colon + 1).trim() || undefined, + }; + } + return { title: normalized, subtitle: explicitSubtitle || undefined }; +} + +function extractAuthors($: CheerioAPI): string[] { + const authors: string[] = []; + const seen = new Set<string>(); + const selectors = ['span.visible-contributors a', '[data-testid="contributors"] a', 'a[href*="/author/"]']; + + for (const selector of selectors) { + $(selector).each((_, el) => { + const name = cleanText($(el).text()); + if (!name || seen.has(name)) return; + seen.add(name); + authors.push(name); + }); + if (authors.length) break; + } + + return authors; +} + +function extractDescription($: CheerioAPI): string | undefined { + const synopsis = $('[data-full-synopsis]').first(); + const full = cleanText(synopsis.attr('data-full-synopsis')); + if (full) return full; + + const synopsisText = cleanText(synopsis.text()); + if (synopsisText) return synopsisText; + + const fallback = + cleanText($('[itemprop="description"]').first().text()) || + cleanText($('meta[name="description"]').attr('content')) || + cleanText($('meta[property="og:description"]').attr('content')); + return fallback || undefined; +} + +function extractSeriesName($: CheerioAPI): string | undefined { + const series = $('span.series.product-field').last(); + const name = + cleanText(series.find('span.product-sequence-field a').first().text()) || + cleanText($('[data-testid="series"] a').first().text()) || + cleanText($('[data-testid="series"]').first().text()); + return name || undefined; +} + +function extractSeriesIndex($: CheerioAPI): number | undefined { + const raw = + cleanText($('span.series.product-field').last().find('span.sequenced-name-prefix').first().text()) || + cleanText($('[data-testid="series"]').first().text()); + const match = raw.match(/Book\s+(\d+(?:\.\d+)?)\s*-/i) ?? raw.match(/#\s*(\d+(?:\.\d+)?)/); + if (!match) return undefined; + const value = parseFloat(match[1]); + return Number.isNaN(value) ? undefined : value; +} + +function extractDetails($: CheerioAPI): KoboDetails { + const details: KoboDetails = {}; + const items = $('div.bookitem-secondary-metadata ul li').toArray(); + if (!items.length) return details; + + const firstText = cleanText($(items[0]).text()); + if (firstText && !/^(release date|isbn|book id|language|publisher):/i.test(firstText)) { + details.publisher = firstText; + } + + for (const item of items) { + const label = cleanText($(item).clone().children().remove().end().text()).replace(/:$/, ''); + const spanValue = cleanText($(item).find('span').first().text()); + const inlineValue = cleanText($(item).text()).replace(new RegExp(`^${escapeRegExp(label)}:?\\s*`, 'i'), ''); + const value = spanValue || inlineValue; + if (!label || !value) continue; + + if (/^release date$/i.test(label)) { + details.publishedYear = parseYearFromText(value); + } else if (/^pages$/i.test(label)) { + details.pageCount = parseIntegerFromText(value); + } else if (/^(isbn|book id)$/i.test(label)) { + const normalized = normalizeIsbn(value); + if (normalized?.length === 13) details.isbn13 = normalized; + else if (normalized?.length === 10) details.isbn10 = normalized; + else if (/^book id$/i.test(label)) details.bookId = value; + } else if (/^language$/i.test(label)) { + details.language = value; + } else if (/^publisher$/i.test(label)) { + details.publisher = value; + } + } + + return details; +} + +function extractPageCount($: CheerioAPI): number | undefined { + for (const column of $('#about-this-book-widget .book-stats .column').toArray()) { + const label = cleanText($(column).find('span').first().text()); + if (!/^pages$/i.test(label)) continue; + return parseIntegerFromText(cleanText($(column).find('strong').first().text())); + } + + const configuredPageCount = extractConfiguredGoogleBookPageCount($); + if (configuredPageCount !== undefined) return configuredPageCount; + + return parseIntegerFromText(cleanText($('meta[property="books:page_count"], meta[name="pageCount"]').attr('content'))); +} + +function extractGenres($: CheerioAPI): string[] { + const genres: string[] = []; + const seen = new Set<string>(); + const add = (value: string | undefined) => { + const genre = cleanText(value); + if (!genre || seen.has(genre)) return; + seen.add(genre); + genres.push(genre); + }; + + $('ul.category-rankings meta[property="genre"], meta[property="book:genre"], meta[property="genre"]').each((_, el) => add($(el).attr('content'))); + return genres; +} + +function extractCoverUrl($: CheerioAPI): string | undefined { + const raw = + $('img.cover-image').first().attr('src') || + $('img[class*="cover-image"]').first().attr('src') || + $('meta[property="og:image"]').attr('content') || + $('link[rel="image_src"]').attr('href'); + if (!raw) return undefined; + return normalizeKoboCoverUrl(resolveKoboUrl(raw)); +} + +function normalizeKoboCoverUrl(url: string): string { + return url.replace(/\/book-images\/([^/]+)\/\d+\/\d+\/\d+\/(False|false)\//, '/book-images/$1/1650/2200/100/$2/'); +} + +function resolveKoboUrl(value: string): string { + if (value.startsWith('//')) return `https:${value}`; + return new URL(value, KOBO_BASE_URL).toString(); +} + +function normalizeKoboPathSegment(value: string, fallback: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, ''); + return normalized || fallback; +} + +function cleanText(value: string | undefined): string { + return (value ?? '').replace(/\s+/g, ' ').trim(); +} + +function normalizeIsbn(value: string): string | undefined { + const normalized = value.replace(/[^0-9X]/gi, '').toUpperCase(); + return normalized || undefined; +} + +function parseYearFromText(text: string): number | undefined { + const match = text.match(/\b(1[0-9]{3}|2[0-9]{3})\b/); + return match ? parseInt(match[1], 10) : undefined; +} + +function parseIntegerFromText(text: string | undefined): number | undefined { + if (!text) return undefined; + const match = text.replace(/,/g, '').match(/\d+/); + if (!match) return undefined; + const value = parseInt(match[0], 10); + return Number.isFinite(value) ? value : undefined; +} + +function extractConfiguredGoogleBookPageCount($: CheerioAPI): number | undefined { + const rawConfig = $('#ratings-widget-details-wrapper').attr('data-kobo-gizmo-config'); + if (!rawConfig) return undefined; + + try { + const config = JSON.parse(rawConfig) as { googleBook?: string }; + if (!config.googleBook) return undefined; + const book = JSON.parse(config.googleBook) as { numberOfPages?: unknown }; + if (typeof book.numberOfPages === 'number') return book.numberOfPages; + if (typeof book.numberOfPages === 'string') return parseIntegerFromText(book.numberOfPages); + return undefined; + } catch { + return undefined; + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/server/src/modules/metadata-fetch/providers/metadata-provider.ts b/server/src/modules/metadata-fetch/providers/metadata-provider.ts index 6c089fa27..bef1e8a4a 100644 --- a/server/src/modules/metadata-fetch/providers/metadata-provider.ts +++ b/server/src/modules/metadata-fetch/providers/metadata-provider.ts @@ -6,6 +6,7 @@ export interface MetadataProvider { readonly key: MetadataProviderKey; readonly label: string; readonly identifiable: boolean; + readonly timeoutMs?: number; search(params: MetadataSearchParams): Promise<MetadataCandidate[]>; } diff --git a/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.test.ts b/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.test.ts index 185d6e610..3268caf18 100644 --- a/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.test.ts @@ -18,6 +18,7 @@ describe('OpenLibraryProvider', () => { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; beforeEach(() => { diff --git a/server/src/modules/metadata-fetch/providers/provider-constants.ts b/server/src/modules/metadata-fetch/providers/provider-constants.ts index ff2dc3c5e..dda1e1581 100644 --- a/server/src/modules/metadata-fetch/providers/provider-constants.ts +++ b/server/src/modules/metadata-fetch/providers/provider-constants.ts @@ -1,6 +1,7 @@ export const PROVIDER_TIMEOUT_MS = { DEFAULT: 10_000, SCRAPE: 15_000, + KOBO_SCRAPE: 30_000, } as const; export const PROVIDER_LIMITS = { @@ -8,6 +9,7 @@ export const PROVIDER_LIMITS = { GOODREADS_MAX_RESULTS: 3, COMICVINE_MAX_RESULTS: 10, RANOBEDB_MAX_RESULTS: 5, + KOBO_MAX_RESULTS: 3, DEFAULT_SEARCH_RESULTS: 10, } as const; @@ -17,4 +19,5 @@ export const PROVIDER_DELAYS_MS = { HARDCOVER_RATE_LIMIT: 1_000, COMICVINE_VELOCITY_GUARD: 1_000, RANOBEDB_BETWEEN_REQUESTS: 500, + KOBO_BETWEEN_REQUESTS: 700, } as const; diff --git a/server/src/modules/metadata-fetch/providers/ranobedb/ranobedb.provider.test.ts b/server/src/modules/metadata-fetch/providers/ranobedb/ranobedb.provider.test.ts index 9f13082b3..cbe836a9c 100644 --- a/server/src/modules/metadata-fetch/providers/ranobedb/ranobedb.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/ranobedb/ranobedb.provider.test.ts @@ -18,6 +18,7 @@ const enabledConfig: ProviderConfigurations = { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: true }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; const disabledConfig: ProviderConfigurations = { diff --git a/server/src/modules/metadata-preferences/dto/update-provider-config.dto.ts b/server/src/modules/metadata-preferences/dto/update-provider-config.dto.ts index 462d11b73..c07437529 100644 --- a/server/src/modules/metadata-preferences/dto/update-provider-config.dto.ts +++ b/server/src/modules/metadata-preferences/dto/update-provider-config.dto.ts @@ -37,6 +37,12 @@ export class ComicVineProviderConfigDto { @IsOptional() @IsString() apiKey?: string; } +export class KoboProviderConfigDto { + @IsOptional() @IsBoolean() enabled?: boolean; + @IsOptional() @IsString() country?: string; + @IsOptional() @IsString() language?: string; +} + export class UpdateProviderConfigDto { @IsOptional() @ValidateNested() @Type(() => GoogleProviderConfigDto) google?: GoogleProviderConfigDto; @IsOptional() @ValidateNested() @Type(() => AmazonProviderConfigDto) amazon?: AmazonProviderConfigDto; @@ -48,4 +54,5 @@ export class UpdateProviderConfigDto { @IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) audnexus?: SimpleProviderConfigDto; @IsOptional() @ValidateNested() @Type(() => ComicVineProviderConfigDto) comicvine?: ComicVineProviderConfigDto; @IsOptional() @ValidateNested() @Type(() => SimpleProviderConfigDto) ranobedb?: SimpleProviderConfigDto; + @IsOptional() @ValidateNested() @Type(() => KoboProviderConfigDto) kobo?: KoboProviderConfigDto; } diff --git a/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts b/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts index de8d0b678..6209ea5f1 100644 --- a/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts +++ b/server/src/modules/metadata-preferences/metadata-preference-resolver.test.ts @@ -22,6 +22,7 @@ describe('MetadataPreferenceResolver', () => { MetadataProviderKey.GOOGLE, MetadataProviderKey.ITUNES, MetadataProviderKey.AMAZON, + MetadataProviderKey.KOBO, MetadataProviderKey.OPEN_LIBRARY, ]); } @@ -29,12 +30,18 @@ describe('MetadataPreferenceResolver', () => { expect(defaults.fields.cover.providers).toEqual([ MetadataProviderKey.AMAZON, MetadataProviderKey.ITUNES, + MetadataProviderKey.KOBO, MetadataProviderKey.GOODREADS, MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY, ]); - expect(defaults.fields.genres.providers).toEqual([MetadataProviderKey.GOODREADS, MetadataProviderKey.GOOGLE, MetadataProviderKey.ITUNES]); + expect(defaults.fields.genres.providers).toEqual([ + MetadataProviderKey.GOODREADS, + MetadataProviderKey.GOOGLE, + MetadataProviderKey.ITUNES, + MetadataProviderKey.KOBO, + ]); const fieldsWithoutItunes: (keyof typeof defaults.fields)[] = [ 'publisher', @@ -50,6 +57,7 @@ describe('MetadataPreferenceResolver', () => { MetadataProviderKey.GOODREADS, MetadataProviderKey.GOOGLE, MetadataProviderKey.AMAZON, + MetadataProviderKey.KOBO, MetadataProviderKey.OPEN_LIBRARY, ]); } diff --git a/server/src/modules/metadata-preferences/metadata-preference-resolver.ts b/server/src/modules/metadata-preferences/metadata-preference-resolver.ts index 78ad89d9a..cee3c12ac 100644 --- a/server/src/modules/metadata-preferences/metadata-preference-resolver.ts +++ b/server/src/modules/metadata-preferences/metadata-preference-resolver.ts @@ -18,6 +18,7 @@ const DEFAULT_PROVIDER_ORDER: MetadataProviderKey[] = [ MetadataProviderKey.GOODREADS, MetadataProviderKey.GOOGLE, MetadataProviderKey.AMAZON, + MetadataProviderKey.KOBO, MetadataProviderKey.OPEN_LIBRARY, ]; @@ -30,6 +31,7 @@ const PROVIDERS_WITH_ITUNES: MetadataProviderKey[] = [ MetadataProviderKey.GOOGLE, MetadataProviderKey.ITUNES, MetadataProviderKey.AMAZON, + MetadataProviderKey.KOBO, MetadataProviderKey.OPEN_LIBRARY, ]; @@ -41,13 +43,14 @@ const FIELD_DEFAULTS: Partial<Record<MetadataField, Partial<FieldPreference>>> = providers: [ MetadataProviderKey.AMAZON, MetadataProviderKey.ITUNES, + MetadataProviderKey.KOBO, MetadataProviderKey.GOODREADS, MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY, ], }, authors: { providers: PROVIDERS_WITH_ITUNES }, - genres: { providers: [MetadataProviderKey.GOODREADS, MetadataProviderKey.GOOGLE, MetadataProviderKey.ITUNES] }, + genres: { providers: [MetadataProviderKey.GOODREADS, MetadataProviderKey.GOOGLE, MetadataProviderKey.ITUNES, MetadataProviderKey.KOBO] }, }; @Injectable() diff --git a/server/src/modules/metadata-preferences/provider-config.service.test.ts b/server/src/modules/metadata-preferences/provider-config.service.test.ts index 35fe93509..8535f8d3c 100644 --- a/server/src/modules/metadata-preferences/provider-config.service.test.ts +++ b/server/src/modules/metadata-preferences/provider-config.service.test.ts @@ -73,6 +73,7 @@ describe('ProviderConfigService', () => { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }); }); @@ -354,6 +355,7 @@ describe('ProviderConfigService', () => { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }); expect(statuses.map((s) => s.key)).toEqual([ @@ -367,6 +369,7 @@ describe('ProviderConfigService', () => { MetadataProviderKey.AUDNEXUS, MetadataProviderKey.COMICVINE, MetadataProviderKey.RANOBEDB, + MetadataProviderKey.KOBO, ]); expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.configured).toBe(false); expect(statuses.find((s) => s.key === MetadataProviderKey.GOOGLE)?.hint).toContain('API key required'); @@ -390,6 +393,7 @@ describe('ProviderConfigService', () => { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }); expect(statuses.find((s) => s.key === MetadataProviderKey.HARDCOVER)?.configured).toBe(true); diff --git a/server/src/modules/metadata-preferences/provider-config.service.ts b/server/src/modules/metadata-preferences/provider-config.service.ts index 1d611cf80..02498cae0 100644 --- a/server/src/modules/metadata-preferences/provider-config.service.ts +++ b/server/src/modules/metadata-preferences/provider-config.service.ts @@ -43,6 +43,7 @@ const DEFAULT_CONFIG: ProviderConfigurations = { audnexus: { enabled: false }, comicvine: { enabled: false, apiKey: '' }, ranobedb: { enabled: false }, + kobo: { enabled: false, country: 'us', language: 'en' }, }; function asObject(value: unknown): Record<string, unknown> { @@ -115,6 +116,15 @@ function mergeComicVineConfig(base: ProviderConfigurations['comicvine'], value: }; } +function mergeKoboConfig(base: ProviderConfigurations['kobo'], value: unknown): ProviderConfigurations['kobo'] { + const next = asObject(value); + return { + enabled: asBoolean(next.enabled, base.enabled), + country: asString(next.country, base.country), + language: asString(next.language, base.language), + }; +} + const PROVIDER_LABELS: Record<MetadataProviderKey, string> = { [MetadataProviderKey.GOOGLE]: 'Google Books', [MetadataProviderKey.AMAZON]: 'Amazon', @@ -126,6 +136,7 @@ const PROVIDER_LABELS: Record<MetadataProviderKey, string> = { [MetadataProviderKey.AUDNEXUS]: 'AudNexus', [MetadataProviderKey.COMICVINE]: 'ComicVine', [MetadataProviderKey.RANOBEDB]: 'RanobeDB', + [MetadataProviderKey.KOBO]: 'Kobo', }; type ProviderEnableRule = { @@ -170,6 +181,7 @@ export class ProviderConfigService { audnexus: { ...DEFAULT_CONFIG.audnexus }, comicvine: { ...DEFAULT_CONFIG.comicvine }, ranobedb: { ...DEFAULT_CONFIG.ranobedb }, + kobo: { ...DEFAULT_CONFIG.kobo }, }; } @@ -186,6 +198,7 @@ export class ProviderConfigService { audnexus: mergeSimpleConfig(base.audnexus, next.audnexus), comicvine: mergeComicVineConfig(base.comicvine, next.comicvine), ranobedb: mergeSimpleConfig(base.ranobedb, next.ranobedb), + kobo: mergeKoboConfig(base.kobo, next.kobo), }; } @@ -251,6 +264,11 @@ export class ProviderConfigService { ...config.comicvine, apiKey: config.comicvine.apiKey.trim(), }, + kobo: { + ...config.kobo, + country: this.normalizeKoboPathSegment(config.kobo.country, DEFAULT_CONFIG.kobo.country), + language: this.normalizeKoboPathSegment(config.kobo.language, DEFAULT_CONFIG.kobo.language), + }, }; if (!normalized.google.enabled || normalized.google.apiKey) return normalized; @@ -274,6 +292,14 @@ export class ProviderConfigService { return normalized; } + private normalizeKoboPathSegment(value: string, fallback: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, ''); + return normalized || fallback; + } + private parsePersistedConfig( rawValue: string, fallback: ProviderConfigurations, @@ -393,6 +419,13 @@ export class ProviderConfigService { enabled: cfg.ranobedb.enabled, configured: true, }, + { + key: MetadataProviderKey.KOBO, + label: PROVIDER_LABELS[MetadataProviderKey.KOBO], + enabled: cfg.kobo.enabled, + configured: true, + hint: 'Web scraping may be blocked by Kobo bot protection', + }, ]; } diff --git a/server/src/modules/metadata-score/dto/update-weights.dto.ts b/server/src/modules/metadata-score/dto/update-weights.dto.ts index 1e5c86f13..53456985b 100644 --- a/server/src/modules/metadata-score/dto/update-weights.dto.ts +++ b/server/src/modules/metadata-score/dto/update-weights.dto.ts @@ -23,4 +23,5 @@ export class UpdateWeightsDto { @IsInt() @Min(0) hardcoverId!: number; @IsInt() @Min(0) openLibraryId!: number; @IsInt() @Min(0) itunesId!: number; + @IsInt() @Min(0) koboId!: number; } diff --git a/server/src/modules/metadata-score/metadata-score.controller.test.ts b/server/src/modules/metadata-score/metadata-score.controller.test.ts index 30eae44b4..53bc69908 100644 --- a/server/src/modules/metadata-score/metadata-score.controller.test.ts +++ b/server/src/modules/metadata-score/metadata-score.controller.test.ts @@ -57,6 +57,7 @@ describe('MetadataScoreController', () => { hardcoverId: 1, openLibraryId: 1, itunesId: 1, + koboId: 1, }; service.updateWeights.mockResolvedValue(dto); diff --git a/server/src/modules/metadata-score/metadata-score.repository.ts b/server/src/modules/metadata-score/metadata-score.repository.ts index a520986e2..9b9982b33 100644 --- a/server/src/modules/metadata-score/metadata-score.repository.ts +++ b/server/src/modules/metadata-score/metadata-score.repository.ts @@ -29,6 +29,7 @@ const METADATA_SCORE_BASE_SELECT = { hardcoverId: bookMetadata.hardcoverId, openLibraryId: bookMetadata.openLibraryId, itunesId: bookMetadata.itunesId, + koboId: bookMetadata.koboId, } as const; export type ScorePage = { diff --git a/server/src/modules/metadata-score/metadata-score.scorer.test.ts b/server/src/modules/metadata-score/metadata-score.scorer.test.ts index bba4de2f1..5201801ea 100644 --- a/server/src/modules/metadata-score/metadata-score.scorer.test.ts +++ b/server/src/modules/metadata-score/metadata-score.scorer.test.ts @@ -23,6 +23,7 @@ function makeData(overrides: Partial<ScoreData> = {}): ScoreData { hardcoverId: null, openLibraryId: null, itunesId: null, + koboId: null, authorCount: 0, genreCount: 0, tagCount: 0, @@ -79,6 +80,7 @@ describe('MetadataScoreScorer', () => { hardcoverId: 0, openLibraryId: 0, itunesId: 0, + koboId: 0, }; expect(scorer.compute(data, weights)).toBe(100); @@ -110,6 +112,7 @@ describe('MetadataScoreScorer', () => { hardcoverId: 0, openLibraryId: 0, itunesId: 0, + koboId: 0, }; expect(scorer.compute(makeData({ title: 'Dune', coverSource: 'extracted', authorCount: 10 }), weights)).toBe(100); diff --git a/server/src/modules/metadata-score/metadata-score.scorer.ts b/server/src/modules/metadata-score/metadata-score.scorer.ts index f5c8e5711..290a5cac9 100644 --- a/server/src/modules/metadata-score/metadata-score.scorer.ts +++ b/server/src/modules/metadata-score/metadata-score.scorer.ts @@ -21,6 +21,7 @@ export type ScoreData = { hardcoverId: string | null; openLibraryId: string | null; itunesId: string | null; + koboId: string | null; authorCount: number; genreCount: number; tagCount: number; @@ -52,6 +53,7 @@ const SCORE_RULES: Record<MetadataScoreField, ScoreRule> = { hardcoverId: { kind: 'string', value: (data) => data.hardcoverId }, openLibraryId: { kind: 'string', value: (data) => data.openLibraryId }, itunesId: { kind: 'string', value: (data) => data.itunesId }, + koboId: { kind: 'string', value: (data) => data.koboId }, }; @Injectable() diff --git a/server/src/modules/metadata-score/metadata-score.service.test.ts b/server/src/modules/metadata-score/metadata-score.service.test.ts index 4b6c87e36..10b8760e7 100644 --- a/server/src/modules/metadata-score/metadata-score.service.test.ts +++ b/server/src/modules/metadata-score/metadata-score.service.test.ts @@ -37,6 +37,7 @@ function makeWeights(): MetadataScoreWeights { hardcoverId: 1, openLibraryId: 1, itunesId: 1, + koboId: 1, }; } @@ -102,6 +103,7 @@ describe('MetadataScoreService', () => { hardcoverId: null, openLibraryId: null, itunesId: null, + koboId: null, authorCount: 1, genreCount: 0, tagCount: 0, @@ -157,6 +159,7 @@ describe('MetadataScoreService', () => { hardcoverId: null, openLibraryId: null, itunesId: null, + koboId: null, authorCount: 1, genreCount: 0, tagCount: 0, @@ -184,6 +187,7 @@ describe('MetadataScoreService', () => { hardcoverId: null, openLibraryId: null, itunesId: null, + koboId: null, authorCount: 1, genreCount: 0, tagCount: 0, @@ -216,6 +220,7 @@ describe('MetadataScoreService', () => { hardcoverId: null, openLibraryId: null, itunesId: null, + koboId: null, authorCount: 1, genreCount: 0, tagCount: 0, diff --git a/server/src/modules/metadata/extractors/comic-format.extractor.ts b/server/src/modules/metadata/extractors/comic-format.extractor.ts index 5a82600ec..244a7baae 100644 --- a/server/src/modules/metadata/extractors/comic-format.extractor.ts +++ b/server/src/modules/metadata/extractors/comic-format.extractor.ts @@ -47,6 +47,7 @@ export class ComicFormatExtractor implements FormatExtractor { hardcoverId: comicMetadata?.hardcoverId ?? null, openLibraryId: comicMetadata?.openLibraryId ?? null, ranobedbId: comicMetadata?.ranobedbId ?? null, + koboId: comicMetadata?.koboId ?? null, itunesId: comicMetadata?.itunesId ?? null, cover: cover ?? null, comicMetadata: comicMetadata?.comicMetadata ?? null, diff --git a/server/src/modules/metadata/extractors/format-extractor.interface.ts b/server/src/modules/metadata/extractors/format-extractor.interface.ts index 1c574b7cc..3fe165347 100644 --- a/server/src/modules/metadata/extractors/format-extractor.interface.ts +++ b/server/src/modules/metadata/extractors/format-extractor.interface.ts @@ -21,6 +21,7 @@ export interface ParsedBookData { hardcoverId?: string | null; openLibraryId?: string | null; ranobedbId?: string | null; + koboId?: string | null; itunesId?: string | null; audibleId?: string | null; cover: Buffer | null; diff --git a/server/src/modules/metadata/extractors/opf-metadata.mapper.ts b/server/src/modules/metadata/extractors/opf-metadata.mapper.ts index aa8297fff..5f7c5ddc8 100644 --- a/server/src/modules/metadata/extractors/opf-metadata.mapper.ts +++ b/server/src/modules/metadata/extractors/opf-metadata.mapper.ts @@ -24,6 +24,7 @@ export function hasOpfMetadata(metadata: ParsedOpf): boolean { metadata.hardcoverId !== null || metadata.openLibraryId !== null || metadata.ranobedbId !== null || + metadata.koboId !== null || metadata.itunesId !== null ); } @@ -51,6 +52,7 @@ export function mapOpfMetadata(metadata: ParsedOpf, cover: Buffer | null): Parse hardcoverId: metadata.hardcoverId, openLibraryId: metadata.openLibraryId, ranobedbId: metadata.ranobedbId, + koboId: metadata.koboId, itunesId: metadata.itunesId, cover, }; diff --git a/server/src/modules/metadata/extractors/pdf-format.extractor.ts b/server/src/modules/metadata/extractors/pdf-format.extractor.ts index 3116c6fb0..57d9b97c9 100644 --- a/server/src/modules/metadata/extractors/pdf-format.extractor.ts +++ b/server/src/modules/metadata/extractors/pdf-format.extractor.ts @@ -36,6 +36,7 @@ export class PdfFormatExtractor implements FormatExtractor { hardcoverId: pdf.hardcoverId, openLibraryId: pdf.openLibraryId, ranobedbId: pdf.ranobedbId, + koboId: pdf.koboId, itunesId: pdf.itunesId, cover: pdf.coverBuffer, pageCount: pdf.pageCount, diff --git a/server/src/modules/metadata/lib/cbz-metadata.ts b/server/src/modules/metadata/lib/cbz-metadata.ts index e58c46e00..d24dc82f3 100644 --- a/server/src/modules/metadata/lib/cbz-metadata.ts +++ b/server/src/modules/metadata/lib/cbz-metadata.ts @@ -45,6 +45,7 @@ export interface ParsedCbzMetadata { hardcoverId: string | null; openLibraryId: string | null; ranobedbId: string | null; + koboId: string | null; itunesId: string | null; comicMetadata: ParsedCbzComicMetadata | null; } @@ -155,7 +156,7 @@ function parseProjectxManagedNotes(notes: string | null): Map<string, string> { function parseProviderIdsFromWebUrl( webUrl: string | null, -): Partial<Record<'goodreadsId' | 'amazonId' | 'hardcoverId' | 'googleBooksId' | 'openLibraryId', string>> { +): Partial<Record<'goodreadsId' | 'amazonId' | 'hardcoverId' | 'googleBooksId' | 'openLibraryId' | 'koboId', string>> { if (!webUrl) return {}; if (webUrl.startsWith('https://www.goodreads.com/book/show/')) { @@ -173,6 +174,9 @@ function parseProviderIdsFromWebUrl( if (webUrl.startsWith('https://openlibrary.org/works/')) { return { openLibraryId: webUrl.slice('https://openlibrary.org/works/'.length) || '' }; } + if (webUrl.startsWith('https://www.kobo.com/us/en/ebook/')) { + return { koboId: webUrl.slice('https://www.kobo.com/us/en/ebook/'.length) || '' }; + } return {}; } @@ -262,6 +266,7 @@ function parseComicInfoXml(xmlBuf: Buffer): ParsedCbzMetadata | null { hardcoverId: managedNotes.get('hardcoverId') ?? providerIdsFromWeb.hardcoverId ?? null, openLibraryId: managedNotes.get('openLibraryId') ?? providerIdsFromWeb.openLibraryId ?? null, ranobedbId: managedNotes.get('ranobedbId') ?? null, + koboId: managedNotes.get('koboId') ?? providerIdsFromWeb.koboId ?? null, itunesId: null, comicMetadata: hasComicFields ? { @@ -318,6 +323,7 @@ function parseComicBookInfoJson(comment: string): ParsedCbzMetadata | null { hardcoverId: null, openLibraryId: null, ranobedbId: null, + koboId: null, itunesId: null, comicMetadata: null, }; diff --git a/server/src/modules/metadata/lib/opf-parser.ts b/server/src/modules/metadata/lib/opf-parser.ts index 51bd87bb8..1092e55d8 100644 --- a/server/src/modules/metadata/lib/opf-parser.ts +++ b/server/src/modules/metadata/lib/opf-parser.ts @@ -22,6 +22,7 @@ export interface ParsedOpf { hardcoverId: string | null; openLibraryId: string | null; ranobedbId: string | null; + koboId: string | null; itunesId: string | null; } @@ -205,6 +206,7 @@ export function parseOpf(xml: string): ParsedOpf { let schemeHardcoverId: string | null = null; let schemeOpenLibraryId: string | null = null; let schemeRanobedbId: string | null = null; + let schemeKoboId: string | null = null; let schemeItunesId: string | null = null; let urnGoogleBooksId: string | null = null; @@ -213,6 +215,7 @@ export function parseOpf(xml: string): ParsedOpf { let urnHardcoverId: string | null = null; let urnOpenLibraryId: string | null = null; let urnRanobedbId: string | null = null; + let urnKoboId: string | null = null; let urnItunesId: string | null = null; for (const ident of toArray(metadata['identifier'])) { @@ -235,6 +238,7 @@ export function parseOpf(xml: string): ParsedOpf { if (scheme === 'hardcover') schemeHardcoverId ??= value || null; if (scheme === 'openlibrary') schemeOpenLibraryId ??= value || null; if (scheme === 'ranobedb') schemeRanobedbId ??= value || null; + if (scheme === 'kobo') schemeKoboId ??= value || null; if (scheme === 'itunes') schemeItunesId ??= value || null; // urn:-prefixed provider identifiers (legacy / backward-compat) @@ -244,6 +248,7 @@ export function parseOpf(xml: string): ParsedOpf { 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:ranobedb:')) urnRanobedbId ??= value.slice('urn:ranobedb:'.length) || null; + if (value.startsWith('urn:kobo:')) urnKoboId ??= value.slice('urn:kobo:'.length) || null; if (value.startsWith('urn:itunes:')) urnItunesId ??= value.slice('urn:itunes:'.length) || null; } @@ -254,6 +259,7 @@ export function parseOpf(xml: string): ParsedOpf { const hardcoverId = schemeHardcoverId ?? urnHardcoverId; const openLibraryId = schemeOpenLibraryId ?? urnOpenLibraryId; const ranobedbId = schemeRanobedbId ?? urnRanobedbId; + const koboId = schemeKoboId ?? urnKoboId; const itunesId = schemeItunesId ?? urnItunesId; isbn10 ??= propertyMeta('bookorbit:isbn10') ?? namedMeta('bookorbit:isbn10'); @@ -321,6 +327,7 @@ export function parseOpf(xml: string): ParsedOpf { hardcoverId, openLibraryId, ranobedbId, + koboId, itunesId, }; } diff --git a/server/src/modules/metadata/lib/pdf-parser.ts b/server/src/modules/metadata/lib/pdf-parser.ts index de6f71b6a..843b85ce8 100644 --- a/server/src/modules/metadata/lib/pdf-parser.ts +++ b/server/src/modules/metadata/lib/pdf-parser.ts @@ -28,6 +28,7 @@ export interface PdfParsed { hardcoverId: string | null; openLibraryId: string | null; ranobedbId: string | null; + koboId: string | null; itunesId: string | null; coverBuffer: Buffer | null; } @@ -138,6 +139,7 @@ export async function parsePdfFile(absolutePath: string, options: PdfParseOption hardcoverId: xmp?.hardcoverId ?? null, openLibraryId: xmp?.openLibraryId ?? null, ranobedbId: xmp?.ranobedbId ?? null, + koboId: xmp?.koboId ?? null, itunesId: xmp?.itunesId ?? null, coverBuffer, }; diff --git a/server/src/modules/metadata/lib/pdf-xmp-reader.ts b/server/src/modules/metadata/lib/pdf-xmp-reader.ts index 79a5161b4..2eac0e642 100644 --- a/server/src/modules/metadata/lib/pdf-xmp-reader.ts +++ b/server/src/modules/metadata/lib/pdf-xmp-reader.ts @@ -25,6 +25,7 @@ export interface XmpParsed { hardcoverId: string | null; openLibraryId: string | null; ranobedbId: string | null; + koboId: string | null; itunesId: string | null; } @@ -157,6 +158,7 @@ export function parseXmp(xmpXml: string): XmpParsed | null { hardcoverId: str(merged[`${px}:hardcoverId`]), openLibraryId: str(merged[`${px}:openLibraryId`]), ranobedbId: str(merged[`${px}:ranobedbId`]), + koboId: str(merged[`${px}:koboId`]), itunesId: str(merged[`${px}:itunesId`]), }; } diff --git a/server/src/modules/metadata/metadata.service.ts b/server/src/modules/metadata/metadata.service.ts index 5f89be9e7..d0e4c6cba 100644 --- a/server/src/modules/metadata/metadata.service.ts +++ b/server/src/modules/metadata/metadata.service.ts @@ -571,6 +571,7 @@ export class MetadataService { hardcoverId: data.hardcoverId, openLibraryId: data.openLibraryId, ranobedbId: data.ranobedbId, + koboId: data.koboId, itunesId: data.itunesId, comicMetadata: data.comicMetadata ?? undefined, }); @@ -594,6 +595,7 @@ export class MetadataService { if (filtered.hardcoverId !== undefined) scalarFields.hardcoverId = filtered.hardcoverId; if (filtered.openLibraryId !== undefined) scalarFields.openLibraryId = filtered.openLibraryId; if (filtered.ranobedbId !== undefined) scalarFields.ranobedbId = filtered.ranobedbId; + if (filtered.koboId !== undefined) scalarFields.koboId = filtered.koboId; if (filtered.itunesId !== undefined) scalarFields.itunesId = filtered.itunesId; if (Object.keys(scalarFields).length > 0) { scalarFields.updatedAt = new Date(); diff --git a/server/src/modules/migration/adapters/source-adapter.types.ts b/server/src/modules/migration/adapters/source-adapter.types.ts index a50992ac4..2b8cfb5d6 100644 --- a/server/src/modules/migration/adapters/source-adapter.types.ts +++ b/server/src/modules/migration/adapters/source-adapter.types.ts @@ -62,6 +62,7 @@ export interface SourceBook { goodreadsId?: string | null; amazonId?: string | null; hardcoverId?: string | null; + koboId?: string | null; audibleId?: string | null; comicvineId?: string | null; durationSeconds?: number | null; diff --git a/server/src/modules/migration/executor/executor-utils.test.ts b/server/src/modules/migration/executor/executor-utils.test.ts index 95754e54e..7857c7faf 100644 --- a/server/src/modules/migration/executor/executor-utils.test.ts +++ b/server/src/modules/migration/executor/executor-utils.test.ts @@ -81,6 +81,7 @@ describe('migration executor utils', () => { goodreadsId: 'gr', amazonId: 'amz', hardcoverId: 'hc', + koboId: 'kobo', audibleId: 'aud', comicvineId: 'cv', durationSeconds: 3601.7, @@ -98,6 +99,23 @@ describe('migration executor utils', () => { }); }); + it('maps Kobo provider IDs when present in source metadata', () => { + const patch = buildMetadataPatch({ + title: null, + subtitle: null, + isbn10: null, + isbn13: null, + description: null, + publisher: null, + publishedYear: null, + language: null, + koboId: 'kobo-book-slug', + presentFields: ['koboId'], + }); + + expect(patch).toEqual({ koboId: 'kobo-book-slug' }); + }); + it('builds contributor values with truncation and defaulted sort name', () => { const values = buildContributorValues({ name: 'Name', diff --git a/server/src/modules/migration/executor/executor-utils.ts b/server/src/modules/migration/executor/executor-utils.ts index 839fa07c9..baec0645e 100644 --- a/server/src/modules/migration/executor/executor-utils.ts +++ b/server/src/modules/migration/executor/executor-utils.ts @@ -83,6 +83,7 @@ export function buildMetadataPatch(sourceBook: { goodreadsId?: string | null; amazonId?: string | null; hardcoverId?: string | null; + koboId?: string | null; audibleId?: string | null; comicvineId?: string | null; durationSeconds?: number | null; @@ -109,6 +110,7 @@ export function buildMetadataPatch(sourceBook: { goodreadsId: field('goodreadsId', truncateNullableText(sourceBook.goodreadsId, 50)), amazonId: field('amazonId', truncateNullableText(sourceBook.amazonId, 20)), hardcoverId: field('hardcoverId', truncateNullableText(sourceBook.hardcoverId, 255)), + koboId: field('koboId', truncateNullableText(sourceBook.koboId, 255)), audibleId: field('audibleId', truncateNullableText(sourceBook.audibleId, 20)), comicvineId: field('comicvineId', truncateNullableText(sourceBook.comicvineId, 50)), durationSeconds: field('durationSeconds', sanitizeNonNegativeInteger(sourceBook.durationSeconds)), diff --git a/server/src/modules/migration/executor/migration-import.repository.test.ts b/server/src/modules/migration/executor/migration-import.repository.test.ts index 968de886d..c9ac161de 100644 --- a/server/src/modules/migration/executor/migration-import.repository.test.ts +++ b/server/src/modules/migration/executor/migration-import.repository.test.ts @@ -32,6 +32,7 @@ describe('MigrationImportRepository', () => { title: 'Dune', openLibraryId: 'OL1W', itunesId: '123', + koboId: 'dune-kobo', }, { bookId: 2, @@ -48,6 +49,7 @@ describe('MigrationImportRepository', () => { title: expect.anything(), openLibraryId: expect.anything(), itunesId: expect.anything(), + koboId: expect.anything(), updatedAt: expect.anything(), }), }), diff --git a/server/src/modules/migration/executor/migration-import.repository.ts b/server/src/modules/migration/executor/migration-import.repository.ts index 4cb324ce1..3654d1a8c 100644 --- a/server/src/modules/migration/executor/migration-import.repository.ts +++ b/server/src/modules/migration/executor/migration-import.repository.ts @@ -48,6 +48,7 @@ const BOOK_METADATA_COLUMN_BY_PROPERTY = { goodreadsId: 'goodreads_id', amazonId: 'amazon_id', hardcoverId: 'hardcover_id', + koboId: 'kobo_id', audibleId: 'audible_id', comicvineId: 'comicvine_id', openLibraryId: 'open_library_id', diff --git a/server/src/modules/statistics/statistics.repository.ts b/server/src/modules/statistics/statistics.repository.ts index cb3c8900f..02749a0dd 100644 --- a/server/src/modules/statistics/statistics.repository.ts +++ b/server/src/modules/statistics/statistics.repository.ts @@ -735,7 +735,9 @@ export class StatisticsRepository { ${bookMetadata.openLibraryId} is not null or ${bookMetadata.itunesId} is not null or ${bookMetadata.audibleId} is not null or - ${bookMetadata.comicvineId} is not null + ${bookMetadata.koboId} is not null or + ${bookMetadata.comicvineId} is not null or + ${bookMetadata.ranobedbId} is not null )`; const [row] = await this.db diff --git a/server/src/scripts/setup-kobo-cloudscraper.ts b/server/src/scripts/setup-kobo-cloudscraper.ts new file mode 100644 index 000000000..df9a447c0 --- /dev/null +++ b/server/src/scripts/setup-kobo-cloudscraper.ts @@ -0,0 +1,59 @@ +import { execFile as execFileCallback } from 'child_process'; +import { existsSync } from 'fs'; +import { mkdir } from 'fs/promises'; +import { join } from 'path'; +import { promisify } from 'util'; + +const execFile = promisify(execFileCallback); + +const SERVER_ROOT = process.cwd(); +const VENV_DIR = join(SERVER_ROOT, '.venv', 'kobo-cloudscraper'); +const PYTHON_BIN = process.platform === 'win32' ? join(VENV_DIR, 'Scripts', 'python.exe') : join(VENV_DIR, 'bin', 'python'); +const REQUIREMENTS_PATH = join(SERVER_ROOT, 'requirements', 'kobo-cloudscraper.txt'); + +async function runCommand(command: string, args: string[]): Promise<void> { + await execFile(command, args, { + cwd: SERVER_ROOT, + maxBuffer: 1024 * 1024, + }); +} + +async function hasCloudscraper(): Promise<boolean> { + if (!existsSync(PYTHON_BIN)) return false; + + try { + await runCommand(PYTHON_BIN, ['-c', 'import cloudscraper; assert getattr(cloudscraper, "__version__", "") == "3.0.0"']); + return true; + } catch { + return false; + } +} + +async function createVenv(): Promise<void> { + await mkdir(join(SERVER_ROOT, '.venv'), { recursive: true }); + + try { + await runCommand('python3', ['-m', 'venv', VENV_DIR]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not create Kobo cloudscraper venv with python3: ${message}`, { cause: error }); + } +} + +async function run(): Promise<void> { + if (await hasCloudscraper()) { + return; + } + + await createVenv(); + await runCommand(PYTHON_BIN, ['-m', 'pip', 'install', '--disable-pip-version-check', '--quiet', '-r', REQUIREMENTS_PATH]); + + if (!(await hasCloudscraper())) { + throw new Error('Kobo cloudscraper setup completed, but cloudscraper 3.0.0 could not be imported'); + } +} + +void run().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +});