From 1823f06ab7a4135fb53c38c1efa8d51725d6a7fa Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 18 Oct 2023 14:09:13 +1100 Subject: [PATCH] Plugin settings (#4143) * Add backend support for plugin settings * Add plugin settings config * Add UI support for plugin settings --- gqlgen.yml | 5 + graphql/documents/data/config.graphql | 1 + graphql/documents/mutations/plugins.graphql | 4 + graphql/documents/queries/plugins.graphql | 7 ++ graphql/schema/schema.graphql | 3 + graphql/schema/types/config.graphql | 1 + graphql/schema/types/plugin.graphql | 14 +++ internal/api/resolver.go | 4 + internal/api/resolver_model_config.go | 25 +++++ internal/api/resolver_mutation_configure.go | 11 ++ internal/manager/config/config.go | 53 ++++++++- pkg/plugin/config.go | 52 ++++++++- pkg/plugin/plugins.go | 26 +++-- pkg/plugin/setting.go | 50 +++++++++ ui/v2.5/src/components/Settings/Inputs.tsx | 12 ++- .../SettingsInterfacePanel.tsx | 4 +- .../Settings/SettingsLibraryPanel.tsx | 4 +- .../Settings/SettingsPluginsPanel.tsx | 96 ++++++++++++++++- .../Settings/SettingsScrapingPanel.tsx | 4 +- .../Settings/SettingsSecurityPanel.tsx | 4 +- .../Settings/SettingsServicesPanel.tsx | 9 +- .../Settings/SettingsSystemPanel.tsx | 5 +- ui/v2.5/src/components/Settings/context.tsx | 101 ++++++++++++++---- ui/v2.5/src/core/StashService.ts | 5 + 24 files changed, 444 insertions(+), 56 deletions(-) create mode 100644 internal/api/resolver_model_config.go create mode 100644 pkg/plugin/setting.go diff --git a/gqlgen.yml b/gqlgen.yml index 5e8f09444b6..3693475e5a6 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -152,4 +152,9 @@ models: model: github.com/stashapp/stash/pkg/scraper.Source SavedFindFilterType: model: github.com/stashapp/stash/pkg/models.FindFilterType + # force resolvers + ConfigResult: + fields: + plugins: + resolver: true diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 022cace5b9e..cfec9336df6 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -207,4 +207,5 @@ fragment ConfigData on ConfigResult { ...ConfigDefaultSettingsData } ui + plugins } diff --git a/graphql/documents/mutations/plugins.graphql b/graphql/documents/mutations/plugins.graphql index 0ce26740403..e7785a6edbd 100644 --- a/graphql/documents/mutations/plugins.graphql +++ b/graphql/documents/mutations/plugins.graphql @@ -10,6 +10,10 @@ mutation RunPluginTask( runPluginTask(plugin_id: $plugin_id, task_name: $task_name, args: $args) } +mutation ConfigurePlugin($plugin_id: ID!, $input: Map!) { + configurePlugin(plugin_id: $plugin_id, input: $input) +} + mutation SetPluginsEnabled($enabledMap: BoolMap!) { setPluginsEnabled(enabledMap: $enabledMap) } diff --git a/graphql/documents/queries/plugins.graphql b/graphql/documents/queries/plugins.graphql index 0a757cac321..901e1722b27 100644 --- a/graphql/documents/queries/plugins.graphql +++ b/graphql/documents/queries/plugins.graphql @@ -17,6 +17,13 @@ query Plugins { description hooks } + + settings { + name + display_name + description + type + } } } diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 1c1e987e44e..ccd7e6a625f 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -319,6 +319,9 @@ type Mutation { input: ConfigDefaultSettingsInput! ): ConfigDefaultSettingsResult! + # overwrites the entire plugin configuration for the given plugin + configurePlugin(plugin_id: ID!, input: Map!): Map! + # overwrites the entire UI configuration configureUI(input: Map!): Map! # sets a single UI key value diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index ea588c58204..90b2fde4c6f 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -521,6 +521,7 @@ type ConfigResult { scraping: ConfigScrapingResult! defaults: ConfigDefaultSettingsResult! ui: Map! + plugins(include: [String!]): Map! } "Directory structure of a path" diff --git a/graphql/schema/types/plugin.graphql b/graphql/schema/types/plugin.graphql index e9da728b53e..45a2f55f1c1 100644 --- a/graphql/schema/types/plugin.graphql +++ b/graphql/schema/types/plugin.graphql @@ -9,6 +9,7 @@ type Plugin { tasks: [PluginTask!] hooks: [PluginHook!] + settings: [PluginSetting!] } type PluginTask { @@ -42,3 +43,16 @@ input PluginValueInput { o: [PluginArgInput!] a: [PluginValueInput!] } + +enum PluginSettingTypeEnum { + STRING + NUMBER + BOOLEAN +} + +type PluginSetting { + name: String! + display_name: String + description: String + type: PluginSettingTypeEnum! +} diff --git a/internal/api/resolver.go b/internal/api/resolver.go index 7988182acaa..8c82be334e1 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -84,6 +84,9 @@ func (r *Resolver) Tag() TagResolver { func (r *Resolver) SavedFilter() SavedFilterResolver { return &savedFilterResolver{r} } +func (r *Resolver) ConfigResult() ConfigResultResolver { + return &configResultResolver{r} +} type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } @@ -99,6 +102,7 @@ type studioResolver struct{ *Resolver } type movieResolver struct{ *Resolver } type tagResolver struct{ *Resolver } type savedFilterResolver struct{ *Resolver } +type configResultResolver struct{ *Resolver } func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error { return r.repository.WithTxn(ctx, fn) diff --git a/internal/api/resolver_model_config.go b/internal/api/resolver_model_config.go new file mode 100644 index 00000000000..a255699effb --- /dev/null +++ b/internal/api/resolver_model_config.go @@ -0,0 +1,25 @@ +package api + +import ( + "context" + + "github.com/stashapp/stash/internal/manager/config" +) + +func (r *configResultResolver) Plugins(ctx context.Context, obj *ConfigResult, include []string) (map[string]interface{}, error) { + if len(include) == 0 { + ret := config.GetInstance().GetAllPluginConfiguration() + return ret, nil + } + + ret := make(map[string]interface{}) + + for _, plugin := range include { + c := config.GetInstance().GetPluginConfiguration(plugin) + if len(c) > 0 { + ret[plugin] = c + } + } + + return ret, nil +} diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 1c44461a85c..2ba94ba169e 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -629,3 +629,14 @@ func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, v return r.ConfigureUI(ctx, cfg) } + +func (r *mutationResolver) ConfigurePlugin(ctx context.Context, pluginID string, input map[string]interface{}) (map[string]interface{}, error) { + c := config.GetInstance() + c.SetPluginConfiguration(pluginID, input) + + if err := c.Write(); err != nil { + return c.GetPluginConfiguration(pluginID), err + } + + return c.GetPluginConfiguration(pluginID), nil +} diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 0676a7a63f4..ee1eb1b2838 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -131,8 +131,10 @@ const ( PythonPath = "python_path" // plugin options - PluginsPath = "plugins_path" - DisabledPlugins = "plugins.disabled" + PluginsPath = "plugins_path" + PluginsSetting = "plugins.settings" + PluginsSettingPrefix = PluginsSetting + "." + DisabledPlugins = "plugins.disabled" // i18n Language = "language" @@ -723,6 +725,53 @@ func (i *Instance) GetPluginsPath() string { return i.getString(PluginsPath) } +func (i *Instance) GetAllPluginConfiguration() map[string]interface{} { + i.RLock() + defer i.RUnlock() + + ret := make(map[string]interface{}) + + sub := i.viper(PluginsSetting).GetStringMap(PluginsSetting) + if sub == nil { + return ret + } + + for plugin := range sub { + // HACK: viper changes map keys to case insensitive values, so the workaround is to + // convert map keys to snake case for storage + name := fromSnakeCase(plugin) + ret[name] = fromSnakeCaseMap(i.viper(PluginsSetting).GetStringMap(PluginsSettingPrefix + plugin)) + } + + return ret +} + +func (i *Instance) GetPluginConfiguration(pluginID string) map[string]interface{} { + i.RLock() + defer i.RUnlock() + + key := PluginsSettingPrefix + toSnakeCase(pluginID) + + // HACK: viper changes map keys to case insensitive values, so the workaround is to + // convert map keys to snake case for storage + v := i.viper(key).GetStringMap(key) + + return fromSnakeCaseMap(v) +} + +func (i *Instance) SetPluginConfiguration(pluginID string, v map[string]interface{}) { + i.RLock() + defer i.RUnlock() + + pluginID = toSnakeCase(pluginID) + + key := PluginsSettingPrefix + pluginID + + // HACK: viper changes map keys to case insensitive values, so the workaround is to + // convert map keys to snake case for storage + i.viper(key).Set(key, toSnakeCaseMap(v)) +} + func (i *Instance) GetDisabledPlugins() []string { return i.getStringSlice(DisabledPlugins) } diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go index eac7289a805..6529402b24c 100644 --- a/pkg/plugin/config.go +++ b/pkg/plugin/config.go @@ -59,6 +59,9 @@ type Config struct { // Javascript files that will be injected into the stash UI. UI UIConfig `yaml:"ui"` + + // Settings that will be used to configure the plugin. + Settings map[string]SettingConfig `yaml:"settings"` } type UIConfig struct { @@ -87,6 +90,14 @@ func (c UIConfig) getJavascriptFiles(parent Config) []string { return ret } +type SettingConfig struct { + // defaults to string + Type PluginSettingTypeEnum `yaml:"type"` + // defaults to key name + DisplayName string `yaml:"displayName"` + Description string `yaml:"description"` +} + func (c Config) getPluginTasks(includePlugin bool) []*PluginTask { var ret []*PluginTask @@ -133,6 +144,28 @@ func convertHooks(hooks []HookTriggerEnum) []string { return ret } +func (c Config) getPluginSettings() []PluginSetting { + ret := []PluginSetting{} + + for k, o := range c.Settings { + t := o.Type + if t == "" { + t = PluginSettingTypeEnumString + } + + s := PluginSetting{ + Name: k, + DisplayName: o.DisplayName, + Description: o.Description, + Type: t, + } + + ret = append(ret, s) + } + + return ret +} + func (c Config) getName() string { if c.Name != "" { return c.Name @@ -154,6 +187,7 @@ func (c Config) toPlugin() *Plugin { Javascript: c.UI.getJavascriptFiles(c), CSS: c.UI.getCSSFiles(c), }, + Settings: c.getPluginSettings(), } } @@ -211,6 +245,20 @@ func (c Config) getExecCommand(task *OperationConfig) []string { return ret } +func (c Config) valid() error { + if c.Interface != "" && !c.Interface.Valid() { + return fmt.Errorf("invalid interface type %s", c.Interface) + } + + for k, o := range c.Settings { + if o.Type != "" && !o.Type.IsValid() { + return fmt.Errorf("invalid type %s for setting %s", k, o.Type) + } + } + + return nil +} + type interfaceEnum string // Valid interfaceEnum values @@ -292,8 +340,8 @@ func loadPluginFromYAML(reader io.Reader) (*Config, error) { ret.Interface = InterfaceEnumRaw } - if !ret.Interface.Valid() { - return nil, fmt.Errorf("invalid interface type %s", ret.Interface) + if err := ret.valid(); err != nil { + return nil, err } return ret, nil diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go index addc22d6714..2f9c60dc729 100644 --- a/pkg/plugin/plugins.go +++ b/pkg/plugin/plugins.go @@ -24,14 +24,15 @@ import ( ) type Plugin struct { - ID string `json:"id"` - Name string `json:"name"` - Description *string `json:"description"` - URL *string `json:"url"` - Version *string `json:"version"` - Tasks []*PluginTask `json:"tasks"` - Hooks []*PluginHook `json:"hooks"` - UI PluginUI `json:"ui"` + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + URL *string `json:"url"` + Version *string `json:"version"` + Tasks []*PluginTask `json:"tasks"` + Hooks []*PluginHook `json:"hooks"` + UI PluginUI `json:"ui"` + Settings []PluginSetting `json:"settings"` Enabled bool `json:"enabled"` } @@ -44,6 +45,15 @@ type PluginUI struct { CSS []string `json:"css"` } +type PluginSetting struct { + Name string `json:"name"` + // defaults to string + Type PluginSettingTypeEnum `json:"type"` + // defaults to key name + DisplayName string `json:"displayName"` + Description string `json:"description"` +} + type ServerConfig interface { GetHost() string GetPort() int diff --git a/pkg/plugin/setting.go b/pkg/plugin/setting.go new file mode 100644 index 00000000000..582a4fe1d52 --- /dev/null +++ b/pkg/plugin/setting.go @@ -0,0 +1,50 @@ +package plugin + +import ( + "fmt" + "io" + "strconv" +) + +type PluginSettingTypeEnum string + +const ( + PluginSettingTypeEnumString PluginSettingTypeEnum = "STRING" + PluginSettingTypeEnumNumber PluginSettingTypeEnum = "NUMBER" + PluginSettingTypeEnumBoolean PluginSettingTypeEnum = "BOOLEAN" +) + +var AllPluginSettingTypeEnum = []PluginSettingTypeEnum{ + PluginSettingTypeEnumString, + PluginSettingTypeEnumNumber, + PluginSettingTypeEnumBoolean, +} + +func (e PluginSettingTypeEnum) IsValid() bool { + switch e { + case PluginSettingTypeEnumString, PluginSettingTypeEnumNumber, PluginSettingTypeEnumBoolean: + return true + } + return false +} + +func (e PluginSettingTypeEnum) String() string { + return string(e) +} + +func (e *PluginSettingTypeEnum) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = PluginSettingTypeEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid PluginSettingTypeEnum", str) + } + return nil +} + +func (e PluginSettingTypeEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} diff --git a/ui/v2.5/src/components/Settings/Inputs.tsx b/ui/v2.5/src/components/Settings/Inputs.tsx index 9ecf1d54c3d..d05afbfad5d 100644 --- a/ui/v2.5/src/components/Settings/Inputs.tsx +++ b/ui/v2.5/src/components/Settings/Inputs.tsx @@ -192,6 +192,7 @@ export const ChangeButtonSetting = (props: IDialogSetting) => { id, className, headingID, + heading, tooltipID, subHeadingID, subHeading, @@ -211,7 +212,11 @@ export const ChangeButtonSetting = (props: IDialogSetting) => {

- {headingID ? intl.formatMessage({ id: headingID }) : undefined} + {headingID + ? intl.formatMessage({ id: headingID }) + : heading + ? heading + : undefined}

@@ -240,7 +245,7 @@ export const ChangeButtonSetting = (props: IDialogSetting) => { }; export interface ISettingModal { - heading?: string; + heading?: React.ReactNode; headingID?: string; subHeadingID?: string; subHeading?: React.ReactNode; @@ -319,6 +324,7 @@ export const ModalSetting = (props: IModalSetting) => { className, value, headingID, + heading, subHeadingID, subHeading, onChange, @@ -338,6 +344,7 @@ export const ModalSetting = (props: IModalSetting) => { headingID={headingID} subHeadingID={subHeadingID} + heading={heading} subHeading={subHeading} value={value} renderField={renderField} @@ -356,6 +363,7 @@ export const ModalSetting = (props: IModalSetting) => { buttonText={buttonText} buttonTextID={buttonTextID} headingID={headingID} + heading={heading} tooltipID={tooltipID} subHeadingID={subHeadingID} subHeading={subHeading} diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 49f72b52db1..c3c36417e04 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -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, diff --git a/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx index d8cc0f67cee..76c72968f95 100644 --- a/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx @@ -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) { diff --git a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx index 0fc0bbbdaed..da3aa13ab03 100644 --- a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx @@ -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 = ({ + 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 ( + onChange(!value)} + /> + ); + case GQL.PluginSettingTypeEnum.String: + return ( + onChange(v)} + /> + ); + case GQL.PluginSettingTypeEnum.Number: + return ( + 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 ?? [])} )); @@ -145,10 +201,40 @@ export const SettingsPluginsPanel: React.FC = () => { ); } + function renderPluginSettings( + pluginID: string, + settings: GQL.PluginSetting[] + ) { + const pluginSettings = plugins[pluginID] ?? {}; + + return settings.map((setting) => ( + + savePluginSettings(pluginID, { + ...pluginSettings, + [setting.name]: v, + }) + } + /> + )); + } + return renderPlugins(); - }, [data?.plugins, intl, Toast, changedPluginID, refetch]); + }, [ + data?.plugins, + intl, + Toast, + changedPluginID, + refetch, + plugins, + savePluginSettings, + ]); - if (loading) return ; + if (loading || configLoading) return ; return ( <> diff --git a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx index 51761ec70e0..3b7b3214eed 100644 --- a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx @@ -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)); diff --git a/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx index b9921793873..aaed1e7d4ef 100644 --- a/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx @@ -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(); diff --git a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx index 57a8bf99fc0..d25b5452b20 100644 --- a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx @@ -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(); diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index c5b0f36c1c3..d41a4a3bc64 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -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, diff --git a/ui/v2.5/src/components/Settings/context.tsx b/ui/v2.5/src/components/Settings/context.tsx index eae1bd4040f..7f4e4702faf 100644 --- a/ui/v2.5/src/components/Settings/context.tsx +++ b/ui/v2.5/src/components/Settings/context.tsx @@ -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>; 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) => void; saveDLNA: (input: Partial) => void; saveUI: (input: Partial) => void; + savePluginSettings: (pluginID: string, input: {}) => void; refetch: () => void; } -export const SettingStateContext = React.createContext({ - loading: false, - error: undefined, - general: {}, - interface: {}, - defaults: {}, - scraping: {}, - dlna: {}, - ui: {}, - apiKey: "", - saveGeneral: () => {}, - saveInterface: () => {}, - saveDefaults: () => {}, - saveScraping: () => {}, - saveDLNA: () => {}, - saveUI: () => {}, - refetch: () => {}, -}); +export const SettingStateContext = + React.createContext(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({}); + const [pendingPlugins, setPendingPlugins] = useState(); + const [updatePluginConfig] = useConfigurePlugin(); + const [updateSuccess, setUpdateSuccess] = useState(); 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 + ) { + 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 (
@@ -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()} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 9a7f516adf3..3da819d49da 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2031,6 +2031,11 @@ export const useConfigureDLNA = () => update: updateConfiguration, }); +export const useConfigurePlugin = () => + GQL.useConfigurePluginMutation({ + update: updateConfiguration, + }); + export const useEnableDLNA = () => GQL.useEnableDlnaMutation(); export const useDisableDLNA = () => GQL.useDisableDlnaMutation();