Plugin settings (#4143)

* Add backend support for plugin settings
* Add plugin settings config
* Add UI support for plugin settings
This commit is contained in:
WithoutPants 2023-10-18 14:09:13 +11:00 committed by GitHub
parent 06d8353f4f
commit 2b8718100b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 445 additions and 57 deletions

View file

@ -152,4 +152,9 @@ models:
model: github.com/stashapp/stash/pkg/scraper.Source model: github.com/stashapp/stash/pkg/scraper.Source
SavedFindFilterType: SavedFindFilterType:
model: github.com/stashapp/stash/pkg/models.FindFilterType model: github.com/stashapp/stash/pkg/models.FindFilterType
# force resolvers
ConfigResult:
fields:
plugins:
resolver: true

View file

@ -207,4 +207,5 @@ fragment ConfigData on ConfigResult {
...ConfigDefaultSettingsData ...ConfigDefaultSettingsData
} }
ui ui
plugins
} }

View file

@ -10,6 +10,10 @@ mutation RunPluginTask(
runPluginTask(plugin_id: $plugin_id, task_name: $task_name, args: $args) 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!) { mutation SetPluginsEnabled($enabledMap: BoolMap!) {
setPluginsEnabled(enabledMap: $enabledMap) setPluginsEnabled(enabledMap: $enabledMap)
} }

View file

@ -17,6 +17,13 @@ query Plugins {
description description
hooks hooks
} }
settings {
name
display_name
description
type
}
} }
} }

View file

@ -319,6 +319,9 @@ type Mutation {
input: ConfigDefaultSettingsInput! input: ConfigDefaultSettingsInput!
): ConfigDefaultSettingsResult! ): ConfigDefaultSettingsResult!
# overwrites the entire plugin configuration for the given plugin
configurePlugin(plugin_id: ID!, input: Map!): Map!
# overwrites the entire UI configuration # overwrites the entire UI configuration
configureUI(input: Map!): Map! configureUI(input: Map!): Map!
# sets a single UI key value # sets a single UI key value

View file

@ -521,6 +521,7 @@ type ConfigResult {
scraping: ConfigScrapingResult! scraping: ConfigScrapingResult!
defaults: ConfigDefaultSettingsResult! defaults: ConfigDefaultSettingsResult!
ui: Map! ui: Map!
plugins(include: [String!]): Map!
} }
"Directory structure of a path" "Directory structure of a path"

View file

@ -9,6 +9,7 @@ type Plugin {
tasks: [PluginTask!] tasks: [PluginTask!]
hooks: [PluginHook!] hooks: [PluginHook!]
settings: [PluginSetting!]
} }
type PluginTask { type PluginTask {
@ -42,3 +43,16 @@ input PluginValueInput {
o: [PluginArgInput!] o: [PluginArgInput!]
a: [PluginValueInput!] a: [PluginValueInput!]
} }
enum PluginSettingTypeEnum {
STRING
NUMBER
BOOLEAN
}
type PluginSetting {
name: String!
display_name: String
description: String
type: PluginSettingTypeEnum!
}

View file

@ -84,6 +84,9 @@ func (r *Resolver) Tag() TagResolver {
func (r *Resolver) SavedFilter() SavedFilterResolver { func (r *Resolver) SavedFilter() SavedFilterResolver {
return &savedFilterResolver{r} return &savedFilterResolver{r}
} }
func (r *Resolver) ConfigResult() ConfigResultResolver {
return &configResultResolver{r}
}
type mutationResolver struct{ *Resolver } type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver } type queryResolver struct{ *Resolver }
@ -99,6 +102,7 @@ type studioResolver struct{ *Resolver }
type movieResolver struct{ *Resolver } type movieResolver struct{ *Resolver }
type tagResolver struct{ *Resolver } type tagResolver struct{ *Resolver }
type savedFilterResolver struct{ *Resolver } type savedFilterResolver struct{ *Resolver }
type configResultResolver struct{ *Resolver }
func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error { func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error {
return r.repository.WithTxn(ctx, fn) return r.repository.WithTxn(ctx, fn)

View file

@ -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
}

View file

@ -629,3 +629,14 @@ func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, v
return r.ConfigureUI(ctx, cfg) 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
}

View file

@ -131,8 +131,10 @@ const (
PythonPath = "python_path" PythonPath = "python_path"
// plugin options // plugin options
PluginsPath = "plugins_path" PluginsPath = "plugins_path"
DisabledPlugins = "plugins.disabled" PluginsSetting = "plugins.settings"
PluginsSettingPrefix = PluginsSetting + "."
DisabledPlugins = "plugins.disabled"
// i18n // i18n
Language = "language" Language = "language"
@ -723,6 +725,53 @@ func (i *Instance) GetPluginsPath() string {
return i.getString(PluginsPath) 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 { func (i *Instance) GetDisabledPlugins() []string {
return i.getStringSlice(DisabledPlugins) return i.getStringSlice(DisabledPlugins)
} }

View file

@ -59,6 +59,9 @@ type Config struct {
// Javascript files that will be injected into the stash UI. // Javascript files that will be injected into the stash UI.
UI UIConfig `yaml:"ui"` UI UIConfig `yaml:"ui"`
// Settings that will be used to configure the plugin.
Settings map[string]SettingConfig `yaml:"settings"`
} }
type UIConfig struct { type UIConfig struct {
@ -87,6 +90,14 @@ func (c UIConfig) getJavascriptFiles(parent Config) []string {
return ret 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 { func (c Config) getPluginTasks(includePlugin bool) []*PluginTask {
var ret []*PluginTask var ret []*PluginTask
@ -133,6 +144,28 @@ func convertHooks(hooks []HookTriggerEnum) []string {
return ret 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 { func (c Config) getName() string {
if c.Name != "" { if c.Name != "" {
return c.Name return c.Name
@ -154,6 +187,7 @@ func (c Config) toPlugin() *Plugin {
Javascript: c.UI.getJavascriptFiles(c), Javascript: c.UI.getJavascriptFiles(c),
CSS: c.UI.getCSSFiles(c), CSS: c.UI.getCSSFiles(c),
}, },
Settings: c.getPluginSettings(),
} }
} }
@ -211,6 +245,20 @@ func (c Config) getExecCommand(task *OperationConfig) []string {
return ret 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 type interfaceEnum string
// Valid interfaceEnum values // Valid interfaceEnum values
@ -292,8 +340,8 @@ func loadPluginFromYAML(reader io.Reader) (*Config, error) {
ret.Interface = InterfaceEnumRaw ret.Interface = InterfaceEnumRaw
} }
if !ret.Interface.Valid() { if err := ret.valid(); err != nil {
return nil, fmt.Errorf("invalid interface type %s", ret.Interface) return nil, err
} }
return ret, nil return ret, nil

View file

@ -24,14 +24,15 @@ import (
) )
type Plugin struct { type Plugin struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
URL *string `json:"url"` URL *string `json:"url"`
Version *string `json:"version"` Version *string `json:"version"`
Tasks []*PluginTask `json:"tasks"` Tasks []*PluginTask `json:"tasks"`
Hooks []*PluginHook `json:"hooks"` Hooks []*PluginHook `json:"hooks"`
UI PluginUI `json:"ui"` UI PluginUI `json:"ui"`
Settings []PluginSetting `json:"settings"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
@ -44,6 +45,15 @@ type PluginUI struct {
CSS []string `json:"css"` 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 { type ServerConfig interface {
GetHost() string GetHost() string
GetPort() int GetPort() int

50
pkg/plugin/setting.go Normal file
View file

@ -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()))
}

View file

@ -192,6 +192,7 @@ export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
id, id,
className, className,
headingID, headingID,
heading,
tooltipID, tooltipID,
subHeadingID, subHeadingID,
subHeading, subHeading,
@ -211,7 +212,11 @@ export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
<div className={`setting ${className ?? ""} ${disabledClassName}`} id={id}> <div className={`setting ${className ?? ""} ${disabledClassName}`} id={id}>
<div> <div>
<h3 title={tooltip}> <h3 title={tooltip}>
{headingID ? intl.formatMessage({ id: headingID }) : undefined} {headingID
? intl.formatMessage({ id: headingID })
: heading
? heading
: undefined}
</h3> </h3>
<div className="value"> <div className="value">
@ -240,7 +245,7 @@ export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
}; };
export interface ISettingModal<T> { export interface ISettingModal<T> {
heading?: string; heading?: React.ReactNode;
headingID?: string; headingID?: string;
subHeadingID?: string; subHeadingID?: string;
subHeading?: React.ReactNode; subHeading?: React.ReactNode;
@ -319,6 +324,7 @@ export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
className, className,
value, value,
headingID, headingID,
heading,
subHeadingID, subHeadingID,
subHeading, subHeading,
onChange, onChange,
@ -338,6 +344,7 @@ export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
<SettingModal<T> <SettingModal<T>
headingID={headingID} headingID={headingID}
subHeadingID={subHeadingID} subHeadingID={subHeadingID}
heading={heading}
subHeading={subHeading} subHeading={subHeading}
value={value} value={value}
renderField={renderField} renderField={renderField}
@ -356,6 +363,7 @@ export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
buttonText={buttonText} buttonText={buttonText}
buttonTextID={buttonTextID} buttonTextID={buttonTextID}
headingID={headingID} headingID={headingID}
heading={heading}
tooltipID={tooltipID} tooltipID={tooltipID}
subHeadingID={subHeadingID} subHeadingID={subHeadingID}
subHeading={subHeading} subHeading={subHeading}

View file

@ -13,7 +13,7 @@ import {
SelectSetting, SelectSetting,
StringSetting, StringSetting,
} from "../Inputs"; } from "../Inputs";
import { SettingStateContext } from "../context"; import { useSettings } from "../context";
import DurationUtils from "src/utils/duration"; import DurationUtils from "src/utils/duration";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
@ -65,7 +65,7 @@ export const SettingsInterfacePanel: React.FC = () => {
saveUI, saveUI,
loading, loading,
error, error,
} = React.useContext(SettingStateContext); } = useSettings();
const { const {
interactive, interactive,

View file

@ -4,14 +4,14 @@ import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { StashSetting } from "./StashConfiguration"; import { StashSetting } from "./StashConfiguration";
import { SettingSection } from "./SettingSection"; import { SettingSection } from "./SettingSection";
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs"; import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
import { SettingStateContext } from "./context"; import { useSettings } from "./context";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
export const SettingsLibraryPanel: React.FC = () => { export const SettingsLibraryPanel: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { general, loading, error, saveGeneral, defaults, saveDefaults } = const { general, loading, error, saveGeneral, defaults, saveDefaults } =
React.useContext(SettingStateContext); useSettings();
function commaDelimitedToList(value: string | undefined) { function commaDelimitedToList(value: string | undefined) {
if (value) { if (value) {

View file

@ -13,19 +13,74 @@ import { CollapseButton } from "../Shared/CollapseButton";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { SettingSection } from "./SettingSection"; 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 { 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 = () => { export const SettingsPluginsPanel: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const { loading: configLoading, plugins, savePluginSettings } = useSettings();
const { data, loading, refetch } = usePlugins();
const [changedPluginID, setChangedPluginID] = React.useState< const [changedPluginID, setChangedPluginID] = React.useState<
string | undefined string | undefined
>(); >();
const { data, loading, refetch } = usePlugins();
async function onReloadPlugins() { async function onReloadPlugins() {
await mutateReloadPlugins().catch((e) => Toast.error(e)); await mutateReloadPlugins().catch((e) => Toast.error(e));
} }
@ -101,6 +156,7 @@ export const SettingsPluginsPanel: React.FC = () => {
} }
> >
{renderPluginHooks(plugin.hooks ?? undefined)} {renderPluginHooks(plugin.hooks ?? undefined)}
{renderPluginSettings(plugin.id, plugin.settings ?? [])}
</SettingGroup> </SettingGroup>
)); ));
@ -145,10 +201,40 @@ export const SettingsPluginsPanel: React.FC = () => {
); );
} }
return renderPlugins(); function renderPluginSettings(
}, [data?.plugins, intl, Toast, changedPluginID, refetch]); 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 ( return (
<> <>

View file

@ -16,7 +16,7 @@ import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { ScrapeType } from "src/core/generated-graphql"; import { ScrapeType } from "src/core/generated-graphql";
import { SettingSection } from "./SettingSection"; import { SettingSection } from "./SettingSection";
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs"; import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
import { SettingStateContext } from "./context"; import { useSettings } from "./context";
import { StashBoxSetting } from "./StashBoxConfiguration"; import { StashBoxSetting } from "./StashBoxConfiguration";
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
@ -87,7 +87,7 @@ export const SettingsScrapingPanel: React.FC = () => {
useListMovieScrapers(); useListMovieScrapers();
const { general, scraping, loading, error, saveGeneral, saveScraping } = const { general, scraping, loading, error, saveGeneral, saveScraping } =
React.useContext(SettingStateContext); useSettings();
async function onReloadScrapers() { async function onReloadScrapers() {
await mutateReloadScrapers().catch((e) => Toast.error(e)); await mutateReloadScrapers().catch((e) => Toast.error(e));

View file

@ -4,7 +4,7 @@ import { SettingSection } from "./SettingSection";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { SettingStateContext } from "./context"; import { useSettings } from "./context";
import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { useGenerateAPIKey } from "src/core/StashService"; import { useGenerateAPIKey } from "src/core/StashService";
@ -72,7 +72,7 @@ export const SettingsSecurityPanel: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
const { general, apiKey, loading, error, saveGeneral, refetch } = const { general, apiKey, loading, error, saveGeneral, refetch } =
React.useContext(SettingStateContext); useSettings();
const [generateAPIKey] = useGenerateAPIKey(); const [generateAPIKey] = useGenerateAPIKey();

View file

@ -20,7 +20,7 @@ import {
StringSetting, StringSetting,
SelectSetting, SelectSetting,
} from "./Inputs"; } from "./Inputs";
import { SettingStateContext } from "./context"; import { useSettings } from "./context";
import { import {
videoSortOrderIntlMap, videoSortOrderIntlMap,
defaultVideoSort, defaultVideoSort,
@ -35,12 +35,7 @@ export const SettingsServicesPanel: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const { const { dlna, loading: configLoading, error, saveDLNA } = useSettings();
dlna,
loading: configLoading,
error,
saveDLNA,
} = React.useContext(SettingStateContext);
// undefined to hide dialog, true for enable, false for disable // undefined to hide dialog, true for enable, false for disable
const [enableDisable, setEnableDisable] = useState<boolean>(); const [enableDisable, setEnableDisable] = useState<boolean>();

View file

@ -10,7 +10,7 @@ import {
StringListSetting, StringListSetting,
StringSetting, StringSetting,
} from "./Inputs"; } from "./Inputs";
import { SettingStateContext } from "./context"; import { useSettings } from "./context";
import { import {
VideoPreviewInput, VideoPreviewInput,
VideoPreviewSettingsInput, VideoPreviewSettingsInput,
@ -20,8 +20,7 @@ import { useIntl } from "react-intl";
export const SettingsConfigurationPanel: React.FC = () => { export const SettingsConfigurationPanel: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { general, loading, error, saveGeneral } = const { general, loading, error, saveGeneral } = useSettings();
React.useContext(SettingStateContext);
const transcodeQualities = [ const transcodeQualities = [
GQL.StreamingResolutionEnum.Low, GQL.StreamingResolutionEnum.Low,

View file

@ -13,6 +13,7 @@ import {
useConfigureDLNA, useConfigureDLNA,
useConfigureGeneral, useConfigureGeneral,
useConfigureInterface, useConfigureInterface,
useConfigurePlugin,
useConfigureScraping, useConfigureScraping,
useConfigureUI, useConfigureUI,
} from "src/core/StashService"; } from "src/core/StashService";
@ -21,6 +22,7 @@ import { useToast } from "src/hooks/Toast";
import { withoutTypename } from "src/utils/data"; import { withoutTypename } from "src/utils/data";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
type PluginSettings = Record<string, Record<string, unknown>>;
export interface ISettingsContextState { export interface ISettingsContextState {
loading: boolean; loading: boolean;
error: ApolloError | undefined; error: ApolloError | undefined;
@ -30,6 +32,7 @@ export interface ISettingsContextState {
scraping: GQL.ConfigScrapingInput; scraping: GQL.ConfigScrapingInput;
dlna: GQL.ConfigDlnaInput; dlna: GQL.ConfigDlnaInput;
ui: IUIConfig; ui: IUIConfig;
plugins: PluginSettings;
// apikey isn't directly settable, so expose it here // apikey isn't directly settable, so expose it here
apiKey: string; apiKey: string;
@ -40,28 +43,23 @@ export interface ISettingsContextState {
saveScraping: (input: Partial<GQL.ConfigScrapingInput>) => void; saveScraping: (input: Partial<GQL.ConfigScrapingInput>) => void;
saveDLNA: (input: Partial<GQL.ConfigDlnaInput>) => void; saveDLNA: (input: Partial<GQL.ConfigDlnaInput>) => void;
saveUI: (input: Partial<IUIConfig>) => void; saveUI: (input: Partial<IUIConfig>) => void;
savePluginSettings: (pluginID: string, input: {}) => void;
refetch: () => void; refetch: () => void;
} }
export const SettingStateContext = React.createContext<ISettingsContextState>({ export const SettingStateContext =
loading: false, React.createContext<ISettingsContextState | null>(null);
error: undefined,
general: {}, export const useSettings = () => {
interface: {}, const context = React.useContext(SettingStateContext);
defaults: {},
scraping: {}, if (context === null) {
dlna: {}, throw new Error("useSettings must be used within a SettingsContext");
ui: {}, }
apiKey: "",
saveGeneral: () => {}, return context;
saveInterface: () => {}, };
saveDefaults: () => {},
saveScraping: () => {},
saveDLNA: () => {},
saveUI: () => {},
refetch: () => {},
});
export const SettingsContext: React.FC = ({ children }) => { export const SettingsContext: React.FC = ({ children }) => {
const Toast = useToast(); const Toast = useToast();
@ -97,6 +95,10 @@ export const SettingsContext: React.FC = ({ children }) => {
const [pendingUI, setPendingUI] = useState<{}>(); const [pendingUI, setPendingUI] = useState<{}>();
const [updateUIConfig] = useConfigureUI(); const [updateUIConfig] = useConfigureUI();
const [plugins, setPlugins] = useState<PluginSettings>({});
const [pendingPlugins, setPendingPlugins] = useState<PluginSettings>();
const [updatePluginConfig] = useConfigurePlugin();
const [updateSuccess, setUpdateSuccess] = useState<boolean>(); const [updateSuccess, setUpdateSuccess] = useState<boolean>();
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
@ -132,6 +134,7 @@ export const SettingsContext: React.FC = ({ children }) => {
setScraping({ ...withoutTypename(data.configuration.scraping) }); setScraping({ ...withoutTypename(data.configuration.scraping) });
setDLNA({ ...withoutTypename(data.configuration.dlna) }); setDLNA({ ...withoutTypename(data.configuration.dlna) });
setUI(data.configuration.ui); setUI(data.configuration.ui);
setPlugins(data.configuration.plugins);
}, [data, error]); }, [data, error]);
const resetSuccess = useDebounce(() => setUpdateSuccess(undefined), 4000); 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() { function maybeRenderLoadingIndicator() {
if (updateSuccess === false) { if (updateSuccess === false) {
return ( return (
@ -448,7 +508,8 @@ export const SettingsContext: React.FC = ({ children }) => {
pendingDefaults || pendingDefaults ||
pendingScraping || pendingScraping ||
pendingDLNA || pendingDLNA ||
pendingUI pendingUI ||
pendingPlugins
) { ) {
return ( return (
<div className="loading-indicator"> <div className="loading-indicator">
@ -480,6 +541,7 @@ export const SettingsContext: React.FC = ({ children }) => {
scraping, scraping,
dlna, dlna,
ui, ui,
plugins,
saveGeneral, saveGeneral,
saveInterface, saveInterface,
saveDefaults, saveDefaults,
@ -487,6 +549,7 @@ export const SettingsContext: React.FC = ({ children }) => {
saveDLNA, saveDLNA,
saveUI, saveUI,
refetch, refetch,
savePluginSettings,
}} }}
> >
{maybeRenderLoadingIndicator()} {maybeRenderLoadingIndicator()}

View file

@ -2031,6 +2031,11 @@ export const useConfigureDLNA = () =>
update: updateConfiguration, update: updateConfiguration,
}); });
export const useConfigurePlugin = () =>
GQL.useConfigurePluginMutation({
update: updateConfiguration,
});
export const useEnableDLNA = () => GQL.useEnableDlnaMutation(); export const useEnableDLNA = () => GQL.useEnableDlnaMutation();
export const useDisableDLNA = () => GQL.useDisableDlnaMutation(); export const useDisableDLNA = () => GQL.useDisableDlnaMutation();