Files
silo-server/web/src/components/admin/libraries/useLibraryForm.ts
T
a26b2de454 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>
2026-07-07 09:42:41 -04:00

357 lines
11 KiB
TypeScript

import { useMemo, useState } from "react";
import type {
CreateLibraryRequest,
Library,
LibraryProviderChainResponse,
PluginInstallation,
} from "@/api/types";
import {
useCreateLibrary,
useLibraryProviderDefaults,
useLibraryProviders,
useSetLibraryProviders,
useUpdateLibrary,
} from "@/hooks/queries/admin/libraries";
import { useAdminPlugins } from "@/hooks/queries/admin/plugins";
import { PROVIDER_TRAILER_KINDS } from "@/lib/extraKinds";
export type LevelChainItem = {
plugin_installation_id: number;
capability_id: string;
provider_slug: string;
enabled: boolean;
};
// 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;
paths?: string;
}
export interface UseLibraryFormOptions {
library: Library | null;
onClose?: () => void;
onSaved?: (library: Library) => void;
resetAfterCreate?: boolean;
}
export function contentLevelsForType(libraryType: string): string[] {
switch (libraryType) {
case "series":
return ["series", "season", "episode"];
case "movies":
return ["movie"];
case "mixed":
return ["movie", "series", "season", "episode", "audiobook", "ebook"];
case "audiobooks":
return ["audiobook"];
case "ebooks":
case "ebook":
return ["ebook"];
case "manga":
return ["manga"];
case "podcasts":
return ["podcast", "podcast_episode"];
default:
return [];
}
}
export function contentLevelLabel(level: string): string {
return level
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
// 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[]> = {};
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,
}));
}
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(
Object.entries(activeLevelChains).map(([level, items]) => [
level,
items.map((item, i) => ({
plugin_installation_id: item.plugin_installation_id,
capability_id: item.capability_id,
priority: i,
enabled: item.enabled,
})),
]),
),
};
}
export function useLibraryForm({
library,
onClose,
onSaved,
resetAfterCreate = false,
}: UseLibraryFormOptions) {
const [name, setName] = useState(library?.name ?? "");
const [paths, setPaths] = useState<string[]>(library?.paths?.length ? library.paths : [""]);
const [type, setType] = useState(library?.type ?? "movies");
const [enabled, setEnabled] = useState(library?.enabled ?? true);
const [metadataLanguage, setMetadataLanguage] = useState(library?.metadata_language ?? "en");
const [autoTranslateMetadata, setAutoTranslateMetadata] = useState(
library?.auto_translate_metadata ?? false,
);
const [chapterThumbnailsEnabled, setChapterThumbnailsEnabled] = useState(
library?.chapter_thumbnails_enabled ?? false,
);
const [introDetectionEnabled, setIntroDetectionEnabled] = useState(
library?.intro_detection_enabled ?? false,
);
const [trailerKinds, setTrailerKinds] = useState<string[]>(
library?.trailer_kinds ?? [...PROVIDER_TRAILER_KINDS],
);
const [levelChains, setLevelChains] = useState<Record<string, LevelChainItem[]>>({});
const [chainDirty, setChainDirty] = useState(false);
const [submitAttempted, setSubmitAttempted] = useState(false);
const createMutation = useCreateLibrary();
const updateMutation = useUpdateLibrary();
const setChainMutation = useSetLibraryProviders();
const { installations } = useAdminPlugins();
const { data: currentChain } = useLibraryProviders(library?.id ?? null);
// 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(
() => levelChainsFromResponse(providerDefaults),
[providerDefaults],
);
const resolvedLevelChains = useMemo(() => {
if (!library) {
return defaultLevelChains;
}
if (currentChain === undefined) {
return levelChains;
}
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 = {};
if (!name.trim()) next.name = "Give this library a name.";
if (!paths.some((p) => p.trim())) next.paths = "Add at least one folder to scan.";
return next;
}, [name, paths]);
const errors: LibraryFormErrors = submitAttempted ? allErrors : {};
function updatePath(index: number, value: string) {
const next = [...paths];
next[index] = value;
setPaths(next);
}
function addPath() {
setPaths([...paths, ""]);
}
function removePath(index: number) {
setPaths(paths.filter((_, i) => i !== index));
}
function mergeBrowsedPaths(selectedPaths: string[]) {
const merged = [...paths.filter((path) => path.trim())];
for (const selectedPath of selectedPaths) {
if (!merged.includes(selectedPath)) {
merged.push(selectedPath);
}
}
setPaths(merged.length > 0 ? merged : [""]);
}
function handleTypeChange(newType: string) {
setType(newType);
if (!library) {
// 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);
}
}
function toggleTrailerKind(kind: string) {
setTrailerKinds((current) =>
current.includes(kind) ? current.filter((k) => k !== kind) : [...current, kind],
);
}
function reorderLevel(level: string, items: LevelChainItem[]) {
setLevelChains({ ...activeLevelChains, [level]: items });
setChainDirty(true);
}
function toggleLevelProvider(level: string, index: number) {
const source = activeLevelChains[level] ?? [];
const updated = [...source];
updated[index] = { ...updated[index]!, enabled: !updated[index]!.enabled };
setLevelChains({ ...activeLevelChains, [level]: updated });
setChainDirty(true);
}
function finishCreate(created: Library) {
onSaved?.(created);
if (resetAfterCreate) {
setName("");
setPaths([""]);
setSubmitAttempted(false);
} else {
onClose?.();
}
}
function submit(): { ok: boolean; errors: LibraryFormErrors } {
setSubmitAttempted(true);
if (allErrors.name || allErrors.paths) {
return { ok: false, errors: allErrors };
}
const body: CreateLibraryRequest = {
name: name.trim(),
paths: paths.filter((p) => p.trim()),
type,
enabled,
metadata_language: metadataLanguage,
auto_translate_metadata: autoTranslateMetadata,
chapter_thumbnails_enabled: chapterThumbnailsEnabled,
intro_detection_enabled: introDetectionEnabled,
trailer_kinds: trailerKinds,
};
if (library) {
updateMutation.mutate(
{ id: library.id, body },
{
onSuccess: () => {
if (chainDirty) {
setChainMutation.mutate(
{
id: library.id,
body: buildProviderChainBody(activeLevelChains),
},
{ onSuccess: () => onClose?.() },
);
} else {
onClose?.();
}
},
},
);
return { ok: true, errors: {} };
}
createMutation.mutate(body, {
onSuccess: (created) => {
if (chainDirty) {
setChainMutation.mutate(
{
id: created.id,
body: buildProviderChainBody(activeLevelChains),
},
{ onSuccess: () => finishCreate(created) },
);
} else {
finishCreate(created);
}
},
});
return { ok: true, errors: {} };
}
return {
library,
name,
setName,
paths,
updatePath,
addPath,
removePath,
mergeBrowsedPaths,
type,
handleTypeChange,
enabled,
setEnabled,
metadataLanguage,
setMetadataLanguage,
autoTranslateMetadata,
setAutoTranslateMetadata,
chapterThumbnailsEnabled,
setChapterThumbnailsEnabled,
introDetectionEnabled,
setIntroDetectionEnabled,
trailerKinds,
toggleTrailerKind,
contentLevels: contentLevelsForType(type),
activeLevelChains,
chainLoading,
reorderLevel,
toggleLevelProvider,
hasMetadataProviders: hasMetadataProviderCapability(installations),
errors,
isPending,
submit,
};
}
export type LibraryFormController = ReturnType<typeof useLibraryForm>;