2026-06-09 14:51:38 -04:00
|
|
|
import { useMemo, useState } from "react";
|
|
|
|
|
|
2026-07-07 09:42:41 -04:00
|
|
|
import type {
|
|
|
|
|
CreateLibraryRequest,
|
|
|
|
|
Library,
|
|
|
|
|
LibraryProviderChainResponse,
|
|
|
|
|
PluginInstallation,
|
|
|
|
|
} from "@/api/types";
|
2026-06-09 14:51:38 -04:00
|
|
|
import {
|
|
|
|
|
useCreateLibrary,
|
2026-07-07 09:42:41 -04:00
|
|
|
useLibraryProviderDefaults,
|
2026-06-09 14:51:38 -04:00
|
|
|
useLibraryProviders,
|
|
|
|
|
useSetLibraryProviders,
|
|
|
|
|
useUpdateLibrary,
|
|
|
|
|
} from "@/hooks/queries/admin/libraries";
|
|
|
|
|
import { useAdminPlugins } from "@/hooks/queries/admin/plugins";
|
2026-07-06 18:52:44 -04:00
|
|
|
import { PROVIDER_TRAILER_KINDS } from "@/lib/extraKinds";
|
2026-06-09 14:51:38 -04:00
|
|
|
|
|
|
|
|
export type LevelChainItem = {
|
|
|
|
|
plugin_installation_id: number;
|
|
|
|
|
capability_id: string;
|
|
|
|
|
provider_slug: string;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-07 09:42:41 -04:00
|
|
|
// 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"),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-09 14:51:38 -04:00
|
|
|
|
|
|
|
|
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":
|
2026-06-10 08:18:35 -04:00
|
|
|
return ["movie", "series", "season", "episode", "audiobook", "ebook"];
|
2026-06-09 14:51:38 -04:00
|
|
|
case "audiobooks":
|
|
|
|
|
return ["audiobook"];
|
2026-06-10 08:18:35 -04:00
|
|
|
case "ebooks":
|
|
|
|
|
case "ebook":
|
|
|
|
|
return ["ebook"];
|
2026-06-16 20:13:10 -04:00
|
|
|
case "manga":
|
|
|
|
|
return ["manga"];
|
2026-06-09 14:51:38 -04:00
|
|
|
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(" ");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 09:42:41 -04:00
|
|
|
// 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,
|
2026-06-09 14:51:38 -04:00
|
|
|
): Record<string, LevelChainItem[]> {
|
|
|
|
|
const mapped: Record<string, LevelChainItem[]> = {};
|
2026-07-07 09:42:41 -04:00
|
|
|
for (const [level, entries] of Object.entries(response?.levels ?? {})) {
|
|
|
|
|
mapped[level] = [...entries]
|
|
|
|
|
.sort((a, b) => a.priority - b.priority)
|
|
|
|
|
.map((entry) => ({
|
2026-06-09 14:51:38 -04:00
|
|
|
plugin_installation_id: entry.plugin_installation_id,
|
|
|
|
|
capability_id: entry.capability_id,
|
|
|
|
|
provider_slug: entry.provider_slug,
|
|
|
|
|
enabled: entry.enabled,
|
|
|
|
|
}));
|
|
|
|
|
}
|
2026-07-07 09:42:41 -04:00
|
|
|
return mapped;
|
|
|
|
|
}
|
2026-06-09 14:51:38 -04:00
|
|
|
|
2026-07-07 09:42:41 -04:00
|
|
|
// 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 };
|
2026-06-09 14:51:38 -04:00
|
|
|
for (const level of contentLevelsForType(libraryType)) {
|
2026-07-07 09:42:41 -04:00
|
|
|
if (!merged[level] || merged[level].length === 0) {
|
|
|
|
|
merged[level] = defaults[level] ?? [];
|
2026-06-09 14:51:38 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-07 09:42:41 -04:00
|
|
|
return merged;
|
2026-06-09 14:51:38 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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");
|
2026-06-10 14:58:54 -04:00
|
|
|
const [autoTranslateMetadata, setAutoTranslateMetadata] = useState(
|
|
|
|
|
library?.auto_translate_metadata ?? false,
|
|
|
|
|
);
|
2026-06-09 14:51:38 -04:00
|
|
|
const [chapterThumbnailsEnabled, setChapterThumbnailsEnabled] = useState(
|
|
|
|
|
library?.chapter_thumbnails_enabled ?? false,
|
|
|
|
|
);
|
|
|
|
|
const [introDetectionEnabled, setIntroDetectionEnabled] = useState(
|
|
|
|
|
library?.intro_detection_enabled ?? false,
|
|
|
|
|
);
|
2026-07-06 18:52:44 -04:00
|
|
|
const [trailerKinds, setTrailerKinds] = useState<string[]>(
|
|
|
|
|
library?.trailer_kinds ?? [...PROVIDER_TRAILER_KINDS],
|
|
|
|
|
);
|
2026-06-09 14:51:38 -04:00
|
|
|
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);
|
2026-07-07 09:42:41 -04:00
|
|
|
// 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);
|
2026-06-09 14:51:38 -04:00
|
|
|
|
|
|
|
|
const isPending =
|
|
|
|
|
createMutation.isPending || updateMutation.isPending || setChainMutation.isPending;
|
|
|
|
|
|
|
|
|
|
const defaultLevelChains = useMemo(
|
2026-07-07 09:42:41 -04:00
|
|
|
() => levelChainsFromResponse(providerDefaults),
|
|
|
|
|
[providerDefaults],
|
2026-06-09 14:51:38 -04:00
|
|
|
);
|
|
|
|
|
const resolvedLevelChains = useMemo(() => {
|
|
|
|
|
if (!library) {
|
|
|
|
|
return defaultLevelChains;
|
|
|
|
|
}
|
|
|
|
|
if (currentChain === undefined) {
|
|
|
|
|
return levelChains;
|
|
|
|
|
}
|
2026-07-07 09:42:41 -04:00
|
|
|
return mergeChainWithDefaults(levelChainsFromResponse(currentChain), defaultLevelChains, type);
|
|
|
|
|
}, [currentChain, defaultLevelChains, levelChains, library, type]);
|
2026-06-09 14:51:38 -04:00
|
|
|
const activeLevelChains = chainDirty ? levelChains : resolvedLevelChains;
|
2026-07-07 09:42:41 -04:00
|
|
|
// 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));
|
2026-06-09 14:51:38 -04:00
|
|
|
|
|
|
|
|
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) {
|
2026-07-07 09:42:41 -04:00
|
|
|
// 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);
|
2026-06-09 14:51:38 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 18:52:44 -04:00
|
|
|
function toggleTrailerKind(kind: string) {
|
|
|
|
|
setTrailerKinds((current) =>
|
|
|
|
|
current.includes(kind) ? current.filter((k) => k !== kind) : [...current, kind],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 14:51:38 -04:00
|
|
|
function reorderLevel(level: string, items: LevelChainItem[]) {
|
|
|
|
|
setLevelChains({ ...activeLevelChains, [level]: items });
|
|
|
|
|
setChainDirty(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleLevelProvider(level: string, index: number) {
|
2026-06-26 12:46:40 -04:00
|
|
|
const source = activeLevelChains[level] ?? [];
|
|
|
|
|
const updated = [...source];
|
|
|
|
|
updated[index] = { ...updated[index]!, enabled: !updated[index]!.enabled };
|
|
|
|
|
setLevelChains({ ...activeLevelChains, [level]: updated });
|
2026-06-09 14:51:38 -04:00
|
|
|
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,
|
2026-06-10 14:58:54 -04:00
|
|
|
auto_translate_metadata: autoTranslateMetadata,
|
2026-06-09 14:51:38 -04:00
|
|
|
chapter_thumbnails_enabled: chapterThumbnailsEnabled,
|
|
|
|
|
intro_detection_enabled: introDetectionEnabled,
|
2026-07-06 18:52:44 -04:00
|
|
|
trailer_kinds: trailerKinds,
|
2026-06-09 14:51:38 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
2026-06-10 14:58:54 -04:00
|
|
|
autoTranslateMetadata,
|
|
|
|
|
setAutoTranslateMetadata,
|
2026-06-09 14:51:38 -04:00
|
|
|
chapterThumbnailsEnabled,
|
|
|
|
|
setChapterThumbnailsEnabled,
|
|
|
|
|
introDetectionEnabled,
|
|
|
|
|
setIntroDetectionEnabled,
|
2026-07-06 18:52:44 -04:00
|
|
|
trailerKinds,
|
|
|
|
|
toggleTrailerKind,
|
2026-06-09 14:51:38 -04:00
|
|
|
contentLevels: contentLevelsForType(type),
|
|
|
|
|
activeLevelChains,
|
2026-07-07 09:42:41 -04:00
|
|
|
chainLoading,
|
2026-06-09 14:51:38 -04:00
|
|
|
reorderLevel,
|
|
|
|
|
toggleLevelProvider,
|
2026-07-07 09:42:41 -04:00
|
|
|
hasMetadataProviders: hasMetadataProviderCapability(installations),
|
2026-06-09 14:51:38 -04:00
|
|
|
errors,
|
|
|
|
|
isPending,
|
|
|
|
|
submit,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type LibraryFormController = ReturnType<typeof useLibraryForm>;
|