mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): batch library overrides and improve comic refresh
This commit is contained in:
@@ -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)"
|
||||
/>
|
||||
|
||||
+2
-4
@@ -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
|
||||
|
||||
+55
-38
@@ -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>
|
||||
|
||||
+154
-49
@@ -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>
|
||||
|
||||
+19
-6
@@ -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>
|
||||
|
||||
+49
-22
@@ -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>
|
||||
|
||||
+39
-15
@@ -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();
|
||||
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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
|
||||
|
||||
Reference in New Issue
Block a user