feat(metadata): batch library overrides and improve comic refresh

This commit is contained in:
neon
2026-03-24 20:09:15 -06:00
parent f847cc1d22
commit d2eba30728
43 changed files with 1022 additions and 612 deletions
+5 -1
View File
@@ -53,6 +53,10 @@ function navigateToAccount() {
router.push({ name: 'settings-account' })
}
function navigateToSettings() {
router.push({ name: 'settings-libraries' })
}
const uploadOpen = ref(false)
const searchFocused = ref(false)
@@ -512,7 +516,7 @@ function formatBadgeClass(fmt: string): string {
variant="ghost"
size="icon"
class="h-8 w-8 rounded-xl border border-primary/35 text-foreground/70 hover:border-primary/70 hover:text-foreground transition-colors"
@click="router.push({ name: 'settings-account' })"
@click="navigateToSettings"
>
<Settings :size="15" />
</Button>
@@ -27,6 +27,7 @@ const { search: searchAuthors } = useAuthorSearch()
const { search: searchNarrators } = useNarratorSearch()
const { search: searchGenres } = useGenreSearch()
const { search: searchTags } = useTagSearch()
const searchComicMetadata = async (q: string): Promise<string[]> => (q.trim() ? [] : [])
const coverPanel = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
const searchOpen = ref(false)
@@ -130,6 +131,20 @@ async function autoFill() {
if (preview.openLibraryId != null) form.openLibraryId = preview.openLibraryId
if (preview.itunesId != null) form.itunesId = preview.itunesId
if (preview.audibleId != null) form.audibleId = preview.audibleId
if (preview.comicvineId != null) form.comicvineId = preview.comicvineId
if (preview.comicMetadata) {
if (preview.comicMetadata.issueNumber !== undefined) form.comicIssueNumber = preview.comicMetadata.issueNumber
if (preview.comicMetadata.volumeName !== undefined) form.comicVolumeName = preview.comicMetadata.volumeName
if (preview.comicMetadata.storyArcs !== undefined) form.comicStoryArcs = preview.comicMetadata.storyArcs
if (preview.comicMetadata.pencillers !== undefined) form.comicPencillers = preview.comicMetadata.pencillers
if (preview.comicMetadata.inkers !== undefined) form.comicInkers = preview.comicMetadata.inkers
if (preview.comicMetadata.colorists !== undefined) form.comicColorists = preview.comicMetadata.colorists
if (preview.comicMetadata.letterers !== undefined) form.comicLetterers = preview.comicMetadata.letterers
if (preview.comicMetadata.coverArtists !== undefined) form.comicCoverArtists = preview.comicMetadata.coverArtists
if (preview.comicMetadata.characters !== undefined) form.comicCharacters = preview.comicMetadata.characters
if (preview.comicMetadata.teams !== undefined) form.comicTeams = preview.comicMetadata.teams
if (preview.comicMetadata.locations !== undefined) form.comicLocations = preview.comicMetadata.locations
}
if (preview.coverUrl) coverPanel.value?.setUrl(preview.coverUrl)
}
</script>
@@ -391,50 +406,50 @@ async function autoFill() {
<!-- Story Arcs -->
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Story Arcs</label>
<ChipInput v-model="form.comicStoryArcs" />
<ChipInput v-model="form.comicStoryArcs" :search-fn="searchComicMetadata" />
</div>
<!-- Pencillers | Inkers -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pencillers</label>
<ChipInput v-model="form.comicPencillers" />
<ChipInput v-model="form.comicPencillers" :search-fn="searchComicMetadata" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Inkers</label>
<ChipInput v-model="form.comicInkers" />
<ChipInput v-model="form.comicInkers" :search-fn="searchComicMetadata" />
</div>
</div>
<!-- Colorists | Letterers -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Colorists</label>
<ChipInput v-model="form.comicColorists" />
<ChipInput v-model="form.comicColorists" :search-fn="searchComicMetadata" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Letterers</label>
<ChipInput v-model="form.comicLetterers" />
<ChipInput v-model="form.comicLetterers" :search-fn="searchComicMetadata" />
</div>
</div>
<!-- Cover Artists -->
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Cover Artists</label>
<ChipInput v-model="form.comicCoverArtists" />
<ChipInput v-model="form.comicCoverArtists" :search-fn="searchComicMetadata" />
</div>
<!-- Characters | Teams -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Characters</label>
<ChipInput v-model="form.comicCharacters" />
<ChipInput v-model="form.comicCharacters" :search-fn="searchComicMetadata" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Teams</label>
<ChipInput v-model="form.comicTeams" />
<ChipInput v-model="form.comicTeams" :search-fn="searchComicMetadata" />
</div>
</div>
<!-- Locations -->
<div class="space-y-1">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Locations</label>
<ChipInput v-model="form.comicLocations" />
<ChipInput v-model="form.comicLocations" :search-fn="searchComicMetadata" />
</div>
</div>
</div>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { X, Sparkles, ChevronRight } from 'lucide-vue-next'
import { isAudioFormat } from '@projectx/types'
import type { BookDetail, MetadataCandidate, MetadataSource } from '@projectx/types'
import { useMetadataSearch } from '../../../composables/useMetadataSearch'
import { useCoverVersions } from '../../../composables/useCoverVersions'
@@ -62,7 +63,7 @@ onMounted(() => {
})
function handleSearch(params: { title: string; author: string; isbn: string }) {
const isAudiobook = props.book.durationSeconds != null || !!props.book.providerIds.audible
const isAudiobook = props.book.files.some((f) => f.format != null && isAudioFormat(f.format))
selectedCandidate.value = null
view.value = 'search'
search({ ...params, bookId: props.book.id, isAudiobook })
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { BookDetail } from '@projectx/types'
import type { BookDetail, ComicMetadataFields } from '@projectx/types'
import { useCoverVersions } from './useCoverVersions'
import { toast } from 'vue-sonner'
@@ -24,6 +24,8 @@ export interface MetadataRefreshPreview {
openLibraryId?: string | null
itunesId?: string | null
audibleId?: string | null
comicvineId?: string | null
comicMetadata?: ComicMetadataFields
}
export function useRefreshMetadata() {
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import type { FieldPreference, MetadataField } from '@projectx/types'
import type { FieldPreferenceOverrides } from '@projectx/types'
import { Info } from 'lucide-vue-next'
import { useLibraries } from '@/features/library/composables/useLibraries'
import GlobalPreferencePanel from './components/GlobalPreferencePanel.vue'
@@ -9,8 +9,19 @@ import { useProviderConfig } from './composables/useProviderConfig'
import { useMetadataPreferences } from './composables/useMetadataPreferences'
const { statuses, fetchConfig } = useProviderConfig()
const { globalPrefs, libraryPrefs, savingGlobal, savingField, fetchGlobal, saveGlobal, fetchLibrary, saveFieldOverride, resetLibrary } =
useMetadataPreferences()
const {
globalPrefs,
libraryPrefs,
savingGlobal,
savingLibrary,
fetchGlobal,
saveGlobal,
resetGlobal,
clearAllProviders,
fetchLibrary,
saveLibraryDraft,
resetLibrary,
} = useMetadataPreferences()
const { libraries, fetchLibraries } = useLibraries()
onMounted(() => {
@@ -19,14 +30,18 @@ onMounted(() => {
const fetchedLibraryIds = new Set<number>()
watch(libraries, async (libs) => {
const newLibs = libs.filter((lib) => !fetchedLibraryIds.has(lib.id))
newLibs.forEach((lib) => fetchedLibraryIds.add(lib.id))
await Promise.all(newLibs.map((lib) => fetchLibrary(lib.id)))
})
watch(
libraries,
async (libs) => {
const newLibs = libs.filter((lib) => !fetchedLibraryIds.has(lib.id))
newLibs.forEach((lib) => fetchedLibraryIds.add(lib.id))
await Promise.all(newLibs.map((lib) => fetchLibrary(lib.id)))
},
{ immediate: true },
)
async function onFieldChange(libraryId: number, field: MetadataField, pref: FieldPreference | null) {
await saveFieldOverride(libraryId, field, pref)
async function onSaveLibrary(libraryId: number, overrides: FieldPreferenceOverrides) {
await saveLibraryDraft(libraryId, overrides)
}
async function onResetLibrary(libraryId: number) {
@@ -56,7 +71,14 @@ async function onResetLibrary(libraryId: number) {
</div>
</div>
<GlobalPreferencePanel :preferences="globalPrefs" :statuses="statuses" :saving="savingGlobal" @save="saveGlobal" />
<GlobalPreferencePanel
:preferences="globalPrefs"
:statuses="statuses"
:saving="savingGlobal"
@save="saveGlobal"
@clear-all="clearAllProviders"
@reset-to-default="resetGlobal"
/>
<div v-if="libraries.length" class="space-y-4">
<div class="px-1">
@@ -68,10 +90,12 @@ async function onResetLibrary(libraryId: number) {
v-for="lib in libraries"
:key="lib.id"
:library-name="lib.name"
:library-primary-format="lib.formatPriority[0] ?? null"
:library-prefs="libraryPrefs.get(lib.id) ?? null"
:global-prefs="globalPrefs"
:statuses="statuses"
:saving-field="savingField"
@field-change="onFieldChange"
:saving="savingLibrary === lib.id"
@save="onSaveLibrary"
@reset="onResetLibrary"
/>
</div>
@@ -10,8 +10,7 @@ defineProps<{
preferences: Record<MetadataField, FieldPreference>
statuses: ProviderStatus[]
overriddenFields?: Set<MetadataField>
savingField?: string | null
libraryId?: number
saving?: boolean
}>()
const emit = defineEmits<{
@@ -40,7 +39,7 @@ const open = ref(true)
:preference="preferences[field]"
:statuses="statuses"
:inherited="overriddenFields !== undefined ? !overriddenFields.has(field) : undefined"
:saving="savingField === `${libraryId}:${field}`"
:saving="saving"
@change="(f, p) => emit('change', f, p)"
@revert="(f) => emit('revert', f)"
/>
@@ -8,8 +8,7 @@ defineProps<{
preferences: MetadataFetchPreferences
statuses: ProviderStatus[]
overriddenFields?: Set<MetadataField>
savingField?: string | null
libraryId?: number
saving?: boolean
}>()
const emit = defineEmits<{
@@ -65,8 +64,7 @@ const GROUPS: { label: string; fields: MetadataField[] }[] = [
:preferences="preferences.fields"
:statuses="statuses"
:overridden-fields="overriddenFields"
:saving-field="savingField"
:library-id="libraryId"
:saving="saving"
@change="(f, p) => emit('change', f, p)"
@revert="(f) => emit('revert', f)"
/>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { AlertTriangle, RotateCcw, Settings2 } from 'lucide-vue-next'
import type { FieldPreference, MetadataField, MetadataProviderKey, ProviderStatus } from '@projectx/types'
import type { FieldPreference, MetadataField, ProviderStatus } from '@projectx/types'
import MergeStrategyPicker from './MergeStrategyPicker.vue'
import ProviderChipList from './ProviderChipList.vue'
import FieldConfigSheet from './FieldConfigSheet.vue'
@@ -9,8 +9,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { Badge } from '@/components/ui/badge'
const RESERVOIR_TYPE = 'application/x-provider-key'
const props = defineProps<{
field: MetadataField
preference: FieldPreference
@@ -45,7 +43,6 @@ const FIELD_LABELS: Record<MetadataField, string> = {
const label = computed(() => FIELD_LABELS[props.field] ?? props.field)
const noProviders = computed(() => props.preference.enabled && props.preference.providers.length === 0)
const sheetOpen = ref(false)
const isDragOver = ref(false)
function update(patch: Partial<FieldPreference>) {
emit('change', props.field, { ...props.preference, ...patch })
@@ -54,46 +51,10 @@ function update(patch: Partial<FieldPreference>) {
function onSheetChange(pref: FieldPreference) {
emit('change', props.field, pref)
}
function onDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes(RESERVOIR_TYPE)) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
isDragOver.value = true
}
function onDragLeave(e: DragEvent) {
const row = e.currentTarget as Element
if (e.relatedTarget instanceof Node && row.contains(e.relatedTarget)) return
isDragOver.value = false
}
function onDrop(e: DragEvent) {
isDragOver.value = false
const key = e.dataTransfer?.getData(RESERVOIR_TYPE) as MetadataProviderKey | undefined
if (!key) return
if (props.preference.providers.includes(key)) return
update({ providers: [...props.preference.providers, key] })
}
</script>
<template>
<div
class="flex flex-col md:flex-row md:items-center gap-4 px-6 py-3.5 transition-colors relative hover:bg-muted/15"
:class="isDragOver ? 'bg-primary/5 outline outline-primary/25 -outline-offset-1' : ''"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<!-- Drop hint overlay -->
<div v-if="isDragOver" class="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<span
class="text-xs text-primary font-bold bg-card border-2 border-primary/30 px-4 py-1.5 rounded-full shadow-lg animate-in zoom-in-95 duration-200"
>
Drop to add to {{ label }}
</span>
</div>
<div class="flex flex-col md:flex-row md:items-center gap-4 px-6 py-3.5 transition-colors relative hover:bg-muted/15">
<!-- Enable toggle + label -->
<div class="flex items-center gap-3 md:w-44 shrink-0">
<div
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Loader2, Save, Settings } from 'lucide-vue-next'
import { Loader2, RotateCcw, Save, Settings, Trash2 } from 'lucide-vue-next'
import type { FieldPreference, MetadataFetchPreferences, MetadataField, ProviderStatus } from '@projectx/types'
import FieldPreferenceTable from './FieldPreferenceTable.vue'
@@ -10,7 +10,11 @@ const props = defineProps<{
saving: boolean
}>()
const emit = defineEmits<{ save: [prefs: MetadataFetchPreferences] }>()
const emit = defineEmits<{
save: [prefs: MetadataFetchPreferences]
clearAll: [prefs: MetadataFetchPreferences]
resetToDefault: []
}>()
const draft = ref<MetadataFetchPreferences | null>(null)
@@ -19,10 +23,9 @@ function withDefaultOptions(prefs: MetadataFetchPreferences): MetadataFetchPrefe
...prefs,
options: {
genres: {
mode: prefs.options?.genres.mode ?? 'firstProvider',
providerScope: prefs.options?.genres.providerScope ?? 'selectedProviders',
mode: prefs.options?.genres.mode ?? 'merge',
},
saveProviderIds: prefs.options?.saveProviderIds ?? false,
saveProviderIds: prefs.options?.saveProviderIds ?? true,
},
}
}
@@ -49,6 +52,22 @@ function setGenreMerge(enabled: boolean) {
if (!draft.value?.options) return
draft.value.options.genres.mode = enabled ? 'merge' : 'firstProvider'
}
function toggleSaveProviderIds() {
if (!draft.value?.options) return
draft.value.options.saveProviderIds = !draft.value.options.saveProviderIds
}
function handleClearAll() {
if (!draft.value) return
if (!confirm('Remove all active providers from every field? This will be saved immediately and cannot be undone.')) return
emit('clearAll', draft.value)
}
function handleResetToDefault() {
if (!confirm('Reset all global field rules to system defaults? This will overwrite your current configuration.')) return
emit('resetToDefault')
}
</script>
<template>
@@ -58,11 +77,29 @@ function setGenreMerge(enabled: boolean) {
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">Global Defaults</span>
<p class="settings-hint">Default rules applied to every library. Override per-library below.</p>
</div>
<button class="settings-btn-primary h-8 px-3" :disabled="saving || !draft" @click="save">
<Loader2 v-if="saving" :size="14" class="animate-spin" />
<Save v-else :size="14" />
<span>Save Defaults</span>
</button>
<div class="flex items-center gap-2 flex-wrap">
<button
class="settings-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground border border-border rounded-md hover:text-destructive hover:border-destructive/50 hover:bg-destructive/5 transition-colors disabled:opacity-40"
:disabled="saving || !draft"
@click="handleClearAll"
>
<Trash2 :size="13" />
<span>Clear All Providers</span>
</button>
<button
class="settings-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground border border-border rounded-md hover:text-foreground hover:border-border/80 hover:bg-muted/50 transition-colors disabled:opacity-40"
:disabled="saving"
@click="handleResetToDefault"
>
<RotateCcw :size="13" />
<span>Reset to Default</span>
</button>
<button class="settings-btn-primary h-8 px-3" :disabled="saving || !draft" @click="save">
<Loader2 v-if="saving" :size="14" class="animate-spin" />
<Save v-else :size="14" />
<span>Save Defaults</span>
</button>
</div>
</div>
<div v-if="draft">
@@ -90,33 +127,12 @@ function setGenreMerge(enabled: boolean) {
/>
</div>
<div class="space-y-1">
<span class="text-sm font-medium text-foreground">Merge genres across providers</span>
<p class="text-xs text-muted-foreground">Instead of taking genres from a single provider, combine them from multiple sources.</p>
<span class="text-sm font-medium text-foreground">Combine genres from all selected providers</span>
<p class="text-xs text-muted-foreground">
Collect and deduplicate genres from every provider assigned to the Genres field, rather than stopping at the first result.
</p>
</div>
</label>
<fieldset class="pl-12 space-y-3" :class="draft.options?.genres.mode !== 'merge' ? 'opacity-40 pointer-events-none' : ''">
<label class="flex items-center gap-2.5 cursor-pointer group">
<input
type="radio"
name="genre-provider-scope"
class="h-4 w-4 border-input accent-primary cursor-pointer"
:checked="draft.options?.genres.providerScope === 'selectedProviders'"
@change="draft.options && (draft.options.genres.providerScope = 'selectedProviders')"
/>
<span class="text-sm text-foreground group-hover:text-primary transition-colors">Use only providers selected for Genres field</span>
</label>
<label class="flex items-center gap-2.5 cursor-pointer group">
<input
type="radio"
name="genre-provider-scope"
class="h-4 w-4 border-input accent-primary cursor-pointer"
:checked="draft.options?.genres.providerScope === 'allConfiguredProviders'"
@change="draft.options && (draft.options.genres.providerScope = 'allConfiguredProviders')"
/>
<span class="text-sm text-foreground group-hover:text-primary transition-colors">Use all enabled and configured providers</span>
</label>
</fieldset>
</div>
<!-- IDs Behavior -->
@@ -125,7 +141,7 @@ function setGenreMerge(enabled: boolean) {
<div
class="relative flex h-5 w-9 shrink-0 items-center rounded-full transition-colors mt-0.5"
:class="draft.options?.saveProviderIds ? 'bg-primary' : 'bg-muted border border-border'"
@click.prevent="draft.options && (draft.options.saveProviderIds = !draft.options.saveProviderIds)"
@click.prevent="toggleSaveProviderIds"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
@@ -133,9 +149,10 @@ function setGenreMerge(enabled: boolean) {
/>
</div>
<div class="space-y-1">
<span class="text-sm font-medium text-foreground">Save provider IDs</span>
<span class="text-sm font-medium text-foreground">Store provider IDs on books</span>
<p class="text-xs text-muted-foreground">
Persist IDs (ISBN, ASIN, Goodreads ID, etc.) back to the book records during auto-refresh.
When fetching metadata, save the returned provider IDs (ISBN, ASIN, Goodreads ID, etc.) on the book record for more accurate future
lookups.
</p>
</div>
</label>
@@ -1,49 +1,119 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ChevronDown, ChevronRight, RotateCcw, Library } from 'lucide-vue-next'
import type { FieldPreference, LibraryMetadataPreferences, MetadataField, ProviderStatus } from '@projectx/types'
import { computed, ref, watch, type CSSProperties } from 'vue'
import { ChevronDown, ChevronRight, Loader2, RotateCcw, Library, Trash2, Save } from 'lucide-vue-next'
import type {
FieldPreference,
FieldPreferenceOverrides,
LibraryMetadataPreferences,
MetadataFetchPreferences,
MetadataField,
ProviderStatus,
} from '@projectx/types'
import { ALL_METADATA_FIELDS } from '@projectx/types'
import FieldPreferenceTable from './FieldPreferenceTable.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Badge } from '@/components/ui/badge'
import { getFormatColor } from '@/features/book/lib/format-colors'
const props = defineProps<{
libraryName: string
libraryPrimaryFormat: string | null
libraryPrefs: LibraryMetadataPreferences | null
globalPrefs: MetadataFetchPreferences | null
statuses: ProviderStatus[]
savingField: string | null
saving?: boolean
}>()
const emit = defineEmits<{
fieldChange: [libraryId: number, field: MetadataField, pref: FieldPreference | null]
save: [libraryId: number, overrides: FieldPreferenceOverrides]
reset: [libraryId: number]
}>()
const open = ref(false)
const draft = ref<MetadataFetchPreferences | null>(null)
const pendingChanges = ref<Map<MetadataField, FieldPreference | null>>(new Map())
const hasOverrides = computed(() => {
watch(
() => props.libraryPrefs,
(lp) => {
if (!lp) return
draft.value = JSON.parse(JSON.stringify(lp.effective))
pendingChanges.value = new Map()
},
{ immediate: true },
)
const isDirty = computed(() => pendingChanges.value.size > 0)
const hasPersistedOverrides = computed(() => {
if (!props.libraryPrefs?.overrides) return false
return Object.keys(props.libraryPrefs.overrides).length > 0
})
const overriddenFields = computed<Set<MetadataField>>(() => {
if (!props.libraryPrefs?.overrides) return new Set()
return new Set(Object.keys(props.libraryPrefs.overrides) as MetadataField[])
const base = new Set<MetadataField>(Object.keys(props.libraryPrefs?.overrides ?? {}) as MetadataField[])
for (const [field, pref] of pendingChanges.value) {
if (pref === null) base.delete(field)
else base.add(field)
}
return base
})
const primaryFormat = computed(() => {
if (!props.libraryPrimaryFormat) return 'ANY'
return props.libraryPrimaryFormat.toUpperCase()
})
const primaryFormatStyle = computed<CSSProperties>(() => ({
color: getFormatColor(props.libraryPrimaryFormat),
}))
function toggleOpen() {
open.value = !open.value
}
function onFieldChange(field: MetadataField, pref: FieldPreference) {
if (!props.libraryPrefs) return
emit('fieldChange', props.libraryPrefs.libraryId, field, pref)
if (!draft.value || props.saving) return
draft.value = { ...draft.value, fields: { ...draft.value.fields, [field]: pref } }
pendingChanges.value = new Map(pendingChanges.value).set(field, pref)
}
function onRevert(field: MetadataField) {
if (!props.libraryPrefs) return
emit('fieldChange', props.libraryPrefs.libraryId, field, null)
if (!draft.value || !props.globalPrefs || props.saving) return
const globalField = props.globalPrefs.fields[field]
draft.value = { ...draft.value, fields: { ...draft.value.fields, [field]: { ...globalField } } }
pendingChanges.value = new Map(pendingChanges.value).set(field, null)
}
function onClearAll() {
if (!draft.value) return
if (!confirm(`Remove all active providers from every field in "${props.libraryName}"?`)) return
const updatedFields = { ...draft.value.fields }
const newChanges = new Map(pendingChanges.value)
for (const field of ALL_METADATA_FIELDS) {
const updated = { ...updatedFields[field], providers: [] }
updatedFields[field] = updated
newChanges.set(field, updated)
}
draft.value = { ...draft.value, fields: updatedFields as MetadataFetchPreferences['fields'] }
pendingChanges.value = newChanges
}
function onReset() {
if (!props.libraryPrefs) return
if (!confirm(`Reset "${props.libraryName}" to global defaults? All library overrides will be removed.`)) return
emit('reset', props.libraryPrefs.libraryId)
}
function onSave() {
if (!props.libraryPrefs || !isDirty.value) return
const newOverrides: FieldPreferenceOverrides = { ...props.libraryPrefs.overrides }
for (const [field, pref] of pendingChanges.value) {
if (pref === null) delete newOverrides[field]
else newOverrides[field] = pref
}
emit('save', props.libraryPrefs.libraryId, newOverrides)
}
</script>
<template>
@@ -51,7 +121,8 @@ function onReset() {
class="border border-border rounded-xl bg-card overflow-hidden shadow-sm transition-all"
:class="open ? 'ring-1 ring-primary/20' : 'hover:border-primary/30'"
>
<div class="flex items-center gap-3 px-5 py-4 cursor-pointer select-none" @click="open = !open">
<!-- Accordion toggle row -->
<div class="flex items-center gap-3 px-5 py-4 cursor-pointer select-none" @click="toggleOpen">
<div class="flex items-center gap-4 flex-1 min-w-0">
<div
class="flex items-center justify-center w-8 h-8 rounded-lg bg-muted shrink-0 text-muted-foreground transition-colors"
@@ -62,50 +133,84 @@ function onReset() {
<div class="min-w-0 space-y-0.5">
<div class="flex items-center gap-3">
<span class="text-sm font-semibold text-foreground truncate">{{ libraryName }}</span>
<Badge v-if="hasOverrides" variant="secondary" class="h-4.5 px-1.5 text-[10px] font-bold uppercase tracking-tight">
{{ overriddenFields.size }} {{ overriddenFields.size === 1 ? 'Override' : 'Overrides' }}
<Badge variant="outline" class="h-4.5 px-1.5 text-[10px] font-bold uppercase tracking-tight">
<span class="text-muted-foreground">Primary:</span>
<span class="ml-1" :style="primaryFormatStyle">{{ primaryFormat }}</span>
</Badge>
<Badge v-if="overriddenFields.size > 0" variant="secondary" class="h-4.5 px-1.5 text-[10px] font-bold uppercase tracking-tight">
{{ overriddenFields.size }} {{ overriddenFields.size === 1 ? 'Override' : 'Overrides' }}
<span v-if="isDirty" class="ml-1 opacity-60">(unsaved)</span>
</Badge>
<Badge v-else variant="outline" class="h-4.5 px-1.5 text-[10px] font-bold uppercase tracking-tight opacity-60">
{{ isDirty ? 'Unsaved changes' : 'Global Defaults' }}
</Badge>
<Badge v-else variant="outline" class="h-4.5 px-1.5 text-[10px] font-bold uppercase tracking-tight opacity-60"> Global Defaults </Badge>
</div>
<p class="text-[11px] text-muted-foreground font-mono truncate" v-if="!open">
{{ hasOverrides ? `Overriding: ${Array.from(overriddenFields).join(', ')}` : 'Inheriting all global metadata rules' }}
<p v-if="!open" class="text-[11px] text-muted-foreground font-mono truncate">
{{
hasPersistedOverrides
? `Overriding: ${Array.from(Object.keys(libraryPrefs?.overrides ?? {})).join(', ')}`
: 'Inheriting all global metadata rules'
}}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<Tooltip v-if="hasOverrides">
<TooltipTrigger as-child>
<button
class="shrink-0 flex items-center justify-center h-8 w-8 rounded-md border border-border bg-background text-muted-foreground hover:text-destructive hover:bg-destructive/5 hover:border-destructive/30 transition-all"
@click.stop="onReset"
>
<RotateCcw :size="13" />
</button>
</TooltipTrigger>
<TooltipContent>Reset library to global defaults</TooltipContent>
</Tooltip>
<div class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-muted transition-colors">
<component :is="open ? ChevronDown : ChevronRight" :size="16" class="text-muted-foreground" />
</div>
<div class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-muted transition-colors">
<component :is="open ? ChevronDown : ChevronRight" :size="16" class="text-muted-foreground" />
</div>
</div>
<div v-if="open && libraryPrefs" class="border-t border-border bg-muted/5 animate-in fade-in slide-in-from-top-1 duration-200">
<FieldPreferenceTable
:preferences="libraryPrefs.effective"
:statuses="statuses"
:overridden-fields="overriddenFields"
:saving-field="savingField"
:library-id="libraryPrefs.libraryId"
@change="onFieldChange"
@revert="onRevert"
/>
</div>
<!-- Expanded content -->
<template v-if="open">
<div v-if="draft">
<!-- Action sub-header -->
<div class="px-5 py-4 border-t border-border flex flex-col md:flex-row md:items-center justify-between gap-4 bg-muted/30">
<div>
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">{{ libraryName }}</span>
<p class="settings-hint">Fields not overridden inherit global defaults.</p>
</div>
<div class="flex items-center gap-2 flex-wrap">
<button
class="settings-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground border border-border rounded-md hover:text-destructive hover:border-destructive/50 hover:bg-destructive/5 transition-colors disabled:opacity-40"
:disabled="saving || !draft"
@click="onClearAll"
>
<Trash2 :size="13" />
<span>Clear All Providers</span>
</button>
<Tooltip>
<TooltipTrigger as-child>
<button
class="settings-btn h-8 px-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground border border-border rounded-md hover:text-foreground hover:border-border/80 hover:bg-muted/50 transition-colors disabled:opacity-40"
:disabled="saving"
@click="onReset"
>
<RotateCcw :size="13" />
<span>Reset to Default</span>
</button>
</TooltipTrigger>
<TooltipContent>Remove all overrides and inherit global defaults</TooltipContent>
</Tooltip>
<button class="settings-btn-primary h-8 px-3" :disabled="saving || !isDirty" @click="onSave">
<Loader2 v-if="saving" :size="14" class="animate-spin" />
<Save v-else :size="14" />
<span>Save</span>
</button>
</div>
</div>
<div v-else-if="open && !libraryPrefs" class="border-t border-border px-6 py-10 flex items-center justify-center">
<Loader2 :size="20" class="animate-spin text-muted-foreground" />
</div>
<FieldPreferenceTable
:preferences="draft"
:statuses="statuses"
:overridden-fields="overriddenFields"
:saving="saving"
@change="onFieldChange"
@revert="onRevert"
/>
</div>
<div v-else class="border-t border-border px-6 py-10 flex items-center justify-center">
<Loader2 :size="20" class="animate-spin text-muted-foreground" />
</div>
</template>
</div>
</template>
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { GripVertical, X } from 'lucide-vue-next'
import type { MetadataProviderKey, ProviderStatus } from '@projectx/types'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
const REORDER_TYPE = 'application/x-field-chip-index'
import { PROVIDER_DND_GROUP, toProviderDragItems } from '../lib/provider-drag'
const props = defineProps<{
providers: MetadataProviderKey[]
@@ -14,6 +14,11 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:providers': [value: MetadataProviderKey[]] }>()
interface ProviderChipItem {
key: MetadataProviderKey
dragId: string
}
function statusFor(key: MetadataProviderKey) {
return props.statuses.find((s) => s.key === key)
}
@@ -23,90 +28,104 @@ function isUsable(key: MetadataProviderKey) {
return s ? s.enabled && s.configured : true
}
const draggingIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
const localProviders = ref<ProviderChipItem[]>([])
const isDragging = ref(false)
function onDragStart(index: number, e: DragEvent) {
draggingIndex.value = index
e.dataTransfer!.effectAllowed = 'move'
e.dataTransfer!.setData(REORDER_TYPE, String(index))
e.stopPropagation()
watch(
() => props.providers,
(providers) => {
if (isDragging.value) return
localProviders.value = toProviderDragItems(providers)
},
{ immediate: true },
)
function sameOrder(a: MetadataProviderKey[], b: MetadataProviderKey[]) {
return a.length === b.length && a.every((value, index) => value === b[index])
}
function onDragOver(index: number, e: DragEvent) {
if (!e.dataTransfer?.types.includes(REORDER_TYPE)) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
if (draggingIndex.value !== null && draggingIndex.value !== index) {
dragOverIndex.value = index
function commitProviders() {
const seen = new Set<MetadataProviderKey>()
const deduplicated = localProviders.value.filter((item) => {
if (seen.has(item.key)) return false
seen.add(item.key)
return true
})
if (deduplicated.length !== localProviders.value.length) {
localProviders.value = deduplicated
}
const normalized = deduplicated.map((item) => item.key)
if (!sameOrder(normalized, props.providers)) {
emit('update:providers', normalized)
}
}
function onDragLeave(index: number) {
if (dragOverIndex.value === index) dragOverIndex.value = null
}
function onDrop(targetIndex: number, e: DragEvent) {
if (!e.dataTransfer?.types.includes(REORDER_TYPE)) return
e.stopPropagation()
if (draggingIndex.value === null || draggingIndex.value === targetIndex) return
const updated = [...props.providers]
const [moved] = updated.splice(draggingIndex.value, 1)
updated.splice(targetIndex, 0, moved)
emit('update:providers', updated)
draggingIndex.value = null
dragOverIndex.value = null
function onDragStart() {
isDragging.value = true
}
function onDragEnd() {
draggingIndex.value = null
dragOverIndex.value = null
isDragging.value = false
commitProviders()
}
function removeProvider(index: number) {
const updated = [...props.providers]
const updated = [...localProviders.value]
updated.splice(index, 1)
emit('update:providers', updated)
localProviders.value = updated
commitProviders()
}
</script>
<template>
<div class="flex flex-wrap gap-1.5 min-h-[26px] items-center">
<div
v-for="(key, index) in providers"
:key="key"
:draggable="!disabled"
:title="statusFor(key)?.label ?? key"
class="flex items-center gap-1 h-6 pl-1.5 pr-1 rounded text-xs font-medium select-none"
:style="providerChipStyle(key, !isUsable(key))"
:class="[
!disabled ? 'cursor-grab active:cursor-grabbing' : 'cursor-default',
draggingIndex === index ? 'opacity-30 scale-95' : '',
dragOverIndex === index ? 'ring-2 ring-white/60 scale-105' : '',
]"
style="transition: transform 0.12s ease"
@dragstart="onDragStart(index, $event)"
@dragover="onDragOver(index, $event)"
@dragleave="onDragLeave(index)"
@drop="onDrop(index, $event)"
@dragend="onDragEnd"
<div class="min-h-[26px]">
<VueDraggable
v-model="localProviders"
item-key="dragId"
tag="div"
class="flex flex-wrap gap-1.5 min-h-[26px] items-center"
:animation="150"
:group="{ name: PROVIDER_DND_GROUP, pull: false, put: true }"
handle=".provider-chip-handle"
ghost-class="opacity-40"
chosen-class="provider-chip-chosen"
drag-class="scale-95"
:disabled="disabled"
@start="onDragStart"
@add="commitProviders"
@end="onDragEnd"
>
<GripVertical v-if="!disabled" :size="10" class="opacity-50 shrink-0" />
<span class="opacity-70 tabular-nums leading-none">{{ index + 1 }}</span>
<span>{{ PROVIDER_SHORT_LABELS[key] ?? key }}</span>
<button
v-if="!disabled"
class="ml-0.5 h-4 w-4 flex items-center justify-center rounded-sm opacity-60 hover:opacity-100 hover:bg-white/20 transition-opacity"
@click.stop="removeProvider(index)"
@mousedown.stop
<div
v-for="(item, index) in localProviders"
:key="item.dragId"
:title="statusFor(item.key)?.label ?? item.key"
class="flex items-center gap-1 h-6 pl-1.5 pr-1 rounded text-xs font-medium select-none transition-transform"
:style="providerChipStyle(item.key, !isUsable(item.key))"
:class="!disabled ? 'provider-chip-handle cursor-grab active:cursor-grabbing' : 'cursor-default'"
>
<X :size="12" :stroke-width="3" />
</button>
</div>
<GripVertical v-if="!disabled" :size="10" class="opacity-50 shrink-0" />
<span class="opacity-70 tabular-nums leading-none">{{ index + 1 }}</span>
<span>{{ PROVIDER_SHORT_LABELS[item.key] ?? item.key }}</span>
<button
v-if="!disabled"
class="ml-0.5 h-4 w-4 flex items-center justify-center rounded-sm opacity-60 hover:opacity-100 hover:bg-white/20 transition-opacity"
@click.stop="removeProvider(index)"
@mousedown.stop
>
<X :size="12" :stroke-width="3" />
</button>
</div>
</VueDraggable>
<span v-if="providers.length === 0 && !disabled" class="text-xs text-muted-foreground/60 italic h-6 flex items-center px-1">
<span v-if="localProviders.length === 0 && !disabled" class="text-xs text-muted-foreground/60 italic h-6 flex items-center px-1">
drag a provider here
</span>
</div>
</template>
<style scoped>
.provider-chip-chosen {
outline: 2px solid color-mix(in oklch, var(--primary) 55%, white);
outline-offset: 1px;
}
</style>
@@ -102,11 +102,8 @@ const rows: { key: keyof ProviderConfigurations; label: string; hint?: string; f
{
key: 'audible',
label: 'Audible',
hint: 'Pulls audiobook metadata from Audible product pages. A session cookie is highly recommended.',
fields: [
{ key: 'domain', label: 'Region', type: 'select', options: AUDIBLE_DOMAINS },
{ key: 'cookie', label: 'Cookie', type: 'password' },
],
hint: 'Pulls audiobook metadata from Audible. No additional setup is required.',
fields: [{ key: 'domain', label: 'Region', type: 'select', options: AUDIBLE_DOMAINS }],
},
{ key: 'audnexus', label: 'AudNexus', hint: 'Community-driven audiobook metadata. No setup required.', fields: [] },
{
@@ -160,6 +157,11 @@ function save() {
if (!draft.value) return
emit('save', draft.value)
}
function onSecretFieldFocus(event: FocusEvent) {
const input = event.target as HTMLInputElement | null
if (input?.readOnly) input.readOnly = false
}
</script>
<template>
@@ -230,10 +232,21 @@ function save() {
<input
v-else
v-model="(draft[row.key] as unknown as Record<string, string>)[field.key]"
:type="field.type"
:type="field.type === 'password' ? 'text' : field.type"
:name="`metadata-${row.key}-${field.key}`"
:placeholder="field.label"
:disabled="!draft[row.key].enabled"
:readonly="field.type === 'password'"
:autocomplete="field.type === 'password' ? 'off' : 'off'"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
:data-lpignore="field.type === 'password' ? 'true' : undefined"
:data-1p-ignore="field.type === 'password' ? 'true' : undefined"
:data-form-type="field.type === 'password' ? 'other' : undefined"
:style="field.type === 'password' ? '--webkit-text-security: disc' : undefined"
class="h-9 w-64 lg:w-80 rounded-md border border-input bg-background px-3 text-xs font-medium placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-40 transition-all"
@focus="field.type === 'password' ? onSecretFieldFocus($event) : undefined"
/>
</div>
</div>
@@ -1,40 +1,67 @@
<script setup lang="ts">
import { computed } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { GripVertical } from 'lucide-vue-next'
import type { ProviderStatus } from '@projectx/types'
import type { MetadataProviderKey, ProviderStatus } from '@projectx/types'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { PROVIDER_DND_GROUP, createProviderDragItem, type ProviderDragItem } from '../lib/provider-drag'
defineProps<{
const props = defineProps<{
statuses: ProviderStatus[]
}>()
function onDragStart(key: string, e: DragEvent) {
e.dataTransfer!.effectAllowed = 'copy'
e.dataTransfer!.setData('application/x-provider-key', key)
interface ReservoirProviderItem {
key: MetadataProviderKey
label: string
usable: boolean
}
const reservoirProviders = computed<ReservoirProviderItem[]>(() =>
props.statuses.map((status) => ({
key: status.key as MetadataProviderKey,
label: status.label,
usable: status.enabled && status.configured,
})),
)
function cloneProvider(item: ReservoirProviderItem): ProviderDragItem {
return createProviderDragItem(item.key)
}
function providerTitle(provider: ReservoirProviderItem) {
const status = props.statuses.find((item) => item.key === provider.key)
if (!status?.enabled) return `${provider.label} - disabled`
if (!status?.configured) return `${provider.label} - not configured`
return `Drag to assign ${provider.label} to a field`
}
</script>
<template>
<div class="flex flex-wrap gap-2">
<VueDraggable
:model-value="reservoirProviders"
item-key="key"
tag="div"
class="flex flex-wrap gap-2"
:group="{ name: PROVIDER_DND_GROUP, pull: 'clone', put: false }"
:sort="false"
:animation="150"
:clone="cloneProvider"
handle=".provider-reservoir-handle"
>
<div
v-for="status in statuses"
:key="status.key"
:draggable="status.enabled && status.configured"
:title="
!status.enabled
? `${status.label} - disabled`
: !status.configured
? `${status.label} - not configured`
: `Drag to assign ${status.label} to a field`
"
v-for="provider in reservoirProviders"
:key="provider.key"
:title="providerTitle(provider)"
class="flex items-center gap-1.5 h-7 px-3 rounded text-[10px] font-bold uppercase tracking-tight select-none transition-all shadow-xs"
:style="providerChipStyle(status.key, !(status.enabled && status.configured))"
:style="providerChipStyle(provider.key, !provider.usable)"
:class="
status.enabled && status.configured ? 'cursor-grab active:cursor-grabbing hover:scale-105 active:scale-95' : 'cursor-not-allowed opacity-40'
provider.usable
? 'provider-reservoir-handle cursor-grab active:cursor-grabbing hover:scale-105 active:scale-95'
: 'cursor-not-allowed opacity-40'
"
@dragstart="onDragStart(status.key, $event)"
>
<GripVertical v-if="status.enabled && status.configured" :size="10" class="shrink-0 -ml-1 opacity-50" />
<span>{{ PROVIDER_SHORT_LABELS[status.key] ?? status.key }}</span>
<GripVertical v-if="provider.usable" :size="10" class="shrink-0 -ml-1 opacity-50" />
<span>{{ PROVIDER_SHORT_LABELS[provider.key] ?? provider.key }}</span>
</div>
</div>
</VueDraggable>
</template>
@@ -1,14 +1,15 @@
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import type { FieldPreference, LibraryMetadataPreferences, MetadataFetchPreferences, MetadataField } from '@projectx/types'
import type { FieldPreferenceOverrides, LibraryMetadataPreferences, MetadataFetchPreferences } from '@projectx/types'
import { ALL_METADATA_FIELDS } from '@projectx/types'
export function useMetadataPreferences() {
const globalPrefs = ref<MetadataFetchPreferences | null>(null)
const libraryPrefs = ref<Map<number, LibraryMetadataPreferences>>(new Map())
const loadingGlobal = ref(false)
const savingGlobal = ref(false)
const savingField = ref<string | null>(null)
const savingLibrary = ref<number | null>(null)
async function fetchGlobal() {
loadingGlobal.value = true
@@ -47,26 +48,47 @@ export function useMetadataPreferences() {
}
}
async function saveFieldOverride(libraryId: number, field: MetadataField, pref: FieldPreference | null) {
const key = `${libraryId}:${field}`
savingField.value = key
async function saveLibraryDraft(libraryId: number, overrides: FieldPreferenceOverrides) {
savingLibrary.value = libraryId
try {
const url = `/api/v1/metadata-preferences/libraries/${libraryId}/fields/${field}`
const res = await api(url, {
method: pref === null ? 'DELETE' : 'PUT',
headers: pref !== null ? { 'Content-Type': 'application/json' } : undefined,
body: pref !== null ? JSON.stringify(pref) : undefined,
const res = await api(`/api/v1/metadata-preferences/libraries/${libraryId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overrides }),
})
if (res.ok || res.status === 204) {
if (res.ok) {
await fetchLibrary(libraryId)
toast.success('Library preferences saved')
} else {
toast.error('Failed to save field override')
toast.error('Failed to save library preferences')
}
} finally {
savingField.value = null
savingLibrary.value = null
}
}
async function resetGlobal() {
const res = await api('/api/v1/metadata-preferences/global', { method: 'DELETE' })
if (res.ok || res.status === 204) {
await fetchGlobal()
toast.success('Global preferences reset to system defaults')
} else {
toast.error('Failed to reset global preferences')
}
}
async function clearAllProviders(prefs: MetadataFetchPreferences) {
const fields: MetadataFetchPreferences['fields'] = { ...prefs.fields }
for (const field of ALL_METADATA_FIELDS) {
fields[field] = { ...prefs.fields[field], providers: [] }
}
const cleared: MetadataFetchPreferences = {
...prefs,
fields,
}
await saveGlobal(cleared)
}
async function resetLibrary(libraryId: number) {
const res = await api(`/api/v1/metadata-preferences/libraries/${libraryId}`, { method: 'DELETE' })
if (res.ok || res.status === 204) {
@@ -82,11 +104,13 @@ export function useMetadataPreferences() {
libraryPrefs,
loadingGlobal,
savingGlobal,
savingField,
savingLibrary,
fetchGlobal,
saveGlobal,
resetGlobal,
clearAllProviders,
fetchLibrary,
saveFieldOverride,
saveLibraryDraft,
resetLibrary,
}
}
@@ -0,0 +1,30 @@
import type { MetadataProviderKey } from '@projectx/types'
export interface ProviderDragItem {
key: MetadataProviderKey
dragId: string
}
export const PROVIDER_DND_GROUP = 'metadata-preferences-provider'
let providerDragId = 0
export function createProviderDragItem(key: MetadataProviderKey): ProviderDragItem {
providerDragId += 1
return { key, dragId: `${key}:${providerDragId}` }
}
export function toProviderDragItems(providers: MetadataProviderKey[]): ProviderDragItem[] {
return providers.map((key) => createProviderDragItem(key))
}
export function normalizeProviderOrder(items: ProviderDragItem[]): MetadataProviderKey[] {
const next: MetadataProviderKey[] = []
const seen = new Set<MetadataProviderKey>()
for (const item of items) {
if (seen.has(item.key)) continue
seen.add(item.key)
next.push(item.key)
}
return next
}
@@ -37,7 +37,6 @@ export const ALL_METADATA_FIELDS: MetadataField[] = [
export type MergeStrategy = "fillMissing" | "overwrite" | "overwriteIfProvided";
export type GenreMergeMode = "firstProvider" | "merge";
export type GenreProviderScope = "selectedProviders" | "allConfiguredProviders";
export interface FieldPreference {
enabled: boolean;
@@ -50,7 +49,6 @@ export type FieldPreferenceOverrides = Partial<Record<MetadataField, FieldPrefer
export interface MetadataFetchOptions {
genres: {
mode: GenreMergeMode;
providerScope: GenreProviderScope;
};
saveProviderIds: boolean;
}
@@ -178,6 +178,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
isbn: meta?.isbn13 ?? meta?.isbn10 ?? undefined,
existingProviderIds: this.collectProviderIds(meta ?? {}),
isAudiobook: (meta?.durationSeconds !== null && meta?.durationSeconds !== undefined) || !!meta?.audibleId,
maxCandidatesPerProvider: 1,
};
const existingFields: Partial<Record<MetadataField, unknown>> = {
@@ -361,6 +361,7 @@ describe('BookService', () => {
[MetadataProviderKey.OPEN_LIBRARY]: 'ol-id',
},
isAudiobook: false,
maxCandidatesPerProvider: 1,
},
{
title: 'Old Title',
@@ -476,6 +477,48 @@ describe('BookService', () => {
);
});
it('refreshMetadata persists comic metadata returned by pipeline', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Author One', sortName: null }],
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: {
title: 'Resolved',
comicMetadata: {
issueNumber: '12',
volumeName: 'Arkham Asylum',
pencillers: ['Jock'],
},
},
sources: {},
providerIds: {},
});
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({ id: 1 } as never);
await service.refreshMetadata(1, false, user);
expect(updateSpy).toHaveBeenCalledWith(
1,
{
title: 'Resolved',
comicMetadata: {
issueNumber: '12',
volumeName: 'Arkham Asylum',
pencillers: ['Jock'],
},
},
user,
);
});
it('updateMetadata writes scalar fields, collections, schedules file write, and triggers embedding', async () => {
const { service, bookRepo, metadataService, embedder, fileWriteService } = makeService();
const user = makeUser();
+3
View File
@@ -488,6 +488,7 @@ export class BookService {
isbn: meta?.isbn13 ?? meta?.isbn10 ?? undefined,
existingProviderIds: providerIds,
isAudiobook: (meta?.durationSeconds !== null && meta?.durationSeconds !== undefined) || !!meta?.audibleId,
maxCandidatesPerProvider: 1,
};
const existingFields: Partial<Record<MetadataField, unknown>> = {
@@ -518,6 +519,7 @@ export class BookService {
openLibraryId?: string;
itunesId?: string;
audibleId?: string;
comicvineId?: string;
} = { ...resolved };
this.applyResolvedProviderIds(previewResult, resolvedProviderIds);
return previewResult;
@@ -538,6 +540,7 @@ export class BookService {
if (r.seriesIndex !== undefined) dto.seriesIndex = r.seriesIndex as number | null;
if (r.duration !== undefined) dto.durationSeconds = r.duration as number | null;
if (r.abridged !== undefined) dto.abridged = r.abridged as boolean | null;
if (r.comicMetadata !== undefined) dto.comicMetadata = r.comicMetadata as UpdateBookMetadataDto['comicMetadata'];
this.applyResolvedProviderIds(dto, resolvedProviderIds);
let detail: BookDetailDto | undefined;
@@ -11,7 +11,6 @@ import { of } from 'rxjs';
import { MetadataPreferenceResolver } from '../metadata-preferences/metadata-preference-resolver';
import { MetadataPreferencesService } from '../metadata-preferences/metadata-preferences.service';
import { ProviderConfigService } from '../metadata-preferences/provider-config.service';
import { MetadataFetchPipeline } from './metadata-fetch-pipeline';
import { MetadataFetchService } from './metadata-fetch.service';
import { ProviderRegistry } from './provider-registry';
@@ -46,7 +45,6 @@ describe('MetadataFetchPipeline', () => {
let fetchService: Mocked<MetadataFetchService>;
let preferencesService: Mocked<MetadataPreferencesService>;
let resolver: Mocked<MetadataPreferenceResolver>;
let providerConfigService: Mocked<ProviderConfigService>;
let registry: Mocked<ProviderRegistry>;
let pipeline: MetadataFetchPipeline;
@@ -65,16 +63,12 @@ describe('MetadataFetchPipeline', () => {
withForwardCompatibility: vi.fn(),
} as unknown as Mocked<MetadataPreferenceResolver>;
providerConfigService = {
getProviderStatuses: vi.fn(),
} as unknown as Mocked<ProviderConfigService>;
registry = {
all: vi.fn(),
} as unknown as Mocked<ProviderRegistry>;
const throttleTracker = { isThrottled: vi.fn().mockReturnValue(false) } as unknown as ProviderThrottleTracker;
pipeline = new MetadataFetchPipeline(fetchService, preferencesService, resolver, providerConfigService, registry, throttleTracker);
pipeline = new MetadataFetchPipeline(fetchService, preferencesService, resolver, registry, throttleTracker);
});
it('derives enabled provider keys from active fields, filters unknown providers, and de-duplicates keys', async () => {
@@ -189,6 +183,45 @@ describe('MetadataFetchPipeline', () => {
expect(sources.coverUrl).toBe(MetadataProviderKey.OPEN_LIBRARY);
});
it('passes through comic metadata from the preferred provider', async () => {
const prefs = createPreferences((fields) => {
fields.title = {
enabled: true,
providers: [MetadataProviderKey.COMICVINE, MetadataProviderKey.AMAZON],
mergeStrategy: 'overwriteIfProvided',
};
fields.seriesName = {
enabled: true,
providers: [MetadataProviderKey.COMICVINE, MetadataProviderKey.AMAZON],
mergeStrategy: 'overwriteIfProvided',
};
});
preferencesService.getGlobal.mockResolvedValue(prefs);
resolver.resolve.mockReturnValue(prefs);
resolver.withForwardCompatibility.mockReturnValue(prefs);
registry.all.mockReturnValue([{ key: MetadataProviderKey.COMICVINE }, { key: MetadataProviderKey.AMAZON }] as never);
fetchService.search.mockReturnValue(
of(
candidate(MetadataProviderKey.COMICVINE, 'cv1', {
comicMetadata: {
issueNumber: '12',
volumeName: 'Arkham Asylum',
pencillers: ['Jock'],
},
}),
),
);
const { resolved } = await pipeline.runWithSources({ title: 'Arkham Asylum #12' }, {});
expect(resolved.comicMetadata).toEqual({
issueNumber: '12',
volumeName: 'Arkham Asylum',
pencillers: ['Jock'],
});
});
it('merges genres from selected providers when genre merge mode is enabled', async () => {
const prefs = createPreferences((fields) => {
fields.genres = {
@@ -198,7 +231,7 @@ describe('MetadataFetchPipeline', () => {
};
});
prefs.options = {
genres: { mode: 'merge', providerScope: 'selectedProviders' },
genres: { mode: 'merge' },
saveProviderIds: false,
};
@@ -219,48 +252,6 @@ describe('MetadataFetchPipeline', () => {
expect(sources.genres).toBe(MetadataProviderKey.GOOGLE);
});
it('merges genres from all enabled and configured providers when scope is allConfiguredProviders', async () => {
const prefs = createPreferences((fields) => {
for (const f of ALL_METADATA_FIELDS) {
fields[f] = {
...fields[f],
providers: [MetadataProviderKey.GOOGLE],
};
}
fields.genres = {
enabled: true,
providers: [MetadataProviderKey.GOOGLE],
mergeStrategy: 'overwriteIfProvided',
};
});
prefs.options = {
genres: { mode: 'merge', providerScope: 'allConfiguredProviders' },
saveProviderIds: false,
};
preferencesService.getGlobal.mockResolvedValue(prefs);
resolver.resolve.mockReturnValue(prefs);
resolver.withForwardCompatibility.mockReturnValue(prefs);
providerConfigService.getProviderStatuses.mockResolvedValue([
{ key: MetadataProviderKey.GOOGLE, label: 'Google', enabled: true, configured: true },
{ key: MetadataProviderKey.AMAZON, label: 'Amazon', enabled: true, configured: true },
{ key: MetadataProviderKey.OPEN_LIBRARY, label: 'OpenLibrary', enabled: false, configured: true },
] as never);
registry.all.mockReturnValue([
{ key: MetadataProviderKey.GOOGLE },
{ key: MetadataProviderKey.AMAZON },
{ key: MetadataProviderKey.OPEN_LIBRARY },
] as never);
fetchService.search.mockReturnValue(
of(candidate(MetadataProviderKey.GOOGLE, 'g1', { genres: ['Fantasy'] }), candidate(MetadataProviderKey.AMAZON, 'a1', { genres: ['Epic'] })),
);
const { resolved } = await pipeline.runWithSources({ title: 'Query' }, {});
expect(fetchService.search).toHaveBeenCalledWith({ title: 'Query' }, [MetadataProviderKey.GOOGLE, MetadataProviderKey.AMAZON]);
expect(resolved.genres).toEqual(['Fantasy', 'Epic']);
});
it('returns provider ids for matched providers when saveProviderIds is enabled', async () => {
const prefs = createPreferences((fields) => {
fields.title = {
@@ -270,7 +261,7 @@ describe('MetadataFetchPipeline', () => {
};
});
prefs.options = {
genres: { mode: 'firstProvider', providerScope: 'selectedProviders' },
genres: { mode: 'firstProvider' },
saveProviderIds: true,
};
@@ -295,7 +286,7 @@ describe('MetadataFetchPipeline', () => {
};
});
prefs.options = {
genres: { mode: 'firstProvider', providerScope: 'selectedProviders' },
genres: { mode: 'firstProvider' },
saveProviderIds: false,
};
@@ -1,10 +1,17 @@
import { Injectable, Logger } from '@nestjs/common';
import { AudiobookChapter, FieldPreference, MetadataCandidate, MetadataFetchPreferences, MetadataField, MetadataProviderKey } from '@projectx/types';
import {
AudiobookChapter,
ComicMetadataFields,
FieldPreference,
MetadataCandidate,
MetadataFetchPreferences,
MetadataField,
MetadataProviderKey,
} from '@projectx/types';
import { firstValueFrom, toArray } from 'rxjs';
import { MetadataPreferenceResolver } from '../metadata-preferences/metadata-preference-resolver';
import { MetadataPreferencesService } from '../metadata-preferences/metadata-preferences.service';
import { ProviderConfigService } from '../metadata-preferences/provider-config.service';
import { MetadataFetchService } from './metadata-fetch.service';
import { ProviderRegistry } from './provider-registry';
import { ProviderThrottleTracker } from './provider-throttle.tracker';
@@ -13,6 +20,7 @@ import { MetadataSearchParams } from './providers/metadata-search-params';
export type ResolvedMetadataFields = Partial<Record<MetadataField, string | string[] | number | null>> & {
coverUrl?: string;
chapters?: AudiobookChapter[];
comicMetadata?: ComicMetadataFields;
};
type ResolvedProviderIds = Partial<Record<MetadataProviderKey, string>>;
@@ -24,7 +32,6 @@ export class MetadataFetchPipeline {
private readonly fetchService: MetadataFetchService,
private readonly preferencesService: MetadataPreferencesService,
private readonly resolver: MetadataPreferenceResolver,
private readonly providerConfigService: ProviderConfigService,
private readonly registry: ProviderRegistry,
private readonly throttleTracker: ProviderThrottleTracker,
) {}
@@ -56,7 +63,7 @@ export class MetadataFetchPipeline {
const registeredKeys = this.registry.all().map((p) => p.key) as MetadataProviderKey[];
const preferences: MetadataFetchPreferences = this.resolver.withForwardCompatibility(this.resolver.resolve(global, overrides), registeredKeys);
const enabledProviders = await this.deriveProviderSet(preferences, registeredKeys);
const enabledProviders = this.deriveProviderSet(preferences, registeredKeys);
const candidates = await firstValueFrom(this.fetchService.search(params, enabledProviders).pipe(toArray()), {
defaultValue: [] as MetadataCandidate[],
});
@@ -65,32 +72,22 @@ export class MetadataFetchPipeline {
for (const c of candidates) {
if (!byProvider.has(c.provider)) byProvider.set(c.provider, c);
}
return this.applyPreferences(preferences, byProvider, existingFields, registeredKeys);
return this.applyPreferences(preferences, byProvider, existingFields);
}
private async deriveProviderSet(preferences: MetadataFetchPreferences, registeredKeys: MetadataProviderKey[]) {
private deriveProviderSet(preferences: MetadataFetchPreferences, registeredKeys: MetadataProviderKey[]) {
const registered = new Set(registeredKeys);
const keys = new Set<MetadataProviderKey>();
const mergeGenresFromAllConfigured =
preferences.fields.genres.enabled &&
preferences.options?.genres.mode === 'merge' &&
preferences.options?.genres.providerScope === 'allConfiguredProviders';
if (mergeGenresFromAllConfigured) {
const configuredKeys = await this.getEnabledConfiguredProviders(registered);
configuredKeys.forEach((k) => keys.add(k));
}
for (const [field, fp] of Object.entries(preferences.fields) as [MetadataField, FieldPreference][]) {
for (const [, fp] of Object.entries(preferences.fields) as [MetadataField, FieldPreference][]) {
if (!fp.enabled) continue;
if (field === 'genres' && mergeGenresFromAllConfigured) continue;
fp.providers.filter((k) => registered.has(k)).forEach((k) => keys.add(k));
}
const active: MetadataProviderKey[] = [];
for (const key of keys) {
if (this.throttleTracker.isThrottled(key)) {
this.logger.debug(`[${key}] skipped - currently throttled`);
this.logger.warn(`[${key}] throttled - skipping metadata fetch for this run`);
} else {
active.push(key);
}
@@ -98,20 +95,10 @@ export class MetadataFetchPipeline {
return active;
}
private async getEnabledConfiguredProviders(registered: Set<MetadataProviderKey>): Promise<MetadataProviderKey[]> {
try {
const statuses = await this.providerConfigService.getProviderStatuses();
return statuses.filter((s) => s.enabled && s.configured && registered.has(s.key)).map((s) => s.key);
} catch {
return [...registered];
}
}
private applyPreferences(
preferences: MetadataFetchPreferences,
byProvider: Map<string, MetadataCandidate>,
existing: Partial<Record<MetadataField, unknown>>,
providerOrder: MetadataProviderKey[],
): { resolved: ResolvedMetadataFields; sources: Record<string, string>; providerIds: ResolvedProviderIds } {
const result: ResolvedMetadataFields = {};
const sources: Record<string, string> = {};
@@ -121,9 +108,7 @@ export class MetadataFetchPipeline {
if (!fp.enabled) continue;
if (field === 'genres' && preferences.options?.genres.mode === 'merge') {
const providerKeys =
preferences.options.genres.providerScope === 'allConfiguredProviders' ? providerOrder : (fp.providers as MetadataProviderKey[]);
const { genres, sourceProvider } = this.mergeGenres(providerKeys, byProvider);
const { genres, sourceProvider } = this.mergeGenres(fp.providers as MetadataProviderKey[], byProvider);
if (!genres.length) continue;
const existingValue = existing[field];
@@ -193,6 +178,9 @@ export class MetadataFetchPipeline {
}
}
const comicMetadata = this.resolveComicMetadata(preferences, byProvider);
if (comicMetadata) result.comicMetadata = comicMetadata;
return { resolved: result, sources, providerIds };
}
@@ -246,4 +234,47 @@ export class MetadataFetchPipeline {
if (Array.isArray(value)) return value.length === 0;
return false;
}
private resolveComicMetadata(preferences: MetadataFetchPreferences, byProvider: Map<string, MetadataCandidate>): ComicMetadataFields | undefined {
const preferredProviders = [
...(preferences.fields.seriesName?.providers ?? []),
...(preferences.fields.title?.providers ?? []),
...byProvider.keys(),
] as MetadataProviderKey[];
const seen = new Set<MetadataProviderKey>();
for (const providerKey of preferredProviders) {
if (seen.has(providerKey)) continue;
seen.add(providerKey);
const comicMetadata = byProvider.get(providerKey)?.comicMetadata;
if (comicMetadata && this.hasComicMetadata(comicMetadata)) return comicMetadata;
}
return undefined;
}
private hasComicMetadata(comicMetadata: ComicMetadataFields): boolean {
const scalarFields: (keyof ComicMetadataFields)[] = ['issueNumber', 'volumeName'];
for (const field of scalarFields) {
const value = comicMetadata[field];
if (typeof value === 'string' && value.trim().length > 0) return true;
}
const arrayFields: (keyof ComicMetadataFields)[] = [
'storyArcs',
'pencillers',
'inkers',
'colorists',
'letterers',
'coverArtists',
'characters',
'teams',
'locations',
];
for (const field of arrayFields) {
const value = comicMetadata[field];
if (Array.isArray(value) && value.length > 0) return true;
}
return false;
}
}
@@ -54,7 +54,7 @@ export class MetadataFetchController {
const requestedAudiobookProvider = (dto.providers ?? []).some(
(provider) => provider === MetadataProviderKey.AUDIBLE || provider === MetadataProviderKey.AUDNEXUS,
);
const isAudiobook = dto.isAudiobook ?? (requestedAudiobookProvider || Boolean(existingProviderIds[MetadataProviderKey.AUDIBLE]));
const isAudiobook = requestedAudiobookProvider ? true : (dto.isAudiobook ?? Boolean(existingProviderIds[MetadataProviderKey.AUDIBLE]));
const params: MetadataSearchParams = {
title: normalizeSearchTitle(dto.title),
@@ -146,6 +146,26 @@ describe('AmazonProvider', () => {
expect(result).toHaveLength(2);
});
it('should limit lookup fan-out when maxCandidatesPerProvider is set', async () => {
const searchHtml = `
<div data-component-type="s-search-result" data-asin="ASIN000001"></div>
<div data-component-type="s-search-result" data-asin="ASIN000002"></div>
<div data-component-type="s-search-result" data-asin="ASIN000003"></div>
`;
const bookHtml = `<span id="productTitle">Book</span>`;
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(searchHtml) })
.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(bookHtml) });
const result = await provider.search({ title: 'Test', maxCandidatesPerProvider: 1 });
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(result).toHaveLength(1);
expect(result[0].providerId).toBe('ASIN000001');
});
it('should handle fetch failure', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
const result = await provider.search({ title: 'Test' });
@@ -37,10 +37,11 @@ export class AmazonProvider implements IdentifiableProvider {
const { enabled, domain, cookie } = await this.providerConfig.getConfig().then((c) => c.amazon);
if (!enabled) return [];
const asins = await this.searchAsins(params, domain, cookie);
const maxCandidates = normalizeMaxCandidates(params.maxCandidatesPerProvider);
const asins = await this.searchAsins(params, domain, cookie, maxCandidates);
const results: MetadataCandidate[] = [];
for (const asin of asins.slice(0, MAX_RESULTS)) {
for (const asin of asins.slice(0, maxCandidates)) {
if (results.length > 0) await sleep(BETWEEN_REQUESTS_MS);
const candidate = await this.fetchByAsin(asin, domain, cookie);
if (candidate) results.push(candidate);
@@ -54,12 +55,12 @@ export class AmazonProvider implements IdentifiableProvider {
return this.fetchByAsin(providerId, domain, cookie);
}
private async searchAsins(params: MetadataSearchParams, domain: string, cookie: string): Promise<string[]> {
private async searchAsins(params: MetadataSearchParams, domain: string, cookie: string, limit: number): Promise<string[]> {
const query = params.isbn?.trim() || [params.title, params.author].filter(Boolean).join(' ');
if (!query) return [];
const url = `https://www.${domain}/s?k=${encodeURIComponent(query)}&i=stripbooks`;
const html = await this.fetchHtml(url, cookie, 'search', query);
return html ? extractAsins(html, MAX_RESULTS) : [];
return html ? extractAsins(html, limit) : [];
}
private async fetchByAsin(asin: string, domain: string, cookie: string): Promise<MetadataCandidate | null> {
@@ -124,3 +125,10 @@ export class AmazonProvider implements IdentifiableProvider {
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeMaxCandidates(value: number | undefined): number {
if (!Number.isFinite(value) || value == null) return MAX_RESULTS;
const rounded = Math.floor(value);
if (rounded < 1) return 1;
return Math.min(rounded, MAX_RESULTS);
}
@@ -6,7 +6,7 @@ import type { AudNexusBook, AudNexusChaptersResponse } from './audnexus.types';
function makeBook(overrides: Partial<AudNexusBook> = {}): AudNexusBook {
return {
asin: 'B09ABCDEF1',
name: 'Project Hail Mary',
title: 'Project Hail Mary',
...overrides,
};
}
@@ -24,11 +24,21 @@ describe('mapAudNexusBook — basic fields', () => {
expect(result.audibleId).toBe('B09XYZ1234');
});
it('maps name to title', () => {
const result = mapAudNexusBook(makeBook({ name: 'The Martian' }));
it('maps title to title', () => {
const result = mapAudNexusBook(makeBook({ title: 'The Martian' }));
expect(result.title).toBe('The Martian');
});
it('falls back to legacy name when title is missing', () => {
const result = mapAudNexusBook(makeBook({ title: undefined, name: 'The Martian (Legacy)' }));
expect(result.title).toBe('The Martian (Legacy)');
});
it('maps subtitle', () => {
const result = mapAudNexusBook(makeBook({ subtitle: 'A Novel' }));
expect(result.subtitle).toBe('A Novel');
});
it('maps authors array', () => {
const result = mapAudNexusBook(makeBook({ authors: [{ name: 'Andy Weir', asin: 'B000AP9GVK' }] }));
expect(result.authors).toEqual(['Andy Weir']);
@@ -47,9 +57,14 @@ describe('mapAudNexusBook — basic fields', () => {
expect(mapAudNexusBook(makeBook()).narrators).toEqual([]);
});
it('maps summary to description', () => {
const result = mapAudNexusBook(makeBook({ summary: 'A lone astronaut.' }));
expect(result.description).toBe('A lone astronaut.');
it('prefers description over summary', () => {
const result = mapAudNexusBook(makeBook({ description: 'Plain text description', summary: '<p>HTML summary</p>' }));
expect(result.description).toBe('Plain text description');
});
it('strips HTML from summary when description is absent', () => {
const result = mapAudNexusBook(makeBook({ summary: '<p>A lone <b>astronaut</b>.</p>' }));
expect(result.description).toBe('A lone astronaut .');
});
it('maps publisherName', () => {
@@ -119,6 +134,16 @@ describe('mapAudNexusBook — abridged', () => {
// ── SERIES ───────────────────────────────────────────────────────────────────
describe('mapAudNexusBook — series', () => {
it('maps seriesPrimary and numeric position from current payload', () => {
const result = mapAudNexusBook(
makeBook({
seriesPrimary: { asin: 'B0SERIES', name: 'The Expanse', position: '3' },
}),
);
expect(result.seriesName).toBe('The Expanse');
expect(result.seriesIndex).toBe(3);
});
it('maps seriesName and numeric seriesPart', () => {
const result = mapAudNexusBook(makeBook({ seriesName: 'The Expanse', seriesPart: '3' }));
expect(result.seriesName).toBe('The Expanse');
@@ -2,6 +2,18 @@ import { AudiobookChapter, MetadataCandidate, MetadataProviderKey } from '@proje
import { AudNexusBook, AudNexusChaptersResponse } from './audnexus.types';
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
}
export function mapAudNexusBook(book: AudNexusBook, chaptersResponse?: AudNexusChaptersResponse): MetadataCandidate {
let publishedYear: number | undefined;
if (book.releaseDate) {
@@ -13,21 +25,28 @@ export function mapAudNexusBook(book: AudNexusBook, chaptersResponse?: AudNexusC
const abridged = book.formatType != null ? book.formatType.toLowerCase() === 'abridged' : undefined;
const seriesIndex = book.seriesPart != null ? parseFloat(book.seriesPart) : undefined;
const seriesRaw = book.seriesPrimary?.position ?? book.seriesPart;
const seriesIndex = seriesRaw != null ? parseFloat(String(seriesRaw)) : undefined;
const chapters: AudiobookChapter[] | undefined = chaptersResponse?.chapters?.map((ch) => ({
title: ch.title,
startMs: ch.startOffsetMs,
durationMs: ch.lengthMs,
}));
const chapters: AudiobookChapter[] | undefined = Array.isArray(chaptersResponse?.chapters)
? chaptersResponse.chapters.map((ch) => ({
title: ch.title,
startMs: ch.startOffsetMs,
durationMs: ch.lengthMs,
}))
: undefined;
const description = book.description?.trim() || (book.summary ? stripHtml(book.summary) : undefined);
const title = book.title ?? book.name ?? '';
return {
provider: MetadataProviderKey.AUDNEXUS,
providerId: book.asin,
title: book.name,
authors: book.authors?.map((a) => a.name) ?? [],
narrators: book.narrators?.map((n) => n.name) ?? [],
description: book.summary,
title,
subtitle: book.subtitle,
authors: Array.isArray(book.authors) ? book.authors.map((a) => a.name) : [],
narrators: Array.isArray(book.narrators) ? book.narrators.map((n) => n.name) : [],
description,
publisher: book.publisherName,
publishedYear,
language: book.language,
@@ -35,7 +54,7 @@ export function mapAudNexusBook(book: AudNexusBook, chaptersResponse?: AudNexusC
durationSeconds,
abridged,
audibleId: book.asin,
seriesName: book.seriesName,
seriesName: book.seriesPrimary?.name ?? book.seriesName,
seriesIndex: !isNaN(seriesIndex ?? NaN) ? seriesIndex : undefined,
chapters,
};
@@ -1,14 +1,25 @@
export interface AudNexusBook {
asin: string;
name: string;
// Historical field (older AudNexus payloads)
name?: string;
// Current field
title?: string;
subtitle?: string;
authors?: Array<{ name: string; asin: string }>;
narrators?: Array<{ name: string }>;
image?: string;
language?: string;
runtimeLengthMin?: number;
formatType?: string;
description?: string;
seriesPrimary?: {
asin?: string;
name?: string;
position?: string | number;
};
// Historical fields (older AudNexus payloads)
seriesName?: string;
seriesPart?: string;
seriesPart?: string | number;
publisherName?: string;
releaseDate?: string;
summary?: string;
@@ -9,6 +9,7 @@ import { mapIssueToCandidate } from './comicvine.mapper';
import { ComicVineIssue } from './comicvine.types';
const ISSUE_PATTERN = /^(.*?)\s*#(\d[\d.]*)(.*)$/;
const MAX_RESULTS = 10;
interface ParsedIssueTitle {
seriesName: string;
@@ -55,12 +56,13 @@ export class ComicVineProvider implements IdentifiableProvider {
}
if (!params.title) return [];
const maxCandidates = normalizeMaxCandidates(params.maxCandidatesPerProvider);
const parsed = parseIssueTitle(params.title);
if (parsed) {
return this.structuredSearch(parsed.seriesName, parsed.issueNumber, apiKey);
return this.structuredSearch(parsed.seriesName, parsed.issueNumber, apiKey, maxCandidates);
}
return this.generalSearch(params.title, apiKey);
return this.generalSearch(params.title, apiKey, maxCandidates);
}
async lookupById(providerId: string): Promise<MetadataCandidate | null> {
@@ -73,7 +75,7 @@ export class ComicVineProvider implements IdentifiableProvider {
return mapIssueToCandidate(issue);
}
private async structuredSearch(seriesName: string, issueNumber: string, apiKey: string): Promise<MetadataCandidate[]> {
private async structuredSearch(seriesName: string, issueNumber: string, apiKey: string, maxCandidates: number): Promise<MetadataCandidate[]> {
const volumes = await this.client.searchVolumes(seriesName, apiKey);
if (volumes.length === 0) {
this.logger.debug(`ComicVine: no volumes found for "${seriesName}"`);
@@ -89,7 +91,7 @@ export class ComicVineProvider implements IdentifiableProvider {
for (const volume of sorted.slice(0, 8)) {
const issues = await this.client.searchIssuesInVolume(volume.id, issueNumber, apiKey);
if (issues.length > 0) {
const enriched = await Promise.all(issues.map((issue) => this.enrichWithDetails(issue, apiKey)));
const enriched = await Promise.all(issues.slice(0, maxCandidates).map((issue) => this.enrichWithDetails(issue, apiKey)));
return enriched.map(mapIssueToCandidate);
}
}
@@ -98,9 +100,9 @@ export class ComicVineProvider implements IdentifiableProvider {
return [];
}
private async generalSearch(query: string, apiKey: string): Promise<MetadataCandidate[]> {
private async generalSearch(query: string, apiKey: string, maxCandidates: number): Promise<MetadataCandidate[]> {
const issues = await this.client.searchIssues(query, apiKey);
const enriched = await Promise.all(issues.map((issue) => this.enrichWithDetails(issue, apiKey)));
const enriched = await Promise.all(issues.slice(0, maxCandidates).map((issue) => this.enrichWithDetails(issue, apiKey)));
return enriched.map(mapIssueToCandidate);
}
@@ -111,3 +113,10 @@ export class ComicVineProvider implements IdentifiableProvider {
return detailed ?? issue;
}
}
function normalizeMaxCandidates(value: number | undefined): number {
if (!Number.isFinite(value) || value == null) return MAX_RESULTS;
const rounded = Math.floor(value);
if (rounded < 1) return 1;
return Math.min(rounded, MAX_RESULTS);
}
@@ -6,4 +6,7 @@ export interface MetadataSearchParams {
isbn?: string;
existingProviderIds?: Partial<Record<MetadataProviderKey, string>>;
isAudiobook?: boolean;
// Hint for providers to cap deep candidate exploration in non-interactive flows
// (e.g. auto-fill/background refresh where there is no manual candidate picking).
maxCandidatesPerProvider?: number;
}
@@ -104,7 +104,6 @@ describe('UpdateGlobalPreferencesDto', () => {
options: {
genres: {
mode: 'merge',
providerScope: 'allConfiguredProviders',
},
saveProviderIds: true,
},
@@ -125,7 +124,6 @@ describe('UpdateGlobalPreferencesDto', () => {
options: {
genres: {
mode: 'invalid',
providerScope: 'invalid',
},
saveProviderIds: 'yes',
},
@@ -12,11 +12,10 @@ import {
} from 'class-validator';
import { plainToInstance, Type } from 'class-transformer';
import { ALL_METADATA_FIELDS, MetadataProviderKey } from '@projectx/types';
import type { GenreMergeMode, GenreProviderScope, MetadataField, MergeStrategy } from '@projectx/types';
import type { GenreMergeMode, MetadataField, MergeStrategy } from '@projectx/types';
const MERGE_STRATEGIES: MergeStrategy[] = ['fillMissing', 'overwrite', 'overwriteIfProvided'];
const GENRE_MERGE_MODES: GenreMergeMode[] = ['firstProvider', 'merge'];
const GENRE_PROVIDER_SCOPES: GenreProviderScope[] = ['selectedProviders', 'allConfiguredProviders'];
const PROVIDER_KEYS = Object.values(MetadataProviderKey);
export class FieldPreferenceDto {
@@ -35,9 +34,6 @@ export class FieldPreferenceDto {
class GenreOptionsDto {
@IsIn(GENRE_MERGE_MODES)
mode!: GenreMergeMode;
@IsIn(GENRE_PROVIDER_SCOPES)
providerScope!: GenreProviderScope;
}
export class MetadataFetchOptionsDto {
@@ -50,7 +46,7 @@ export class MetadataFetchOptionsDto {
}
@ValidatorConstraint({ name: 'isFieldPreferencesMap', async: false })
class IsFieldPreferencesMapConstraint implements ValidatorConstraintInterface {
export class IsFieldPreferencesMapConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const knownFields = new Set<string>(ALL_METADATA_FIELDS);
@@ -1,69 +0,0 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { MetadataProviderKey } from '@projectx/types';
import { UpdateLibraryFieldDto } from './update-library-field.dto';
async function validateInput(input: Record<string, unknown>) {
const dto = plainToInstance(UpdateLibraryFieldDto, input);
const errors = await validate(dto);
return { dto, errors };
}
describe('UpdateLibraryFieldDto', () => {
it('accepts valid override payloads', async () => {
const { dto, errors } = await validateInput({
enabled: true,
providers: [MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY],
mergeStrategy: 'overwriteIfProvided',
});
expect(errors).toHaveLength(0);
expect(dto.providers).toEqual([MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY]);
});
it('rejects invalid provider keys', async () => {
const { errors } = await validateInput({
enabled: true,
providers: [MetadataProviderKey.GOOGLE, 'unknown-provider'],
mergeStrategy: 'overwrite',
});
expect(errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
property: 'providers',
constraints: expect.objectContaining({
isIn: expect.stringContaining('providers'),
}),
}),
]),
);
});
it('rejects non-array providers and unsupported merge strategy', async () => {
const { errors } = await validateInput({
enabled: true,
providers: MetadataProviderKey.GOOGLE,
mergeStrategy: 'replace',
});
expect(errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
property: 'providers',
constraints: expect.objectContaining({
isArray: 'providers must be an array',
}),
}),
expect.objectContaining({
property: 'mergeStrategy',
constraints: expect.objectContaining({
isIn: expect.stringContaining('mergeStrategy'),
}),
}),
]),
);
});
});
@@ -1,18 +0,0 @@
import { IsIn, IsBoolean, IsArray, IsString } from 'class-validator';
import { MetadataProviderKey } from '@projectx/types';
import type { MergeStrategy } from '@projectx/types';
const MERGE_STRATEGIES: MergeStrategy[] = ['fillMissing', 'overwrite', 'overwriteIfProvided'];
const PROVIDER_KEYS = Object.values(MetadataProviderKey);
export class UpdateLibraryFieldDto {
@IsBoolean() enabled!: boolean;
@IsArray()
@IsString({ each: true })
@IsIn(PROVIDER_KEYS, { each: true })
providers!: MetadataProviderKey[];
@IsIn(MERGE_STRATEGIES)
mergeStrategy!: MergeStrategy;
}
@@ -0,0 +1,68 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { MetadataProviderKey } from '@projectx/types';
import { UpdateLibraryOverridesDto } from './update-library-overrides.dto';
async function validateInput(input: Record<string, unknown>) {
const dto = plainToInstance(UpdateLibraryOverridesDto, input);
const errors = await validate(dto);
return { dto, errors };
}
describe('UpdateLibraryOverridesDto', () => {
it('accepts an empty overrides object', async () => {
const { errors } = await validateInput({ overrides: {} });
expect(errors).toHaveLength(0);
});
it('accepts a valid partial field overrides map', async () => {
const { errors } = await validateInput({
overrides: {
title: {
enabled: true,
providers: [MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY],
mergeStrategy: 'fillMissing',
},
authors: {
enabled: false,
providers: [],
mergeStrategy: 'overwrite',
},
},
});
expect(errors).toHaveLength(0);
});
it('rejects overrides with an unknown field key', async () => {
const { errors } = await validateInput({
overrides: {
'not-a-real-field': {
enabled: true,
providers: [MetadataProviderKey.GOOGLE],
mergeStrategy: 'overwrite',
},
},
});
expect(errors.length).toBeGreaterThan(0);
});
it('rejects overrides with an invalid provider key inside a known field', async () => {
const { errors } = await validateInput({
overrides: {
title: {
enabled: true,
providers: ['not-a-provider'],
mergeStrategy: 'fillMissing',
},
},
});
expect(errors.length).toBeGreaterThan(0);
});
it('rejects overrides that is not an object', async () => {
const { errors } = await validateInput({ overrides: 'invalid' });
expect(errors.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,9 @@
import { Validate } from 'class-validator';
import type { FieldPreferenceOverrides } from '@projectx/types';
import { IsFieldPreferencesMapConstraint } from './update-global-preferences.dto';
export class UpdateLibraryOverridesDto {
@Validate(IsFieldPreferencesMapConstraint)
overrides!: FieldPreferenceOverrides;
}
@@ -57,7 +57,7 @@ describe('MetadataPreferenceResolver', () => {
expect(defaults.fields.title.mergeStrategy).toBe('fillMissing');
expect(defaults.fields.description.mergeStrategy).toBe('overwriteIfProvided');
expect(defaults.options).toEqual({
genres: { mode: 'merge', providerScope: 'allConfiguredProviders' },
genres: { mode: 'merge' },
saveProviderIds: true,
});
});
@@ -114,7 +114,7 @@ describe('MetadataPreferenceResolver', () => {
const malformed = {
fields: defaults.fields,
options: {
genres: { mode: 'invalid', providerScope: 'invalid' },
genres: { mode: 'invalid' },
saveProviderIds: 'yes',
},
} as unknown as MetadataFetchPreferences;
@@ -4,7 +4,6 @@ import {
FieldPreference,
FieldPreferenceOverrides,
GenreMergeMode,
GenreProviderScope,
MetadataFetchPreferences,
MetadataFetchOptions,
MetadataField,
@@ -22,7 +21,6 @@ const DEFAULT_PROVIDER_ORDER: MetadataProviderKey[] = [
const DEFAULT_MERGE_STRATEGY: MergeStrategy = 'overwriteIfProvided';
const MERGE_STRATEGIES: Set<MergeStrategy> = new Set(['fillMissing', 'overwrite', 'overwriteIfProvided']);
const GENRE_MERGE_MODES: Set<GenreMergeMode> = new Set(['firstProvider', 'merge']);
const GENRE_PROVIDER_SCOPES: Set<GenreProviderScope> = new Set(['selectedProviders', 'allConfiguredProviders']);
const PROVIDERS_WITH_ITUNES: MetadataProviderKey[] = [
MetadataProviderKey.GOODREADS,
@@ -65,7 +63,6 @@ export class MetadataPreferenceResolver {
const options: MetadataFetchOptions = {
genres: {
mode: 'merge',
providerScope: 'allConfiguredProviders',
},
saveProviderIds: true,
};
@@ -138,13 +135,10 @@ export class MetadataPreferenceResolver {
candidate.genres && typeof candidate.genres === 'object' && !Array.isArray(candidate.genres) ? candidate.genres : {};
const mode = GENRE_MERGE_MODES.has(genresCandidate.mode as GenreMergeMode) ? (genresCandidate.mode as GenreMergeMode) : fallback.genres.mode;
const providerScope = GENRE_PROVIDER_SCOPES.has(genresCandidate.providerScope as GenreProviderScope)
? (genresCandidate.providerScope as GenreProviderScope)
: fallback.genres.providerScope;
const saveProviderIds = typeof candidate.saveProviderIds === 'boolean' ? candidate.saveProviderIds : fallback.saveProviderIds;
return {
genres: { mode, providerScope },
genres: { mode },
saveProviderIds,
};
}
@@ -1,4 +1,3 @@
import { BadRequestException } from '@nestjs/common';
import type { Mocked } from 'vitest';
import { MetadataProviderKey } from '@projectx/types';
@@ -13,8 +12,9 @@ describe('MetadataPreferencesController', () => {
service = {
getGlobal: vi.fn(),
setGlobal: vi.fn(),
resetGlobal: vi.fn(),
getForLibrary: vi.fn(),
setLibraryFieldOverride: vi.fn(),
setLibraryOverrides: vi.fn(),
resetLibraryToGlobal: vi.fn(),
} as unknown as Mocked<MetadataPreferencesService>;
@@ -46,6 +46,12 @@ describe('MetadataPreferencesController', () => {
expect(service.setGlobal).toHaveBeenCalledWith(dto);
});
it('delegates global reset to service', async () => {
await controller.resetGlobal();
expect(service.resetGlobal).toHaveBeenCalledTimes(1);
});
it('returns library-specific effective preferences', async () => {
service.getForLibrary.mockResolvedValue({ libraryId: 1, overrides: null, effective: { fields: {} } } as never);
@@ -55,39 +61,20 @@ describe('MetadataPreferencesController', () => {
expect(result.libraryId).toBe(1);
});
it('writes a field override when the field key is valid', async () => {
it('writes bulk library overrides', async () => {
const dto = {
enabled: true,
providers: [MetadataProviderKey.OPEN_LIBRARY],
mergeStrategy: 'overwriteIfProvided',
overrides: {
title: {
enabled: true,
providers: [MetadataProviderKey.OPEN_LIBRARY],
mergeStrategy: 'overwriteIfProvided',
},
},
};
await controller.setLibraryFieldOverride(2, 'title', dto as never);
await controller.setLibraryOverrides(2, dto as never);
expect(service.setLibraryFieldOverride).toHaveBeenCalledWith(2, 'title', dto);
});
it('rejects invalid field keys when writing a library override', () => {
expect(() =>
controller.setLibraryFieldOverride(2, 'not-a-field', {
enabled: true,
providers: [MetadataProviderKey.GOOGLE],
mergeStrategy: 'fillMissing',
} as never),
).toThrow(BadRequestException);
expect(service.setLibraryFieldOverride).not.toHaveBeenCalled();
});
it('clears a valid field override', async () => {
await controller.clearLibraryFieldOverride(4, 'authors');
expect(service.setLibraryFieldOverride).toHaveBeenCalledWith(4, 'authors', null);
});
it('rejects invalid field keys when clearing a library override', () => {
expect(() => controller.clearLibraryFieldOverride(4, 'invalid')).toThrow(BadRequestException);
expect(service.setLibraryFieldOverride).not.toHaveBeenCalled();
expect(service.setLibraryOverrides).toHaveBeenCalledWith(2, dto.overrides);
});
it('delegates library reset to service', async () => {
@@ -1,10 +1,9 @@
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Put } from '@nestjs/common';
import { Permission, ALL_METADATA_FIELDS } from '@projectx/types';
import type { FieldPreference, MetadataField } from '@projectx/types';
import { Controller, Body, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Put } from '@nestjs/common';
import { Permission } from '@projectx/types';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { UpdateGlobalPreferencesDto } from './dto/update-global-preferences.dto';
import { UpdateLibraryFieldDto } from './dto/update-library-field.dto';
import { UpdateLibraryOverridesDto } from './dto/update-library-overrides.dto';
import { MetadataPreferencesService } from './metadata-preferences.service';
@Controller('metadata-preferences')
@@ -12,13 +11,6 @@ import { MetadataPreferencesService } from './metadata-preferences.service';
export class MetadataPreferencesController {
constructor(private readonly service: MetadataPreferencesService) {}
private parseMetadataField(field: string): MetadataField {
if ((ALL_METADATA_FIELDS as string[]).includes(field)) {
return field as MetadataField;
}
throw new BadRequestException(`Unsupported metadata field: ${field}`);
}
@Get('global')
getGlobal() {
return this.service.getGlobal();
@@ -35,16 +27,16 @@ export class MetadataPreferencesController {
return this.service.getForLibrary(id);
}
@Put('libraries/:id/fields/:field')
@Put('libraries/:id')
@HttpCode(HttpStatus.OK)
setLibraryFieldOverride(@Param('id', ParseIntPipe) id: number, @Param('field') field: string, @Body() dto: UpdateLibraryFieldDto) {
return this.service.setLibraryFieldOverride(id, this.parseMetadataField(field), dto as FieldPreference);
setLibraryOverrides(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateLibraryOverridesDto) {
return this.service.setLibraryOverrides(id, dto.overrides);
}
@Delete('libraries/:id/fields/:field')
@Delete('global')
@HttpCode(HttpStatus.NO_CONTENT)
clearLibraryFieldOverride(@Param('id', ParseIntPipe) id: number, @Param('field') field: string) {
return this.service.setLibraryFieldOverride(id, this.parseMetadataField(field), null);
resetGlobal() {
return this.service.resetGlobal();
}
@Delete('libraries/:id')
@@ -139,63 +139,36 @@ describe('MetadataPreferencesService', () => {
expect(result.effective.fields.title.providers).toEqual([MetadataProviderKey.AMAZON]);
});
it('clears one library override and stores null when no overrides remain', async () => {
db.query.libraries.findFirst.mockResolvedValue({
metadataFetchPreferences: {
title: {
enabled: true,
providers: [MetadataProviderKey.GOOGLE],
mergeStrategy: 'fillMissing',
},
},
});
it('writes library overrides and stores null when overrides object is empty', async () => {
db.query.libraries.findFirst.mockResolvedValue({ id: 3 });
await service.setLibraryFieldOverride(3, 'title', null);
await service.setLibraryOverrides(3, {});
expect(db.update).toHaveBeenCalledTimes(1);
expect(db.__updateChain.set).toHaveBeenCalledWith({ metadataFetchPreferences: null });
expect(db.__updateChain.where).toHaveBeenCalledTimes(1);
});
it('writes or replaces a single library override', async () => {
db.query.libraries.findFirst.mockResolvedValue({
metadataFetchPreferences: {
subtitle: {
enabled: false,
providers: [MetadataProviderKey.OPEN_LIBRARY],
mergeStrategy: 'overwriteIfProvided',
},
it('writes library overrides when overrides object is non-empty', async () => {
db.query.libraries.findFirst.mockResolvedValue({ id: 4 });
const overrides = {
title: {
enabled: true,
providers: [MetadataProviderKey.GOODREADS],
mergeStrategy: 'overwrite',
},
});
};
await service.setLibraryFieldOverride(4, 'title', {
enabled: true,
providers: [MetadataProviderKey.GOODREADS],
mergeStrategy: 'overwrite',
});
await service.setLibraryOverrides(4, overrides as never);
expect(db.__updateChain.set).toHaveBeenCalledWith({
metadataFetchPreferences: expect.objectContaining({
subtitle: expect.any(Object),
title: {
enabled: true,
providers: [MetadataProviderKey.GOODREADS],
mergeStrategy: 'overwrite',
},
}),
});
expect(db.__updateChain.set).toHaveBeenCalledWith({ metadataFetchPreferences: overrides });
});
it('throws NotFoundException when writing an override for a missing library', async () => {
it('throws NotFoundException when writing overrides for a missing library', async () => {
db.query.libraries.findFirst.mockResolvedValue(undefined);
await expect(
service.setLibraryFieldOverride(10, 'title', {
enabled: true,
providers: [MetadataProviderKey.GOOGLE],
mergeStrategy: 'fillMissing',
}),
).rejects.toThrow(NotFoundException);
await expect(service.setLibraryOverrides(10, {})).rejects.toThrow(NotFoundException);
});
it('resets a library to global preferences and throws when library is missing', async () => {
@@ -1,7 +1,7 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { FieldPreference, FieldPreferenceOverrides, LibraryMetadataPreferences, MetadataFetchPreferences, MetadataField } from '@projectx/types';
import { FieldPreferenceOverrides, LibraryMetadataPreferences, MetadataFetchPreferences } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -53,25 +53,20 @@ export class MetadataPreferencesService {
return { libraryId, overrides, effective };
}
async setLibraryFieldOverride(libraryId: number, field: MetadataField, preference: FieldPreference | null): Promise<void> {
async setLibraryOverrides(libraryId: number, overrides: FieldPreferenceOverrides): Promise<void> {
const library = await this.db.query.libraries.findFirst({
where: eq(schema.libraries.id, libraryId),
columns: { metadataFetchPreferences: true },
columns: { id: true },
});
if (!library) throw new NotFoundException(`Library ${libraryId} not found`);
await this.db
.update(schema.libraries)
.set({ metadataFetchPreferences: Object.keys(overrides).length ? overrides : null })
.where(eq(schema.libraries.id, libraryId));
}
const current: FieldPreferenceOverrides = library.metadataFetchPreferences ?? {};
if (preference === null) {
const next = { ...current };
delete next[field];
await this.db
.update(schema.libraries)
.set({ metadataFetchPreferences: Object.keys(next).length ? next : null })
.where(eq(schema.libraries.id, libraryId));
} else {
const next: FieldPreferenceOverrides = { ...current, [field]: preference };
await this.db.update(schema.libraries).set({ metadataFetchPreferences: next }).where(eq(schema.libraries.id, libraryId));
}
async resetGlobal(): Promise<void> {
await this.db.delete(schema.appSettings).where(eq(schema.appSettings.key, GLOBAL_PREFERENCES_KEY));
}
async resetLibraryToGlobal(libraryId: number): Promise<void> {
@@ -90,6 +90,21 @@ describe('ProviderConfigService', () => {
expect(config.goodreads).toEqual({ enabled: true });
});
it('drops unsupported properties from stored provider sections', async () => {
db.query.appSettings.findFirst.mockResolvedValue({
value: JSON.stringify({
google: { enabled: true, apiKey: 'g-key', extra: 'ignored' },
audible: { enabled: true, domain: 'audible.com', cookie: 'legacy-cookie' },
}),
});
const config = await service.getConfig();
expect(config.google).toEqual({ enabled: true, apiKey: 'g-key' });
expect(config.audible).toEqual({ enabled: true, domain: 'audible.com' });
expect((config.audible as Record<string, unknown>).cookie).toBeUndefined();
});
it('falls back to defaults when stored JSON is invalid', async () => {
db.query.appSettings.findFirst.mockResolvedValue({ value: '{not-json' });
@@ -125,6 +140,17 @@ describe('ProviderConfigService', () => {
);
});
it('ignores unsupported properties during update merge', async () => {
db.query.appSettings.findFirst.mockResolvedValue(undefined);
const updated = await service.updateConfig({
audible: { enabled: true, domain: 'audible.com', cookie: 'legacy-cookie' } as never,
} as never);
expect(updated.audible).toEqual({ enabled: true, domain: 'audible.com' });
expect((updated.audible as Record<string, unknown>).cookie).toBeUndefined();
});
it('builds provider statuses including provider-specific configuration hints', async () => {
const statuses = await service.getProviderStatuses({
google: { enabled: true, apiKey: '' },
@@ -26,6 +26,64 @@ function asObject(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function asBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
function asString(value: unknown, fallback: string): string {
return typeof value === 'string' ? value : fallback;
}
function mergeGoogleConfig(base: ProviderConfigurations['google'], value: unknown): ProviderConfigurations['google'] {
const next = asObject(value);
return {
enabled: asBoolean(next.enabled, base.enabled),
apiKey: asString(next.apiKey, base.apiKey),
};
}
function mergeAmazonConfig(base: ProviderConfigurations['amazon'], value: unknown): ProviderConfigurations['amazon'] {
const next = asObject(value);
return {
enabled: asBoolean(next.enabled, base.enabled),
domain: asString(next.domain, base.domain),
cookie: asString(next.cookie, base.cookie),
};
}
type SimpleProviderConfig = { enabled: boolean };
function mergeSimpleConfig<T extends SimpleProviderConfig>(base: T, value: unknown): T {
const next = asObject(value);
return {
enabled: asBoolean(next.enabled, base.enabled),
} as T;
}
function mergeHardcoverConfig(base: ProviderConfigurations['hardcover'], value: unknown): ProviderConfigurations['hardcover'] {
const next = asObject(value);
return {
enabled: asBoolean(next.enabled, base.enabled),
apiKey: asString(next.apiKey, base.apiKey),
};
}
function mergeAudibleConfig(base: ProviderConfigurations['audible'], value: unknown): ProviderConfigurations['audible'] {
const next = asObject(value);
return {
enabled: asBoolean(next.enabled, base.enabled),
domain: asString(next.domain, base.domain),
};
}
function mergeComicVineConfig(base: ProviderConfigurations['comicvine'], value: unknown): ProviderConfigurations['comicvine'] {
const next = asObject(value);
return {
enabled: asBoolean(next.enabled, base.enabled),
apiKey: asString(next.apiKey, base.apiKey),
};
}
const PROVIDER_LABELS: Record<MetadataProviderKey, string> = {
[MetadataProviderKey.GOOGLE]: 'Google Books',
[MetadataProviderKey.AMAZON]: 'Amazon',
@@ -65,15 +123,15 @@ export class ProviderConfigService {
try {
const stored = asObject(JSON.parse(row.value));
return {
google: { ...defaults.google, ...asObject(stored.google) },
amazon: { ...defaults.amazon, ...asObject(stored.amazon) },
goodreads: { ...defaults.goodreads, ...asObject(stored.goodreads) },
hardcover: { ...defaults.hardcover, ...asObject(stored.hardcover) },
openLibrary: { ...defaults.openLibrary, ...asObject(stored.openLibrary) },
itunes: { ...defaults.itunes, ...asObject(stored.itunes) },
audible: { ...defaults.audible, ...asObject(stored.audible) },
audnexus: { ...defaults.audnexus, ...asObject(stored.audnexus) },
comicvine: { ...defaults.comicvine, ...asObject(stored.comicvine) },
google: mergeGoogleConfig(defaults.google, stored.google),
amazon: mergeAmazonConfig(defaults.amazon, stored.amazon),
goodreads: mergeSimpleConfig(defaults.goodreads, stored.goodreads),
hardcover: mergeHardcoverConfig(defaults.hardcover, stored.hardcover),
openLibrary: mergeSimpleConfig(defaults.openLibrary, stored.openLibrary),
itunes: mergeSimpleConfig(defaults.itunes, stored.itunes),
audible: mergeAudibleConfig(defaults.audible, stored.audible),
audnexus: mergeSimpleConfig(defaults.audnexus, stored.audnexus),
comicvine: mergeComicVineConfig(defaults.comicvine, stored.comicvine),
};
} catch {
return defaults;
@@ -83,15 +141,15 @@ export class ProviderConfigService {
async updateConfig(patch: Partial<ProviderConfigurations>): Promise<ProviderConfigurations> {
const current = await this.getConfig();
const next: ProviderConfigurations = {
google: { ...current.google, ...asObject(patch.google) },
amazon: { ...current.amazon, ...asObject(patch.amazon) },
goodreads: { ...current.goodreads, ...asObject(patch.goodreads) },
hardcover: { ...current.hardcover, ...asObject(patch.hardcover) },
openLibrary: { ...current.openLibrary, ...asObject(patch.openLibrary) },
itunes: { ...current.itunes, ...asObject(patch.itunes) },
audible: { ...current.audible, ...asObject(patch.audible) },
audnexus: { ...current.audnexus, ...asObject(patch.audnexus) },
comicvine: { ...current.comicvine, ...asObject(patch.comicvine) },
google: mergeGoogleConfig(current.google, patch.google),
amazon: mergeAmazonConfig(current.amazon, patch.amazon),
goodreads: mergeSimpleConfig(current.goodreads, patch.goodreads),
hardcover: mergeHardcoverConfig(current.hardcover, patch.hardcover),
openLibrary: mergeSimpleConfig(current.openLibrary, patch.openLibrary),
itunes: mergeSimpleConfig(current.itunes, patch.itunes),
audible: mergeAudibleConfig(current.audible, patch.audible),
audnexus: mergeSimpleConfig(current.audnexus, patch.audnexus),
comicvine: mergeComicVineConfig(current.comicvine, patch.comicvine),
};
const value = JSON.stringify(next);
await this.db