feat(metadata): add external HTTP metadata providers

Register book-metadata-provider services by address, with no code change per
provider; Settings lists ready-made ones to install. Ranking prefers a record of
the book's own medium. LubimyCzytac moves out to its own provider and Aladin,
whose API closes, is retired; their ids stay. Adds migrations 0129 and 0130.
See docs/METADATA_PROVIDERS.md.

Ref #1405
This commit is contained in:
neon
2026-10-22 21:05:06 +00:00
parent 9e49f496f1
commit b50b8da56c
186 changed files with 59209 additions and 2550 deletions
+12 -6
View File
@@ -97,7 +97,7 @@ Take your library anywhere with the official native BookOrbit app. Read ebooks,
### Library Management
- **Multiple Libraries**: Isolate content with per-library folders, custom scan rules, and format priorities.
- **14 Metadata Providers**: Google Books, Open Library, Amazon, Goodreads, Kobo, Hardcover, Audible, Audnexus, Libro.fm, and iTunes, plus ComicVine for comics, RanobeDB for light novels, and Aladin and Lubimyczytać for Korean and Polish catalogs. Cover art is sourced separately from iTunes, DuckDuckGo, and AudiobookCovers.
- **12 Metadata Providers**: Google Books, Open Library, Amazon, Goodreads, Kobo, Hardcover, Audible, Audnexus, Libro.fm, and iTunes, plus ComicVine for comics and RanobeDB for light novels. [External providers](docs/METADATA_PROVIDERS.md) add more, such as Douban, FantLab, J-Novel Club, Libris, LubimyCzytac and MangaBaka. Cover art is sourced separately from iTunes, DuckDuckGo, and AudiobookCovers.
- **Smart Scopes & Collections**: Organize your collection with curated lists and dynamic, rule-based saved filters.
### Platform & Delivery
@@ -167,12 +167,18 @@ The download is pre-configured with your server URL and credentials, so there is
## Documentation and Contributing
Full documentation is at **[bookorbit.app](https://bookorbit.app/what-is-bookorbit)**, covering libraries, metadata, readers, Kobo sync, OPDS, users and permissions, OIDC setup, and more.
Full documentation lives at **[bookorbit.app](https://bookorbit.app/what-is-bookorbit)**: libraries, metadata, readers, Kobo sync, OPDS, users and permissions, OIDC and more.
For setting up book requests, see the [book requests guide](docs/BOOK_REQUESTS.md): indexers,
download clients, path mappings, automation, and the encryption key they all need.
For local development, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). To contribute, see [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for the full workflow: branch naming, test expectations, PR checklist, and commit format.
For optional STAlign read-aloud generation and QuartzNet hardware acceleration, see [docs/READ_ALOUD.md](docs/READ_ALOUD.md).
**Guides**
- **Book requests**: indexers, download clients, path mappings, automation and the encryption key they need. See the [book requests guide](https://bookorbit.app/book-requests/).
- **Read-aloud**: optional STAlign generation and QuartzNet hardware acceleration. See [docs/READ_ALOUD.md](docs/READ_ALOUD.md).
- **External metadata providers**: add a source that runs beside BookOrbit, such as FantLab or MangaBaka, with [docs/METADATA_PROVIDERS.md](docs/METADATA_PROVIDERS.md), or build your own with [docs/BUILDING_METADATA_PROVIDERS.md](docs/BUILDING_METADATA_PROVIDERS.md).
**Contributing**
- **Local development**: architecture, first-time setup, running, testing and conventions in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
- **Contributing**: branch naming, test expectations, the PR checklist and commit format in [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md).
## Unsupported Storage Configurations
@@ -26,6 +26,7 @@ const domainByResource: Record<AuditResource, AuditResourceDomain> = {
[AuditResource.BookRequest]: 'content',
[AuditResource.DownloadClient]: 'integrations',
[AuditResource.RequestIndexer]: 'integrations',
[AuditResource.MetadataHttpProvider]: 'integrations',
[AuditResource.Author]: 'people',
[AuditResource.AppSettings]: 'settings',
[AuditResource.Genre]: 'metadata',
@@ -422,8 +422,8 @@ function providerFallback(provider: MetadataProviderKey): string {
function providerSourceStyle(provider: MetadataProviderKey): Record<string, string> {
const color = getProviderColor(provider)
return {
borderColor: `${color}66`,
backgroundColor: `${color}12`,
borderColor: `color-mix(in srgb, ${color} 40%, transparent)`,
backgroundColor: `color-mix(in srgb, ${color} 7%, transparent)`,
}
}
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { Plus, Trash2, X } from '@lucide/vue'
import {
COMMUNITY_RATING_PROVIDER_KEYS,
isBuiltInMetadataProviderKey,
CUSTOM_FIELD_OPERATORS,
CUSTOM_FIELD_TYPE_OPERATORS,
FIELD_OPERATORS,
@@ -24,7 +25,8 @@ import { READ_STATUSES } from '@bookorbit/types'
import { useActiveCustomFields } from '@/features/book/composables/useActiveCustomFields'
import { fieldLabel, operatorLabel } from '@/features/book/lib/filter-labels'
import { providerIconPathSafe } from '@/features/book/lib/provider-icons'
import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { providerShortLabel } from '@/lib/provider-colors'
import { useMetadataProviderLabels } from '@/features/book/composables/useMetadataProviderLabels'
import { useLibraries } from '@/features/library/composables/useLibraries'
import FilterChipTypeahead from './FilterChipTypeahead.vue'
import FilterFormatPicker from './FilterFormatPicker.vue'
@@ -97,13 +99,18 @@ const READ_STATUS_LABELS = computed<Record<string, string>>(() => ({
abandoned: t('book.readStatus.abandoned'),
}))
const COMMUNITY_RATING_PROVIDER_OPTIONS = computed<{ value: CommunityRatingProvider; label: string }[]>(() => [
{ value: 'any', label: t('book.filter.anyProvider') },
...COMMUNITY_RATING_PROVIDER_KEYS.map((provider) => ({
value: provider,
label: PROVIDER_SHORT_LABELS[provider] ?? provider,
})),
])
const { labels: providerLabels } = useMetadataProviderLabels()
// External providers' ratings can be filtered on too; they join the built-in list once known.
const COMMUNITY_RATING_PROVIDER_OPTIONS = computed<{ value: CommunityRatingProvider; label: string }[]>(() => {
const external = Object.keys(providerLabels.value).filter((key) => !isBuiltInMetadataProviderKey(key))
return [
{ value: 'any', label: t('book.filter.anyProvider') },
...[...COMMUNITY_RATING_PROVIDER_KEYS, ...external].map((provider) => ({
value: provider,
label: providerShortLabel(provider, providerLabels.value[provider]),
})),
]
})
const ENDPOINT_BY_FIELD: Partial<Record<RuleField, string>> = {
author: '/api/v1/metadata/authors',
@@ -106,8 +106,8 @@ const publishedDisplay = computed(() => (detail.value ? displayPublishedDate(det
function providerLinkStyle(provider: string) {
const color = getProviderColor(provider)
return {
borderColor: `${color}66`,
backgroundColor: `${color}12`,
borderColor: `color-mix(in srgb, ${color} 40%, transparent)`,
backgroundColor: `color-mix(in srgb, ${color} 7%, transparent)`,
}
}
@@ -26,10 +26,11 @@ import {
import BookFormatChip from '@/features/book/components/BookFormatChip.vue'
import { bookFormatEntries, fileFormatKey, formatKeyName } from '@/features/book/lib/book-formats'
import { providerIconPathSafe } from '@/features/book/lib/provider-icons'
import { createBookProviderLinks } from '@/features/book/lib/provider-links'
import { createBookProviderLinks, createExternalProviderLinks } from '@/features/book/lib/provider-links'
import { useMetadataProviderLabels } from '@/features/book/composables/useMetadataProviderLabels'
import { faceMedium } from '@/features/book/lib/cover-slots'
import { readingDateToDateKey } from '@/features/book/lib/reading-date'
import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { getProviderColor, providerShortLabel } from '@/lib/provider-colors'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useBookCoverAspectRatio } from '@/features/book/composables/useBookCoverAspectRatio'
import { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS, isComicFormat } from '@bookorbit/types'
@@ -498,12 +499,13 @@ function formatDuration(seconds: number | null | undefined): string {
const localRating = ref<number | null>(null)
const hoverRating = ref<number | null>(null)
const displayRating = computed(() => hoverRating.value ?? localRating.value)
const { labels: providerLabels } = useMetadataProviderLabels()
const communityRatingByProvider = computed(() => {
const map: Record<string, { score: string; tooltip: string }> = {}
for (const rating of props.book.communityRatings) {
if (rating.rating == null || !Number.isFinite(rating.rating)) continue
const score = Number.isInteger(rating.rating) ? rating.rating.toFixed(0) : rating.rating.toFixed(1)
const label = PROVIDER_SHORT_LABELS[rating.provider] ?? rating.provider
const label = providerShortLabel(rating.provider, providerLabels.value[rating.provider])
const value = formatCommunityRatingValue(rating.rating, rating.ratingCount)
map[rating.provider] = { score, tooltip: `${label} ${value}` }
}
@@ -825,7 +827,10 @@ const supplementalLoading = ref(false)
const resettingFileIds = ref<number[]>([])
const providerIconErrors = ref<Record<string, boolean>>({})
const providerLinks = computed(() => createBookProviderLinks(props.book.providerIds, providerLinkSettings.value))
const providerLinks = computed(() => [
...createBookProviderLinks(props.book.providerIds, providerLinkSettings.value),
...createExternalProviderLinks(props.book.providerIds, props.book.providerLinks, providerLabels.value),
])
const communityRatingBadges = computed(() => {
const linkByKey = new Map(providerLinks.value.map((link) => [link.key, link]))
@@ -834,7 +839,7 @@ const communityRatingBadges = computed(() => {
.map((rating) => {
const link = linkByKey.get(rating.provider)
const meta = communityRatingByProvider.value[rating.provider]
const label = PROVIDER_SHORT_LABELS[rating.provider] ?? rating.provider
const label = providerShortLabel(rating.provider, providerLabels.value[rating.provider])
return {
key: rating.provider,
label,
@@ -845,7 +850,6 @@ const communityRatingBadges = computed(() => {
tooltip: meta?.tooltip ?? '',
}
})
.filter((badge) => badge.iconUrl)
})
const unlinkedCommunityBadges = computed(() => communityRatingBadges.value.filter((badge) => !badge.url))
@@ -1002,8 +1006,8 @@ function formatDate(iso: string): string {
function providerLinkStyle(provider: string) {
const color = getProviderColor(provider)
return {
borderColor: `${color}66`,
backgroundColor: `${color}12`,
borderColor: `color-mix(in srgb, ${color} 40%, transparent)`,
backgroundColor: `color-mix(in srgb, ${color} 7%, transparent)`,
}
}
@@ -29,6 +29,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { api } from '@/lib/api'
import { registerProviderAppearance } from '@/lib/provider-appearance'
import { metadataScoreColor } from '@/lib/metadata-score-color'
import ChipInput from '@/components/ui/ChipInput.vue'
import IdentifierInput from '@/components/ui/IdentifierInput.vue'
@@ -193,6 +194,20 @@ const coverPanel = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
const searchOpen = ref(false)
const availableMetadataProviders = ref<MetadataProviderInfo[] | null>(null)
const visibleProviderIdFields = computed(() => filterProviderIdFields(availableMetadataProviders.value))
/**
* External providers' ids: one row per registered external provider, plus any id the book still
* holds from a provider that has since been removed, so it can be read and cleared.
*/
const externalProviderIdRows = computed(() => {
const rows = new Map<string, { key: string; label: string; registered: boolean }>()
for (const provider of availableMetadataProviders.value ?? []) {
if (provider.source === 'external') rows.set(provider.key, { key: provider.key, label: provider.label, registered: true })
}
for (const key of Object.keys(form.externalProviderIds)) {
if (!rows.has(key)) rows.set(key, { key, label: key, registered: false })
}
return [...rows.values()]
})
let providerLoadToken = 0
async function loadAvailableMetadataProviders(bookId: number) {
@@ -202,6 +217,7 @@ async function loadAvailableMetadataProviders(bookId: number) {
const res = await api(`/api/v1/metadata-fetch/providers?bookId=${bookId}`)
if (!res.ok) return
const providers = (await res.json()) as MetadataProviderInfo[]
registerProviderAppearance(providers)
if (token === providerLoadToken) availableMetadataProviders.value = providers
} catch {
// non-fatal: keep all provider ID fields visible when provider availability cannot be loaded
@@ -293,7 +309,8 @@ const filledProviderIdSummary = computed(() => {
const value = form[entry.field]
return typeof value === 'string' && value.trim().length > 0
}).length
return `${filled}/${visibleProviderIdFields.value.length}`
const filledExternal = externalProviderIdRows.value.filter((row) => (form.externalProviderIds[row.key] ?? '').trim().length > 0).length
return `${filled + filledExternal}/${visibleProviderIdFields.value.length + externalProviderIdRows.value.length}`
})
const comicCreditFields = computed(
@@ -575,6 +592,24 @@ function applyCustomMetadataPatch(formPatch: MetadataPatch): number {
return updated
}
/** An empty id is sent as null on save, which clears it. */
function clearExternalProviderId(key: string) {
form.externalProviderIds = { ...form.externalProviderIds, [key]: '' }
}
/** External ids have no lock, so a linked candidate's id always lands, with the page it came from. */
function applyExternalProviderIdsPatch(formPatch: MetadataPatch): number {
let updated = 0
for (const [key, id] of Object.entries(formPatch.externalProviderIds ?? {})) {
if (!id || form.externalProviderIds[key] === id) continue
form.externalProviderIds = { ...form.externalProviderIds, [key]: id }
const link = formPatch.externalProviderLinks?.[key]
if (link) form.externalProviderLinks = { ...form.externalProviderLinks, [key]: link }
updated++
}
return updated
}
type FetchedCovers = Partial<Record<CoverMedium, string>>
function applyPatchToForm(formPatch: MetadataPatch, covers: FetchedCovers): { skippedFields: BookMetadataLockField[]; updatedCount: number } {
@@ -591,6 +626,7 @@ function applyPatchToForm(formPatch: MetadataPatch, covers: FetchedCovers): { sk
updatedCount += applyComicPatch(formPatch, skippedFields)
updatedCount += applyAudioPatch(formPatch, skippedFields)
updatedCount += applyCustomMetadataPatch(formPatch)
updatedCount += applyExternalProviderIdsPatch(formPatch)
for (const [medium, url] of Object.entries(covers) as [CoverMedium, string | undefined][]) {
if (!url) continue
@@ -684,6 +720,13 @@ function showSaveResultToast(write: WriteResult | null, libraryAutoWriteEnabled:
toast.info(reason ? t('book.detail.editMetadata.savedWriteSkippedReason', { reason }) : t('book.detail.editMetadata.savedWriteSkipped'))
}
function externalIdsFromPreview(preview: MetadataRefreshPreview): MetadataPatch['externalProviderIds'] {
const ids = Object.entries(preview.externalProviderIds ?? {}).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '',
)
return ids.length > 0 ? Object.fromEntries(ids) : undefined
}
function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch {
return {
isbn10: preview.isbn10,
@@ -716,6 +759,7 @@ function buildPreviewPatch(preview: MetadataRefreshPreview): MetadataPatch {
ranobedbId: preview.ranobedbId,
lubimyczytacId: preview.lubimyczytacId,
aladinId: preview.aladinId,
externalProviderIds: externalIdsFromPreview(preview),
comicMetadata: preview.comicMetadata,
narrators: preview.audioMetadata?.narrators,
durationSeconds: preview.audioMetadata?.durationSeconds ?? undefined,
@@ -1354,7 +1398,10 @@ function handleCoverChanged(medium: CoverMedium | null) {
</div>
</MetadataFieldLabel>
<div v-if="visibleProviderIdFields.length > 0" class="col-span-2 flex flex-col gap-1 border-t border-border pt-2.5">
<div
v-if="visibleProviderIdFields.length > 0 || externalProviderIdRows.length > 0"
class="col-span-2 flex flex-col gap-1 border-t border-border pt-2.5"
>
<div class="flex items-center gap-2">
<h4 class="text-[10px] font-bold tracking-[0.1em] text-muted-foreground uppercase">
{{ t('book.detail.editMetadata.providerIds') }}
@@ -1398,6 +1445,37 @@ function handleCoverChanged(medium: CoverMedium | null) {
<LockOpen v-else class="size-3" aria-hidden="true" />
</button>
</div>
<div v-for="row in externalProviderIdRows" :key="`external-${row.key}`" class="flex items-center gap-2 border-b border-border/60">
<label
:for="`provider-id-external-${row.key}`"
class="w-[5.25rem] shrink-0 truncate text-[10px] font-semibold tracking-wide text-muted-foreground uppercase"
:title="row.registered ? row.label : t('book.detail.editMetadata.externalProviderUnavailable', { provider: row.label })"
>
{{ row.label }}
</label>
<input
:id="`provider-id-external-${row.key}`"
v-model="form.externalProviderIds[row.key]"
type="text"
autocomplete="off"
spellcheck="false"
maxlength="512"
:readonly="!row.registered"
class="h-8 min-w-0 flex-1 rounded-xs border border-transparent bg-transparent px-1.5 font-mono text-xs transition-shadow outline-none hover:border-input focus:border-input focus:bg-background focus:ring-1 focus:ring-ring sm:h-7"
/>
<!-- A removed provider's id cannot be edited, since the server refuses a new value for it, but it can be cleared. -->
<button
v-if="!row.registered && form.externalProviderIds[row.key]"
type="button"
class="inline-flex size-6 shrink-0 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:aria-label="t('book.detail.editMetadata.clearExternalProviderId', { provider: row.label })"
:title="t('book.detail.editMetadata.clearExternalProviderId', { provider: row.label })"
@click="clearExternalProviderId(row.key)"
>
<X class="size-3" aria-hidden="true" />
</button>
<span v-else class="size-6 shrink-0" aria-hidden="true" />
</div>
</div>
</div>
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import { refreshMetadataProviderLabels, useMetadataProviderLabels } from '../useMetadataProviderLabels'
const responses: Array<{ status: number; body?: unknown }> = []
vi.mock('@/lib/api', () => ({
api: vi.fn<() => Promise<unknown>>(() => {
const next = responses.shift() ?? { status: 500 }
return Promise.resolve({ ok: next.status < 400, status: next.status, json: () => Promise.resolve(next.body) })
}),
}))
describe('useMetadataProviderLabels', () => {
it('asks again after an outage, and picks up providers added later on refresh', async () => {
responses.push({ status: 503 })
const { labels } = useMetadataProviderLabels()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(labels.value).toEqual({})
responses.push({ status: 200, body: [{ key: 'fantlab', label: 'FantLab' }] })
useMetadataProviderLabels()
await vi.waitFor(() => expect(labels.value).toEqual({ fantlab: 'FantLab' }))
responses.push({
status: 200,
body: [
{ key: 'fantlab', label: 'FantLab (fantlab:8080)' },
{ key: 'libris', label: 'Libris' },
],
})
await refreshMetadataProviderLabels()
expect(labels.value).toEqual({ fantlab: 'FantLab (fantlab:8080)', libris: 'Libris' })
})
})
@@ -1,4 +1,5 @@
import { computed, ref, shallowReactive, toRaw, toValue, type MaybeRefOrGetter } from 'vue'
import { isKnownMetadataProviderKey } from '@bookorbit/types'
import type {
BookCommunityRating,
BookMetadataLockField,
@@ -21,6 +22,7 @@ import {
FIELD_DEFS,
LIST_FIELD_KEYS,
PROVIDER_ID_FIELD,
providerIdFieldFor,
classifyField,
classifyIdentifier,
descriptionPlainText,
@@ -48,6 +50,7 @@ export {
COMIC_KEY_MAP,
FIELD_DEFS,
PROVIDER_ID_FIELD,
providerIdFieldFor,
getCandidateValueFrom,
isComicDiffFieldKey,
isProviderIdPatchField,
@@ -167,6 +170,9 @@ export interface MetadataPatch {
ranobedbId?: string | null
lubimyczytacId?: string | null
aladinId?: string | null
/** External providers' ids, keyed by provider, linked from the candidates that contributed. */
externalProviderIds?: Partial<Record<string, string>>
externalProviderLinks?: Partial<Record<string, string>>
comicMetadata?: ComicMetadataFields
customMetadata?: CustomMetadataBookValueInput[]
}
@@ -339,7 +345,7 @@ export function useMetadataDiff(input: MetadataDiffInput) {
}
}
const idField = PROVIDER_ID_FIELD[active.provider]
const idField = providerIdFieldFor(active.provider)
const existing = existingProviderId(toValue(input.providerIds), active.provider)
const offered = active.providerId ?? ''
if (idField && (offered || existing)) {
@@ -651,10 +657,14 @@ export function useMetadataDiff(input: MetadataDiffInput) {
// Link every provider that contributed a value, so a later fetch can find the same record.
for (const candidate of contributors) {
if (!candidate.providerId) continue
const idField = PROVIDER_ID_FIELD[candidate.provider]
const idField = providerIdFieldFor(candidate.provider)
if (idField && formPatch[idField] === undefined && !lockedFieldSet.value.has(idField)) {
formPatch[idField] = candidate.providerId
}
if (!idField && !isKnownMetadataProviderKey(candidate.provider)) {
formPatch.externalProviderIds = { ...formPatch.externalProviderIds, [candidate.provider]: candidate.providerId }
if (candidate.sourceUrl) formPatch.externalProviderLinks = { ...formPatch.externalProviderLinks, [candidate.provider]: candidate.sourceUrl }
}
if (
candidate.provider === 'hardcover' &&
candidate.hardcoverEditionId &&
@@ -669,7 +679,7 @@ export function useMetadataDiff(input: MetadataDiffInput) {
const ids = toValue(input.providerIds)
if (ids) {
for (const [provider, id] of Object.entries(ids)) {
const idField = PROVIDER_ID_FIELD[provider as MetadataProviderKey]
const idField = providerIdFieldFor(provider)
if (idField && formPatch[idField] === undefined && id) {
formPatch[idField] = id
}
@@ -3,6 +3,7 @@ import { api } from '@/lib/api'
import { readApiErrorDetail } from '@/lib/api-error'
import {
FORMAT_TO_GROUP,
isKnownMetadataProviderKey,
type BookCommunityRating,
type BookDetail,
type BookMetadataLockField,
@@ -123,6 +124,26 @@ export type MetadataSaveFailure = {
status: number | null
}
/** The ids of external providers on a book: every key of `providerIds` without an id field of its own. */
function externalIdsFromBook(book: BookDetail): Record<string, string> {
return Object.fromEntries(
Object.entries(book.providerIds).filter(
(entry): entry is [string, string] => !isKnownMetadataProviderKey(entry[0]) && typeof entry[1] === 'string' && entry[1] !== '',
),
)
}
/** Only what changed: a new or edited id as itself, a removed or emptied one as null. */
function changedExternalProviderIds(current: Record<string, string>, previous: Record<string, string>): Record<string, string | null> | null {
const changes: Record<string, string | null> = {}
for (const key of new Set([...Object.keys(current), ...Object.keys(previous)])) {
const next = current[key]?.trim() ?? ''
if (next === (previous[key] ?? '')) continue
changes[key] = next || null
}
return Object.keys(changes).length > 0 ? changes : null
}
export function useMetadataEditor() {
const saving = ref(false)
const error = ref<MetadataSaveFailure | null>(null)
@@ -163,6 +184,9 @@ export function useMetadataEditor() {
ranobedbId: null as string | null,
lubimyczytacId: null as string | null,
aladinId: null as string | null,
/** External providers' ids, keyed by provider. An empty string clears one on save. */
externalProviderIds: {} as Record<string, string>,
externalProviderLinks: {} as Record<string, string>,
comicIssueNumber: null as string | null,
comicVolumeName: null as string | null,
comicStoryArcs: [] as string[],
@@ -219,6 +243,10 @@ export function useMetadataEditor() {
form.ranobedbId = book.providerIds.ranobedb ?? null
form.lubimyczytacId = book.providerIds.lubimyczytac ?? null
form.aladinId = book.providerIds.aladin ?? null
form.externalProviderIds = externalIdsFromBook(book)
form.externalProviderLinks = Object.fromEntries(
Object.entries(book.providerLinks ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
)
const cm = book.comicMetadata
form.comicIssueNumber = cm?.issueNumber ?? null
form.comicVolumeName = cm?.volumeName ?? null
@@ -295,6 +323,15 @@ export function useMetadataEditor() {
}
}
const externalIds = changedExternalProviderIds(form.externalProviderIds, previous.externalProviderIds)
if (externalIds) {
payload.externalProviderIds = externalIds
const links = Object.fromEntries(
Object.keys(externalIds).flatMap((key) => (form.externalProviderLinks[key] ? [[key, form.externalProviderLinks[key]]] : [])),
)
if (Object.keys(links).length > 0) payload.externalProviderLinks = links
}
const customMetadata = changedCustomMetadataPayload(form.customMetadata, previous.customMetadata)
if (customMetadata.length > 0) {
payload.customMetadata = customMetadata
@@ -0,0 +1,43 @@
import { ref } from 'vue'
import type { MetadataProviderInfo } from '@bookorbit/types'
import { api } from '@/lib/api'
import { registerProviderAppearance } from '@/lib/provider-appearance'
const labels = ref<Partial<Record<string, string>>>({})
let loading: Promise<void> | null = null
function load(): Promise<void> {
return (async () => {
try {
const res = await api('/api/v1/metadata-fetch/providers')
// A refusal stands for the session; an outage is worth asking again.
if (res.status >= 500) throw new Error(`provider list answered ${res.status}`)
if (!res.ok) return
const providers = (await res.json()) as MetadataProviderInfo[]
labels.value = Object.fromEntries(providers.map((provider) => [provider.key, provider.label]))
registerProviderAppearance(providers)
} catch {
// Non-fatal: callers fall back to the provider key. The next caller tries again.
loading = null
}
})()
}
/**
* Re-reads the names after providers were added, removed or renamed, so screens already open pick
* up the change without a reload.
*/
export function refreshMetadataProviderLabels(): Promise<void> {
loading = load()
return loading
}
/**
* Display names for every metadata provider the server knows, external ones included. Built-ins
* have client-side names already; this is how a provider registered at runtime gets its own name
* on screens that never load the provider list themselves. Loaded once, then on every refresh.
*/
export function useMetadataProviderLabels() {
if (!loading) loading = load()
return { labels }
}
@@ -1,5 +1,6 @@
import { computed, onUnmounted, reactive, ref, shallowReactive } from 'vue'
import { api } from '@/lib/api'
import { registerProviderAppearance } from '@/lib/provider-appearance'
import { METADATA_PROVIDER_STATUS_EVENT } from '@bookorbit/types'
import type {
ConcreteBookMediaKind,
@@ -57,6 +58,7 @@ export function useMetadataSearch() {
const res = await api(`/api/v1/metadata-fetch/providers${query}`)
if (res.ok) {
providers.value = (await res.json()) as MetadataProviderInfo[]
registerProviderAppearance(providers.value)
selectAllProviders()
}
}
@@ -11,6 +11,7 @@ import {
classifyRating,
classifyText,
descriptionPlainText,
providerIdFieldFor,
summarizeDifferences,
} from '../metadata-diff-fields'
@@ -109,3 +110,12 @@ describe('summarizeDifferences', () => {
expect(summarizeDifferences(current, candidate, { amazon: 'B002KP6DXQ' })).toEqual({ fills: 1, changes: 2 })
})
})
describe('providerIdFieldFor', () => {
it('keeps the id field of a source that moved out, and gives other external providers none', () => {
expect(providerIdFieldFor('google')).toBe('googleBooksId')
expect(providerIdFieldFor('lubimyczytac')).toBe('lubimyczytacId')
expect(providerIdFieldFor('aladin')).toBe('aladinId')
expect(providerIdFieldFor('fantlab')).toBeUndefined()
})
})
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MetadataProviderKey, type BookDetail, type MetadataFetchDiagnostics } from '@bookorbit/types'
import { metadataRefreshAppliedMessage, metadataRefreshEmptyMessage } from '../metadata-refresh-feedback'
import { metadataProviderLabel, metadataRefreshAppliedMessage, metadataRefreshEmptyMessage } from '../metadata-refresh-feedback'
function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
return {
@@ -136,3 +136,11 @@ describe('metadataRefreshAppliedMessage', () => {
)
})
})
describe('metadataProviderLabel', () => {
it('names built-in sources and the ones that moved out, and falls back to the key', () => {
expect(metadataProviderLabel('openLibrary')).toBe('Open Library')
expect(metadataProviderLabel('lubimyczytac')).toBe('LubimyCzytac')
expect(metadataProviderLabel('fantlab')).toBe('fantlab')
})
})
@@ -2,7 +2,7 @@ import type { Rule, RuleField, RuleOperator, SortField } from '@bookorbit/types'
import { isReadAlongFormatKey, parseCustomRuleFieldId, parseCustomSortFieldId } from '@bookorbit/types'
import { i18n } from '@/i18n'
import { activeCustomFieldLabel } from '@/features/book/composables/useActiveCustomFields'
import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { providerShortLabel } from '@/lib/provider-colors'
import { formatBytes } from '@/lib/formatting'
import { formatKeyName } from './book-formats'
@@ -48,7 +48,7 @@ function communityRatingProviderLabel(rule: Rule): string {
if (rule.field !== 'communityRating' && rule.field !== 'communityRatingCount') return ''
const provider = rule.provider ?? 'any'
if (provider === 'any') return t('book.filter.anyProvider')
return PROVIDER_SHORT_LABELS[provider] ?? provider
return providerShortLabel(provider)
}
export function ruleToParts(rule: Rule): { field: string; operator: string; value: string | null } {
@@ -1,4 +1,6 @@
import { isKnownMetadataProviderKey } from '@bookorbit/types'
import type {
KnownMetadataProviderKey,
ComicMetadataFields,
MetadataCandidate,
MetadataProviderKey,
@@ -118,7 +120,11 @@ export function isComicDiffFieldKey(key: DiffFieldKey): key is ComicDiffFieldKey
return key in COMIC_KEY_MAP
}
export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField | undefined> = {
/**
* The providers with an id field of their own: the built-ins, and the ones that moved out but kept
* theirs. Other external providers' ids travel in `externalProviderIds` instead.
*/
export const PROVIDER_ID_FIELD: Record<KnownMetadataProviderKey, ProviderIdPatchField | undefined> = {
google: 'googleBooksId',
goodreads: 'goodreadsId',
amazon: 'amazonId',
@@ -137,6 +143,11 @@ export const PROVIDER_ID_FIELD: Record<MetadataProviderKey, ProviderIdPatchField
const PROVIDER_ID_PATCH_FIELDS = new Set<string>(Object.values(PROVIDER_ID_FIELD).filter((v): v is ProviderIdPatchField => v !== undefined))
/** The named id field a provider's id lives in; undefined for an external provider without one. */
export function providerIdFieldFor(provider: MetadataProviderKey): ProviderIdPatchField | undefined {
return isKnownMetadataProviderKey(provider) ? PROVIDER_ID_FIELD[provider] : undefined
}
export function isProviderIdPatchField(key: DiffFieldKey): key is ProviderIdPatchField {
return PROVIDER_ID_PATCH_FIELDS.has(key)
}
@@ -480,7 +491,7 @@ export function summarizeDifferences(current: MetadataSource, candidate: Metadat
if (candidate.comicMetadata) {
for (const def of COMIC_FIELD_DEFS) count(classifyField(def.key, current, candidate).kind)
}
if (PROVIDER_ID_FIELD[candidate.provider] && candidate.providerId) {
if (providerIdFieldFor(candidate.provider) && candidate.providerId) {
count(classifyIdentifier(existingProviderId(providerIds, candidate.provider), candidate.providerId).kind)
}
return summary
@@ -1,30 +1,19 @@
import {
MetadataProviderKey,
BUILT_IN_METADATA_PROVIDER_LABELS,
isMovedMetadataProviderKey,
MOVED_METADATA_PROVIDERS,
type BookDetail,
type BuiltInMetadataProviderKey,
type MetadataFetchDiagnostics,
type MetadataProviderKey as MetadataProviderKeyType,
} from '@bookorbit/types'
import type { MetadataProviderKey as MetadataProviderKeyType } from '@bookorbit/types'
const PROVIDER_LABELS: Record<MetadataProviderKeyType, string> = {
[MetadataProviderKey.GOOGLE]: 'Google Books',
[MetadataProviderKey.GOODREADS]: 'Goodreads',
[MetadataProviderKey.AMAZON]: 'Amazon',
[MetadataProviderKey.HARDCOVER]: 'Hardcover',
[MetadataProviderKey.OPEN_LIBRARY]: 'Open Library',
[MetadataProviderKey.ITUNES]: 'iTunes',
[MetadataProviderKey.AUDIBLE]: 'Audible',
[MetadataProviderKey.AUDNEXUS]: 'AudNexus',
[MetadataProviderKey.LIBROFM]: 'Libro.fm',
[MetadataProviderKey.COMICVINE]: 'ComicVine',
[MetadataProviderKey.RANOBEDB]: 'RanobeDB',
[MetadataProviderKey.KOBO]: 'Kobo',
[MetadataProviderKey.LUBIMYCZYTAC]: 'LubimyCzytac',
[MetadataProviderKey.ALADIN]: 'Aladin',
}
const PROVIDER_LABELS = BUILT_IN_METADATA_PROVIDER_LABELS
/** A display name for a provider key, including the author providers that share the book registry's names. */
export function metadataProviderLabel(provider: string): string {
return PROVIDER_LABELS[provider as MetadataProviderKeyType] ?? provider
if (isMovedMetadataProviderKey(provider)) return MOVED_METADATA_PROVIDERS[provider].label
return PROVIDER_LABELS[provider as BuiltInMetadataProviderKey] ?? provider
}
export function metadataRefreshEmptyMessage(diagnostics: MetadataFetchDiagnostics, book: BookDetail): string {
@@ -86,7 +75,7 @@ function bookSearchLabel(book: BookDetail): string {
}
function formatProviderList(providers: MetadataProviderKeyType[]): string {
return formatTextList(providers.map((provider) => PROVIDER_LABELS[provider] ?? provider))
return formatTextList(providers.map((provider) => metadataProviderLabel(provider)))
}
function formatTextList(items: string[]): string {
@@ -1,3 +1,5 @@
import { providerAppearance } from '@/lib/provider-appearance'
const PROVIDER_ICON_BASE_PATH = '/assets/provider-icons'
const PROVIDER_ICON_PATHS = {
@@ -21,6 +23,9 @@ export function providerIconPath(provider: ProviderIconKey): string {
return PROVIDER_ICON_PATHS[provider]
}
/** A built-in's bundled icon, else the icon BookOrbit serves for an external provider, else null. */
export function providerIconPathSafe(provider: string): string | null {
return Object.prototype.hasOwnProperty.call(PROVIDER_ICON_PATHS, provider) ? PROVIDER_ICON_PATHS[provider as ProviderIconKey] : null
return Object.prototype.hasOwnProperty.call(PROVIDER_ICON_PATHS, provider)
? PROVIDER_ICON_PATHS[provider as ProviderIconKey]
: (providerAppearance(provider)?.iconUrl ?? null)
}
+18 -1
View File
@@ -1,4 +1,4 @@
import { MetadataProviderKey, type ProviderIds } from '@bookorbit/types'
import { isKnownMetadataProviderKey, MetadataProviderKey, type ProviderIds } from '@bookorbit/types'
import { normalizeAmazonDomain } from '@/lib/amazon-domain'
import { providerIconPathSafe } from './provider-icons'
@@ -141,3 +141,20 @@ export function createBookProviderLinks(providerIds: ProviderIds, settings: Prov
]
})
}
/**
* Links for external providers' ids. The server sends the page each id was applied from, or one
* built from the provider's own template, so nothing here knows any external provider.
*/
export function createExternalProviderLinks(
providerIds: ProviderIds,
links: Partial<Record<string, string>> | undefined,
labels: Partial<Record<string, string>> = {},
): BookProviderLink[] {
return Object.entries(providerIds).flatMap(([key, id]) => {
const url = links?.[key]
if (isKnownMetadataProviderKey(key) || !id?.trim() || !url) return []
const label = labels[key] ?? key
return [{ key, label, url, iconUrl: providerIconPathSafe(key), fallback: label.slice(0, 2).toUpperCase() }]
})
}
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { ExternalLink } from '@lucide/vue'
/** A text link that opens outside BookOrbit, marked as such. */
defineProps<{
href: string
label: string
/** When the visible label alone does not say what it leads to, such as a bare "Docs". */
ariaLabel?: string
}>()
</script>
<template>
<a
:href="href"
target="_blank"
rel="noopener noreferrer"
:aria-label="ariaLabel"
class="inline-flex items-center gap-1 rounded-sm text-xs text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<span class="underline underline-offset-2">{{ label }}</span>
<ExternalLink :size="12" class="shrink-0" aria-hidden="true" />
</a>
</template>
@@ -45,4 +45,20 @@ describe('SettingsTabs', () => {
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('select')).toEqual([['display']])
})
it('marks a tab that has something waiting, in words a screen reader can say', () => {
const wrapper = mount(SettingsTabs, {
props: {
tabs: [
{ id: 'quiet', label: 'Quiet' },
{ id: 'busy', label: 'Busy', count: 3, attention: 'Needs attention' },
],
activeTab: 'quiet',
},
})
const [quiet, busy] = wrapper.findAll('[role="tab"]')
expect(quiet!.find('[role="img"]').exists()).toBe(false)
expect(busy!.get('[role="img"]').attributes('aria-label')).toBe('Needs attention')
})
})
@@ -9,6 +9,8 @@ interface SettingsTabItem {
controls?: string
/** A quiet count after the label, such as how many items the tab holds. */
count?: number
/** Marks a tab with something waiting in it; the text is what a screen reader hears. */
attention?: string
}
const props = withDefaults(
@@ -85,7 +87,13 @@ function handleSelect(tab: T): void {
{{ tab.label
}}<span v-if="tab.count !== undefined" class="ms-[7px] text-[11.5px] font-normal tabular-nums text-muted-foreground">{{
formatNumber(tab.count)
}}</span>
}}</span
><span
v-if="tab.attention"
role="img"
:aria-label="tab.attention"
class="ms-1.5 inline-block size-1.5 rounded-full bg-warning align-middle"
/>
</button>
</div>
<div
@@ -1,14 +1,56 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ProviderConfigurations } from '@bookorbit/types'
import SettingsTabs from '@/features/settings/components/SettingsTabs.vue'
import { useRouteTab } from '@/composables/useRouteTab'
import ExternalProvidersTab from './components/ExternalProvidersTab.vue'
import ProviderConfigPanel from './components/ProviderConfigPanel.vue'
import { useProviderConfig } from './composables/useProviderConfig'
import { useProviderThrottleRuntime } from './composables/useProviderThrottleRuntime'
const { t } = useI18n()
const { config, statuses, saving, testingByKey, testResultsByKey, passingTestSignatureByKey, fetchConfig, saveConfig, testProvider } =
useProviderConfig()
const { runtimeByKey, startPolling, stopPolling } = useProviderThrottleRuntime()
/**
* Sources BookOrbit ships with, and the ones an admin runs beside it. They are configured in
* different ways, one with switches and keys, the other by address, so they read best apart.
*/
const TABS = ['built-in', 'external'] as const
type Tab = (typeof TABS)[number]
function normalizeTab(value: unknown): Tab {
return typeof value === 'string' && TABS.includes(value as Tab) ? (value as Tab) : 'built-in'
}
const { activeTab, selectTab } = useRouteTab<Tab>({
routeName: 'settings-metadata-providers',
normalize: normalizeTab,
availableTabs: TABS,
fallback: 'built-in',
})
const tabs = computed(() => {
const external = statuses.value.filter((status) => status.source === 'external' && !status.movedOut)
const needsAttention = statuses.value.some((status) => status.movedOut?.inUse)
return [
{
id: 'built-in' as const,
label: t('settings.metadata.providers.tabs.builtIn'),
count: statuses.value.length ? statuses.value.filter((status) => status.source !== 'external').length : undefined,
},
{
id: 'external' as const,
label: t('settings.metadata.providers.tabs.external'),
count: statuses.value.length ? external.length : undefined,
attention: needsAttention ? t('settings.metadata.providers.tabs.attention') : undefined,
},
]
})
onMounted(() => {
startPolling()
void fetchConfig().catch(() => undefined)
@@ -17,10 +59,18 @@ onMounted(() => {
onUnmounted(() => {
stopPolling()
})
function handleExternalProvidersChanged() {
void fetchConfig().catch(() => undefined)
}
</script>
<template>
<SettingsTabs :tabs="tabs" :active-tab="activeTab" @select="selectTab" />
<!-- Hidden rather than unmounted, so unsaved switches and keys survive a look at the other tab. -->
<ProviderConfigPanel
v-show="activeTab === 'built-in'"
:config="config"
:statuses="statuses"
:runtime-by-key="runtimeByKey"
@@ -31,4 +81,5 @@ onUnmounted(() => {
@save="saveConfig($event as Partial<ProviderConfigurations>)"
@test="testProvider"
/>
<ExternalProvidersTab v-if="activeTab === 'external'" :statuses="statuses" @changed="handleExternalProvidersChanged" />
</template>
@@ -0,0 +1,41 @@
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { describe, expect, it } from 'vitest'
import ExternalProviderSheet from '../components/ExternalProviderSheet.vue'
// The real sheet portals into a reka dialog; the stub renders its slots in place and exposes `dirty`.
const SheetStub = defineComponent({
name: 'SettingsEditorSheet',
props: ['open', 'title', 'description', 'dirty', 'busy', 'removable', 'removeLabel', 'removeConfirm', 'removeConsequence', 'menuActions'],
emits: ['save', 'cancel', 'remove', 'action'],
setup(props, { slots }) {
return () => h('div', { 'data-dirty': String(props.dirty) }, [slots.status?.(), slots.default?.(), slots.footer?.({ requestClose: () => {} })])
},
})
function mountSheet(prefill: { baseUrl: string; allowPrivateAddress: boolean } | null) {
return mount(ExternalProviderSheet, {
props: { open: true, provider: null, busy: false, testing: false, testResult: null, error: null, prefill },
global: { stubs: { SettingsEditorSheet: SheetStub } },
})
}
describe('ExternalProviderSheet', () => {
it('starts from a ready-made provider without counting its address as an edit', async () => {
const wrapper = mountSheet({ baseUrl: 'http://libris:8080', allowPrivateAddress: true })
expect((wrapper.get('input[type="url"]').element as HTMLInputElement).value).toBe('http://libris:8080')
expect(wrapper.get('[role="switch"]').attributes('aria-checked')).toBe('true')
expect(wrapper.get('[data-dirty]').attributes('data-dirty')).toBe('false')
await wrapper.get('input[type="url"]').setValue('http://libris:9000')
expect(wrapper.get('[data-dirty]').attributes('data-dirty')).toBe('true')
})
it('starts empty, with private addresses refused, when added by hand', () => {
const wrapper = mountSheet(null)
expect((wrapper.get('input[type="url"]').element as HTMLInputElement).value).toBe('')
expect(wrapper.get('[role="switch"]').attributes('aria-checked')).toBe('false')
})
})
@@ -0,0 +1,31 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { ProviderStatus } from '@bookorbit/types'
import MovedProvidersNotice from '../components/MovedProvidersNotice.vue'
function moved(key: string, label: string, inUse: boolean, image: string | null = `ghcr.io/bookorbit/${key}-provider`): ProviderStatus {
return { key, label, enabled: false, configured: false, source: 'external', movedOut: { image, inUse } }
}
const GOOGLE: ProviderStatus = { key: 'google', label: 'Google Books', enabled: true, configured: true, source: 'builtin' }
describe('MovedProvidersNotice', () => {
it('says nothing when no moved source is still in use', () => {
const wrapper = mount(MovedProvidersNotice, { props: { statuses: [GOOGLE, moved('lubimyczytac', 'LubimyCzytac', false)] } })
expect(wrapper.find('[role="status"]').exists()).toBe(false)
})
it('names the image to run for a source that moved, and says a closed source cannot come back', () => {
const wrapper = mount(MovedProvidersNotice, {
props: { statuses: [GOOGLE, moved('lubimyczytac', 'LubimyCzytac', true), moved('aladin', 'Aladin', true, null)] },
})
const items = wrapper.findAll('li')
expect(items).toHaveLength(2)
expect(items[0]!.text()).toContain('LubimyCzytac')
expect(items[0]!.find('code').text()).toBe('ghcr.io/bookorbit/lubimyczytac-provider')
expect(items[1]!.text()).toContain('Aladin')
expect(items[1]!.find('code').exists()).toBe(false)
})
})
@@ -26,19 +26,11 @@ function makeConfig(overrides: Partial<ProviderConfigurations> = {}): ProviderCo
comicvine: { enabled: true, apiKey: 'comicvine-secret-key' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
aladin: { enabled: true, ttbKey: 'aladin-ttb-key' },
...overrides,
}
}
const SECRET_FIELD_NAMES = [
'metadata-google-apiKey',
'metadata-amazon-cookie',
'metadata-hardcover-apiKey',
'metadata-comicvine-apiKey',
'metadata-aladin-ttbKey',
]
const SECRET_FIELD_NAMES = ['metadata-google-apiKey', 'metadata-amazon-cookie', 'metadata-hardcover-apiKey', 'metadata-comicvine-apiKey']
function mountPanel(config: ProviderConfigurations = makeConfig(), statuses: ProviderStatus[] = []) {
return mount(ProviderConfigPanel, { props: { config, statuses, saving: false } })
@@ -102,7 +94,6 @@ describe('ProviderConfigPanel secret fields', () => {
expect((secretField(wrapper, 'metadata-google-apiKey').element as HTMLInputElement).value).toBe('google-secret-key')
expect((secretField(wrapper, 'metadata-amazon-cookie').element as HTMLInputElement).value).toBe('amazon-session-cookie')
expect((secretField(wrapper, 'metadata-aladin-ttbKey').element as HTMLInputElement).value).toBe('aladin-ttb-key')
})
it('leaves non-secret fields unmasked', () => {
@@ -25,8 +25,6 @@ function makeConfig(overrides: Partial<ProviderConfigurations> = {}): ProviderCo
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
aladin: { enabled: false, ttbKey: '' },
...overrides,
}
}
@@ -48,8 +46,6 @@ function makeStatuses(): ProviderStatus[] {
configured('comicvine', false, false),
configured('ranobedb', false),
configured('kobo', false),
configured('lubimyczytac', false),
configured('aladin', false, false),
]
}
@@ -95,7 +91,7 @@ describe('ProviderConfigPanel browsing', () => {
const wrapper = mountPanel()
const headings = wrapper.findAll('h3').map((heading) => heading.text())
expect(headings).toEqual(['General book catalogues', 'Audiobooks', 'Comics & light novels', 'Regional catalogues'])
expect(headings).toEqual(['General book catalogues', 'Audiobooks', 'Comics & light novels'])
wrapper.unmount()
})
@@ -113,7 +109,7 @@ describe('ProviderConfigPanel browsing', () => {
await clickFilter(wrapper, 'Needs setup')
expect(providerNames(wrapper)).toEqual(['Hardcover', 'ComicVine', 'Aladin'])
expect(providerNames(wrapper)).toEqual(['Hardcover', 'ComicVine'])
wrapper.unmount()
})
@@ -124,8 +120,8 @@ describe('ProviderConfigPanel browsing', () => {
await search.setValue('kobo')
expect(providerNames(wrapper)).toEqual(['Kobo'])
await search.setValue('korean')
expect(providerNames(wrapper)).toEqual(['Aladin'])
await search.setValue('japanese')
expect(providerNames(wrapper)).toEqual(['RanobeDB'])
await search.setValue('nothing matches this')
expect(wrapper.findAll('[role="switch"]')).toHaveLength(0)
@@ -133,7 +129,7 @@ describe('ProviderConfigPanel browsing', () => {
const clear = wrapper.findAll('button').find((candidate) => candidate.text() === 'Clear filters')
await clear?.trigger('click')
expect(wrapper.findAll('[role="switch"]')).toHaveLength(14)
expect(wrapper.findAll('[role="switch"]')).toHaveLength(12)
wrapper.unmount()
})
})
@@ -0,0 +1,117 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MetadataProviderDirectory, MetadataProviderDirectoryEntry } from '@bookorbit/types'
import ProviderDirectoryPanel from '../components/ProviderDirectoryPanel.vue'
import { composeServiceFor } from '../composables/useProviderDirectory'
const api = vi.hoisted(() => vi.fn<(url: string) => Promise<Response>>())
const copyToClipboard = vi.hoisted(() => vi.fn<(text: string) => Promise<boolean>>())
const toastSuccess = vi.hoisted(() => vi.fn<(message: string) => void>())
const toastError = vi.hoisted(() => vi.fn<(message: string) => void>())
vi.mock('@/lib/api', () => ({ api }))
vi.mock('@/lib/clipboard', () => ({ copyToClipboard }))
vi.mock('vue-sonner', () => ({ toast: { success: toastSuccess, error: toastError } }))
function entry(key: string, name: string, overrides: Partial<MetadataProviderDirectoryEntry> = {}): MetadataProviderDirectoryEntry {
return {
key,
name,
summary: `${name} summary.`,
image: `ghcr.io/bookorbit/${key}-provider`,
version: '0.1.0',
port: 8080,
docsUrl: `https://github.com/bookorbit/bookorbit-metadata-providers/tree/main/providers/${key}`,
homepage: null,
color: '#336699',
mediaTypes: ['book'],
languages: [],
...overrides,
}
}
const DIRECTORY: MetadataProviderDirectory = {
source: 'bundled',
fetchedAt: null,
providers: [
entry('libris', 'Libris', { mediaTypes: ['book', 'audiobook'], languages: ['sv'] }),
entry('lubimyczytac', 'LubimyCzytac', { port: 9090 }),
entry('fantlab', 'FantLab'),
],
}
async function mountPanel(registeredKeys: string[] = ['fantlab']) {
const wrapper = mount(ProviderDirectoryPanel, { props: { registeredKeys } })
await flushPromises()
return wrapper
}
beforeEach(() => {
api.mockReset().mockResolvedValue(Response.json(DIRECTORY))
copyToClipboard.mockReset().mockResolvedValue(true)
toastSuccess.mockReset()
toastError.mockReset()
})
describe('ProviderDirectoryPanel', () => {
it('offers only what is not added yet, and names the rest on one line', async () => {
const wrapper = await mountPanel()
expect(wrapper.findAll('li h4').map((heading) => heading.text())).toEqual(['Libris', 'LubimyCzytac'])
expect(wrapper.text()).toContain('Already added: FantLab')
expect(wrapper.text()).toContain('List included with this version')
})
it('says what each covers, and marks a source that used to be built in', async () => {
const wrapper = await mountPanel()
const [libris, lubimyczytac] = wrapper.findAll('li')
expect(libris!.text()).toContain('Books, Audiobooks, Swedish')
expect(libris!.find('code').text()).toBe('ghcr.io/bookorbit/libris-provider:0.1.0')
expect(lubimyczytac!.text()).toContain('Was built in')
expect(libris!.text()).not.toContain('Was built in')
})
it('copies a Compose service that fits under services:', async () => {
const wrapper = await mountPanel()
await wrapper.get('button[aria-label="Copy the Compose service for Libris"]').trigger('click')
await flushPromises()
expect(copyToClipboard).toHaveBeenCalledWith(' libris:\n image: ghcr.io/bookorbit/libris-provider:0.1.0\n restart: unless-stopped\n')
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Libris copied'))
})
it('asks for the address the Compose service answers on, with private addresses allowed', async () => {
const wrapper = await mountPanel()
await wrapper.get('button[aria-label="Add LubimyCzytac"]').trigger('click')
expect(wrapper.emitted('add')).toEqual([[{ baseUrl: 'http://lubimyczytac:9090', allowPrivateAddress: true }]])
})
it('says when every provider is already added', async () => {
const wrapper = await mountPanel(['libris', 'lubimyczytac', 'fantlab'])
expect(wrapper.findAll('li h4')).toHaveLength(0)
expect(wrapper.text()).toContain('You have added every provider in this list.')
})
it('still points at adding by address when the list cannot be loaded', async () => {
api.mockResolvedValue(new Response('nope', { status: 500 }))
const wrapper = await mountPanel()
expect(wrapper.get('[role="alert"]').text()).toContain('You can still add any provider by its address')
})
it('reports a copy the browser refused', async () => {
copyToClipboard.mockResolvedValue(false)
const wrapper = await mountPanel()
await wrapper.get('button[aria-label="Copy the Compose service for Libris"]').trigger('click')
await flushPromises()
expect(toastError).toHaveBeenCalled()
expect(composeServiceFor(DIRECTORY.providers[0]!)).toContain('libris-provider:0.1.0')
})
})
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Hammer } from '@lucide/vue'
import ExternalTextLink from '@/features/settings/components/ExternalTextLink.vue'
const REPOSITORY = 'https://github.com/bookorbit/bookorbit-metadata-providers'
const { t } = useI18n()
const LINKS = [
{ key: 'guide', href: 'https://github.com/bookorbit/bookorbit/blob/main/docs/BUILDING_METADATA_PROVIDERS.md' },
{ key: 'starters', href: `${REPOSITORY}/tree/main/starters` },
{ key: 'checker', href: `${REPOSITORY}/tree/main/conformance` },
{ key: 'suggest', href: `${REPOSITORY}/issues/new` },
] as const
</script>
<template>
<section class="rounded-lg border border-dashed border-border px-4 py-3.5 md:px-5" aria-labelledby="build-your-own-title">
<div class="flex items-start gap-3">
<span class="grid size-8.5 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary" aria-hidden="true">
<Hammer :size="16" />
</span>
<div class="min-w-0 space-y-1.5">
<h3 id="build-your-own-title" class="text-sm font-semibold text-foreground">{{ t('settings.metadata.buildYourOwn.title') }}</h3>
<p class="text-xs leading-relaxed text-muted-foreground">{{ t('settings.metadata.buildYourOwn.body') }}</p>
<ul class="flex flex-wrap gap-x-4 gap-y-1.5 pt-0.5">
<li v-for="link in LINKS" :key="link.key">
<ExternalTextLink :href="link.href" :label="t(`settings.metadata.buildYourOwn.links.${link.key}`)" />
</li>
</ul>
</div>
</div>
</section>
</template>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Loader2, ShieldAlert } from '@lucide/vue'
import { METADATA_HTTP_PROVIDER_TIMEOUT_MS } from '@bookorbit/types'
import type { CreateMetadataHttpProviderPayload, MetadataHttpProviderTestResult, MetadataHttpProviderView } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsEditorSheet from '@/features/settings/components/SettingsEditorSheet.vue'
import type { EditorMenuAction } from '@/features/settings/lib/editor-actions'
import { formatNumber } from '@/i18n/formatters'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
const props = defineProps<{
open: boolean
/** Null while adding a provider. */
provider: MetadataHttpProviderView | null
busy: boolean
testing: boolean
testResult: MetadataHttpProviderTestResult | null
/** Already translated by the panel. */
error: string | null
/** Starting values while adding, such as a ready-made provider's address. */
prefill?: { baseUrl: string; allowPrivateAddress: boolean } | null
}>()
const emit = defineEmits<{
save: [payload: CreateMetadataHttpProviderPayload & { authorization?: string | null }]
cancel: []
remove: []
purge: []
test: []
}>()
const { t } = useI18n()
const PURGE_ACTION = 'purge'
const baseUrl = ref('')
const authorization = ref('')
const clearAuthorization = ref(false)
const allowPrivateAddress = ref(false)
const timeoutSeconds = ref(METADATA_HTTP_PROVIDER_TIMEOUT_MS.default / 1000)
const isEdit = computed(() => props.provider !== null)
const name = computed(() => props.provider?.info?.name ?? props.provider?.key ?? '')
const title = computed(() => (isEdit.value ? name.value : t('settings.metadata.externalProviders.sheet.createTitle')))
const minSeconds = METADATA_HTTP_PROVIDER_TIMEOUT_MS.min / 1000
const maxSeconds = METADATA_HTTP_PROVIDER_TIMEOUT_MS.max / 1000
function reset() {
const prefill = props.provider ? null : props.prefill
baseUrl.value = props.provider?.baseUrl ?? prefill?.baseUrl ?? ''
authorization.value = ''
clearAuthorization.value = false
allowPrivateAddress.value = props.provider?.allowPrivateAddress ?? prefill?.allowPrivateAddress ?? false
timeoutSeconds.value = (props.provider?.timeoutMs ?? METADATA_HTTP_PROVIDER_TIMEOUT_MS.default) / 1000
}
watch(
() => [props.open, props.provider?.id] as const,
([open]) => {
if (open) reset()
},
{ immediate: true },
)
const dirty = computed(() => {
// A ready-made provider's address arrives filled in; only what the admin typed is theirs to lose.
if (!isEdit.value) return baseUrl.value.trim() !== (props.prefill?.baseUrl ?? '') || authorization.value.trim().length > 0
const provider = props.provider!
return (
baseUrl.value.trim() !== provider.baseUrl ||
authorization.value.trim().length > 0 ||
clearAuthorization.value ||
allowPrivateAddress.value !== provider.allowPrivateAddress ||
timeoutSeconds.value * 1000 !== provider.timeoutMs
)
})
const timeoutValid = computed(
() => Number.isInteger(timeoutSeconds.value) && timeoutSeconds.value >= minSeconds && timeoutSeconds.value <= maxSeconds,
)
const canSave = computed(() => baseUrl.value.trim().length > 0 && timeoutValid.value && !props.busy)
const menuActions = computed<EditorMenuAction[]>(() =>
isEdit.value
? [
{
id: PURGE_ACTION,
label: t('settings.metadata.externalProviders.sheet.purge'),
danger: true,
separated: true,
confirm: t('settings.metadata.externalProviders.sheet.purgeConfirm', { name: name.value }),
consequence: t('settings.metadata.externalProviders.sheet.purgeConsequence'),
},
]
: [],
)
const testLine = computed(() => {
const result = props.testResult
if (!result) return null
if (!result.ok) return { ok: false, text: t('settings.metadata.externalProviders.test.failed', { error: result.error ?? '' }) }
const answered = t('settings.metadata.externalProviders.test.ok', {
name: result.name ?? name.value,
latency: formatNumber(result.latencyMs),
count: result.resultCount ?? 0,
})
return {
ok: true,
text: result.sampleTitle ? `${answered} ${t('settings.metadata.externalProviders.test.sample', { title: result.sampleTitle })}` : answered,
}
})
function handleSave() {
if (!canSave.value) return
const payload: CreateMetadataHttpProviderPayload & { authorization?: string | null } = {
baseUrl: baseUrl.value.trim(),
allowPrivateAddress: allowPrivateAddress.value,
timeoutMs: timeoutSeconds.value * 1000,
}
if (authorization.value.trim()) payload.authorization = authorization.value.trim()
else if (clearAuthorization.value) payload.authorization = null
emit('save', payload)
}
function handleCancel() {
emit('cancel')
}
function handleRemove() {
emit('remove')
}
function handleAction(id: string) {
if (id === PURGE_ACTION) emit('purge')
}
function handleTest() {
emit('test')
}
function handleClearAuthorization() {
clearAuthorization.value = true
authorization.value = ''
}
</script>
<template>
<SettingsEditorSheet
:open="open"
:title="title"
:description="t('settings.metadata.externalProviders.sheet.description')"
:dirty="dirty"
:busy="busy"
:removable="isEdit"
:remove-label="t('settings.metadata.externalProviders.sheet.remove')"
:remove-confirm="t('settings.metadata.externalProviders.sheet.removeConfirm', { name })"
:remove-consequence="t('settings.metadata.externalProviders.sheet.removeConsequence')"
:menu-actions="menuActions"
@save="handleSave"
@cancel="handleCancel"
@remove="handleRemove"
@action="handleAction"
>
<template #status>
<p v-if="provider?.info" class="text-xs text-muted-foreground">
{{ t('settings.metadata.externalProviders.sheet.identity', { key: provider.key, version: provider.info.version ?? '-' }) }}
</p>
</template>
<div class="space-y-5">
<p v-if="error" role="alert" class="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{{ error }}
</p>
<label class="block space-y-1.5">
<span class="settings-label">{{ t('settings.metadata.externalProviders.fields.baseUrl') }}</span>
<input
v-model="baseUrl"
type="url"
inputmode="url"
autocomplete="off"
spellcheck="false"
maxlength="2048"
class="input-field w-full font-mono"
placeholder="http://provider:8080"
aria-describedby="external-provider-url-hint"
/>
<span id="external-provider-url-hint" class="settings-hint block">{{ t('settings.metadata.externalProviders.fields.baseUrlHint') }}</span>
</label>
<div class="space-y-1.5">
<label for="external-provider-authorization" class="settings-label">{{
t('settings.metadata.externalProviders.fields.authorization')
}}</label>
<input
id="external-provider-authorization"
v-model="authorization"
type="text"
v-bind="SECRET_INPUT_ATTRS"
maxlength="4096"
class="input-field input-secret w-full font-mono"
:placeholder="
provider?.hasAuthorization && !clearAuthorization ? t('settings.metadata.externalProviders.fields.authorizationSaved') : 'Bearer ...'
"
aria-describedby="external-provider-authorization-hint"
/>
<span id="external-provider-authorization-hint" class="settings-hint block">{{
t('settings.metadata.externalProviders.fields.authorizationHint')
}}</span>
<button
v-if="provider?.hasAuthorization && !clearAuthorization"
type="button"
class="text-xs font-medium text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="handleClearAuthorization"
>
{{ t('settings.metadata.externalProviders.fields.clearAuthorization') }}
</button>
</div>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<span id="external-provider-private-label" class="settings-label">{{ t('settings.metadata.externalProviders.fields.allowPrivate') }}</span>
<p class="settings-hint">{{ t('settings.metadata.externalProviders.fields.allowPrivateHint') }}</p>
</div>
<ToggleSwitch v-model="allowPrivateAddress" aria-labelledby="external-provider-private-label" />
</div>
<label class="block space-y-1.5">
<span class="settings-label block">{{ t('settings.metadata.externalProviders.fields.timeout') }}</span>
<input
v-model.number="timeoutSeconds"
type="number"
:min="minSeconds"
:max="maxSeconds"
step="1"
class="input-field w-28 tabular-nums"
:aria-invalid="!timeoutValid"
/>
<span class="settings-hint block">{{
t('settings.metadata.externalProviders.fields.timeoutHint', { min: minSeconds, max: maxSeconds })
}}</span>
</label>
<p class="flex items-start gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<ShieldAlert :size="14" class="mt-px shrink-0" aria-hidden="true" />
<span>{{ t('settings.metadata.externalProviders.fields.privacy') }}</span>
</p>
<p
v-if="testLine"
role="status"
class="rounded-md border px-3 py-2 text-xs"
:class="testLine.ok ? 'border-success/30 bg-success/10 text-success' : 'border-destructive/40 bg-destructive/10 text-destructive'"
>
{{ testLine.text }}
</p>
</div>
<template #actions>
<Button v-if="isEdit" size="sm" variant="outline" type="button" :disabled="testing || busy" @click="handleTest">
<Loader2 v-if="testing" class="animate-spin" aria-hidden="true" />
{{ testing ? t('settings.metadata.externalProviders.sheet.testing') : t('settings.metadata.externalProviders.sheet.test') }}
</Button>
</template>
</SettingsEditorSheet>
</template>
@@ -0,0 +1,304 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Plug, Plus } from '@lucide/vue'
import type { MetadataHttpProviderStatus, MetadataHttpProviderTestResult, MetadataHttpProviderView } from '@bookorbit/types'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { refreshMetadataProviderLabels } from '@/features/book/composables/useMetadataProviderLabels'
import ExternalProviderSheet from './ExternalProviderSheet.vue'
import ProviderBrandTile from './ProviderBrandTile.vue'
import { useExternalProviders, type ExternalProviderError } from '../composables/useExternalProviders'
/** Field rules and the built-in panel read provider statuses, which change with every edit here. */
const emit = defineEmits<{ changed: [] }>()
const { t } = useI18n()
const { providers, loaded, fetchProviders, createProvider, updateProvider, removeProvider, testProvider, addToFieldRules } = useExternalProviders()
const sheetOpen = ref(false)
const editingId = ref<number | null>(null)
const busy = ref(false)
const testing = ref(false)
const testResult = ref<MetadataHttpProviderTestResult | null>(null)
const sheetError = ref<string | null>(null)
const sheetPrefill = ref<{ baseUrl: string; allowPrivateAddress: boolean } | null>(null)
const togglingIds = ref(new Set<number>())
const editing = computed(() => providers.value.find((provider) => provider.id === editingId.value) ?? null)
const enabledCount = computed(() => providers.value.filter((provider) => provider.enabled).length)
const ERROR_KEYS: Record<string, string> = {
METADATA_PROVIDER_PRIVATE_ADDRESS: 'settings.metadata.externalProviders.errors.privateAddress',
METADATA_PROVIDER_UNREACHABLE: 'settings.metadata.externalProviders.errors.unreachable',
METADATA_PROVIDER_UNAUTHORIZED: 'settings.metadata.externalProviders.errors.unauthorized',
METADATA_PROVIDER_INVALID_INFO: 'settings.metadata.externalProviders.errors.invalidInfo',
METADATA_PROVIDER_INVALID_URL: 'settings.metadata.externalProviders.errors.invalidUrl',
METADATA_PROVIDER_KEY_TAKEN: 'settings.metadata.externalProviders.errors.keyTaken',
METADATA_PROVIDER_KEY_CHANGED: 'settings.metadata.externalProviders.errors.keyChanged',
METADATA_PROVIDER_NOT_READY: 'settings.metadata.externalProviders.errors.notReady',
METADATA_PROVIDER_AUTHORIZATION_UNREADABLE: 'settings.metadata.externalProviders.errors.authorizationUnreadable',
REQUEST_ENCRYPTION_KEY_MISSING: 'settings.system.requests.errors.encryptionKeyMissing',
}
const STATUS_CLASSES: Record<'active' | 'ready' | 'warning', string> = {
active: 'border-success/30 bg-success/10 text-success',
ready: 'border-border text-muted-foreground',
warning: 'border-amber-500/35 bg-amber-500/10 text-amber-600 dark:text-amber-400',
}
/** Translated copy for a code the panel knows, with the server's own words as the detail. */
function describeError(error: ExternalProviderError): string {
const key = error.code ? ERROR_KEYS[error.code] : undefined
const headline = key ? t(key) : t('settings.metadata.externalProviders.errors.generic')
return error.message && !key ? `${headline} ${error.message}` : error.message && key ? `${headline} (${error.message})` : headline
}
function nameOf(provider: MetadataHttpProviderView): string {
return provider.info?.name ?? provider.key
}
function hostOf(provider: MetadataHttpProviderView): string {
try {
return new URL(provider.baseUrl).host
} catch {
return provider.baseUrl
}
}
function chipFor(provider: MetadataHttpProviderView): { label: string; tone: keyof typeof STATUS_CLASSES; title?: string } {
if (provider.authorizationUnreadable) {
return {
label: t('settings.metadata.externalProviders.status.authorizationUnreadable'),
tone: 'warning',
title: t('settings.metadata.externalProviders.authorizationUnreadableHint'),
}
}
const healthy: MetadataHttpProviderStatus[] = ['ok']
if (!healthy.includes(provider.status)) {
return {
label: t(`settings.metadata.externalProviders.status.${provider.status}`),
tone: 'warning',
title: provider.lastError ?? undefined,
}
}
return provider.enabled
? { label: t('settings.metadata.externalProviders.status.active'), tone: 'active' }
: { label: t('settings.metadata.externalProviders.status.ready'), tone: 'ready' }
}
function openCreate(prefill: { baseUrl: string; allowPrivateAddress: boolean } | null = null) {
editingId.value = null
sheetPrefill.value = prefill
testResult.value = null
sheetError.value = null
sheetOpen.value = true
}
function handleAddClick() {
openCreate()
}
/** The tab offers ready-made providers below this list: it adds them through the same sheet. */
const registeredKeys = computed(() => providers.value.map((provider) => provider.key))
defineExpose({ openCreate, registeredKeys })
function openEdit(provider: MetadataHttpProviderView) {
editingId.value = provider.id
testResult.value = null
sheetError.value = null
sheetOpen.value = true
}
function closeSheet() {
sheetOpen.value = false
}
async function handleSave(payload: Parameters<typeof createProvider>[0]) {
busy.value = true
sheetError.value = null
try {
const result = editing.value ? await updateProvider(editing.value.id, payload) : await createProvider(payload)
if (!result.ok) {
sheetError.value = describeError(result.error)
return
}
if (!editing.value) {
toast.success(t('settings.metadata.externalProviders.added', { name: nameOf(result.value) }))
editingId.value = result.value.id
} else {
toast.success(t('settings.metadata.externalProviders.saved', { name: nameOf(result.value) }))
sheetOpen.value = false
}
notifyChanged()
} finally {
busy.value = false
}
}
async function handleRemove(purge: boolean) {
const provider = editing.value
if (!provider) return
busy.value = true
try {
const result = await removeProvider(provider.id, purge)
if (!result.ok) {
sheetError.value = describeError(result.error)
return
}
toast.success(t(purge ? 'settings.metadata.externalProviders.purged' : 'settings.metadata.externalProviders.removed', { name: nameOf(provider) }))
sheetOpen.value = false
notifyChanged()
} finally {
busy.value = false
}
}
function handleRemoveOnly() {
void handleRemove(false)
}
function handlePurge() {
void handleRemove(true)
}
async function handleTest() {
const provider = editing.value
if (!provider) return
testing.value = true
try {
const result = await testProvider(provider.id)
testResult.value = result.ok
? result.value
: { ok: false, name: null, version: null, latencyMs: 0, resultCount: null, sampleTitle: null, error: describeError(result.error) }
notifyChanged()
} finally {
testing.value = false
}
}
async function handleToggle(provider: MetadataHttpProviderView, enabled: boolean) {
togglingIds.value = new Set([...togglingIds.value, provider.id])
try {
const result = await updateProvider(provider.id, { enabled })
if (!result.ok) {
toast.error(describeError(result.error))
return
}
notifyChanged()
} finally {
const next = new Set(togglingIds.value)
next.delete(provider.id)
togglingIds.value = next
}
}
async function handleAddToFieldRules(provider: MetadataHttpProviderView) {
const result = await addToFieldRules(provider)
if (!result.ok) {
toast.error(describeError(result.error))
return
}
toast.success(t('settings.metadata.externalProviders.fieldRulesAdded', { name: nameOf(provider), count: result.value }))
notifyChanged()
}
/** Field rules, the built-in panel and every provider name shown elsewhere read what changed here. */
function notifyChanged() {
emit('changed')
void refreshMetadataProviderLabels()
}
onMounted(() => {
void fetchProviders()
})
</script>
<template>
<section class="overflow-hidden rounded-lg border border-border bg-card shadow-xs" aria-labelledby="external-providers-title">
<div class="flex flex-wrap items-center gap-2.5 border-b border-border bg-muted/40 px-4 py-2 md:px-5">
<Plug :size="13" class="shrink-0 text-muted-foreground" aria-hidden="true" />
<h3 id="external-providers-title" class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('settings.metadata.externalProviders.yourProviders') }}
</h3>
<span v-if="providers.length" class="text-[11px] font-semibold tabular-nums text-muted-foreground">
{{ t('settings.metadata.providers.groupEnabled', { enabled: enabledCount, total: providers.length }) }}
</span>
<Button type="button" size="sm" variant="outline" class="ms-auto h-7 gap-1.5 px-2 text-[11px]" @click="handleAddClick">
<Plus :size="12" aria-hidden="true" />
{{ t('settings.metadata.externalProviders.add') }}
</Button>
</div>
<p v-if="loaded && providers.length === 0" class="px-4 py-6 text-center text-xs text-muted-foreground md:px-5">
{{ t('settings.metadata.externalProviders.emptyWithDirectory') }}
</p>
<ul v-else>
<li
v-for="(provider, index) in providers"
:key="provider.id"
class="flex flex-wrap items-center gap-x-3 gap-y-2.5 px-4 py-3 md:flex-nowrap md:px-5"
:class="{ 'border-t border-border': index > 0 }"
>
<ProviderBrandTile :provider-key="provider.key" :label="nameOf(provider)" :enabled="provider.enabled" />
<button
type="button"
class="min-w-0 flex-1 rounded-sm text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('settings.metadata.externalProviders.edit', { name: nameOf(provider) })"
@click="openEdit(provider)"
>
<span class="settings-label block truncate">{{ nameOf(provider) }}</span>
<span class="settings-hint block truncate">
{{ t('settings.metadata.externalProviders.row', { host: hostOf(provider), version: provider.info?.version ?? '-' }) }}
</span>
</button>
<!-- Below md the controls take their own line, so a long provider name keeps its width. -->
<div class="flex w-full items-center gap-2 ps-[2.875rem] md:w-auto md:shrink-0 md:ps-0">
<Badge
variant="outline"
class="h-5 px-1.5 text-[10px] font-semibold uppercase tracking-wide"
:class="STATUS_CLASSES[chipFor(provider).tone]"
:title="chipFor(provider).title"
>
{{ chipFor(provider).label }}
</Badge>
<Button
v-if="provider.enabled"
type="button"
size="sm"
variant="outline"
class="h-7 px-2 text-[11px]"
@click="handleAddToFieldRules(provider)"
>
{{ t('settings.metadata.externalProviders.useInFieldRules') }}
</Button>
<ToggleSwitch
class="ms-auto md:ms-0"
:model-value="provider.enabled"
:disabled="togglingIds.has(provider.id)"
:aria-label="t('settings.metadata.externalProviders.toggle', { name: nameOf(provider) })"
@update:model-value="handleToggle(provider, $event)"
/>
</div>
</li>
</ul>
<ExternalProviderSheet
:open="sheetOpen"
:provider="editing"
:busy="busy"
:testing="testing"
:test-result="testResult"
:error="sheetError"
:prefill="sheetPrefill"
@save="handleSave"
@cancel="closeSheet"
@remove="handleRemoveOnly"
@purge="handlePurge"
@test="handleTest"
/>
</section>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ProviderStatus } from '@bookorbit/types'
import BuildYourOwnProviderCard from './BuildYourOwnProviderCard.vue'
import ExternalProvidersPanel from './ExternalProvidersPanel.vue'
import MovedProvidersNotice from './MovedProvidersNotice.vue'
import ProviderDirectoryPanel from './ProviderDirectoryPanel.vue'
defineProps<{ statuses: ProviderStatus[] }>()
const emit = defineEmits<{ changed: [] }>()
const { t } = useI18n()
const panel = ref<InstanceType<typeof ExternalProvidersPanel> | null>(null)
const registeredKeys = computed(() => panel.value?.registeredKeys ?? [])
function handleChanged() {
emit('changed')
}
function handleAdd(prefill: { baseUrl: string; allowPrivateAddress: boolean }) {
panel.value?.openCreate(prefill)
}
</script>
<template>
<div class="space-y-6">
<p class="max-w-3xl text-sm leading-relaxed text-muted-foreground">{{ t('settings.metadata.externalProviders.description') }}</p>
<MovedProvidersNotice :statuses="statuses" />
<ExternalProvidersPanel ref="panel" @changed="handleChanged" />
<ProviderDirectoryPanel :registered-keys="registeredKeys" @add="handleAdd" />
<BuildYourOwnProviderCard />
</div>
</template>
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { ArrowRight, ChevronDown, ChevronUp, RotateCcw, TriangleAlert, X } from '@lucide/vue'
import type { FieldPreference, MetadataField, MetadataMergeStrategy, MetadataProviderKey, ProviderStatus } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { providerChipStyle, providerShortLabel } from '@/lib/provider-colors'
import ProviderAddMenu from './ProviderAddMenu.vue'
import { providerHasOtherShape, resolvedProviders, skippedProviders, skipReasonFor } from '../lib/field-rules'
@@ -37,9 +37,9 @@ const rows = computed(() =>
key,
index,
label: status?.label ?? key,
short: PROVIDER_SHORT_LABELS[key] ?? key,
short: providerShortLabel(key, status?.label),
skipReason: skipReasonFor(status, props.field, key),
otherShape: providerHasOtherShape(key, props.field),
otherShape: providerHasOtherShape(key, props.field, status?.supportedFields),
}
}),
)
@@ -221,7 +221,7 @@ function handleClose() {
<template v-for="(key, index) in resolved" :key="key">
<ArrowRight v-if="index > 0" :size="13" class="shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="provider-chip inline-flex h-6 items-center rounded-xs px-2 text-xs font-medium" :style="providerChipStyle(key)">
{{ PROVIDER_SHORT_LABELS[key] ?? key }}
{{ providerShortLabel(key, providerLabel(key)) }}
</span>
</template>
</div>
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { ChevronDown, ChevronRight, TriangleAlert } from '@lucide/vue'
import type { FieldPreference, MetadataField, MetadataMergeStrategy, MetadataProviderKey, ProviderStatus } from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { providerChipStyle, providerShortLabel as shortLabelFor } from '@/lib/provider-colors'
import { Badge } from '@/components/ui/badge'
import ProviderChipList from './ProviderChipList.vue'
import ProviderAddMenu from './ProviderAddMenu.vue'
@@ -46,7 +46,7 @@ const detailId = computed(() => `field-rule-detail-${props.field}`)
const mergeSummary = computed(() => t(`settings.metadata.mergeStrategy.${props.preference.mergeStrategy}.label`))
function providerShortLabel(key: MetadataProviderKey): string {
return PROVIDER_SHORT_LABELS[key] ?? key
return shortLabelFor(key, props.statuses.find((entry) => entry.key === key)?.label)
}
function update(patch: Partial<FieldPreference>) {
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { TriangleAlert } from '@lucide/vue'
import type { ProviderStatus } from '@bookorbit/types'
const props = defineProps<{ statuses: ProviderStatus[] }>()
const { t } = useI18n()
type MovedStatus = ProviderStatus & { movedOut: NonNullable<ProviderStatus['movedOut']> }
/** Only the ones something still relies on: an unused source that moved out needs no word. */
const pending = computed(() => props.statuses.filter((status): status is MovedStatus => status.movedOut?.inUse === true))
</script>
<template>
<section
v-if="pending.length"
role="status"
class="rounded-lg border border-warning/30 bg-warning/8 px-4 py-3 md:px-5"
aria-labelledby="moved-providers-title"
>
<div class="flex items-start gap-2.5">
<TriangleAlert :size="15" class="mt-0.5 shrink-0 text-warning" aria-hidden="true" />
<div class="min-w-0 space-y-2 text-xs text-foreground">
<h3 id="moved-providers-title" class="text-sm font-semibold">{{ t('settings.metadata.movedProviders.title') }}</h3>
<p class="leading-relaxed text-muted-foreground">{{ t('settings.metadata.movedProviders.description') }}</p>
<ul class="space-y-1.5">
<li v-for="provider in pending" :key="provider.key" class="space-y-0.5 leading-relaxed">
<i18n-t v-if="provider.movedOut.image" keypath="settings.metadata.movedProviders.run" tag="p" scope="global">
<template #name>
<span class="font-semibold">{{ provider.label }}</span>
</template>
<template #image>
<code class="break-all rounded bg-muted px-1 py-0.5 font-mono text-[11px]">{{ provider.movedOut.image }}</code>
</template>
</i18n-t>
<i18n-t v-else keypath="settings.metadata.movedProviders.closed" tag="p" scope="global">
<template #name>
<span class="font-semibold">{{ provider.label }}</span>
</template>
</i18n-t>
</li>
</ul>
</div>
</div>
</section>
</template>
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { Plus } from '@lucide/vue'
import type { MetadataField, MetadataProviderKey, ProviderStatus } from '@bookorbit/types'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { providerChipStyle, providerShortLabel } from '@/lib/provider-colors'
import { providerHasOtherShape, skipReasonFor } from '../lib/field-rules'
const { t } = useI18n()
@@ -24,18 +24,19 @@ const emit = defineEmits<{ add: [provider: MetadataProviderKey] }>()
const open = ref(false)
// Usable providers first: an unconfigured one, or one that never fills this field, can still be
// added, but it is not the choice anyone is looking for.
// added, but it is not the choice anyone is looking for. A source that moved out and is not
// registered again is listed only so rules that name it still read; it is not offered.
const candidates = computed(() => {
const available = props.statuses.filter((status) => !props.assigned.includes(status.key as MetadataProviderKey))
const available = props.statuses.filter((status) => !status.movedOut && !props.assigned.includes(status.key as MetadataProviderKey))
return available
.map((status) => {
const skipReason = skipReasonFor(status, props.field)
return {
key: status.key as MetadataProviderKey,
label: status.label,
short: PROVIDER_SHORT_LABELS[status.key] ?? status.key,
short: providerShortLabel(status.key, status.label),
skipReason,
otherShape: !skipReason && providerHasOtherShape(status.key as MetadataProviderKey, props.field),
otherShape: !skipReason && providerHasOtherShape(status.key as MetadataProviderKey, props.field, status.supportedFields),
}
})
.sort((a, b) => Number(Boolean(a.skipReason || a.otherShape)) - Number(Boolean(b.skipReason || b.otherShape)))
@@ -7,6 +7,8 @@ const props = defineProps<{
providerKey: string
label: string
enabled: boolean
/** For a provider not registered yet, whose colour BookOrbit does not know. */
color?: string | null
}>()
const iconPath = computed(() => providerIconPathSafe(props.providerKey))
@@ -26,7 +28,7 @@ const monogram = computed(() => {
<span
class="grid size-8.5 shrink-0 place-items-center rounded-lg"
:class="enabled ? 'provider-chip' : 'bg-foreground/6 ring-1 ring-border'"
:style="enabled ? { '--provider-color': getProviderColor(providerKey) } : undefined"
:style="enabled ? { '--provider-color': color ?? getProviderColor(providerKey) } : undefined"
aria-hidden="true"
>
<img
@@ -15,7 +15,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { providerChipStyle, providerShortLabel } from '@/lib/provider-colors'
import { BULK_REORDER_EXEMPT_FIELDS, providerUsageCount, skipReasonFor, type ProviderBulkAction } from '../lib/field-rules'
const { t } = useI18n()
@@ -42,7 +42,7 @@ const entries = computed(() =>
return {
key,
label: status.label,
short: PROVIDER_SHORT_LABELS[key] ?? key,
short: providerShortLabel(key, status.label),
usage: providerUsageCount(props.fields, key),
skipReason: skipReasonFor(status),
keepsExemptOrder: [...BULK_REORDER_EXEMPT_FIELDS].some((field) => props.fields[field]?.providers.includes(key)),
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { VueDraggable } from 'vue-draggable-plus'
import { GripVertical, Slash, X } from '@lucide/vue'
import type { MetadataField, MetadataProviderKey, ProviderStatus } from '@bookorbit/types'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { providerChipStyle, providerShortLabel } from '@/lib/provider-colors'
import { PROVIDER_DND_GROUP, toProviderDragItems } from '../lib/provider-drag'
import { isProviderUsable, providerHasOtherShape, providerRank, skipReasonFor } from '../lib/field-rules'
@@ -61,7 +61,7 @@ const chips = computed(() =>
props.field,
),
label: status?.label ?? item.key,
short: PROVIDER_SHORT_LABELS[item.key] ?? item.key,
short: providerShortLabel(item.key, status?.label),
usable: isProviderUsable(item.key, props.statuses, props.field),
}
}),
@@ -17,8 +17,6 @@ function legacyConfigWithoutLibroFm(): ProviderConfigurations {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
aladin: { enabled: false, ttbKey: '' },
} as ProviderConfigurations
}
@@ -33,7 +31,7 @@ describe('ProviderConfigPanel', () => {
})
expect(wrapper.text()).not.toContain('Libro.fm')
expect(wrapper.findAll('[role="switch"]')).toHaveLength(13)
expect(wrapper.findAll('[role="switch"]')).toHaveLength(11)
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('save')?.[0]?.[0]).not.toHaveProperty('librofm')
wrapper.unmount()
@@ -49,7 +47,7 @@ describe('ProviderConfigPanel', () => {
})
expect(wrapper.text()).toContain('Libro.fm')
expect(wrapper.findAll('[role="switch"]')).toHaveLength(14)
expect(wrapper.findAll('[role="switch"]')).toHaveLength(12)
wrapper.unmount()
})
})
@@ -1,9 +1,15 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { BookImage, BookOpen, Globe, Headphones, Loader2, RotateCcw, Save, Search } from '@lucide/vue'
import { BookImage, BookOpen, Headphones, Loader2, RotateCcw, Save, Search } from '@lucide/vue'
import { MetadataProviderKey } from '@bookorbit/types'
import type { ProviderConfigurations, ProviderConnectionTestResult, ProviderStatus, ProviderThrottleRuntimeState } from '@bookorbit/types'
import type {
BuiltInMetadataProviderKey,
ProviderConfigurations,
ProviderConnectionTestResult,
ProviderStatus,
ProviderThrottleRuntimeState,
} from '@bookorbit/types'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { formatList } from '@/i18n/formatters'
@@ -36,10 +42,9 @@ const GROUP_ICONS: Record<ProviderGroupId, typeof BookOpen> = {
books: BookOpen,
audiobooks: Headphones,
comics: BookImage,
regional: Globe,
}
const TESTABLE_PROVIDERS: MetadataProviderKey[] = [MetadataProviderKey.AMAZON, MetadataProviderKey.HARDCOVER, MetadataProviderKey.ALADIN]
const TESTABLE_PROVIDERS: BuiltInMetadataProviderKey[] = [MetadataProviderKey.AMAZON, MetadataProviderKey.HARDCOVER]
const rows = useProviderRows()
const draft = ref<ProviderConfigurations | null>(null)
@@ -106,11 +111,11 @@ function testResultFor(key: string): ProviderConnectionTestResult | null {
return props.testResultsByKey?.[key as MetadataProviderKey] ?? null
}
function isTestable(key: keyof ProviderConfigurations): key is MetadataProviderKey {
return TESTABLE_PROVIDERS.includes(key as MetadataProviderKey)
function isTestable(key: keyof ProviderConfigurations): key is BuiltInMetadataProviderKey {
return TESTABLE_PROVIDERS.includes(key)
}
function currentProviderSignature(key: MetadataProviderKey): string {
function currentProviderSignature(key: BuiltInMetadataProviderKey): string {
if (!draft.value) return ''
if (key !== MetadataProviderKey.HARDCOVER) return JSON.stringify(draft.value[key] ?? null)
return JSON.stringify({
@@ -119,7 +124,7 @@ function currentProviderSignature(key: MetadataProviderKey): string {
})
}
function hasPassingTestForCurrentInput(key: MetadataProviderKey): boolean {
function hasPassingTestForCurrentInput(key: BuiltInMetadataProviderKey): boolean {
const expectedSignature = props.passingTestSignatureByKey?.[key]
const result = testResultFor(key)
if (!expectedSignature || !result || !result.ok || result.status !== 'success') return false
@@ -131,7 +136,7 @@ function enableBlockedMessage(row: ProviderRowDef): string | null {
if (!draft.value || !row.enableRequirement) return null
const requirement = row.enableRequirement
if (!requirement.isConfigured(draft.value)) return requirement.blockedMessage
if (requirement.requiresPassingTest && !hasPassingTestForCurrentInput(row.key as MetadataProviderKey)) {
if (requirement.requiresPassingTest && !hasPassingTestForCurrentInput(row.key)) {
return requirement.missingTestMessage ?? t('settings.metadata.providers.runTestBeforeEnabling')
}
return null
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Check, Copy, PackageOpen, Plus } from '@lucide/vue'
import { isMovedMetadataProviderKey, type MetadataProviderDirectoryEntry } from '@bookorbit/types'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { copyToClipboard } from '@/lib/clipboard'
import { formatLanguageName, formatList, formatRelativeTimeFromNow } from '@/i18n/formatters'
import ExternalTextLink from '@/features/settings/components/ExternalTextLink.vue'
import ProviderBrandTile from './ProviderBrandTile.vue'
import { composeAddressFor, composeServiceFor, useProviderDirectory } from '../composables/useProviderDirectory'
const props = defineProps<{
/** Keys already registered, which show as added rather than offered again. */
registeredKeys: string[]
}>()
const emit = defineEmits<{ add: [prefill: { baseUrl: string; allowPrivateAddress: boolean }] }>()
const { t } = useI18n()
const { directory, failed, fetchDirectory } = useProviderDirectory()
const registered = computed(() => new Set(props.registeredKeys))
const offered = computed(() => (directory.value?.providers ?? []).filter((entry) => !registered.value.has(entry.key)))
const addedNames = computed(() =>
formatList((directory.value?.providers ?? []).filter((entry) => registered.value.has(entry.key)).map((entry) => entry.name)),
)
const sourceLine = computed(() => {
const current = directory.value
if (!current) return null
if (current.source === 'remote' && current.fetchedAt) {
return t('settings.metadata.providerDirectory.updated', { time: formatRelativeTimeFromNow(current.fetchedAt) })
}
return t('settings.metadata.providerDirectory.bundled')
})
function wasBuiltIn(entry: MetadataProviderDirectoryEntry): boolean {
return isMovedMetadataProviderKey(entry.key)
}
function coverage(entry: MetadataProviderDirectoryEntry): string {
return formatList([
...entry.mediaTypes.map((type) => t(`settings.metadata.providerDirectory.media.${type}`)),
...entry.languages.map((code) => formatLanguageName(code)),
])
}
function imageOf(entry: MetadataProviderDirectoryEntry): string {
return `${entry.image}:${entry.version}`
}
async function handleCopy(entry: MetadataProviderDirectoryEntry) {
if (await copyToClipboard(composeServiceFor(entry))) {
toast.success(t('settings.metadata.providerDirectory.copied', { name: entry.name }))
} else {
toast.error(t('settings.metadata.providerDirectory.copyFailed'))
}
}
function handleAdd(entry: MetadataProviderDirectoryEntry) {
emit('add', { baseUrl: composeAddressFor(entry), allowPrivateAddress: true })
}
onMounted(() => {
void fetchDirectory()
})
</script>
<template>
<section class="@container overflow-hidden rounded-lg border border-border bg-card shadow-xs" aria-labelledby="provider-directory-title">
<div class="flex flex-wrap items-center gap-2.5 border-b border-border bg-muted/40 px-4 py-2 md:px-5">
<PackageOpen :size="13" class="shrink-0 text-muted-foreground" aria-hidden="true" />
<h3 id="provider-directory-title" class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{{ t('settings.metadata.providerDirectory.title') }}
</h3>
<span v-if="sourceLine" class="ms-auto text-[11px] text-muted-foreground">{{ sourceLine }}</span>
</div>
<p class="border-b border-border px-4 py-2.5 text-xs leading-relaxed text-muted-foreground md:px-5">
{{ t('settings.metadata.providerDirectory.hint') }}
</p>
<p v-if="failed" role="alert" class="px-4 py-6 text-center text-xs text-muted-foreground md:px-5">
{{ t('settings.metadata.providerDirectory.loadFailed') }}
</p>
<template v-else-if="directory">
<ul v-if="offered.length" class="grid gap-3 p-3 @2xl:grid-cols-2 @2xl:p-4">
<li v-for="entry in offered" :key="entry.key" class="flex min-w-0 flex-col gap-2.5 rounded-lg border border-border bg-background/60 p-3.5">
<div class="flex items-start gap-3">
<ProviderBrandTile :provider-key="entry.key" :label="entry.name" :enabled="true" :color="entry.color" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<h4 class="settings-label truncate">{{ entry.name }}</h4>
<Badge
v-if="wasBuiltIn(entry)"
variant="outline"
class="h-5 px-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ t('settings.metadata.providerDirectory.wasBuiltIn') }}
</Badge>
</div>
<p class="settings-hint truncate">{{ coverage(entry) }}</p>
</div>
</div>
<p class="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{{ entry.summary }}</p>
<code class="block truncate rounded-md bg-muted px-2 py-1 font-mono text-[11px] text-foreground" :title="imageOf(entry)">{{
imageOf(entry)
}}</code>
<div class="mt-auto flex flex-wrap items-center gap-2 pt-0.5">
<Button
type="button"
size="sm"
variant="outline"
class="h-7 gap-1.5 px-2 text-[11px]"
:aria-label="t('settings.metadata.providerDirectory.copyLabel', { name: entry.name })"
@click="handleCopy(entry)"
>
<Copy :size="12" aria-hidden="true" />
{{ t('settings.metadata.providerDirectory.copy') }}
</Button>
<Button
type="button"
size="sm"
class="h-7 gap-1.5 px-2 text-[11px]"
:aria-label="t('settings.metadata.providerDirectory.addLabel', { name: entry.name })"
@click="handleAdd(entry)"
>
<Plus :size="12" aria-hidden="true" />
{{ t('settings.metadata.providerDirectory.add') }}
</Button>
<ExternalTextLink
class="ms-auto"
:href="entry.docsUrl"
:label="t('settings.metadata.providerDirectory.docs')"
:aria-label="t('settings.metadata.providerDirectory.docsLabel', { name: entry.name })"
/>
</div>
</li>
</ul>
<p v-else class="px-4 py-6 text-center text-xs text-muted-foreground md:px-5">
{{ t('settings.metadata.providerDirectory.allAdded') }}
</p>
<!-- They are listed in full just above, under Your providers. -->
<p v-if="offered.length && addedNames.length" class="flex items-start gap-1.5 px-4 pb-3.5 text-xs text-muted-foreground md:px-5">
<Check :size="13" class="mt-px shrink-0 text-success" aria-hidden="true" />
<span>{{ t('settings.metadata.providerDirectory.alreadyAdded', { names: addedNames }) }}</span>
</p>
</template>
</section>
</template>
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ALL_METADATA_FIELDS } from '@bookorbit/types'
import type { FieldPreference, MetadataField, MetadataFetchPreferences, MetadataHttpProviderView, MetadataProviderKey } from '@bookorbit/types'
import { supportedFieldsOf, useExternalProviders } from '../useExternalProviders'
const key = (value: string) => value as MetadataProviderKey
function prefs(): MetadataFetchPreferences {
const fields = ALL_METADATA_FIELDS.reduce<Record<MetadataField, FieldPreference>>(
(result, field) => {
result[field] = { enabled: true, providers: [key('google')], mergeStrategy: 'overwriteIfProvided' }
return result
},
{} as Record<MetadataField, FieldPreference>,
)
fields.title.providers = [key('google'), key('fantlab')]
return { fields }
}
function provider(overrides: Partial<MetadataHttpProviderView['info']> = {}): MetadataHttpProviderView {
return {
id: 1,
key: key('fantlab'),
baseUrl: 'http://fantlab:8080/',
hasAuthorization: false,
authorizationUnreadable: false,
enabled: true,
allowPrivateAddress: true,
timeoutMs: 10_000,
status: 'ok',
lastError: null,
info: {
key: key('fantlab'),
name: 'FantLab',
version: '0.1.0',
specVersion: '1',
avatarUrl: null,
authenticationRequired: false,
mediaTypes: ['book'],
lookup: true,
supportedFields: null,
searchParams: ['query', 'author', 'mediaType'],
idMaxLength: 11,
bookUrlTemplate: null,
rateLimit: { maxConcurrency: 2, minIntervalMs: 0 },
coverShape: null,
...overrides,
},
iconUrl: null,
infoFetchedAt: null,
createdAt: '2026-10-22T00:00:00.000Z',
updatedAt: '2026-10-22T00:00:00.000Z',
}
}
const requests: { url: string; method: string; body: unknown }[] = []
vi.mock('@/lib/api', () => ({
api: vi.fn<(url: string, init?: RequestInit) => Promise<unknown>>((url, init) => {
const method = init?.method ?? 'GET'
requests.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined })
if (url.endsWith('/metadata-preferences/global') && method === 'GET') {
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(prefs())) })
}
// The real endpoint answers a successful save with an empty body.
return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') })
}),
}))
describe('useExternalProviders', () => {
beforeEach(() => {
requests.length = 0
})
it('appends the provider to the end of each rule it can fill, skipping rules that already have it', async () => {
const { addToFieldRules } = useExternalProviders()
const result = await addToFieldRules(provider({ supportedFields: ['title', 'description', 'publisher'] }))
expect(result).toEqual({ ok: true, value: 2 })
const saved = requests.find((request) => request.method === 'PUT')?.body as MetadataFetchPreferences
expect(saved.fields.title.providers).toEqual(['google', 'fantlab'])
expect(saved.fields.description.providers).toEqual(['google', 'fantlab'])
expect(saved.fields.publisher.providers).toEqual(['google', 'fantlab'])
expect(saved.fields.genres.providers).toEqual(['google'])
})
it('saves nothing when the provider is already in every rule it can fill', async () => {
const { addToFieldRules } = useExternalProviders()
await expect(addToFieldRules(provider({ supportedFields: ['title'] }))).resolves.toEqual({ ok: true, value: 0 })
expect(requests.some((request) => request.method === 'PUT')).toBe(false)
})
it('uses every spec field for the provider media when the provider does not list its fields', () => {
expect(supportedFieldsOf(provider())).toContain('seriesIndex')
expect(supportedFieldsOf(provider())).not.toContain('narrators')
expect(supportedFieldsOf(provider({ mediaTypes: ['book', 'audiobook'] }))).toContain('narrators')
})
})
@@ -0,0 +1,140 @@
import { ref } from 'vue'
import type {
CreateMetadataHttpProviderPayload,
MetadataFetchPreferences,
MetadataField,
MetadataHttpProviderTestResult,
MetadataHttpProviderView,
UpdateMetadataHttpProviderPayload,
} from '@bookorbit/types'
import { api } from '@/lib/api'
import { registerProviderAppearance } from '@/lib/provider-appearance'
/** What a failed call said, so the panel can show translated copy for the codes it knows. */
export interface ExternalProviderError {
code: string | null
message: string | null
}
export type ExternalProviderResult<T> = { ok: true; value: T } | { ok: false; error: ExternalProviderError }
/** Field rules a provider can fill when its info does not list them: everything the spec maps. */
const SPEC_BOOK_FIELDS: MetadataField[] = [
'title',
'subtitle',
'description',
'cover',
'authors',
'publisher',
'isbn',
'publishedYear',
'language',
'pageCount',
'seriesName',
'seriesIndex',
'genres',
]
const SPEC_AUDIOBOOK_FIELDS: MetadataField[] = ['audioCover', 'narrators', 'duration']
async function readError(res: Response): Promise<ExternalProviderError> {
const body = (await res.json().catch(() => null)) as { errorCode?: unknown; message?: unknown } | null
return {
code: typeof body?.errorCode === 'string' ? body.errorCode : null,
message: typeof body?.message === 'string' ? body.message : null,
}
}
async function send<T>(url: string, init?: RequestInit): Promise<ExternalProviderResult<T>> {
try {
const res = await api(url, init)
if (!res.ok) return { ok: false, error: await readError(res) }
// Some endpoints answer a successful write with an empty body.
const text = await res.text()
return { ok: true, value: (text ? JSON.parse(text) : null) as T }
} catch {
return { ok: false, error: { code: null, message: null } }
}
}
function jsonInit(method: string, body?: unknown): RequestInit {
return { method, headers: { 'Content-Type': 'application/json' }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }
}
function appearanceOf(provider: MetadataHttpProviderView) {
return { key: provider.key, iconUrl: provider.iconUrl, color: provider.info?.color ?? null }
}
/** The fields a provider says it fills, or every spec field for its media when it does not say. */
export function supportedFieldsOf(provider: MetadataHttpProviderView): MetadataField[] {
const declared = provider.info?.supportedFields
if (declared) return declared
const audio = provider.info?.mediaTypes.includes('audiobook') ?? false
return [...SPEC_BOOK_FIELDS, ...(audio ? SPEC_AUDIOBOOK_FIELDS : [])]
}
export function useExternalProviders() {
const providers = ref<MetadataHttpProviderView[]>([])
const loaded = ref(false)
async function fetchProviders(): Promise<void> {
const result = await send<MetadataHttpProviderView[]>('/api/v1/metadata-http-providers')
if (result.ok) {
providers.value = result.value
registerProviderAppearance(result.value.map(appearanceOf))
}
loaded.value = true
}
function replace(provider: MetadataHttpProviderView): void {
const index = providers.value.findIndex((entry) => entry.id === provider.id)
providers.value = index >= 0 ? providers.value.map((entry) => (entry.id === provider.id ? provider : entry)) : [...providers.value, provider]
registerProviderAppearance([appearanceOf(provider)])
}
async function createProvider(payload: CreateMetadataHttpProviderPayload): Promise<ExternalProviderResult<MetadataHttpProviderView>> {
const result = await send<MetadataHttpProviderView>('/api/v1/metadata-http-providers', jsonInit('POST', payload))
if (result.ok) replace(result.value)
return result
}
async function updateProvider(id: number, payload: UpdateMetadataHttpProviderPayload): Promise<ExternalProviderResult<MetadataHttpProviderView>> {
const result = await send<MetadataHttpProviderView>(`/api/v1/metadata-http-providers/${id}`, jsonInit('PATCH', payload))
if (result.ok) replace(result.value)
return result
}
async function removeProvider(id: number, purge: boolean): Promise<ExternalProviderResult<unknown>> {
const result = await send<unknown>(`/api/v1/metadata-http-providers/${id}${purge ? '?purge=true' : ''}`, { method: 'DELETE' })
if (result.ok) providers.value = providers.value.filter((entry) => entry.id !== id)
return result
}
async function testProvider(id: number): Promise<ExternalProviderResult<MetadataHttpProviderTestResult>> {
const result = await send<MetadataHttpProviderTestResult>(`/api/v1/metadata-http-providers/${id}/test`, jsonInit('POST', {}))
// A test also refreshes what BookOrbit knows about the provider, status included.
await fetchProviders()
return result
}
/**
* Appends the provider to the end of every global field rule it can fill. Nothing does this on
* its own: a new provider takes part in auto-fetch only once an admin asks for it.
*/
async function addToFieldRules(provider: MetadataHttpProviderView): Promise<ExternalProviderResult<number>> {
const current = await send<MetadataFetchPreferences>('/api/v1/metadata-preferences/global')
if (!current.ok) return current
const preferences = current.value
let added = 0
for (const field of supportedFieldsOf(provider)) {
const rule = preferences.fields[field]
if (!rule || rule.providers.includes(provider.key)) continue
rule.providers = [...rule.providers, provider.key]
added++
}
if (added === 0) return { ok: true, value: 0 }
const saved = await send<unknown>('/api/v1/metadata-preferences/global', jsonInit('PUT', preferences))
return saved.ok ? { ok: true, value: added } : saved
}
return { providers, loaded, fetchProviders, createProvider, updateProvider, removeProvider, testProvider, addToFieldRules }
}
@@ -1,7 +1,8 @@
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import { MetadataProviderKey } from '@bookorbit/types'
import { registerProviderAppearance } from '@/lib/provider-appearance'
import { isBuiltInMetadataProviderKey, MetadataProviderKey } from '@bookorbit/types'
import type { ProviderConfigurations, ProviderConnectionTestResult, ProviderStatus } from '@bookorbit/types'
import { stripBearerPrefix } from '../lib/provider-token'
@@ -15,6 +16,8 @@ export function useProviderConfig() {
const passingTestSignatureByKey = ref<Partial<Record<MetadataProviderKey, string>>>({})
function normalizePatchForSignature(key: MetadataProviderKey, patch: Partial<ProviderConfigurations>): unknown {
// External providers have their own settings and never reach this built-in config form.
if (!isBuiltInMetadataProviderKey(key)) return null
const value = patch[key]
if (!value) return null
if (key !== MetadataProviderKey.HARDCOVER) return value
@@ -43,6 +46,7 @@ export function useProviderConfig() {
const data: { config: ProviderConfigurations; statuses: ProviderStatus[] } = await res.json()
config.value = data.config
statuses.value = data.statuses
registerProviderAppearance(data.statuses.filter((status) => status.source === 'external'))
} finally {
loading.value = false
}
@@ -0,0 +1,34 @@
import { ref } from 'vue'
import type { MetadataProviderDirectory, MetadataProviderDirectoryEntry } from '@bookorbit/types'
import { api } from '@/lib/api'
/**
* The Compose service for one ready-made provider, indented to sit under `services:`. The service
* name is the provider's key, so the address the Add sheet is prefilled with resolves to it.
*/
export function composeServiceFor(entry: MetadataProviderDirectoryEntry): string {
return [` ${entry.key}:`, ` image: ${entry.image}:${entry.version}`, ' restart: unless-stopped', ''].join('\n')
}
/** Where BookOrbit reaches the provider when it runs beside it in the same Compose project. */
export function composeAddressFor(entry: MetadataProviderDirectoryEntry): string {
return `http://${entry.key}:${entry.port}`
}
export function useProviderDirectory() {
const directory = ref<MetadataProviderDirectory | null>(null)
const failed = ref(false)
async function fetchDirectory(): Promise<void> {
try {
const res = await api('/api/v1/metadata-http-providers/directory')
if (!res.ok) throw new Error(String(res.status))
directory.value = (await res.json()) as MetadataProviderDirectory
failed.value = false
} catch {
failed.value = true
}
}
return { directory, failed, fetchDirectory }
}
@@ -156,26 +156,5 @@ export function useProviderRows(): ComputedRef<ProviderRowDef[]> {
},
},
{ key: 'ranobedb', label: 'RanobeDB', group: 'comics', hint: t('settings.metadata.providers.hints.ranobedb'), fields: [] },
{ key: 'lubimyczytac', label: 'LubimyCzytac', group: 'regional', hint: t('settings.metadata.providers.hints.lubimyczytac'), fields: [] },
{
key: 'aladin',
label: 'Aladin',
group: 'regional',
hint: t('settings.metadata.providers.hints.aladin'),
fields: [
{
key: 'ttbKey',
label: t('settings.metadata.providers.fields.ttbKey'),
type: 'password',
placeholder: 'ttb...',
alwaysEditable: true,
widthClass: SECRET_WIDTH,
},
],
enableRequirement: {
isConfigured: (c) => !!c.aladin.ttbKey.trim(),
blockedMessage: t('settings.metadata.providers.blocked.aladin'),
},
},
])
}
@@ -100,8 +100,22 @@ describe('skipReasonFor', () => {
})
describe('isProviderUsable', () => {
it('treats a provider with no status entry as usable so it stays removable', () => {
expect(isProviderUsable(key('unknownProvider'), STATUSES)).toBe(true)
it('treats a built-in with no status entry as usable, since that is stale data', () => {
expect(isProviderUsable(key('openLibrary'), [])).toBe(true)
})
it('treats a removed external provider as skipped, the way the server does', () => {
expect(isProviderUsable(key('fantlab'), STATUSES)).toBe(false)
expect(skippedProviders([key('fantlab')], STATUSES)).toEqual(['fantlab'])
})
it("reads an external provider's own declared fields", () => {
const statuses = [
...STATUSES,
{ key: key('fantlab'), label: 'FantLab', enabled: true, configured: true, source: 'external' as const, supportedFields: ['title' as const] },
]
expect(isProviderUsable(key('fantlab'), statuses, 'title')).toBe(true)
expect(isProviderUsable(key('fantlab'), statuses, 'narrators')).toBe(false)
})
it('rejects a disabled or unconfigured provider', () => {
@@ -1,4 +1,4 @@
import { ALL_METADATA_FIELDS, providerFieldSupport } from '@bookorbit/types'
import { ALL_METADATA_FIELDS, isBuiltInMetadataProviderKey, providerFieldSupport } from '@bookorbit/types'
import type { FieldPreference, MetadataField, MetadataProviderKey, ProviderStatus } from '@bookorbit/types'
/** Display grouping for the rule table. Every metadata field belongs to exactly one group. */
@@ -40,7 +40,7 @@ export function skipReasonFor(
): ProviderSkipReason | null {
if (status && !status.enabled) return 'disabled'
if (status && !status.configured) return 'notConfigured'
if (field && provider && providerFieldSupport(provider, field) === 'never') return 'neverSupplies'
if (field && provider && providerFieldSupport(provider, field, status?.supportedFields) === 'never') return 'neverSupplies'
return null
}
@@ -49,26 +49,32 @@ export function skipReasonFor(
* empty slot when nothing else can. The server tries it after every provider whose art can fit,
* whatever its place in the order, so it ranks there too.
*/
export function providerHasOtherShape(provider: MetadataProviderKey, field: MetadataField | undefined): boolean {
return field !== undefined && providerFieldSupport(provider, field) === 'otherShape'
export function providerHasOtherShape(
provider: MetadataProviderKey,
field: MetadataField | undefined,
declaredFields?: readonly MetadataField[] | null,
): boolean {
return field !== undefined && providerFieldSupport(provider, field, declaredFields) === 'otherShape'
}
/**
* A provider with no status entry counts as usable: an unknown key is stale data rather
* than a broken provider, and hiding it would leave no way to remove it.
* A built-in with no status entry counts as usable: that is stale data rather than a broken
* provider. An external provider with none has been removed, and the server skips it, so it is
* shown as skipped; it stays listed either way, so it can still be taken out of the order.
*/
export function isProviderUsable(provider: MetadataProviderKey, statuses: ProviderStatus[], field?: MetadataField): boolean {
if (field && providerFieldSupport(provider, field) === 'never') return false
const status = statuses.find((entry) => entry.key === provider)
return status ? status.enabled && status.configured : true
if (field && providerFieldSupport(provider, field, status?.supportedFields) === 'never') return false
return status ? status.enabled && status.configured : isBuiltInMetadataProviderKey(provider)
}
/** The providers that will actually be consulted, in the order the server tries them. */
export function resolvedProviders(providers: MetadataProviderKey[], statuses: ProviderStatus[], field?: MetadataField): MetadataProviderKey[] {
const usable = providers.filter((provider) => isProviderUsable(provider, statuses, field))
const declared = (provider: MetadataProviderKey) => statuses.find((entry) => entry.key === provider)?.supportedFields
return [
...usable.filter((provider) => !providerHasOtherShape(provider, field)),
...usable.filter((provider) => providerHasOtherShape(provider, field)),
...usable.filter((provider) => !providerHasOtherShape(provider, field, declared(provider))),
...usable.filter((provider) => providerHasOtherShape(provider, field, declared(provider))),
]
}
@@ -5,9 +5,9 @@ export { AMAZON_DOMAINS } from '@/lib/amazon-domain'
* The medium a source supplies metadata for. Sources are picked by medium ("I want
* audiobook narrators"), not alphabetically, so the list is sectioned the same way.
*/
export type ProviderGroupId = 'books' | 'audiobooks' | 'comics' | 'regional'
export type ProviderGroupId = 'books' | 'audiobooks' | 'comics'
export const PROVIDER_GROUP_ORDER: ProviderGroupId[] = ['books', 'audiobooks', 'comics', 'regional']
export const PROVIDER_GROUP_ORDER: ProviderGroupId[] = ['books', 'audiobooks', 'comics']
export interface ProviderFieldDef {
key: string
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { createExternalProviderLinks } from '@/features/book/lib/provider-links'
import { providerIconPathSafe } from '@/features/book/lib/provider-icons'
import { registerProviderAppearance } from '../provider-appearance'
import { getProviderColor, providerChipStyle } from '../provider-colors'
describe('provider appearance', () => {
it('gives an external provider its own icon and colour once a provider list has named them', () => {
expect(getProviderColor('fantlab')).toBe('var(--muted-foreground)')
expect(providerIconPathSafe('fantlab')).toBeNull()
registerProviderAppearance([{ key: 'fantlab', iconUrl: '/api/v1/metadata-providers/fantlab/icon?v=abc', color: '#2c98cd' }])
expect(getProviderColor('fantlab')).toBe('#2c98cd')
expect(providerChipStyle('fantlab')).toEqual({ '--provider-color': '#2c98cd' })
expect(providerIconPathSafe('fantlab')).toBe('/api/v1/metadata-providers/fantlab/icon?v=abc')
expect(createExternalProviderLinks({ fantlab: '3104' } as never, { fantlab: 'https://fantlab.ru/work3104' })[0].iconUrl).toBe(
'/api/v1/metadata-providers/fantlab/icon?v=abc',
)
})
it('never lets an external provider restyle a built-in', () => {
registerProviderAppearance([{ key: 'goodreads', iconUrl: '/evil.png', color: '#000000' }])
expect(getProviderColor('goodreads')).toBe('#00d8d1')
expect(providerIconPathSafe('goodreads')).toBe('/assets/provider-icons/goodreads.svg')
})
it('drops an icon and colour the provider no longer declares', () => {
registerProviderAppearance([{ key: 'mangabaka', iconUrl: '/icon', color: '#e30047' }])
registerProviderAppearance([{ key: 'mangabaka', iconUrl: null, color: null }])
expect(getProviderColor('mangabaka')).toBe('var(--muted-foreground)')
expect(providerIconPathSafe('mangabaka')).toBeNull()
})
})
+25
View File
@@ -0,0 +1,25 @@
import { ref } from 'vue'
export interface ProviderAppearance {
iconUrl: string | null
color: string | null
}
const appearance = ref<Partial<Record<string, ProviderAppearance>>>({})
/**
* External providers' icons and colours, which only the server knows. Every screen that loads a
* provider list records them here, so the shared icon and colour helpers can answer for a provider
* registered at runtime the way they answer for a built-in.
*/
export function registerProviderAppearance(providers: Iterable<{ key: string; iconUrl?: string | null; color?: string | null }>): void {
const next = { ...appearance.value }
for (const provider of providers) {
next[provider.key] = { iconUrl: provider.iconUrl ?? null, color: provider.color ?? null }
}
appearance.value = next
}
export function providerAppearance(key: string): ProviderAppearance | undefined {
return appearance.value[key]
}
+9 -2
View File
@@ -1,4 +1,5 @@
import type { MetadataProviderKey } from '@bookorbit/types'
import { providerAppearance } from './provider-appearance'
const PROVIDER_HEX: Record<string, string> = {
google: '#34A853',
@@ -18,7 +19,8 @@ const PROVIDER_HEX: Record<string, string> = {
auto: '#8B5CF6',
}
const DEFAULT_COLOR = 'oklch(0.5 0.01 0)'
// An external provider that declares no colour gets a tuned theme token, legible in both themes.
const DEFAULT_COLOR = 'var(--muted-foreground)'
export const PROVIDER_SHORT_LABELS: Record<string, string> = {
google: 'Google',
@@ -38,8 +40,13 @@ export const PROVIDER_SHORT_LABELS: Record<string, string> = {
auto: 'Fetched',
}
/** A compact name for chips: the built-in short form, else the provider's own label, else its key. */
export function providerShortLabel(provider: string, label?: string | null): string {
return PROVIDER_SHORT_LABELS[provider] ?? label ?? provider
}
export function getProviderColor(provider: string): string {
return PROVIDER_HEX[provider] ?? DEFAULT_COLOR
return PROVIDER_HEX[provider] ?? providerAppearance(provider)?.color ?? DEFAULT_COLOR
}
function makeProviderPillStyle(color: string, bgPct: number, outlinePct: number): Record<string, string> {
+120
View File
@@ -1955,6 +1955,11 @@
}
},
"providers": {
"tabs": {
"builtIn": "Built-in",
"external": "External",
"attention": "Needs attention"
},
"availableSources": "Available Sources",
"saveChanges": "Save Changes",
"discard": "Discard",
@@ -2026,6 +2031,115 @@
"aladin": "Aladin requires a TTB Key before it can be enabled"
}
},
"providerDirectory": {
"title": "Available to install",
"hint": "Each one runs as a small container beside BookOrbit. Copy its Compose service into the file that runs BookOrbit, start it with docker compose up -d, then choose Add.",
"updated": "Updated {time}",
"bundled": "List included with this version",
"loadFailed": "The list of ready-made providers could not be loaded. You can still add any provider by its address.",
"copy": "Copy Compose",
"copyLabel": "Copy the Compose service for {name}",
"copied": "{name} copied. Paste it under services: in your Compose file, then run docker compose up -d.",
"copyFailed": "Could not copy to the clipboard. The provider's docs have the same snippet.",
"add": "Add",
"addLabel": "Add {name}",
"alreadyAdded": "Already added: {names}",
"allAdded": "You have added every provider in this list.",
"wasBuiltIn": "Was built in",
"docs": "Docs",
"docsLabel": "{name} documentation, opens in a new tab",
"media": {
"book": "Books",
"audiobook": "Audiobooks",
"comic": "Comics"
}
},
"buildYourOwn": {
"title": "Build your own",
"body": "Missing a source? A provider is a small web service that answers three JSON requests, so any language will do. Start from the Rust or Go starter, check it with the conformance checker, then add it here like any other.",
"links": {
"guide": "Building guide",
"starters": "Starters",
"checker": "Conformance checker",
"suggest": "Suggest a source"
}
},
"movedProviders": {
"title": "Some sources are no longer built in",
"description": "Field rules that list these sources skip them for now. The ids and links already on your books are kept.",
"run": "{name} now runs as its own provider: run {image}, then add it under External providers.",
"closed": "{name} closed the service BookOrbit used, so it cannot be added back. Remove it from your field rules."
},
"externalProviders": {
"title": "External providers",
"description": "Metadata services that run outside BookOrbit, such as a container beside it or a hosted service. Add one by its address; once enabled it appears in search, Book Dock and field rules like any other source.",
"yourProviders": "Your providers",
"emptyWithDirectory": "Nothing added yet. Pick a ready-made provider below, or add one by its address.",
"add": "Add provider",
"empty": "No external providers yet. Any service that speaks the book-metadata-provider spec can be added here.",
"edit": "Edit {name}",
"toggle": "Enable {name}",
"row": "{host} · version {version}",
"useInFieldRules": "Use in field rules",
"fieldRulesAdded": "{name} added to {count, plural, =0 {no new field rules; it is already in every rule it can fill} one {# field rule} other {# field rules}}.",
"added": "{name} added. It stays off until you enable it.",
"saved": "{name} saved.",
"removed": "{name} removed. Its stored ids and ratings were kept.",
"purged": "{name} removed with its stored ids and ratings.",
"status": {
"active": "Active",
"ready": "Ready",
"stale": "Last check failed",
"unreachable": "Unreachable",
"key_changed": "Key changed",
"incompatible": "Incompatible",
"authorizationUnreadable": "Header unreadable"
},
"authorizationUnreadableHint": "The saved authorization header could not be decrypted, so this provider is not searched. Enter the header again.",
"sheet": {
"createTitle": "Add external provider",
"description": "An HTTP service that speaks the book-metadata-provider spec. BookOrbit asks it for metadata; it never runs the provider's code.",
"identity": "Key {key} · version {version}",
"test": "Test",
"testing": "Testing...",
"remove": "Remove provider",
"removeConfirm": "Remove {name}?",
"removeConsequence": "Its stored ids, ratings and field-rule entries stay, so adding it again restores them.",
"purge": "Remove and delete its data",
"purgeConfirm": "Remove {name} and delete its data?",
"purgeConsequence": "Deletes every id and rating stored from it. This cannot be undone."
},
"fields": {
"baseUrl": "Address",
"baseUrlHint": "The provider's base URL, such as http://provider:8080 for a container on the same Docker network.",
"authorization": "Authorization header",
"authorizationHint": "Optional. Sent exactly as entered, such as \"Bearer abc123\". Stored encrypted and never shown again.",
"authorizationSaved": "A header is saved. Enter a new one to replace it.",
"clearAuthorization": "Remove the saved header",
"allowPrivate": "Allow private address",
"allowPrivateHint": "Needed for a provider on your own network or in a container beside BookOrbit. Leave it off for a provider on the internet.",
"timeout": "Timeout (seconds)",
"timeoutHint": "Between {min} and {max} seconds.",
"privacy": "The provider receives the titles, authors and ISBNs of the books it is asked about."
},
"test": {
"ok": "{name} answered in {latency} ms with {count, plural, one {# result} other {# results}}.",
"sample": "First result: {title}",
"failed": "Test failed: {error}"
},
"errors": {
"privateAddress": "That address is on a private network. Turn on Allow private address to reach a provider there.",
"unreachable": "BookOrbit could not reach a provider at that address.",
"unauthorized": "The provider refused the authorization header.",
"invalidInfo": "That address answered, but not with usable provider info.",
"invalidUrl": "Enter the provider address as a full http or https URL, without credentials in it.",
"keyTaken": "A provider with the same key is already registered.",
"keyChanged": "That address serves a different provider. Add it as a new provider instead.",
"notReady": "Run a successful test before enabling this provider.",
"authorizationUnreadable": "The saved authorization header could not be decrypted. Enter it again before enabling this provider.",
"generic": "The request to the provider failed."
}
},
"fieldRules": {
"clearAllProviders": "Clear All Providers",
"clearAll": "Clear All",
@@ -5273,6 +5387,7 @@
"BookRequest": "Book request",
"DownloadClient": "Download client",
"RequestIndexer": "Indexer",
"MetadataHttpProvider": "Metadata provider",
"Author": "Author",
"AppSettings": "Application settings",
"Genre": "Genre",
@@ -5385,6 +5500,9 @@
"RequestIndexerPluginInspect": "Indexer plugin inspected",
"RequestIndexerPluginInstall": "Indexer plugin installed",
"RequestIndexerPluginRemove": "Indexer plugin deleted",
"MetadataHttpProviderCreate": "Metadata provider added",
"MetadataHttpProviderUpdate": "Metadata provider updated",
"MetadataHttpProviderDelete": "Metadata provider removed",
"AuthorUpdate": "Update author",
"AuthorDelete": "Delete author",
"AuthorMerge": "Merge authors",
@@ -6732,6 +6850,8 @@
"durationLabel": "Duration (s)",
"abridgedLabel": "Abridged",
"providerIds": "Provider IDs",
"externalProviderUnavailable": "{provider} is no longer registered, so this id is kept but not used",
"clearExternalProviderId": "Clear the {provider} id",
"comicDetails": "Comic Details",
"comicIssueNumberLabel": "Issue Number",
"comicVolumeLabel": "Volume",
+372
View File
@@ -0,0 +1,372 @@
# Building a Metadata Provider
A metadata provider is a small web service that answers BookOrbit's metadata searches from a source
it does not know: a regional catalogue, a fan wiki, a manga database, your own spreadsheet. BookOrbit
calls it over HTTP and never runs its code, so you write it in any language, release it on your own
schedule, and nobody has to change BookOrbit for it.
This guide takes you from an empty folder to a published image that anyone can add to BookOrbit.
To add a provider someone else made, see [External Metadata Providers](METADATA_PROVIDERS.md).
## How it works
```text
BookOrbit your provider the source
| GET /search?query=Dune -------> | |
| | -- its own API or pages ----> |
| | <---------------------------- |
| <---- { "matches": [ ... ] } --- | |
```
Your provider answers three requests:
- `GET /provider/info`: who it is (name, a permanent key, version, icon, what it can do).
- `GET /search`: books matching a title, author or ISBN, best first.
- `GET /book/{id}`: one book by the id your provider gave it earlier. Optional, but recommended.
BookOrbit caches answers, merges them with its other sources through field rules, stores your ids
on the books it matches, and shows your icon and colour wherever your results appear.
## Before you start
- **Check the source's terms.** You are responsible for how your provider uses its source. Prefer an
official API over scraping pages: APIs change less often, and many sites forbid scraping.
- **Choose the key now, and never change it.** The key (such as `mangabaka`) is how BookOrbit files
every id and rating your provider gives it. A provider that changes its key is a new provider to
BookOrbit, and users have to add it again.
- **Choose a language you will keep maintaining.** There are starters in Rust and Go. Rust is what
the published providers use: the same provider ran in about a third of Go's memory. Go needs
nothing but `go run`. Python, Node or anything else that serves JSON works too.
## Quick start
The [bookorbit-metadata-providers](https://github.com/bookorbit/bookorbit-metadata-providers)
repository has a working starter in Rust and one in Go, and a conformance checker. Both starters
serve one made-up book and behave the same; pick the language you prefer.
1. **Copy a starter.**
```bash
git clone https://github.com/bookorbit/bookorbit-metadata-providers.git
```
For Rust:
```bash
cp -R bookorbit-metadata-providers/starters/rust my-provider
```
Or for Go:
```bash
cp -R bookorbit-metadata-providers/starters/go my-provider
```
2. **Run it**, so you can see every endpoint working before you change anything. In Rust,
`cargo run`; in Go, `go run .`. Then:
```bash
curl "http://localhost:8080/search?query=earthsea"
```
3. **Name it.** Change `info()` in `src/main.rs` (Rust) or `main.go` (Go): give it your `name` and
the `key` you chose.
4. **Connect your source.** In `src/source.rs` or `source.go`, replace the example with calls to
your source:
- Search returns matches best first, at most the query's limit.
- Lookup returns the book for an id, or nothing (`None`, `nil`) when the id does not exist.
- Return an error for anything else that goes wrong. Never hide a failure behind an empty list:
to BookOrbit, an empty list means "this book is not there", and it remembers that.
5. **Check it against the contract.** With your provider running:
```bash
cd bookorbit-metadata-providers/conformance && go run . -url http://localhost:8080 -query "Dune"
```
It prints a line per check and ends with the number of failures and warnings. Fix every failure;
each warning says what BookOrbit will do instead.
6. **Try it in BookOrbit.** Add it under **Settings > Metadata > Providers > External providers**
with **Allow private address** on, choose **Test**, turn it on and search for a book.
## Recommendations
**Identity**
- Serve `/provider/info` with a `name`, a `key` and a `version`.
- Bump `version` whenever your answers change shape: new fields, different mapping, a fixed bug.
BookOrbit keeps cached answers per version, so an unchanged version keeps serving old ones.
- Give it an icon (`avatarUrl`) and a `color`, so your results are recognisable at a glance.
**Answers**
- Put the best match first, and return a handful rather than everything: BookOrbit uses at most 50,
and a person picks from the first few.
- Leave out what is not a book on a shelf: reviews, articles, interviews, collections pages.
- Support `GET /book/{id}` and declare `lookup: true` when your source has stable ids. Refreshing a
book by id is faster and more accurate than searching for it again.
- Declare what you can fill with `supportedFields` and `mediaTypes`, so BookOrbit only asks you for
what you have.
- Give each record its own `mediaType` when your source has more than one: a light novel is
`book`, its manga `comic`. When titles are otherwise alike, BookOrbit prefers the record that
matches the medium of the book being matched, so a novel's EPUB gets the novel.
**Data**
- Plain-text descriptions: strip HTML, Markdown and site markup, and keep paragraphs.
- Write people's names the way the book does. Give years as four digits, ISBNs without hyphens,
ratings on a 0-5 scale.
- Return the largest cover your source has, with `coverWidth` and `coverHeight` when you know them.
- Fill only what you know. A missing field is better than a guessed one.
**Failures and limits**
- Answer `429` with `Retry-After` when your source throttles you. BookOrbit backs off.
- Answer `502` or `503` when your source fails. BookOrbit may then answer from its cache.
- Answer `"partial": true` when some matches could not be fetched, so the answer is not cached as
complete.
- Pace your calls to the source inside the provider, whatever BookOrbit asks for: several BookOrbit
servers, or other apps, may share one provider. Declare a `rateLimit` too, so BookOrbit does not
queue more than you can serve.
- Identify yourself to the source with a `User-Agent` such as `my-provider/1.2.0`.
**Safety and privacy**
- Keep it stateless and read-only. It needs no database and no volume.
- Run as a non-root user in a minimal image (`scratch` or distroless). Both starters' Dockerfiles
already do both.
- Support an `AUTH_TOKEN` (both starters do) for anyone who exposes it beyond their own network.
- Log failures, not the titles people look up.
- Say in your README which details you send to your source. BookOrbit sends you the title, author
and ISBN of each book it asks about.
## Test it
- **Unit tests with real answers.** Save a few real responses from your source into `testdata/`,
trimmed to the fields you read, and test your mapping against them. Tests then run offline and
keep passing when the source is down. Both published providers do this.
- **The conformance checker** before every release: it checks the endpoints, types, limits, status
codes, icon and colour the way BookOrbit reads them.
- **In BookOrbit:** **Test** in the provider's settings runs a real search and shows what came back.
## Package it
Ship it as a container image. Each starter's `Dockerfile` builds a static binary into an empty
`scratch` image that runs as a non-root user: about 1 MB for the Rust starter, 5 MB for the Go one.
```bash
docker build -t my-provider .
```
```bash
docker run --rm -p 8080:8080 my-provider
```
Build for both `linux/amd64` and `linux/arm64`: many people run BookOrbit on a Raspberry Pi or another
ARM machine.
```bash
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/your-name/my-provider:0.1.0 --push .
```
## Publish it
GitHub Container Registry (GHCR) is free for public images and sits next to your code.
1. **Push your code to GitHub.**
2. **Add a release workflow** as `.github/workflows/publish.yml`. Pushing a tag such as `v0.1.0` then
builds both architectures and publishes the image:
```yaml
name: Publish
on:
push:
tags: ["v*"]
permissions:
contents: read
packages: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
```
3. **Release:** bump `version` in `/provider/info`, then tag and push.
```bash
git tag v0.1.0 && git push origin v0.1.0
```
4. **Make the image public.** GitHub creates new packages as private. Open the package from your
profile's **Packages** tab, then **Package settings > Change visibility > Public**.
5. **Write the README people need:** what the provider covers, a Compose snippet with a pinned
version, the address to enter in BookOrbit, its environment variables, what it sends to its
source, and any known gaps.
6. **Tell people about it** in a
[Community Discussion](https://github.com/bookorbit/bookorbit/issues/new?template=discussion.yml).
To have it offered under **Available to install** in every BookOrbit, open a pull request that
adds it to `directory.json` in
[bookorbit-metadata-providers](https://github.com/bookorbit/bookorbit-metadata-providers).
### Versions
Use semantic versions and tag each image with its full version (`0.1.0`), its minor line (`0.1`)
and, if you like, `latest`. Tell users to pin a version, so an update never surprises them.
Once people use your provider:
- Keep its key forever.
- Keep answering for ids you issued before. BookOrbit stores them on people's books and looks them
up again on every refresh; an id that suddenly returns 404 tells BookOrbit the book is gone.
- Bump `version` with every change to your answers.
## Reference
The contract builds on the
[book-metadata-provider](https://github.com/FlyinPancake/book-metadata-provider) specification. Every
field below except `name`, `providerId` and `title` is optional.
### `GET /provider/info`
```json
{
"name": "MangaBaka",
"key": "mangabaka",
"version": "0.1.0",
"avatarUrl": "https://mangabaka.org/images/maomao/180x180.png",
"color": "#e30047",
"mediaTypes": ["book", "comic"],
"lookup": true,
"searchParams": ["limit"],
"supportedFields": [
"title",
"description",
"cover",
"authors",
"seriesName",
"seriesIndex"
],
"bookUrlTemplate": "https://mangabaka.org/manga/{id}",
"idMaxLength": 12,
"rateLimit": { "maxConcurrency": 1, "minIntervalMs": 2000 },
"coverShape": "portrait"
}
```
| Field | What it does |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Display name, up to 100 characters. Required |
| `key` | Permanent identity: 2-32 lowercase letters, digits or hyphens, starting with a letter. Built-in names such as `goodreads` are taken. Defaults to a slug of `name` |
| `version` | Your release. Part of BookOrbit's cache key |
| `avatarUrl` | Your icon (see [Icon and colour](#icon-and-colour)) |
| `color` | `#rrggbb` brand colour |
| `mediaTypes` | Any of `book`, `audiobook`, `comic`. Defaults to `book` and `audiobook` |
| `lookup` | `true` when you serve `GET /book/{id}` |
| `searchParams` | Optional search parameters you want: `isbn`, `limit` |
| `supportedFields` | Field rules you can fill: `title`, `subtitle`, `description`, `cover`, `audioCover`, `authors`, `publisher`, `isbn`, `publishedYear`, `language`, `pageCount`, `communityRating`, `seriesName`, `seriesIndex`, `genres`, `narrators`, `duration`, `abridged`. Defaults to all |
| `bookUrlTemplate` | A link to a book's page, with `{id}` in it, shown on the book |
| `idMaxLength` | Longest id you issue, at most 512 |
| `rateLimit` | `maxConcurrency` (1-4, default 2) and `minIntervalMs` (0-60000, default 0): how hard BookOrbit may call you |
| `coverShape` | `portrait` or `square`, when all your covers are one shape |
| `authenticationRequired` | `true` when your provider needs an `Authorization` header |
### `GET /search`
BookOrbit sends `query` (the title), `author` and `mediaType` (`book` or `audiobook`), plus `isbn` and
`limit` if you listed them in `searchParams`. It only searches when it has a title or an ISBN.
Answer with your matches, best first:
```json
{
"matches": [
{ "providerId": "1692", "title": "BERSERK", "authors": ["MIURA Kentaro"] }
]
}
```
### `GET /book/{id}`
The `id` is one of your `providerId` values, URL-encoded. Answer with one book in the same shape as
a match, or `404` when it does not exist.
### A book
| Field | Notes |
| ------------------------------------ | ------------------------------------------------------------------ |
| `providerId` | Your id for it, a string. Required; a match without one is dropped |
| `title` | Required |
| `mediaType` | `book`, `comic` or `audiobook`: what this record is |
| `subtitle` | |
| `authors`, `narrators` | Lists of names |
| `description` | Plain text, up to 50,000 characters |
| `publisher` | |
| `publishedYear` | `"1968"` |
| `publishedDate` | `"1968-11-01"` |
| `languageCode` | `"en"` |
| `isbn13`, `isbn10` | Or a single `isbn` of either length |
| `pages` | Page count |
| `duration` | Audiobook length in seconds |
| `genres` | List of names |
| `series` | `[{ "series": "Earthsea Cycle", "sequence": "1" }]` |
| `cover`, `coverWidth`, `coverHeight` | A public `http(s)` image URL, and its size in pixels |
| `rating`, `ratingCount` | Community rating on a 0-5 scale, and how many people rated |
| `url` | The book's page on your source |
### Status codes
| You answer | BookOrbit |
| ------------------------------------- | --------------------------------------------------------- |
| `200` | Uses the answer. An empty `matches` list means none found |
| `404` from `/book/{id}` | Treats the id as gone |
| `429` with `Retry-After` | Waits that long before asking again |
| `401`, `403` | Reports the authorization header as refused |
| Anything else, a timeout, or bad JSON | Counts a failure, and may answer from its cache |
BookOrbit waits as long as the admin set for the provider: 10 seconds by default, between 2 and 30.
It reads at most 2 MB of an answer and the first 50 matches, and follows at most 3 redirects.
### Icon and colour
BookOrbit downloads the image at `avatarUrl` when the provider is added, tested, or rechecked every
six hours, and serves its own copy, so readers' browsers never contact you. It keeps PNG, WebP, JPEG,
GIF and ICO images up to 256 KB, judged by the file's contents; SVG is never kept. A square image of
64 pixels or more looks best. `color` must be six hex digits, such as `#2c98cd`, or BookOrbit ignores
it. Without an icon BookOrbit shows your initials; without a colour, a neutral one.
## FAQ
**Do I need to change BookOrbit?** No. An admin adds your provider by its address.
**Can one container serve several providers?** Yes. Serve each under its own path, such as
`http://pack:8080/goodreads/` and `http://pack:8080/audible/`, each with its own key, and add each
address separately.
**Can I see what BookOrbit asked?** Log the request path and status, not the query: the query is
someone's reading.
+145
View File
@@ -0,0 +1,145 @@
# External Metadata Providers
BookOrbit comes with its own metadata sources: Google Books, Goodreads, Hardcover and others. When
your library needs something they do not cover well, such as manga, Chinese books or Russian-language
science fiction, you can add an **external provider**: a small service that runs beside BookOrbit and
answers its metadata searches.
Once added, an external provider works like a built-in source. It shows up in manual search, Book
Dock, auto-fetch and library refresh, it can fill field rules, and it gets its own icon and colour
wherever it appears. Adding one needs no BookOrbit update.
## Ready-made providers
| Provider | Good for | Image |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- |
| Douban | Chinese books from [book.douban.com](https://book.douban.com): titles, authors, publishers, ratings, ISBN lookups. No covers | `ghcr.io/bookorbit/douban-provider` |
| FantLab | Russian-language science fiction and fantasy from [fantlab.ru](https://fantlab.ru): titles, authors, series, genres, ratings, ISBN lookups | `ghcr.io/bookorbit/fantlab-provider` |
| J-Novel Club | Light novels and manga published in English by [J-Novel Club](https://j-novel.club). Reads the volume number from the book's title | `ghcr.io/bookorbit/jnovelclub-provider` |
| Libris | Swedish books from [Libris](https://libris.kb.se), the National Library of Sweden's catalogue | `ghcr.io/bookorbit/libris-provider` |
| LubimyCzytac | Polish books from [lubimyczytac.pl](https://lubimyczytac.pl): titles, authors, series, genres | `ghcr.io/bookorbit/lubimyczytac-provider` |
| MangaBaka | Manga, manhwa, manhua and light novels from [mangabaka.org](https://mangabaka.org). Reads the volume number from the book's title | `ghcr.io/bookorbit/mangabaka-provider` |
Their source, and what each one returns, is in
[bookorbit-metadata-providers](https://github.com/bookorbit/bookorbit-metadata-providers). To make
your own, see [Building a Metadata Provider](BUILDING_METADATA_PROVIDERS.md).
### LubimyCzytac and Aladin used to be built in
Neither is built into BookOrbit any more. Nothing on your books is lost: their ids and links stay
where they were. Field rules that name either one skip it, and **Settings > Metadata > Providers**
says what to do.
- **LubimyCzytac** now runs as an external provider. Run its image and add it as below; field rules
that name it pick up where they left off.
```yaml
services:
lubimyczytac:
image: ghcr.io/bookorbit/lubimyczytac-provider:0.1.0
restart: unless-stopped
```
- **Aladin** is retired. Aladin stopped issuing Open API keys on 4 September 2026 and ends the API on
30 October 2026, with no replacement, so there is no provider to run. Take it out of your field
rules. The Aladin key BookOrbit stored was deleted when it updated.
## Set one up
**Settings > Metadata > Providers** has two tabs: **Built-in** for the sources BookOrbit ships with,
and **External** for the ones you run. Under **Available to install**, the External tab lists the
ready-made providers with a **Copy Compose** button and an **Add** button that fills in the address
for you, so steps 1 to 3 below are mostly done for you.
1. **Run the provider next to BookOrbit.** Add it to the same Docker Compose file, so the two share
a network. It needs no ports published and no volumes:
```yaml
services:
mangabaka:
image: ghcr.io/bookorbit/mangabaka-provider:0.1.0
restart: unless-stopped
```
Start it with `docker compose up -d`.
2. **Add it in BookOrbit.** Open **Settings > Metadata > Providers > External** and choose **Add** on
its card, or **Add provider** for one that is not listed. The address is the service name and
port: `http://mangabaka:8080`.
3. **Turn on Allow private address.** A container beside BookOrbit is on a private network, and
BookOrbit refuses private addresses unless you allow it for that provider. Leave it off for a
provider hosted on the internet.
4. **Save, then choose Test.** A good test shows how many results a sample search found and how
long it took. The provider appears in the list with its own icon.
5. **Turn it on.** It now answers manual searches: open a book, **Edit Metadata > Search**, and its
results show beside the built-in sources.
6. **Let auto-fetch use it.** A new provider is in no field rule yet. Choose **Use in field rules** to
add it to the end of every rule it can fill, or add it to individual rules yourself in
**Settings > Metadata > Field Rules**. Move it up in the rules where it should win: for a manga
library, MangaBaka belongs ahead of the general sources for title, series and cover.
### Tips
- **Pin a version** such as `0.1.0` rather than `latest`, and update on purpose. After updating,
choose **Test** to pick up the new version straight away; otherwise BookOrbit notices within six
hours.
- **Keep it private.** A provider only has to be reachable from BookOrbit. Do not publish its port to
the internet unless you also set its `AUTH_TOKEN` (see its README) and enter the same value in
BookOrbit's **Authorization header** field.
- **Authorization headers are stored encrypted** with `BOOK_REQUEST_ENCRYPTION_KEY`, the same key
book requests use (see `server/.env.example`). If that key changes, the provider shows **Header
unreadable** and is skipped until you enter the header again.
- **More than one at once is fine.** Each provider has its own key, ids and ratings, and BookOrbit
asks each at the pace it asks for.
## What a provider can see
BookOrbit sends a provider the title, author and ISBN of each book it asks about, and the provider's
own id for a book it has already matched. It sends nothing else about your library or your users.
A provider passes those details on to the service it gets its data from, so a provider for a public
site means that site sees which books you look up.
The list under **Available to install** is read from the
[bookorbit-metadata-providers](https://github.com/bookorbit/bookorbit-metadata-providers) repository
on GitHub, at most once a day and only when an admin opens the External tab. To keep BookOrbit from
contacting GitHub for it, set `METADATA_PROVIDER_DIRECTORY_URL=off`; the list that came with your
version is shown instead.
## Status
| Status | What it means |
| ----------------- | --------------------------------------------------------------------------------- |
| Ready | Working, but turned off |
| Active | Working and turned on |
| Last check failed | The six-hourly check failed; it keeps answering with what it last reported |
| Unreachable | Three checks in a row failed; it is skipped until one succeeds. Choose **Test** |
| Key changed | The provider now reports a different identity. Remove it and add it again |
| Incompatible | Something answered at that address, but not a metadata provider BookOrbit can use |
| Header unreadable | The saved authorization header can no longer be decrypted. Enter it again |
## Removing a provider
**Remove provider** keeps the ids and ratings it stored on your books and its place in field rules,
so adding it again later picks up where it left off. **Remove and delete its data** also deletes those
ids, ratings and its cached answers.
## Troubleshooting
- **"That address is on a private network"**: turn on **Allow private address** for it.
- **"Could not reach a provider at that address"**: the address is what BookOrbit, not your browser,
can reach. Inside Docker that is the service name (`http://mangabaka:8080`); `localhost` would be
the BookOrbit container itself. Check the provider is running with `docker compose ps`.
- **"Answered, but not with usable provider info"**: the address is wrong or points at something
else. Open `<address>/provider/info` from the BookOrbit host; a provider answers with JSON that has
a `name`. A missing or extra path in the address is the usual cause.
- **"A provider with the same key is already registered"**: that provider is already added. Each
provider can be added once.
- **Two providers show the same name**: BookOrbit adds the host, such as "FantLab (fantlab:8080)", so
they can be told apart.
- **No results for a book you know is there**: the provider searches by title and author as they are
in BookOrbit. For MangaBaka, a title like "Berserk Vol. 3" works; for FantLab, Russian titles find
more than English ones.
+12 -2
View File
@@ -107,6 +107,10 @@ export enum AuditAction {
RequestIndexerPluginInstall = "request_indexer.plugin.install",
RequestIndexerPluginRemove = "request_indexer.plugin.remove",
MetadataHttpProviderCreate = "metadata_http_provider.create",
MetadataHttpProviderUpdate = "metadata_http_provider.update",
MetadataHttpProviderDelete = "metadata_http_provider.delete",
AuthorUpdate = "author.update",
AuthorDelete = "author.delete",
AuthorMerge = "author.merge",
@@ -167,6 +171,7 @@ export enum AuditResource {
BookRequest = "book_request",
DownloadClient = "download_client",
RequestIndexer = "request_indexer",
MetadataHttpProvider = "metadata_http_provider",
Author = "author",
AppSettings = "app_settings",
Genre = "genre",
@@ -243,7 +248,7 @@ export const AUDIT_CATEGORY_PREFIXES: Record<Exclude<AuditCategory, "other">, re
requests: ["book_request.", "download_client.", "request_indexer."],
collections: ["collection.", "smart_scope."],
devices: ["kobo.", "email."],
settings: ["app_settings.", "entity_manager.", "author.", "maintenance."],
settings: ["app_settings.", "entity_manager.", "author.", "maintenance.", "metadata_http_provider."],
};
export function getAuditCategory(action: string): AuditCategory {
@@ -273,7 +278,12 @@ export const AUDIT_SECURITY_GROUPS = {
AuditAction.UserUnlock,
AuditAction.OidcUserProvisioned,
],
passwords: [AuditAction.AuthPasswordChange, AuditAction.AuthPasswordResetRequest, AuditAction.AuthPasswordReset, AuditAction.AuthPasswordAdminReset],
passwords: [
AuditAction.AuthPasswordChange,
AuditAction.AuthPasswordResetRequest,
AuditAction.AuthPasswordReset,
AuditAction.AuthPasswordAdminReset,
],
permissions: [AuditAction.UserPermissionSet, AuditAction.UserSuperuserEnable, AuditAction.UserSuperuserDisable],
access: [
AuditAction.UserContentFiltersSet,
+2
View File
@@ -65,6 +65,8 @@ export interface BookDockMetadata {
ranobedbId?: string | null;
lubimyczytacId?: string | null;
aladinId?: string | null;
/** Ids from external providers, keyed by provider. Absent on older servers. */
externalProviderIds?: Partial<Record<string, string>> | null;
comicMetadata?: ComicMetadataFields | null;
}
+8
View File
@@ -335,6 +335,12 @@ export type BookDetail = {
coverVersion: string;
hardcoverEditionId: string | null;
providerIds: ProviderIds;
/**
* Record links for external providers' ids, keyed like `providerIds`: the page the id was applied
* from, or the provider's own URL template. Built-in links stay with the client's templates.
* Absent on older servers.
*/
providerLinks?: Partial<Record<string, string>>;
authors: { id: number; name: string; sortName: string | null }[];
genres: string[];
tags: string[];
@@ -398,6 +404,8 @@ export type BookMetadataRefreshPreviewFields = {
ranobedbId?: string | null;
lubimyczytacId?: string | null;
aladinId?: string | null;
/** Ids from external providers, keyed by provider. Absent on older servers. */
externalProviderIds?: Partial<Record<string, string | null>>;
audioMetadata?: {
narrators?: string[];
durationSeconds?: number | null;
+1
View File
@@ -29,6 +29,7 @@ export * from "./format-priority";
export * from "./maintenance";
export * from "./metadata-fetch";
export * from "./metadata-cache";
export * from "./metadata-http-provider";
export * from "./pattern-resolver";
export * from "./query";
export * from "./jump-buckets";
+132 -3
View File
@@ -1,8 +1,13 @@
import type { AudiobookChapter, NarratorRef } from "./audiobook";
import type { CoverMedium } from "./book";
import type { ConcreteBookMediaKind, CoverMedium } from "./book";
import type { MetadataCacheServedProvider, MetadataCandidateCacheInfo } from "./metadata-cache";
import type { SeriesIndex } from "./series-index";
/**
* Every provider key BookOrbit has a dedicated id field for. Most are built in; the ones in
* `MOVED_METADATA_PROVIDERS` are not, and keep their id columns and the API fields released clients
* send.
*/
export const MetadataProviderKey = {
GOOGLE: "google",
GOODREADS: "goodreads",
@@ -31,7 +36,11 @@ export const COMMUNITY_RATING_PROVIDER_KEYS = [
MetadataProviderKey.AUDIBLE,
] as const;
export type CommunityRatingProviderKey = (typeof COMMUNITY_RATING_PROVIDER_KEYS)[number];
/**
* Any provider whose ratings can be filtered on. External providers are named at runtime, so the
* list above only seeds defaults; it is not the set of valid values.
*/
export type CommunityRatingProviderKey = MetadataProviderKey;
export interface ComicMetadataFields {
issueNumber?: string;
@@ -47,7 +56,111 @@ export interface ComicMetadataFields {
storyArcs?: string[];
}
export type MetadataProviderKey = (typeof MetadataProviderKey)[keyof typeof MetadataProviderKey];
/**
* Providers that used to be compiled into BookOrbit. One with an `image` now runs as its own service
* under the same key, so stored ids, ratings and field rules carry over once an admin adds it. One
* without has closed: its ids and links stay on books, and there is nothing to run.
*/
export const MOVED_METADATA_PROVIDERS: Readonly<Record<"lubimyczytac" | "aladin", { label: string; image: string | null }>> = {
lubimyczytac: { label: "LubimyCzytac", image: "ghcr.io/bookorbit/lubimyczytac-provider" },
// Aladin stopped issuing Open API keys on 2026-09-04 and ends the API on 2026-10-30, with no replacement.
aladin: { label: "Aladin", image: null },
};
export type MovedMetadataProviderKey = keyof typeof MOVED_METADATA_PROVIDERS;
/** A key with its own id column and API field, built in or moved out. */
export type KnownMetadataProviderKey = (typeof MetadataProviderKey)[keyof typeof MetadataProviderKey];
/** A provider compiled into BookOrbit. Maps that only make sense for these are keyed by this type. */
export type BuiltInMetadataProviderKey = Exclude<KnownMetadataProviderKey, MovedMetadataProviderKey>;
/**
* A provider an admin registered at runtime: an HTTP service speaking the book-metadata-provider
* spec. Its key is only known once registered, so any string that passes
* `EXTERNAL_METADATA_PROVIDER_KEY_PATTERN` and is not reserved can be one.
*/
export type ExternalMetadataProviderKey = string & { readonly __externalMetadataProviderKey?: never };
/**
* Any provider, built-in or external. A `Record` over this type is not exhaustive (it collapses to a
* string index), so a map that must list every provider is keyed by `BuiltInMetadataProviderKey`
* instead, and everything else is a `Partial` with a fallback. A guard test enforces it.
*/
export type MetadataProviderKey = KnownMetadataProviderKey | ExternalMetadataProviderKey;
/** Display names of the built-in providers, the one list every surface reads. */
export const BUILT_IN_METADATA_PROVIDER_LABELS: Record<BuiltInMetadataProviderKey, string> = {
google: "Google Books",
goodreads: "Goodreads",
amazon: "Amazon",
hardcover: "Hardcover",
openLibrary: "Open Library",
itunes: "iTunes",
audible: "Audible",
audnexus: "AudNexus",
librofm: "Libro.fm",
comicvine: "ComicVine",
ranobedb: "RanobeDB",
kobo: "Kobo",
};
/** At most 32 characters, which fits every column and DTO that carries a provider key. */
export const EXTERNAL_METADATA_PROVIDER_KEY_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
const KNOWN_METADATA_PROVIDER_KEYS: ReadonlySet<string> = new Set(Object.values(MetadataProviderKey));
const BUILT_IN_METADATA_PROVIDER_KEYS: ReadonlySet<string> = new Set(
Object.values(MetadataProviderKey).filter((key) => !(key in MOVED_METADATA_PROVIDERS)),
);
/**
* Keys an external provider may not take: every built-in, the author and cover-search sources, and
* pseudo-providers the clients use for themselves (the iOS editor files auto-fill results under
* `autofill`, Book Dock uses `auto`, the match view `fetched`).
*/
export const RESERVED_METADATA_PROVIDER_KEYS: readonly string[] = [
...BUILT_IN_METADATA_PROVIDER_KEYS,
"openlibrary",
"googlebooks",
"duckduckgo",
"any",
"all",
"none",
"auto",
"autofill",
"fetched",
"file",
"manual",
"custom",
"unknown",
"builtin",
"external",
];
const RESERVED_KEY_SET: ReadonlySet<string> = new Set(RESERVED_METADATA_PROVIDER_KEYS.map((key) => key.toLowerCase()));
export function isBuiltInMetadataProviderKey(key: string): key is BuiltInMetadataProviderKey {
return BUILT_IN_METADATA_PROVIDER_KEYS.has(key);
}
/** Whether the key's ids live in their own column and API field, rather than with other external ids. */
export function isKnownMetadataProviderKey(key: string): key is KnownMetadataProviderKey {
return KNOWN_METADATA_PROVIDER_KEYS.has(key);
}
export function isMovedMetadataProviderKey(key: string): key is MovedMetadataProviderKey {
return Object.prototype.hasOwnProperty.call(MOVED_METADATA_PROVIDERS, key);
}
/** Syntax only: whether `key` could name an external provider. Registration also checks collisions. */
export function isExternalMetadataProviderKeySyntax(key: string): boolean {
return EXTERNAL_METADATA_PROVIDER_KEY_PATTERN.test(key) && !RESERVED_KEY_SET.has(key);
}
/** Whether `key` is a built-in or could name an external provider. Says nothing about registration. */
export function isMetadataProviderKeySyntax(key: unknown): key is MetadataProviderKey {
return typeof key === "string" && (isBuiltInMetadataProviderKey(key) || isExternalMetadataProviderKeySyntax(key));
}
export interface MetadataSeriesMembership {
seriesName: string;
@@ -125,6 +238,11 @@ export interface MetadataCandidate {
comicMetadata?: ComicMetadataFields;
communityRating?: number;
communityRatingCount?: number;
/**
* The medium the provider says this record is, where it says: a light novel and its manga share a
* title, and this tells them apart. Absent from older servers and from providers that do not say.
*/
mediaKind?: ConcreteBookMediaKind;
}
/**
@@ -133,10 +251,21 @@ export interface MetadataCandidate {
*/
export type MetadataSearchCandidateEvent = MetadataCandidate & { cache?: MetadataCandidateCacheInfo };
/** Whether a provider is compiled into BookOrbit or registered at runtime over HTTP. */
export type MetadataProviderSource = "builtin" | "external";
export interface MetadataProviderInfo {
key: MetadataProviderKey;
label: string;
identifiable: boolean;
/** Absent on servers older than external providers. */
source?: MetadataProviderSource;
/**
* An external provider's icon, served by BookOrbit, and its brand colour (`#rrggbb`). Absent for
* built-ins, whose clients bundle their own, and for a provider that declares none.
*/
iconUrl?: string;
color?: string;
selectedByFieldRules?: boolean;
/** Zero-based priority for the effective Cover field rule. Absent when the provider is not used for covers. */
coverPriority?: number;
@@ -0,0 +1,127 @@
import type { ExternalMetadataProviderKey } from "./metadata-fetch";
import type { MetadataField } from "./metadata-preferences";
/**
* An external metadata provider: an HTTP service an admin registered, speaking the community
* book-metadata-provider spec (https://github.com/FlyinPancake/book-metadata-provider) plus the
* optional extensions BookOrbit reads. BookOrbit never runs its code; the spec is the only coupling.
*/
export const METADATA_HTTP_PROVIDER_STATUSES = ["ok", "stale", "unreachable", "key_changed", "incompatible"] as const;
/**
* - `ok`: the last `/provider/info` answered and validated.
* - `stale`: the last refresh failed; the provider keeps serving with the info it had.
* - `unreachable`: several refreshes in a row failed; it is out of searches until one succeeds.
* - `key_changed`: the provider now reports a different key; out of searches until re-registered,
* because its stored ids, ratings and field rules are filed under the old key.
* - `incompatible`: it answered with info BookOrbit cannot use (no usable key, bad types).
*/
export type MetadataHttpProviderStatus = (typeof METADATA_HTTP_PROVIDER_STATUSES)[number];
export type MetadataHttpProviderMediaType = "book" | "audiobook" | "comic";
/** What the provider said about itself, normalized and bounded. Unknown fields are dropped. */
export interface MetadataHttpProviderInfo {
key: ExternalMetadataProviderKey;
name: string;
version: string | null;
specVersion: string;
avatarUrl: string | null;
authenticationRequired: boolean;
mediaTypes: MetadataHttpProviderMediaType[];
lookup: boolean;
/** Field-rule fields it fills, when it says. Null means "every field the spec maps". */
supportedFields: MetadataField[] | null;
searchParams: string[];
idMaxLength: number;
bookUrlTemplate: string | null;
rateLimit: { maxConcurrency: number; minIntervalMs: number };
coverShape: "portrait" | "square" | null;
/** Brand colour as `#rrggbb`. Info stored before colours existed has none. */
color?: string | null;
}
/** Icons BookOrbit downloads from a provider's `avatarUrl` are at most this size. SVG is never accepted. */
export const METADATA_HTTP_PROVIDER_ICON_MAX_BYTES = 256 * 1024;
/** One registered provider as the settings page sees it. The authorization header is never sent back. */
export interface MetadataHttpProviderView {
id: number;
key: ExternalMetadataProviderKey;
baseUrl: string;
hasAuthorization: boolean;
/** A header is saved but no longer decrypts (the encryption key changed), so it is never searched. */
authorizationUnreadable: boolean;
enabled: boolean;
allowPrivateAddress: boolean;
timeoutMs: number;
status: MetadataHttpProviderStatus;
lastError: string | null;
info: MetadataHttpProviderInfo | null;
/** BookOrbit's own copy of the provider's icon, versioned; null when it declares none or it could not be used. */
iconUrl: string | null;
infoFetchedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateMetadataHttpProviderPayload {
baseUrl: string;
/** The full `Authorization` header value, sent as given. */
authorization?: string | null;
allowPrivateAddress?: boolean;
timeoutMs?: number;
}
export interface UpdateMetadataHttpProviderPayload {
baseUrl?: string;
/** A string replaces the stored header, null clears it, absent keeps it. */
authorization?: string | null;
allowPrivateAddress?: boolean;
timeoutMs?: number;
enabled?: boolean;
}
export interface MetadataHttpProviderTestResult {
ok: boolean;
name: string | null;
version: string | null;
latencyMs: number;
resultCount: number | null;
sampleTitle: string | null;
error: string | null;
}
export const METADATA_HTTP_PROVIDER_TIMEOUT_MS = { min: 2_000, default: 10_000, max: 30_000 } as const;
/**
* A ready-made provider an admin can run beside BookOrbit. The list is read from the
* bookorbit-metadata-providers repository, so a provider published after a release still shows up.
*/
export interface MetadataProviderDirectoryEntry {
/** The key it registers under, and the Compose service name the snippet uses. */
key: string;
name: string;
/** One or two sentences, in English: the directory is published content, not app copy. */
summary: string;
/** Without a tag, such as `ghcr.io/bookorbit/mangabaka-provider`. */
image: string;
/** The release to pin in the snippet. */
version: string;
port: number;
docsUrl: string;
homepage: string | null;
color: string | null;
mediaTypes: MetadataHttpProviderMediaType[];
/** BCP 47 codes of the languages its books are mostly in; empty when it is not tied to one. */
languages: string[];
}
export interface MetadataProviderDirectory {
providers: MetadataProviderDirectoryEntry[];
/** `bundled` is the copy shipped with this build: the online list is turned off or unreachable. */
source: "remote" | "bundled";
/** When the online list was last read; null for the bundled copy. */
fetchedAt: string | null;
}
+25 -39
View File
@@ -1,4 +1,4 @@
import { MetadataProviderKey } from "./metadata-fetch";
import { isBuiltInMetadataProviderKey, MetadataProviderKey, type BuiltInMetadataProviderKey, type MetadataProviderSource } from "./metadata-fetch";
export type MetadataField =
| "title"
@@ -128,11 +128,6 @@ export interface KoboProviderConfig {
language: string;
}
export interface AladinProviderConfig {
enabled: boolean;
ttbKey: string;
}
export interface ProviderConfigurations {
google: GoogleProviderConfig;
amazon: AmazonProviderConfig;
@@ -146,8 +141,6 @@ export interface ProviderConfigurations {
comicvine: ComicVineProviderConfig;
ranobedb: SimpleProviderConfig;
kobo: KoboProviderConfig;
lubimyczytac: SimpleProviderConfig;
aladin: AladinProviderConfig;
}
export interface ProviderStatus {
@@ -156,6 +149,18 @@ export interface ProviderStatus {
configured: boolean;
enabled: boolean;
hint?: string;
/** Absent on servers older than external providers, which only had built-ins. */
source?: MetadataProviderSource;
/** An external provider's declared fields; built-ins use `METADATA_PROVIDER_SUPPORTED_FIELDS`. */
supportedFields?: MetadataField[] | null;
/** An external provider's icon and colour, as on `MetadataProviderInfo`. */
iconUrl?: string;
color?: string;
/**
* Set for a provider that used to be built in and is not registered: the image to run, or null when
* the source closed and there is none, and whether field rules or stored ids still use it.
*/
movedOut?: { image: string | null; inUse: boolean };
}
export type ProviderConnectionTestStatus = "success" | "warning" | "fail";
@@ -176,7 +181,7 @@ export interface ProviderConnectionTestResult {
* The two cover rules list a provider only where its art can be that slot's shape. Audible's art is
* always square, so it is not under `cover`; it still fills an empty ebook slot as a last resort.
*/
export const METADATA_PROVIDER_SUPPORTED_FIELDS: Record<MetadataProviderKey, readonly MetadataField[]> = {
export const METADATA_PROVIDER_SUPPORTED_FIELDS: Record<BuiltInMetadataProviderKey, readonly MetadataField[]> = {
[MetadataProviderKey.GOOGLE]: [
"title",
"subtitle",
@@ -347,41 +352,22 @@ export const METADATA_PROVIDER_SUPPORTED_FIELDS: Record<MetadataProviderKey, rea
"seriesIndex",
"genres",
],
[MetadataProviderKey.LUBIMYCZYTAC]: [
"title",
"description",
"cover",
"authors",
"publisher",
"isbn",
"publishedYear",
"language",
"pageCount",
"seriesName",
"seriesIndex",
"genres",
],
[MetadataProviderKey.ALADIN]: [
"title",
"description",
"cover",
"authors",
"publisher",
"isbn",
"publishedYear",
"language",
"pageCount",
"seriesName",
"genres",
],
};
/** How a provider stands against one field: it can fill it, its cover art is the other shape, or never. */
export type ProviderFieldSupport = "supplies" | "otherShape" | "never";
export function providerFieldSupport(provider: MetadataProviderKey, field: MetadataField): ProviderFieldSupport {
const fields = METADATA_PROVIDER_SUPPORTED_FIELDS[provider];
// An unknown key is stale data, and a hint about it would be a guess.
/**
* `declaredFields` is what an external provider says it fills; built-ins are answered from
* `METADATA_PROVIDER_SUPPORTED_FIELDS`.
*/
export function providerFieldSupport(
provider: MetadataProviderKey,
field: MetadataField,
declaredFields?: readonly MetadataField[] | null,
): ProviderFieldSupport {
const fields = declaredFields ?? (isBuiltInMetadataProviderKey(provider) ? METADATA_PROVIDER_SUPPORTED_FIELDS[provider] : undefined);
// An unknown key, or a provider that did not say, would only get a guess.
if (!fields || fields.includes(field)) return "supplies";
if (field === "cover" || field === "audioCover") return "otherShape";
return "never";
+25 -1
View File
@@ -255,6 +255,29 @@ export const E2E_SUITES = Object.freeze({
...SHARED_DB_AND_HELPER_PATHS,
],
},
"metadata-http-providers": {
id: "metadata-http-providers",
name: "External Metadata Providers",
timeout: 90,
lane: "full",
description: "External HTTP metadata providers: registration, search, refresh, ids, ratings and purge",
vitestTarget: "test/metadata-http-providers.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/metadata-http-providers-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
changedPaths: [
"server/src/modules/metadata-http-provider/**",
"server/src/modules/metadata-fetch/**",
"server/src/modules/metadata-preferences/**",
"server/src/modules/book/**",
"server/src/modules/book-dock/**",
"server/src/modules/book-metadata-fetch/**",
"server/test/metadata-http-providers.e2e-spec.ts",
".github/workflows/e2e-runner.yml",
...SHARED_TYPES_PATHS,
...SHARED_DB_AND_HELPER_PATHS,
],
},
"metadata-lock": {
id: "metadata-lock",
name: "Metadata Lock",
@@ -1060,7 +1083,8 @@ export const E2E_SUITES = Object.freeze({
name: "Read-Aloud Jobs",
timeout: 60,
lane: "full",
description: "Read-along generation lifecycle against a fake worker: publish, review and re-sync, replace in place, ownership, retry, cancel, resume",
description:
"Read-along generation lifecycle against a fake worker: publish, review and re-sync, replace in place, ownership, retry, cancel, resume",
vitestTarget: "test/read-aloud-jobs.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/read-aloud-jobs-e2e-junit.xml`,
prepareDedicatedDatabase: true,
+5
View File
@@ -129,6 +129,11 @@ APP_URL=http://localhost:6263
# Generate with: openssl rand -hex 32
# BOOK_REQUEST_ENCRYPTION_KEY=
# List of ready-made metadata providers shown under Settings > Metadata > Providers > External.
# BookOrbit reads it from GitHub at most once a day, only when an admin opens that tab. Set off to
# never contact GitHub for it; the list that came with this release is shown instead.
# METADATA_PROVIDER_DIRECTORY_URL=off
# Server-side root containing migration backup files (optional).
# Use a dedicated read-only mount. Backup paths submitted through the migration API
# must be absolute and resolve inside this root.
+2
View File
@@ -50,6 +50,7 @@ import { OpdsModule } from './modules/opds/opds.module';
import { PathModule } from './modules/path/path.module';
import { MetadataFetchModule } from './modules/metadata-fetch/metadata-fetch.module';
import { MetadataPreferencesModule } from './modules/metadata-preferences/metadata-preferences.module';
import { MetadataHttpProviderModule } from './modules/metadata-http-provider/metadata-http-provider.module';
import { MetadataModule } from './modules/metadata/metadata.module';
import { NarratorModule } from './modules/narrator/narrator.module';
import { ReaderPreferencesModule } from './modules/reader-preferences/reader-preferences.module';
@@ -163,6 +164,7 @@ import { ReadAloudModule } from './modules/read-aloud/read-aloud.module';
UploadModule,
MetadataFetchModule,
MetadataPreferencesModule,
MetadataHttpProviderModule,
CustomMetadataModule,
CustomIconModule,
RecommendationModule,
@@ -0,0 +1,35 @@
import { buildMessage, ValidateBy, type ValidationOptions } from 'class-validator';
import { isExternalMetadataProviderKeySyntax, isKnownMetadataProviderKey } from '@bookorbit/types';
const MAX_ENTRIES = 50;
/**
* A map from external provider key to a string of at most `maxLength` characters, or null to
* clear it. Keys with a column of their own are refused: their ids have named fields, and one
* value must not arrive two ways.
*/
export function IsExternalProviderMap(maxLength: number, options?: ValidationOptions): PropertyDecorator {
return ValidateBy(
{
name: 'isExternalProviderMap',
constraints: [maxLength],
validator: {
validate: (value: unknown) => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const entries = Object.entries(value as Record<string, unknown>);
return (
entries.length <= MAX_ENTRIES &&
entries.every(
([key, entry]) =>
isExternalMetadataProviderKeySyntax(key) &&
!isKnownMetadataProviderKey(key) &&
(entry === null || (typeof entry === 'string' && entry.length <= maxLength)),
)
);
},
defaultMessage: buildMessage(() => '$property must map external provider keys to strings or null', options),
},
},
options,
);
}
@@ -0,0 +1,20 @@
import { buildMessage, ValidateBy, type ValidationOptions } from 'class-validator';
import { isMetadataProviderKeySyntax } from '@bookorbit/types';
/**
* A built-in provider key, or one an external provider could have. Syntax only: whether the
* provider is registered is a runtime question the service answers, and an older client re-sending
* a provider that has since been removed must not get a 400 for the whole request.
*/
export function IsMetadataProviderKey(options?: ValidationOptions): PropertyDecorator {
return ValidateBy(
{
name: 'isMetadataProviderKey',
validator: {
validate: (value: unknown) => isMetadataProviderKeySyntax(value),
defaultMessage: buildMessage((eachPrefix) => `${eachPrefix}$property must be a metadata provider key`, options),
},
},
options,
);
}
@@ -0,0 +1,28 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const KEY_HEX_LENGTH = 64;
/** A 64-character hex string as a 32-byte key, or null when it is unset or malformed. */
export function parseAesGcmKey(raw: string | undefined | null): Buffer | null {
const value = raw ?? '';
return value.length === KEY_HEX_LENGTH && /^[0-9a-f]+$/i.test(value) ? Buffer.from(value, 'hex') : null;
}
/** `iv | tag | ciphertext`, base64. */
export function encryptAesGcm(key: Buffer, plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64');
}
/** Throws when the blob was written with another key or has been tampered with. */
export function decryptAesGcm(key: Buffer, ciphertext: string): string {
const buf = Buffer.from(ciphertext, 'base64');
const decipher = createDecipheriv(ALGORITHM, key, buf.subarray(0, IV_LENGTH));
decipher.setAuthTag(buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH));
return Buffer.concat([decipher.update(buf.subarray(IV_LENGTH + TAG_LENGTH)), decipher.final()]).toString('utf8');
}
@@ -1,6 +1,6 @@
import { isAudioFormat } from '@bookorbit/types';
import { compareAudioTracks, planAudioTrackOrderRepair, resolveIsAudiobook } from './book-media.utils';
import { compareAudioTracks, planAudioTrackOrderRepair, resolveIsAudiobook, resolveSearchMediaKind } from './book-media.utils';
describe('resolveIsAudiobook', () => {
it('uses the primary file when the book has files', () => {
@@ -39,6 +39,26 @@ describe('resolveIsAudiobook', () => {
});
});
describe('resolveSearchMediaKind', () => {
const epub = { id: 1, format: 'epub', role: 'content' };
const cbz = { id: 2, format: 'cbz', role: 'content' };
const m4b = { id: 3, format: 'm4b', role: 'content' };
const cover = { id: 4, format: 'jpg', role: 'cover' };
it('is the audiobook for the audio side, and the reading medium otherwise', () => {
expect(resolveSearchMediaKind([epub, m4b], true, 3)).toBe('audiobook');
expect(resolveSearchMediaKind([epub, m4b], false, 3)).toBe('ebook');
expect(resolveSearchMediaKind([cbz, cover], false, 2)).toBe('comic');
});
it('reads the files when the primary is audio or missing, and ignores covers', () => {
expect(resolveSearchMediaKind([m4b, cbz], false, 3)).toBe('comic');
expect(resolveSearchMediaKind([cover, cbz], false)).toBe('comic');
expect(resolveSearchMediaKind([cover], false)).toBeUndefined();
expect(resolveSearchMediaKind(undefined, false)).toBeUndefined();
});
});
describe('planAudioTrackOrderRepair', () => {
type Row = { id: number; absolutePath: string; format: string | null; sortOrder: number | null };
+32 -1
View File
@@ -1,4 +1,11 @@
import { getBookMediaKind, getBookMediaProfile, isAudioFormat, type CoverMedia, type CoverMedium } from '@bookorbit/types';
import {
getBookMediaKind,
getBookMediaProfile,
isAudioFormat,
type ConcreteBookMediaKind,
type CoverMedia,
type CoverMedium,
} from '@bookorbit/types';
import { compareBookFilePaths } from './audio-track-order.utils';
@@ -36,6 +43,30 @@ export function resolveIsAudiobook(
return meta?.durationSeconds != null || !!meta?.audibleId || !!meta?.librofmId;
}
/**
* What the book is read as, leaving any audio aside: a comic when its readable files are comics, an
* ebook otherwise. A manga in EPUB is an ebook here, since only its format is known. Undefined when
* no readable file states a format.
*/
export function resolveTextMediaKind(files: readonly BookMediaFileRow[] | undefined, primaryFileId?: number | null): 'ebook' | 'comic' | undefined {
const primary = primaryFileId == null ? undefined : files?.find((file) => file.id === primaryFileId);
const primaryKind = primary ? getBookMediaKind(primary.format) : undefined;
if (primaryKind === 'ebook' || primaryKind === 'comic') return primaryKind;
const kinds = (files ?? []).filter((file) => file.role === 'content' || file.role === 'primary').map((file) => getBookMediaKind(file.format));
if (kinds.includes('ebook')) return 'ebook';
if (kinds.includes('comic')) return 'comic';
return undefined;
}
/** The medium a metadata search is run as: the audiobook when it is for the audio side, else the book's reading medium. */
export function resolveSearchMediaKind(
files: readonly BookMediaFileRow[] | undefined,
isAudiobook: boolean,
primaryFileId?: number | null,
): ConcreteBookMediaKind | undefined {
return isAudiobook ? 'audiobook' : resolveTextMediaKind(files, primaryFileId);
}
/** The pipeline's view of the slots: each is an existing field when filled, and locked slots are skipped. */
export function coverFetchInputs(state: CoverFetchState): {
existing: { cover: true | null; audioCover: true | null };
+4
View File
@@ -24,6 +24,7 @@ function resetEnv(): void {
delete process.env.SWAGGER_ENABLED;
delete process.env.KOBO_CLOUDSCRAPER_PYTHON;
delete process.env.KOREADER_PLUGIN_PATH;
delete process.env.METADATA_PROVIDER_DIRECTORY_URL;
delete process.env.DATABASE_URL;
delete process.env.JWT_SECRET;
delete process.env.JWT_EXPIRES_IN;
@@ -69,6 +70,7 @@ describe('config', () => {
version: 'Local build',
githubReleasesRepo: 'bookorbit/bookorbit',
githubReleasesToken: undefined,
metadataProviderDirectoryUrl: 'https://raw.githubusercontent.com/bookorbit/bookorbit-metadata-providers/main/directory.json',
oidcAllowLocalIssuers: false,
swaggerEnabled: false,
koboCloudscraperPython: undefined,
@@ -88,6 +90,7 @@ describe('config', () => {
process.env.GITHUB_RELEASES_REPO = 'acme/app';
process.env.GITHUB_RELEASES_TOKEN = 'ghp_example';
process.env.NATIVE_REDIRECT_URI = 'myfork://oauth2-callback';
process.env.METADATA_PROVIDER_DIRECTORY_URL = ' OFF ';
expect(appConfig()).toEqual({
nodeEnv: 'production',
@@ -97,6 +100,7 @@ describe('config', () => {
version: 'v2.3.4',
githubReleasesRepo: 'acme/app',
githubReleasesToken: 'ghp_example',
metadataProviderDirectoryUrl: null,
oidcAllowLocalIssuers: true,
swaggerEnabled: true,
koboCloudscraperPython: '/opt/bookorbit-python/bin/python',
+10
View File
@@ -13,6 +13,7 @@ export const appConfig = registerAs('app', () => ({
version: process.env.APP_VERSION ?? 'Local build',
githubReleasesRepo: process.env.GITHUB_RELEASES_REPO?.trim() || 'bookorbit/bookorbit',
githubReleasesToken: process.env.GITHUB_RELEASES_TOKEN?.trim() || undefined,
metadataProviderDirectoryUrl: parseDirectoryUrl(process.env.METADATA_PROVIDER_DIRECTORY_URL),
oidcAllowLocalIssuers: parseBooleanFlag(process.env.OIDC_ALLOW_LOCAL_ISSUERS, false),
swaggerEnabled: parseBooleanFlag(process.env.SWAGGER_ENABLED, false),
koboCloudscraperPython: process.env.KOBO_CLOUDSCRAPER_PYTHON?.trim() || undefined,
@@ -134,3 +135,12 @@ function parseBooleanFlag(value: string | undefined, fallback: boolean): boolean
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
const DEFAULT_METADATA_PROVIDER_DIRECTORY_URL = 'https://raw.githubusercontent.com/bookorbit/bookorbit-metadata-providers/main/directory.json';
/** Null when the online list of ready-made metadata providers is turned off. */
function parseDirectoryUrl(value: string | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) return DEFAULT_METADATA_PROVIDER_DIRECTORY_URL;
return trimmed.toLowerCase() === 'off' ? null : trimmed;
}
+9
View File
@@ -222,6 +222,15 @@ describe('validateEnv', () => {
expect(() => validateEnv({ ...PRODUCTION_ENV, PODCAST_ENCRYPTION_KEY: '1234567890abcdef' })).not.toThrow();
});
it('accepts the provider directory URL only as https or off', () => {
for (const value of ['', 'off', 'OFF', 'https://example.com/directory.json']) {
expect(() => validateEnv({ ...BASE_ENV, METADATA_PROVIDER_DIRECTORY_URL: value })).not.toThrow();
}
expect(() => validateEnv({ ...BASE_ENV, METADATA_PROVIDER_DIRECTORY_URL: 'http://example.com/directory.json' })).toThrow(
'METADATA_PROVIDER_DIRECTORY_URL',
);
});
describe('read-aloud worker settings', () => {
const TOKEN = '1234567890abcdef';
+8
View File
@@ -174,6 +174,14 @@ const envSchema = z.object({
.string()
.transform((val) => val.trim())
.optional(),
METADATA_PROVIDER_DIRECTORY_URL: z
.string()
.trim()
.refine(
(value) => value === '' || value.toLowerCase() === 'off' || /^https:\/\//i.test(value),
'METADATA_PROVIDER_DIRECTORY_URL must be an https URL or off',
)
.optional(),
KOREADER_PLUGIN_PATH: z
.string()
.transform((val) => val.trim())
@@ -0,0 +1,34 @@
CREATE TABLE "book_provider_ids" (
"book_id" integer NOT NULL,
"provider" varchar(32) NOT NULL,
"external_id" varchar(512) NOT NULL,
"source_url" varchar(2048),
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "book_provider_ids_book_id_provider_pk" PRIMARY KEY("book_id","provider")
);
--> statement-breakpoint
CREATE TABLE "metadata_http_providers" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar(32) NOT NULL,
"base_url" text NOT NULL,
"authorization_enc" text,
"enabled" boolean DEFAULT false NOT NULL,
"allow_private_address" boolean DEFAULT false NOT NULL,
"timeout_ms" integer DEFAULT 10000 NOT NULL,
"info" jsonb,
"info_fetched_at" timestamp with time zone,
"status" varchar(20) DEFAULT 'ok' NOT NULL,
"consecutive_failures" integer DEFAULT 0 NOT NULL,
"last_error" text,
"created_by" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "metadata_http_providers_timeout_chk" CHECK ("metadata_http_providers"."timeout_ms" >= 2000 and "metadata_http_providers"."timeout_ms" <= 30000),
CONSTRAINT "metadata_http_providers_status_chk" CHECK ("metadata_http_providers"."status" in ('ok', 'stale', 'unreachable', 'key_changed', 'incompatible')),
CONSTRAINT "metadata_http_providers_failures_chk" CHECK ("metadata_http_providers"."consecutive_failures" >= 0)
);
--> statement-breakpoint
ALTER TABLE "book_provider_ids" ADD CONSTRAINT "book_provider_ids_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "metadata_http_providers" ADD CONSTRAINT "metadata_http_providers_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "book_provider_ids_provider_external_id_idx" ON "book_provider_ids" USING btree ("provider","external_id");--> statement-breakpoint
CREATE UNIQUE INDEX "metadata_http_providers_key_uq" ON "metadata_http_providers" USING btree ("key");
@@ -0,0 +1,10 @@
CREATE TABLE "metadata_http_provider_icons" (
"provider_id" integer PRIMARY KEY NOT NULL,
"source_url" text NOT NULL,
"content_type" varchar(32) NOT NULL,
"hash" varchar(16) NOT NULL,
"data" "bytea" NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "metadata_http_provider_icons" ADD CONSTRAINT "metadata_http_provider_icons_provider_id_metadata_http_providers_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."metadata_http_providers"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -904,6 +904,20 @@
"when": 1792649881231,
"tag": "0128_add_reading_history",
"breakpoints": true
},
{
"idx": 129,
"version": "7",
"when": 1792693992005,
"tag": "0129_add_external_metadata_providers",
"breakpoints": true
},
{
"idx": 130,
"version": "7",
"when": 1792705454131,
"tag": "0130_add_metadata_provider_icons",
"breakpoints": true
}
]
}
+5
View File
@@ -10,6 +10,11 @@ import { parsePgTimestamptz } from '../../common/utils/pg-timestamp.utils';
* See `parsePgTimestamptz`. Columns the application stamps itself (created/updated marks,
* job timestamps) are always near the present, so plain `timestamp()` remains fine there.
*/
/** `bytea`, read and written as a Buffer, which is what node-postgres gives and takes. */
export const bytea = customType<{ data: Buffer; driverData: Buffer }>({
dataType: () => 'bytea',
});
export const timestamptz = customType<{ data: Date; driverData: string }>({
dataType: () => 'timestamp with time zone',
toDriver: (value) => value.toISOString(),
+1
View File
@@ -45,5 +45,6 @@ export * from './tts';
export * from './upload-sessions';
export * from './podcasts';
export * from './metadata-cache';
export * from './metadata-http-providers';
export * from './delivery';
export * from './server-identity';
@@ -0,0 +1,74 @@
import { sql } from 'drizzle-orm';
import { boolean, check, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import type { MetadataHttpProviderInfo, MetadataHttpProviderStatus } from '@bookorbit/types';
import { users } from './auth';
import { bytea } from './columns';
/**
* Metadata providers an admin registered by URL. Each one is somebody else's HTTP service speaking
* the book-metadata-provider spec; BookOrbit stores only how to reach it and what it said about
* itself. The key is pinned from the provider's own `/provider/info` on registration and never
* changes, because stored ids, ratings and field rules are filed under it.
*/
export const metadataHttpProviders = pgTable(
'metadata_http_providers',
{
id: serial('id').primaryKey(),
key: varchar('key', { length: 32 }).notNull(),
baseUrl: text('base_url').notNull(),
/** AES-256-GCM blob of the whole `Authorization` header. Never returned, never logged. */
authorizationEnc: text('authorization_enc'),
/** Off until the admin turns it on, so a provider is never searched before it has been seen. */
enabled: boolean('enabled').notNull().default(false),
/**
* A provider in a container beside BookOrbit lives on a private address by definition, so this
* is an explicit per-row opt-in rather than a default, the same stance as indexers.
*/
allowPrivateAddress: boolean('allow_private_address').notNull().default(false),
timeoutMs: integer('timeout_ms').notNull().default(10_000),
info: jsonb('info').$type<MetadataHttpProviderInfo>(),
infoFetchedAt: timestamp('info_fetched_at', { withTimezone: true }),
status: varchar('status', { length: 20 }).$type<MetadataHttpProviderStatus>().notNull().default('ok'),
consecutiveFailures: integer('consecutive_failures').notNull().default(0),
lastError: text('last_error'),
createdBy: integer('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
uniqueIndex('metadata_http_providers_key_uq').on(t.key),
check('metadata_http_providers_timeout_chk', sql`${t.timeoutMs} >= 2000 and ${t.timeoutMs} <= 30000`),
check('metadata_http_providers_status_chk', sql`${t.status} in ('ok', 'stale', 'unreachable', 'key_changed', 'incompatible')`),
check('metadata_http_providers_failures_chk', sql`${t.consecutiveFailures} >= 0`),
],
);
export type MetadataHttpProviderRow = typeof metadataHttpProviders.$inferSelect;
export type NewMetadataHttpProviderRow = typeof metadataHttpProviders.$inferInsert;
/**
* BookOrbit's own copy of each provider's icon, downloaded from its `avatarUrl`. Browsers load it
* from BookOrbit, so they never contact the provider, which may sit on a network they cannot reach.
* Kept apart from the provider row so listing providers never reads the image bytes.
*/
export const metadataHttpProviderIcons = pgTable('metadata_http_provider_icons', {
providerId: integer('provider_id')
.primaryKey()
.references(() => metadataHttpProviders.id, { onDelete: 'cascade' }),
/** The `avatarUrl` it came from, so a changed URL is fetched again. */
sourceUrl: text('source_url').notNull(),
contentType: varchar('content_type', { length: 32 }).notNull(),
/** Short content hash, also the cache-busting version in the icon's URL. */
hash: varchar('hash', { length: 16 }).notNull(),
data: bytea('data').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
});
export type MetadataHttpProviderIconRow = typeof metadataHttpProviderIcons.$inferSelect;
+23
View File
@@ -187,6 +187,29 @@ export const bookCommunityRatings = pgTable(
],
);
/**
* Ids from external metadata providers, one row per book and provider. Built-in providers keep
* their own columns on `book_metadata`; any provider registered at runtime lands here, so a new one
* needs no column. Rows outlive their provider's registration, so re-adding it restores them.
*/
export const bookProviderIds = pgTable(
'book_provider_ids',
{
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
provider: varchar('provider', { length: 32 }).notNull(),
externalId: varchar('external_id', { length: 512 }).notNull(),
/** The record page the id was applied from, for a link on the book page. */
sourceUrl: varchar('source_url', { length: 2048 }),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [primaryKey({ columns: [t.bookId, t.provider] }), index('book_provider_ids_provider_external_id_idx').on(t.provider, t.externalId)],
);
export const authors = pgTable(
'authors',
{
@@ -34,6 +34,7 @@ import {
formatKeyRank,
isAudioFormat,
MetadataProviderKey,
isMetadataProviderKeySyntax,
normalizeFormatPriority,
NotificationType,
parseSeriesIndex,
@@ -42,6 +43,7 @@ import {
} from '@bookorbit/types';
import type { BookRequestImportFormats, CoverMedium } from '@bookorbit/types';
import type { FileRole } from '../scanner/lib/classify';
import { ProviderIdStore } from '../metadata-fetch/provider-ids/provider-id.store';
import { BookReadService } from '../book/book-read.service';
import { NotificationService } from '../notification/notification.service';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
@@ -103,7 +105,6 @@ const PUBLISHED_YEAR_RANGE_CONSTRAINT = 'book_metadata_published_year_range_chk'
const INVALID_PUBLISHED_YEAR_MESSAGE = `Invalid metadata: published year must be between ${MIN_PUBLISHED_YEAR} and ${MAX_PUBLISHED_YEAR}.`;
const INVALID_METADATA_MESSAGE = 'Invalid metadata values for this file. Review metadata fields and try again.';
const INTERNAL_FAILURE_MESSAGE = 'Filing this book failed inside BookOrbit. Check the server log for the cause.';
const METADATA_PROVIDER_KEYS = new Set<MetadataProviderKey>(Object.values(MetadataProviderKey));
type NormalizedFinalizeMetadata = {
title: string | null;
@@ -137,6 +138,7 @@ type NormalizedFinalizeMetadata = {
aladinId: string | null;
seriesMemberships: MetadataSeriesMembership[] | undefined;
communityRatings: Array<{ provider: MetadataProviderKey; rating: number; ratingCount: number | null }> | undefined;
externalProviderIds: Partial<Record<string, string>> | undefined;
comicMetadata: ComicMetadataFields | undefined;
};
@@ -256,6 +258,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
private readonly fileWriteService: FileWriteService,
@Optional() private readonly seriesIdentity?: SeriesIdentityService,
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
@Optional() private readonly providerIdStore?: ProviderIdStore,
) {
this.autoFinalizeQueue = new BookDockWorkQueue(
AUTO_FINALIZE_QUEUE_CONCURRENCY,
@@ -1410,6 +1413,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
await this.seriesMemberships?.syncPrimaryFromMetadata(bookId);
}
if (meta.externalProviderIds) await this.providerIdStore?.write(bookId, meta.externalProviderIds);
if (meta.communityRatings !== undefined) {
await this.bookReadService.replaceCommunityRatings(bookId, meta.communityRatings);
}
@@ -1637,6 +1641,7 @@ function normalizeFinalizeMetadata(meta: BookDockMetadata | null | undefined): N
aladinId: normalizeText(normalizedMeta?.aladinId, 20),
seriesMemberships: normalizeSeriesMemberships(normalizedMeta?.seriesMemberships),
communityRatings: normalizeCommunityRatings(normalizedMeta?.communityRatings),
externalProviderIds: normalizedMeta?.externalProviderIds ?? undefined,
comicMetadata: normalizeComicMetadata(normalizedMeta?.comicMetadata),
};
}
@@ -1666,7 +1671,7 @@ function normalizeCommunityRatings(
const ratings = new Map<MetadataProviderKey, { provider: MetadataProviderKey; rating: number; ratingCount: number | null }>();
for (const item of value) {
if (!item || !METADATA_PROVIDER_KEYS.has(item.provider)) continue;
if (!item || !isMetadataProviderKeySyntax(item.provider)) continue;
if (!Number.isFinite(item.rating) || item.rating < 0 || item.rating > 5) continue;
const ratingCount = Number.isInteger(item.ratingCount) && item.ratingCount! >= 0 ? item.ratingCount! : null;
ratings.set(item.provider, { provider: item.provider, rating: item.rating, ratingCount });
@@ -6,6 +6,7 @@ import { Readable } from 'stream';
import { isAudioFormat, MetadataProviderKey, resolveBookDockSearchTitle, type BookDockMetadata } from '@bookorbit/types';
import type { BookDockFileRow } from '../../db/schema';
import { resolveSearchMediaKind } from '../../common/utils/book-media.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { canonicalIsbn13, normalizeMetadataIsbn } from '../../common/text-match/isbn-normalize';
import { waitForDirectoryStability } from '../../common/utils/fs-stability.utils';
@@ -13,6 +14,7 @@ import { SUPPORTED_BOOK_FORMATS, UploadValidatorService } from '../upload/upload
import { UploadStorageService } from '../upload/upload-storage.service';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { MetadataFetchPipeline } from '../metadata-fetch/metadata-fetch-pipeline';
import { isExternalProviderIdKey } from '../metadata-fetch/provider-ids/provider-id.store';
import { inspectEpubMediaOverlayFields } from '../reader/epub/epub-media-overlay-capability';
import { interpretRelease } from '../scanner/lib/release-plan';
import { buildSingleBookCandidate } from '../scanner/lib/walk';
@@ -373,6 +375,7 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
author: meta?.authors?.[0] ?? undefined,
isbn: meta?.isbn13?.trim() || meta?.isbn10?.trim() || undefined,
isAudiobook: row.format != null && isAudioFormat(row.format),
mediaKind: resolveSearchMediaKind([{ format: row.format, role: 'content' }], row.format != null && isAudioFormat(row.format)),
};
if (!params.title && !params.isbn) return;
// The same field rules the book will be refreshed by once it is on a shelf: the library it is
@@ -413,10 +416,15 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
delete sources.audioCoverUrl;
const fetched = normalizeBookDockMetadata(resolved) ?? {};
for (const [provider, providerId] of Object.entries(providerIds)) {
if (!providerId) continue;
const field = BOOK_DOCK_PROVIDER_ID_FIELDS[provider as MetadataProviderKey];
if (!field || !providerId) continue;
(fetched as Record<string, unknown>)[field] = providerId;
sources[field] = provider;
if (field) {
(fetched as Record<string, unknown>)[field] = providerId;
sources[field] = provider;
} else if (isExternalProviderIdKey(provider)) {
// No column to name: an external provider's id rides along until the book is filed.
fetched.externalProviderIds = { ...fetched.externalProviderIds, [provider]: providerId };
}
}
const fetchedMetadataSources = normalizeBookDockMetadataSources(sources);
const updates =
@@ -1,4 +1,11 @@
import { MetadataProviderKey, parseSeriesIndex, type BookDockMetadata } from '@bookorbit/types';
import {
isExternalMetadataProviderKeySyntax,
isKnownMetadataProviderKey,
isMetadataProviderKeySyntax,
parseSeriesIndex,
type BookDockMetadata,
type MetadataProviderKey,
} from '@bookorbit/types';
const PASSTHROUGH_FIELDS = [
'title',
@@ -39,9 +46,9 @@ const BOOK_DOCK_METADATA_FIELDS = new Set<keyof BookDockMetadata>([
'chapters',
'seriesMemberships',
'communityRatings',
'externalProviderIds',
'comicMetadata',
]);
const METADATA_PROVIDER_KEYS = new Set<string>(Object.values(MetadataProviderKey));
export function normalizeBookDockMetadata(value: unknown): BookDockMetadata | null {
if (!isRecord(value)) return null;
@@ -60,6 +67,7 @@ export function normalizeBookDockMetadata(value: unknown): BookDockMetadata | nu
copyChapters(value, normalized);
copySeriesMemberships(value, normalized);
copyCommunityRatings(value, normalized);
copyExternalProviderIds(value, normalized);
copyComicMetadata(value, normalized);
return normalized;
@@ -116,6 +124,24 @@ function copySeriesMemberships(source: Record<string, unknown>, target: BookDock
});
}
/** External provider ids, kept only where the key could name one and the id is a bounded string. */
function copyExternalProviderIds(source: Record<string, unknown>, target: BookDockMetadata): void {
if (source.externalProviderIds === undefined) return;
if (!isRecord(source.externalProviderIds)) {
target.externalProviderIds = null;
return;
}
const entries = Object.entries(source.externalProviderIds).filter(
([key, id]) =>
!isKnownMetadataProviderKey(key) &&
isExternalMetadataProviderKeySyntax(key) &&
typeof id === 'string' &&
id.trim().length > 0 &&
id.length <= 512,
);
target.externalProviderIds = entries.length > 0 ? (Object.fromEntries(entries.slice(0, 50)) as Record<string, string>) : null;
}
function copyCommunityRatings(source: Record<string, unknown>, target: BookDockMetadata): void {
if (source.communityRatings === undefined) return;
if (source.communityRatings === null) {
@@ -128,7 +154,7 @@ function copyCommunityRatings(source: Record<string, unknown>, target: BookDockM
if (
!isRecord(value) ||
typeof value.provider !== 'string' ||
!METADATA_PROVIDER_KEYS.has(value.provider) ||
!isMetadataProviderKeySyntax(value.provider) ||
typeof value.rating !== 'number' ||
!Number.isFinite(value.rating) ||
value.rating < 0 ||
+5 -3
View File
@@ -1,7 +1,6 @@
import {
IsArray,
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsNumber,
@@ -21,7 +20,9 @@ import {
ValidatorConstraint,
} from 'class-validator';
import { Type } from 'class-transformer';
import { MetadataProviderKey, SERIES_INDEX_MAX_LENGTH, SERIES_INDEX_PATTERN, type SeriesIndex } from '@bookorbit/types';
import { SERIES_INDEX_MAX_LENGTH, SERIES_INDEX_PATTERN, type MetadataProviderKey, type SeriesIndex } from '@bookorbit/types';
import { IsExternalProviderMap } from '../../../common/decorators/is-external-provider-map.decorator';
import { IsMetadataProviderKey } from '../../../common/decorators/is-metadata-provider-key.decorator';
import { NormalizeIdentifier } from '../../../common/decorators/normalize-identifier.decorator';
@ValidatorConstraint({ name: 'hasCompleteDefaultDestination', async: false })
@@ -62,7 +63,7 @@ class BookDockSeriesMembershipDto {
}
class BookDockCommunityRatingDto {
@IsIn(Object.values(MetadataProviderKey)) provider!: MetadataProviderKey;
@IsMetadataProviderKey() provider!: MetadataProviderKey;
@IsNumber() @Min(0) @Max(5) rating!: number;
@IsOptional() @IsInt() @Min(0) ratingCount?: number | null;
}
@@ -125,6 +126,7 @@ export class BookDockMetadataFieldsDto {
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(50) ranobedbId?: string | null;
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(512) lubimyczytacId?: string | null;
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(20) aladinId?: string | null;
@IsOptional() @IsExternalProviderMap(512) externalProviderIds?: Record<string, string> | null;
@IsOptional() @ValidateNested() @Type(() => BookDockComicMetadataDto) comicMetadata?: BookDockComicMetadataDto | null;
}
@@ -3,7 +3,7 @@ import type { BookMetadataFetchReason } from '@bookorbit/types';
import { MetadataProviderKey, NotificationType, Permission, parseSeriesIndex } from '@bookorbit/types';
import { NotificationService } from '../notification/notification.service';
import { chapterProvenance, normalizeAudiobookChapters } from '../../common/utils/audiobook-chapters.utils';
import { coverFetchInputs, resolveIsAudiobook, type CoverFetchState } from '../../common/utils/book-media.utils';
import { coverFetchInputs, resolveIsAudiobook, resolveSearchMediaKind, type CoverFetchState } from '../../common/utils/book-media.utils';
import { AudiobookChaptersService } from '../audiobook-chapters/audiobook-chapters.service';
import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
@@ -14,6 +14,7 @@ import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { ComicMetadataRepository } from '../metadata/comic-metadata.repository';
import { MetadataService } from '../metadata/metadata.service';
import { ExistingMetadataFields, MetadataFetchPipeline, ResolvedMetadataFields } from '../metadata-fetch/metadata-fetch-pipeline';
import { isExternalProviderIdKey, ProviderIdStore } from '../metadata-fetch/provider-ids/provider-id.store';
import { ProviderThrottleTracker } from '../metadata-fetch/provider-throttle.tracker';
import type { MetadataSearchParams } from '../metadata-fetch/providers/metadata-search-params';
import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-lock.service';
@@ -69,6 +70,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
private readonly coverStore: BookCoverStore,
@Optional() private readonly gateway?: BookMetadataFetchGateway,
@Optional() private readonly audiobookChapters?: AudiobookChaptersService,
@Optional() private readonly providerIdStore?: ProviderIdStore,
) {}
async onApplicationBootstrap() {
@@ -276,15 +278,17 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
}
}
const isAudiobook = resolveIsAudiobook(fileRows, meta, book.books.primaryFileId);
const searchParams: MetadataSearchParams = {
title: meta?.title ?? undefined,
author: authorRows[0]?.name ?? undefined,
isbn: meta?.isbn13?.trim() || meta?.isbn10?.trim() || undefined,
seriesName: meta?.seriesName ?? undefined,
seriesIndex: meta?.seriesIndex ?? undefined,
existingProviderIds: this.collectProviderIds(meta ?? {}),
existingProviderIds: { ...this.collectProviderIds(meta ?? {}), ...(await this.providerIdStore?.read(bookId)) },
hardcoverEditionId: meta?.hardcoverEditionId ?? undefined,
isAudiobook: resolveIsAudiobook(fileRows, meta, book.books.primaryFileId),
isAudiobook,
mediaKind: resolveSearchMediaKind(fileRows, isAudiobook, book.books.primaryFileId),
maxCandidatesPerProvider: 1,
};
@@ -420,6 +424,9 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
if (filteredProviderIds[MetadataProviderKey.RANOBEDB]) scalarFields.ranobedbId = filteredProviderIds[MetadataProviderKey.RANOBEDB];
if (filteredProviderIds[MetadataProviderKey.LUBIMYCZYTAC]) scalarFields.lubimyczytacId = filteredProviderIds[MetadataProviderKey.LUBIMYCZYTAC];
if (filteredProviderIds[MetadataProviderKey.ALADIN]) scalarFields.aladinId = filteredProviderIds[MetadataProviderKey.ALADIN];
// Ids from external providers have no column; they land in their own table, set-only like the columns above.
const externalIds = Object.fromEntries(Object.entries(filteredProviderIds).filter(([key, value]) => value && isExternalProviderIdKey(key)));
if (Object.keys(externalIds).length > 0) await this.providerIdStore?.write(bookId, externalIds);
const duration = this.asNullableNumber(filteredResolved.duration);
if (duration !== undefined) scalarFields.durationSeconds = duration;
@@ -1,11 +1,7 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const KEY_HEX_LENGTH = 64;
import { decryptAesGcm, encryptAesGcm, parseAesGcmKey } from '../../common/secrets/aes-gcm-secret';
/**
* Shared by every part of this feature that holds a third-party secret, so the "refuse without a
@@ -22,13 +18,9 @@ export class RequestCredentialService {
constructor(config: ConfigService) {
const raw = config.get<string>('bookRequest.encryptionKey') ?? '';
if (raw.length === KEY_HEX_LENGTH && /^[0-9a-f]+$/i.test(raw)) {
this.key = Buffer.from(raw, 'hex');
} else {
if (raw.length > 0) {
this.logger.warn('BOOK_REQUEST_ENCRYPTION_KEY must be a 64-char hex string (32 bytes). Credentials cannot be stored.');
}
this.key = null;
this.key = parseAesGcmKey(raw);
if (!this.key && raw.length > 0) {
this.logger.warn('BOOK_REQUEST_ENCRYPTION_KEY must be a 64-char hex string (32 bytes). Credentials cannot be stored.');
}
}
@@ -44,12 +36,7 @@ export class RequestCredentialService {
});
}
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64');
return encryptAesGcm(this.key, plaintext);
}
/**
@@ -64,15 +51,8 @@ export class RequestCredentialService {
});
}
const buf = Buffer.from(ciphertext, 'base64');
const iv = buf.subarray(0, IV_LENGTH);
const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH);
try {
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
return decryptAesGcm(this.key, ciphertext);
} catch {
throw new BadRequestException({
message: 'Stored credentials could not be decrypted. BOOK_REQUEST_ENCRYPTION_KEY may have changed.',
@@ -1058,6 +1058,7 @@ describe('BookService', () => {
[MetadataProviderKey.OPEN_LIBRARY]: 'ol-id',
},
isAudiobook: false,
mediaKind: 'ebook',
maxCandidatesPerProvider: 1,
},
{
+49 -6
View File
@@ -16,7 +16,7 @@ import { inArray, type SQL } from 'drizzle-orm';
import { MAX_BOOK_QUERY_OFFSET_ROWS, isBookQueryOffsetWithinLimit } from '../../common/constants/pagination.constants';
import { chapterProvenance, normalizeAudiobookChapters, resolveStoredAudiobookChapters } from '../../common/utils/audiobook-chapters.utils';
import { coverFetchInputs, resolveIsAudiobook } from '../../common/utils/book-media.utils';
import { coverFetchInputs, resolveIsAudiobook, resolveSearchMediaKind } from '../../common/utils/book-media.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { mapWithConcurrency } from '../../common/utils/batch.utils';
import { SelfWriteRegistry } from '../../common/services/self-write-registry.service';
@@ -48,6 +48,7 @@ import {
customSortFieldIds,
hasCollectionScopedSort,
isAudioFormat,
isMetadataProviderKeySyntax,
isSortField,
jumpRailStrategyForSort,
resolveUploadPath,
@@ -97,6 +98,8 @@ import { MetadataFetchPipeline, ResolvedMetadataFields, type ExistingMetadataFie
import type { CacheDirective } from '../../common/provider-cache/provider-cache-spec';
import { splitMetadataIsbn } from '../../common/text-match/isbn-normalize';
import type { MetadataSearchParams } from '../metadata-fetch/providers/metadata-search-params';
import { isExternalProviderIdKey, ProviderIdStore } from '../metadata-fetch/provider-ids/provider-id.store';
import { ProviderRegistry } from '../metadata-fetch/provider-registry';
import { FileRenameService, RENAME_RELEVANT_FIELDS } from '../file-write/file-rename.service';
import { FileWriteService } from '../file-write/file-write.service';
import { NarratorService } from '../narrator/narrator.service';
@@ -159,7 +162,6 @@ const METADATA_UPDATE_FAILPOINTS = [
'beforeTransactionCommit',
] as const;
const METADATA_PROVIDER_KEY_SET = new Set<string>(Object.values(MetadataProviderKey));
/** An explicit single-book refresh goes live; a provider that fails may still answer from the cache. */
const EXPLICIT_REFRESH_DIRECTIVE: CacheDirective = { policy: 'refresh', allowFallback: true };
@@ -342,6 +344,8 @@ export class BookService {
@Optional() private readonly metadataExtraction?: MetadataExtractionService,
@Optional() private readonly positionConverter?: PositionConverterService,
@Optional() private readonly audiobookChapters?: AudiobookChaptersService,
@Optional() private readonly providerIdStore?: ProviderIdStore,
@Optional() private readonly providerRegistry?: ProviderRegistry,
) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
@@ -441,9 +445,12 @@ export class BookService {
| 'ranobedbId'
| 'lubimyczytacId'
| 'aladinId'
| 'externalProviderIds'
>,
providerIds: Partial<Record<MetadataProviderKey, string>>,
): void {
const external = Object.entries(providerIds).filter(([key, value]) => value && isExternalProviderIdKey(key));
if (external.length > 0) dto.externalProviderIds = { ...dto.externalProviderIds, ...Object.fromEntries(external) };
if (providerIds[MetadataProviderKey.GOOGLE]) dto.googleBooksId = providerIds[MetadataProviderKey.GOOGLE];
if (providerIds[MetadataProviderKey.GOODREADS]) dto.goodreadsId = providerIds[MetadataProviderKey.GOODREADS];
if (providerIds[MetadataProviderKey.AMAZON]) dto.amazonId = providerIds[MetadataProviderKey.AMAZON];
@@ -526,6 +533,34 @@ export class BookService {
};
}
/** The page an external id was applied from, else the provider's own URL template. */
private externalProviderLinks(
stored: { ids: Partial<Record<string, string>>; links: Partial<Record<string, string>> } | undefined,
): Partial<Record<string, string>> {
const links: Partial<Record<string, string>> = {};
for (const [key, externalId] of Object.entries(stored?.ids ?? {})) {
const link = stored?.links[key] ?? (externalId ? this.providerRegistry?.find(key)?.bookUrl?.(externalId) : undefined);
if (link) links[key] = link;
}
return links;
}
/**
* External ids are accepted only for providers registered now. Clearing one is always allowed, so
* an id left by a provider removed since the editor loaded can still be deleted.
*/
private externalIdsToWrite(
dto: UpdateBookMetadataDto,
): { ids: Partial<Record<string, string | null>>; links: Partial<Record<string, string | null>> } | null {
if (!dto.externalProviderIds) return null;
for (const [key, value] of Object.entries(dto.externalProviderIds)) {
if (value !== null && !this.providerRegistry?.isExternal(key)) {
throw new BadRequestException({ message: `Unknown metadata provider: ${key}`, errorCode: 'METADATA_PROVIDER_UNKNOWN' });
}
}
return { ids: dto.externalProviderIds, links: dto.externalProviderLinks ?? {} };
}
private normalizeCommunityRatingInputs(
ratings: UpdateBookMetadataDto['communityRatings'],
): Array<{ provider: MetadataProviderKey; rating: number; ratingCount: number | null }> | undefined {
@@ -534,7 +569,7 @@ export class BookService {
const byProvider = new Map<MetadataProviderKey, { provider: MetadataProviderKey; rating: number; ratingCount: number | null }>();
for (const item of ratings) {
if (!METADATA_PROVIDER_KEY_SET.has(item.provider)) continue;
if (!isMetadataProviderKeySyntax(item.provider)) continue;
if (!Number.isFinite(item.rating) || item.rating < 0 || item.rating > 5) continue;
const ratingCount =
@@ -550,7 +585,7 @@ export class BookService {
): BookCommunityRating[] {
return rows
.filter((row): row is { provider: MetadataProviderKey; rating: number; ratingCount: number | null; updatedAt: Date | null } =>
METADATA_PROVIDER_KEY_SET.has(row.provider),
isMetadataProviderKeySyntax(row.provider),
)
.map((row) => ({
provider: row.provider,
@@ -1902,6 +1937,7 @@ export class BookService {
write: WriteResult | null;
libraryAutoWriteEnabled: boolean;
}> {
const externalIds = this.externalIdsToWrite(dto);
const scalarFields: Parameters<BookRepository['updateMetadataFields']>[1] = {};
if (dto.title !== undefined) scalarFields.title = dto.title ?? null;
if (dto.subtitle !== undefined) scalarFields.subtitle = dto.subtitle ?? null;
@@ -2028,6 +2064,8 @@ export class BookService {
this.throwIfMetadataUpdateFailpoint('beforeTransactionCommit');
});
if (externalIds) await this.providerIdStore?.write(id, externalIds.ids, externalIds.links);
if (dto.authors !== undefined) {
this.metadataService.emitAuthorsReplaced(id, replacedAuthorIds);
}
@@ -3082,8 +3120,9 @@ export class BookService {
await this.libraryService.verifyUserAccessLevel(user.id, book.books.libraryId, this.isSuperuser(user), 'editor');
const meta = book.book_metadata;
const providerIds = this.collectExistingProviderIds(meta ?? {});
const providerIds = { ...this.collectExistingProviderIds(meta ?? {}), ...(await this.providerIdStore?.read(id)) };
const isAudiobook = resolveIsAudiobook(fileRows, meta, book.books.primaryFileId);
const searchParams: MetadataSearchParams = {
title: meta?.title ?? undefined,
author: authorRows[0]?.name ?? undefined,
@@ -3092,7 +3131,8 @@ export class BookService {
seriesIndex: meta?.seriesIndex ?? undefined,
existingProviderIds: providerIds,
hardcoverEditionId: meta?.hardcoverEditionId ?? undefined,
isAudiobook: resolveIsAudiobook(fileRows, meta, book.books.primaryFileId),
isAudiobook,
mediaKind: resolveSearchMediaKind(fileRows, isAudiobook, book.books.primaryFileId),
maxCandidatesPerProvider: 1,
};
@@ -3505,6 +3545,7 @@ export class BookService {
const { book, authorRows, genreRows, tagRows, fileRows, narratorRows, seriesMembershipRows, communityRatingRows } = result;
const meta = book.book_metadata;
const customMetadata = await this.customMetadataService.getBookValues(id, book.books.libraryId);
const externalProviderIds = (await this.providerIdStore?.readMany([id]))?.get(id);
const hasAudioFiles = fileRows.some((f) => f.format && isAudioFormat(f.format));
const orderedFileRows = hasAudioFiles ? [...fileRows].sort((a, b) => compareBookFilePaths(a.absolutePath, b.absolutePath)) : fileRows;
const storedChapters = meta?.chapters as AudiobookChapter[] | null | undefined;
@@ -3574,7 +3615,9 @@ export class BookService {
[MetadataProviderKey.RANOBEDB]: meta?.ranobedbId ?? null,
[MetadataProviderKey.LUBIMYCZYTAC]: meta?.lubimyczytacId ?? null,
[MetadataProviderKey.ALADIN]: meta?.aladinId ?? null,
...externalProviderIds?.ids,
},
providerLinks: this.externalProviderLinks(externalProviderIds),
authors: authorRows,
genres: genreRows.map((g) => g.name),
tags: tagRows.map((t) => t.name),
@@ -74,6 +74,8 @@ export class BookDetailDto {
coverVersion: string;
hardcoverEditionId: string | null;
providerIds: ProviderIds;
/** Record links for external providers' ids. See `BookDetail.providerLinks`. */
providerLinks?: Partial<Record<string, string>>;
authors: { id: number; name: string; sortName: string | null }[];
genres: string[];
tags: string[];
@@ -1,6 +1,8 @@
import { IsArray, IsBoolean, IsIn, IsInt, IsNumber, IsOptional, IsString, Matches, Max, MaxLength, Min, ValidateNested } from 'class-validator';
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, Matches, Max, MaxLength, Min, ValidateNested } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { MetadataProviderKey, SERIES_INDEX_MAX_LENGTH, SERIES_INDEX_PATTERN, type SeriesIndex } from '@bookorbit/types';
import { SERIES_INDEX_MAX_LENGTH, SERIES_INDEX_PATTERN, type MetadataProviderKey, type SeriesIndex } from '@bookorbit/types';
import { IsExternalProviderMap } from '../../../common/decorators/is-external-provider-map.decorator';
import { IsMetadataProviderKey } from '../../../common/decorators/is-metadata-provider-key.decorator';
import { MAX_SERIES_TOTAL_BOOKS } from '../../../common/utils/series-total-books.utils';
import { PROVIDER_ID_MAX_LENGTHS } from '../../../common/utils/provider-id.utils';
import { NormalizeIdentifier } from '../../../common/decorators/normalize-identifier.decorator';
@@ -41,8 +43,13 @@ export class BookSeriesMembershipDto {
@IsOptional() @IsInt() @Min(1) @Max(MAX_SERIES_TOTAL_BOOKS) expectedBookCount?: number | null;
}
const MAX_EXTERNAL_PROVIDER_ID_LENGTH = 512;
const MAX_EXTERNAL_PROVIDER_LINK_LENGTH = 2048;
export class CommunityRatingDto {
@IsIn(Object.values(MetadataProviderKey)) provider!: MetadataProviderKey;
// Any provider key, not only built-ins: released iOS builds send back every rating they were
// given, including an external provider's, whenever one rating changes.
@IsMetadataProviderKey() provider!: MetadataProviderKey;
@IsNumber() @Min(0) @Max(5) rating!: number;
@IsOptional() @IsInt() @Min(0) ratingCount?: number | null;
}
@@ -82,6 +89,14 @@ export class UpdateBookMetadataDto {
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.ranobedbId) ranobedbId?: string | null;
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.lubimyczytacId) lubimyczytacId?: string | null;
@NormalizeIdentifier() @IsOptional() @IsString() @MaxLength(PROVIDER_ID_MAX_LENGTHS.aladinId) aladinId?: string | null;
/** Ids from external providers, keyed by provider. Null clears one; an absent key is left alone. */
@IsOptional()
@IsExternalProviderMap(MAX_EXTERNAL_PROVIDER_ID_LENGTH)
externalProviderIds?: Partial<Record<string, string | null>>;
/** The record page each external id was taken from, for the book page's links. */
@IsOptional()
@IsExternalProviderMap(MAX_EXTERNAL_PROVIDER_LINK_LENGTH)
externalProviderLinks?: Partial<Record<string, string | null>>;
@IsOptional() @ValidateNested() @Type(() => ComicMetadataDto) comicMetadata?: ComicMetadataDto;
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CustomMetadataValueDto) customMetadata?: CustomMetadataValueDto[];
}
@@ -244,24 +244,21 @@ describe('validateGroupRule', () => {
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
});
it('rejects providers that do not expose community ratings', () => {
it('rejects a rating provider that cannot be a provider key', () => {
const rule = {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'communityRating', operator: 'gte', value: 4.5, provider: 'kobo' }],
rules: [{ type: 'rule', field: 'communityRating', operator: 'gte', value: 4.5, provider: 'Not A Provider' }],
};
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
});
it('rejects unsupported providers on community rating count rules', () => {
const rule = {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'communityRatingCount', operator: 'gte', value: 1000, provider: 'kobo' }],
};
expect(() => validateGroupRule(rule)).toThrow(BadRequestException);
it("accepts an external provider's ratings on rating and count rules", () => {
for (const field of ['communityRating', 'communityRatingCount']) {
const rule = { type: 'group', join: 'AND', rules: [{ type: 'rule', field, operator: 'gte', value: 4, provider: 'fantlab' }] };
expect(() => validateGroupRule(rule)).not.toThrow();
}
});
it('accepts array values for includesAny operators', () => {
@@ -2,7 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { z } from 'zod';
import {
COMMUNITY_RATING_PROVIDER_KEYS,
isMetadataProviderKeySyntax,
CUSTOM_FIELD_OPERATORS,
FIELD_OPERATORS,
RULE_OPERATORS,
@@ -16,8 +16,6 @@ import {
} from '@bookorbit/types';
import { isDateKey } from '../../../common/utils/timezone.utils';
const COMMUNITY_RATING_PROVIDER_VALUES = ['any', ...COMMUNITY_RATING_PROVIDER_KEYS] as const;
const DATE_VALUE_FIELDS = new Set<StaticRuleField>(['addedAt', 'startedAt', 'finishedAt', 'publishedDate']);
const DATE_VALUE_OPERATORS = new Set<RuleOperator>(['before', 'after', 'between']);
const RELATIVE_DATE_OPERATORS = new Set<RuleOperator>(['moreThanAgo']);
@@ -93,7 +91,14 @@ const ruleSchema: z.ZodType<Rule> = z
operator: z.enum(RULE_OPERATORS as unknown as [string, ...string[]]),
value: z.union([z.string(), z.number(), z.array(z.string().min(1)).min(1).max(20), z.array(z.number()).min(1).max(20)]).optional(),
valueTo: z.union([z.string(), z.number()]).optional(),
provider: z.enum(COMMUNITY_RATING_PROVIDER_VALUES as unknown as [CommunityRatingProvider, ...CommunityRatingProvider[]]).optional(),
// Any provider's ratings can be filtered on, including an external provider's; the built-in list
// only seeds the picker.
provider: z
.string()
.refine((value): value is CommunityRatingProvider => value === 'any' || isMetadataProviderKeySyntax(value), {
message: 'Unknown rating provider',
})
.optional(),
})
.superRefine((rule, ctx) => {
const custom = isCustomRuleField(rule.field);
@@ -15,7 +15,6 @@ const baseConfig: ProviderConfigurations = {
comicvine: { enabled: false, apiKey: '' },
ranobedb: { enabled: false },
kobo: { enabled: false, country: 'us', language: 'en' },
lubimyczytac: { enabled: false },
};
function mockJsonResponse(body: unknown, status = 200): Response {
@@ -1,3 +1,6 @@
import type { SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import { FileRenameRepository } from './file-rename.repository';
describe('FileRenameRepository', () => {
@@ -254,7 +257,12 @@ describe('FileRenameRepository', () => {
expect.objectContaining({ bookId: 99, libraryFolderId: 7, absolutePath: '/library/Author/Book/old.opf', relPath: 'Author/Book/old.opf' }),
expect.objectContaining({ primaryFileId: 10, status: 'present' }),
]);
expect(tx.execute).toHaveBeenCalledTimes(1);
// The source copy's reads go first: a merged-away duplicate's reads are not history.
const dialect = new PgDialect();
const statements = tx.execute.mock.calls.map(([query]) => dialect.sqlToQuery(query as SQL).sql);
expect(statements).toHaveLength(2);
expect(statements[0]).toContain('delete from "reading_attempts"');
expect(statements[1]).toContain('delete from "books"');
expect(tx.delete).not.toHaveBeenCalled();
});
@@ -24,6 +24,11 @@ export class MetadataCacheProviderCatalog {
}
}
/** A provider registered at runtime can leave again; its cached rows then become orphans the sweeper drops. */
unregister(namespace: MetadataCacheNamespace, provider: string): void {
this.entries.delete(catalogKey(namespace, provider));
}
get(namespace: MetadataCacheNamespace, provider: string): CatalogEntry | undefined {
return this.entries.get(catalogKey(namespace, provider));
}
@@ -1,20 +1,41 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { MetadataCacheProviderCatalog } from '../metadata-cache/metadata-cache-provider-catalog';
import { ProviderRegistry } from './provider-registry';
/** Tells the metadata cache which book providers exist and how each is cached. */
/**
* Tells the metadata cache which book providers exist and how each is cached, and keeps telling it
* as external providers are enabled, changed and removed at runtime.
*/
@Injectable()
export class BookProviderCacheRegistrar implements OnModuleInit {
export class BookProviderCacheRegistrar implements OnModuleInit, OnModuleDestroy {
private registered = new Set<string>();
private unsubscribe: () => void = () => undefined;
constructor(
private readonly registry: ProviderRegistry,
private readonly catalog: MetadataCacheProviderCatalog,
) {}
onModuleInit(): void {
this.sync();
this.unsubscribe = this.registry.onChange(() => this.sync());
}
onModuleDestroy(): void {
this.unsubscribe();
}
private sync(): void {
const providers = this.registry.all();
const current = new Set(providers.map((provider) => provider.key as string));
for (const key of this.registered) {
if (!current.has(key)) this.catalog.unregister('book', key);
}
this.catalog.register(
'book',
this.registry.all().map((provider) => ({ provider: provider.key, label: provider.label, spec: provider.cache })),
providers.map((provider) => ({ provider: provider.key, label: provider.label, spec: provider.cache })),
);
this.registered = current;
}
}

Some files were not shown because too many files have changed in this diff Show More