diff --git a/gqlgen.yml b/gqlgen.yml
index ec9feab24a6..5e8f09444b6 100644
--- a/gqlgen.yml
+++ b/gqlgen.yml
@@ -33,6 +33,8 @@ models:
model: github.com/99designs/gqlgen/graphql.Int64
Timestamp:
model: github.com/stashapp/stash/internal/api.Timestamp
+ BoolMap:
+ model: github.com/stashapp/stash/internal/api.BoolMap
# define to force resolvers
Image:
model: github.com/stashapp/stash/pkg/models.Image
diff --git a/graphql/documents/mutations/plugins.graphql b/graphql/documents/mutations/plugins.graphql
index d964bd6b2a0..0ce26740403 100644
--- a/graphql/documents/mutations/plugins.graphql
+++ b/graphql/documents/mutations/plugins.graphql
@@ -9,3 +9,7 @@ mutation RunPluginTask(
) {
runPluginTask(plugin_id: $plugin_id, task_name: $task_name, args: $args)
}
+
+mutation SetPluginsEnabled($enabledMap: BoolMap!) {
+ setPluginsEnabled(enabledMap: $enabledMap)
+}
diff --git a/graphql/documents/queries/plugins.graphql b/graphql/documents/queries/plugins.graphql
index d827542b611..0a757cac321 100644
--- a/graphql/documents/queries/plugins.graphql
+++ b/graphql/documents/queries/plugins.graphql
@@ -2,6 +2,7 @@ query Plugins {
plugins {
id
name
+ enabled
description
url
version
@@ -26,6 +27,7 @@ query PluginTasks {
plugin {
id
name
+ enabled
}
}
}
diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql
index 4c011ad0db2..52a6c168824 100644
--- a/graphql/schema/schema.graphql
+++ b/graphql/schema/schema.graphql
@@ -394,6 +394,12 @@ type Mutation {
"Reload scrapers"
reloadScrapers: Boolean!
+ """
+ Enable/disable plugins - enabledMap is a map of plugin IDs to enabled booleans.
+ Plugins not in the map are not affected.
+ """
+ setPluginsEnabled(enabledMap: BoolMap!): Boolean!
+
"Run plugin task. Returns the job ID"
runPluginTask(
plugin_id: ID!
diff --git a/graphql/schema/types/plugin.graphql b/graphql/schema/types/plugin.graphql
index b397e0d08b2..e9da728b53e 100644
--- a/graphql/schema/types/plugin.graphql
+++ b/graphql/schema/types/plugin.graphql
@@ -5,6 +5,8 @@ type Plugin {
url: String
version: String
+ enabled: Boolean!
+
tasks: [PluginTask!]
hooks: [PluginHook!]
}
diff --git a/graphql/schema/types/scalars.graphql b/graphql/schema/types/scalars.graphql
index 2e4c592913f..8f1d21551bf 100644
--- a/graphql/schema/types/scalars.graphql
+++ b/graphql/schema/types/scalars.graphql
@@ -8,6 +8,9 @@ scalar Timestamp
# generic JSON object
scalar Map
+# string, boolean map
+scalar BoolMap
+
scalar Any
scalar Int64
diff --git a/internal/api/bool_map.go b/internal/api/bool_map.go
new file mode 100644
index 00000000000..32c0fcacb46
--- /dev/null
+++ b/internal/api/bool_map.go
@@ -0,0 +1,38 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+
+ "github.com/99designs/gqlgen/graphql"
+)
+
+func MarshalBoolMap(val map[string]bool) graphql.Marshaler {
+ return graphql.WriterFunc(func(w io.Writer) {
+ err := json.NewEncoder(w).Encode(val)
+ if err != nil {
+ panic(err)
+ }
+ })
+}
+
+func UnmarshalBoolMap(v interface{}) (map[string]bool, error) {
+ m, ok := v.(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("%T is not a map", v)
+ }
+
+ result := make(map[string]bool)
+ for k, v := range m {
+ key := k
+ val, ok := v.(bool)
+ if !ok {
+ return nil, fmt.Errorf("key %s (%T) is not a bool", k, v)
+ }
+
+ result[key] = val
+ }
+
+ return result, nil
+}
diff --git a/internal/api/models.go b/internal/api/models.go
index 03c20ee4396..40d93cc2e41 100644
--- a/internal/api/models.go
+++ b/internal/api/models.go
@@ -1,16 +1,7 @@
package api
import (
- "errors"
- "fmt"
- "io"
- "strconv"
- "time"
-
- "github.com/99designs/gqlgen/graphql"
- "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
- "github.com/stashapp/stash/pkg/utils"
)
type BaseFile interface{}
@@ -18,47 +9,3 @@ type BaseFile interface{}
type GalleryFile struct {
*models.BaseFile
}
-
-var ErrTimestamp = errors.New("cannot parse Timestamp")
-
-func MarshalTimestamp(t time.Time) graphql.Marshaler {
- if t.IsZero() {
- return graphql.Null
- }
-
- return graphql.WriterFunc(func(w io.Writer) {
- _, err := io.WriteString(w, strconv.Quote(t.Format(time.RFC3339Nano)))
- if err != nil {
- logger.Warnf("could not marshal timestamp: %v", err)
- }
- })
-}
-
-func UnmarshalTimestamp(v interface{}) (time.Time, error) {
- if tmpStr, ok := v.(string); ok {
- if len(tmpStr) == 0 {
- return time.Time{}, fmt.Errorf("%w: empty string", ErrTimestamp)
- }
-
- switch tmpStr[0] {
- case '>', '<':
- d, err := time.ParseDuration(tmpStr[1:])
- if err != nil {
- return time.Time{}, fmt.Errorf("%w: cannot parse %v-duration: %v", ErrTimestamp, tmpStr[0], err)
- }
- t := time.Now()
- // Compute point in time:
- if tmpStr[0] == '<' {
- t = t.Add(-d)
- } else {
- t = t.Add(d)
- }
-
- return t, nil
- }
-
- return utils.ParseDateStringAsTime(tmpStr)
- }
-
- return time.Time{}, fmt.Errorf("%w: not a string", ErrTimestamp)
-}
diff --git a/internal/api/resolver_mutation_plugin.go b/internal/api/resolver_mutation_plugin.go
index 58ad359b71a..8b3d554f892 100644
--- a/internal/api/resolver_mutation_plugin.go
+++ b/internal/api/resolver_mutation_plugin.go
@@ -4,8 +4,10 @@ import (
"context"
"github.com/stashapp/stash/internal/manager"
+ "github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/plugin"
+ "github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *mutationResolver) RunPluginTask(ctx context.Context, pluginID string, taskName string, args []*plugin.PluginArgInput) (string, error) {
@@ -22,3 +24,32 @@ func (r *mutationResolver) ReloadPlugins(ctx context.Context) (bool, error) {
return true, nil
}
+
+func (r *mutationResolver) SetPluginsEnabled(ctx context.Context, enabledMap map[string]bool) (bool, error) {
+ c := config.GetInstance()
+
+ existingDisabled := c.GetDisabledPlugins()
+ var newDisabled []string
+
+ // remove plugins that are no longer disabled
+ for _, disabledID := range existingDisabled {
+ if enabled, found := enabledMap[disabledID]; !enabled || !found {
+ newDisabled = append(newDisabled, disabledID)
+ }
+ }
+
+ // add plugins that are newly disabled
+ for pluginID, enabled := range enabledMap {
+ if !enabled {
+ newDisabled = stringslice.StrAppendUnique(newDisabled, pluginID)
+ }
+ }
+
+ c.Set(config.DisabledPlugins, newDisabled)
+
+ if err := c.Write(); err != nil {
+ return false, err
+ }
+
+ return true, nil
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 6c8ea857114..67a45666c95 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -295,6 +295,10 @@ func cssHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.Respo
var paths []string
for _, p := range pluginCache.ListPlugins() {
+ if !p.Enabled {
+ continue
+ }
+
paths = append(paths, p.UI.CSS...)
}
@@ -318,6 +322,10 @@ func javascriptHandler(c *config.Instance, pluginCache *plugin.Cache) func(w htt
var paths []string
for _, p := range pluginCache.ListPlugins() {
+ if !p.Enabled {
+ continue
+ }
+
paths = append(paths, p.UI.Javascript...)
}
diff --git a/internal/api/timestamp.go b/internal/api/timestamp.go
new file mode 100644
index 00000000000..92713a56e8c
--- /dev/null
+++ b/internal/api/timestamp.go
@@ -0,0 +1,57 @@
+package api
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "strconv"
+ "time"
+
+ "github.com/99designs/gqlgen/graphql"
+ "github.com/stashapp/stash/pkg/logger"
+ "github.com/stashapp/stash/pkg/utils"
+)
+
+var ErrTimestamp = errors.New("cannot parse Timestamp")
+
+func MarshalTimestamp(t time.Time) graphql.Marshaler {
+ if t.IsZero() {
+ return graphql.Null
+ }
+
+ return graphql.WriterFunc(func(w io.Writer) {
+ _, err := io.WriteString(w, strconv.Quote(t.Format(time.RFC3339Nano)))
+ if err != nil {
+ logger.Warnf("could not marshal timestamp: %v", err)
+ }
+ })
+}
+
+func UnmarshalTimestamp(v interface{}) (time.Time, error) {
+ if tmpStr, ok := v.(string); ok {
+ if len(tmpStr) == 0 {
+ return time.Time{}, fmt.Errorf("%w: empty string", ErrTimestamp)
+ }
+
+ switch tmpStr[0] {
+ case '>', '<':
+ d, err := time.ParseDuration(tmpStr[1:])
+ if err != nil {
+ return time.Time{}, fmt.Errorf("%w: cannot parse %v-duration: %v", ErrTimestamp, tmpStr[0], err)
+ }
+ t := time.Now()
+ // Compute point in time:
+ if tmpStr[0] == '<' {
+ t = t.Add(-d)
+ } else {
+ t = t.Add(d)
+ }
+
+ return t, nil
+ }
+
+ return utils.ParseDateStringAsTime(tmpStr)
+ }
+
+ return time.Time{}, fmt.Errorf("%w: not a string", ErrTimestamp)
+}
diff --git a/internal/api/models_test.go b/internal/api/timestamp_test.go
similarity index 100%
rename from internal/api/models_test.go
rename to internal/api/timestamp_test.go
diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go
index 113d3668524..0676a7a63f4 100644
--- a/internal/manager/config/config.go
+++ b/internal/manager/config/config.go
@@ -131,7 +131,8 @@ const (
PythonPath = "python_path"
// plugin options
- PluginsPath = "plugins_path"
+ PluginsPath = "plugins_path"
+ DisabledPlugins = "plugins.disabled"
// i18n
Language = "language"
@@ -722,6 +723,10 @@ func (i *Instance) GetPluginsPath() string {
return i.getString(PluginsPath)
}
+func (i *Instance) GetDisabledPlugins() []string {
+ return i.getStringSlice(DisabledPlugins)
+}
+
func (i *Instance) GetPythonPath() string {
return i.getString(PythonPath)
}
diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go
index 8e9354d0b97..addc22d6714 100644
--- a/pkg/plugin/plugins.go
+++ b/pkg/plugin/plugins.go
@@ -32,6 +32,8 @@ type Plugin struct {
Tasks []*PluginTask `json:"tasks"`
Hooks []*PluginHook `json:"hooks"`
UI PluginUI `json:"ui"`
+
+ Enabled bool `json:"enabled"`
}
type PluginUI struct {
@@ -48,6 +50,7 @@ type ServerConfig interface {
GetConfigPath() string
HasTLSConfig() bool
GetPluginsPath() string
+ GetDisabledPlugins() []string
GetPythonPath() string
}
@@ -122,11 +125,39 @@ func loadPlugins(path string) ([]Config, error) {
return plugins, nil
}
+func (c Cache) enabledPlugins() []Config {
+ disabledPlugins := c.config.GetDisabledPlugins()
+
+ var ret []Config
+ for _, p := range c.plugins {
+ disabled := stringslice.StrInclude(disabledPlugins, p.id)
+
+ if !disabled {
+ ret = append(ret, p)
+ }
+ }
+
+ return ret
+}
+
+func (c Cache) pluginDisabled(id string) bool {
+ disabledPlugins := c.config.GetDisabledPlugins()
+
+ return stringslice.StrInclude(disabledPlugins, id)
+}
+
// ListPlugins returns plugin details for all of the loaded plugins.
func (c Cache) ListPlugins() []*Plugin {
+ disabledPlugins := c.config.GetDisabledPlugins()
+
var ret []*Plugin
for _, s := range c.plugins {
- ret = append(ret, s.toPlugin())
+ p := s.toPlugin()
+
+ disabled := stringslice.StrInclude(disabledPlugins, p.ID)
+ p.Enabled = !disabled
+
+ ret = append(ret, p)
}
return ret
@@ -135,7 +166,7 @@ func (c Cache) ListPlugins() []*Plugin {
// ListPluginTasks returns all runnable plugin tasks in all loaded plugins.
func (c Cache) ListPluginTasks() []*PluginTask {
var ret []*PluginTask
- for _, s := range c.plugins {
+ for _, s := range c.enabledPlugins() {
ret = append(ret, s.getPluginTasks(true)...)
}
@@ -175,6 +206,10 @@ func (c Cache) makeServerConnection(ctx context.Context) common.StashServerConne
func (c Cache) CreateTask(ctx context.Context, pluginID string, operationName string, args []*PluginArgInput, progress chan float64) (Task, error) {
serverConnection := c.makeServerConnection(ctx)
+ if c.pluginDisabled(pluginID) {
+ return nil, fmt.Errorf("plugin %s is disabled", pluginID)
+ }
+
// find the plugin and operation
plugin := c.getPlugin(pluginID)
@@ -227,7 +262,7 @@ func (c Cache) ExecuteSceneUpdatePostHooks(ctx context.Context, input models.Sce
func (c Cache) executePostHooks(ctx context.Context, hookType HookTriggerEnum, hookContext common.HookContext) error {
visitedPlugins := session.GetVisitedPlugins(ctx)
- for _, p := range c.plugins {
+ for _, p := range c.enabledPlugins() {
hooks := p.getHooks(hookType)
// don't revisit a plugin we've already visited
// only log if there's hooks that we're skipping
diff --git a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx
index 0cd02703c10..0fc0bbbdaed 100644
--- a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx
+++ b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx
@@ -2,7 +2,11 @@ import React, { useMemo } from "react";
import { Button } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
-import { mutateReloadPlugins, usePlugins } from "src/core/StashService";
+import {
+ mutateReloadPlugins,
+ mutateSetPluginsEnabled,
+ usePlugins,
+} from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
import TextUtils from "src/utils/text";
import { CollapseButton } from "../Shared/CollapseButton";
@@ -16,7 +20,11 @@ export const SettingsPluginsPanel: React.FC = () => {
const Toast = useToast();
const intl = useIntl();
- const { data, loading } = usePlugins();
+ const [changedPluginID, setChangedPluginID] = React.useState<
+ string | undefined
+ >();
+
+ const { data, loading, refetch } = usePlugins();
async function onReloadPlugins() {
await mutateReloadPlugins().catch((e) => Toast.error(e));
@@ -40,6 +48,39 @@ export const SettingsPluginsPanel: React.FC = () => {
}
}
+ function renderEnableButton(pluginID: string, enabled: boolean) {
+ async function onClick() {
+ await mutateSetPluginsEnabled({ [pluginID]: !enabled }).catch((e) =>
+ Toast.error(e)
+ );
+
+ setChangedPluginID(pluginID);
+ refetch();
+ }
+
+ return (
+
+ );
+ }
+
+ function onReloadUI() {
+ window.location.reload();
+ }
+
+ function maybeRenderReloadUI(pluginID: string) {
+ if (pluginID === changedPluginID) {
+ return (
+
+ );
+ }
+ }
+
function renderPlugins() {
const elements = (data?.plugins ?? []).map((plugin) => (
{
heading: `${plugin.name} ${
plugin.version ? `(${plugin.version})` : undefined
}`,
+ className: !plugin.enabled ? "disabled" : undefined,
subHeading: plugin.description,
}}
- topLevel={renderLink(plugin.url ?? undefined)}
+ topLevel={
+ <>
+ {renderLink(plugin.url ?? undefined)}
+ {maybeRenderReloadUI(plugin.id)}
+ {renderEnableButton(plugin.id, plugin.enabled)}
+ >
+ }
>
{renderPluginHooks(plugin.hooks ?? undefined)}
@@ -98,7 +146,7 @@ export const SettingsPluginsPanel: React.FC = () => {
}
return renderPlugins();
- }, [data?.plugins, intl]);
+ }, [data?.plugins, intl, Toast, changedPluginID, refetch]);
if (loading) return ;
diff --git a/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx
index 396e1d66af0..8a6110ab8da 100644
--- a/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx
+++ b/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx
@@ -22,7 +22,7 @@ export const PluginTasks: React.FC = () => {
}
const taskPlugins = plugins.data.plugins.filter(
- (p) => p.tasks && p.tasks.length > 0
+ (p) => p.enabled && p.tasks && p.tasks.length > 0
);
return (
diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts
index f6313b2eef3..9a7f516adf3 100644
--- a/ui/v2.5/src/core/StashService.ts
+++ b/ui/v2.5/src/core/StashService.ts
@@ -2088,6 +2088,14 @@ export const mutateMigrate = (input: GQL.MigrateInput) =>
},
});
+type BoolMap = { [key: string]: boolean };
+
+export const mutateSetPluginsEnabled = (enabledMap: BoolMap) =>
+ client.mutate({
+ mutation: GQL.SetPluginsEnabledDocument,
+ variables: { enabledMap },
+ });
+
/// Tasks
export const mutateMetadataScan = (input: GQL.ScanMetadataInput) =>
diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json
index 9dfb9fc48c5..8c03f07f3a8 100644
--- a/ui/v2.5/src/locales/en-GB.json
+++ b/ui/v2.5/src/locales/en-GB.json
@@ -34,12 +34,14 @@
"delete_file_and_funscript": "Delete file (and funscript)",
"delete_generated_supporting_files": "Delete generated supporting files",
"delete_stashid": "Delete StashID",
+ "disable": "Disable",
"disallow": "Disallow",
"download": "Download",
"download_anonymised": "Download anonymised",
"download_backup": "Download Backup",
"edit": "Edit",
"edit_entity": "Edit {entityType}",
+ "enable": "Enable",
"encoding_image": "Encoding image",
"export": "Export",
"export_all": "Export all…",