mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01: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:
parent
06d8353f4f
commit
2b8718100b
24 changed files with 445 additions and 57 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -207,4 +207,5 @@ fragment ConfigData on ConfigResult {
|
|||
...ConfigDefaultSettingsData
|
||||
}
|
||||
ui
|
||||
plugins
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ query Plugins {
|
|||
description
|
||||
hooks
|
||||
}
|
||||
|
||||
settings {
|
||||
name
|
||||
display_name
|
||||
description
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -521,6 +521,7 @@ type ConfigResult {
|
|||
scraping: ConfigScrapingResult!
|
||||
defaults: ConfigDefaultSettingsResult!
|
||||
ui: Map!
|
||||
plugins(include: [String!]): Map!
|
||||
}
|
||||
|
||||
"Directory structure of a path"
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
25
internal/api/resolver_model_config.go
Normal file
25
internal/api/resolver_model_config.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,8 @@ const (
|
|||
|
||||
// plugin options
|
||||
PluginsPath = "plugins_path"
|
||||
PluginsSetting = "plugins.settings"
|
||||
PluginsSettingPrefix = PluginsSetting + "."
|
||||
DisabledPlugins = "plugins.disabled"
|
||||
|
||||
// i18n
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ type Plugin struct {
|
|||
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
|
||||
|
|
|
|||
50
pkg/plugin/setting.go
Normal file
50
pkg/plugin/setting.go
Normal 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()))
|
||||
}
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue