diff --git a/pkg/plugin/common/log/log.go b/pkg/plugin/common/log/log.go index 33bf5f3cb..d4ebc0876 100644 --- a/pkg/plugin/common/log/log.go +++ b/pkg/plugin/common/log/log.go @@ -52,6 +52,7 @@ var ( } ProgressLevel = Level{ char: 'p', + Name: "progress", } NoneLevel = Level{ Name: "none", diff --git a/pkg/plugin/examples/python/log.py b/pkg/plugin/examples/python/log.py new file mode 100644 index 000000000..cd5c3a78d --- /dev/null +++ b/pkg/plugin/examples/python/log.py @@ -0,0 +1,44 @@ +import sys + +# Log messages sent from a plugin instance are transmitted via stderr and are +# encoded with a prefix consisting of special character SOH, then the log +# level (one of t, d, i, w, e, or p - corresponding to trace, debug, info, +# warning, error and progress levels respectively), then special character +# STX. +# +# The LogTrace, LogDebug, LogInfo, LogWarning, and LogError methods, and their equivalent +# formatted methods are intended for use by plugin instances to transmit log +# messages. The LogProgress method is also intended for sending progress data. +# + +def __prefix(levelChar): + startLevelChar = b'\x01' + endLevelChar = b'\x02' + + ret = startLevelChar + levelChar + endLevelChar + return ret.decode() + +def __log(levelChar, s): + if levelChar == "": + return + + print(__prefix(levelChar) + s + "\n", file=sys.stderr, flush=True) + +def LogTrace(s): + __log(b't', s) + +def LogDebug(s): + __log(b'd', s) + +def LogInfo(s): + __log(b'i', s) + +def LogWarning(s): + __log(b'w', s) + +def LogError(s): + __log(b'e', s) + +def LogProgress(p): + progress = min(max(0, p), 1) + __log(b'p', str(progress)) diff --git a/pkg/plugin/examples/python/pyplugin.py b/pkg/plugin/examples/python/pyplugin.py new file mode 100644 index 000000000..91d09d9d9 --- /dev/null +++ b/pkg/plugin/examples/python/pyplugin.py @@ -0,0 +1,123 @@ +import json +import sys +import time + +import log +from stash_interface import StashInterface + +# raw plugins may accept the plugin input from stdin, or they can elect +# to ignore it entirely. In this case it optionally reads from the +# command-line parameters. +def main(): + input = None + + if len(sys.argv) < 2: + input = readJSONInput() + log.LogDebug("Raw input: %s" % json.dumps(input)) + else: + log.LogDebug("Using command line inputs") + mode = sys.argv[1] + log.LogDebug("Command line inputs: {}".format(sys.argv[1:])) + + input = {} + input['args'] = { + "mode": mode + } + + # just some hard-coded values + input['server_connection'] = { + "Scheme": "http", + "Port": 9999, + } + + output = {} + run(input, output) + + out = json.dumps(output) + print(out + "\n") + +def readJSONInput(): + input = sys.stdin.read() + return json.loads(input) + +def run(input, output): + modeArg = input['args']["mode"] + + try: + if modeArg == "" or modeArg == "add": + client = StashInterface(input["server_connection"]) + addTag(client) + elif modeArg == "remove": + client = StashInterface(input["server_connection"]) + removeTag(client) + elif modeArg == "long": + doLongTask() + elif modeArg == "indef": + doIndefiniteTask() + except Exception as e: + raise + #output["error"] = str(e) + #return + + output["output"] = "ok" + +def doLongTask(): + total = 100 + upTo = 0 + + log.LogInfo("Doing long task") + while upTo < total: + time.sleep(1) + + log.LogProgress(float(upTo) / float(total)) + upTo = upTo + 1 + +def doIndefiniteTask(): + log.LogWarning("Sleeping indefinitely") + while True: + time.sleep(1) + +def addTag(client): + tagName = "Hawwwwt" + tagID = client.findTagIdWithName(tagName) + + if tagID == None: + tagID = client.createTagWithName(tagName) + + scene = client.findRandomSceneId() + + if scene == None: + raise Exception("no scenes to add tag to") + + tagIds = [] + for t in scene["tags"]: + tagIds.append(t["id"]) + + # remove first to ensure we don't re-add the same id + try: + tagIds.remove(tagID) + except ValueError: + pass + + tagIds.append(tagID) + + input = { + "id": scene["id"], + "tag_ids": tagIds + } + + log.LogInfo("Adding tag to scene {}".format(scene["id"])) + client.updateScene(input) + +def removeTag(client): + tagName = "Hawwwwt" + tagID = client.findTagIdWithName(tagName) + + if tagID == None: + log.LogInfo("Tag does not exist. Nothing to remove") + return + + log.LogInfo("Destroying tag") + client.destroyTag(tagID) + +main() \ No newline at end of file diff --git a/pkg/plugin/examples/python/pyraw.yml b/pkg/plugin/examples/python/pyraw.yml new file mode 100644 index 000000000..71c963b6f --- /dev/null +++ b/pkg/plugin/examples/python/pyraw.yml @@ -0,0 +1,29 @@ +# example plugin config +name: Hawwwwt Tagger (Raw Python edition) +description: Python Hawwwwt tagging utility (using raw interface). +version: 1.0 +url: http://www.github.com/stashapp/stash +exec: + - python + - "{pluginDir}/pyplugin.py" +interface: raw +tasks: + - name: Add hawwwwt tag to random scene + description: Creates a "Hawwwwt" tag if not present and adds to a random scene. + defaultArgs: + mode: add + - name: Remove hawwwwt tag from system + description: Removes the "Hawwwwt" tag from all scenes and deletes the tag. + defaultArgs: + mode: remove + - name: Indefinite task + description: Sleeps indefinitely - interruptable + # we'll try command-line argument for this one + execArgs: + - indef + - "{pluginDir}" + - name: Long task + description: Sleeps for 100 seconds - interruptable + defaultArgs: + mode: long + diff --git a/pkg/plugin/examples/python/stash_interface.py b/pkg/plugin/examples/python/stash_interface.py new file mode 100644 index 000000000..e05d27ddc --- /dev/null +++ b/pkg/plugin/examples/python/stash_interface.py @@ -0,0 +1,122 @@ +import requests + +class StashInterface: + port = "" + url = "" + headers = { + "Accept-Encoding": "gzip, deflate, br", + "Content-Type": "application/json", + "Accept": "application/json", + "Connection": "keep-alive", + "DNT": "1" + } + + def __init__(self, conn): + self.port = conn['Port'] + scheme = conn['Scheme'] + + self.url = scheme + "://localhost:" + str(self.port) + "/graphql" + + # TODO - cookies + + def __callGraphQL(self, query, variables = None): + json = {} + json['query'] = query + if variables != None: + json['variables'] = variables + + # handle cookies + response = requests.post(self.url, json=json, headers=self.headers) + + if response.status_code == 200: + result = response.json() + if result.get("error", None): + for error in result["error"]["errors"]: + raise Exception("GraphQL error: {}".format(error)) + if result.get("data", None): + return result.get("data") + else: + raise Exception("GraphQL query failed:{} - {}. Query: {}. Variables: {}".format(response.status_code, response.content, query, variables)) + + def findTagIdWithName(self, name): + query = """ +query { + allTags { + id + name + } +} + """ + + result = self.__callGraphQL(query) + + for tag in result["allTags"]: + if tag["name"] == name: + return tag["id"] + return None + + def createTagWithName(self, name): + query = """ +mutation tagCreate($input:TagCreateInput!) { + tagCreate(input: $input){ + id + } +} +""" + variables = {'input': { + 'name': name + }} + + result = self.__callGraphQL(query, variables) + return result["tagCreate"]["id"] + + def destroyTag(self, id): + query = """ +mutation tagDestroy($input: TagDestroyInput!) { + tagDestroy(input: $input) +} +""" + variables = {'input': { + 'id': id + }} + + self.__callGraphQL(query, variables) + + def findRandomSceneId(self): + query = """ +query findScenes($filter: FindFilterType!) { + findScenes(filter: $filter) { + count + scenes { + id + tags { + id + } + } + } +} +""" + + variables = {'filter': { + 'per_page': 1, + 'sort': 'random' + }} + + result = self.__callGraphQL(query, variables) + + if result["findScenes"]["count"] == 0: + return None + + return result["findScenes"]["scenes"][0] + + def updateScene(self, sceneData): + query = """ +mutation sceneUpdate($input:SceneUpdateInput!) { + sceneUpdate(input: $input) { + id + } +} +""" + variables = {'input': sceneData} + + self.__callGraphQL(query, variables) \ No newline at end of file