mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 00:13:46 +01:00
Add support for disabling plugins (#4141)
* Move timestamp to own file * Backend changes * UI changes
This commit is contained in:
parent
e5af37efbc
commit
b6808dc714
18 changed files with 260 additions and 62 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,3 +9,7 @@ mutation RunPluginTask(
|
|||
) {
|
||||
runPluginTask(plugin_id: $plugin_id, task_name: $task_name, args: $args)
|
||||
}
|
||||
|
||||
mutation SetPluginsEnabled($enabledMap: BoolMap!) {
|
||||
setPluginsEnabled(enabledMap: $enabledMap)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ query Plugins {
|
|||
plugins {
|
||||
id
|
||||
name
|
||||
enabled
|
||||
description
|
||||
url
|
||||
version
|
||||
|
|
@ -26,6 +27,7 @@ query PluginTasks {
|
|||
plugin {
|
||||
id
|
||||
name
|
||||
enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ type Plugin {
|
|||
url: String
|
||||
version: String
|
||||
|
||||
enabled: Boolean!
|
||||
|
||||
tasks: [PluginTask!]
|
||||
hooks: [PluginHook!]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ scalar Timestamp
|
|||
# generic JSON object
|
||||
scalar Map
|
||||
|
||||
# string, boolean map
|
||||
scalar BoolMap
|
||||
|
||||
scalar Any
|
||||
|
||||
scalar Int64
|
||||
|
|
|
|||
38
internal/api/bool_map.go
Normal file
38
internal/api/bool_map.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
|
||||
|
|
|
|||
57
internal/api/timestamp.go
Normal file
57
internal/api/timestamp.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Button size="sm" onClick={onClick}>
|
||||
<FormattedMessage
|
||||
id={enabled ? "actions.disable" : "actions.enable"}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function onReloadUI() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function maybeRenderReloadUI(pluginID: string) {
|
||||
if (pluginID === changedPluginID) {
|
||||
return (
|
||||
<Button size="sm" onClick={() => onReloadUI()}>
|
||||
Reload UI
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlugins() {
|
||||
const elements = (data?.plugins ?? []).map((plugin) => (
|
||||
<SettingGroup
|
||||
|
|
@ -48,9 +89,16 @@ export const SettingsPluginsPanel: React.FC = () => {
|
|||
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)}
|
||||
</SettingGroup>
|
||||
|
|
@ -98,7 +146,7 @@ export const SettingsPluginsPanel: React.FC = () => {
|
|||
}
|
||||
|
||||
return renderPlugins();
|
||||
}, [data?.plugins, intl]);
|
||||
}, [data?.plugins, intl, Toast, changedPluginID, refetch]);
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -2088,6 +2088,14 @@ export const mutateMigrate = (input: GQL.MigrateInput) =>
|
|||
},
|
||||
});
|
||||
|
||||
type BoolMap = { [key: string]: boolean };
|
||||
|
||||
export const mutateSetPluginsEnabled = (enabledMap: BoolMap) =>
|
||||
client.mutate<GQL.SetPluginsEnabledMutation>({
|
||||
mutation: GQL.SetPluginsEnabledDocument,
|
||||
variables: { enabledMap },
|
||||
});
|
||||
|
||||
/// Tasks
|
||||
|
||||
export const mutateMetadataScan = (input: GQL.ScanMetadataInput) =>
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
Loading…
Reference in a new issue