fix(metadata): honor configured Amazon domain in book links

Use the sanitized provider-link setting so book detail URLs target the selected marketplace.

Fixes #1423
This commit is contained in:
neon
2026-09-18 16:32:18 +00:00
parent c99f9689e8
commit c292d6ccd2
16 changed files with 229 additions and 32 deletions
@@ -66,6 +66,7 @@ import StorygraphBookSyncGridItem from '@/features/storygraph/components/Storygr
import BookEditionsCard from '@/features/book/components/detail/details/BookEditionsCard.vue'
import BookReadingActivityCard from '@/features/book/components/detail/details/BookReadingActivityCard.vue'
import { useBookReadingLog } from '@/features/book/composables/useBookReadingLog'
import { useProviderLinkSettings } from '@/features/book/composables/useProviderLinkSettings'
type FileProgress = {
percentage: number
@@ -113,6 +114,7 @@ function togglePersonalReview() {
}
const { weights: scoreWeights, fetchWeights } = useMetadataScoreWeights()
const { settings: providerLinkSettings, loadSettings: loadProviderLinkSettings } = useProviderLinkSettings()
const {
bookProgress: koreaderBookProgress,
fetchBookProgress: fetchKoreaderProgress,
@@ -122,6 +124,7 @@ const {
onMounted(() => {
void fetchWeights()
void reloadReadingLog()
void loadProviderLinkSettings()
})
const {
@@ -715,7 +718,7 @@ const supplementalLoading = ref(false)
const resettingFileIds = ref<number[]>([])
const providerIconErrors = ref<Record<string, boolean>>({})
const providerLinks = computed(() => createBookProviderLinks(props.book.providerIds))
const providerLinks = computed(() => createBookProviderLinks(props.book.providerIds, providerLinkSettings.value))
const communityRatingBadges = computed(() => {
const linkByKey = new Map(providerLinks.value.map((link) => [link.key, link]))
@@ -5,6 +5,7 @@ import type { BookDetail } from '@bookorbit/types'
import DetailsTab from '../DetailsTab.vue'
import BookReadingActivityCard from '../../details/BookReadingActivityCard.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useProviderLinkSettings } from '@/features/book/composables/useProviderLinkSettings'
const mocks = vi.hoisted(() => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
@@ -154,9 +155,11 @@ describe('DetailsTab cover surface', () => {
mocks.hasPermission.mockReset()
mocks.hasPermission.mockReturnValue(true)
mocks.user.value.settings.timezone = 'UTC'
useProviderLinkSettings().settings.value = { amazonDomain: 'amazon.com' }
mocks.api.mockImplementation(async (input) => {
const url = String(input)
if (url.includes('/metadata-preferences/provider-links')) return response({ amazonDomain: 'amazon.com' })
if (url.includes('/metadata-score/weights')) return response({})
if (url.includes('/playback-state')) return response(null)
if (url.includes('/collections/membership')) return response([])
@@ -541,6 +544,19 @@ describe('DetailsTab cover surface', () => {
expect(tooltips.some((t) => t.includes('4.3 / 5') && t.includes('12,345'))).toBe(true)
})
it('uses the configured Amazon domain for the book provider link', async () => {
const defaultImplementation = mocks.api.getMockImplementation()!
mocks.api.mockImplementation(async (input, init) => {
if (String(input).includes('/metadata-preferences/provider-links')) return response({ amazonDomain: 'amazon.de' })
return defaultImplementation(input, init)
})
const wrapper = mountDetails(makeBook({ providerIds: { amazon: 'B012345678' } }))
await flushPromises()
expect(wrapper.find('a[href="https://www.amazon.de/dp/B012345678"]').exists()).toBe(true)
})
it('places the sync grid items with the current book id', async () => {
const wrapper = mountDetails(makeBook())
await flushPromises()
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/api', () => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
}))
import { api } from '@/lib/api'
import { useProviderLinkSettings } from '../useProviderLinkSettings'
const apiMock = vi.mocked(api)
describe('useProviderLinkSettings', () => {
beforeEach(() => {
apiMock.mockReset()
useProviderLinkSettings().settings.value = { amazonDomain: 'amazon.com' }
})
it('loads the sanitized provider-link settings', async () => {
apiMock.mockResolvedValue({
ok: true,
json: async () => ({ amazonDomain: 'amazon.de' }),
} as Response)
const { settings, loadSettings } = useProviderLinkSettings()
await loadSettings()
expect(apiMock).toHaveBeenCalledWith('/api/v1/metadata-preferences/provider-links')
expect(settings.value).toEqual({ amazonDomain: 'amazon.de' })
})
it('keeps the default when the settings request fails', async () => {
apiMock.mockResolvedValue({ ok: false } as Response)
const { settings, loadSettings } = useProviderLinkSettings()
await loadSettings()
expect(settings.value).toEqual({ amazonDomain: 'amazon.com' })
})
it('deduplicates concurrent settings requests', async () => {
let resolveRequest!: (response: Response) => void
apiMock.mockReturnValue(
new Promise<Response>((resolve) => {
resolveRequest = resolve
}),
)
const { loadSettings } = useProviderLinkSettings()
const first = loadSettings()
const second = loadSettings()
resolveRequest({ ok: false } as Response)
await Promise.all([first, second])
expect(apiMock).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,38 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { ProviderLinkSettings } from '@/features/book/lib/provider-links'
const DEFAULT_SETTINGS: ProviderLinkSettings = { amazonDomain: 'amazon.com' }
const settings = ref<ProviderLinkSettings>(DEFAULT_SETTINGS)
let pendingRequest: Promise<void> | null = null
export function useProviderLinkSettings() {
function loadSettings(): Promise<void> {
if (pendingRequest) return pendingRequest
pendingRequest = (async () => {
try {
const response = await api('/api/v1/metadata-preferences/provider-links')
if (!response.ok) return
const data: unknown = await response.json()
if (
typeof data === 'object' &&
data !== null &&
'amazonDomain' in data &&
typeof data.amazonDomain === 'string' &&
data.amazonDomain.trim()
) {
settings.value = { amazonDomain: data.amazonDomain }
}
} catch {
return
} finally {
pendingRequest = null
}
})()
return pendingRequest
}
return { settings, loadSettings }
}
@@ -139,6 +139,23 @@ describe('book provider links', () => {
expect(comicVineIssueUrl(' 1126983 ')).toBe('https://comicvine.gamespot.com/issue/4000-1126983/')
})
it('uses the configured Amazon domain for Amazon links', () => {
const links = createBookProviderLinks({ amazon: 'B012345678' }, { amazonDomain: 'amazon.de' })
expect(links).toEqual([
expect.objectContaining({
key: MetadataProviderKey.AMAZON,
url: 'https://www.amazon.de/dp/B012345678',
}),
])
})
it('falls back to Amazon US for an unsupported domain', () => {
const links = createBookProviderLinks({ amazon: 'B012345678' }, { amazonDomain: 'example.invalid/path' })
expect(links[0]?.url).toBe('https://www.amazon.com/dp/B012345678')
})
it('builds a trimmed and encoded Libro.fm audiobook URL', () => {
expect(libroFmAudiobookUrl(' 978 123 ')).toBe('https://libro.fm/audiobooks/978%20123')
})
@@ -1,4 +1,5 @@
import { MetadataProviderKey, type ProviderIds } from '@bookorbit/types'
import { normalizeAmazonDomain } from '@/lib/amazon-domain'
import { providerIconPathSafe } from './provider-icons'
export type BookProviderLink = {
@@ -10,9 +11,13 @@ export type BookProviderLink = {
}
type ProviderLinkDefinition = Omit<BookProviderLink, 'url' | 'iconUrl'> & {
url: (id: string) => string
url: (id: string, settings: ProviderLinkSettings) => string
}
export type ProviderLinkSettings = { amazonDomain: string }
const DEFAULT_LINK_SETTINGS: ProviderLinkSettings = { amazonDomain: 'amazon.com' }
function encoded(value: string): string {
return encodeURIComponent(value)
}
@@ -55,7 +60,7 @@ const PROVIDER_LINK_DEFINITIONS: readonly ProviderLinkDefinition[] = [
{
key: MetadataProviderKey.AMAZON,
label: 'Amazon',
url: (id) => `https://www.amazon.com/dp/${encoded(id)}`,
url: (id, settings) => `https://www.${normalizeAmazonDomain(settings.amazonDomain)}/dp/${encoded(id)}`,
fallback: 'A',
},
{
@@ -120,7 +125,7 @@ const PROVIDER_LINK_DEFINITIONS: readonly ProviderLinkDefinition[] = [
},
]
export function createBookProviderLinks(providerIds: ProviderIds): BookProviderLink[] {
export function createBookProviderLinks(providerIds: ProviderIds, settings: ProviderLinkSettings = DEFAULT_LINK_SETTINGS): BookProviderLink[] {
return PROVIDER_LINK_DEFINITIONS.flatMap((definition) => {
const id = providerIds[definition.key]?.trim()
if (!id) return []
@@ -129,7 +134,7 @@ export function createBookProviderLinks(providerIds: ProviderIds): BookProviderL
{
key: definition.key,
label: definition.label,
url: definition.url(id),
url: definition.url(id, settings),
iconUrl: providerIconPathSafe(definition.key),
fallback: definition.fallback,
},
@@ -1,4 +1,5 @@
import type { ProviderConfigurations } from '@bookorbit/types'
export { AMAZON_DOMAINS } from '@/lib/amazon-domain'
/**
* The medium a source supplies metadata for. Sources are picked by medium ("I want
@@ -12,7 +13,7 @@ export interface ProviderFieldDef {
key: string
label: string
type: 'text' | 'password' | 'select'
options?: string[]
options?: readonly string[]
placeholder?: string
helper?: string
/** Editable while the provider is still off, because enabling it depends on this value. */
@@ -39,28 +40,6 @@ export interface ProviderRowDef {
/** A provider slice of the draft, addressed by field key rather than by concrete shape. */
export type ProviderDraftEntry = { enabled: boolean } & Record<string, unknown>
export const AMAZON_DOMAINS = [
'amazon.com',
'amazon.co.uk',
'amazon.de',
'amazon.fr',
'amazon.it',
'amazon.es',
'amazon.ca',
'amazon.com.au',
'amazon.co.jp',
'amazon.in',
'amazon.com.br',
'amazon.com.mx',
'amazon.nl',
'amazon.se',
'amazon.pl',
'amazon.sg',
'amazon.ae',
'amazon.sa',
'amazon.tr',
]
export const AUDIBLE_DOMAINS = [
'audible.com',
'audible.co.uk',
+26
View File
@@ -0,0 +1,26 @@
export const AMAZON_DOMAINS = [
'amazon.com',
'amazon.co.uk',
'amazon.de',
'amazon.fr',
'amazon.it',
'amazon.es',
'amazon.ca',
'amazon.com.au',
'amazon.co.jp',
'amazon.in',
'amazon.com.br',
'amazon.com.mx',
'amazon.nl',
'amazon.se',
'amazon.pl',
'amazon.sg',
'amazon.ae',
'amazon.sa',
'amazon.tr',
] as const
export function normalizeAmazonDomain(value: string): string {
const normalized = value.trim().toLowerCase()
return AMAZON_DOMAINS.some((domain) => domain === normalized) ? normalized : 'amazon.com'
}