fix(metadata): stop specialist providers hijacking new library chains (#294)
* fix(metadata): seed specialist providers off and scope chains to declared levels New library provider chains were seeded from every enabled metadata provider, ordered purely by each plugin's declared default_priority and enabled whenever that priority was > 0. Two consequences: - A specialist provider (e.g. silo.sportarr, which declares series/season/ episode) could out-rank the general providers and land at position 1, enabled, on every new TV series library. - Single-purpose providers that declare only their own level (audiobook / ebook / manga metadata) were still attached as disabled rows to series and movie libraries, cluttering the chain editor with providers that cannot serve that content. Introduce a `default_enabled` capability-metadata flag (defaults to true, so every existing plugin is unaffected). A provider sets it false to be seeded installed-but-disabled while keeping its declared priority, so a user can opt in per-library and it slots in where the manifest intends instead of jumping to the top. At the same time, seedDefaultChain and AppendProviderToAllChains now drop providers that do not declare a content level, reusing the same providerSupportsLevel rule as the chain-less fallback (issue #106). LookupSeedPlacement resolves support/priority/enabled with a single metadata fetch. buildSeededChainEntries is extracted as a pure, unit-tested helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): standardize metadata provider slug casing in library chain editor The library provider-chain editor showed the same provider differently depending on where the chain came from: a freshly defaulted chain used the capability display name ("TMDB"), while a chain loaded from the server used the capability id ("tmdb", which the API returns as provider_slug). So a provider read one way before saving and another after, and differed between library types depending on which levels already had a saved chain. Standardize on the capability id everywhere (matches the server's provider_slug and the mono/slug styling). Extract the provider mapping into a pure, unit-tested metadataProvidersFromInstallations helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): mirror server seeding rules in the library form's default chain The form builds its own default chain client-side, and any touch (including changing the library type on create, the normal path for a series library) marks it dirty and POSTs it after create — replacing the server-seeded chain. That chain still enabled every provider with a declared priority and listed unsupported providers as disabled rows, so the server-side fix evaporated on the UI create path. buildDefaultLevelChains now applies the same rules as buildSeededChainEntries: providers that don't declare the level are dropped, a declaring provider is enabled only if it doesn't opt out via default_enabled, and a legacy catch-all (no declared levels) is parked last, disabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(web,api): serve default provider chains from the server Replace the form's client-side reimplementation of the seeding rules with a new additive endpoint, GET /api/v1/libraries/provider-defaults?library_type=X, which returns the exact chain seedDefaultChain would write for that type. The create form now renders those server-computed defaults, changing the library type just refetches them (no longer marking the chain dirty), and a create with an untouched chain lets the server-seeded chain stand instead of writing one back. Editing an existing library uses the same defaults to fill levels its saved chain doesn't cover. Types the server seeds no metadata levels for (e.g. podcasts) return an empty levels map rather than an error. This removes buildDefaultLevelChains / metadataProvidersFromInstallations and the default_priority/default_enabled manifest parsing from the frontend — one source of truth for default ordering and enablement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): show a loading state in the provider chain editor While the server chain (for an existing library) or the type's defaults are still in flight, the editor rendered empty provider lists for a moment. Show a spinner row instead; local edits always render immediately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 4.8
Quick
parent
4a76016015
commit
a26b2de454
@@ -1,8 +1,14 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { CreateLibraryRequest, Library } from "@/api/types";
|
||||
import type {
|
||||
CreateLibraryRequest,
|
||||
Library,
|
||||
LibraryProviderChainResponse,
|
||||
PluginInstallation,
|
||||
} from "@/api/types";
|
||||
import {
|
||||
useCreateLibrary,
|
||||
useLibraryProviderDefaults,
|
||||
useLibraryProviders,
|
||||
useSetLibraryProviders,
|
||||
useUpdateLibrary,
|
||||
@@ -17,12 +23,16 @@ export type LevelChainItem = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type MetadataProvider = {
|
||||
plugin_installation_id: number;
|
||||
capability_id: string;
|
||||
slug: string;
|
||||
defaultPriority: Record<string, number>;
|
||||
};
|
||||
// hasMetadataProviderCapability reports whether any enabled plugin installation
|
||||
// exposes a metadata provider — used only to decide between the chain editor
|
||||
// and the "install a plugin" empty state. The chain contents themselves come
|
||||
// from the server.
|
||||
export function hasMetadataProviderCapability(installations: PluginInstallation[]): boolean {
|
||||
return installations.some(
|
||||
(inst) =>
|
||||
inst.enabled && (inst.capabilities ?? []).some((cap) => cap.type === "metadata_provider.v1"),
|
||||
);
|
||||
}
|
||||
|
||||
export interface LibraryFormErrors {
|
||||
name?: string;
|
||||
@@ -65,64 +75,43 @@ export function contentLevelLabel(level: string): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function buildDefaultLevelChains(
|
||||
metadataProviders: MetadataProvider[],
|
||||
libraryType: string,
|
||||
): Record<string, LevelChainItem[]> {
|
||||
const defaultChain: Record<string, LevelChainItem[]> = {};
|
||||
for (const level of contentLevelsForType(libraryType)) {
|
||||
const sorted = [...metadataProviders].sort((a, b) => {
|
||||
const pa = a.defaultPriority[level] ?? 0;
|
||||
const pb = b.defaultPriority[level] ?? 0;
|
||||
if ((pa === 0) !== (pb === 0)) return pa === 0 ? 1 : -1;
|
||||
return pa - pb;
|
||||
});
|
||||
defaultChain[level] = sorted.map((provider) => ({
|
||||
plugin_installation_id: provider.plugin_installation_id,
|
||||
capability_id: provider.capability_id,
|
||||
provider_slug: provider.slug,
|
||||
enabled: (provider.defaultPriority[level] ?? 0) > 0,
|
||||
}));
|
||||
}
|
||||
return defaultChain;
|
||||
}
|
||||
|
||||
function buildLevelChainsFromServer(
|
||||
currentChain: {
|
||||
levels?: Record<
|
||||
string,
|
||||
Array<{
|
||||
plugin_installation_id: number;
|
||||
capability_id: string;
|
||||
provider_slug: string;
|
||||
enabled: boolean;
|
||||
}>
|
||||
>;
|
||||
} | null,
|
||||
metadataProviders: MetadataProvider[],
|
||||
libraryType: string,
|
||||
// levelChainsFromResponse converts a provider-chain API response (a library's
|
||||
// saved chain, or the server-computed defaults for a library type) into the
|
||||
// editor's per-level item lists, preserving server priority order.
|
||||
export function levelChainsFromResponse(
|
||||
response: LibraryProviderChainResponse | null | undefined,
|
||||
): Record<string, LevelChainItem[]> {
|
||||
const mapped: Record<string, LevelChainItem[]> = {};
|
||||
if (currentChain?.levels) {
|
||||
for (const [level, entries] of Object.entries(currentChain.levels)) {
|
||||
mapped[level] = entries.map((entry) => ({
|
||||
for (const [level, entries] of Object.entries(response?.levels ?? {})) {
|
||||
mapped[level] = [...entries]
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((entry) => ({
|
||||
plugin_installation_id: entry.plugin_installation_id,
|
||||
capability_id: entry.capability_id,
|
||||
provider_slug: entry.provider_slug,
|
||||
enabled: entry.enabled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const defaults = buildDefaultLevelChains(metadataProviders, libraryType);
|
||||
for (const level of contentLevelsForType(libraryType)) {
|
||||
if (!mapped[level] || mapped[level].length === 0) {
|
||||
mapped[level] = defaults[level] ?? [];
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// mergeChainWithDefaults fills content levels the saved chain doesn't cover
|
||||
// (e.g. a library created before a level existed) with the server-computed
|
||||
// defaults, without touching levels the chain already defines.
|
||||
export function mergeChainWithDefaults(
|
||||
chain: Record<string, LevelChainItem[]>,
|
||||
defaults: Record<string, LevelChainItem[]>,
|
||||
libraryType: string,
|
||||
): Record<string, LevelChainItem[]> {
|
||||
const merged = { ...chain };
|
||||
for (const level of contentLevelsForType(libraryType)) {
|
||||
if (!merged[level] || merged[level].length === 0) {
|
||||
merged[level] = defaults[level] ?? [];
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function buildProviderChainBody(activeLevelChains: Record<string, LevelChainItem[]>) {
|
||||
return {
|
||||
levels: Object.fromEntries(
|
||||
@@ -171,38 +160,16 @@ export function useLibraryForm({
|
||||
const setChainMutation = useSetLibraryProviders();
|
||||
const { installations } = useAdminPlugins();
|
||||
const { data: currentChain } = useLibraryProviders(library?.id ?? null);
|
||||
|
||||
const metadataProviders = useMemo(() => {
|
||||
const result: MetadataProvider[] = [];
|
||||
for (const inst of installations) {
|
||||
if (!inst.enabled) continue;
|
||||
for (const cap of inst.capabilities ?? []) {
|
||||
if (cap.type === "metadata_provider.v1") {
|
||||
const dp =
|
||||
(cap.metadata?.default_priority as Record<string, number>) ??
|
||||
((cap.metadata?.metadata as Record<string, unknown>)?.default_priority as Record<
|
||||
string,
|
||||
number
|
||||
>) ??
|
||||
{};
|
||||
result.push({
|
||||
plugin_installation_id: inst.id,
|
||||
capability_id: cap.id,
|
||||
slug: cap.display_name || cap.id,
|
||||
defaultPriority: dp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [installations]);
|
||||
// The server computes default chains (same logic that seeds them on create),
|
||||
// so the form never re-derives defaults from plugin manifests client-side.
|
||||
const { data: providerDefaults, isLoading: defaultsLoading } = useLibraryProviderDefaults(type);
|
||||
|
||||
const isPending =
|
||||
createMutation.isPending || updateMutation.isPending || setChainMutation.isPending;
|
||||
|
||||
const defaultLevelChains = useMemo(
|
||||
() => buildDefaultLevelChains(metadataProviders, type),
|
||||
[metadataProviders, type],
|
||||
() => levelChainsFromResponse(providerDefaults),
|
||||
[providerDefaults],
|
||||
);
|
||||
const resolvedLevelChains = useMemo(() => {
|
||||
if (!library) {
|
||||
@@ -211,9 +178,14 @@ export function useLibraryForm({
|
||||
if (currentChain === undefined) {
|
||||
return levelChains;
|
||||
}
|
||||
return buildLevelChainsFromServer(currentChain, metadataProviders, type);
|
||||
}, [currentChain, defaultLevelChains, levelChains, library, metadataProviders, type]);
|
||||
return mergeChainWithDefaults(levelChainsFromResponse(currentChain), defaultLevelChains, type);
|
||||
}, [currentChain, defaultLevelChains, levelChains, library, type]);
|
||||
const activeLevelChains = chainDirty ? levelChains : resolvedLevelChains;
|
||||
// The chain editor has nothing truthful to show until the server chain (for
|
||||
// an existing library) and the type's defaults have arrived; local edits
|
||||
// always render immediately.
|
||||
const chainLoading =
|
||||
!chainDirty && (defaultsLoading || (library !== null && currentChain === undefined));
|
||||
|
||||
const allErrors = useMemo<LibraryFormErrors>(() => {
|
||||
const next: LibraryFormErrors = {};
|
||||
@@ -250,8 +222,11 @@ export function useLibraryForm({
|
||||
function handleTypeChange(newType: string) {
|
||||
setType(newType);
|
||||
if (!library) {
|
||||
setLevelChains(buildDefaultLevelChains(metadataProviders, newType));
|
||||
setChainDirty(true);
|
||||
// Drop any local chain edits: the new type's defaults come from the
|
||||
// server, and with a clean chain the create flow lets the server-seeded
|
||||
// chain stand instead of writing one back.
|
||||
setLevelChains({});
|
||||
setChainDirty(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,9 +343,10 @@ export function useLibraryForm({
|
||||
toggleTrailerKind,
|
||||
contentLevels: contentLevelsForType(type),
|
||||
activeLevelChains,
|
||||
chainLoading,
|
||||
reorderLevel,
|
||||
toggleLevelProvider,
|
||||
hasMetadataProviders: metadataProviders.length > 0,
|
||||
hasMetadataProviders: hasMetadataProviderCapability(installations),
|
||||
errors,
|
||||
isPending,
|
||||
submit,
|
||||
|
||||
Reference in New Issue
Block a user