fix: Resolve Stash v0.31+ GraphQL mutation parse error for Scan and AutoTag tasks

This commit is contained in:
david
2026-08-18 16:01:07 -04:00
parent fceeac55f8
commit c13d022a57
+79 -158
View File
@@ -211,6 +211,27 @@ def execute_stash_graphql(query_str, stash_url=None, variables=None, timeout=4):
with urllib.request.urlopen(req, timeout=timeout) as res: with urllib.request.urlopen(req, timeout=timeout) as res:
return json.loads(res.read().decode()) return json.loads(res.read().decode())
def build_stash_mutation(mutation_name, input_obj=None, fields=None, args_dict=None):
call_parts = []
if input_obj is not None:
raw_json = json.dumps(input_obj)
gql_input = re.sub(r'\"([a-zA-Z_][a-zA-Z0-9_]*)\"\s*:', r'\1:', raw_json)
call_parts.append(f"input: {gql_input}")
if args_dict:
for k, v in args_dict.items():
if isinstance(v, bool):
call_parts.append(f"{k}: {'true' if v else 'false'}")
elif isinstance(v, (int, float)):
call_parts.append(f"{k}: {v}")
else:
v_str = str(v).replace('"', '\\"')
call_parts.append(f'{k}: "{v_str}"')
call_str = f"{mutation_name}({', '.join(call_parts)})" if call_parts else mutation_name
if fields:
return f"mutation {{ {call_str} {{ {fields} }} }}"
return f"mutation {{ {call_str} }}"
def parse_stash_scene_data(scene, match_strategy='exact_path'): def parse_stash_scene_data(scene, match_strategy='exact_path'):
if not scene: if not scene:
return None return None
@@ -1987,13 +2008,7 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
scanGenerateSprites = payload.get('scanGenerateSprites', False) scanGenerateSprites = payload.get('scanGenerateSprites', False)
scanGeneratePhashes = payload.get('scanGeneratePhashes', False) scanGeneratePhashes = payload.get('scanGeneratePhashes', False)
mutation = ''' input_obj = {
mutation MetadataScan($input: ScanMetadataInput!) {
metadataScan(input: $input)
}
'''
variables = {
"input": {
"paths": paths if paths else None, "paths": paths if paths else None,
"rescan": bool(rescan), "rescan": bool(rescan),
"scanGenerateCovers": bool(scanGenerateCovers), "scanGenerateCovers": bool(scanGenerateCovers),
@@ -2001,8 +2016,11 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
"scanGenerateSprites": bool(scanGenerateSprites), "scanGenerateSprites": bool(scanGenerateSprites),
"scanGeneratePhashes": bool(scanGeneratePhashes) "scanGeneratePhashes": bool(scanGeneratePhashes)
} }
} # Remove None values
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15) input_obj = {k: v for k, v in input_obj.items() if v is not None}
mutation = build_stash_mutation("metadataScan", input_obj=input_obj)
res = execute_stash_graphql(mutation, stash_url, timeout=15)
self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('metadataScan')}) self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('metadataScan')})
except Exception as e: except Exception as e:
self.send_json_response(500, {"error": str(e)}) self.send_json_response(500, {"error": str(e)})
@@ -2017,37 +2035,27 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
mode = payload.get('mode', 'autotag') mode = payload.get('mode', 'autotag')
if mode == 'autotag': if mode == 'autotag':
mutation = ''' input_obj = {
mutation MetadataAutoTag($input: AutoTagMetadataInput!) {
metadataAutoTag(input: $input)
}
'''
variables = {
"input": {
"paths": paths if paths else None, "paths": paths if paths else None,
"performers": bool(payload.get('performers', True)), "performers": ["*"] if bool(payload.get('performers', True)) else None,
"studios": bool(payload.get('studios', True)), "studios": ["*"] if bool(payload.get('studios', True)) else None,
"tags": bool(payload.get('tags', True)) "tags": ["*"] if bool(payload.get('tags', True)) else None
} }
} input_obj = {k: v for k, v in input_obj.items() if v is not None}
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15) mutation = build_stash_mutation("metadataAutoTag", input_obj=input_obj)
res = execute_stash_graphql(mutation, stash_url, timeout=15)
self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataAutoTag')}) self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataAutoTag')})
else: else:
mutation = ''' input_obj = {
mutation MetadataIdentify($input: IdentifyMetadataInput!) {
metadataIdentify(input: $input)
}
'''
variables = {
"input": {
"paths": paths if paths else None, "paths": paths if paths else None,
"options": { "options": {
"setCoverImage": True, "setCoverImage": True,
"setOrganized": True "setOrganized": True
} }
} }
} input_obj = {k: v for k, v in input_obj.items() if v is not None}
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15) mutation = build_stash_mutation("metadataIdentify", input_obj=input_obj)
res = execute_stash_graphql(mutation, stash_url, timeout=15)
self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataIdentify')}) self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataIdentify')})
except Exception as e: except Exception as e:
self.send_json_response(500, {"error": str(e)}) self.send_json_response(500, {"error": str(e)})
@@ -2065,28 +2073,14 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
results = {} results = {}
if clean_type in ['metadata', 'both']: if clean_type in ['metadata', 'both']:
mutation = ''' input_clean = {"dryRun": dry_run, "paths": paths if paths else None}
mutation MetadataClean($input: CleanMetadataInput!) { input_clean = {k: v for k, v in input_clean.items() if v is not None}
metadataClean(input: $input) mutation = build_stash_mutation("metadataClean", input_obj=input_clean)
} res = execute_stash_graphql(mutation, stash_url, timeout=15)
'''
variables = {
"input": {
"dryRun": dry_run,
"paths": paths if paths else None
}
}
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15)
results['metadataClean'] = res.get('data', {}).get('metadataClean') results['metadataClean'] = res.get('data', {}).get('metadataClean')
if clean_type in ['generated', 'both']: if clean_type in ['generated', 'both']:
mutation_gen = ''' input_gen = {
mutation MetadataCleanGenerated($input: CleanGeneratedInput!) {
metadataCleanGenerated(input: $input)
}
'''
variables_gen = {
"input": {
"dryRun": dry_run, "dryRun": dry_run,
"blobFiles": True, "blobFiles": True,
"sprites": True, "sprites": True,
@@ -2095,8 +2089,8 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
"markers": True, "markers": True,
"imageThumbnails": True "imageThumbnails": True
} }
} mutation_gen = build_stash_mutation("metadataCleanGenerated", input_obj=input_gen)
res_gen = execute_stash_graphql(mutation_gen, stash_url, variables=variables_gen, timeout=15) res_gen = execute_stash_graphql(mutation_gen, stash_url, timeout=15)
results['metadataCleanGenerated'] = res_gen.get('data', {}).get('metadataCleanGenerated') results['metadataCleanGenerated'] = res_gen.get('data', {}).get('metadataCleanGenerated')
self.send_json_response(200, {"success": True, "results": results}) self.send_json_response(200, {"success": True, "results": results})
@@ -2110,13 +2104,7 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
payload = json.loads(post_data.decode('utf-8')) payload = json.loads(post_data.decode('utf-8'))
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
mutation = ''' input_gen = {
mutation MetadataGenerate($input: GenerateMetadataInput!) {
metadataGenerate(input: $input)
}
'''
variables = {
"input": {
"covers": bool(payload.get('covers', True)), "covers": bool(payload.get('covers', True)),
"sprites": bool(payload.get('sprites', True)), "sprites": bool(payload.get('sprites', True)),
"previews": bool(payload.get('previews', True)), "previews": bool(payload.get('previews', True)),
@@ -2127,8 +2115,9 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
"overwrite": bool(payload.get('overwrite', False)), "overwrite": bool(payload.get('overwrite', False)),
"paths": payload.get('paths') if payload.get('paths') else None "paths": payload.get('paths') if payload.get('paths') else None
} }
} input_gen = {k: v for k, v in input_gen.items() if v is not None}
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15) mutation = build_stash_mutation("metadataGenerate", input_obj=input_gen)
res = execute_stash_graphql(mutation, stash_url, timeout=15)
self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataGenerate')}) self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataGenerate')})
except Exception as e: except Exception as e:
self.send_json_response(500, {"error": str(e)}) self.send_json_response(500, {"error": str(e)})
@@ -2143,20 +2132,12 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
stop_all = payload.get('stop_all', False) stop_all = payload.get('stop_all', False)
if stop_all or not job_id: if stop_all or not job_id:
mutation = ''' mutation = "mutation { stopAllJobs }"
mutation StopAllJobs {
stopAllJobs
}
'''
res = execute_stash_graphql(mutation, stash_url, timeout=10) res = execute_stash_graphql(mutation, stash_url, timeout=10)
self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('stopAllJobs')}) self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('stopAllJobs')})
else: else:
mutation = ''' mutation = f'mutation {{ stopJob(job_id: "{job_id}") }}'
mutation StopJob($job_id: ID!) { res = execute_stash_graphql(mutation, stash_url, timeout=10)
stopJob(job_id: $job_id)
}
'''
res = execute_stash_graphql(mutation, stash_url, variables={"job_id": str(job_id)}, timeout=10)
self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('stopJob')}) self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('stopJob')})
except Exception as e: except Exception as e:
self.send_json_response(500, {"error": str(e)}) self.send_json_response(500, {"error": str(e)})
@@ -2300,12 +2281,8 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.send_json_response(400, {"error": "Missing plugin_id or task_name"}) self.send_json_response(400, {"error": "Missing plugin_id or task_name"})
return return
mutation = ''' mutation = f'mutation {{ runPluginTask(plugin_id: "{plugin_id}", task_name: "{task_name}") }}'
mutation RunPluginTask($plugin_id: ID!, $task_name: String!) { res = execute_stash_graphql(mutation, stash_url, timeout=15)
runPluginTask(plugin_id: $plugin_id, task_name: $task_name)
}
'''
res = execute_stash_graphql(mutation, stash_url, variables={"plugin_id": plugin_id, "task_name": task_name}, timeout=15)
self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('runPluginTask')}) self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('runPluginTask')})
except Exception as e: except Exception as e:
self.send_json_response(500, {"error": str(e)}) self.send_json_response(500, {"error": str(e)})
@@ -2324,19 +2301,13 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.send_json_response(400, {"error": "Missing scene_id"}) self.send_json_response(400, {"error": "Missing scene_id"})
return return
mutation = ''' input_obj = {
mutation SceneDestroy($input: SceneDestroyInput!) {
sceneDestroy(input: $input)
}
'''
variables = {
"input": {
"id": str(scene_id), "id": str(scene_id),
"delete_file": delete_file, "delete_file": delete_file,
"delete_generated": delete_generated "delete_generated": delete_generated
} }
} mutation = build_stash_mutation("sceneDestroy", input_obj=input_obj)
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15) res = execute_stash_graphql(mutation, stash_url, timeout=15)
self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('sceneDestroy')}) self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('sceneDestroy')})
except Exception as e: except Exception as e:
self.send_json_response(500, {"error": str(e)}) self.send_json_response(500, {"error": str(e)})
@@ -2424,22 +2395,12 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.send_json_response(400, {"error": "Missing source_ids or destination_id"}) self.send_json_response(400, {"error": "Missing source_ids or destination_id"})
return return
mutation = ''' input_obj = {
mutation PerformerMerge($input: PerformerMergeInput!) {
performerMerge(input: $input) {
id
name
scene_count
}
}
'''
variables = {
"input": {
"source": source_ids, "source": source_ids,
"destination": dest_id "destination": dest_id
} }
} mutation = build_stash_mutation("performerMerge", input_obj=input_obj, fields="id name scene_count")
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=20) res = execute_stash_graphql(mutation, stash_url, timeout=20)
self.send_json_response(200, {"success": True, "merged_performer": res.get('data', {}).get('performerMerge')}) self.send_json_response(200, {"success": True, "merged_performer": res.get('data', {}).get('performerMerge')})
except Exception as e: except Exception as e:
self.send_json_response(500, {"error": str(e)}) self.send_json_response(500, {"error": str(e)})
@@ -2458,21 +2419,14 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
merged_count = 0 merged_count = 0
errors = [] errors = []
mutation = '''
mutation PerformerMerge($input: PerformerMergeInput!) {
performerMerge(input: $input) {
id
name
}
}
'''
for c in clusters: for c in clusters:
source_ids = [str(sid) for sid in c.get('source_ids', [])] source_ids = [str(sid) for sid in c.get('source_ids', [])]
dest_id = str(c.get('destination_id')) dest_id = str(c.get('destination_id'))
if not source_ids or not dest_id: if not source_ids or not dest_id:
continue continue
try: try:
res = execute_stash_graphql(mutation, stash_url, variables={"input": {"source": source_ids, "destination": dest_id}}, timeout=20) mutation = build_stash_mutation("performerMerge", input_obj={"source": source_ids, "destination": dest_id}, fields="id name")
res = execute_stash_graphql(mutation, stash_url, timeout=20)
if res.get('data', {}).get('performerMerge'): if res.get('data', {}).get('performerMerge'):
merged_count += 1 merged_count += 1
elif res.get('errors'): elif res.get('errors'):
@@ -2561,24 +2515,16 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
payload = json.loads(post_data.decode('utf-8')) payload = json.loads(post_data.decode('utf-8'))
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
paths = payload.get('paths', []) paths = payload.get('paths', [])
tag_performers = bool(payload.get('performers', True))
tag_studios = bool(payload.get('studios', True))
tag_tags = bool(payload.get('tags', True))
mutation = ''' input_obj = {
mutation AutoTagScenes($input: AutoTagMetadataInput!) { "paths": paths if paths else None,
metadataAutoTag(input: $input) "performers": ["*"] if bool(payload.get('performers', True)) else None,
"studios": ["*"] if bool(payload.get('studios', True)) else None,
"tags": ["*"] if bool(payload.get('tags', True)) else None
} }
''' input_obj = {k: v for k, v in input_obj.items() if v is not None}
variables = { mutation = build_stash_mutation("metadataAutoTag", input_obj=input_obj)
"input": { res = execute_stash_graphql(mutation, stash_url, timeout=20)
"paths": paths if paths else [],
"performers": tag_performers,
"studios": tag_studios,
"tags": tag_tags
}
}
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=20)
self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataAutoTag')}) self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('metadataAutoTag')})
except Exception as e: except Exception as e:
self.send_json_response(500, {"error": str(e)}) self.send_json_response(500, {"error": str(e)})
@@ -2652,22 +2598,12 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.send_json_response(400, {"error": "Missing source_ids or destination_id"}) self.send_json_response(400, {"error": "Missing source_ids or destination_id"})
return return
mutation = ''' input_obj = {
mutation TagsMerge($input: TagsMergeInput!) {
tagsMerge(input: $input) {
id
name
scene_count
}
}
'''
variables = {
"input": {
"source": source_ids, "source": source_ids,
"destination": dest_id "destination": dest_id
} }
} mutation = build_stash_mutation("tagsMerge", input_obj=input_obj, fields="id name scene_count")
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=20) res = execute_stash_graphql(mutation, stash_url, timeout=20)
self.send_json_response(200, {"success": True, "merged_tag": res.get('data', {}).get('tagsMerge')}) self.send_json_response(200, {"success": True, "merged_tag": res.get('data', {}).get('tagsMerge')})
except Exception as e: except Exception as e:
self.send_json_response(500, {"error": str(e)}) self.send_json_response(500, {"error": str(e)})
@@ -2686,21 +2622,14 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
merged_count = 0 merged_count = 0
errors = [] errors = []
mutation = '''
mutation TagsMerge($input: TagsMergeInput!) {
tagsMerge(input: $input) {
id
name
}
}
'''
for c in clusters: for c in clusters:
source_ids = [str(sid) for sid in c.get('source_ids', [])] source_ids = [str(sid) for sid in c.get('source_ids', [])]
dest_id = str(c.get('destination_id')) dest_id = str(c.get('destination_id'))
if not source_ids or not dest_id: if not source_ids or not dest_id:
continue continue
try: try:
res = execute_stash_graphql(mutation, stash_url, variables={"input": {"source": source_ids, "destination": dest_id}}, timeout=20) mutation = build_stash_mutation("tagsMerge", input_obj={"source": source_ids, "destination": dest_id}, fields="id name")
res = execute_stash_graphql(mutation, stash_url, timeout=20)
if res.get('data', {}).get('tagsMerge'): if res.get('data', {}).get('tagsMerge'):
merged_count += 1 merged_count += 1
elif res.get('errors'): elif res.get('errors'):
@@ -2907,12 +2836,8 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
try: try:
parent_dir = os.path.dirname(orig_local_path) parent_dir = os.path.dirname(orig_local_path)
stash_parent = parent_dir.replace('/mnt/isolation/videos', '/data') stash_parent = parent_dir.replace('/mnt/isolation/videos', '/data')
mutation = ''' mutation = build_stash_mutation("metadataScan", input_obj={"paths": [stash_parent], "rescan": True})
mutation MetadataScan($input: ScanMetadataInput!) { execute_stash_graphql(mutation, stash_url, timeout=10)
metadataScan(input: $input)
}
'''
execute_stash_graphql(mutation, stash_url, variables={"input": {"paths": [stash_parent], "rescan": True}}, timeout=10)
except Exception as e: except Exception as e:
print(f"Background Stash rescan trigger error: {e}") print(f"Background Stash rescan trigger error: {e}")
@@ -2991,12 +2916,8 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
if trigger_rescan and rescanned_dirs: if trigger_rescan and rescanned_dirs:
try: try:
stash_dirs = [d.replace('/mnt/isolation/videos', '/data') for d in rescanned_dirs] stash_dirs = [d.replace('/mnt/isolation/videos', '/data') for d in rescanned_dirs]
mutation = ''' mutation = build_stash_mutation("metadataScan", input_obj={"paths": stash_dirs, "rescan": True})
mutation MetadataScan($input: ScanMetadataInput!) { execute_stash_graphql(mutation, stash_url, timeout=15)
metadataScan(input: $input)
}
'''
execute_stash_graphql(mutation, stash_url, variables={"input": {"paths": stash_dirs, "rescan": True}}, timeout=15)
except Exception as e: except Exception as e:
print(f"Batch Stash rescan trigger error: {e}") print(f"Batch Stash rescan trigger error: {e}")