mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Plugin settings (#4143)
* Add backend support for plugin settings * Add plugin settings config * Add UI support for plugin settings
This commit is contained in:
@@ -192,6 +192,7 @@ export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
|
||||
id,
|
||||
className,
|
||||
headingID,
|
||||
heading,
|
||||
tooltipID,
|
||||
subHeadingID,
|
||||
subHeading,
|
||||
@@ -211,7 +212,11 @@ export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
|
||||
<div className={`setting ${className ?? ""} ${disabledClassName}`} id={id}>
|
||||
<div>
|
||||
<h3 title={tooltip}>
|
||||
{headingID ? intl.formatMessage({ id: headingID }) : undefined}
|
||||
{headingID
|
||||
? intl.formatMessage({ id: headingID })
|
||||
: heading
|
||||
? heading
|
||||
: undefined}
|
||||
</h3>
|
||||
|
||||
<div className="value">
|
||||
@@ -240,7 +245,7 @@ export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
|
||||
};
|
||||
|
||||
export interface ISettingModal<T> {
|
||||
heading?: string;
|
||||
heading?: React.ReactNode;
|
||||
headingID?: string;
|
||||
subHeadingID?: string;
|
||||
subHeading?: React.ReactNode;
|
||||
@@ -319,6 +324,7 @@ export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
|
||||
className,
|
||||
value,
|
||||
headingID,
|
||||
heading,
|
||||
subHeadingID,
|
||||
subHeading,
|
||||
onChange,
|
||||
@@ -338,6 +344,7 @@ export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
|
||||
<SettingModal<T>
|
||||
headingID={headingID}
|
||||
subHeadingID={subHeadingID}
|
||||
heading={heading}
|
||||
subHeading={subHeading}
|
||||
value={value}
|
||||
renderField={renderField}
|
||||
@@ -356,6 +363,7 @@ export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
|
||||
buttonText={buttonText}
|
||||
buttonTextID={buttonTextID}
|
||||
headingID={headingID}
|
||||
heading={heading}
|
||||
tooltipID={tooltipID}
|
||||
subHeadingID={subHeadingID}
|
||||
subHeading={subHeading}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
SelectSetting,
|
||||
StringSetting,
|
||||
} from "../Inputs";
|
||||
import { SettingStateContext } from "../context";
|
||||
import { useSettings } from "../context";
|
||||
import DurationUtils from "src/utils/duration";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
@@ -65,7 +65,7 @@ export const SettingsInterfacePanel: React.FC = () => {
|
||||
saveUI,
|
||||
loading,
|
||||
error,
|
||||
} = React.useContext(SettingStateContext);
|
||||
} = useSettings();
|
||||
|
||||
const {
|
||||
interactive,
|
||||
|
||||
@@ -4,14 +4,14 @@ import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import { StashSetting } from "./StashConfiguration";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
|
||||
import { SettingStateContext } from "./context";
|
||||
import { useSettings } from "./context";
|
||||
import { useIntl } from "react-intl";
|
||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export const SettingsLibraryPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { general, loading, error, saveGeneral, defaults, saveDefaults } =
|
||||
React.useContext(SettingStateContext);
|
||||
useSettings();
|
||||
|
||||
function commaDelimitedToList(value: string | undefined) {
|
||||
if (value) {
|
||||
|
||||
@@ -13,19 +13,74 @@ import { CollapseButton } from "../Shared/CollapseButton";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
import { Setting, SettingGroup } from "./Inputs";
|
||||
import {
|
||||
BooleanSetting,
|
||||
NumberSetting,
|
||||
Setting,
|
||||
SettingGroup,
|
||||
StringSetting,
|
||||
} from "./Inputs";
|
||||
import { faLink, faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useSettings } from "./context";
|
||||
|
||||
interface IPluginSettingProps {
|
||||
pluginID: string;
|
||||
setting: GQL.PluginSetting;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
const PluginSetting: React.FC<IPluginSettingProps> = ({
|
||||
pluginID,
|
||||
setting,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const commonProps = {
|
||||
heading: setting.display_name ? setting.display_name : setting.name,
|
||||
id: `plugin-${pluginID}-${setting.name}`,
|
||||
subHeading: setting.description ?? undefined,
|
||||
};
|
||||
|
||||
switch (setting.type) {
|
||||
case GQL.PluginSettingTypeEnum.Boolean:
|
||||
return (
|
||||
<BooleanSetting
|
||||
{...commonProps}
|
||||
checked={(value as boolean) ?? false}
|
||||
onChange={() => onChange(!value)}
|
||||
/>
|
||||
);
|
||||
case GQL.PluginSettingTypeEnum.String:
|
||||
return (
|
||||
<StringSetting
|
||||
{...commonProps}
|
||||
value={(value as string) ?? ""}
|
||||
onChange={(v) => onChange(v)}
|
||||
/>
|
||||
);
|
||||
case GQL.PluginSettingTypeEnum.Number:
|
||||
return (
|
||||
<NumberSetting
|
||||
{...commonProps}
|
||||
value={(value as number) ?? 0}
|
||||
onChange={(v) => onChange(v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const SettingsPluginsPanel: React.FC = () => {
|
||||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
|
||||
const { loading: configLoading, plugins, savePluginSettings } = useSettings();
|
||||
const { data, loading, refetch } = usePlugins();
|
||||
|
||||
const [changedPluginID, setChangedPluginID] = React.useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
const { data, loading, refetch } = usePlugins();
|
||||
|
||||
async function onReloadPlugins() {
|
||||
await mutateReloadPlugins().catch((e) => Toast.error(e));
|
||||
}
|
||||
@@ -101,6 +156,7 @@ export const SettingsPluginsPanel: React.FC = () => {
|
||||
}
|
||||
>
|
||||
{renderPluginHooks(plugin.hooks ?? undefined)}
|
||||
{renderPluginSettings(plugin.id, plugin.settings ?? [])}
|
||||
</SettingGroup>
|
||||
));
|
||||
|
||||
@@ -145,10 +201,40 @@ export const SettingsPluginsPanel: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
return renderPlugins();
|
||||
}, [data?.plugins, intl, Toast, changedPluginID, refetch]);
|
||||
function renderPluginSettings(
|
||||
pluginID: string,
|
||||
settings: GQL.PluginSetting[]
|
||||
) {
|
||||
const pluginSettings = plugins[pluginID] ?? {};
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
return settings.map((setting) => (
|
||||
<PluginSetting
|
||||
key={setting.name}
|
||||
pluginID={pluginID}
|
||||
setting={setting}
|
||||
value={pluginSettings[setting.name]}
|
||||
onChange={(v) =>
|
||||
savePluginSettings(pluginID, {
|
||||
...pluginSettings,
|
||||
[setting.name]: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
return renderPlugins();
|
||||
}, [
|
||||
data?.plugins,
|
||||
intl,
|
||||
Toast,
|
||||
changedPluginID,
|
||||
refetch,
|
||||
plugins,
|
||||
savePluginSettings,
|
||||
]);
|
||||
|
||||
if (loading || configLoading) return <LoadingIndicator />;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import { ScrapeType } from "src/core/generated-graphql";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
|
||||
import { SettingStateContext } from "./context";
|
||||
import { useSettings } from "./context";
|
||||
import { StashBoxSetting } from "./StashBoxConfiguration";
|
||||
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
@@ -87,7 +87,7 @@ export const SettingsScrapingPanel: React.FC = () => {
|
||||
useListMovieScrapers();
|
||||
|
||||
const { general, scraping, loading, error, saveGeneral, saveScraping } =
|
||||
React.useContext(SettingStateContext);
|
||||
useSettings();
|
||||
|
||||
async function onReloadScrapers() {
|
||||
await mutateReloadScrapers().catch((e) => Toast.error(e));
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SettingSection } from "./SettingSection";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { SettingStateContext } from "./context";
|
||||
import { useSettings } from "./context";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { useGenerateAPIKey } from "src/core/StashService";
|
||||
@@ -72,7 +72,7 @@ export const SettingsSecurityPanel: React.FC = () => {
|
||||
const Toast = useToast();
|
||||
|
||||
const { general, apiKey, loading, error, saveGeneral, refetch } =
|
||||
React.useContext(SettingStateContext);
|
||||
useSettings();
|
||||
|
||||
const [generateAPIKey] = useGenerateAPIKey();
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
StringSetting,
|
||||
SelectSetting,
|
||||
} from "./Inputs";
|
||||
import { SettingStateContext } from "./context";
|
||||
import { useSettings } from "./context";
|
||||
import {
|
||||
videoSortOrderIntlMap,
|
||||
defaultVideoSort,
|
||||
@@ -35,12 +35,7 @@ export const SettingsServicesPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
|
||||
const {
|
||||
dlna,
|
||||
loading: configLoading,
|
||||
error,
|
||||
saveDLNA,
|
||||
} = React.useContext(SettingStateContext);
|
||||
const { dlna, loading: configLoading, error, saveDLNA } = useSettings();
|
||||
|
||||
// undefined to hide dialog, true for enable, false for disable
|
||||
const [enableDisable, setEnableDisable] = useState<boolean>();
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
StringListSetting,
|
||||
StringSetting,
|
||||
} from "./Inputs";
|
||||
import { SettingStateContext } from "./context";
|
||||
import { useSettings } from "./context";
|
||||
import {
|
||||
VideoPreviewInput,
|
||||
VideoPreviewSettingsInput,
|
||||
@@ -20,8 +20,7 @@ import { useIntl } from "react-intl";
|
||||
export const SettingsConfigurationPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { general, loading, error, saveGeneral } =
|
||||
React.useContext(SettingStateContext);
|
||||
const { general, loading, error, saveGeneral } = useSettings();
|
||||
|
||||
const transcodeQualities = [
|
||||
GQL.StreamingResolutionEnum.Low,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useConfigureDLNA,
|
||||
useConfigureGeneral,
|
||||
useConfigureInterface,
|
||||
useConfigurePlugin,
|
||||
useConfigureScraping,
|
||||
useConfigureUI,
|
||||
} from "src/core/StashService";
|
||||
@@ -21,6 +22,7 @@ import { useToast } from "src/hooks/Toast";
|
||||
import { withoutTypename } from "src/utils/data";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
|
||||
type PluginSettings = Record<string, Record<string, unknown>>;
|
||||
export interface ISettingsContextState {
|
||||
loading: boolean;
|
||||
error: ApolloError | undefined;
|
||||
@@ -30,6 +32,7 @@ export interface ISettingsContextState {
|
||||
scraping: GQL.ConfigScrapingInput;
|
||||
dlna: GQL.ConfigDlnaInput;
|
||||
ui: IUIConfig;
|
||||
plugins: PluginSettings;
|
||||
|
||||
// apikey isn't directly settable, so expose it here
|
||||
apiKey: string;
|
||||
@@ -40,28 +43,23 @@ export interface ISettingsContextState {
|
||||
saveScraping: (input: Partial<GQL.ConfigScrapingInput>) => void;
|
||||
saveDLNA: (input: Partial<GQL.ConfigDlnaInput>) => void;
|
||||
saveUI: (input: Partial<IUIConfig>) => void;
|
||||
savePluginSettings: (pluginID: string, input: {}) => void;
|
||||
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export const SettingStateContext = React.createContext<ISettingsContextState>({
|
||||
loading: false,
|
||||
error: undefined,
|
||||
general: {},
|
||||
interface: {},
|
||||
defaults: {},
|
||||
scraping: {},
|
||||
dlna: {},
|
||||
ui: {},
|
||||
apiKey: "",
|
||||
saveGeneral: () => {},
|
||||
saveInterface: () => {},
|
||||
saveDefaults: () => {},
|
||||
saveScraping: () => {},
|
||||
saveDLNA: () => {},
|
||||
saveUI: () => {},
|
||||
refetch: () => {},
|
||||
});
|
||||
export const SettingStateContext =
|
||||
React.createContext<ISettingsContextState | null>(null);
|
||||
|
||||
export const useSettings = () => {
|
||||
const context = React.useContext(SettingStateContext);
|
||||
|
||||
if (context === null) {
|
||||
throw new Error("useSettings must be used within a SettingsContext");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export const SettingsContext: React.FC = ({ children }) => {
|
||||
const Toast = useToast();
|
||||
@@ -97,6 +95,10 @@ export const SettingsContext: React.FC = ({ children }) => {
|
||||
const [pendingUI, setPendingUI] = useState<{}>();
|
||||
const [updateUIConfig] = useConfigureUI();
|
||||
|
||||
const [plugins, setPlugins] = useState<PluginSettings>({});
|
||||
const [pendingPlugins, setPendingPlugins] = useState<PluginSettings>();
|
||||
const [updatePluginConfig] = useConfigurePlugin();
|
||||
|
||||
const [updateSuccess, setUpdateSuccess] = useState<boolean>();
|
||||
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
@@ -132,6 +134,7 @@ export const SettingsContext: React.FC = ({ children }) => {
|
||||
setScraping({ ...withoutTypename(data.configuration.scraping) });
|
||||
setDLNA({ ...withoutTypename(data.configuration.dlna) });
|
||||
setUI(data.configuration.ui);
|
||||
setPlugins(data.configuration.plugins);
|
||||
}, [data, error]);
|
||||
|
||||
const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), 4000);
|
||||
@@ -433,6 +436,63 @@ export const SettingsContext: React.FC = ({ children }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// saves the configuration if no further changes are made after a half second
|
||||
const savePluginConfig = useDebounce(async (input: PluginSettings) => {
|
||||
try {
|
||||
setUpdateSuccess(undefined);
|
||||
|
||||
for (const pluginID in input) {
|
||||
await updatePluginConfig({
|
||||
variables: {
|
||||
plugin_id: pluginID,
|
||||
input: input[pluginID],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setPendingPlugins(undefined);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setSaveError(e);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingPlugins) {
|
||||
return;
|
||||
}
|
||||
|
||||
savePluginConfig(pendingPlugins);
|
||||
}, [pendingPlugins, savePluginConfig]);
|
||||
|
||||
function savePluginSettings(
|
||||
pluginID: string,
|
||||
input: Record<string, unknown>
|
||||
) {
|
||||
if (!plugins) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPlugins({
|
||||
...plugins,
|
||||
[pluginID]: input,
|
||||
});
|
||||
|
||||
setPendingPlugins((current) => {
|
||||
if (!current) {
|
||||
// use full UI object to ensure nothing is wiped
|
||||
return {
|
||||
...plugins,
|
||||
[pluginID]: input,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[pluginID]: input,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function maybeRenderLoadingIndicator() {
|
||||
if (updateSuccess === false) {
|
||||
return (
|
||||
@@ -448,7 +508,8 @@ export const SettingsContext: React.FC = ({ children }) => {
|
||||
pendingDefaults ||
|
||||
pendingScraping ||
|
||||
pendingDLNA ||
|
||||
pendingUI
|
||||
pendingUI ||
|
||||
pendingPlugins
|
||||
) {
|
||||
return (
|
||||
<div className="loading-indicator">
|
||||
@@ -480,6 +541,7 @@ export const SettingsContext: React.FC = ({ children }) => {
|
||||
scraping,
|
||||
dlna,
|
||||
ui,
|
||||
plugins,
|
||||
saveGeneral,
|
||||
saveInterface,
|
||||
saveDefaults,
|
||||
@@ -487,6 +549,7 @@ export const SettingsContext: React.FC = ({ children }) => {
|
||||
saveDLNA,
|
||||
saveUI,
|
||||
refetch,
|
||||
savePluginSettings,
|
||||
}}
|
||||
>
|
||||
{maybeRenderLoadingIndicator()}
|
||||
|
||||
Reference in New Issue
Block a user