mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,4 +207,5 @@ fragment ConfigData on ConfigResult {
|
||||||
...ConfigDefaultSettingsData
|
...ConfigDefaultSettingsData
|
||||||
}
|
}
|
||||||
ui
|
ui
|
||||||
|
plugins
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ query Plugins {
|
||||||
description
|
description
|
||||||
hooks
|
hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
name
|
||||||
|
display_name
|
||||||
|
description
|
||||||
|
type
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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!
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
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,
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue